Guias de Desenvolvimento

Signing Sessions

Guias dos SDKs

Integração com Lovable

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.

Sumário

  1. Pré-requisitos
  2. Armazenar segredos no Supabase
  3. Schema Postgres: tabela envelope_status
  4. Edge Function: create-signing-session
  5. Edge Function: signdocs-webhook
  6. Edge Function: get-signing-status (polling fallback)
  7. React UI — caminho redirect
  8. React UI — caminho embed (iframe)
  9. Testar ponta-a-ponta em HML
  10. Cutover para produção
  11. Troubleshooting (estilo "pergunte ao Lovable")
  12. Apêndice A — Evolução para multi-signer (envelopes)

🎬 Veja funcionando antes de começar

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.

0. Pré-requisitos

ItemOnde obter
Projeto Lovable ativolovable.dev (Free tier OK; Pro $5/mês recomendado para iterar com segurança)
Backend SupabaseRecomendado: 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 HMLSelf-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
⚠️ Restrição arquitetural

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.

⚠️ Lovable Cloud é one-way

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

✅ HML é gratuito e sem limite de testes

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.

📝 Mudança de UI no Lovable (2026): Connectors movidos

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

⚙️ Para projeto Lovable já existente?

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.

🔑 Como obter credenciais HML (self-service via wizard de plano)

Pré-condições obrigatórias

Fluxo passo a passo (~3 min):

  1. Crie conta grátis em app.signdocs.com.br. No cadastro, marque Pessoa Jurídica e preencha CNPJ.
  2. No dashboard, abra o menu de Perfil (avatar) → Gerenciar Plano.
  3. No wizard de plano, responda nesta sequência para chegar ao Enterprise:
    • "Quer o plano grátis (≤5 docs/mês)?" → Não
    • "Precisa de mais de 4 usuários?" → Sim
    • "Precisa de mais de 80 docs/mês?" → Sim
    • "Precisa de mais de 200 docs/mês?" → Sim
  4. Você cai no card "Plano Enterprise — Orçamento personalizado". No campo "Descreva suas necessidades", explique o caso de uso. Exemplo: "Integração SignDocs Brasil em SaaS no Lovable com Supabase Edge Functions. Quero credenciais HML para integrar e testar antes de migrar para produção."
  5. Clique Receber Orçamento. Um email é enviado ao time SignDocs E sua conta é flagada como Enterprise imediatamente (sem espera, sem revisão manual). O email é só para acompanhamento de vendas.
  6. Volte para o Perfil. Agora aparece o botão Abrir API Dashboard (visível só para Enterprise). Clique.
  7. Na tela "Ativar credenciais HML", preencha:
    • Nome da empresa (obrigatório)
    • CNPJ (opcional mas recomendado para integrar com nota fiscal/cobrança depois)
    • Marque "Concordo com os termos de uso da API sandbox SignDocs"
  8. Clique Ativar credenciais HML.
  9. Um modal abre UMA ÚNICA VEZ com:
    • tenantId
    • clientId
    • clientSecret ⚠️ não mostrado de novo
    • Sandbox endpoint URL
    • Docs URL
    Copie todos os 4 imediatamente para um gerenciador de senhas. Se perder o clientSecret, é necessário reprovisionar (rotaciona o secret; clientId continua o mesmo, mas o secret antigo invalida).
💡 É só para teste de integração?

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.


1. Armazenar segredos no Supabase

Quatro segredos precisam existir antes de qualquer função rodar. Use o painel do Supabase (recomendado para iniciantes) ou a CLI.

Pelo painel (recomendado)

  1. Abra o projeto Supabase vinculado ao seu Lovable.
  2. Navegue em Edge Functions → Secrets.
  3. Clique Add new secret e crie as chaves a seguir, uma de cada vez.
ChaveValor (HML)
SIGNDOCS_CLIENT_IDo client_id gerado em app.signdocs.com.br (self-service)
SIGNDOCS_CLIENT_SECRETo client_secret gerado em app.signdocs.com.br (mostrado uma única vez ao gerar)
SIGNDOCS_BASE_URLhttps://api-hml.signdocs.com.br
SIGNDOCS_WEBHOOK_SECRET(deixe vazio — preenchido na seção 4)
⚠️ Erro nº1 em toda integração HML

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.

🛠️ Alternativa: via Supabase CLI
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"

2. Schema Postgres: tabela envelope_status

Esta 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).

📋 Prompt para colar no Lovable
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.
✅ O que verificar depois

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.

⚠️ Atenção ao policy

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.

🔍 SQL de referência (para comparar com o que o Lovable gerou)
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

3. Edge Function: create-signing-session

Nú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.

📋 Prompt para colar no Lovable
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.
✅ O que você deve ver depois

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.

⚠️ 5 coisas que o Lovable erra em ~30% das gerações (descobertas em teste real)
  1. ?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)}.
  2. Usar 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.
  3. Deploy com --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.
  4. Placeholder no 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.
  5. Service role key no cliente. Se aparecer SUPABASE_SERVICE_ROLE_KEY em qualquer arquivo fora de supabase/functions/, pare e peça correção — esse segredo nunca vai ao frontend.
🔧 Se algo der errado, peça ao Lovable

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

🔍 Código de referência (para comparar com o que o Lovable gerou)
// 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.


4. Edge Function: signdocs-webhook

Esta 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).

📋 Prompt para colar no Lovable
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).
✅ Depois do deploy, anote a URL pública

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

⚠️ Esta função precisa ser deployada com --no-verify-jwt

Webhooks 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."

Registrar o webhook no SignDocs HML

  1. Pegue a URL do webhook (passo anterior).
  2. No painel HML SignDocs → Webhooks → Novo, cole a URL.
  3. Marque os eventos: TRANSACTION.COMPLETED, TRANSACTION.CANCELLED, TRANSACTION.EXPIRED, TRANSACTION.FAILED (a API do SignDocs emite eventos com prefixo TRANSACTION.*; SIGNING_SESSION.* não existe).
  4. Ao salvar, a resposta mostra um campo secretcopie imediatamente, ele só aparece uma vez.
  5. Cole o secret diretamente no painel Supabase (Edge Functions → Secrets → editar 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.
  6. Redeploy a função para ela ler o novo secret (Lovable Cloud faz automático ao salvar).
⚠️ WAF HML bloqueia URLs locais

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.

🔧 Troubleshoot por prompt

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

🔍 Código de referência (para comparar com o que o Lovable gerou)
// 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 });
});

4.5. Edge Function: 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.

📋 Prompt para colar no Lovable
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.
🔍 Código de referência
// 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,
  });
});

5. React UI — caminho redirect

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.

📋 Prompt para colar no Lovable
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.
✅ Como testar no preview do Lovable
  1. Abra o preview.
  2. Faça login com um usuário Supabase.
  3. Clique Enviar para assinatura com um PDF de teste.
  4. Você é redirecionado para sign-hml.signdocs.com.br. Complete a assinatura.
  5. Volta automaticamente para /assinado. O <SigningStatus /> deve virar COMPLETED em menos de 2 segundos — sem precisar dar F5.
⚠️ Se o status não atualizar em tempo real

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

🔍 Código de referência — os 3 arquivos
// 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.


6. React UI — caminho embed (iframe)

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.

📋 Prompt para colar no Lovable
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.
⚠️ Requisitos operacionais do embed
🔍 Código de referência (SignEmbed.tsx)
// 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>
    </>
  );
}

7. Testar ponta-a-ponta em HML

O preview do Lovable é suficiente para quase tudo. Você só precisa sair dele se for validar cenários de segurança.

Roteiro feliz (preview do Lovable)

  1. Clique Preview no Lovable.
  2. Faça login com um usuário Supabase real (use um email que você consiga acessar — o magic link chega por email).
  3. Upload de um PDF de teste (qualquer contrato descartável, <10MB).
  4. Use seu próprio email como signatário e um CPF válido (ex: 111.444.777-35).
  5. Clique Enviar para assinatura → redirecionado para o hosted signing em HML.
  6. Complete o fluxo (click-only não pede nada além de confirmação).
  7. Retorne ao preview. O badge deve virar COMPLETED em menos de 3s (Realtime imediato ou polling em até 3s).
  8. Painel Supabase → envelope_status: a linha deve ter evidence_id preenchido (formato ev_...).
  9. Email automático: você também recebe um email de 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 que fazer com o evidence_id

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:

⚠️ Limites práticos para o teste

Validações de segurança (recomendadas antes de prod)

🔧 Peça ao Lovable para rodar estes testes

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

✅ Prova de que não há polling disfarçado

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.


8. Cutover para produção

O código não muda — só os secrets e a URL base.

  1. No painel Supabase → Secrets, atualize SIGNDOCS_BASE_URL para https://api.signdocs.com.br (sem -hml).
  2. Troque SIGNDOCS_CLIENT_ID e SIGNDOCS_CLIENT_SECRET pelas credenciais de produção (enviadas depois da aprovação do tenant).
  3. Registre o webhook novamente no painel SignDocs prod. A resposta traz um novo secret.
  4. Atualize SIGNDOCS_WEBHOOK_SECRET com o novo valor.
  5. Redeploy as duas Edge Functions (Lovable faz automaticamente ao salvar, ou via CLI).
  6. Smoke-test com um envelope real antes de ativar para todos os usuários.
✅ Por que nada no código React muda

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.


9. Troubleshooting (estilo "pergunte ao Lovable")

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.

SintomaPeç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."
🛠️ Logs via CLI (quando o prompt não resolver)
supabase functions logs create-signing-session --tail
supabase functions logs signdocs-webhook --tail

Apêndice A — Evolução para multi-signer (envelopes)

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.

Quando você precisa de envelopes

Se o caso é um único signatário por documento, fique no template atual — envelopes adicionam complexidade desnecessária.

O que continua igual (reutilizável)

Tudo que você já paga o custo de aprender e debugar permanece exatamente o mesmo:

O que muda

1. Schema

Em vez de uma única linha por sessão, você precisa de um envelope com N signatários. Duas opções:

Opção A — estender a tabela existente (rápido, OK até 2-3 signatários)
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.
Opção B — tabela separada (mais limpa para 3+ signatários)
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).

2. Backend — 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).

3. Webhook — adicione handlers para 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).

4. Frontend — UX por signatário + rollup de envelope

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.

Roadmap de migração (não-disruptivo)

  1. Semana 1: ship single-signer (template atual). Valide integração, secrets, webhook em produção. Não migre nada ainda.
  2. Semana 2-3: aplique a migration de schema (Opção A ou B). Importante: aditiva. O single-signer continua funcionando exatamente como antes.
  3. Semana 4: implemente create-envelope em paralelo com create-signing-session. Os dois flows coexistem; cada tipo de documento decide qual chamar.
  4. Semana 5+: gradualmente migre os tipos de documento que requerem multi-signer para o novo flow. Single-signer permanece para casos onde só uma parte assina.

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.

⚠️ Quando NÃO migrar

Referências canônicas

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.