Receber webhooks
Um webhook avisa o seu sistema sobre uma alteração no Sursa assim que ela acontece: um cartão foi publicado,
verificado ou arquivado, uma pergunta ficou sem resposta. O Sursa envia um POST HTTPS com o evento para um endereço
que você registra. Este guia monta um receptor que funciona corretamente diante de novas tentativas, mudanças de ordem
e duplicatas.
Para saber o que cada evento significa e contém, veja a visão geral dos webhooks e o catálogo de eventos na referência da API.
1. Registrar um webhook
Um administrador registra webhooks no Sursa em Administration → Integrations → Webhooks and API (administração,
integrações, webhooks) ou com POST /v1/webhooks e uma chave com a função
admin e o escopo webhooks:manage:
curl -X POST https://api.sursa.com/v1/webhooks \
-H "Authorization: Bearer $SURSA_ADMIN_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/sursa",
"description": "Sync verified cards to the help center",
"events": ["card.published", "card.updated", "card.verified", "card.archived"]
}'A resposta traz o segredo de assinatura em secret, começando com whsec_. Ele é mostrado uma única vez.
Guarde-o junto com o seu receptor, por exemplo em uma variável de ambiente SURSA_WEBHOOK_SECRET.
O endereço precisa usar https e resolver para endereços IP públicos: os endereços de redes privadas, de loopback e
de outras redes reservadas são rejeitados no registro e em cada entrega. Para desenvolver no seu próprio computador,
exponha o seu receptor local por meio de um serviço de túnel que dê a ele um endereço HTTPS público.
Envie um evento de teste a qualquer momento com
POST /v1/webhooks/{webhook_id}/test. A resposta mostra o que o seu receptor
respondeu.
2. Como é uma entrega
POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042
{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
| Cabeçalho | Significado |
|---|---|
webhook-id | O ID do evento. É o mesmo em todas as novas tentativas. Use-o para descartar duplicatas. |
webhook-timestamp | Quando esta tentativa foi assinada, em segundos Unix. |
webhook-signature | A assinatura no formato Standard Webhooks. |
X-KB-Signature | A mesma entrega, assinada no formato anterior do Sursa. |
X-KB-Event | O tipo de evento. |
X-KB-Attempt | O número da tentativa, a partir de 1. |
X-KB-Sequence | A posição desta entrega na ordem do webhook. |
Os nomes dos cabeçalhos não diferenciam maiúsculas de minúsculas; o seu framework pode mostrá-los como
X-Kb-Signature ou x-kb-signature.
O corpo é o evento. O data dele depende do tipo. Um webhook configurado com o payload thin recebe o evento sem
data; nesse caso, leia o objeto pela API com a sua própria chave.
3. Verificar a assinatura
Verifique a assinatura de cada entrega antes de confiar nela. Os dois formatos assinam os bytes exatos do corpo, então leia o corpo bruto antes de qualquer parsing de JSON: serializar o JSON de novo muda os bytes e quebra a verificação.
| Formato | String assinada | Chave | Assinatura |
|---|---|---|---|
Standard Webhooks (webhook-signature) | <webhook-id>.<webhook-timestamp>.<body> | A parte do segredo depois de whsec_, decodificada de base64 | v1, + base64 do HMAC-SHA256 |
Formato anterior (X-KB-Signature) | <t>.<body> | A string inteira do segredo, como texto | v1= + hex do HMAC-SHA256 |
Verificar qualquer um dos dois basta. Prefira o cabeçalho Standard Webhooks: a mesma verificação está disponível em
muitas bibliotecas prontas. Durante uma rotação do segredo, cada cabeçalho traz uma assinatura por segredo válido
(separadas por espaço em webhook-signature, com v1= repetido em X-KB-Signature). Uma entrega é válida se
qualquer uma delas corresponder.
Rejeite também um timestamp que esteja a mais de 5 minutos do seu relógio. Isso impede que alguém reenvie mais tarde uma entrega gravada.
# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"
# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A
# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1Testar a verificação de assinaturas
Execute o seu código com estes valores antes de conectá-lo às entregas reais. Com o relógio ajustado para o timestamp, as duas verificações precisam passar, e precisam falhar quando um byte do corpo muda.
| Entrada | Valor |
|---|---|
| Segredo | whsec_test_secret_0123456789abcdef |
webhook-id | evt_0123456789abcdef0123456789abcdef |
webhook-timestamp e t | 1791194400 |
| Corpo | {"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}} |
webhook-signature | v1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg= |
X-KB-Signature | t=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc |
4. Responder rápido, trabalhar depois
O Sursa espera até 10 segundos pela sua resposta. Qualquer status 2xx significa que a entrega deu certo;
qualquer outra coisa, inclusive um redirecionamento, um tempo limite esgotado ou uma conexão fechada, significa que
ela falhou e terá novas tentativas.
Por isso, o receptor deve apenas verificar, registrar o evento e responder. Faça o trabalho de verdade depois, a partir da sua própria fila.
// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";
const SECRET = process.env.SURSA_WEBHOOK_SECRET;
http
.createServer(async (req, res) => {
const parts = [];
for await (const part of req) parts.push(part);
const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing
const valid = req.headers["webhook-signature"]
? verifyStandard(SECRET, req.headers, body)
: verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
if (!valid) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(body);
await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
res.writeHead(204).end();
})
.listen(3000);Responda 2xx só depois que o evento estiver guardado com segurança. Se você responder primeiro e o processo cair em
seguida, o evento se perde para você, porque o Sursa já o contou como entregue.
5. Novas tentativas
Uma entrega com falha é tentada de novo com pausas cada vez maiores: depois de cerca de 1 minuto, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas, 24 horas e 24 horas, nove tentativas ao longo de quase três dias. Cada pausa varia em até 20% para que as novas tentativas de muitos webhooks não cheguem no mesmo instante.
- Uma resposta
429ou503comRetry-Afteradia a próxima tentativa para pelo menos esse momento, até 24 horas depois. Use-a quando o seu sistema estiver sobrecarregado ou em manutenção. - Uma resposta
410 Gonedesativa o webhook: envie-a só quando não quiser mais entregas nesse endereço. - Depois de 10 tentativas seguidas com falha, o webhook é pausado, e os administradores da organização são
avisados. Os eventos continuam se acumulando e, quando o webhook é retomado, na interface de administração ou com
POST /v1/webhooks/{webhook_id}/resume, são entregues em ordem. - Depois da última tentativa, a entrega é marcada como
exhausted. Você pode enviá-la de novo manualmente.
As entregas de um webhook, com cada tentativa, o código da sua resposta e o tempo dela, são listadas por
GET /v1/webhooks/{webhook_id}/deliveries e na interface de
administração.
POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver envia
uma delas de novo. Um reenvio tem o mesmo webhook-id.
6. Ordem e duplicatas
- Ordem. As entregas a um webhook seguem estritamente uma depois da outra, na ordem de
sequence. Enquanto uma entrega recebe novas tentativas, as posteriores esperam. Webhooks diferentes não esperam uns pelos outros. - Duplicatas. O mesmo evento pode chegar mais de uma vez, por exemplo quando a sua resposta se perdeu no caminho
de volta. Guarde o
webhook-id(ele também é oidno corpo) de cada evento que você processa e ignore um que já tenha visto. - Pensar em estados. Para alterações no mesmo objeto, compare
created_atousequencecom o que você guardou e ignore um evento mais antigo que o estado que você já tem. Na dúvida, leia o objeto atual pela API: o evento diz que algo mudou; a API diz como ele está agora.
7. Reconciliar com o feed de eventos
Todo evento também fica guardado por 30 dias no feed de eventos, haja ou não um webhook inscrito nele. Se o seu
receptor ficou fora do ar por mais tempo do que as novas tentativas cobrem, ou se você quer conferir se nada se
perdeu, leia o feed com uma chave que tenha a função admin e o escopo webhooks:manage:
curl -G https://api.sursa.com/v1/webhook-events \
-H "Authorization: Bearer $SURSA_API_KEY" \
--data-urlencode "type=card.*" \
--data-urlencode "created_since=2026-10-20T00:00:00Z" \
--data-urlencode "limit=200"Um evento no feed tem o mesmo id e o mesmo formato que em uma entrega, sem os campos de entrega sequence,
payload e test. Como o seu receptor ignora os eventos que já viu, você pode executar a reconciliação com a
frequência que quiser, por exemplo uma vez por hora.
GET /v1/webhook-events/{event_id} lê um evento pelo ID dele.
8. Rotacionar o segredo
POST /v1/webhooks/{webhook_id}/rotate-secret emite um novo segredo e
mantém o anterior válido por um período de sobreposição, de 24 horas por padrão e de até 7 dias. Durante a
sobreposição, cada entrega é assinada com os dois segredos.
- Faça a rotação e guarde o novo segredo ao lado do antigo no seu receptor.
- Aceite uma entrega se ela for verificada com qualquer um dos dois segredos.
- Depois da sobreposição, remova o segredo antigo.
Se o segredo antigo vazou, encerre a sobreposição imediatamente com
POST /v1/webhooks/{webhook_id}/revoke-previous-secret ou faça
a rotação com "overlap_hours": 0.
Checklist
- Verifique uma das assinaturas sobre o corpo bruto e confira o timestamp.
- Guarde o evento, identificado pelo
webhook-id, e então responda2xxem até 10 segundos. - Ignore duplicatas e eventos desatualizados; leia o objeto atual pela API quando isso importar.
- Responda
429ou503comRetry-Afterquando precisar que o Sursa diminua o ritmo; nunca responda410por engano. - Ignore os tipos de evento e os campos que você não conhece.
- Reconcilie com o feed de eventos de forma periódica.