Pular para o conteúdo
AdigitalMAX AdigitalMAX API v1
Construir

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

Assine só o que você trata. Evento assinado e descartado ainda ocupa entrega e retentativa.
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.

A requisição que chega no seu endpoint
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.

O 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 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.

O que é assinado
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:

  1. 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ã.
  2. 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"));

Polí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.

Um escalonamento plausível dentro da janela de 24 horas. >>> PENDENTE DE BACKEND <<< O contrato fixa a janela, não os intervalos.
TentativaQuandoAcumulado
1imediatamente0
2+ 1 minuto1 min
3+ 5 minutos6 min
4+ 30 minutos36 min
5+ 2 horas2 h 36
6+ 6 horas8 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 tabela mínima
# 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.

bash
#!/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.

bash
# 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

Os cinco sintomas que aparecem em quase toda integração nova.
SintomaCausa quase sempreCorreçã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.