Webhooks
Webhook é como você descobre que alguém assinou sem perguntar de minuto em minuto. São nove
eventos, entregues por POST em JSON, assinados com HMAC-SHA256 e um segredo por
endpoint. Esta página tem o código de validação pronto em quatro linguagens, e ele não é
opcional: endpoint de webhook sem verificação de assinatura é um endpoint público que aceita
qualquer coisa que se pareça com um evento nosso.
>>> PENDENTE DE BACKEND <<<
Os nomes de evento, o esquema de assinatura e o nome do cabeçalho vêm do contrato canônico
docs/CONTRATO_API.openapi.yaml e não foram exercidos contra o servidor. O
calendário exato de retentativa não está fixado no contrato, apenas a janela total; o que
está nesta página como escalonamento é ilustrativo.
Os nove eventos
| Evento | Disparado quando | O que fazer |
|---|---|---|
document.created |
O documento é criado. Como toda criação nasce em rascunho, este evento nunca significa que algo saiu. | Registrar o id ao lado do seu identificador interno. |
document.sent |
Sai do rascunho e os convites partem. | Marcar como "em assinatura" na sua tela. É aqui que um envelope é consumido. |
document.viewed |
Um signatário abre o documento pela primeira vez. | Sinal de que a entrega funcionou. A ausência dele por 48 horas é o melhor gatilho para cobrar por outro canal. |
signer.authenticated |
Um signatário cumpre todos os fatores exigidos. | Guardar os fatores confirmados, que é o que sustenta a prova depois. |
document.signed |
Um signatário assina. Uma vez por signatário. | Atualizar o progresso. Não trate como conclusão: em documento com três pessoas ele chega três vezes. |
document.completed |
O último signatário assinou. | É o evento que importa. Aqui o PDF assinado e o certificado passam a existir e podem ser baixados. |
document.refused |
Um signatário recusa. | Ler o motivo e devolver ao seu fluxo de negócio. Recusa raramente é erro técnico; costuma ser cláusula. |
document.expired |
O prazo venceu sem conclusão. | Decidir entre criar um documento novo ou encerrar. O documento não volta sozinho. |
document.cancelled |
Quem enviou cancelou. | Fechar o caso do seu lado. O cancelamento pode ter vindo do painel, e não da sua integração. |
Se você só puder tratar um evento, trate document.completed. Se puder tratar
três, acrescente document.refused e document.expired: juntos,
os três fecham todos os desfechos possíveis de um documento e já eliminam a necessidade
de sondar a API.
Formato do envio
Um POST com Content-Type: application/json. O seu servidor tem
10 segundos para responder qualquer 2xx. Responda antes de
processar, enfileirando o trabalho: gerar PDF, mandar e-mail e escrever em três tabelas
dentro do handler é o caminho conhecido para estourar o tempo e receber a mesma
entrega várias vezes.
POST /webhooks/adigitalmax HTTP/1.1
Host: seu-produto.com.br
Content-Type: application/json
User-Agent: AdigitalMAX-Webhook/1.0
X-Adigitalmax-Signature: t=1786031051,v1=6f1c0f9d4a3e8b27c5d09fa71e4b3268d5a0c7e19b6f43812ad0e75c9f3b0246
É um cabeçalho só. O t é o instante da assinatura em segundos
Unix, e o v1 é o HMAC em hexadecimal. O prefixo de versão permite acrescentar um
esquema v2 no futuro sem quebrar você: aceite o par que reconhece e ignore os
demais.
{
"eventoId": "9c14f7a2-0b5e-4d83-91f6-27ad5e30c8b4",
"evento": "document.completed",
"ocorridoEm": "2026-08-18T16:04:11-03:00",
"dados": {
"documento": {
"id": "6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48",
"titulo": "Contrato de prestação de serviços",
"status": "ASSINADO",
"nivelAssinatura": "SEGURA",
"criadoEm": "2026-08-16T09:12:44-03:00",
"finalizadoEm": "2026-08-18T16:04:11-03:00",
"paginas": 7
},
"signatarios": [
{
"id": "d3b8e105-4f27-4a91-b6c0-8e12f45a9d37",
"nome": "Maria Oliveira",
"email": "maria@exemplo.com.br",
"funcao": "ASSINAR",
"status": "ASSINADO",
"assinadoEm": "2026-08-18T16:04:11-03:00",
"fatoresCumpridos": ["EMAIL", "SMS_OTP"]
}
],
"certificado": {
"codigo": "ADX-CERT-9F2K4M7Q1B",
"versao": 1
}
}
}
O envelope é sempre o mesmo: eventoId, evento,
ocorridoEm e dados. O que muda entre eventos é o conteúdo de
dados. Em signer.authenticated e document.signed, há
também dados.signatario apontando qual deles disparou o evento.
Não há identificador de organização no payload. Isso é princípio do contrato inteiro: o escopo vem da credencial, e não de um campo. Se você atende várias organizações, use um endpoint por organização, cada um com o seu segredo; é o segredo que identifica de quem é o evento, e ele é bem mais difícil de forjar que um campo de JSON.
Como validar a assinatura
A assinatura é o HMAC-SHA256 da concatenação do valor de t, um ponto, e o
corpo bruto exatamente como chegou, usando o segredo do webhook como chave.
v1 = HMAC_SHA256(segredo, t + "." + corpo_bruto)
# t valor lido do cabecalho X-Adigitalmax-Signature, em segundos
# corpo_bruto os bytes do corpo, sem reserializar, sem reordenar chave,
# sem normalizar espaco em branco
# saida hexadecimal minusculo, comparado em tempo constante
Confira o HMAC sobre o corpo bruto, antes de qualquer desserialização. Se o seu framework já converteu o JSON em objeto e você o converte de volta em texto para calcular o HMAC, a assinatura vai falhar de forma intermitente: a ordem das chaves, o espaçamento e o escape de caracteres não são preservados. Praticamente todo relato de "a assinatura não bate" tem esta causa. Nos exemplos abaixo, cada linguagem mostra como obter o corpo cru.
Duas verificações, e as duas precisam passar antes de você tocar no payload:
- A janela de tempo. Recuse carimbos com mais de cinco minutos. Sem isso, alguém que capturou um envio válido pode reenviá-lo amanhã.
- A assinatura. Compare em tempo constante. Comparação com
==vaza, pelo tempo de resposta, quantos caracteres iniciais estavam certos, e isso basta para forjar a assinatura byte a byte.
// Node 18 ou mais novo, com Express 4. Arquivo completo, sem recorte.
// npm install express
// ADM_WEBHOOK_SEGREDO=... node webhook.mjs
import crypto from "node:crypto";
import express from "express";
const SEGREDO = process.env.ADM_WEBHOOK_SEGREDO;
const TOLERANCIA_SEGUNDOS = 300;
const app = express();
// express.raw guarda os BYTES do corpo em req.body. Com express.json()
// voce recebe um objeto e perde o texto original, que e justamente o
// que precisa ser assinado. Esta linha e a parte mais importante.
app.use("/webhooks/adigitalmax", express.raw({ type: "application/json", limit: "1mb" }));
/** Le "t=1786031051,v1=6f1c..." e devolve as partes. */
function lerAssinatura(cabecalho) {
const partes = Object.fromEntries(
String(cabecalho || "")
.split(",")
.map((p) => p.trim().split("=", 2))
.filter((p) => p.length === 2)
);
return { t: partes.t, v1: partes.v1 };
}
function assinaturaConfere(corpoBruto, cabecalho) {
const { t, v1 } = lerAssinatura(cabecalho);
if (!t || !v1) return false;
// 1. Janela de tempo: fecha o ataque de repeticao.
const idade = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (Number.isNaN(idade) || idade > TOLERANCIA_SEGUNDOS) return false;
// 2. Recalcula sobre o corpo bruto.
const esperado = crypto
.createHmac("sha256", SEGREDO)
.update(`${t}.`)
.update(corpoBruto)
.digest("hex");
// 3. Comparacao em tempo constante. timingSafeEqual exige mesmo
// comprimento, por isso a checagem antes.
if (v1.length !== esperado.length) return false;
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado));
}
const eventosVistos = new Set(); // em produção, use Redis ou uma tabela.
app.post("/webhooks/adigitalmax", (req, res) => {
if (!assinaturaConfere(req.body, req.headers["x-adigitalmax-signature"])) {
// 401 e deliberado: nao devolva detalhe do que falhou.
return res.status(401).send("assinatura invalida");
}
const evento = JSON.parse(req.body.toString("utf8"));
// Idempotencia pelo eventoId, que se repete nas retentativas.
if (eventosVistos.has(evento.eventoId)) {
return res.status(200).send("ja processado");
}
eventosVistos.add(evento.eventoId);
// Responda AGORA e processe depois. O limite e de 10 segundos, e
// gerar PDF ou mandar e-mail aqui dentro estoura esse limite.
res.status(200).send("ok");
setImmediate(() => processar(evento).catch(console.error));
});
async function processar(evento) {
switch (evento.evento) {
case "document.completed":
console.log("Concluido:", evento.dados.documento.id);
// baixarPdfAssinado(evento.dados.documento.id)
break;
case "document.refused":
console.log("Recusado:", evento.dados.signatario?.motivoRecusa);
break;
case "document.expired":
console.log("Expirou:", evento.dados.documento.id);
break;
default:
// Evento novo que voce ainda nao trata. Registre e siga; nunca
// lance excecao, senao a plataforma passa a retentar a toa.
console.log("Evento nao tratado:", evento.evento);
}
}
app.listen(3000, () => console.log("Ouvindo na porta 3000"));<?php
// PHP 8.1 ou mais novo, sem framework. Arquivo completo.
// Aponte o seu webhook para este arquivo e defina ADM_WEBHOOK_SEGREDO.
declare(strict_types=1);
const TOLERANCIA_SEGUNDOS = 300;
/**
* php://input entrega os bytes exatos do corpo. Nunca use $_POST nem
* json_encode(json_decode(...)) para recalcular: a reserializacao muda
* a ordem das chaves e o escape, e a assinatura deixa de bater.
*/
$corpoBruto = file_get_contents('php://input');
$cabecalhos = array_change_key_case(getallheaders(), CASE_LOWER);
$segredo = (string) getenv('ADM_WEBHOOK_SEGREDO');
$cabecalho = $cabecalhos['x-adigitalmax-signature'] ?? '';
/** Le "t=1786031051,v1=6f1c..." e devolve as partes. */
function lerAssinatura(string $cabecalho): array
{
$partes = [];
foreach (explode(',', $cabecalho) as $item) {
$par = explode('=', trim($item), 2);
if (count($par) === 2) {
$partes[$par[0]] = $par[1];
}
}
return $partes;
}
function assinaturaConfere(string $corpoBruto, string $cabecalho, string $segredo): bool
{
$partes = lerAssinatura($cabecalho);
$t = $partes['t'] ?? '';
$v1 = $partes['v1'] ?? '';
if ($t === '' || $v1 === '') {
return false;
}
// 1. Janela de tempo: fecha o ataque de repeticao.
if (abs(time() - (int) $t) > TOLERANCIA_SEGUNDOS) {
return false;
}
// 2. Recalcula sobre o corpo bruto.
$esperado = hash_hmac('sha256', $t . '.' . $corpoBruto, $segredo);
// 3. hash_equals compara em tempo constante. Nunca use ===.
return hash_equals($esperado, $v1);
}
if (!assinaturaConfere($corpoBruto, $cabecalho, $segredo)) {
http_response_code(401);
echo 'assinatura invalida';
exit;
}
$evento = json_decode($corpoBruto, true, 512, JSON_THROW_ON_ERROR);
// Idempotencia pelo eventoId, que se repete nas retentativas. Aqui em
// arquivo, para o exemplo caber sozinho; em producao, uma tabela com
// indice unico, porque SELECT antes de INSERT nao resolve concorrencia.
$marca = sys_get_temp_dir() . '/adx-' . preg_replace('/[^a-zA-Z0-9-]/', '', (string) $evento['eventoId']);
if (file_exists($marca)) {
http_response_code(200);
echo 'ja processado';
exit;
}
touch($marca);
// Responda antes de processar. O limite e de 10 segundos.
http_response_code(200);
echo 'ok';
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
switch ($evento['evento']) {
case 'document.completed':
error_log('Concluido: ' . $evento['dados']['documento']['id']);
// enfileirarDownload($evento['dados']['documento']['id']);
break;
case 'document.refused':
error_log('Recusado: ' . ($evento['dados']['signatario']['motivoRecusa'] ?? ''));
break;
case 'document.expired':
error_log('Expirou: ' . $evento['dados']['documento']['id']);
break;
default:
// Evento novo. Registre e siga; nunca lance excecao aqui.
error_log('Evento nao tratado: ' . $evento['evento']);
}#!/usr/bin/env python3
"""Receptor de webhook do AdigitalMAX. Arquivo completo, com Flask.
pip install flask
ADM_WEBHOOK_SEGREDO=... python3 webhook.py
"""
import hashlib
import hmac
import json
import os
import threading
import time
from flask import Flask, request
SEGREDO = os.environ["ADM_WEBHOOK_SEGREDO"].encode("utf-8")
TOLERANCIA_SEGUNDOS = 300
app = Flask(__name__)
eventos_vistos = set() # em producao, use Redis ou uma tabela.
def ler_assinatura(cabecalho: str) -> dict:
"""Le "t=1786031051,v1=6f1c..." e devolve as partes."""
partes = {}
for item in (cabecalho or "").split(","):
par = item.strip().split("=", 1)
if len(par) == 2:
partes[par[0]] = par[1]
return partes
def assinatura_confere(corpo_bruto: bytes, cabecalho: str) -> bool:
partes = ler_assinatura(cabecalho)
t = partes.get("t")
v1 = partes.get("v1")
if not t or not v1:
return False
# 1. Janela de tempo: fecha o ataque de repeticao.
try:
if abs(time.time() - int(t)) > TOLERANCIA_SEGUNDOS:
return False
except ValueError:
return False
# 2. Recalcula sobre o corpo bruto. request.get_data() devolve os
# bytes originais; request.json ja perdeu o texto exato.
base = t.encode("utf-8") + b"." + corpo_bruto
esperado = hmac.new(SEGREDO, base, hashlib.sha256).hexdigest()
# 3. compare_digest compara em tempo constante. Nunca use ==.
return hmac.compare_digest(esperado, v1)
@app.post("/webhooks/adigitalmax")
def receber():
corpo_bruto = request.get_data()
if not assinatura_confere(corpo_bruto, request.headers.get("X-Adigitalmax-Signature", "")):
# Sem detalhe do que falhou: a mensagem viraria pista.
return "assinatura invalida", 401
evento = json.loads(corpo_bruto)
# Idempotencia pelo eventoId, que se repete nas retentativas.
if evento["eventoId"] in eventos_vistos:
return "ja processado", 200
eventos_vistos.add(evento["eventoId"])
# Responda agora, processe fora do ciclo. O limite e de 10 segundos.
threading.Thread(target=processar, args=(evento,), daemon=True).start()
return "ok", 200
def processar(evento: dict) -> None:
nome = evento["evento"]
dados = evento["dados"]
if nome == "document.completed":
print("Concluido:", dados["documento"]["id"])
# baixar_pdf_assinado(dados["documento"]["id"])
elif nome == "document.refused":
print("Recusado:", dados.get("signatario", {}).get("motivoRecusa"))
elif nome == "document.expired":
print("Expirou:", dados["documento"]["id"])
else:
# Evento novo que voce ainda nao trata. Registre e siga.
print("Evento nao tratado:", nome)
if __name__ == "__main__":
app.run(port=3000)#!/usr/bin/env bash
# Confere na mao a assinatura de uma entrega que voce salvou em disco.
# Serve para provar que a sua implementacao esta errada, e nao a nossa,
# quando a verificacao falha no seu servidor.
#
# ADM_WEBHOOK_SEGREDO=... ./verificar.sh corpo.json "t=1786031051,v1=6f1c..."
set -euo pipefail
SEGREDO="${ADM_WEBHOOK_SEGREDO:?defina ADM_WEBHOOK_SEGREDO}"
CORPO_ARQUIVO="${1:?uso: verificar.sh corpo.json CABECALHO}"
CABECALHO="${2:?}"
T=$(echo "$CABECALHO" | tr ',' '\n' | grep '^t=' | cut -d= -f2)
V1=$(echo "$CABECALHO" | tr ',' '\n' | grep '^v1=' | cut -d= -f2)
# O printf junta t, ponto e o corpo BRUTO, sem passar por jq.
# Passar por jq reformata o JSON e a assinatura deixa de bater.
CALCULADA=$(
{ printf '%s.' "$T"; cat "$CORPO_ARQUIVO"; } \
| openssl dgst -sha256 -hmac "$SEGREDO" -hex \
| sed 's/^.*= //'
)
echo "recebida: $V1"
echo "calculada: $CALCULADA"
if [ "$CALCULADA" = "$V1" ]; then
echo "OK: a assinatura confere."
else
echo "FALHOU: confira se o corpo foi salvo byte a byte, sem reformatar."
exit 1
fiPolítica de retentativa
Consideramos entregue qualquer resposta 2xx dentro de 10 segundos. Qualquer
outra coisa, incluindo timeout, conexão recusada, erro de TLS e redirecionamento,
conta como falha. As falhas são repetidas com espera crescente por até 24
horas, e depois de falhas consecutivas o webhook é desativado e o administrador é
avisado por e-mail.
| Tentativa | Quando | Acumulado |
|---|---|---|
| 1 | imediatamente | 0 |
| 2 | + 1 minuto | 1 min |
| 3 | + 5 minutos | 6 min |
| 4 | + 30 minutos | 36 min |
| 5 | + 2 horas | 2 h 36 |
| 6 | + 6 horas | 8 h 36 |
| 7 | + 15 horas | ~24 h |
Não construa lógica que dependa do intervalo exato. O que você pode assumir com segurança é
o que o contrato garante: entrega pelo menos uma vez, retentativa por até 24 horas, e
desativação depois de falha continuada. O histórico completo fica em
GET /webhooks/{webhookId}/entregas,
com o corpo enviado, o código que o seu servidor devolveu e o número de tentativas.
Falha continuada desativa o webhook. Isso protege você de acumular
milhares de retentativas contra um servidor que foi desligado, e protege a nossa fila.
Reativar é uma edição no painel ou um
PATCH /webhooks/{webhookId}.
Responder 410 Gone antecipa a desativação, e é mais limpo do que deixar
falhar por horas quando o endpoint foi descomissionado de verdade.
Idempotência e ordem
A entrega é pelo menos uma vez, e não exatamente uma vez. Você vai receber o mesmo evento duas vezes em algum momento, e o cenário mais comum não é falha nossa: é o seu servidor processar em 11 segundos, nós desistirmos da resposta aos 10, e a retentativa chegar enquanto o primeiro processamento termina.
A defesa é guardar o eventoId do corpo em uma tabela com índice único e conferir
antes de processar. Ele é estável entre as retentativas da mesma entrega, e é por isso que o
contrato manda tratar o consumo como idempotente usando exatamente esse campo.
# A restricao UNIQUE e o mecanismo, e nao o SELECT antes do INSERT:
# sob concorrencia, dois processos passam pelo SELECT ao mesmo tempo.
CREATE TABLE webhook_eventos (
evento_id UUID PRIMARY KEY,
evento TEXT NOT NULL,
recebido_em TIMESTAMPTZ NOT NULL DEFAULT now(),
processado_em TIMESTAMPTZ
);
# INSERT ... ON CONFLICT DO NOTHING, e se nao inseriu, ja foi tratado.
A ordem não é garantida. Sob retentativa, document.completed
pode chegar antes de um document.signed atrasado. Não construa máquina de estado
que dependa da sequência de chegada: use o ocorridoEm do payload para ordenar, e
trate o status do documento como a verdade do momento em que o evento foi
gerado. Se precisar do estado atual com certeza, consulte
GET /documentos/{documentoId}: essa
consulta sempre vence qualquer evento.
Testar sem expor servidor
Três caminhos, do mais simples ao mais completo. Os três dispensam publicar o seu servidor de desenvolvimento na internet.
1. Assinar você mesmo, contra o seu localhost
O jeito mais rápido de fechar o ciclo sem rede nenhuma: você gera a assinatura com o mesmo segredo e bate no seu próprio servidor. Se isto passar, a sua verificação está correta e qualquer falha posterior é de transporte, não de código.
#!/usr/bin/env bash
# Simula uma entrega assinada contra o seu servidor local.
# Use o MESMO segredo que voce configurou na aplicacao.
set -euo pipefail
SEGREDO="${ADM_WEBHOOK_SEGREDO:?defina ADM_WEBHOOK_SEGREDO}"
DESTINO="${1:-http://localhost:3000/webhooks/adigitalmax}"
T=$(date +%s)
CORPO='{"eventoId":"9c14f7a2-0b5e-4d83-91f6-27ad5e30c8b4","evento":"document.completed","ocorridoEm":"2026-08-18T16:04:11-03:00","dados":{"documento":{"id":"6f2a1c94-8b3d-4e57-9a20-71c5f0d3ab48","titulo":"Contrato de teste","status":"ASSINADO","nivelAssinatura":"SEGURA","paginas":7},"signatarios":[],"certificado":{"codigo":"ADX-CERT-TESTE00001","versao":1}}}'
V1=$(
printf '%s.%s' "$T" "$CORPO" \
| openssl dgst -sha256 -hmac "$SEGREDO" -hex \
| sed 's/^.*= //'
)
curl --silent --show-error --include \
--request POST "$DESTINO" \
--header "Content-Type: application/json" \
--header "X-Adigitalmax-Signature: t=$T,v1=$V1" \
--data-raw "$CORPO"
Depois que isso funcionar, faça o teste negativo: mude um caractere do corpo sem recalcular a
assinatura e confirme que o seu servidor responde 401. Uma verificação que
aceita tudo passa no teste positivo exatamente como uma correta. Faça também o teste do
relógio: assine com T de uma hora atrás e confirme a recusa.
2. Túnel para a sua máquina
Para receber eventos de verdade, gerados por documentos de sandbox, um túnel publica uma URL
temporária que aponta para o seu localhost. Ferramentas como
cloudflared tunnel, ngrok ou localtunnel resolvem
isso, e nenhuma exige abrir porta no seu roteador nem no firewall da empresa.
# Publica http://localhost:3000 em uma URL https temporaria.
cloudflared tunnel --url http://localhost:3000
# Cadastre a URL que ele imprimir, acrescentando o seu caminho:
# https://algo-aleatorio.trycloudflare.com/webhooks/adigitalmax
# Use SEMPRE uma chave de sandbox para cadastrar este webhook.
Nunca aponte um webhook de produção para um túnel de desenvolvimento. A URL do túnel muda a cada reinício e some quando você fecha o terminal, e o resultado é evento real de cliente real caindo em endereço inexistente até o webhook ser desativado por falha continuada.
3. O histórico de entregas
GET /webhooks/{webhookId}/entregas e
a tela equivalente do painel mostram, por entrega, o corpo enviado, o código que o seu
servidor devolveu, quanto tempo levou e quantas tentativas houve. É por onde se responde a
pergunta "vocês mandaram?" sem depender do log do seu lado.
Erros comuns, e o que cada um significa
| Sintoma | Causa quase sempre | Correção |
|---|---|---|
| A assinatura nunca confere | O corpo está sendo reserializado antes do cálculo. | Ler os bytes crus: express.raw, php://input, request.get_data(). |
| Confere no teste local e falha em produção | Um proxy ou WAF na frente reescreve o corpo, remove o cabeçalho ou normaliza o JSON. | Registrar o corpo exato recebido e comparar com o do histórico de entregas. |
| Falha de forma intermitente | Relógio do servidor fora de sincronia, estourando a janela de cinco minutos. | Ligar NTP. Este é o item que ninguém verifica e que explica metade dos casos intermitentes. |
| O mesmo evento chega várias vezes | O handler demora mais de 10 segundos, ou responde 3xx. |
Responder 200 primeiro e processar depois. Redirecionamento não é sucesso. |
| Nunca chega nada | URL sem HTTPS, certificado inválido, ou host que resolve para faixa privada. A validação é feita a cada entrega, e não só no cadastro. | Ver o erro exato no histórico de entregas; ele traz o motivo da recusa. |