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.

# Instalar npm install @signdocs-brasil/api @nestjs/config class-validator class-transformer # .env (nunca commitado) SIGNDOCS_CLIENT_ID=... SIGNDOCS_CLIENT_SECRET=... SIGNDOCS_BASE_URL=https://api-hml.signdocs.com.br SIGNDOCS_WEBHOOK_SECRET=... APP_PUBLIC_URL=https://seuapp.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.

// src/signdocs/signdocs.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { SignDocsBrasilClient } from '@signdocs-brasil/api'; import { SignDocsService } from './signdocs.service'; export const SIGNDOCS_CLIENT = Symbol('SIGNDOCS_CLIENT'); @Module({ imports: [ConfigModule], providers: [ { provide: SIGNDOCS_CLIENT, inject: [ConfigService], useFactory: (config: ConfigService) => new SignDocsBrasilClient({ clientId: config.getOrThrow('SIGNDOCS_CLIENT_ID'), clientSecret: config.getOrThrow('SIGNDOCS_CLIENT_SECRET'), baseUrl: config.getOrThrow('SIGNDOCS_BASE_URL'), }), }, SignDocsService, ], exports: [SignDocsService], }) export class SignDocsModule {}

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

// src/signdocs/signdocs.service.ts import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SignDocsBrasilClient } from '@signdocs-brasil/api'; import { SIGNDOCS_CLIENT } from './signdocs.module'; @Injectable() export class SignDocsService { private readonly logger = new Logger(SignDocsService.name); constructor( @Inject(SIGNDOCS_CLIENT) private readonly client: SignDocsBrasilClient, private readonly config: ConfigService, ) {} async createSession(input: { contractId: string; signer: { name: string; email: string; cpf: string }; pdf: Buffer; filename: string; profile: 'CLICK_ONLY' | 'CLICK_PLUS_OTP' | 'BIOMETRIC' | 'DIGITAL_CERTIFICATE'; }) { const session = await this.client.signingSessions.create( { purpose: 'DOCUMENT_SIGNATURE', policy: { profile: input.profile }, signer: input.signer, document: { content: input.pdf.toString('base64'), filename: input.filename }, returnUrl: `${this.config.get('APP_PUBLIC_URL')}/contratos/${input.contractId}/retorno`, metadata: { contractId: input.contractId }, }, `contract-${input.contractId}-${input.signer.cpf}`, // X-Idempotency-Key ); // Guarde sessionId e transactionId; NUNCA logue o clientSecret. this.logger.log(`sessão ${session.sessionId} criada para ${input.contractId}`); return { sessionId: session.sessionId, transactionId: session.transactionId, signingUrl: `${session.url}?cs=${session.clientSecret}`, expiresAt: session.expiresAt, }; } getTransaction(transactionId: string) { return this.client.transactions.get(transactionId); } }

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.

// src/signing/dto/create-signing.dto.ts import { IsEmail, IsIn, IsString, Length, Matches } from 'class-validator'; export class CreateSigningDto { @IsString() contractId: string; @IsString() @Length(3, 120) signerName: string; @IsEmail() signerEmail: string; @Matches(/^\d{11}$/) signerCpf: string; @IsIn(['CLICK_ONLY', 'CLICK_PLUS_OTP', 'BIOMETRIC', 'DIGITAL_CERTIFICATE']) profile: 'CLICK_ONLY' | 'CLICK_PLUS_OTP' | 'BIOMETRIC' | 'DIGITAL_CERTIFICATE'; } // src/signing/signing.controller.ts @Controller('contratos') export class SigningController { constructor( private readonly signdocs: SignDocsService, private readonly contracts: ContractsService, ) {} @Post(':id/assinatura') @UseGuards(JwtAuthGuard) // só usuário autenticado do SEU sistema async start(@Param('id') id: string, @Body() dto: CreateSigningDto) { const pdf = await this.contracts.renderPdf(id); const result = await this.signdocs.createSession({ contractId: id, signer: { name: dto.signerName, email: dto.signerEmail, cpf: dto.signerCpf }, pdf, filename: `contrato-${id}.pdf`, profile: dto.profile, }); await this.contracts.markPending(id, result.transactionId); return { signingUrl: result.signingUrl, expiresAt: result.expiresAt }; } }

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:

// src/main.ts import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, // preenche req.rawBody sem desligar o parser JSON }); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); await app.listen(3000); } bootstrap();
// src/webhooks/signdocs-webhook.controller.ts import { Controller, Headers, HttpCode, Post, Req, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectQueue } from '@nestjs/bullmq'; import { Queue } from 'bullmq'; import { verifyWebhookSignature } from '@signdocs-brasil/api'; import type { RawBodyRequest } from '@nestjs/common'; import type { Request } from 'express'; @Controller('webhooks/signdocs') export class SignDocsWebhookController { constructor( private readonly config: ConfigService, @InjectQueue('signdocs-events') private readonly queue: Queue, ) {} @Post() @HttpCode(200) async receive( @Req() req: RawBodyRequest<Request>, @Headers('x-signdocs-signature') signature: string, @Headers('x-signdocs-timestamp') timestamp: string, ) { const raw = req.rawBody?.toString('utf8'); if (!raw || !signature || !timestamp) throw new UnauthorizedException(); const ok = verifyWebhookSignature( raw, signature, timestamp, this.config.getOrThrow('SIGNDOCS_WEBHOOK_SECRET'), ); if (!ok) throw new UnauthorizedException('assinatura inválida'); const event = JSON.parse(raw); // jobId = id do evento: BullMQ ignora duplicatas com o mesmo jobId await this.queue.add(event.eventType, event, { jobId: event.id, removeOnComplete: 1000 }); return { received: true }; } }

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.id torna 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.

// src/common/idempotency.interceptor.ts @Injectable() export class IdempotencyInterceptor implements NestInterceptor { constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} async intercept(ctx: ExecutionContext, next: CallHandler) { const req = ctx.switchToHttp().getRequest(); const key = req.headers['idempotency-key']; if (!key) return next.handle(); const cacheKey = `idem:${req.user.id}:${key}`; const cached = await this.cache.get(cacheKey); if (cached) return of(cached); return next.handle().pipe( tap((body) => this.cache.set(cacheKey, body, 24 * 60 * 60 * 1000)), ); } }

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