AssinAPI

Integrando com Lovable

Apps Lovable têm frontend React e backend Supabase. Toda chamada à AssinAPI deve ficar em uma Supabase Edge Function.

Secret key

Supabase → Project Settings → Edge Functions → Secrets → ASSINAPI_SECRET_KEY. Nunca em variáveis VITE_*.

Arquitetura

React (Lovable) → supabase.functions.invoke → Edge Function (ASSINAPI_SECRET_KEY em Secrets) → AssinAPI → webhook → Edge Function → tabela do contrato.

Prompt pronto

markdown
Implemente a integração com a AssinAPI neste app Lovable. Toda chamada à AssinAPI deve acontecer em uma Supabase Edge Function (backend). Guarde a chave em Supabase Secrets com o nome ASSINAPI_SECRET_KEY. O frontend React chama a Edge Function via supabase.functions.invoke — nunca a AssinAPI diretamente.

## Objetivo
Implementar: criação de envelope; upload de PDF; cadastro de signatário (nome, e-mail, CPF); envio para assinatura e armazenamento dos signingLinks; endpoint de webhook para envelope.completed (com validação HMAC); download do documento assinado e do certificado de evidências.

## Configuração
- Base URL: https://sandbox.api.assinapi.com.br/v1
- Autenticação: header `Authorization: Bearer ${ASSINAPI_SECRET_KEY}`
- Leia a chave SOMENTE da variável de ambiente ASSINAPI_SECRET_KEY no servidor.
- Leia o segredo do webhook de ASSINAPI_WEBHOOK_SECRET.
- Chaves ask_test_… = Sandbox (OTP de teste 123456, CPF de teste 529.982.247-25). Chaves ask_live_… = Produção.

## Regras de segurança (obrigatórias)
- NUNCA exponha ASSINAPI_SECRET_KEY no frontend, no navegador, em app mobile, em logs ou no repositório.
- Crie uma camada server-side chamada `assinapi.service` que concentra todas as chamadas à AssinAPI.
- O frontend conversa apenas com o seu backend.
- Não registre CPF, telefone, códigos OTP ou links de assinatura em logs.

## Endpoints
- POST https://sandbox.api.assinapi.com.br/v1/envelopes
  body: { "title": string, "message"?: string, "signatureLevel"?: "SIMPLE"|"ADVANCED", "expirationDays"?: number, "externalId"?: string }
  201 → { "id": "env_…", "status": "DRAFT", … }
- POST https://sandbox.api.assinapi.com.br/v1/envelopes/{envelopeId}/documents
  multipart/form-data com o campo "file" (PDF)  — ou JSON { "contentBase64": string, "filename": string }
  201 → envelope com documents[].sha256
- POST https://sandbox.api.assinapi.com.br/v1/envelopes/{envelopeId}/signers
  body: { "name": string, "email": string, "cpf": string, "phone"?: string, "authenticationMethod"?: "EMAIL_OTP"|"SMS_OTP", "signingOrder"?: number }
  201 → { "id": "sgn_…", "status": "PENDING", "cpfMasked": "***.456.789-**" }
- POST https://sandbox.api.assinapi.com.br/v1/envelopes/{envelopeId}/send
  body: { "notify"?: boolean }   (notify=false → você envia o link por outro canal)
  200 → { "status": "SENT", "signingLinks": [{ "signerId", "name", "signingUrl", "expiresAt" }] }
  Os signingLinks só aparecem NESTA resposta: salve-os se precisar.
- GET https://sandbox.api.assinapi.com.br/v1/envelopes/{envelopeId}
  200 → { "status": "DRAFT"|"SENT"|"IN_PROGRESS"|"COMPLETED"|"DECLINED"|"EXPIRED"|"CANCELLED", "signers": [{ "status" }] }
- GET https://sandbox.api.assinapi.com.br/v1/envelopes/{envelopeId}/evidence   (somente após COMPLETED)
  200 → { "downloads": { "documents": [{ "final": url, "original": url }], "certificate": url, "manifest": url }, "verifyUrl": string }
  As URLs expiram em minutos: baixe e armazene no seu storage, não salve a URL.

Atalho (uma chamada só): POST https://sandbox.api.assinapi.com.br/v1/signature-requests
  body: { "title": string, "document": { "filename": string, "contentBase64": string }, "signer": { "name", "email", "cpf" }, "authentication": "EMAIL_OTP" }
  201 → { "id": "env_…", "status": "SENT", "signingUrl": string, "signers": [...] }

## Idempotência e retries
- Envie `Idempotency-Key: <uuid>` em todo POST. Em retry (timeout, 429, 5xx) reenvie com a MESMA chave.
- Em 429 respeite o header Retry-After e use backoff exponencial.

## Tratamento de erros
Erros têm o formato { "error": { "code", "message", "hint", "docs", "requestId" } }. Trate pelo `code` (estável), exiba `message` ao usuário quando fizer sentido e registre o `requestId` para suporte.
Códigos comuns: VALIDATION_FAILED, CPF_REQUIRED, INVALID_CPF, ENVELOPE_INCOMPLETE, INVALID_STATE_TRANSITION, INVALID_API_KEY, RATE_LIMITED.

## Webhook
- Registre o endpoint: POST https://sandbox.api.assinapi.com.br/v1/webhooks { "url": "https://…/api/webhooks/assinapi", "events": ["envelope.completed", "signer.signed"] } → guarde o "secret" retornado (exibido uma vez).
- Headers recebidos: X-Signature (v1=<hex>), X-Event-Id, X-Timestamp.
- Valide: HMAC_SHA256(ASSINAPI_WEBHOOK_SECRET, `${X-Event-Id}.${X-Timestamp}.${corpo_bruto}`) === X-Signature (comparação em tempo constante).
- Rejeite X-Timestamp com mais de 5 minutos; deduplique por X-Event-Id; responda 2xx rápido.
- Payload envelope.completed: { "id", "type", "data": { "envelope": { "id", "status", "externalId" }, "evidence": { "manifestHash", "verifyUrl" }, "documents": [...] } }
- Ao receber envelope.completed, atualize o status no banco e (se aplicável) baixe o PDF final via GET /envelopes/{id}/evidence.

## Entregáveis
- `assinapi.service` com funções tipadas para cada operação.
- Persistir envelopeId (e externalId) junto ao registro do seu sistema.
- Variáveis de ambiente documentadas (.env.example sem valores reais).
- Testes da camada de serviço com a API mockada.

Criar envelope

  • Cria um envelope (rascunho).
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-create-envelope/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes', {
    method: 'POST',
    headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({
      "title": "Contrato de prestação de serviços",
      "expirationDays": 7
    }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Enviar PDF

  • Anexa o PDF ao envelope. O SHA-256 é calculado pela AssinAPI.
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-upload-pdf/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const form = new FormData();
  form.append('file', await req.blob(), 'contrato.pdf');
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes/ENVELOPE_ID/documents', {
    method: 'POST',
    headers: { Authorization: `Bearer ${secret}` },
    body: form,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Adicionar signatário

  • Adiciona um signatário (CPF obrigatório na assinatura avançada).
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-add-signer/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes/ENVELOPE_ID/signers', {
    method: 'POST',
    headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({
      "name": "João da Silva",
      "email": "joao@email.com",
      "cpf": "529.982.247-25",
      "authenticationMethod": "EMAIL_OTP"
    }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Enviar para assinatura

  • Envia para assinatura. A resposta traz os signingLinks.
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-send/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes/ENVELOPE_ID/send', {
    method: 'POST',
    headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({
      "notify": true
    }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Consultar status

  • Consulta status do envelope e dos signatários.
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-status/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes/ENVELOPE_ID', {
    method: 'GET',
    headers: { Authorization: `Bearer ${secret}` },
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Baixar documento assinado

  • Retorna URLs temporárias do PDF assinado, certificado e manifesto.
  • No Lovable, peça: "crie uma Supabase Edge Function assinapi com este código e guarde ASSINAPI_SECRET_KEY em Supabase Secrets". O frontend chama a função com supabase.functions.invoke.
typescript
// supabase/functions/assinapi-download/index.ts
Deno.serve(async (req) => {
  const secret = Deno.env.get('ASSINAPI_SECRET_KEY')!;
  const res = await fetch('https://sandbox.api.assinapi.com.br/v1/envelopes/ENVELOPE_ID/evidence', {
    method: 'GET',
    headers: { Authorization: `Bearer ${secret}` },
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message} (requestId ${data.error.requestId})`);
  return new Response(JSON.stringify(data), { status: res.status, headers: { 'Content-Type': 'application/json' } });
});

Receber webhooks

  • Valide SEMPRE a assinatura HMAC (X-Signature) usando o corpo bruto.
  • Rejeite eventos com X-Timestamp com mais de 5 minutos e deduplique por X-Event-Id.
  • Responda 2xx rapidamente; processe de forma assíncrona. Falhas são reenviadas com backoff exponencial.
  • Crie a função com verify_jwt = false (o webhook usa HMAC, não JWT do Supabase).
typescript
// supabase/functions/assinapi-webhook/index.ts
Deno.serve(async (req) => {
  const raw = await req.text();
  const secret = Deno.env.get('ASSINAPI_WEBHOOK_SECRET')!;
  const eventId = req.headers.get('x-event-id') ?? '';
  const ts = Number(req.headers.get('x-timestamp'));
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return new Response('stale', { status: 400 });
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${eventId}.${ts}.${raw}`));
  const expected = 'v1=' + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  if (expected !== req.headers.get('x-signature')) return new Response('invalid signature', { status: 401 });
  const event = JSON.parse(raw);
  // ex.: atualizar tabela contracts onde assinapi_envelope_id = event.data.envelope.id
  return new Response('ok');
});