Assinatura Digital em NestJS: Módulo, Webhook com Raw Body e Idempotência
NestJS impõe uma estrutura (módulos, providers, pipes, guards, interceptors) que combina bem com o que uma integração de assinatura precisa: um cliente configurado uma única vez, um endpoint de webhook que valida a assinatura HMAC sobre o corpo bruto, idempotência declarativa e processamento assíncrono fora do ciclo da requisição. Este guia mostra, arquivo por arquivo, como montar um SignDocsModule em cima do SDK oficial @signdocs-brasil/api, sem deixar credenciais escaparem para o cliente e sem os dois tropeços clássicos do framework: o rawBody desligado e o ValidationPipe descartando campos que você precisava.
Se você chegou aqui sem conhecer a API, comece pelo quickstart de 5 minutos e pelo guia genérico de integração em Node.js; a versão para Next.js cobre o lado App Router. Aqui o foco é o que muda quando o backend é Nest.
O desenho: quem fala com a SignDocs é só o backend
O padrão é o mesmo de qualquer integração séria: o front (web ou app) nunca vê client_id nem client_secret. Ele pede ao seu backend Nest que crie a sessão de assinatura; o backend chama a SignDocs, guarda o transactionId e devolve ao front apenas o link de assinatura. A prova de que alguém assinou chega depois, pelo webhook, e é o backend que a recebe, valida e persiste.
| Peça | Responsabilidade no Nest | O que nunca deve fazer |
|---|---|---|
SignDocsModule |
Instancia o SDK uma vez com ConfigService; exporta o SignDocsService |
Ler credenciais de arquivo commitado |
SignDocsService |
Cria sessões e envelopes, consulta status, baixa documento e evidências | Expor o clientSecret da sessão em logs |
SigningController |
Recebe pedidos do front, aplica idempotência, devolve o link | Confiar no redirecionamento como prova de assinatura |
WebhookController |
Valida HMAC sobre o corpo bruto, responde 2xx rápido, enfileira | Processar síncrono e estourar o timeout de 15 s da entrega |
Configuração: ConfigModule e variáveis de ambiente
Instale o SDK e o módulo de configuração. O SDK cuida do POST /oauth2/token (client credentials, token de 15 minutos) e do cache do token; você só informa credenciais e a URL base, que em homologação é https://api-hml.signdocs.com.br.
Valide o ambiente na subida do processo. Um validationSchema (Joi) ou uma classe validada com class-validator evita o erro mais silencioso de todos: o serviço sobe em produção apontando para o host de homologação.
O useFactory garante uma única instância do cliente por processo, o que é exatamente o que você quer: o cache de token vive nela. Não crie um cliente por requisição.
O serviço: criar sessão, consultar, baixar
O serviço encapsula as chamadas que o resto da aplicação usa. Repare no returnUrl: ele leva o signatário de volta ao seu app depois de assinar, com session_id anexado à query string, mas não é prova de assinatura. A prova vem pelo webhook ou por GET /v1/transactions/{id}.
O segundo argumento de create vira o cabeçalho X-Idempotency-Key. Com uma chave estável (contrato + CPF), um clique duplo no front ou um retry do seu próprio HTTP client devolve a mesma sessão em vez de criar duas e consumir dois documentos da cota. A chave vale por 24 horas; o mesmo corpo repete a resposta, um corpo diferente com a mesma chave recebe 409. Os detalhes estão no guia de rate limits e idempotência.
O controller de criação: DTO, ValidationPipe e o que devolver
Use class-validator para o DTO e ative o ValidationPipe globalmente com whitelist: true. Cuidado com o efeito colateral: whitelist descarta qualquer campo sem decorator. Se você aceitar metadata livre do front, declare-o explicitamente ou ele some antes de chegar ao serviço.
Quem recebe o link é quem assina. Se o perfil for CLICK_ONLY, o link é a autenticação: entregue-o apenas por um canal que só o signatário acessa (área logada, e-mail dele, o convite da própria plataforma). Devolvê-lo a um terceiro, como o vendedor que abriu o contrato, anula a evidência de quem clicou.
O webhook: rawBody, HMAC e 2xx em milissegundos
Aqui mora o tropeço número um do Nest. O body-parser padrão transforma o JSON em objeto antes do seu controller, e um JSON.stringify de volta não reproduz os bytes originais (ordem de chaves, espaços, unicode). A assinatura HMAC é calculada sobre {timestamp}.{payload} com o payload exato, então você precisa do corpo bruto. O Nest resolve isso com rawBody: true na criação da aplicação:
Três decisões nesse controller merecem destaque:
- O verificador também confere o timestamp (tolerância padrão de 5 minutos), o que fecha a porta para replay de um payload antigo capturado.
- O controller não processa nada. A SignDocs espera resposta em até 15 segundos e faz no máximo 3 tentativas para erros de servidor; se o seu processamento (gravar no banco, gerar PDF, chamar o ERP) atrasar, você perde o evento. Enfileirar e responder é o único desenho seguro.
jobId: event.idtorna a fila idempotente: a mesma entrega, chegando duas vezes, vira um único job.
O worker (um @Processor('signdocs-events')) trata SIGNING_SESSION.COMPLETED, ENVELOPE.ALL_SIGNED, SIGNING_SESSION.EXPIRED e o que mais você tiver assinado ao registrar o webhook. Como a ordem entre eventos não é garantida, o worker deve ser tolerante: ao receber um STEP.COMPLETED depois do COMPLETED da sessão, não regrida o estado. A lista completa de eventos e o formato do payload (data, nunca payload) estão em webhooks e eventos.
Idempotência declarativa com um interceptor
A API já é idempotente do lado dela; o que falta é o seu endpoint de criação também ser, para que o front possa repetir a chamada sem medo. Um interceptor que lê um cabeçalho Idempotency-Key do seu próprio contrato com o front, guarda a resposta em cache (Redis) e a repete resolve isso sem sujar cada controller.
Combine as duas camadas: a chave do front garante que você não chama a SignDocs duas vezes; a X-Idempotency-Key enviada ao SDK garante que, se chamar, a SignDocs não cria duas sessões. Cota preservada nos dois lados.
Testes: sandbox, e2e e o evento de teste
Em homologação (api-hml.signdocs.com.br) tudo é gratuito e a biometria é simulada; as entidades expiram em 7 dias. Para o teste e2e do webhook, registre a URL do seu ambiente e dispare POST /v1/webhooks/{webhookId}/test: a plataforma envia um evento real, assinado com o segredo daquele webhook, o que valida de ponta a ponta o rawBody, o HMAC e a fila. O roteiro completo (mock, sandbox, smoke em CI) está em testar a integração no sandbox e no CI.
| Sintoma | Causa provável no Nest | Correção |
|---|---|---|
| HMAC nunca bate, mesmo com o segredo certo | req.rawBody indefinido: rawBody: true não foi passado ao NestFactory.create |
Ativar rawBody; conferir se nenhum middleware consome o stream antes |
metadata chega vazio no serviço |
ValidationPipe com whitelist descartou o campo sem decorator |
Declarar o campo no DTO (@IsObject()) ou remover o whitelist naquela rota |
| Duas sessões para o mesmo contrato | Retry do front sem chave de idempotência | Interceptor + X-Idempotency-Key no SDK |
| Webhook marcado como FAILED no painel | Processamento síncrono passou de 15 s ou lançou 500 | Responder 200 após enfileirar; mover o trabalho para o worker |
| 401 em toda chamada após alguns minutos | Cliente recriado por requisição perdeu o cache de token, ou relógio do container defasado | Instância única via useFactory; sincronizar NTP |
Os códigos de erro seguem RFC 7807 (application/problem+json); o mapeamento para exceções do Nest, com filtro global, está no guia de tratamento de falhas.
Quando usar envelope em vez de sessão
Para um documento com vários signatários (contratante, contratado, duas testemunhas), troque signingSessions.create por envelopes.create com signingMode PARALLEL ou SEQUENTIAL e totalSigners, e adicione cada signatário com POST /v1/envelopes/{id}/sessions informando signerIndex (a partir de 1) e a política daquela pessoa. O envelope aceita expiresInMinutes entre 5 minutos e 7 dias (padrão 72 horas) e emite ENVELOPE.ALL_SIGNED quando o último assina. O serviço acima cresce naturalmente com um método createEnvelope; a estrutura de módulos, webhook e fila permanece idêntica.
Custos: a API não está nos planos do site, que são do aplicativo. O acesso é por plano sob medida com o time comercial, e o sandbox de homologação é gratuito, sem cartão, para você validar todo o módulo antes de qualquer conversa.
Perguntas Frequentes
Por que o HMAC do webhook não bate no NestJS mesmo com o segredo correto?
Quase sempre porque o controller está recebendo o corpo já convertido em objeto pelo body-parser e tentando reconstruí-lo com JSON.stringify. A assinatura é calculada sobre a string {timestamp}.{payload} com os bytes exatos que a SignDocs enviou, e a reconstrução altera ordem de chaves, espaços e escapes. Passe rawBody: true em NestFactory.create, leia req.rawBody no controller e entregue essa string ao verifyWebhookSignature junto com os cabeçalhos X-SignDocs-Signature e X-SignDocs-Timestamp. Confira também se nenhum middleware consome o stream da requisição antes do parser do Nest.
Preciso criar um SignDocsBrasilClient por requisição?
Não, e é melhor não fazer isso. O cliente do SDK guarda em memória o token OAuth2 obtido no POST /oauth2/token, que vale 15 minutos. Uma instância por requisição descarta esse cache e gera um pedido de token a cada chamada, o que desperdiça latência e pode esbarrar em limites. No Nest, registre o cliente como provider com useFactory injetando o ConfigService: uma única instância por processo, com as credenciais lidas do ambiente e a baseUrl apontando para homologação ou produção conforme o deploy.
Como evitar que um clique duplo crie duas sessões e consuma dois documentos?
Em duas camadas. No seu endpoint, um interceptor lê um cabeçalho Idempotency-Key definido pelo front, guarda a resposta em cache por 24 horas e a repete para chamadas idênticas. Na chamada à SignDocs, passe uma chave estável (por exemplo contrato + CPF) como segundo argumento de signingSessions.create; ela vira o cabeçalho X-Idempotency-Key, e a plataforma devolve a mesma sessão para o mesmo corpo dentro de 24 horas, ou 409 se o corpo mudou. Assim nem o seu backend nem a cota da API são afetados por retries.
Posso processar o evento dentro do controller do webhook?
Pode, mas é o desenho que mais perde eventos. A entrega tem timeout de 15 segundos e no máximo 3 tentativas para erros de servidor; depois disso a entrega fica registrada como FAILED e não é repetida indefinidamente. Se gravar no banco, gerar arquivos ou chamar um ERP dentro da requisição, qualquer lentidão vira perda. Valide o HMAC, enfileire o evento (BullMQ com jobId igual ao id do evento, para deduplicar) e responda 200 imediatamente; o worker faz o resto. Para o que ficar em dúvida, consulte GET /v1/transactions/{id}.
O ValidationPipe com whitelist pode atrapalhar a integração?
Sim, de um jeito silencioso. Com whitelist: true, o pipe remove do corpo qualquer campo que não tenha decorator de validação no DTO. Se o front envia um objeto metadata livre (por exemplo o id do contrato ou do cliente final) e o DTO não o declara, o campo chega vazio ao serviço e a sessão é criada sem ele. Declare o campo com @IsObject() ou @IsOptional(), ou desative o whitelist apenas naquela rota. O mesmo vale para campos numéricos que dependem de transform: true para serem convertidos.
Quanto custa integrar a API e como testo antes de contratar?
A API não faz parte dos planos publicados no site, que são do aplicativo. O acesso é por plano sob medida, definido com o time comercial conforme volume e perfis de autenticação. Antes disso, crie credenciais de homologação gratuitamente, sem cartão: o host api-hml.signdocs.com.br responde a todas as chamadas, a biometria é simulada por padrão e as entidades expiram em 7 dias. Dá para validar o módulo, o webhook com rawBody, a fila e o fluxo de envelope de ponta a ponta sem custo.
Monte o SignDocsModule no sandbox hoje
Credenciais de homologação gratuitas, sem cartão, com biometria simulada e o evento de teste de webhook para validar rawBody, HMAC e fila de ponta a ponta. Em produção, plano sob medida com o time comercial.
Criar credenciais de homologação Fale com o time comercial