> ## Documentation Index
> Fetch the complete documentation index at: https://docs.weve.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks de saída

> Os eventos que o club manda para o seu sistema: aluno, acesso, compra, assinatura e progresso.

O club cadastra um **endpoint** — uma URL do seu sistema — e escolhe os eventos que quer
receber. Cada vez que um deles acontece, a API faz um `POST` assinado para essa URL e insiste
até receber um `2xx`. É a volta do [webhook de entrada](/essentials/generic-webhook): lá a
plataforma de venda avisa o club; aqui o club avisa você.

O formato segue o [Standard Webhooks](https://www.standardwebhooks.com), então as bibliotecas
oficiais do padrão conferem a assinatura sem código nosso.

## A requisição

```http theme={null}
POST https://seu-sistema.example.com/weve/webhooks
Content-Type: application/json
User-Agent: weve-webhooks/1
webhook-id: e1a3c5b7-2d4f-4a6b-8c0e-9f1a3b5c7d9e
webhook-timestamp: 1789390962
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"e1a3c5b7-2d4f-4a6b-8c0e-9f1a3b5c7d9e","type":"access.granted","occurred_at":"2026-09-14T13:02:41Z","club_id":"7a1e9c4b-5d3f-4e2a-8b6c-1f0d9e8a7b65","data":{…}}
```

| cabeçalho           | o que é                                                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `webhook-id`        | O id do **evento**. É o mesmo em toda tentativa e em todos os endpoints do club que recebem aquele evento — deduplique por ele |
| `webhook-timestamp` | Segundos Unix do momento da **tentativa**, não do fato                                                                         |
| `webhook-signature` | `v1,<base64>`. Durante a troca de segredo vêm **duas** assinaturas, separadas por espaço                                       |

## O envelope

Todo evento tem a mesma casca:

```json theme={null}
{
  "id": "e1a3c5b7-2d4f-4a6b-8c0e-9f1a3b5c7d9e",
  "type": "access.granted",
  "occurred_at": "2026-09-14T13:02:41Z",
  "club_id": "7a1e9c4b-5d3f-4e2a-8b6c-1f0d9e8a7b65",
  "data": {}
}
```

| campo         | o que é                                                                      |
| ------------- | ---------------------------------------------------------------------------- |
| `id`          | O id do evento, igual ao cabeçalho `webhook-id`                              |
| `type`        | Um dos tipos do catálogo abaixo                                              |
| `occurred_at` | Quando o fato aconteceu, em UTC                                              |
| `club_id`     | O club de onde o evento veio — útil quando um sistema recebe de vários clubs |
| `data`        | O corpo do tipo, descrito na página de cada família                          |

## Catálogo

| `type`                  | o que aconteceu                                                                                                                     | corpo                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `student.created`       | A conta de aluno nasceu no club — cadastro, compra, convite, importação ou link de cadastro. Pode ainda não ter confirmado o e-mail | [Aluno](/api-reference/webhook-events/student)                    |
| `student.updated`       | Quem administra corrigiu o nome ou o e-mail do aluno                                                                                | [Aluno](/api-reference/webhook-events/student)                    |
| `student.removed`       | O aluno foi removido do club e os dados dele, anonimizados. Só o id sai                                                             | [Aluno removido](/api-reference/webhook-events/student-removed)   |
| `access.granted`        | Ganhou acesso a uma entrega (compra, assinatura, concessão, convite, importação, link de cadastro ou entrega gratuita)              | [Acesso](/api-reference/webhook-events/access)                    |
| `access.revoked`        | Perdeu o acesso (reembolso, cancelamento sem carência, revogação, remoção)                                                          | [Acesso](/api-reference/webhook-events/access)                    |
| `access.extended`       | A data de fim do acesso ficou mais longa                                                                                            | [Acesso](/api-reference/webhook-events/access)                    |
| `access.shortened`      | A data de fim do acesso ficou mais curta                                                                                            | [Acesso](/api-reference/webhook-events/access)                    |
| `access.restored`       | Uma revogação foi desfeita                                                                                                          | [Acesso](/api-reference/webhook-events/access)                    |
| `access.moved`          | A matrícula mudou de turma                                                                                                          | [Acesso](/api-reference/webhook-events/access)                    |
| `purchase.paid`         | Uma compra de produto mapeado foi aprovada — uma vez por compra, mesmo que a plataforma reenvie                                     | [Compra](/api-reference/webhook-events/purchase)                  |
| `purchase.refunded`     | Reembolso ou contestação (`purchase.status` diz qual)                                                                               | [Compra](/api-reference/webhook-events/purchase)                  |
| `subscription.past_due` | A cobrança da assinatura atrasou — uma vez por atraso, não a cada tentativa da plataforma                                           | [Assinatura](/api-reference/webhook-events/subscription)          |
| `subscription.canceled` | A assinatura foi cancelada; com `current_period_end` no futuro, o acesso segue até lá                                               | [Assinatura](/api-reference/webhook-events/subscription)          |
| `lesson.completed`      | A primeira conclusão de uma aula por um aluno. Desfazer e concluir de novo não repete                                               | [Aula concluída](/api-reference/webhook-events/lesson-completed)  |
| `course.completed`      | O aluno concluiu todas as aulas publicadas do curso. Uma vez por aluno e curso                                                      | [Curso concluído](/api-reference/webhook-events/course-completed) |
| `webhook.test`          | A mensagem do botão de teste. Não vem de fato nenhum                                                                                | [Teste](/api-reference/webhook-events/test)                       |

`event_types` é uma **lista explícita**, sem coringa: um endpoint recebe só os tipos que
escolheu, e um tipo novo que entrar no catálogo nunca começa a sair para quem não o pediu.

<Note>
  O documento do aluno (CPF) não sai em evento nenhum.
</Note>

## Verificar a assinatura

A assinatura é um HMAC-SHA256 sobre `{webhook-id}.{webhook-timestamp}.{corpo}`. A chave é o
segredo **sem o prefixo `whsec_`, decodificado de base64**; o resultado vai em base64 depois de
`v1,`.

Três cuidados que valem para qualquer linguagem:

* **Assine o corpo cru**, os bytes exatos que chegaram, antes de qualquer parse. Um JSON lido e
  serializado de novo muda espaço, ordem ou escape de acento, e a assinatura deixa de conferir.
* **Aceite qualquer uma das assinaturas** do cabeçalho: na troca de segredo vêm duas.
* **Recuse o que chega velho**: timestamp com mais de 5 minutos de diferença do seu relógio.
  Como o timestamp entra na assinatura, uma requisição capturada não pode ser repetida depois.

<CodeGroup>
  ```ts Node/TypeScript theme={null}
  import express from "express";
  import { Webhook } from "standardwebhooks";

  const wh = new Webhook(process.env.WEVE_WEBHOOK_SECRET!); // "whsec_…"
  const app = express();

  // express.raw: o corpo chega como Buffer, sem parse.
  app.post("/weve/webhooks", express.raw({ type: "application/json" }), (req, res) => {
    let event;
    try {
      // Confere assinatura e timestamp (tolerância de 5 minutos) e devolve o JSON.
      event = wh.verify(req.body, {
        "webhook-id": req.header("webhook-id")!,
        "webhook-timestamp": req.header("webhook-timestamp")!,
        "webhook-signature": req.header("webhook-signature")!,
      });
    } catch {
      return res.status(401).end();
    }

    enqueue(event); // processe depois
    res.status(204).end();
  });
  ```

  ```python Python theme={null}
  import os

  from flask import Flask, request
  from standardwebhooks.webhooks import Webhook, WebhookVerificationError

  wh = Webhook(os.environ["WEVE_WEBHOOK_SECRET"])  # "whsec_…"
  app = Flask(__name__)

  @app.post("/weve/webhooks")
  def weve_webhooks():
      try:
          # request.get_data(): o corpo cru, em bytes.
          event = wh.verify(request.get_data(), request.headers)
      except WebhookVerificationError:
          return "", 401

      enqueue(event)  # processe depois
      return "", 204
  ```

  ```php PHP theme={null}
  <?php
  $secret = getenv('WEVE_WEBHOOK_SECRET'); // "whsec_…"
  $body = file_get_contents('php://input'); // o corpo cru

  $id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
  $timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
  $signatures = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

  if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
      http_response_code(401);
      exit;
  }

  $key = base64_decode(substr($secret, strlen('whsec_')));
  $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));

  $valid = false;
  foreach (preg_split('/\s+/', trim($signatures)) as $candidate) {
      [$version, $signature] = array_pad(explode(',', $candidate, 2), 2, '');
      if ($version === 'v1' && hash_equals($expected, $signature)) {
          $valid = true;
          break;
      }
  }

  if (!$valid) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($body, true);
  enqueue($event); // processe depois
  http_response_code(204);
  ```

  ```go Go theme={null}
  package weve

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"io"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"time"
  )

  func WebhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, err := io.ReadAll(r.Body) // o corpo cru
  	if err != nil {
  		http.Error(w, "", http.StatusBadRequest)
  		return
  	}

  	id := r.Header.Get("webhook-id")
  	timestamp := r.Header.Get("webhook-timestamp")

  	unix, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil || id == "" || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute {
  		http.Error(w, "", http.StatusUnauthorized)
  		return
  	}

  	secret := strings.TrimPrefix(os.Getenv("WEVE_WEBHOOK_SECRET"), "whsec_")
  	key, err := base64.StdEncoding.DecodeString(secret)
  	if err != nil {
  		http.Error(w, "", http.StatusInternalServerError)
  		return
  	}

  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(id + "." + timestamp + "."))
  	mac.Write(body)
  	expected := mac.Sum(nil)

  	valid := false
  	for _, candidate := range strings.Fields(r.Header.Get("webhook-signature")) {
  		version, value, ok := strings.Cut(candidate, ",")
  		if !ok || version != "v1" {
  			continue
  		}
  		got, err := base64.StdEncoding.DecodeString(value)
  		// hmac.Equal compara em tempo constante.
  		if err == nil && hmac.Equal(got, expected) {
  			valid = true
  			break
  		}
  	}

  	if !valid {
  		http.Error(w, "", http.StatusUnauthorized)
  		return
  	}

  	enqueue(body) // processe depois
  	w.WriteHeader(http.StatusNoContent)
  }
  ```
</CodeGroup>

<Warning>
  Compare a assinatura em **tempo constante** (`hash_equals`, `hmac.Equal`,
  `hmac.compare_digest`). Uma comparação comum de string para no primeiro byte diferente, e o
  tempo da resposta vaza quanto da assinatura estava certo.
</Warning>

### O segredo

O segredo (`whsec_…`) aparece **uma única vez**: na resposta de
[Cadastrar endpoint](/api-reference/webhook-endpoints/create) e na de
[Trocar segredo](/api-reference/webhook-endpoints/rotate-secret). Perdeu, troque.

Na troca, o segredo anterior **continua assinando por 24 horas**: nesse intervalo cada entrega
leva as duas assinaturas, e o seu lado aceita a que conferir. Atualize o segredo do seu lado
dentro desse prazo e nenhuma entrega fica sem conferir no meio do caminho.

## Responder

**Responda `2xx` rápido e processe depois.** Cada tentativa tem **15 segundos** de prazo;
grave o evento numa fila sua e responda, em vez de fazer o trabalho dentro da requisição.

* Qualquer resposta fora de `2xx` conta como **falha**, inclusive `4xx`: um `401` depois de
  trocar o segredo ou um `404` de um deploy pela metade se consertam do seu lado, e a entrega
  volta a tentar.
* Redirecionamento (`3xx`) **não é seguido**. Cadastre a URL final.
* `Retry-After` em `429` e `503` é respeitado, até 10 horas: ele alonga a espera até a próxima
  tentativa, nunca a encurta.

## Novas tentativas

Uma entrega tem **8 tentativas** no total. Depois de cada falha, a próxima espera:

| depois da tentativa | espera       |
| ------------------- | ------------ |
| 1ª                  | \~5 segundos |
| 2ª                  | \~5 minutos  |
| 3ª                  | \~30 minutos |
| 4ª                  | \~2 horas    |
| 5ª                  | \~5 horas    |
| 6ª                  | \~10 horas   |
| 7ª                  | \~10 horas   |

Cada espera varia ±10%, para mil entregas que falharam juntas não voltarem juntas. São cerca de
**27 horas** entre a primeira e a última tentativa; esgotadas as oito, a entrega fica `failed`.

[Reenviar entrega](/api-reference/webhook-endpoints/deliveries-retry) tenta agora, com o mesmo
corpo e o mesmo `webhook-id`, e o efeito depende do estado da entrega:

* **Fechada** (`succeeded`, `failed` ou `canceled`): ganha **uma** tentativa manual. Falhando,
  volta a `failed`, sem voltar para a agenda.
* **`pending`**: a próxima tentativa só é **antecipada**, e a entrega continua na agenda.

## Entrega pelo menos uma vez, e sem ordem

**A mesma mensagem pode chegar mais de uma vez** — a sua resposta se perdeu na rede, alguém
reenviou pelo painel. Deduplique pelo `webhook-id`: guarde os ids já processados e ignore o
repetido.

**Não há garantia de ordem.** Uma falha numa tentativa faz o evento seguinte chegar antes. Não
decida pelo tipo da última mensagem que chegou: use o `occurred_at` e o **estado do recurso** que
vem no `data`. O corpo é montado segundos depois do fato e traz o recurso como está naquele
momento — num `access.revoked`, `data.enrollment.status` já é `revoked`.

## O disjuntor

Um endpoint que só falha não deve receber rajadas:

* A partir de **5 falhas seguidas**, o endpoint passa a receber **uma tentativa por vez**, com
  intervalos que começam em 1 minuto e dobram até 1 hora (`hold_until`).
* **Qualquer `2xx` zera** o disjuntor.
* **Cinco dias sem nenhuma entrega aceita desligam o endpoint** (`status: disabled`,
  `disabled_reason: failing`). A fila dele é cancelada e quem administra o club recebe um e-mail.

O que uma pessoa pede **não espera o disjuntor**: [Enviar teste](/api-reference/webhook-endpoints/test),
[Reenviar entrega](/api-reference/webhook-endpoints/deliveries-retry) e
[Recuperar entregas](/api-reference/webhook-endpoints/recover) tentam na hora, e a tentativa
serve de **sonda**. Deu certo, o disjuntor zera; falhou, ele arma de novo.

Consertado o seu lado, religue o endpoint (`status: enabled`), o que zera o disjuntor, e chame
[Recuperar entregas](/api-reference/webhook-endpoints/recover): o que falhou, ou foi cancelado
porque o endpoint estava desligado, desde a data informada — até 30 dias atrás — volta para a
fila, com uma tentativa cada. O que foi cancelado pela remoção de um aluno ou por expirar não
volta: o corpo dele não existe mais.

## Estados do endpoint

| `status`   | o que acontece                                                                                                                                                         |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`  | Entrega                                                                                                                                                                |
| `paused`   | **Retém**: as entregas continuam sendo criadas, se acumulam e saem quando o endpoint volta a `enabled` — desde que isso aconteça em até 30 dias (ver Retenção, abaixo) |
| `disabled` | **Encerra**: nada novo é criado e o que estava na fila é cancelado                                                                                                     |

Pausar é para uma manutenção do seu lado, em que nada pode se perder. Desligar é para parar de
receber.

## O endereço

* Precisa ser `https` e resolver para a **internet pública**. Rede privada, `localhost` e IPs
  reservados são recusados no cadastro — e de novo **a cada conexão**, contra o IP que o DNS
  devolveu naquela hora.
* **Não há IP fixo de saída**: não dá para liberar a entrega por lista de IPs. A assinatura é o
  que prova que a mensagem veio do club.
* Até **20 endpoints por club**.
* Cadastrar e gerir endpoints exige permissão de **administração do club**: o histórico de
  entregas leva nome e e-mail de alunos.

## Testar

[Enviar teste](/api-reference/webhook-endpoints/test) cria uma entrega `webhook.test` que passa
pelo **mesmo caminho** dos eventos reais: assinatura, conferência do endereço, novas tentativas.
O teste que passa prova o que vai acontecer com os eventos de verdade. Só funciona com o endpoint
`enabled`, e **não espera o disjuntor**: é o jeito de conferir, na hora, que o seu lado voltou.

## Retenção

| o quê                                             | por quanto tempo         |
| ------------------------------------------------- | ------------------------ |
| O corpo de cada mensagem                          | 30 dias depois de criada |
| As tentativas, com até 2 KB do começo da resposta | 30 dias                  |
| O histórico de entregas                           | 90 dias                  |

Depois que o corpo é apagado, a entrega continua no histórico com `payload_purged`, e não há mais
o que reenviar.

O prazo do corpo não tem exceção. Uma entrega que fica **pendente mais de 30 dias** — o caso é o
endpoint pausado e esquecido — é **cancelada** com `cancel_reason: expired`, para o corpo sair no
prazo como todos os outros.

## Remoção de aluno

Quando um aluno é removido do club (LGPD):

* sai `student.removed`, **só com o id** — o sinal para apagar a pessoa do seu sistema também;
* os corpos das mensagens anteriores que falavam dela são **apagados**;
* o que ainda não tinha saído é **cancelado** (`cancel_reason: student_removed`);
* eventos daquele aluno gravados depois da remoção **não saem**.


## Related topics

- [student.removed](/api-reference/webhook-events/student-removed.md)
- [access.*](/api-reference/webhook-events/access.md)
- [Enviar teste](/api-reference/webhook-endpoints/test.md)
- [Reenviar entrega](/api-reference/webhook-endpoints/deliveries-retry.md)
- [Listar entregas](/api-reference/webhook-endpoints/deliveries-list.md)
