Adicione assinatura eletrônica com validade jurídica brasileira (MP 2.200-2, ICP-Brasil, LGPD) a um SaaS gerado pelo Lovable, usando Supabase Edge Functions como backend proxy. Guia orientado a prompts: cada seção mostra o texto para colar no chat do Lovable, e o código gerado fica colapsado como referência.
Atalho de 10 minutos. Cole o mega-prompt do starter em um projeto Lovable em branco — ele scaffolda a tabela, as duas Edge Functions e os componentes React de uma vez.
Demo ao vivo: sign-docs-brasil.lovable.app
É o template deste guia rodando em ambiente HML. Use seu próprio email, suba qualquer PDF descartável, complete uma assinatura real e veja o status atualizar em tempo real. CPF de teste: 111.444.777-35. Para remixar este template, clique no badge "Made with Lovable" no rodapé do app.
| Item | Onde obter |
|---|---|
| Projeto Lovable ativo | lovable.dev (Free tier OK; Pro $5/mês recomendado para iterar com segurança) |
| Backend Supabase | Recomendado: habilitar Lovable Cloud quando o Lovable oferecer mid-fluxo (auto-deploy de migrations e Edge Functions). Alternativa avançada: conectar Supabase externo via Connectors no dashboard do workspace (deploy manual via SQL Editor + Supabase CLI). |
| Credenciais SignDocs HML | Self-service via wizard de plano (PJ obrigatório, ~3 min). Veja o fluxo detalhado de 9 passos abaixo. Resumo: Perfil → Gerenciar Plano → wizard até "Plano Enterprise" → Receber Orçamento → API Dashboard → Ativar credenciais HML → modal mostra tenantId/clientId/clientSecret uma única vez. |
| SDK TypeScript | @signdocs-brasil/api (npm, v1.3.0+) — usado dentro das Edge Functions |
| OpenAPI spec (opcional) | openapi.yaml — cole na raiz do projeto Lovable antes de começar |
Este guia usa o fluxo OAuth2 client_credentials (server-to-server). Não existe variante "só frontend" — o client_secret nunca pode ir para o browser. Por isso toda chamada ao SignDocs passa por uma Edge Function do Supabase.
Quando o Lovable oferece "Enable Cloud" durante o setup, ele avisa: "This can't be undone once enabled". É uma porta unidirecional para esse projeto específico. Se mudar de ideia depois, precisa abandonar o projeto Lovable e começar outro. Para um template remixável (ou qualquer SaaS sério), Cloud é o caminho certo — auto-deploy é o que viabiliza a UX de "remix → funciona".
Não tem cota nem cobrança em homologação. Use à vontade para integrar e testar. Mas atenção: todos os documentos, sessões e evidências em HML são apagados em até 7 dias automaticamente — é ambiente efêmero, por design. Use apenas PDFs descartáveis, CPFs fictícios e emails de teste. Nunca envie contratos reais ou PII de clientes para HML.
O Supabase Connector saiu do toolbar do projeto e foi para o dashboard (sidebar lateral → "Connectors"). O ícone de raio que ficava no editor agora é só upgrade/billing. Essa mudança só afeta quem usa external Supabase (não Cloud).
Este guia assume um projeto em branco. Se você já tem código, auth, rotas e componentes em produção, cole o preamble de projeto existente ANTES dos prompts deste guia para evitar que o Lovable sobrescreva sua autenticação ou rotas.
Fluxo passo a passo (~3 min):
tenantIdclientIdclientSecret ⚠️ não mostrado de novoclientSecret, é necessário reprovisionar (rotaciona o secret; clientId continua o mesmo, mas o secret antigo invalida).
Se sua demanda real é de testes (não 200 docs/mês de fato), seja honesto na descrição do passo 4 — o time SignDocs não vai cobrar você por algo que não está usando. A flag Enterprise é apenas para destravar o acesso à API; cutover comercial para produção é conversado depois, quando o volume justificar.
Quatro segredos precisam existir antes de qualquer função rodar. Use o painel do Supabase (recomendado para iniciantes) ou a CLI.
| Chave | Valor (HML) |
|---|---|
SIGNDOCS_CLIENT_ID | o client_id gerado em app.signdocs.com.br (self-service) |
SIGNDOCS_CLIENT_SECRET | o client_secret gerado em app.signdocs.com.br (mostrado uma única vez ao gerar) |
SIGNDOCS_BASE_URL | https://api-hml.signdocs.com.br |
SIGNDOCS_WEBHOOK_SECRET | (deixe vazio — preenchido na seção 4) |
A URL de HML usa hífen: api-hml.signdocs.com.br. Não é api.hml.signdocs.com.br. Se copiou errado, as chamadas OAuth2 retornam timeout ou 404 silenciosos.
supabase secrets set SIGNDOCS_CLIENT_ID="<seu_client_id>"
supabase secrets set SIGNDOCS_CLIENT_SECRET="<seu_client_secret>"
supabase secrets set SIGNDOCS_BASE_URL="https://api-hml.signdocs.com.br"
envelope_statusEsta tabela é o espelho local do status de cada sessão de assinatura. É atualizada pelo webhook (seção 4) e lida em tempo real pelo componente React (seção 5).
Crie uma tabela Postgres chamada "envelope_status" no Supabase com estas colunas:
- session_id text PRIMARY KEY
- transaction_id text
- user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE
- status text NOT NULL DEFAULT 'PENDING'
- evidence_id text
- updated_at timestamptz NOT NULL DEFAULT now()
Habilite Row Level Security. Adicione uma policy SELECT chamada
"owners read their sessions" com USING (auth.uid() = user_id).
Inclua a tabela na publicação supabase_realtime para que o frontend receba
updates em tempo real quando o webhook mudar o status.
No painel Supabase → Database → Tables, a tabela envelope_status deve aparecer com ícone de cadeado (RLS ativo). Em Database → Replication → supabase_realtime, a tabela deve estar marcada.
A policy SELECT é para o frontend (usuários logados lendo o próprio status). A Edge Function de webhook usa a service role key e bypassa RLS — por isso o webhook consegue fazer UPDATE mesmo sem policy de escrita. Não crie uma policy de UPDATE — ela seria confusa e inútil.
create table public.envelope_status (
session_id text primary key,
transaction_id text,
user_id uuid references auth.users(id) on delete cascade,
status text not null default 'PENDING',
evidence_id text,
updated_at timestamptz not null default now()
);
alter table public.envelope_status enable row level security;
create policy "owners read their sessions"
on public.envelope_status for select
using (auth.uid() = user_id);
-- Habilitar Realtime:
-- Database → Replication → Source → supabase_realtime → marque envelope_status
create-signing-sessionNúcleo da integração: recebe dados do signatário, chama o SignDocs, grava o status inicial no Supabase e devolve o link de assinatura para o frontend.
Crie uma Supabase Edge Function chamada "create-signing-session".
OBJETIVO: receber dados de um signatário e criar uma sessão de assinatura
no SignDocs Brasil.
ENTRADA (JSON no body): signerName, signerEmail, signerCpf (OBRIGATÓRIO,
11 dígitos), pdfBase64, filename (opcional), returnUrl.
⚠️ CPF é obrigatório — SignDocs rejeita sessões sem CPF. Valide no
backend (400 se vazio/menos de 11 dígitos) e também no frontend.
SECRETS lidos de Deno.env: SIGNDOCS_CLIENT_ID, SIGNDOCS_CLIENT_SECRET,
SIGNDOCS_BASE_URL, SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
PASSO 1 — Autenticar o chamador: extrair o Bearer JWT do header
Authorization e validar via supabase.auth.getUser(). Se inválido, 401.
PASSO 2 — Criar a sessão via SDK "@signdocs-brasil/api@1.3.0":
- purpose: "DOCUMENT_SIGNATURE"
- policy: { profile: "CLICK_ONLY" }
- signer: { name, email, cpf, userExternalId: userData.user.id }
- document: { content: pdfBase64, filename: filename ?? "contrato.pdf" }
- returnUrl, locale: "pt-BR"
PASSO 3 — Montar signingUrl = session.url + "?cs=" + clientSecret
(URL-encoded). OBRIGATÓRIO — sem o ?cs a página retorna 401.
PASSO 4 — Upsert em envelope_status usando session.sessionId (NÃO
session.id — esse campo é undefined no retorno do SDK):
{ session_id: session.sessionId, transaction_id: session.transactionId,
user_id: userData.user.id, status: "PENDING" }.
SAÍDA: JSON { sessionId: session.sessionId, signingUrl, expiresAt }.
Aceitar apenas POST. Outros métodos: 405.
⚠️ returnUrl LIMPO: passe exatamente `${window.location.origin}/assinado`.
Não adicione query params nem placeholders como "__SESSION_ID__" —
SignDocs anexa ?session_id=ss_... automaticamente no redirect de
sucesso. Manipulação manual corrompe o query param.
Lovable cria supabase/functions/create-signing-session/index.ts e faz o deploy. No painel Supabase → Edge Functions, a função deve aparecer como Active.
?cs=<clientSecret> esquecido. O SDK devolve session.url e session.clientSecret separados. Sem o ?cs a página retorna 401. Monte signingUrl com ?cs=${encodeURIComponent(session.clientSecret)}.session.id em vez de session.sessionId. O SDK retorna session.sessionId (formato ss_...); session.id vem undefined. Se o upsert usar session.id, a linha fica com session_id = null e viola o NOT NULL constraint do PK.--no-verify-jwt por engano. Esta função precisa verificar JWT (é chamada pelo seu app logado). Só signdocs-webhook e get-signing-status usam --no-verify-jwt.returnUrl. Não adicione ?session_id=__SESSION_ID__ expecting the frontend a substituir depois — não tem como. Passe a URL limpa; SignDocs anexa automaticamente.SUPABASE_SERVICE_ROLE_KEY em qualquer arquivo fora de supabase/functions/, pare e peça correção — esse segredo nunca vai ao frontend."A função create-signing-session está retornando 401 na página de assinatura. Verifique que a resposta combina session.url com '?cs=' + encodeURIComponent(session.clientSecret) antes de devolver signingUrl."
"Confira se a função create-signing-session está validando o Bearer JWT do Supabase Auth antes de chamar o SDK SignDocs."
// supabase/functions/create-signing-session/index.ts
import { SignDocsBrasilClient } from "npm:@signdocs-brasil/api@1.3.0";
import { createClient } from "npm:@supabase/supabase-js@2";
const signdocs = new SignDocsBrasilClient({
clientId: Deno.env.get("SIGNDOCS_CLIENT_ID")!,
clientSecret: Deno.env.get("SIGNDOCS_CLIENT_SECRET")!,
baseUrl: Deno.env.get("SIGNDOCS_BASE_URL")!,
});
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const authHeader = req.headers.get("Authorization") ?? "";
const { data: userData, error: userErr } = await supabase.auth.getUser(
authHeader.replace("Bearer ", ""),
);
if (userErr || !userData.user) return new Response("Unauthorized", { status: 401 });
const { signerName, signerEmail, signerCpf, pdfBase64, filename, returnUrl } = await req.json();
// Valida CPF — obrigatório (SignDocs rejeita sessões sem CPF)
const cpfDigits = (signerCpf ?? "").replace(/\D/g, "");
if (cpfDigits.length !== 11) {
return Response.json({ error: "CPF obrigatório (11 dígitos)" }, { status: 400 });
}
// Valida returnUrl — rejeita placeholders que podem ter vazado do frontend
if (returnUrl?.includes("__SESSION_ID__")) {
return Response.json({ error: "returnUrl não deve conter placeholders" }, { status: 400 });
}
const session = await signdocs.signingSessions.create({
purpose: "DOCUMENT_SIGNATURE",
policy: { profile: "CLICK_ONLY" },
signer: {
name: signerName,
email: signerEmail,
cpf: cpfDigits,
userExternalId: userData.user.id,
},
document: { content: pdfBase64, filename: filename ?? "contrato.pdf" },
returnUrl,
locale: "pt-BR",
});
const signingUrl = `${session.url}?cs=${encodeURIComponent(session.clientSecret)}`;
await supabase.from("envelope_status").upsert({
session_id: session.sessionId,
transaction_id: session.transactionId,
user_id: userData.user.id,
status: "PENDING",
});
return Response.json({
sessionId: session.sessionId,
signingUrl,
expiresAt: session.expiresAt,
});
});
O SDK faz cache do Bearer JWT internamente (TTL ~3600s). Múltiplas chamadas dentro do mesmo container reutilizam o token — uma chamada a /oauth2/token por hora, não por sessão.
signdocs-webhookEsta função recebe notificações do SignDocs (assinatura concluída, cancelada, expirada) e atualiza a tabela envelope_status. Ela é a única fonte da verdade para o status final. O frontend escuta a mudança via Realtime — com polling leve de 3s como fallback (ver seção 5).
Crie uma Supabase Edge Function chamada "signdocs-webhook".
OBJETIVO: receber webhooks do SignDocs Brasil, validar assinatura HMAC,
e atualizar a tabela envelope_status.
SECRETS lidos de Deno.env: SIGNDOCS_WEBHOOK_SECRET, SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY.
PASSO 1 — Aceitar apenas POST. Outros métodos: 405.
⚠️ CRÍTICO — Deno NÃO tem Buffer global. Use um helper hexToBytes()
para converter strings hex para Uint8Array antes de passar ao
timingSafeEqual. NUNCA use Buffer.from() — crasha com ReferenceError
em runtime e você perde todos os webhooks.
Helper obrigatório (no topo do arquivo):
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
PASSO 2 — Validar a assinatura HMAC:
- Ler os headers x-signdocs-signature e x-signdocs-timestamp.
- Rejeitar (401) se o timestamp estiver fora de uma janela de 300s.
- Computar HMAC-SHA256 sobre "${timestamp}.${body}" usando
SIGNDOCS_WEBHOOK_SECRET.
- Converter sig e expected para Uint8Array via hexToBytes() e comparar
com timingSafeEqual. Se divergir, 401.
PASSO 3 — Logar o payload recebido (eventType, transactionId, data) e
o número de linhas afetadas pelo UPDATE — fundamental para debug remoto.
PASSO 4 — Parsear body JSON. SignDocs emite eventos com prefixo
TRANSACTION.* (SIGNING_SESSION.* não existe na API; inclua no switch
só por compatibilidade futura):
- TRANSACTION.COMPLETED: status='COMPLETED', evidence_id=event.data?.evidenceId
- TRANSACTION.CANCELLED: status='CANCELLED'
- TRANSACTION.EXPIRED: status='EXPIRED'
- TRANSACTION.FAILED: status='FAILED'
WHERE transaction_id = event.transactionId OR session_id = event.data?.sessionId.
(event.data.sessionId frequentemente vem undefined; o UPDATE por
transaction_id é o caminho principal.)
PASSO 5 — Retornar 200 "ok".
Usar createClient do @supabase/supabase-js com SERVICE_ROLE_KEY
(bypassa RLS para fazer UPDATE).
A URL fica em https://<project-ref>.supabase.co/functions/v1/signdocs-webhook. Você vai colar ela no painel SignDocs HML para registrar o webhook (próximo bloco).
--no-verify-jwtWebhooks vêm dos servidores do SignDocs, não de usuários Supabase autenticados. Se o Lovable deployar com JWT verification ligado, todo webhook vai bater em 401 e seu status nunca sai de PENDING.
Se deployou com a flag errada: "Redeploy a função signdocs-webhook com --no-verify-jwt para que webhooks externos sem JWT possam chamar."
TRANSACTION.COMPLETED, TRANSACTION.CANCELLED, TRANSACTION.EXPIRED, TRANSACTION.FAILED (a API do SignDocs emite eventos com prefixo TRANSACTION.*; SIGNING_SESSION.* não existe).secret — copie imediatamente, ele só aparece uma vez.SIGNDOCS_WEBHOOK_SECRET). NÃO cole via chat do Lovable nem em qualquer AI assistant — o secret fica no histórico da conversa e vira vetor de ataque para um template público. Se acidentalmente passar por chat, rotacione: delete o webhook em SignDocs HML, registre de novo, atualize com o novo secret pelo painel Supabase.O SignDocs HML rejeita (CloudFront 403) webhooks apontando para localhost, IPs privados ou hostnames sem DNS público. Para testar localmente, use um tunnel (ngrok, cloudflared) ou um webhook.site. Projetos Supabase hosteados não têm esse problema — a URL já é pública.
"Os webhooks do SignDocs estão retornando 401. Verifique se a validação HMAC está concatenando `${timestamp}.${body}` (com ponto) antes de passar ao createHmac, e se está usando timingSafeEqual para comparar."
"A função signdocs-webhook está sendo chamada mas o status na tabela envelope_status não muda. Confira se o UPDATE usa service role key (bypass de RLS) e se o WHERE cobre tanto event.data.sessionId quanto event.transactionId."
// supabase/functions/signdocs-webhook/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
import { createHmac, timingSafeEqual } from "node:crypto";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const webhookSecret = Deno.env.get("SIGNDOCS_WEBHOOK_SECRET")!;
const TOLERANCE_SEC = 300;
// Deno não tem Buffer global — converte hex → Uint8Array manualmente
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
function verify(body: string, sig: string, ts: string): boolean {
const t = parseInt(ts, 10);
if (isNaN(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;
const expected = createHmac("sha256", webhookSecret).update(`${t}.${body}`).digest("hex");
if (sig.length !== expected.length) return false;
return timingSafeEqual(hexToBytes(sig), hexToBytes(expected));
}
const statusMap: Record<string, string> = {
"TRANSACTION.COMPLETED": "COMPLETED",
"SIGNING_SESSION.COMPLETED": "COMPLETED",
"TRANSACTION.CANCELLED": "CANCELLED",
"SIGNING_SESSION.CANCELLED": "CANCELLED",
"TRANSACTION.EXPIRED": "EXPIRED",
"SIGNING_SESSION.EXPIRED": "EXPIRED",
"TRANSACTION.FAILED": "FAILED",
};
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const body = await req.text();
const sig = req.headers.get("x-signdocs-signature") ?? "";
const ts = req.headers.get("x-signdocs-timestamp") ?? "";
if (!verify(body, sig, ts)) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(body);
console.log("signdocs-webhook event:", {
eventType: event.eventType, transactionId: event.transactionId,
dataSessionId: event.data?.sessionId,
});
const newStatus = statusMap[event.eventType];
if (!newStatus) return new Response("ok", { status: 200 });
const patch: Record<string, unknown> = {
status: newStatus, updated_at: new Date().toISOString(),
};
if (newStatus === "COMPLETED" && event.data?.evidenceId) {
patch.evidence_id = event.data.evidenceId;
}
// Match por transaction_id (caminho principal) OU session_id (fallback).
// SignDocs sempre manda event.transactionId; event.data.sessionId pode vir undefined.
const { data } = await supabase.from("envelope_status")
.update(patch)
.or(
`transaction_id.eq.${event.transactionId}` +
(event.data?.sessionId ? `,session_id.eq.${event.data.sessionId}` : ""),
)
.select();
console.log("update envelope_status rows:", data?.length ?? 0);
return new Response("ok", { status: 200 });
});
get-signing-status (para polling)Endpoint auxiliar consultado pelo frontend como fallback de polling quando o canal Realtime não propaga o UPDATE. Sem ele, a tela /assinado pode ficar presa em PENDING quando o usuário é redirecionado de volta do SignDocs mais rápido que o canal Realtime consegue subscribe.
Crie uma Supabase Edge Function chamada "get-signing-status".
Runtime: Deno. Deploy COM --no-verify-jwt (endpoint público — segurança
vem do session_id não ser adivinhável e o retorno ser mínimo).
ENTRADA (POST JSON): { sessionId: string }
FLUXO:
- Aceitar apenas POST. 405 para outros métodos.
- Validar que sessionId começa com "ss_". Se não, 400.
- SELECT status, evidence_id, updated_at FROM envelope_status
WHERE session_id = sessionId LIMIT 1.
- Retornar { status, evidenceId, updatedAt }.
Se não achar, { status: "NOT_FOUND" }.
Configure em supabase/config.toml: verify_jwt = false para esta função.
// supabase/functions/get-signing-status/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const { sessionId } = await req.json();
if (typeof sessionId !== "string" || !sessionId.startsWith("ss_")) {
return Response.json({ error: "sessionId inválido" }, { status: 400 });
}
const { data, error } = await supabase.from("envelope_status")
.select("status, evidence_id, updated_at")
.eq("session_id", sessionId).maybeSingle();
if (error) return Response.json({ error: error.message }, { status: 500 });
if (!data) return Response.json({ status: "NOT_FOUND" });
return Response.json({
status: data.status,
evidenceId: data.evidence_id,
updatedAt: data.updated_at,
});
});
Opção mais simples e recomendada por padrão. O usuário sai do app para assinar em sign-hml.signdocs.com.br e volta pelo returnUrl. Três componentes: um hook, um botão e um badge de status com Realtime.
Crie 3 arquivos React/TypeScript para o fluxo de assinatura por redirect:
ARQUIVO 1: src/hooks/useSignDocs.ts
- Exporta useSignDocs() com async startSigning(input).
- input: { signerName, signerEmail, signerCpf?, pdfBase64, filename? }.
- Chama supabase.functions.invoke("create-signing-session", {
body: { ...input, returnUrl: `${window.location.origin}/assinado` }
}).
- Faz window.location.href = data.signingUrl ao receber a resposta.
ARQUIVO 2: src/components/SignButton.tsx
- Props: pdfBase64, signer ({ name, email, cpf? }).
- Button (shadcn/ui) "Enviar para assinatura" → chama startSigning.
ARQUIVO 3: src/components/SigningStatus.tsx
- Prop: sessionId.
- ARQUITETURA DE 3 CAMADAS para garantir convergência do status:
1. Fetch inicial via supabase.functions.invoke("get-signing-status")
2. Canal Realtime com filter session_id=eq.${sessionId} escutando UPDATE
3. No callback .subscribe(s => if s==="SUBSCRIBED") refetch get-signing-status
(cobre o caso em que o UPDATE aconteceu antes do subscribe)
4. setInterval de 3000ms fazendo refetch enquanto status === "PENDING"
(fallback total quando Realtime falha silenciosamente)
- Badge (shadcn/ui) variant="default" se COMPLETED, "secondary" senão.
- Cleanup: supabase.removeChannel + clearInterval.
Crie também a edge function get-signing-status (ver seção 4.5 do guia) —
ela é chamada pelo SigningStatus para polling e fetch inicial.
Crie também a rota /assinado com uma tela de sucesso simples.
sign-hml.signdocs.com.br. Complete a assinatura./assinado. O <SigningStatus /> deve virar COMPLETED em menos de 2 segundos — sem precisar dar F5.95% das vezes é porque a tabela não está na publicação supabase_realtime (veja seção 2), ou porque o filtro do canal usa coluna errada. Peça ao Lovable: "Confira que src/components/SigningStatus.tsx está usando filter: `session_id=eq.${sessionId}` no canal Realtime."
// src/hooks/useSignDocs.ts
import { supabase } from "@/integrations/supabase/client";
export function useSignDocs() {
async function startSigning(input: {
signerName: string;
signerEmail: string;
signerCpf: string; // obrigatório — SignDocs rejeita sem CPF
pdfBase64: string;
filename?: string;
}) {
const cpfDigits = input.signerCpf.replace(/\D/g, "");
if (cpfDigits.length !== 11) throw new Error("CPF obrigatório (11 dígitos)");
const { data, error } = await supabase.functions.invoke("create-signing-session", {
body: {
...input,
signerCpf: cpfDigits,
// returnUrl LIMPO — SignDocs adiciona ?session_id= automaticamente.
// Nunca inclua placeholders; a substituição não acontece.
returnUrl: `${window.location.origin}/assinado`,
},
});
if (error) throw error;
window.location.href = data.signingUrl;
}
return { startSigning };
}
// src/components/SignButton.tsx
import { Button } from "@/components/ui/button";
import { useSignDocs } from "@/hooks/useSignDocs";
export function SignButton({ pdfBase64, signer }: {
pdfBase64: string;
signer: { name: string; email: string; cpf: string }; // cpf obrigatório
}) {
const { startSigning } = useSignDocs();
return (
<Button onClick={() => startSigning({
signerName: signer.name,
signerEmail: signer.email,
signerCpf: signer.cpf,
pdfBase64,
})}>Enviar para assinatura</Button>
);
}
// src/components/SigningStatus.tsx
import { useEffect, useRef, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Badge } from "@/components/ui/badge";
// 3 camadas: Realtime (caminho feliz) + refetch no SUBSCRIBED
// (cobre UPDATE antes do subscribe) + polling 3s (fallback total).
async function fetchStatus(sessionId: string): Promise<string> {
const { data } = await supabase.functions.invoke("get-signing-status",
{ body: { sessionId } });
return data?.status ?? "PENDING";
}
export function SigningStatus({ sessionId }: { sessionId: string }) {
const [status, setStatus] = useState("PENDING");
const statusRef = useRef(status); statusRef.current = status;
useEffect(() => {
let cancelled = false;
const update = (s: string) => { if (!cancelled) setStatus(s); };
fetchStatus(sessionId).then(update);
const channel = supabase.channel(`envelope:${sessionId}`)
.on("postgres_changes",
{ event: "UPDATE", schema: "public", table: "envelope_status",
filter: `session_id=eq.${sessionId}` },
(p) => update((p.new as { status: string }).status),
)
.subscribe((s) => {
if (s === "SUBSCRIBED") fetchStatus(sessionId).then(update);
});
const poll = setInterval(() => {
if (statusRef.current === "PENDING") fetchStatus(sessionId).then(update);
}, 3000);
return () => { cancelled = true; supabase.removeChannel(channel); clearInterval(poll); };
}, [sessionId]);
return (
<Badge variant={status === "COMPLETED" ? "default" : "secondary"}>
{status}
</Badge>
);
}
Realtime é o caminho feliz (latência <500ms). O refetch no SUBSCRIBED cobre a condição de corrida quando o UPDATE acontece antes do subscribe completar (acontece quando o SignDocs redireciona de volta mais rápido que o handshake do canal). O polling de 3s é o safety net final — sem ele, a UI pode ficar presa em PENDING mesmo com o banco já atualizado.
Quando o produto exige assinatura sem sair do app. Mais vistoso, mas com mais arestas — só faça esta seção se o redirect (seção 5) não atender.
Crie um componente React em src/components/SignEmbed.tsx que abre a
assinatura em um modal com iframe, em vez de redirecionar.
PROPS: pdfBase64, signer ({ name, email, cpf? }).
ESTADO: signingUrl, sessionId.
COMPORTAMENTO:
- Botão "Assinar agora". Ao clicar, chama
supabase.functions.invoke("create-signing-session", { body: {...} })
com returnUrl apontando para /embed-done.
- Depois que recebe signingUrl, abre um Dialog (shadcn/ui) contendo
um <iframe src={signingUrl}> com className="w-full h-full border-0"
e allow="camera; microphone; geolocation".
- Dialog é max-w-4xl e h-[80vh].
LISTENER postMessage:
- window.addEventListener("message", onMessage) no useEffect.
- Aceita apenas se e.origin.endsWith(".signdocs.com.br").
- Se type === "signdocs.session.completed" ou "...cancelled",
fecha o modal (setSigningUrl(null)).
- No cleanup, removeEventListener.
IMPORTANTE: postMessage é UX otimista (fechar modal sem delay).
O webhook continua sendo a ÚNICA fonte da verdade para COMPLETED.
frame-ancestors: sign-hml.signdocs.com.br permite iframe apenas de domínios whitelistados por tenant. Antes de ir para prod, peça à equipe SignDocs para liberar seu domínio.BIOMETRIC exigem allow="camera; microphone". Sem isso, a verificação facial falha silenciosamente.postMessage pode falhar por CSP, adblock ou bug do browser. Sempre confie no webhook para marcar COMPLETED.// src/components/SignEmbed.tsx
import { useEffect, useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { supabase } from "@/integrations/supabase/client";
export function SignEmbed({ pdfBase64, signer }: {
pdfBase64: string;
signer: { name: string; email: string; cpf?: string };
}) {
const [signingUrl, setSigningUrl] = useState<string | null>(null);
async function open() {
const { data, error } = await supabase.functions.invoke("create-signing-session", {
body: {
signerName: signer.name,
signerEmail: signer.email,
signerCpf: signer.cpf,
pdfBase64,
returnUrl: `${window.location.origin}/embed-done`,
},
});
if (error) throw error;
setSigningUrl(data.signingUrl);
}
useEffect(() => {
function onMessage(e: MessageEvent) {
if (!e.origin.endsWith(".signdocs.com.br")) return;
const { type } = e.data ?? {};
if (type === "signdocs.session.completed" || type === "signdocs.session.cancelled") {
setSigningUrl(null);
}
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
return (
<>
<button onClick={open}>Assinar agora</button>
<Dialog open={!!signingUrl} onOpenChange={(o) => !o && setSigningUrl(null)}>
<DialogContent className="max-w-4xl h-[80vh] p-0">
{signingUrl && (
<iframe src={signingUrl} className="w-full h-full border-0"
allow="camera; microphone; geolocation" />
)}
</DialogContent>
</Dialog>
</>
);
}
O preview do Lovable é suficiente para quase tudo. Você só precisa sair dele se for validar cenários de segurança.
111.444.777-35).COMPLETED em menos de 3s (Realtime imediato ou polling em até 3s).envelope_status: a linha deve ter evidence_id preenchido (formato ev_...).no-reply@mg.signdocs.com.br com confirmação e o ID da evidência. SignDocs envia automaticamente — não precisa construir esse fluxo no app.O evidence_id retornado é o identificador do pacote de evidência .p7m — bundle PKCS#7/CMS criptograficamente assinado contendo hash do documento, geolocalização, IP, timestamp, método de autenticação e (quando aplicável) certificado ICP-Brasil. Para um SaaS de produção:
evidence_id permanentemente (não fica em HML por mais de 7 dias).GET /v1/evidence/{evidenceId}/download autenticado com Bearer JWT."Envie um POST para a função signdocs-webhook com body JSON qualquer e headers x-signdocs-signature: invalid e x-signdocs-timestamp: <timestamp atual>. A função deve responder 401 sem tocar na tabela envelope_status."
"Abra duas abas do preview logadas como o mesmo usuário. Na primeira, inicie uma assinatura. Na segunda, renderize <SigningStatus sessionId={...} /> para o mesmo session_id. Após completar na primeira, a segunda deve virar COMPLETED sem F5."
Desregistre o webhook (DELETE /v1/webhooks/{id}) e refaça uma assinatura. O status deve ficar em PENDING mesmo depois de assinar. Se virar COMPLETED sem webhook, há regressão — peça ao Lovable para remover.
O código não muda — só os secrets e a URL base.
SIGNDOCS_BASE_URL para https://api.signdocs.com.br (sem -hml).SIGNDOCS_CLIENT_ID e SIGNDOCS_CLIENT_SECRET pelas credenciais de produção (enviadas depois da aprovação do tenant).secret.SIGNDOCS_WEBHOOK_SECRET com o novo valor.A URL de assinatura vem do próprio session.url retornado pelo SignDocs. Em HML vem como sign-hml.signdocs.com.br; em prod vem como sign.signdocs.com.br. Seu código só precisa não fazer suposição sobre o hostname — o que ele já não faz.
Os sintomas mais comuns, com o prompt exato para colar no chat do Lovable. Se o problema persistir depois do prompt, abra os logs da Edge Function correspondente.
| Sintoma | Peça ao Lovable |
|---|---|
401 invalid_client no /oauth2/token |
"O SDK SignDocs está retornando 401 invalid_client. Verifique se SIGNDOCS_CLIENT_ID e SIGNDOCS_CLIENT_SECRET no Supabase correspondem ao mesmo tenant, e se SIGNDOCS_BASE_URL usa hífen em HML (api-hml.signdocs.com.br)." |
Webhook recebe 403 do CloudFront no registro |
"A URL do webhook está sendo rejeitada com 403 CloudFront pelo SignDocs HML. Confirme que a função signdocs-webhook está deployada no Supabase hospedado (URL pública) e não aponta para localhost ou IP privado." |
| Edge Function do webhook sempre retorna 401 | "A função signdocs-webhook está respondendo 401 para todos os webhooks. O SIGNDOCS_WEBHOOK_SECRET armazenado é exatamente o valor retornado no campo 'secret' da resposta do POST /v1/webhooks? E a função foi re-deployada depois que o secret foi atualizado?" |
Assinatura completa mas status fica em PENDING |
"A assinatura está sendo concluída mas a tabela envelope_status continua com status PENDING. Abra os logs da função signdocs-webhook — ela está sendo invocada? Se sim, o UPDATE está falhando? Verifique se o WHERE cobre tanto event.data.sessionId quanto event.transactionId." |
| Realtime não atualiza o componente | "O componente SigningStatus não atualiza quando o status muda. Confirme (a) que envelope_status está incluída na publicação supabase_realtime, (b) que a RLS policy de SELECT permite ao usuário ler sua própria linha, e (c) que o filtro do canal é 'session_id=eq.${sessionId}'." |
| Iframe vazio / tela branca no embed | "O iframe do SignEmbed está ficando em branco. Abra o DevTools → Console e procure erros de X-Frame-Options ou frame-ancestors. Se aparecer, o domínio precisa ser liberado pela equipe SignDocs no CSP do tenant." |
session.url retornado, mas clicar dá 401 |
"O signingUrl está dando 401 quando aberto. Verifique se a função concatena session.url com '?cs=' + encodeURIComponent(session.clientSecret) antes de retornar. Sem o query param cs, o hosted signing rejeita." |
ReferenceError: Buffer is not defined nos logs do webhook |
"A função signdocs-webhook está crashando com 'Buffer is not defined'. Substitua Buffer.from(hex, 'utf8') por um helper hexToBytes(hex) que retorna Uint8Array, e passe esse Uint8Array para timingSafeEqual. Deno não tem Buffer global." |
URL /assinado tem dois parâmetros session_id |
"O returnUrl está vazando um placeholder __SESSION_ID__ na URL. Corrija a edge function create-signing-session e o hook useSignDocs para passar returnUrl limpo (apenas /assinado, sem query params) — SignDocs adiciona ?session_id= automaticamente no redirect." |
/assinado mostra PENDING mesmo com webhook já atualizado |
"A tabela envelope_status tem status=COMPLETED mas SigningStatus no /assinado fica preso em PENDING. Implemente a arquitetura de 3 camadas: (1) fetch inicial via get-signing-status, (2) refetch no evento SUBSCRIBED do canal Realtime, (3) polling de 3s enquanto status===PENDING." |
| Biometria trava em mobile no embed | "No mobile, o fluxo BIOMETRIC trava no iframe. Modifique SignEmbed para detectar mobile (user agent ou useMediaQuery) e cair no caminho redirect (window.location.href) em vez de abrir o Dialog com iframe." |
supabase functions logs create-signing-session --tail
supabase functions logs signdocs-webhook --tail
O template deste guia usa o padrão single-signer (uma sessão = um signatário). Para casos onde múltiplas partes precisam assinar o mesmo documento (ex: contrato paciente + psicólogo, contrato locador + locatário, NDA empresa + funcionário), você evolui para envelopes sem reescrever o que já está em produção.
Se o caso é um único signatário por documento, fique no template atual — envelopes adicionam complexidade desnecessária.
Tudo que você já paga o custo de aprender e debugar permanece exatamente o mesmo:
?cs=<clientSecret> na URL de assinatura — cada signatário tem sua própria URL com ?cs=SIGNDOCS_*) — inalteradosupabase.auth.getUser(), userExternalId)returnUrl rules, hexToBytes para Deno cryptoEm vez de uma única linha por sessão, você precisa de um envelope com N signatários. Duas opções:
alter table public.envelope_status
add column signer_index int default 0,
add column signer_email text,
add column signer_status text default 'PENDING';
-- Cada signatário vira uma linha agrupada por transaction_id.
-- Linha "envelope agregadora" (status do envelope inteiro) usa
-- signer_index = -1 ou similar como sentinela.
create table public.envelope_signers (
envelope_id text references envelope_status(transaction_id) on delete cascade,
signer_id text primary key,
signer_email text not null,
signer_name text not null,
signing_url text, -- url + ?cs= por signer
status text not null default 'PENDING',
signed_at timestamptz,
evidence_id text,
signer_order int -- para fluxo sequencial
);
alter table public.envelope_signers enable row level security;
create policy "owners read their envelope signers"
on public.envelope_signers for select
using (auth.uid() = (
select user_id from envelope_status where transaction_id = envelope_id
));
alter publication supabase_realtime add table public.envelope_signers;
Recomendo Opção B para casos com 3+ signatários ou quando você prevê adicionar metadados por signatário (ordem, papel, status individual).
create-signing-session vira create-envelope// Em vez de:
// const session = await signdocs.signingSessions.create({ signer: {...}, ... })
// Faça:
const envelope = await signdocs.envelopes.create({
purpose: "DOCUMENT_SIGNATURE",
policy: { profile: "OTP_EMAIL" }, // ou CLICK_ONLY, BIOMETRIC, CERTIFICATE
signers: [
{ name: "Maria Silva", email: "maria@...", cpf: "...", order: 1 },
{ name: "João Santos", email: "joao@...", cpf: "...", order: 2 },
],
signingMode: "SEQUENTIAL", // ou PARALLEL
document: { content: pdfBase64, filename },
returnUrl,
locale: "pt-BR",
});
// envelope.signers[i].signingUrl + "?cs=" + clientSecret é a URL única
// de cada signatário. Você decide como entregar (email, WhatsApp,
// in-app link).
for (const s of envelope.signers) {
await supabase.from("envelope_signers").insert({
envelope_id: envelope.transactionId,
signer_id: s.signerId,
signer_email: s.email,
signer_name: s.name,
signing_url: `${s.signingUrl}?cs=${encodeURIComponent(s.clientSecret)}`,
signer_order: s.order,
});
}
Endpoint exato + payload completo: ver Envelopes Multi-Signatário e Receitas (Copy-Paste).
STEP.*Os eventos por signatário vêm como STEP.*; o evento de envelope-finalizado continua sendo TRANSACTION.COMPLETED:
// Adicione ao statusMap:
const statusMap: Record<string, string> = {
// ... os existentes (TRANSACTION.*) ...
"STEP.COMPLETED": "STEP_COMPLETED", // signatário individual assinou
"STEP.CANCELLED": "STEP_CANCELLED",
"STEP.FAILED": "STEP_FAILED",
};
// E adicione um branch antes do UPDATE em envelope_status:
if (event.eventType.startsWith("STEP.")) {
await supabase.from("envelope_signers").update({
status: newStatus.replace("STEP_", ""),
signed_at: newStatus === "STEP_COMPLETED" ? new Date().toISOString() : null,
evidence_id: event.data?.evidenceId ?? null,
}).eq("signer_id", event.data?.signerId);
return new Response("ok", { status: 200 });
}
// TRANSACTION.COMPLETED continua marcando o envelope inteiro como
// finalizado (todos os signatários completaram).
Hook: useSignDocs vira useSignDocsEnvelope que retorna uma lista de signing URLs em vez de redirecionar. Você decide a estratégia de entrega:
Componente de status: SigningStatus mostra status por signatário + um rollup do envelope:
┌─────────────────────────────────────┐
│ Maria Silva → ✅ Assinado │
│ João Santos → ⏳ Aguardando │
├─────────────────────────────────────┤
│ Envelope: 1 de 2 assinados │
└─────────────────────────────────────┘
O canal Realtime se inscreve na nova tabela envelope_signers filtrando por envelope_id; o rollup é um SELECT count(*) WHERE status = 'COMPLETED'. Polling fallback de 3s segue idêntico ao single-signer.
create-envelope em paralelo com create-signing-session. Os dois flows coexistem; cada tipo de documento decide qual chamar.Não há big-bang — os dois flows coexistem sem conflito. A tabela envelope_status já tem transaction_id que serve como envelope ID natural na migração.
Em caso de dúvidas na integração HML, abra ticket em suporte@signdocs.com.br incluindo client_id (nunca client_secret) e o webhookId relevante.