Guia da API e dos webhooks do CRM do FormIA

Para pessoas e para agentes de IA (Grok, Claude, ChatGPT…). Tudo aqui vem do código da API v1 do FormIA. Se algo não estiver neste guia, não existe — não invente rotas, campos nem parâmetros. Endereço deste guia: https://formia.cloud/docs/crm-api.md (Markdown) e https://formia.cloud/docs/crm-api (página). Referência OpenAPI: https://formia.cloud/api/docs (interface) e https://formia.cloud/api/docs-json (JSON). Índice para agentes: https://formia.cloud/llms.txt.

Regras rápidas para agentes de IA

  1. Use só as rotas, os campos e os valores deste guia. Campo ou parâmetro desconhecido responde 400 onde a rota valida o que recebe (corpo e query das rotas de contatos e do feed, POST /api/v1/links e os campos de texto dos uploads de recompensa) e é ignorado no resto (webhooks e as outras query strings) — em todos os casos, não mande.
  2. Mande a chave em x-api-key (ou Authorization: Bearer). Nunca coloque a chave na URL, em logs ou em respostas para pessoas.
  3. Todo POST de escrita do CRM exige Idempotency-Key — uma chave nova por operação, a MESMA nas repetições dela.
  4. Respeite 429 e 503: espere o Retry-After (segundos) antes de tentar de novo; sem esse cabeçalho, espere ao menos 60 s.
  5. No feed de eventos, 410 EVENTS_CURSOR_EXPIRED não é erro fatal: siga a recuperação da seção do feed.
  6. Em webhooks, confira a assinatura com o corpo cru antes de usar os dados e deduplique pelo id do evento.
  7. Antes de ações destrutivas (apagar webhook, remover etiqueta em massa, trocar secret, mudar consentimento), confirme com a pessoa responsável.

1. Para que servem a API e os webhooks

  • API (você pergunta, o FormIA responde): ler formulários e respostas; criar, buscar e atualizar contatos do CRM; aplicar etiquetas; mover contatos entre etapas do funil; escrever notas; ler o feed de eventos; gerenciar webhooks.
  • Webhooks (o FormIA avisa você): a cada evento (lead novo, resposta concluída, etiqueta, etapa, nota…) o FormIA faz um POST assinado para a URL que você cadastrou — Zapier, Make, n8n, o servidor do seu agente de IA etc.
  • Tudo é do workspace da chave: um id de outro workspace responde 404, como se não existisse.

2. Endereço e autenticação

  • Base: https://formia.cloud/api/v1
  • Chave de API: começa com fia_live_, é criada em Workspace → Chaves de API (só OWNER/ADMIN, planos Pro e Business) e aparece uma única vez na criação. Até 10 chaves ativas por workspace (acima ⇒ 400 "Máximo de 10 API keys ativas").
  • Envie em um destes cabeçalhos:
x-api-key: fia_live_SUA_CHAVE
Authorization: Bearer fia_live_SUA_CHAVE
  • Os dois cabeçalhos com valores diferentes ⇒ 401. Authorization com outro esquema (Basic…) é ignorado.
  • Chave ausente, inválida ou revogada ⇒ 401 (Missing x-api-key header (or Authorization: Bearer <api key>) ou Invalid or revoked API key).
  • Escopo faltando ⇒ 403 Missing scope: <escopo>.
  • Os escopos novos de uma chave editada valem na próxima requisição (a chave é conferida no banco a cada chamada).

Teste rápido (lista os formulários; precisa de forms:read):

curl -s https://formia.cloud/api/v1/forms -H "x-api-key: $FORMIA_KEY"

3. Planos

RecursoFREESTARTERPROBUSINESS
Chaves de APInãonãosim (até 10)sim (até 10)
API do CRM (requisições por minuto)0060300
Webhooks do CRM (endpoints)031025
Cabeçalho de autenticação no webhooknãonãosimsim
Webhook em lotenãonãosimsim
Contatos no CRM2502.50025.000250.000
Funis1210ilimitado
Log de entregas dos webhooks30 dias30 dias30 dias90 dias

Erros de plano:

  • Criar chave, ou mudar os escopos de uma, sem o plano ⇒ 403 "API keys disponíveis nos planos Pro e Business".
  • Dar escopos do CRM (contacts:*, pipelines:read, events:read) sem API do CRM no plano ⇒ 403 "API do CRM (escopos contacts/pipelines/events) disponível nos planos Pro e Business".
  • Chamar a API do CRM com o plano sem ela (inclusive chave antiga de workspace rebaixado) ⇒ 403 PLAN_NO_CRM_API.
  • Webhooks do CRM com o plano sem eles ⇒ 403 "Webhooks do CRM estão disponíveis a partir do plano Starter" (ao criar, editar, testar e reenviar). No STARTER os webhooks existem só pelo painel (o plano não tem chaves de API).
  • O plano é conferido a cada chamada só na API do CRM e nos webhooks. As rotas de formulários, respostas, recompensa, links e páginas não conferem o plano na chamada: a chave criada num plano com API continua valendo nelas. Editar só o nome da chave não confere o plano; mudar os escopos confere.
  • Ligar cabeçalho de autenticação ou lote fora do PRO/BUSINESS ⇒ 403.
  • Acima do teto de contatos, POST /api/v1/contacts que criaria um contato ⇒ 403 CONTACT_LIMIT_REACHED (atualizar contato existente continua; os formulários continuam captando leads).

4. Escopos (o que cada um permite)

Dê a cada chave só o que ela precisa.

EscopoPermite
forms:readListar formulários, ver um formulário com os campos, listar e baixar arquivos de recompensa, listar páginas publicadas
forms:writeTrocar, criar e desvincular arquivos de recompensa
responses:readListar e ler respostas de formulários. Junto com contacts:read, libera o corpo completo do submission.completed em webhooks criados pela chave
responses:writeAceito na chave, mas nenhuma rota da API v1 usa hoje
links:readLer um link único de acesso (ManyChat)
links:writeCriar e zerar links únicos de acesso (ManyChat)
contacts:readListar, buscar e ler contatos. Sem ele as escritas respondem só ids, e webhooks criados pela chave saem só com ids
contacts:writeCriar/atualizar contatos, etiquetas, etapa e notas
pipelines:readLer funis e etapas
events:readLer o feed de eventos
webhooks:manageCriar, editar, testar, pausar, excluir webhooks do CRM, ver o log e reenviar entregas

Combinações comuns:

  • Agente que lê e escreve no CRM: contacts:read, contacts:write, pipelines:read, events:read (+ webhooks:manage se ele também cria e recebe webhooks).
  • Agente que recebe leads por webhook com todas as respostas: webhooks:manage, contacts:read, responses:read.
  • Bot que só grava (sem ver dados pessoais): contacts:write (+ pipelines:read para achar as etapas).

5. Formato dos erros

Todo erro vem em JSON:

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "informe ao menos uma identidade: email, phone, manychat ou external",
  "code": "IDENTITY_REQUIRED",
  "timestamp": "2026-10-06T12:00:00.000Z",
  "path": "/api/v1/contacts"
}
  • Use o code quando existir; message é texto para pessoas (em português na API do CRM).
  • Erro de validação de campo (campo desconhecido, tipo errado) pode trazer message como lista de textos, sem code.
  • Alguns erros trazem campos extras: retryAfter e scope (429), mergedIntoId (contato mesclado), resumeAfter e oldestAvailableAt (410), unknown e allowed (UNKNOWN_FIELD), field (INVALID_IDENTITY), limit (CONTACT_LIMIT_REACHED).

6. Limites de requisição, 429 e 503

  • API do CRM (/contacts, /pipelines, /events): janela deslizante de 60 s com o teto do plano (PRO 60, BUSINESS 300), contada por chave e por workspace — as chaves do workspace dividem o mesmo teto. Toda resposta leva X-RateLimit-Limit e X-RateLimit-Remaining. Acima ⇒ 429 RATE_LIMITED com Retry-After (segundos), retryAfter e scope (key ou workspace).
  • Além disso há um teto por IP: 600/min na API do CRM; 60/min nas rotas de formulários, respostas e webhooks; 120/min nos links e em GET /api/v1/pages; 10/min para trocar ou desvincular arquivo de recompensa. Nas rotas de webhook: criar 10/min, editar 20/min, testar 5/min, rotacionar secret 5/min, reenviar 20/min. Esse 429 vem sem code, com Retry-After.
  • 503 CRM_BUSY com Retry-After: 2: o CRM do workspace está ocupado com outras gravações. Tente de novo depois do Retry-After.
  • 503 EVENTS_FEED_TXID_AHEAD (feed de eventos em manutenção) com Retry-After: 60.
  • O 429 do teste de webhook por workspace ("Muitos testes neste workspace…") vem sem Retry-After: espere 1 minuto.
  • Como tratar: ao receber 429/503, espere Retry-After segundos (some uma folga aleatória; sem o cabeçalho, ao menos 60 s) e repita a MESMA requisição — nas escritas, com a MESMA Idempotency-Key. Acompanhe X-RateLimit-Remaining para desacelerar antes do 429.

7. Idempotência (Idempotency-Key)

  • Obrigatória em POST /api/v1/contacts, POST /api/v1/contacts/{id}/tags e POST /api/v1/contacts/{id}/notes. Não é usada em POST /api/v1/contacts/lookup (não grava nada) nem nas rotas PATCH, PUT e DELETE.
  • Valor: 1 a 255 caracteres ASCII imprimíveis, sem espaço (ex.: um UUID, ou grok:<id da mensagem>:tag). Ausente ⇒ 400 IDEMPOTENCY_KEY_REQUIRED; inválida ⇒ 400 IDEMPOTENCY_KEY_INVALID.
  • Mesma chave + mesmo corpo + mesma rota (em até 24 h) ⇒ a escrita não se repete: volta o MESMO status e a mesma forma de corpo, com o cabeçalho Idempotent-Replayed: true. Com contacts:read, o recurso vem no estado atual; sem ele (ou se o contato foi mesclado/apagado), o corpo mínimo só com ids.
  • Mesma chave com outro corpo ou outra rota ⇒ 422 IDEMPOTENCY_KEY_REUSED.
  • Escrita que falhou (4xx/5xx) não guarda nada: pode repetir com a mesma chave.
  • Regra prática: derive a chave do que originou a ação (id da mensagem, do pedido, do evento) e da operação — assim um retry nunca duplica. Um mesmo evento que gera várias escritas precisa de uma chave por operação: zap:<id do item>:tag, zap:<id do item>:note (a mesma chave em duas operações diferentes ⇒ 422 IDEMPOTENCY_KEY_REUSED).

8. Formulários e respostas

GET /api/v1/forms

Escopo forms:read. Lista todos os formulários do workspace, do mais recente para o mais antigo.

{
  "data": [
    {
      "id": "cm1formexemplo0000000000",
      "title": "Lead consultoria",
      "slug": "lead-consultoria",
      "status": "PUBLISHED",
      "responseCount": 128,
      "viewCount": 940,
      "createdAt": "2026-10-01T12:00:00.000Z",
      "updatedAt": "2026-10-10T09:30:00.000Z",
      "_count": { "fields": 6 }
    }
  ],
  "total": 1
}

GET /api/v1/forms/{formId}

Escopo forms:read. O formulário com fields (na ordem de página e posição). Segredos de integrações saem mascarados. Resposta: { "data": { …formulário…, "fields": [ … ] } }. Formulário de outro workspace ⇒ 404.

GET /api/v1/forms/{formId}/responses

Escopo responses:read. Só respostas concluídas e que não são spam, da mais nova para a mais antiga. Parâmetros: page (padrão 1) e limit (1 a 100, padrão 50).

curl -s "https://formia.cloud/api/v1/forms/$FORM_ID/responses?page=1&limit=50" -H "x-api-key: $FORMIA_KEY"
{
  "data": [
    {
      "id": "cm1respexemplo0000000000",
      "answers": { "campo_email": { "value": "maria.exemplo@example.com" } },
      "metadata": { "utm": { "source": "instagram" }, "email_verified": true },
      "completedAt": "2026-10-11T14:00:00.000Z",
      "startedAt": "2026-10-11T13:58:25.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 50, "total": 1, "totalPages": 1 }
}
  • answers é indexado pelo id do campo (veja os campos em GET /api/v1/forms/{formId}).
  • metadata pode trazer utm, variant, email_verified, phone_verified (true/false, só com a verificação daquele canal ligada no formulário), bot_check (passed ou unverified) e suspicious (sinais separados por vírgula; ver a seção 14).

GET /api/v1/forms/{formId}/responses/{responseId}

Escopo responses:read. Uma resposta pelo id (inclusive não concluída). Resposta marcada como spam ⇒ 404. Resposta: { "data": { … } }.

9. Contatos do CRM

Planos PRO e BUSINESS. Corpo só em JSON (Content-Type: application/json; outro formato ⇒ 415 UNSUPPORTED_MEDIA_TYPE), até 256 KB (acima ⇒ 413 PAYLOAD_TOO_LARGE). JSON malformado ⇒ 400 INVALID_JSON. Texto com U+0000 ou surrogate UTF-16 sem par ⇒ 400 INVALID_STRING.

O contato

{
  "id": "cm1contatoexemplo00000000",
  "displayName": "Ana Souza",
  "primaryEmail": "ana@exemplo.com",
  "primaryPhone": "+5511987654321",
  "score": 0,
  "ownerType": null,
  "ownerId": null,
  "attributes": { "plano": "ouro" },
  "source": "api",
  "consentMarketing": "granted",
  "consentAt": null,
  "consentSource": "implicit",
  "firstSeenAt": "2026-10-06T12:00:00.000Z",
  "lastActivityAt": "2026-10-06T12:00:00.000Z",
  "createdAt": "2026-10-06T12:00:00.000Z",
  "updatedAt": "2026-10-06T12:00:00.000Z",
  "consent": { "status": "granted", "updatedAt": null, "implicit": true, "source": "implicit" },
  "identities": [
    { "type": "email", "value": "ana@exemplo.com", "source": "api" },
    { "type": "phone", "value": "+5511987654321", "source": "api" }
  ]
}
  • Identidades (como o FormIA reconhece a pessoa): email (minúsculo), phone (E.164; Brasil é o padrão e o DDD é obrigatório — "(11) 98765-4321", "11987654321" e "+5511987654321" são o mesmo número), manychat (id do subscriber, até 120 caracteres [A-Za-z0-9_.:-]) e external (id no seu sistema, até 254 caracteres). Cada identidade pertence a um só contato no workspace.
  • source = utm_source da primeira resposta, ou api para contato criado pela API.
  • consent resume o consentimento de marketing (seção 17). consentSource traz a origem com id (implicit, form:<formId>, api:<id da chave> ou manual:<id do usuário>); quem não precisa do id deve usar consent.source.
  • ownerType: user (membro do workspace) ou api_key (a própria chave); null = sem dono.

Campos sob medida: fields= e include=

  • fields= (separados por vírgula) escolhe o que volta — economiza tokens. Aceitos: id, displayName, primaryEmail, primaryPhone, score, ownerType, ownerId, attributes, source, consentMarketing, consentAt, consentSource, firstSeenAt, lastActivityAt, createdAt, updatedAt, consent, identities, tags, stages. id sempre vem. Nome desconhecido ⇒ 400 UNKNOWN_FIELD (com unknown e allowed).
  • Padrão da lista: todos os campos simples + consent (sem identities, tags, stages — peça em fields=).
  • Padrão do contato único (GET por id, lookup, respostas de escrita): campos simples + consent + identities.
  • include= (só no contato único e no lookup): timeline, notes, tags, stages, identities. timeline e notes trazem os 50 itens mais recentes.
  • tags = lista de nomes (ex.: ["Grok Bot", "VIP"]); stages = [{ "pipelineId", "stageId", "stage", "kind", "enteredAt" }], uma por funil.

GET /api/v1/contacts

Escopo contacts:read. Lista em ordem (updatedAt, id) crescente — feita para sincronizar.

ParâmetroDescrição
limit1 a 200 (padrão 50)
cursoro nextCursor da página anterior (repita os mesmos filtros)
updated_sincedata ISO 8601 entre 1970 e 9999 (ex.: 2026-10-01T12:00:00Z): só contatos com updatedAt maior que ela
tagnome da etiqueta (qualquer caixa)
stageum stageId de GET /api/v1/pipelines (desconhecido ⇒ 400 UNKNOWN_STAGE)
qbusca em nome, e-mail e telefone (telefone com 4 dígitos ou mais)
fieldscampos (acima)
curl -s "https://formia.cloud/api/v1/contacts?updated_since=2026-10-06T12:00:00Z&fields=id,displayName,score,tags&limit=200" \
  -H "x-api-key: $FORMIA_KEY"
{
  "data": [{ "id": "cm1contatoexemplo00000000", "displayName": "Ana Souza", "score": 0, "tags": ["VIP"] }],
  "hasMore": false,
  "nextCursor": null,
  "asOf": "2026-10-06T12:05:00.123Z"
}

Paginação sem buraco nem repetição: a 1ª página fixa asOf; siga nextCursor até hasMore: false. O que mudar durante a paginação vem na próxima sincronização com updated_since=<asOf>. Guarde o asOf como o "desde" da próxima vez. Cursor com outros filtros ⇒ 400 CURSOR_FILTER_MISMATCH; cursor inválido ⇒ 400 INVALID_CURSOR; data inválida ⇒ 400 INVALID_DATE.

Mudar etiqueta, etapa, nota ou ganhar identidade nova também sobe o updatedAt do contato.

POST /api/v1/contacts/lookup

Escopo contacts:read. Recomendado para achar uma pessoa: o e-mail/telefone vai no corpo, não na URL (não aparece em logs de proxy). Exatamente um entre email, phone, manychat e external (senão 400 LOOKUP_REQUIRES_ONE); opcionais fields e include. Não grava nada (sem Idempotency-Key).

curl -s -X POST https://formia.cloud/api/v1/contacts/lookup -H "x-api-key: $FORMIA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"phone":"(11) 98765-4321","fields":"id,displayName,tags,stages"}'
{
  "data": {
    "id": "cm1contatoexemplo00000000",
    "displayName": "Ana Souza",
    "tags": ["VIP"],
    "stages": [{ "pipelineId": "cp1funilexemplo000000000", "stageId": "cs1etapaexemplo000000000", "stage": "Qualificado", "kind": "open", "enteredAt": "2026-10-06T12:10:00.000Z" }]
  }
}

Não achou ⇒ 404 CONTACT_NOT_FOUND. Valor inválido ⇒ 400 INVALID_IDENTITY (com field). Contato mesclado: a busca devolve o contato que ficou.

GET /api/v1/contacts/lookup

Escopo contacts:read. O mesmo, pela query string: ?email=, ?phone=, ?manychat= ou ?external= (+ fields, include). Na URL, + vira espaço: codifique como %2B. Prefira o POST.

GET /api/v1/contacts/{id}

Escopo contacts:read. Um contato (padrão: campos + identidades). Ex.: ?fields=id,displayName,consent&include=tags,stages,notes. Não existe ⇒ 404 CONTACT_NOT_FOUND; foi mesclado em outro ⇒ 404 CONTACT_MERGED com mergedIntoId (use esse id).

POST /api/v1/contacts

Escopo contacts:write + Idempotency-Key. Upsert: procura pelas identidades (e-mail > telefone > ManyChat > externo). Não achou ⇒ cria (201). Achou ⇒ atualiza (200).

CampoRegra
email, phone, manychat, externalao menos um (senão 400 IDENTITY_REQUIRED); valor inválido ⇒ 400 INVALID_IDENTITY
displayNametexto até 120 caracteres; enviado, sobrescreve
attributesobjeto; junta com os atributos atuais (merge raso); valores texto (até 1.000), número ou booleano; null remove a chave; até 50 chaves ([A-Za-z0-9_.-], até 40 caracteres) e 16 KB; senão 400 INVALID_ATTRIBUTE
scoreinteiro de 0 a 1.000.000; enviado, sobrescreve
consentMarketinggranted, denied ou unknown — decisão explícita (seção 17)
curl -s -X POST https://formia.cloud/api/v1/contacts -H "x-api-key: $FORMIA_KEY" \
  -H "Idempotency-Key: $(uuidgen)" -H 'Content-Type: application/json' \
  -d '{"email":"ana@exemplo.com","phone":"(11) 98765-4321","displayName":"Ana Souza","attributes":{"plano":"ouro"}}'
{ "data": { "id": "cm1contatoexemplo00000000", "displayName": "Ana Souza", "…": "contato completo" }, "created": true, "changed": [] }
  • changed: nomes do que mudou (displayName, primaryEmail, primaryPhone, attributes, score, consentMarketing, identities). Contato novo ⇒ [].
  • E-mail/telefone principais só são preenchidos se estiverem vazios; identidade nova sem dono entra no contato.
  • Identidade que já é de outro contato não é movida (não há mesclagem automática): o contato fica o da identidade mais forte e a resposta traz mergeSuggested: [{ "contactId", "matchedBy" }] (chave sem contacts:read: mergeSuggested: true).
  • Chave sem contacts:read recebe só { "data": { "id" }, "created", "changed" }.
  • Pode vir warnings: [{ "code": "CONSENT_RATE_LIMITED", "message" }] (seção 17).
  • Contato novo nasce com source: "api" e consentimento implícito (se você não mandar consentMarketing).

PATCH /api/v1/contacts/{id}

Escopo contacts:write (sem Idempotency-Key). Mande ao menos um: displayName (null limpa), attributes (merge; { "chave": null } remove), ownerType + ownerId, score, consentMarketing. attributes, score e consentMarketing não aceitam null (para consentimento use "unknown").

  • ownerType: "api_key" ⇒ a própria chave vira dona (omita ownerId).
  • ownerType: "user" exige ownerId = id de um membro ativo do workspace; senão 400 INVALID_OWNER.
  • ownerType: null limpa o dono.

Resposta: { "data": <contato>, "changed": [ … ] } — changed com displayName, attributes, score, owner e/ou consentMarketing (sem contacts:read: { "data": { "id" }, "changed" }), com warnings se houver.

POST /api/v1/contacts/{id}/tags

Escopo contacts:write + Idempotency-Key. Corpo { "names": ["VIP", "Grok Bot"] } — 1 a 20 nomes por requisição, cada um com 1 a 50 caracteres (nome vazio ou com 51 a 100 caracteres ⇒ 400 INVALID_TAG; mais de 20 nomes ou nome com mais de 100 caracteres ⇒ 400 de validação, sem code). As que não existem são criadas. Comparação sem diferença de maiúsculas: "VIP", "vip" e " Vip " são a mesma etiqueta, exibida como foi digitada na primeira vez. Teto: 100 etiquetas por contato e 1.000 por workspace (400 TAG_LIMIT).

{ "data": { "id": "cm1contatoexemplo00000000", "tags": ["Grok Bot", "VIP"] }, "added": ["Grok Bot", "VIP"] }

added traz só as que o contato ainda não tinha. Sem contacts:read: { "data": { "id" }, "added" }.

DELETE /api/v1/contacts/{id}/tags/{name}

Escopo contacts:write. Remove a etiqueta (nome codificado na URL, qualquer caixa). Idempotente: se o contato não tinha, removed: false. Resposta: { "data": { "id", "tags" }, "removed": true } (sem contacts:read: { "data": { "id" }, "removed" }).

PUT /api/v1/contacts/{id}/stage

Escopo contacts:write. Corpo { "stageId": "cs…" } (opcional pipelineId; se mandar, tem de ser o funil da etapa, senão 400 STAGE_NOT_IN_PIPELINE). Um contato fica em uma etapa por funil; mover dentro do mesmo funil troca a etapa.

{ "data": { "id": "cm1contatoexemplo00000000", "pipelineId": "cp1funilexemplo000000000", "stageId": "cs1etapaexemplo000000000", "stage": "Qualificado", "kind": "open", "enteredAt": "2026-10-06T12:10:00.000Z" }, "changed": true }

Já estava na etapa ⇒ changed: false (nada muda). Etapa inexistente ou excluída por um administrador ⇒ 404 STAGE_NOT_FOUND (releia GET /api/v1/pipelines). Funil inexistente ⇒ 404 PIPELINE_NOT_FOUND. Sem contacts:read: { "data": { "id", "pipelineId", "stageId" }, "changed" }.

POST /api/v1/contacts/{id}/notes

Escopo contacts:write + Idempotency-Key. Corpo { "body": "texto" } (até 10.000 caracteres). Até 1.000 notas por contato (400 NOTE_LIMIT). Notas são internas: nunca saem em eventos ou webhooks. Resposta 201:

{ "data": { "id": "cn1notaexemplo0000000000", "contactId": "cm1contatoexemplo00000000", "body": "Pediu proposta do plano anual.", "authorType": "api_key", "authorId": "ck1chaveexemplo000000000", "createdAt": "2026-10-06T12:15:00.000Z" } }

Sem contacts:read: { "data": { "id", "contactId" } }.

10. Funis e etapas

GET /api/v1/pipelines

Escopo pipelines:read. Funis com as etapas em ordem. Na primeira chamada de um workspace sem funil, cria o padrão "Funil de vendas": Novo, Qualificado, Em conversa, Proposta, Cliente (won) e Perdido (lost).

{
  "data": [
    {
      "id": "cp1funilexemplo000000000",
      "name": "Funil de vendas",
      "isDefault": true,
      "stages": [
        { "id": "cs1etapanovo000000000000", "name": "Novo", "order": 0, "color": "#64748b", "kind": "open" },
        { "id": "cs1etapaexemplo000000000", "name": "Qualificado", "order": 1, "color": "#3b82f6", "kind": "open" }
      ]
    }
  ]
}

kind: open, won ou lost. Guarde os stageId (leia uma vez na configuração). Renomear etapa não muda o updatedAt dos contatos; excluir uma etapa faz o id antigo responder 404 STAGE_NOT_FOUND.

11. Feed de eventos

GET /api/v1/events

Escopo events:read. Eventos do workspace em ordem de gravação, para polling barato.

ParâmetroDescrição
aftero nextAfter da chamada anterior, ou latest para começar do agora
typestipos separados por vírgula (ex.: contact.created,contact.tagged)
limit1 a 500 (padrão 100)

Tipos do feed: contact.created, contact.updated, contact.tagged, contact.untagged, contact.stage_changed, contact.deleted, note.created, response.completed. (O submission.completed é só para webhooks; não sai no feed nem é aceito em types.) Tipo desconhecido ⇒ 400 UNKNOWN_FIELD.

{
  "data": [
    {
      "id": "ce1eventoexemplo00000000",
      "type": "contact.created",
      "entityType": "contact",
      "entityId": "cm1contatoexemplo00000000",
      "actorType": "system",
      "actorId": null,
      "payload": { "contactId": "cm1contatoexemplo00000000", "responseId": "cm1respexemplo0000000000", "formId": "cm1formexemplo0000000000", "origin": "live" },
      "createdAt": "2026-10-06T12:00:00.000Z"
    }
  ],
  "nextAfter": "ce1eventoexemplo00000000",
  "hasMore": false
}
  • payload não tem dados pessoais: ids (contactId, responseId, formId, tagId, pipelineId, stageId, fromStageId, noteId, mergedIntoId…), nomes de campos alterados em changes e marcadores como origin (ex.: live, api) e reason (em contact.deleted). Busque o contato quando precisar dos dados.
  • actorType: system (formulário/sistema), api_key (a chave em actorId) ou user (painel).
  • Como ler: chame uma vez com after=latest e guarde nextAfter. Depois, a cada N segundos, after=<nextAfter> — e sempre guarde o novo nextAfter, mesmo com data vazio. Com hasMore: true, chame de novo na hora.
  • nextAfter: null (o workspace ainda não tem eventos): na próxima chamada, não mande after (o texto after=null responde 400 UNKNOWN_CURSOR).
  • O feed só mostra transações já encerradas: um evento nunca "aparece atrás" de um nextAfter já entregue.
  • Evento de resposta apagada some do feed, mas o cursor nele continua valendo (sem 410).
  • after malformado ou com data no futuro ⇒ 400 UNKNOWN_CURSOR (erro do cliente: use o nextAfter devolvido).

410 EVENTS_CURSOR_EXPIRED — o evento do seu cursor não existe mais (o feed guarda 90 dias, ou um backup foi restaurado). Corpo: { code, message, resumeAfter, oldestAvailableAt }. Recuperação, nesta ordem:

  1. Guarde resumeAfter.
  2. Ressincronize os contatos com GET /api/v1/contacts?updated_since=<sua última sincronização> (ou a lista completa).
  3. Continue o feed com after=<resumeAfter> (sem after se vier null).

Os eventos entre o cursor perdido e resumeAfter não serão entregues, e a ressincronização não mostra contatos apagados nem response.completed. Se você guarda uma cópia dos contatos, liste todos os ids (fields=id&limit=200, seguindo nextCursor) e apague localmente os que não vierem. Não use after=latest depois de ressincronizar (pularia o que aconteceu durante a ressincronização).

12. Webhooks pela API

Escopo webhooks:manage. Os webhooks são da conta inteira: recebem eventos de todos os formulários, inclusive os criados depois — ou só dos formulários em formIds. Também dá para criar e editar pelo painel (Workspace → Webhooks do CRM).

POST /api/v1/webhooks

CampoRegra
urlobrigatório; só HTTPS, até 2.048 caracteres, domínio completo; endereços internos/privados são recusados (400)
eventsobrigatório; lista não vazia dos eventos da seção 14
nameaté 100 caracteres (padrão: o host da URL)
formIdsids de formulários do workspace (até 100); vazio ou ausente = todos; vale só para response.completed e submission.completed
authHeader{ "name": "Authorization", "value": "Bearer <token>" } — cabeçalho fixo enviado em toda entrega (PRO/BUSINESS); guardado cifrado e nunca devolvido (só a máscara)
minimalPayloadtrue = só ids, sem dados pessoais
submissionMinimaltrue = submission.completed só com ids
batchMode, batchWindowSeclote { "events": [ … ] } a cada janela de 5 a 300 s (padrão 30); PRO/BUSINESS; não combina com submission.completed (400)
ignoreOwnApiKeyIdeventos causados por esta chave não são entregues; criado pela API, o padrão é a própria chave; null = receber tudo

Campos desconhecidos são ignorados.

curl -s -X POST https://formia.cloud/api/v1/webhooks -H "Authorization: Bearer $FORMIA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://agente.example.com/formia","events":["submission.completed"],"authHeader":{"name":"Authorization","value":"Bearer <token do seu agente>"}}'

Resposta 201: o webhook (abaixo) mais secret (whsec_…) — guarde-o, ele não aparece de novo.

Regras de dados pessoais:

  • Chave sem contacts:read só cria/edita webhook mínimo: minimalPayload: false ⇒ 403 "…pela API o webhook só envia o payload mínimo (só ids)…", e qualquer edição dela deixa o webhook mínimo.
  • O corpo completo do submission.completed traz todas as respostas e exige contacts:read e responses:read. Sem responses:read, o submission.completed daquele webhook sai só com ids (submissionMinimal: true, forçado) e a resposta traz warnings.
  • Perder esses escopos depois tem o mesmo efeito nos webhooks da chave.
  • Uma chave só amplia o payload (minimalPayload: false, submissionMinimal: false) de webhook criado por ela (ou cuja URL ela troca para outro domínio na mesma edição); em outro webhook o pedido é ignorado e vem warnings.
  • Revogar a chave pausa os webhooks que ela criou ou cuja URL definiu (disabledKind: "api_key_revoked"); só o painel reativa.

O webhook (visão):

{
  "id": "we1webhookexemplo0000000",
  "name": "agente.example.com",
  "url": "https://agente.example.com/formia",
  "urlMasked": false,
  "events": ["submission.completed"],
  "formIds": [],
  "batchMode": false,
  "batchWindowSec": 30,
  "minimalPayload": false,
  "submissionMinimal": false,
  "authHeader": "Authorization: Bearer ••••abcd",
  "hasAuthHeader": true,
  "ignoreOwnApiKeyId": "ck1chaveexemplo000000000",
  "status": "active",
  "disabledKind": null,
  "disabledAt": null,
  "disabledReason": null,
  "consecutiveFailures": 0,
  "consecutiveExhausted": 0,
  "failingSince": null,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "previousSecretExpiresAt": null,
  "createdByType": "api_key",
  "createdAt": "2026-10-11T12:00:00.000Z",
  "updatedAt": "2026-10-11T12:00:00.000Z"
}

status: active, paused ou disabled. disabledKind (quando parado): manual, failures, backlog, api_key_revoked ou plan_limit.

GET /api/v1/webhooks

Lista: { "data": [webhook], "limits": { "max", "used", "authHeader", "batch", "batchWindowMin", "batchWindowMax", "maxAttempts" }, "events": [ … ] }.

GET /api/v1/webhooks/{id}

Um webhook (sem o secret). De outro workspace ⇒ 404.

PATCH /api/v1/webhooks/{id}

Só os campos enviados mudam (mesmos campos da criação), mais active: false pausa, true reativa (reativar não manda o que aconteceu enquanto esteve parado). formIds: [] volta a receber todos os formulários. Trocar o domínio da URL de um webhook com cabeçalho salvo exige mandar authHeader de novo (valor ou null; senão 400) e gera um secret novo (devolvido uma vez em secret). Pausar e tirar o cabeçalho (authHeader: null) sempre funcionam, mesmo fora do plano. Webhook pausado por revogação de chave não pode ser reativado pela API (403).

DELETE /api/v1/webhooks/{id}

Exclui o webhook com o log e a fila. Resposta { "deleted": true }. Ação destrutiva: confirme com a pessoa antes.

POST /api/v1/webhooks/{id}/test

Manda agora um teste e devolve o resultado (uma tentativa, sem fila, vale também para webhook pausado). Se o webhook assina submission.completed, manda uma amostra realista dele (com "test": true, assinatura sha256=); senão um evento ping. Por workspace: um teste por vez (409) e no máximo 10 por minuto (429).

{ "deliveryId": "…", "eventId": "test_…", "event": "submission.completed", "status": "delivered", "ok": true, "httpStatus": 200, "durationMs": 182, "error": null }

status: delivered, failed, blocked, redirected ou congested (a fila de DNS do próprio FormIA estava cheia — tente de novo em alguns segundos).

POST /api/v1/webhooks/{id}/rotate-secret

Gera um secret novo, devolvido uma vez: { "id", "secret", "previousSecretExpiresAt" }. O anterior continua assinando por 24 h (seção 15) — atualize o destino nesse prazo. Confirme com a pessoa antes.

GET /api/v1/webhooks/{id}/attempts

Log das tentativas, mais novas primeiro. page (padrão 1) e limit (1 a 100, padrão 20). Resposta { "attempts": [ … ], "total", "page", "limit", "maxAttempts" }; cada linha: id, deliveryId (= X-FormIA-Delivery-Id), kind, eventIds, eventTypes, attempt, status, httpStatus, durationMs, error, nextRetryAt, createdAt, deliveryStatus (pending, sending, retrying, delivered, failed ou cancelled) e canResend. O texto devolvido pelo destino em error só aparece para chave com contacts:read e responses:read (senão vem resumido, com errorRedacted).

POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/resend

Reenvia uma entrega que falhou de vez ou foi cancelada (canResend: true): cria uma entrega nova (outro X-FormIA-Delivery-Id) com os mesmos eventos ainda existentes, montada com os dados e as configurações atuais. Resposta { "deliveryId", "queued", "deduplicated" }. Repetir enquanto o reenvio está em andamento devolve o mesmo (deduplicated: true); já entregue há menos de 10 min ⇒ 409; webhook pausado ⇒ 409; entrega de teste ⇒ 400.

13. Como o FormIA entrega (todas as entregas)

  • POST para a sua URL com Content-Type: application/json e User-Agent: FormIA-Webhook/2.0.
  • Cabeçalhos: X-FormIA-Event (tipo do evento, ou batch), X-FormIA-Event-Id (id do evento; num lote, os ids separados por vírgula), X-FormIA-Delivery-Id (o mesmo em todas as tentativas da entrega), a assinatura (seção 15) e o seu cabeçalho fixo (authHeader), se houver.
  • 2xx = entregue. Qualquer outro status, erro de rede ou mais de 10 s sem resposta ⇒ nova tentativa, até 9 tentativas. Esperas (±20 %): 10 s, 40 s, 2 min 40 s, 10 min 40 s, 42 min 40 s, 2 h 50 min 40 s, 6 h, 6 h (≈ 15 h 48 min no total).
  • Redirecionamento (3xx) não é seguido e encerra a entrega; URL que passou a apontar para endereço interno também.
  • Entrega pelo menos uma vez: a mesma entrega pode chegar mais de uma vez (ex.: sua resposta 2xx se perdeu). Deduplique pelo id do evento (event_id/id).
  • Desativação automática: 5 entregas seguidas que esgotaram as tentativas e falhas há pelo menos 1 hora ⇒ o webhook é desativado (disabledKind: "failures"), os donos do workspace recebem um e-mail (no máximo um por dia por webhook) e as entregas na fila são canceladas. Atraso de mais de 7 dias num webhook ativo também desativa (backlog). Reative pelo painel ou com PATCH … {"active": true}; o período parado não é reenviado (use o "Reenviar" do log ou o feed /api/v1/events).
  • Nunca chega histórico: ao criar ou reativar, só eventos novos saem.
  • Corpo de no máximo 256 KB: lote maior é dividido em mais entregas (nenhum evento se perde).

14. Eventos e payloads

Eventos que um webhook pode assinar: contact.created, contact.updated, contact.tagged, contact.untagged, contact.stage_changed, contact.deleted, note.created, response.completed, submission.completed.

Envelope (todos menos submission.completed)

{
  "id": "ce1eventoexemplo00000000",
  "type": "contact.created",
  "createdAt": "2026-10-06T12:00:00.000Z",
  "workspaceId": "cw1workspaceexemplo00000",
  "actor": { "type": "system", "id": null },
  "data": {
    "contactId": "cm1contatoexemplo00000000",
    "responseId": "cm1respexemplo0000000000",
    "formId": "cm1formexemplo0000000000",
    "contact": {
      "id": "cm1contatoexemplo00000000",
      "name": "Ana Souza",
      "email": "ana@exemplo.com",
      "phone": "+5511987654321",
      "score": 0,
      "consent": { "status": "granted", "updatedAt": null, "implicit": true, "source": "implicit" },
      "source": "instagram",
      "tags": [{ "id": "ct1etiquetaexemplo000000", "name": "VIP" }],
      "stages": [{ "pipelineId": "cp1funilexemplo000000000", "stageId": "cs1etapaexemplo000000000", "stage": "Qualificado", "kind": "open", "enteredAt": "2026-10-06T12:10:00.000Z" }],
      "firstSeenAt": "2026-10-06T12:00:00.000Z",
      "lastActivityAt": "2026-10-06T12:00:00.000Z",
      "createdAt": "2026-10-06T12:00:00.000Z"
    },
    "form": { "id": "cm1formexemplo0000000000", "title": "Lead consultoria" }
  }
}
  • data = os ids do evento + (payload completo) o resumo atual do contato em contact e o formulário em form ({ id, title }, quando o evento tem formulário).
  • contact.tags: no máximo 20 (as primeiras em ordem alfabética), com tagsTruncated: true se houver mais. Contato anonimizado sai sem nome/e-mail/telefone (anonymized: true); contato mesclado traz mergedIntoId.
  • Payload mínimo (minimalPayload: true): data só com ids (…Id) e changes — nunca nome, e-mail, telefone ou texto. Ex.: { "contactId": "cm…", "responseId": "cm…", "formId": "cm…" }.
  • Lote (batchMode): { "events": [ <envelope>, … ] }, com X-FormIA-Event: batch; sai quando o mais antigo passa da janela ou ao juntar 100 eventos.
  • Teste: type: "ping", data: { "endpointId", "message": "Teste de webhook do FormIA" }.

data de cada tipo (ids no payload mínimo; o completo acrescenta contact e, se houver formulário, form):

EventoQuandodata (ids e campos)
contact.createdcontato novo (formulário ou API)contactId; do formulário também responseId e formId
contact.updateddados do contato mudaramcontactId, changes (nomes dos campos); do formulário também responseId, formId; numa mesclagem mergedContactId; ao remover funil pipelineId; nota editada ou apagada (changes: ["notes"]) noteId
contact.taggedetiqueta aplicadacontactId, tagId
contact.untaggedetiqueta removidacontactId, tagId
contact.stage_changedmudou de etapacontactId, pipelineId, stageId, fromStageId (null = estava sem etapa; o mínimo omite o null)
contact.deletedcontato apagado ou mescladocontactId, reason (responses_deleted, user_deleted ou merged), mergedIntoId na mesclagem; nunca traz o resumo do contato; o mínimo omite reason
note.creatednota criadacontactId, noteId (o texto da nota nunca sai)
response.completedresposta de formulário concluída que virou contato (tem e-mail, telefone ou ManyChat)responseId, formId, contactId — resposta sem identidade não gera este evento, só o submission.completed

submission.completed (formato plano)

Uma resposta de formulário concluída, no mesmo momento do response.completed — inclusive respostas sem e-mail/telefone. Um evento por requisição (nunca em lote). Corpo completo:

{
  "event": "submission.completed",
  "event_id": "ce1eventoexemplo00000000",
  "form_id": "cm1formexemplo0000000000",
  "form_name": "lead-consultoria",
  "response_id": "cm1respexemplo0000000000",
  "startedAt": "2026-10-11T13:58:25.000Z",
  "completedAt": "2026-10-11T14:00:00.000Z",
  "name": "Maria Exemplo",
  "email": "maria.exemplo@example.com",
  "phone": "+5511999990000",
  "answers": [
    { "field_id": "f1", "question": "Qual é o seu nome?", "type": "short_text", "answer": "Maria Exemplo" },
    { "field_id": "f4", "question": "O que você procura?", "type": "multiple_choice", "answer": "Consultoria, Cursos" },
    { "field_id": "f5", "question": "Já é cliente?", "type": "yes_no", "answer": "Não" },
    { "field_id": "f6", "question": "Aceito receber novidades", "type": "consent", "answer": "Aceito" }
  ],
  "utm_source": "instagram",
  "utm_medium": "bio",
  "utm_campaign": "lancamento",
  "ref": "parceiro-exemplo",
  "referrer": "https://www.instagram.com/",
  "variant": "original",
  "email_verified": true,
  "phone_verified": null,
  "bot_check": "passed",
  "suspicious": null
}
  • form_name é o slug do formulário. name/email/phone vêm da resposta (e-mail minúsculo, telefone E.164 quando reconhecido). Ausente ⇒ null.
  • answers na ordem do formulário, só as respondidas (o consentimento aparece sempre que foi enviado, mesmo "Não marcado"). answer é sempre texto: listas separadas por ", ", sim/não ⇒ "Sim"/"Não", consentimento ⇒ "Aceito", "Não quero receber" ou "Não marcado", upload ⇒ nome do arquivo. question: null = campo que não existe mais. Até 10.000 caracteres por resposta, 1.000 por pergunta e 200 respostas.
  • answers_truncated: true: o corpo passaria de 256 KB, então as últimas respostas ficaram de fora. A resposta inteira: GET /api/v1/forms/{formId}/responses/{responseId} (escopo responses:read).
  • email_verified / phone_verified: true = confirmado por código no formulário; false = verificação ligada, mas não confirmada (ex.: falha no envio do código); null = verificação desligada ou campo não respondido.
  • bot_check: passed ou unverified (captcha); null = sem captcha.
  • suspicious: sinais separados por vírgula, só marcam (não bloqueiam): ip_burst (muitas conclusões do mesmo IP no formulário em 1 h), timing_unknown (modo página sem relógio confiável), fast_fill (rápido, mas cabe preenchimento automático), send_failed_burst (muitas falhas do envio do código no formulário), verify_ip_burst (muitos pedidos de código do mesmo IP); ou null.
  • Contato anonimizado: name, email e phone vêm null e answers vem [].
  • Resposta classificada como robô (spam) não gera o evento. Resposta apagada antes do envio também não.
  • Mínimo (minimalPayload ou submissionMinimal): só event, event_id, form_id, form_name, response_id, completedAt.
  • No teste (POST …/test) vem "test": true e respostas fictícias.

15. Assinatura: como conferir

Cada webhook tem um secret (whsec_…, mostrado só na criação e na rotação). A assinatura é HMAC-SHA256 com o secret inteiro como chave, sobre <timestamp>.<corpo cru> — o corpo exatamente como chegou (bytes), antes de qualquer JSON.parse. Recuse timestamp com mais de 5 minutos de diferença.

submission.completed (e a amostra do teste):

X-Formia-Timestamp: 1791813600
X-Formia-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + corpo cru)>
X-Formia-Signature-Previous: sha256=<mesmo cálculo com o secret anterior>

O -Previous só vem nas 24 h depois de uma rotação. Aceite se uma das duas conferir.

Todos os outros eventos, lotes e o ping (formato t=…,v1=…, ainda em uso):

X-FormIA-Signature: t=1791813600,v1=<hex HMAC-SHA256(secret, t + "." + corpo cru)>

Nas 24 h depois de uma rotação vêm dois v1= (secret novo e anterior); basta um conferir. (Cabeçalho HTTP não diferencia maiúsculas: X-Formia-Signature e X-FormIA-Signature são o mesmo nome; o formato do valor diz qual regra usar.)

Tamanho do corpo: o FormIA manda corpos de até 256 KB. Seu endpoint (e o intermediário — n8n, Make, um proxy) precisa aceitar pelo menos isso; se ele recusar (ex.: 413), cada entrega falha, é repetida e, repetindo, o webhook acaba desativado. No Express, o express.raw aceita só 100 KB por padrão: aumente o limit.

Os dois trechos abaixo aceitam os dois formatos, conferem o formato de cada cabeçalho antes de comparar (cabeçalho estranho ⇒ false, nunca exceção) e comparam bytes em tempo constante.

Node (Express — use express.raw({ type: 'application/json', limit: '512kb' }) para ter o corpo cru):

const crypto = require('crypto');

const HEX64 = /^[0-9a-f]{64}$/;
const FLAT = /^sha256=[0-9a-f]{64}$/;
const TS = /^[0-9]{1,12}$/;

// Mesmos bytes? (os formatos já foram conferidos; compara em tempo constante)
function sameBytes(a, b) {
  const x = Buffer.from(a, 'utf8');
  const y = Buffer.from(b, 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

// headers = cabeçalhos em minúsculas (como no Express); rawBody = Buffer ou string exatamente como chegou.
// Nunca lança: cabeçalho ausente ou fora do formato ⇒ false.
function verifyFormia(headers, rawBody, secret, toleranceSec = 300) {
  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
  const sig = String(headers['x-formia-signature'] || '');
  const hmac = (t) => crypto.createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex');
  const fresh = (t) => TS.test(t) && Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSec;

  if (sig.startsWith('sha256=')) {
    // submission.completed: X-Formia-Timestamp + X-Formia-Signature (+ -Previous na rotação)
    const t = String(headers['x-formia-timestamp'] || '');
    if (!fresh(t)) return false;
    const expected = 'sha256=' + hmac(t);
    const candidates = [sig, String(headers['x-formia-signature-previous'] || '')];
    return candidates.some((c) => FLAT.test(c) && sameBytes(c, expected));
  }
  // Outros eventos: X-FormIA-Signature: t=<unix>,v1=<hex>[,v1=<hex do secret anterior>]
  const parts = sig.split(',').map((p) => {
    const i = p.indexOf('=');
    return i > 0 ? [p.slice(0, i).trim(), p.slice(i + 1).trim()] : ['', ''];
  });
  const t = (parts.find(([k]) => k === 't') || ['', ''])[1];
  if (!fresh(t)) return false;
  const expected = hmac(t);
  return parts.some(([k, v]) => k === 'v1' && HEX64.test(v) && sameBytes(v, expected));
}

// app.post('/formia', express.raw({ type: 'application/json', limit: '512kb' }), (req, res) => {
//   if (!verifyFormia(req.headers, req.body, process.env.FORMIA_WEBHOOK_SECRET)) return res.status(401).end();
//   const body = JSON.parse(req.body);
//   const eventId = body.event_id || body.id; // deduplique por este id
//   res.status(200).end();
// });

Python (Flask — request.get_data() devolve o corpo cru; request.headers.get não diferencia maiúsculas):

import hashlib, hmac, re, time

HEX64 = r"[0-9a-f]{64}"
FLAT = r"sha256=[0-9a-f]{64}"
TS = r"[0-9]{1,12}"

def verify_formia(headers, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    """Nunca lança: cabeçalho ausente ou fora do formato => False."""
    sig = headers.get("X-Formia-Signature", "") or ""

    def mac(t: str) -> bytes:
        return hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()

    def fresh(t: str) -> bool:
        return re.fullmatch(TS, t) is not None and abs(time.time() - int(t)) <= tolerance

    if sig.startswith("sha256="):
        t = headers.get("X-Formia-Timestamp", "") or ""
        if not fresh(t):
            return False
        expected = b"sha256=" + mac(t)
        candidates = (sig, headers.get("X-Formia-Signature-Previous", "") or "")
        return any(re.fullmatch(FLAT, c) and hmac.compare_digest(c.encode(), expected) for c in candidates)
    parts = [[s.strip() for s in p.split("=", 1)] for p in sig.split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), "")
    if not fresh(t):
        return False
    expected = mac(t)
    return any(k == "v1" and re.fullmatch(HEX64, v) and hmac.compare_digest(v.encode(), expected) for k, v in parts)

# @app.post("/formia")
# def formia():
#     raw = request.get_data()
#     if not verify_formia(request.headers, raw, FORMIA_WEBHOOK_SECRET):
#         abort(401)
#     body = json.loads(raw)
#     event_id = body.get("event_id") or body.get("id")  # deduplique por este id
#     return "", 200

Dicas: responda 2xx rápido (o prazo é 10 s) e processe depois; guarde os ids já processados; o authHeader (ex.: Authorization: Bearer <token>) serve para o seu servidor recusar quem não é o FormIA, mas não substitui a assinatura.

16. Rotação de secret

  1. POST /api/v1/webhooks/{id}/rotate-secret (ou "Novo secret" no painel) ⇒ secret novo, mostrado uma vez.
  2. Nas próximas 24 h o FormIA assina com os dois (o anterior em X-Formia-Signature-Previous ou no 2º v1=).
  3. Atualize o secret no destino dentro das 24 h.

Revogar a chave que recebeu o secret pela API troca o secret na hora, sem carência: o dono rotaciona pelo painel e atualiza o destino.

17. Consentimento de marketing

  • Estado: granted (aceito), denied (recusado) ou unknown (sem informação).
  • Aceite implícito: quem nunca decidiu (não marcou nada, ou respondeu formulário sem campo de consentimento) conta como granted implícito: consent.implicit: true, consent.source: "implicit", consentAt: null. Isso é diferente de unknown (decisão explícita "sem informação").
  • A última escolha explícita vale: um aceite pelo formulário depois de uma recusa reativa; pela API, consentMarketing (granted, denied, unknown) aplica direto, inclusive sair de uma recusa. Pedido igual ao estado explícito atual não grava nada.
  • Vários campos de consentimento no formulário: qualquer aceite ⇒ aceito.
  • consent.source: implicit, form, api ou manual (painel).
  • Limite: no máximo 20 decisões pela API/painel por contato em 24 h. Acima, um aceite ou unknown não é gravado, mas a requisição dá certo (200, ou 201 se o upsert criou o contato) com warnings: [{ "code": "CONSENT_RATE_LIMITED", … }] (os outros campos são gravados). A recusa (denied) nunca é barrada.
  • No submission.completed, o consentimento aparece como resposta: "Aceito", "Não quero receber" ou "Não marcado" ("Não marcado" não é recusa).
  • Para e-mail marketing: use só consent.status == "granted"; se quiser apenas quem aceitou de forma explícita, exija também consent.implicit == false.

18. Evitar loop (ignoreOwnApiKeyId)

Um agente que grava no CRM e também recebe o webhook receberia o eco das próprias ações. Por isso, webhook criado pela API já vem com ignoreOwnApiKeyId = a chave que criou: eventos causados por ela (actor.type: "api_key" com esse id) não são entregues. Para receber tudo, mande "ignoreOwnApiKeyId": null. No feed /api/v1/events, filtre você mesmo por actorType/actorId.

19. Receitas

Lead do formulário → etiqueta → etapa

Escopos da chave: webhooks:manage, contacts:read, responses:read (para receber o corpo completo), contacts:write e pipelines:read.

  1. Configuração (uma vez): GET /api/v1/pipelines e guarde o stageId desejado (ex.: "Qualificado").
  2. Crie o webhook com events: ["submission.completed"] (e formIds se quiser só alguns formulários). Guarde o secret.
  3. A cada entrega: confira a assinatura; deduplique por event_id; responda 2xx.
  4. Se email e phone estiverem ausentes, o webhook está no modo mínimo (minimalPayload ou submissionMinimal): leia a resposta em GET /api/v1/forms/{formId}/responses/{responseId} (escopo responses:read; os campos do formulário em GET /api/v1/forms/{formId}). Se vierem null no corpo completo, a resposta não virou contato: não há o que etiquetar (guarde-a ou avise a pessoa). Senão, ache o contato: POST /api/v1/contacts/lookup com { "email": "<email do corpo>", "fields": "id,tags,stages" } (ou phone). O contato é criado pelo FormIA antes do evento sair; se mesmo assim vier 404, crie com POST /api/v1/contacts (mesma identidade).
  5. POST /api/v1/contacts/{id}/tags com { "names": ["Lead site"] } e Idempotency-Key: formia:<event_id>:tag.
  6. PUT /api/v1/contacts/{id}/stage com { "stageId": "<id guardado>" } (repetir não muda nada: changed: false).
curl -s -X POST "https://formia.cloud/api/v1/contacts/$CONTACT_ID/tags" -H "x-api-key: $FORMIA_KEY" \
  -H "Idempotency-Key: formia:$EVENT_ID:tag" -H 'Content-Type: application/json' -d '{"names":["Lead site"]}'
curl -s -X PUT "https://formia.cloud/api/v1/contacts/$CONTACT_ID/stage" -H "x-api-key: $FORMIA_KEY" \
  -H 'Content-Type: application/json' -d "{\"stageId\":\"$STAGE_ID\"}"

Lista de e-mail só com quem consentiu e verificou

A API de contatos não diz se o e-mail foi verificado; a verificação vem da resposta (email_verified no submission.completed e metadata.email_verified em GET /api/v1/forms/{formId}/responses).

  1. Webhook com submission.completed (chave com contacts:read e responses:read para o corpo completo).
  2. Para cada entrega assinada: só siga se email não é null e email_verified é true.
  3. POST /api/v1/contacts/lookup com { "email": "…", "fields": "id,primaryEmail,consent" }.
  4. Inclua na lista só se consent.status == "granted" (e, se você quiser só aceite explícito, consent.implicit == false). Se for denied, remova da lista.
  5. Para manter a lista em dia, assine também contact.updated (ou leia o feed) e, quando changes incluir consentMarketing, releia o contato e aplique a regra do passo 4.

Receber webhooks × consultar o feed

WebhooksFeed GET /api/v1/events
Latênciasegundoso intervalo do seu polling
Precisa de URL pública HTTPSsimnão
Dadosresumo do contato ou respostas no corposó ids (busque o contato depois)
Se você ficar fora do ar9 tentativas em ≈ 16 h; depois "Reenviar" no lognada se perde por 90 dias: continue do nextAfter
PlanosSTARTER (painel), PRO e BUSINESSPRO e BUSINESS

Muitos agentes usam os dois: webhook para reagir na hora e o feed para conferir de tempos em tempos.

Zapier, Make e n8n

  • Receber: crie um gatilho "Webhook" (Catch Hook no Zapier, Custom webhook no Make, Webhook no n8n), copie a URL HTTPS e cadastre no FormIA com submission.completed. Para conferir a assinatura no n8n, use um nó Code com o trecho Node da seção 15 (o corpo cru precisa estar disponível).
  • Enviar ao FormIA: passo "HTTP request" com o método e a URL da rota, cabeçalho x-api-key, Content-Type: application/json e, nos POST de escrita, Idempotency-Key = o id do item do gatilho mais a operação (ex.: zap:<id>:tag na etiqueta e zap:<id>:note na nota — repetir a mesma chave em outra escrita do mesmo fluxo dá 422).
  • O gatilho Webhook precisa aceitar corpos de até 256 KB (seção 15).

Google Sheets

Use um intermediário que tenha o módulo do Google Sheets: Zapier ("Google Sheets → Create Spreadsheet Row"), Make ("Google Sheets → Add a Row") ou n8n (nó "Google Sheets").

  1. Crie o gatilho Webhook no intermediário e cadastre a URL dele no FormIA com submission.completed.
  2. Clique em "Testar" no FormIA para o intermediário receber uma amostra ("test": true) e você mapear as colunas (completedAt, form_name, name, email, phone, event_id…).
  3. Ignore as entregas com "test": true e use event_id para não gravar a mesma linha duas vezes.

Não cadastre como webhook a URL /exec de um App da Web do Google Apps Script: ele responde ao POST com um redirecionamento (302) e o FormIA não segue redirecionamentos — a entrega fica como redirected (falha definitiva) e, repetindo, o webhook é desativado. O Apps Script também não lê os cabeçalhos, então não conferiria a assinatura.

20. Erros comuns e o que fazer

Status e códigoCausaO que fazer
401chave ausente, inválida ou revogada; x-api-key e Bearer diferentesconfira a chave; mande um cabeçalho só
403 Missing scope: …a chave não tem o escopopeça ao administrador para editar os escopos da chave
403 PLAN_NO_CRM_APIplano sem API do CRMplano PRO ou BUSINESS
403 CONTACT_LIMIT_REACHEDteto de contatos do planoupgrade; atualizar contato existente continua
400 UNKNOWN_FIELDnome desconhecido em fields, include ou typesuse os nomes deste guia (allowed traz a lista)
400 (mensagem em lista)campo desconhecido ou tipo errado no corpo/querytire o campo; confira os tipos
400 IDENTITY_REQUIRED / INVALID_IDENTITYupsert sem identidade / e-mail ou telefone inválidomande e-mail ou telefone com DDD
400 LOOKUP_REQUIRES_ONElookup com zero ou mais de uma identidademande exatamente uma
400 IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALIDPOST de escrita sem a chave / chave fora do formatomande uma Idempotency-Key ASCII de 1 a 255 caracteres
422 IDEMPOTENCY_KEY_REUSEDmesma chave com outro corpo/rotagere uma chave nova para a operação nova
400 INVALID_TAG / TAG_LIMITetiqueta vazia ou com mais de 50 caracteres / tetocorrija o nome; remova etiquetas antigas
400 INVALID_ATTRIBUTEchave ou valor de attributes fora das regrasveja a tabela do upsert
400 INVALID_OWNERdono inválidoapi_key sem ownerId; user com id de membro ativo
400 NOTE_LIMIT1.000 notas no contato—
400 INVALID_DATE / INVALID_CURSOR / CURSOR_FILTER_MISMATCHdata, cursor ou filtros trocadosuse o nextCursor com os mesmos filtros
400 UNKNOWN_CURSORafter malformado ou do futurouse o nextAfter devolvido ou after=latest
400 UNKNOWN_STAGE / 404 STAGE_NOT_FOUNDetapa não existereleia GET /api/v1/pipelines
404 CONTACT_NOT_FOUNDcontato não existe no workspacebusque pelo lookup
404 CONTACT_MERGEDcontato mescladouse mergedIntoId
410 EVENTS_CURSOR_EXPIREDcursor do feed saiu da retençãosiga a recuperação da seção 11
413 PAYLOAD_TOO_LARGEcorpo acima de 256 KBenvie menos dados
415 UNSUPPORTED_MEDIA_TYPEcorpo que não é JSONContent-Type: application/json
400 INVALID_JSONJSON malformadocorrija o JSON
400 INVALID_STRINGcaractere U+0000 ou surrogate sem parlimpe o texto
429 RATE_LIMITEDlimite do plano (por chave ou workspace)espere Retry-After; acompanhe X-RateLimit-Remaining
503 CRM_BUSYCRM do workspace ocupadoespere Retry-After e repita (mesma Idempotency-Key)
400 na criação de webhookURL não HTTPS/interna, evento inválido, formulário de outro workspace, lote com submission.completedcorrija o campo citado em message
403 no webhooksem webhooks:manage, plano, payload completo sem contacts:read, reativar webhook de chave revogadaajuste escopos/plano; reative pelo painel
409 no teste / reenvioteste já em andamento / reenvio já entregue ou webhook pausadoespere alguns segundos / reative antes

21. Outras rotas v1 (recompensa e ManyChat)

RotaEscopoO que faz
GET /api/v1/forms/{formId}/reward-fileforms:readBaixa o arquivo de recompensa principal
PUT /api/v1/forms/{formId}/reward-fileforms:writeTroca o arquivo principal (multipart/form-data: file .zip ou .pdf até 50 MB, fileName opcional)
GET /api/v1/forms/{formId}/reward-filesforms:readLista até 5 arquivos: { data: [{ slot, managedFileId, fileName, label, size, mimeType, isPrimary }], total, filesVersion }
GET /api/v1/forms/{formId}/reward-files/{slot}forms:readBaixa o arquivo de um slot
PUT /api/v1/forms/{formId}/reward-files/{slot}forms:writeCria ou troca o arquivo do slot (file, fileName, label opcionais; slot [a-z0-9-], 1 a 32)
DELETE /api/v1/forms/{formId}/reward-files/{slot}forms:writeDesvincula o arquivo (o arquivo em si não é apagado)
POST /api/v1/linkslinks:writeCria (ou reaproveita) o link único de um contato do ManyChat: pageId e subscriberId obrigatórios; name, email, phone, channel, variables, maxViews, ttlHours, reuse opcionais
GET /api/v1/links/{token}links:readEstado do link (status, remaining_views, opened_at, progress_pct…)
POST /api/v1/links/{token}/resetlinks:writeZera as visualizações do link
GET /api/v1/pagesforms:readPáginas publicadas: { data: [{ id, title, slug, access: { enabled, maxViews, ttlHours } }], total }

As rotas de links respondem em snake_case e com 200 em caso de sucesso (o External Request do ManyChat só lê JSON com 200). Erros seguem o formato normal da seção 5 — exceto com o cabeçalho x-manychat-compat: 1 (ou ?compat=manychat): aí os erros 400/404/409/422 voltam como 200 { ok: false, error, message, url: "" }; 401, 403, 429 e 5xx nunca. A receita completa do ManyChat fica no painel (Workspace → ManyChat).

22. Segurança

  • Nunca compartilhe a chave em chat, e-mail, print ou código público. Guarde em variável de ambiente ou cofre de segredos. Única exceção: o agente de IA da seção 23 — crie uma chave só para ele, com o mínimo de escopos, entregue de preferência pelo cofre/variável de ambiente do próprio agente e revogue quando não precisar mais.
  • Uma chave por integração, com os menores escopos possíveis — assim dá para revogar uma sem parar as outras.
  • Revogue (Workspace → Chaves de API) a chave que vazou ou não é mais usada, e crie outra. Revogar pausa os webhooks criados por ela.
  • Rotacione o secret do webhook se ele vazar (seção 16).
  • Confira sempre a assinatura e recuse timestamp velho (mais de 5 min).
  • Prefira POST /api/v1/contacts/lookup a pôr e-mail/telefone na URL.

23. Prompt pronto para agentes

Copie o texto abaixo e entregue ao seu agente (Grok, Claude, ChatGPT…). Antes, crie uma chave de API nova, só para esse agente, com o mínimo de escopos (seção 4). Prefira guardar a chave no cofre de segredos ou numa variável de ambiente do ambiente do agente (ex.: FORMIA_API_KEY) em vez de colar no chat; se precisar colar, use um canal seguro. Revogue a chave quando o agente não precisar mais dela.

Você vai integrar com o CRM do FormIA usando a API v1 e os webhooks.

ANTES de qualquer chamada, leia o guia completo: https://formia.cloud/docs/crm-api.md
(índice: https://formia.cloud/llms.txt — referência OpenAPI: https://formia.cloud/api/docs-json).

Regras obrigatórias:
1. Use SOMENTE as rotas, campos, parâmetros e valores documentados no guia. Nunca invente campo, rota ou parâmetro; se algo não estiver no guia, pergunte para mim.
2. Base: https://formia.cloud/api/v1. Envie a chave no cabeçalho x-api-key (ou Authorization: Bearer). Nunca coloque a chave em URL, log ou mensagem, e nunca a repita para ninguém.
3. Todo POST de escrita do CRM (criar contato, etiquetas, notas) leva o cabeçalho Idempotency-Key: uma chave nova por operação, derivada do que originou a ação E da operação (ex.: <id da mensagem>:tag e <id da mensagem>:note — a mesma chave em duas operações diferentes dá 422), e a MESMA nas repetições.
4. Respostas 429 e 503: espere o número de segundos do cabeçalho Retry-After e repita a mesma requisição (com a mesma Idempotency-Key). Acompanhe X-RateLimit-Remaining.
5. Feed /api/v1/events: guarde sempre o nextAfter; se ele vier null, chame de novo SEM o parâmetro after (nunca after=null). Se vier 410 EVENTS_CURSOR_EXPIRED, guarde resumeAfter, ressincronize os contatos com updated_since e só então continue com after=resumeAfter.
6. Webhooks: confira a assinatura (X-Formia-Signature sha256= com X-Formia-Timestamp, ou t=,v1= nos outros eventos) com o corpo cru e o secret antes de usar os dados; recuse timestamp com mais de 5 minutos; deduplique pelo id do evento (event_id ou id).
7. Para achar uma pessoa, use POST /api/v1/contacts/lookup (o e-mail/telefone vai no corpo), pedindo só os fields necessários.
8. Respeite o consentimento: para marketing, só contatos com consent.status = "granted"; nunca troque o consentimento de alguém sem pedido explícito da pessoa.
9. Antes de qualquer ação destrutiva ou difícil de desfazer — excluir webhook, rotacionar secret, remover etiquetas, mudar consentimento, alterações em massa — descreva o que vai fazer e espere a minha confirmação.
10. Se receber 401 ou 403, pare e me avise (chave, escopo ou plano); não tente contornar.

Chave de API (só sua, trate como segredo): use a variável de ambiente FORMIA_API_KEY se ela existir; senão: <COLE_AQUI_A_CHAVE_DE_API>