Assinatura Digital em Django: Guia Completo de Integração

Django dá tudo de que uma integração de assinatura precisa — settings, ORM, admin de graça — e um tropeço garantido: o 403 do CSRF no webhook, que já consumiu tardes de metade dos integradores Python do Brasil. Este guia monta a integração completa do jeito Django: cliente singleton a partir das settings, view de criação, webhook com csrf_exempt + verificação HMAC sobre request.body, model de registros e fila para o trabalho pesado. A base conceitual da API está no guia Python; aqui o foco é o framework.

Setup: settings, ambiente e o cliente singleton

# settings.py — credenciais SEMPRE via ambiente, nunca versionadas import os SIGNDOCS_CLIENT_ID = os.environ["SIGNDOCS_CLIENT_ID"] SIGNDOCS_CLIENT_SECRET = os.environ["SIGNDOCS_CLIENT_SECRET"] SIGNDOCS_BASE_URL = os.environ.get("SIGNDOCS_BASE_URL", "https://api-hml.signdocs.com.br") SIGNDOCS_WEBHOOK_SECRET = os.environ["SIGNDOCS_WEBHOOK_SECRET"]
# core/signdocs.py — uma instância por processo; o SDK renova o token sozinho from django.conf import settings from signdocs_brasil import SignDocsBrasilClient, ClientConfig client = SignDocsBrasilClient(ClientConfig( client_id=settings.SIGNDOCS_CLIENT_ID, client_secret=settings.SIGNDOCS_CLIENT_SECRET, base_url=settings.SIGNDOCS_BASE_URL, ))

Instale com pip install signdocs-brasil. O SIGNDOCS_BASE_URL por variável é o que troca homologação→produção sem tocar código — desenvolvimento inteiro acontece contra o sandbox gratuito.

O model que ancora tudo

# contratos/models.py class EnvioAssinatura(models.Model): contrato = models.ForeignKey(Contrato, on_delete=models.PROTECT) session_id = models.CharField(max_length=64, unique=True) transaction_id = models.CharField(max_length=64, unique=True, db_index=True) status = models.CharField(max_length=32, default="ACTIVE") ultimo_evento_id = models.CharField(max_length=64, blank=True) # idempotência pdf_assinado = models.FileField(upload_to="assinados/", blank=True) evidencias_p7m = models.FileField(upload_to="evidencias/", blank=True) criado_em = models.DateTimeField(auto_now_add=True) concluido_em = models.DateTimeField(null=True, blank=True)

Com transaction_id único e indexado, o webhook vira um update atômico — e o admin do Django lhe dá de graça a tela de auditoria dos envios que todo time de operação pede depois.

A view de criação

# contratos/views.py from core.signdocs import client def enviar_para_assinatura(request, contrato_id): contrato = get_object_or_404(Contrato, pk=contrato_id, dono=request.user) pdf_b64 = gerar_pdf_base64(contrato) # seu gerador (WeasyPrint, ReportLab...) session = client.signing_sessions.create( purpose="DOCUMENT_SIGNATURE", policy={"profile": "CLICK_PLUS_OTP"}, signer={ "name": contrato.cliente_nome, "email": contrato.cliente_email, "cpf": contrato.cliente_cpf, }, document={"content": pdf_b64, "filename": f"contrato-{contrato.pk}.pdf"}, returnUrl=request.build_absolute_uri(reverse("contrato_concluido")), ) EnvioAssinatura.objects.create( contrato=contrato, session_id=session.session_id, transaction_id=session.transaction_id, ) # O link do signatário é url + ?cs= + clientSecret return redirect(f"{session.url}?cs={session.client_secret}")

O webhook: csrf_exempt + HMAC sobre request.body

O 403 famoso: o middleware CSRF do Django bloqueia qualquer POST externo sem token — inclusive o webhook. A correção é @csrf_exempt na view, e ela é segura porque você troca o CSRF por uma proteção mais forte para este caso: a verificação HMAC prova que a requisição veio da SignDocs, coisa que token CSRF nunca provaria. O que é inaceitável é o meio-termo: csrf_exempt sem HMAC.
# webhooks/views.py import json from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from signdocs_brasil import verify_webhook_signature @csrf_exempt @require_POST def signdocs_webhook(request): raw = request.body # bytes crus — ANTES de qualquer parse signature = request.headers.get("X-SignDocs-Signature", "") if not verify_webhook_signature(raw, signature, settings.SIGNDOCS_WEBHOOK_SECRET): return HttpResponse(status=401) event = json.loads(raw) # Idempotência: reentregas acontecem atualizado = EnvioAssinatura.objects.filter( transaction_id=event["data"]["transactionId"] ).exclude(ultimo_evento_id=event["id"]).update( ultimo_evento_id=event["id"], status="COMPLETED" if event["eventType"] == "SIGNING_SESSION.COMPLETED" else models.F("status"), ) if atualizado and event["eventType"] == "SIGNING_SESSION.COMPLETED": baixar_documentos.delay(event["data"]["transactionId"]) # Celery: pesado vai p/ fila return HttpResponse("ok") # 200 rápido, sempre

A tarefa Celery baixa o PDF (GET /v1/transactions/{id}/download) e o pacote de evidências (/evidence) pelas URLs temporárias e preenche os FileFields. Sem Celery no projeto? django-q ou um management command em cron cumprem o papel em volumes menores — o que não muda é o princípio: a view responde rápido; a fila trabalha.

Teste: as três camadas no Django

  • Unidade: mocke core.signdocs.client e valide a sua lógica — a suíte continua rápida.
  • Contrato: uma suíte menor contra o sandbox real (credenciais de HML, dados fictícios, cada teste cria o que consome — TTL de 7 dias).
  • Webhook: em local, exponha o runserver com um túnel e dispare POST /v1/webhooks/{webhookId}/test — chega um payload assinado de verdade. Os cinco casos que o handler deve cobrir estão em como testar do sandbox ao CI.

E antes do go-live, confira os 12 erros mais comuns — três deles (corpo re-serializado, redirect como prova, reentrega sem idempotência) são exatamente os que o desenho acima já evita.

Perguntas Frequentes

Por que meu webhook em Django retorna 403 Forbidden?

É a proteção CSRF do Django fazendo o trabalho dela — no lugar errado. O middleware CSRF exige um token que só formulários do seu próprio site carregam; um webhook externo nunca o terá, então o POST morre em 403 antes de chegar à sua view. A correção é marcar a view do webhook com @csrf_exempt — e isso é seguro exatamente porque você substitui a proteção CSRF por uma mais forte para esse caso: a verificação da assinatura HMAC-SHA256 do evento. CSRF protege contra requisições forjadas por navegadores; o HMAC prova que a requisição veio da SignDocs. Nunca deixe o endpoint com csrf_exempt E sem verificação HMAC.

Como leio o corpo bruto do webhook no Django?

Use request.body — os bytes crus da requisição — para a verificação HMAC, e só depois faça json.loads. O erro clássico é usar dados já processados (request.POST ou um json já parseado e re-serializado): a re-serialização muda a ordem das chaves ou o espaçamento, os bytes mudam e a assinatura nunca confere. A sequência correta na view: raw = request.body; verificar HMAC sobre raw com o helper verify_webhook_signature do SDK signdocs-brasil (comparação timing-safe + tolerância de timestamp); event = json.loads(raw); processar.

Onde instancio o cliente da SignDocs num projeto Django?

Num módulo próprio (por exemplo, core/signdocs.py) que cria uma instância única de SignDocsBrasilClient a partir das configurações — o SDK obtém e renova o token OAuth2 de 15 minutos sozinho, então uma instância por processo é o desenho certo. As credenciais entram via settings lidas de variáveis de ambiente (os.environ ou django-environ): SIGNDOCS_CLIENT_ID, SIGNDOCS_CLIENT_SECRET e SIGNDOCS_BASE_URL — esta última apontando para api-hml.signdocs.com.br em desenvolvimento e para produção via variável, nunca hardcoded. Jamais coloque o client_secret no settings.py versionado.

Devo processar o webhook de forma síncrona na view?

Só o essencial. A regra dos webhooks é responder 200 rápido: verifique o HMAC, registre a idempotência pelo id do evento, atualize o status no banco — e delegue o trabalho pesado (baixar o PDF assinado e o .p7m, gerar notificações, chamadas a outros sistemas) para uma fila. No ecossistema Django, Celery é o caminho natural; django-q ou um cron com management command também servem para volumes menores. Se a view demorar demais, o retry da SignDocs reenvia o evento — e sem idempotência você processa duas vezes.

Como modelo os registros de assinatura no banco do Django?

Um model dedicado com os identificadores da API e o estado local: session_id e transaction_id (únicos, indexados), referência ao seu objeto de domínio (contrato, matrícula, pedido), status, o event_id do último webhook processado (para idempotência) e timestamps. Guarde também o caminho do PDF assinado e do pacote de evidências depois do download. Com transaction_id único no model, o processamento do webhook vira um update atômico — e o admin do Django lhe dá de graça uma tela de auditoria dos envios.

Como testo a integração Django antes de produção?

Três camadas: testes de unidade com o cliente mockado (a suíte do Django roda rápido e valida a sua lógica); testes contra o sandbox api-hml.signdocs.com.br com credenciais de homologação (valida o contrato real — lembre do TTL de 7 dias: cada teste cria o que consome); e, para o webhook, o endpoint POST /v1/webhooks/{webhookId}/test, que envia um payload assinado de verdade ao seu endpoint — em desenvolvimento local, exponha o runserver com um túnel. O guia completo de estratégia está em como testar do sandbox ao CI.

Do pip install à primeira assinatura, ainda hoje

Credenciais de homologação gratuitas, SDK Python oficial com OAuth2 automático e webhook de teste sob demanda — a integração Django completa no sandbox, sem cartão.

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