Eventos e webhooks
Ao menos uma vez, identificado de forma determinística, assinado.
Toda mudança de estado que vale reagir é publicada como um evento versionado, e um webhook entrega o envelope do evento a você byte a byte, como ele foi assinado. As três coisas para acertar são deduplicação, verificação da assinatura contra os bytes crus, e não esperar uma ordenação que não foi prometida.
O envelope
Uma forma para todo tipo de evento. O payload mora em data; todo o resto é endereçamento.
{
"event_id": "evt_9f2c8a41b7d64e02a3c5f0e81d97b6aa",
"event_type": "obligation.settled",
"event_version": 1,
"occurred_at": "2026-07-15T12:00:00Z",
"published_at": "2026-07-15T12:00:01Z",
"tenant": "sandbox",
"entity": { "entity_id": "obl_a1b2…", "version": 3 },
"correlation": {
"obligation_id": "obl_a1b2…",
"sqd_id": null,
"swp_id": null,
"tx_id": "abc123_0"
},
"data": { "…": "específico do tipo" }
}| Campo | Observações |
|---|---|
| event_id | Casa com ^evt_[0-9a-f]{32}$. Determinístico, então uma reentrega carrega o mesmo id. Esta é a sua chave de deduplicação. |
| event_version | Sempre 1 hoje. Mudanças dentro da versão 1 são apenas aditivas, então ignore campos e tipos que você não reconhece em vez de rejeitá-los. |
| occurred_at | Quando a mudança aconteceu. É este que você usa para ordenar e raciocinar. |
| published_at | Quando emitimos. Não faz parte da identidade do evento; muda em uma reentrega. |
| correlation | As quatro chaves estão sempre presentes, nulas onde não se aplicam, para você poder cruzar sem checar antes se a chave existe. |
| data | Específico do tipo, e nunca uma imagem crua de registro. Nenhum campo aqui carrega dado pessoal. |
Semântica de entrega
- Ao menos uma vez. Exatamente uma vez não é objetivo. Deduplique por
event_id. - Ordenado por entidade, não globalmente. Dois eventos sobre uma obrigação chegam em ordem. Dois eventos sobre obrigações diferentes não carregam promessa nenhuma entre si.
- Derivado e descartável. Eventos são uma projeção do ledger, não o ledger. Uma indisponibilidade longa o bastante para esgotar a janela de retenção perde eventos não publicados de forma permanente, e isso é aceito e não mitigado. Concilie pela API, nunca por um arquivo de eventos.
- Eventos não podem falhar uma escrita. Eles pendem do lado de leitura. Uma falha de publicação nunca rejeita nem atrasa uma transação do ledger.
Eventos também são navegáveis pela API em uma janela recente e limitada, que é a ferramenta certa para se atualizar depois de uma queda e a ferramenta errada para uma trilha de auditoria.
GET /v1/events?type=obligation.settled&entity_id=obl_a1b2…&limit=50Verificando um webhook
Cada entrega carrega um HMAC com data e hora. Três detalhes causam quase toda integração que falha, então estão escritos sem rodeio:
- Assine os bytes crus. Nunca faça parse e reserialize o corpo antes de verificar. Qualquer reformatação muda o digest.
- A chave inclui o prefixo
whsec_. Use a string inteira do secret como ela veio, em bytes UTF-8. Tirar o prefixo é o erro mais comum de todos. - A mensagem assinada é
{timestamp}.{raw_body}: o timestamp do cabeçalho, um ponto literal, e então o corpo.
| Cabeçalho | Carrega |
|---|---|
| X-Quadra-Signature | t=<segundos unix>,v1=<digest hex>. Esquemas futuros adicionam componentes em vez de substituir v1, então faça parse dos pares separados por vírgula e leia o que você suporta. |
| X-Quadra-Event-Id | O mesmo valor de event_id no corpo. |
| X-Quadra-Event-Type | Para rotear sem fazer parse do corpo. |
| X-Quadra-Webhook-Id | Para qual dos seus endpoints isto foi. |
| X-Quadra-Delivery-Id | Esta tentativa. Muda em uma repetição, diferente do id do evento. |
| User-Agent | quadra-webhooks/1 |
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance_s: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
if abs(time.time() - t) > tolerance_s:
return False # janela de repetição: rejeita qualquer coisa com mais de cinco minutos
expected = hmac.new(secret.encode(), b"%d." % t + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Vetores de teste
A sua implementação tem que reproduzir estes exatamente. Os mesmos vetores são conferidos contra o nosso próprio assinador na nossa suíte de testes, então a documentação e o código não podem se afastar um do outro. Se o segundo falhar e os outros passarem, o seu problema é codificação de caracteres.
secret: whsec_cXVhZHJhLXdlYmhvb2tzLXRlc3QtdmVjdG9yLTAwMDE
t: 1784116800
body: {"correlation":{"obligation_id":"obl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","sqd_id":null,"swp_id":null,"tx_id":"abc123_0"},"data":{"amount":100,"creditor_account_id":"acct_creditor001","currency":"QBRL","debtor_account_id":"acct_debtor001","obligation_id":"obl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","rail_family":"QUADRA","resolution_outcome":"SETTLED","resolved_at":"2026-07-15T12:00:00Z","state":"FINAL","tx_id":"abc123_0"},"entity":{"entity_id":"obl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","pk":"abc123#0","sk":"OBLIGATION","table":"quadra-utxos","version":3},"event_id":"evt_9f2c8a41b7d64e02a3c5f0e81d97b6aa","event_type":"obligation.settled","event_version":1,"occurred_at":"2026-07-15T12:00:00Z","published_at":"2026-07-15T12:00:01Z","tenant":"sandbox"}
expected: t=1784116800,v1=09b08259917f36a14dbba2c3450d9cd34cc6985ff14de579221502d3f6a4c7f6secret: whsec_cXVhZHJhLXdlYmhvb2tzLXRlc3QtdmVjdG9yLTAwMDE
t: 1784116801
body: {"memo":"café ☕"}
expected: t=1784116801,v1=60532e477af0e7b8a768d28b5d197854003ee69c27cd7086788326b5df26c73dsecret: whsec_cXVhZHJhLXdlYmhvb2tzLXRlc3QtdmVjdG9yLTAwMDE
t: 1784116802
body: {}
expected: t=1784116802,v1=747a7a33ce53cce185ddc76e615260a22ebb98fcff1cf0f80dfc672c0a1530efsecret: whsec_cXVhZHJhLXdlYmhvb2tzLXRlc3QtdmVjdG9yLTAwMDI
t: 1784116803
body: {}
expected: t=1784116803,v1=a56ca4fb427e536918111704086135febfabc2ac54e23815ffc7d1ff03cc0e43Secrets
Um secret é whsec_ seguido de 43 caracteres base64url. Ele é devolvido em texto claro exatamente uma vez, na resposta que cria o endpoint, e mascarado até os quatro últimos caracteres em todo lugar depois disso. Não existe endpoint de rotação: rotacionar é apagar o endpoint e criar um novo, o que força o secret novo a ser tratado de propósito em vez de ser recolhido em silêncio.
Repetições e suspensão
Uma entrega tem sucesso em qualquer 2xx dentro de dez segundos. Um 3xx é falha, não um redirecionamento a seguir. Nunca seguimos um, porque um redirecionamento em uma entrega assinada é um pedido para mandar o seu payload para outro lugar.
| Tentativa | Espera | |
|---|---|---|
| 1 | imediata | A primeira tentativa. |
| 2 | 1 minuto | Cobre um restart ou uma oscilação curta. |
| 3 | 5 minutos | Cobre um deploy. |
| 4 – 8 | 15 minutos cada | Depois disso a entrega é arquivada como falha. |
Vinte entregas arquivadas em sequência suspendem o endpoint automaticamente. Enquanto ele está suspenso nada é enviado, e reativá-lo não repõe o que foi perdido. Atualize-se pelo endpoint de navegação de eventos.
Testando um endpoint
Uma entrega de teste ignora os seus filtros, carrega test: true, e nunca é persistida, então procurá-la depois na navegação de eventos corretamente devolve 404. Isso é deliberado: um evento de teste não faz parte do histórico do seu ledger.