Webhooks de Assinatura em Fila: Ack Rápido, Reprocessamento e Reconciliação

Todo mundo integra o webhook de assinatura do mesmo jeito no primeiro dia: um endpoint que recebe o evento, grava no banco, avisa o ERP e responde. Funciona no sandbox e quebra no primeiro pico de produção, quando o banco demora, o ERP está fora e a entrega expira. Este guia trata o webhook como o que ele é, uma mensagem que pode chegar atrasada, repetida ou fora de ordem, e mostra o desenho que sobrevive a isso: ack rápido, fila, deduplicação por id, reprocessamento e reconciliação com a API quando algo escapar.

Os fatos abaixo sobre a política de entrega da SignDocs vêm da implementação da plataforma, não de suposições. O catálogo de eventos e o formato do payload estão em webhooks e eventos da API; aqui o assunto é o que o seu lado precisa fazer com eles.

O que a SignDocs promete (e o que não promete) na entrega

Cada evento é uma requisição POST para a URL registrada, com o corpo em JSON e dois cabeçalhos: X-SignDocs-Signature (HMAC-SHA256 em hexadecimal, calculado sobre {timestamp}.{payload}) e X-SignDocs-Timestamp (segundos Unix). A entrega é feita por um worker alimentado por fila, e a política é esta:

Aspecto Comportamento Consequência para o seu consumidor
Timeout por tentativa 15 segundos Responder em milissegundos; nunca esperar o banco ou o ERP
Retentativas Até 3 tentativas no total para erros de rede e respostas 5xx, com espera curta e crescente entre elas Uma indisponibilidade de alguns segundos é absorvida; uma de minutos, não
Depois da última tentativa A entrega é registrada como FAILED e não é repetida indefinidamente Você precisa de reconciliação própria para os casos perdidos
Resposta 4xx Não é retentada: é tratada como recusa do seu endpoint Um bug de validação seu descarta o evento
Ordem Não garantida entre eventos da mesma transação Consumidor tolerante a chegada fora de ordem
Duplicidade Possível (retentativa após um 2xx que se perdeu na rede) Deduplicar pelo id do evento

Isso é o contrato honesto de qualquer webhook: entrega ao menos uma vez, com esforço limitado. Quem promete "exatamente uma vez, sempre" está omitindo alguma parte. O desenho do consumidor precisa partir daí.

Regra 1: responder 2xx em menos de dois segundos

O endpoint que recebe o webhook tem uma única tarefa: verificar que a mensagem é autêntica e guardá-la em um lugar durável. Só isso. Todo o resto (atualizar o contrato, baixar o PDF assinado, chamar o CRM) acontece depois, fora da requisição.

// Pseudocódigo do endpoint: o mesmo em qualquer linguagem receber(req): raw = corpo bruto da requisição (bytes exatos) assinatura = req.headers['X-SignDocs-Signature'] ts = req.headers['X-SignDocs-Timestamp'] se !hmac_valido(raw, assinatura, ts, SEGREDO): # inclui tolerância de 5 min no ts responder 401 # 4xx: a SignDocs não retenta evento = json(raw) fila.publicar(chave = evento.id, corpo = raw) # durável, deduplicado pela chave responder 200 { received: true } # nada mais acontece aqui

Repare que o corpo vai para a fila já verificado e no formato bruto. Verificar antes de enfileirar evita que um atacante encha a sua fila; guardar o bruto permite auditar mais tarde exatamente o que foi recebido.

Três filas, o mesmo padrão

  • SQS (AWS): use uma fila FIFO com MessageDeduplicationId = evento.id e MessageGroupId = transactionId. A deduplicação nativa vale por 5 minutos; o grupo mantém a ordem por transação dentro do que a SignDocs enviou. Configure uma dead-letter queue com maxReceiveCount de 5 para o que o worker não conseguir processar.
  • BullMQ (Node/Redis): queue.add(evento.eventType, evento, { jobId: evento.id, attempts: 5, backoff: { type: 'exponential', delay: 2000 } }). Um jobId repetido é ignorado, o que resolve a duplicidade sem código extra.
  • Sidekiq (Ruby): SignDocsEventJob.perform_async(evento) com o gem sidekiq-unique-jobs usando o id do evento como chave de unicidade, ou uma tabela webhook_events com índice único em event_id como barreira antes do perform_async.

A fila não substitui a tabela. Além de enfileirar, grave o evento bruto em uma tabela webhook_events(event_id UNIQUE, event_type, transaction_id, received_at, raw, processed_at). O UNIQUE é a deduplicação definitiva (a do SQS expira em 5 minutos); o processed_at nulo é a sua lista de pendências para reprocessar.

Regra 2: o worker é idempotente e tolera desordem

O worker consome a fila e aplica o evento ao seu domínio. Duas propriedades o tornam seguro:

Idempotência. Aplicar o mesmo evento duas vezes não pode ter efeito duplo. "Marcar contrato como assinado" é naturalmente idempotente; "enviar e-mail de boas-vindas" não é. Para as ações não idempotentes, verifique processed_at na tabela antes de agir e marque-o na mesma transação de banco em que a ação é registrada.

Tolerância a desordem. Um STEP.COMPLETED pode chegar depois do SIGNING_SESSION.COMPLETED da mesma sessão. Modele o estado do seu contrato como uma máquina que só avança: receber um evento "menor" quando o estado já é "maior" é um no-op, nunca uma regressão.

// Worker (Node, BullMQ) — a mesma lógica vale para SQS ou Sidekiq const ORDEM = { PENDENTE: 0, EM_ANDAMENTO: 1, ASSINADO: 2, ENCERRADO: 3 }; worker.process(async (job) => { const ev = job.data; const txId = ev.data.transactionId; await db.transaction(async (trx) => { const row = await trx('webhook_events').where({ event_id: ev.id }).forUpdate().first(); if (row?.processed_at) return; // já aplicado const contrato = await trx('contratos').where({ transaction_id: txId }).forUpdate().first(); const alvo = estadoPara(ev.eventType); // ex.: SIGNING_SESSION.COMPLETED -> ASSINADO if (alvo && ORDEM[alvo] > ORDEM[contrato.estado]) { // só avança await trx('contratos').where({ id: contrato.id }).update({ estado: alvo }); if (alvo === 'ASSINADO') await agendarDownload(trx, txId); // outra fila } await trx('webhook_events').where({ event_id: ev.id }).update({ processed_at: trx.fn.now() }); }); });

O download do PDF assinado e do pacote de evidências (GET /v1/transactions/{id}/download e /evidence) vai para outra fila, com seus próprios retries: são chamadas de rede que podem falhar por motivos que nada têm a ver com o evento.

Regra 3: reprocessar é normal, não exceção

Com a tabela de eventos, reprocessar é trivial: selecione tudo com processed_at IS NULL mais antigo que alguns minutos e reenfileire. Isso cobre o worker que caiu no meio, o deploy que reiniciou o processo e o bug que lançou exceção. Como o worker é idempotente, reprocessar algo que na verdade já foi aplicado não causa dano.

Para testar esse caminho antes de precisar dele, use POST /v1/webhooks/{webhookId}/test: a plataforma envia um evento de teste real, assinado com o segredo daquele webhook, para a URL registrada. Ele exercita o HMAC, a fila, o worker e a tabela de ponta a ponta. Coloque essa chamada no seu pipeline de CI, como descrito em testar a integração no sandbox e no CI, e derrube o worker de propósito uma vez para ver o reprocessamento funcionando.

Regra 4: reconciliar com a API o que o webhook não trouxe

Mesmo com tudo acima, existe o caso em que a SignDocs tentou três vezes, o seu endpoint estava fora nas três e a entrega ficou como FAILED. O evento nunca chegou. A resposta não é implorar por retentativas infinitas; é uma rotina de reconciliação que pergunta à fonte da verdade.

// Reconciliação: roda a cada 10-15 minutos pendentes = contratos onde estado em (PENDENTE, EM_ANDAMENTO) e enviado_em < agora - 15 min e (ultima_consulta_em é nulo ou ultima_consulta_em < agora - 15 min) para cada contrato em pendentes (respeitando o rate limit): tx = GET /v1/transactions/{contrato.transaction_id} aplicar(tx.status) # a mesma máquina de estados do worker contrato.ultima_consulta_em = agora

Os status possíveis da transação (CREATED, DOCUMENT_UPLOADED, IN_PROGRESS, COMPLETED, CANCELLED, EXPIRED, FAILED) mapeiam para os mesmos estados do seu domínio; GET /v1/transactions/{id} também devolve o metadata que você enviou na criação, útil para reencontrar o contrato quando o vínculo local se perdeu. Consulte só o que está pendente há tempo suficiente, com espaçamento, para não gastar cota de requisições com polling agressivo: os limites e os cabeçalhos RateLimit-* que aparecem em respostas 429 estão em rate limits, paginação e idempotência.

Prazos importam aqui. Uma sessão de assinatura vale por padrão 72 horas e um envelope aceita expiresInMinutes entre 5 minutos e 7 dias; passado o prazo, a transação vai para EXPIRED e você recebe SIGNING_SESSION.EXPIRED ou ENVELOPE.EXPIRED. A reconciliação que encontra um EXPIRED deve encerrar o caso localmente e, se fizer sentido no seu fluxo, criar uma nova sessão. Quando a transação tem um prazo de submissão configurado, uma checagem diária da plataforma emite TRANSACTION.DEADLINE_APPROACHING com menos de 48 horas restantes; trate esse evento como um aviso oportuno, não como base da sua lógica de expiração.

Observabilidade: os quatro números que importam

  • Latência de resposta do endpoint (p99). Se passar de 2 segundos, alguma coisa entrou no caminho da requisição que deveria estar no worker.
  • Idade do evento mais antigo não processado (now - min(received_at) where processed_at is null). É o seu atraso real. Alarme em 5 minutos.
  • Eventos aplicados pela reconciliação, não pelo webhook. Zero é o normal. Qualquer valor persistente indica entregas FAILED e merece investigação do seu lado (firewall, certificado TLS, deploy).
  • Falhas de HMAC. Uma ou outra é ruído da internet; uma sequência é segredo rotacionado sem atualizar o ambiente, ou alguém testando a sua URL.

Para o primeiro item, lembre-se de que a verificação de assinatura inclui a tolerância de tempo de 5 minutos no X-SignDocs-Timestamp: um servidor com relógio defasado rejeita eventos legítimos e aparece nas métricas como "falha de HMAC". NTP resolve.

Checklist de produção

  1. Endpoint verifica HMAC sobre o corpo bruto e responde 200 em milissegundos.
  2. Evento gravado em tabela com event_id UNIQUE antes de qualquer processamento.
  3. Fila com deduplicação pelo id do evento e dead-letter para o que falhar repetidamente.
  4. Worker idempotente, com máquina de estados que só avança.
  5. Download do documento e das evidências em fila separada, com retries próprios.
  6. Rotina de reprocessamento para processed_at IS NULL.
  7. Rotina de reconciliação com GET /v1/transactions/{id} para pendências antigas.
  8. Evento de teste (POST /v1/webhooks/{id}/test) no CI e alarmes nos quatro números acima.

Tudo isso pode ser validado no sandbox de homologação, gratuito e sem cartão, onde a biometria é simulada e as entidades expiram em 7 dias. Em produção, o acesso à API é por plano sob medida com o time comercial.

Perguntas Frequentes

Quantas vezes a SignDocs tenta entregar um webhook?

Cada entrega é um POST com timeout de 15 segundos. Erros de rede e respostas 5xx são retentados, até 3 tentativas no total, com espera curta e crescente entre elas. Depois disso a entrega é registrada como FAILED e não é repetida indefinidamente. Respostas 4xx não são retentadas: são tratadas como recusa do seu endpoint. Por isso o consumidor precisa responder rápido, guardar o evento antes de processar e manter uma rotina de reconciliação com GET /v1/transactions/{id} para o que eventualmente não chegar.

Por que devo responder 200 antes de processar o evento?

Porque o processamento (gravar no banco, baixar o PDF, chamar o ERP) tem latência que você não controla, e a janela de entrega é de 15 segundos por tentativa. Se o seu endpoint faz tudo dentro da requisição, um banco lento ou um ERP fora do ar transforma o evento em FAILED. O desenho seguro é: verificar o HMAC sobre o corpo bruto, gravar o evento em uma tabela com event_id único, publicar em uma fila e responder 200 em milissegundos. Um worker consome a fila depois, com retries próprios.

Como evito processar o mesmo evento duas vezes?

Deduplique pelo campo id do evento em duas camadas. A primeira é a fila: SQS FIFO com MessageDeduplicationId, BullMQ com jobId igual ao id, Sidekiq com chave de unicidade. A segunda, definitiva, é uma tabela webhook_events com índice único em event_id e uma coluna processed_at: o worker verifica processed_at dentro da mesma transação de banco em que aplica o evento, e ações não idempotentes (como enviar um e-mail) só acontecem quando ele ainda está nulo.

Os eventos chegam em ordem?

Não há garantia de ordem entre eventos da mesma transação: um STEP.COMPLETED pode chegar depois do SIGNING_SESSION.COMPLETED. Modele o estado do seu contrato como uma máquina que só avança (pendente, em andamento, assinado, encerrado) e trate um evento de estado menor chegando depois de um maior como no-op. Isso torna o worker indiferente à ordem e também ao reprocessamento, já que reaplicar um evento antigo nunca regride o estado.

O que faço quando um evento nunca chegou?

Reconcilie com a fonte da verdade. Uma rotina periódica seleciona os contratos pendentes há mais de alguns minutos e consulta GET /v1/transactions/{id}, aplicando o status devolvido (ACTIVE, COMPLETED, CANCELLED, EXPIRED, FAILED) pela mesma máquina de estados do worker. A resposta traz também o metadata que você enviou na criação, útil para reencontrar o registro local. Consulte só pendências antigas e com espaçamento, para não consumir a cota de requisições com polling agressivo.

Como testo o caminho de reprocessamento antes de precisar dele?

Use POST /v1/webhooks/{webhookId}/test: a plataforma envia um evento de teste real, assinado com o segredo daquele webhook, para a URL registrada, exercitando HMAC, fila, worker e tabela de ponta a ponta. Coloque essa chamada no CI e, uma vez, derrube o worker de propósito para ver a rotina de processed_at IS NULL reenfileirar o evento. Tudo isso funciona no sandbox de homologação, gratuito e sem cartão, com entidades que expiram em 7 dias.

Valide o consumidor de webhooks no sandbox

Credenciais de homologação gratuitas, sem cartão, e o evento de teste POST /v1/webhooks/{id}/test para exercitar HMAC, fila, worker e reconciliação antes de produção. Em produção, plano sob medida com o time comercial.

Criar credenciais de homologação Fale com o time comercial