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) ehttps://formia.cloud/docs/crm-api(página). Referência OpenAPI:https://formia.cloud/api/docs(interface) ehttps://formia.cloud/api/docs-json(JSON). Índice para agentes:https://formia.cloud/llms.txt.
Regras rápidas para agentes de IA
- 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/linkse 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. - Mande a chave em
x-api-key(ouAuthorization: Bearer). Nunca coloque a chave na URL, em logs ou em respostas para pessoas. - Todo
POSTde escrita do CRM exigeIdempotency-Key— uma chave nova por operação, a MESMA nas repetições dela. - Respeite
429e503: espere oRetry-After(segundos) antes de tentar de novo; sem esse cabeçalho, espere ao menos 60 s. - No feed de eventos,
410 EVENTS_CURSOR_EXPIREDnão é erro fatal: siga a recuperação da seção do feed. - Em webhooks, confira a assinatura com o corpo cru antes de usar os dados e deduplique pelo id do evento.
- 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
POSTassinado 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_CHAVEAuthorization: Bearer fia_live_SUA_CHAVE- Os dois cabeçalhos com valores diferentes ⇒ 401.
Authorizationcom outro esquema (Basic…) é ignorado. - Chave ausente, inválida ou revogada ⇒ 401 (
Missing x-api-key header (or Authorization: Bearer <api key>)ouInvalid 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
| Recurso | FREE | STARTER | PRO | BUSINESS |
|---|---|---|---|---|
| Chaves de API | não | não | sim (até 10) | sim (até 10) |
| API do CRM (requisições por minuto) | 0 | 0 | 60 | 300 |
| Webhooks do CRM (endpoints) | 0 | 3 | 10 | 25 |
| Cabeçalho de autenticação no webhook | não | não | sim | sim |
| Webhook em lote | não | não | sim | sim |
| Contatos no CRM | 250 | 2.500 | 25.000 | 250.000 |
| Funis | 1 | 2 | 10 | ilimitado |
| Log de entregas dos webhooks | 30 dias | 30 dias | 30 dias | 90 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/contactsque criaria um contato ⇒ 403CONTACT_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.
| Escopo | Permite |
|---|---|
forms:read | Listar formulários, ver um formulário com os campos, listar e baixar arquivos de recompensa, listar páginas publicadas |
forms:write | Trocar, criar e desvincular arquivos de recompensa |
responses:read | Listar e ler respostas de formulários. Junto com contacts:read, libera o corpo completo do submission.completed em webhooks criados pela chave |
responses:write | Aceito na chave, mas nenhuma rota da API v1 usa hoje |
links:read | Ler um link único de acesso (ManyChat) |
links:write | Criar e zerar links únicos de acesso (ManyChat) |
contacts:read | Listar, buscar e ler contatos. Sem ele as escritas respondem só ids, e webhooks criados pela chave saem só com ids |
contacts:write | Criar/atualizar contatos, etiquetas, etapa e notas |
pipelines:read | Ler funis e etapas |
events:read | Ler o feed de eventos |
webhooks:manage | Criar, 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:managese 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:readpara 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
codequando existir;messageé texto para pessoas (em português na API do CRM). - Erro de validação de campo (campo desconhecido, tipo errado) pode trazer
messagecomo lista de textos, semcode. - Alguns erros trazem campos extras:
retryAfterescope(429),mergedIntoId(contato mesclado),resumeAftereoldestAvailableAt(410),unknowneallowed(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 levaX-RateLimit-LimiteX-RateLimit-Remaining. Acima ⇒ 429RATE_LIMITEDcomRetry-After(segundos),retryAfterescope(keyouworkspace). - 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 semcode, comRetry-After. - 503
CRM_BUSYcomRetry-After: 2: o CRM do workspace está ocupado com outras gravações. Tente de novo depois doRetry-After. - 503
EVENTS_FEED_TXID_AHEAD(feed de eventos em manutenção) comRetry-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-Aftersegundos (some uma folga aleatória; sem o cabeçalho, ao menos 60 s) e repita a MESMA requisição — nas escritas, com a MESMAIdempotency-Key. AcompanheX-RateLimit-Remainingpara desacelerar antes do 429.
7. Idempotência (Idempotency-Key)
- Obrigatória em
POST /api/v1/contacts,POST /api/v1/contacts/{id}/tagsePOST /api/v1/contacts/{id}/notes. Não é usada emPOST /api/v1/contacts/lookup(não grava nada) nem nas rotasPATCH,PUTeDELETE. - Valor: 1 a 255 caracteres ASCII imprimíveis, sem espaço (ex.: um UUID, ou
grok:<id da mensagem>:tag). Ausente ⇒ 400IDEMPOTENCY_KEY_REQUIRED; inválida ⇒ 400IDEMPOTENCY_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. Comcontacts: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 ⇒ 422IDEMPOTENCY_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 emGET /api/v1/forms/{formId}).metadatapode trazerutm,variant,email_verified,phone_verified(true/false, só com a verificação daquele canal ligada no formulário),bot_check(passedouunverified) esuspicious(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_.:-]) eexternal(id no seu sistema, até 254 caracteres). Cada identidade pertence a um só contato no workspace. source= utm_source da primeira resposta, ouapipara contato criado pela API.consentresume o consentimento de marketing (seção 17).consentSourcetraz a origem com id (implicit,form:<formId>,api:<id da chave>oumanual:<id do usuário>); quem não precisa do id deve usarconsent.source.ownerType:user(membro do workspace) ouapi_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.idsempre vem. Nome desconhecido ⇒ 400UNKNOWN_FIELD(comunknowneallowed).- Padrão da lista: todos os campos simples +
consent(semidentities,tags,stages— peça emfields=). - 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.timelineenotestrazem 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âmetro | Descrição |
|---|---|
limit | 1 a 200 (padrão 50) |
cursor | o nextCursor da página anterior (repita os mesmos filtros) |
updated_since | data ISO 8601 entre 1970 e 9999 (ex.: 2026-10-01T12:00:00Z): só contatos com updatedAt maior que ela |
tag | nome da etiqueta (qualquer caixa) |
stage | um stageId de GET /api/v1/pipelines (desconhecido ⇒ 400 UNKNOWN_STAGE) |
q | busca em nome, e-mail e telefone (telefone com 4 dígitos ou mais) |
fields | campos (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).
| Campo | Regra |
|---|---|
email, phone, manychat, external | ao menos um (senão 400 IDENTITY_REQUIRED); valor inválido ⇒ 400 INVALID_IDENTITY |
displayName | texto até 120 caracteres; enviado, sobrescreve |
attributes | objeto; 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 |
score | inteiro de 0 a 1.000.000; enviado, sobrescreve |
consentMarketing | granted, 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 semcontacts:read:mergeSuggested: true). - Chave sem
contacts:readrecebe 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 mandarconsentMarketing).
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 (omitaownerId).ownerType: "user"exigeownerId= id de um membro ativo do workspace; senão 400INVALID_OWNER.ownerType: nulllimpa 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âmetro | Descrição |
|---|---|
after | o nextAfter da chamada anterior, ou latest para começar do agora |
types | tipos separados por vírgula (ex.: contact.created,contact.tagged) |
limit | 1 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
}payloadnão tem dados pessoais: ids (contactId,responseId,formId,tagId,pipelineId,stageId,fromStageId,noteId,mergedIntoId…), nomes de campos alterados emchangese marcadores comoorigin(ex.:live,api) ereason(emcontact.deleted). Busque o contato quando precisar dos dados.actorType:system(formulário/sistema),api_key(a chave emactorId) ouuser(painel).- Como ler: chame uma vez com
after=lateste guardenextAfter. Depois, a cada N segundos,after=<nextAfter>— e sempre guarde o novonextAfter, mesmo comdatavazio. ComhasMore: true, chame de novo na hora. nextAfter: null(o workspace ainda não tem eventos): na próxima chamada, não mandeafter(o textoafter=nullresponde 400UNKNOWN_CURSOR).- O feed só mostra transações já encerradas: um evento nunca "aparece atrás" de um
nextAfterjá entregue. - Evento de resposta apagada some do feed, mas o cursor nele continua valendo (sem 410).
aftermalformado ou com data no futuro ⇒ 400UNKNOWN_CURSOR(erro do cliente: use onextAfterdevolvido).
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:
- Guarde
resumeAfter. - Ressincronize os contatos com
GET /api/v1/contacts?updated_since=<sua última sincronização>(ou a lista completa). - Continue o feed com
after=<resumeAfter>(semafterse viernull).
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
| Campo | Regra |
|---|---|
url | obrigatório; só HTTPS, até 2.048 caracteres, domínio completo; endereços internos/privados são recusados (400) |
events | obrigatório; lista não vazia dos eventos da seção 14 |
name | até 100 caracteres (padrão: o host da URL) |
formIds | ids 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) |
minimalPayload | true = só ids, sem dados pessoais |
submissionMinimal | true = submission.completed só com ids |
batchMode, batchWindowSec | lote { "events": [ … ] } a cada janela de 5 a 300 s (padrão 30); PRO/BUSINESS; não combina com submission.completed (400) |
ignoreOwnApiKeyId | eventos 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:readsó 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.completedtraz todas as respostas e exigecontacts:readeresponses:read. Semresponses:read, osubmission.completeddaquele webhook sai só com ids (submissionMinimal: true, forçado) e a resposta trazwarnings. - 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 vemwarnings. - 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)
POSTpara a sua URL comContent-Type: application/jsoneUser-Agent: FormIA-Webhook/2.0.- Cabeçalhos:
X-FormIA-Event(tipo do evento, oubatch),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 comPATCH … {"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 emcontacte o formulário emform({ id, title }, quando o evento tem formulário).contact.tags: no máximo 20 (as primeiras em ordem alfabética), comtagsTruncated: truese houver mais. Contato anonimizado sai sem nome/e-mail/telefone (anonymized: true); contato mesclado trazmergedIntoId.- Payload mínimo (
minimalPayload: true):datasó com ids (…Id) echanges— nunca nome, e-mail, telefone ou texto. Ex.:{ "contactId": "cm…", "responseId": "cm…", "formId": "cm…" }. - Lote (
batchMode):{ "events": [ <envelope>, … ] }, comX-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):
| Evento | Quando | data (ids e campos) |
|---|---|---|
contact.created | contato novo (formulário ou API) | contactId; do formulário também responseId e formId |
contact.updated | dados do contato mudaram | contactId, 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.tagged | etiqueta aplicada | contactId, tagId |
contact.untagged | etiqueta removida | contactId, tagId |
contact.stage_changed | mudou de etapa | contactId, pipelineId, stageId, fromStageId (null = estava sem etapa; o mínimo omite o null) |
contact.deleted | contato apagado ou mesclado | contactId, reason (responses_deleted, user_deleted ou merged), mergedIntoId na mesclagem; nunca traz o resumo do contato; o mínimo omite reason |
note.created | nota criada | contactId, noteId (o texto da nota nunca sai) |
response.completed | resposta 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/phonevêm da resposta (e-mail minúsculo, telefone E.164 quando reconhecido). Ausente ⇒null.answersna 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}(escoporesponses: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:passedouunverified(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); ounull.- Contato anonimizado:
name,emailephonevêmnulleanswersvem[]. - Resposta classificada como robô (spam) não gera o evento. Resposta apagada antes do envio também não.
- Mínimo (
minimalPayloadousubmissionMinimal): sóevent,event_id,form_id,form_name,response_id,completedAt. - No teste (
POST …/test) vem"test": truee 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 "", 200Dicas: 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
POST /api/v1/webhooks/{id}/rotate-secret(ou "Novo secret" no painel) ⇒ secret novo, mostrado uma vez.- Nas próximas 24 h o FormIA assina com os dois (o anterior em
X-Formia-Signature-Previousou no 2ºv1=). - 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) ouunknown(sem informação). - Aceite implícito: quem nunca decidiu (não marcou nada, ou respondeu formulário sem campo de consentimento) conta como
grantedimplícito:consent.implicit: true,consent.source: "implicit",consentAt: null. Isso é diferente deunknown(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,apioumanual(painel).- Limite: no máximo 20 decisões pela API/painel por contato em 24 h. Acima, um aceite ou
unknownnão é gravado, mas a requisição dá certo (200, ou 201 se o upsert criou o contato) comwarnings: [{ "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émconsent.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.
- Configuração (uma vez):
GET /api/v1/pipelinese guarde ostageIddesejado (ex.: "Qualificado"). - Crie o webhook com
events: ["submission.completed"](eformIdsse quiser só alguns formulários). Guarde osecret. - A cada entrega: confira a assinatura; deduplique por
event_id; responda 2xx. - Se
emailephoneestiverem ausentes, o webhook está no modo mínimo (minimalPayloadousubmissionMinimal): leia a resposta emGET /api/v1/forms/{formId}/responses/{responseId}(escoporesponses:read; os campos do formulário emGET /api/v1/forms/{formId}). Se vieremnullno 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/lookupcom{ "email": "<email do corpo>", "fields": "id,tags,stages" }(ouphone). O contato é criado pelo FormIA antes do evento sair; se mesmo assim vier 404, crie comPOST /api/v1/contacts(mesma identidade). POST /api/v1/contacts/{id}/tagscom{ "names": ["Lead site"] }eIdempotency-Key: formia:<event_id>:tag.PUT /api/v1/contacts/{id}/stagecom{ "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).
- Webhook com
submission.completed(chave comcontacts:readeresponses:readpara o corpo completo). - Para cada entrega assinada: só siga se
emailnão énulleemail_verifiedétrue. POST /api/v1/contacts/lookupcom{ "email": "…", "fields": "id,primaryEmail,consent" }.- Inclua na lista só se
consent.status == "granted"(e, se você quiser só aceite explícito,consent.implicit == false). Se fordenied, remova da lista. - Para manter a lista em dia, assine também
contact.updated(ou leia o feed) e, quandochangesincluirconsentMarketing, releia o contato e aplique a regra do passo 4.
Receber webhooks × consultar o feed
| Webhooks | Feed GET /api/v1/events | |
|---|---|---|
| Latência | segundos | o intervalo do seu polling |
| Precisa de URL pública HTTPS | sim | não |
| Dados | resumo do contato ou respostas no corpo | só ids (busque o contato depois) |
| Se você ficar fora do ar | 9 tentativas em ≈ 16 h; depois "Reenviar" no log | nada se perde por 90 dias: continue do nextAfter |
| Planos | STARTER (painel), PRO e BUSINESS | PRO 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/jsone, nosPOSTde escrita,Idempotency-Key= o id do item do gatilho mais a operação (ex.:zap:<id>:tagna etiqueta ezap:<id>:notena 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").
- Crie o gatilho Webhook no intermediário e cadastre a URL dele no FormIA com
submission.completed. - 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…). - Ignore as entregas com
"test": truee useevent_idpara 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ódigo | Causa | O que fazer |
|---|---|---|
| 401 | chave ausente, inválida ou revogada; x-api-key e Bearer diferentes | confira a chave; mande um cabeçalho só |
403 Missing scope: … | a chave não tem o escopo | peça ao administrador para editar os escopos da chave |
403 PLAN_NO_CRM_API | plano sem API do CRM | plano PRO ou BUSINESS |
403 CONTACT_LIMIT_REACHED | teto de contatos do plano | upgrade; atualizar contato existente continua |
400 UNKNOWN_FIELD | nome desconhecido em fields, include ou types | use os nomes deste guia (allowed traz a lista) |
| 400 (mensagem em lista) | campo desconhecido ou tipo errado no corpo/query | tire o campo; confira os tipos |
400 IDENTITY_REQUIRED / INVALID_IDENTITY | upsert sem identidade / e-mail ou telefone inválido | mande e-mail ou telefone com DDD |
400 LOOKUP_REQUIRES_ONE | lookup com zero ou mais de uma identidade | mande exatamente uma |
400 IDEMPOTENCY_KEY_REQUIRED / IDEMPOTENCY_KEY_INVALID | POST de escrita sem a chave / chave fora do formato | mande uma Idempotency-Key ASCII de 1 a 255 caracteres |
422 IDEMPOTENCY_KEY_REUSED | mesma chave com outro corpo/rota | gere uma chave nova para a operação nova |
400 INVALID_TAG / TAG_LIMIT | etiqueta vazia ou com mais de 50 caracteres / teto | corrija o nome; remova etiquetas antigas |
400 INVALID_ATTRIBUTE | chave ou valor de attributes fora das regras | veja a tabela do upsert |
400 INVALID_OWNER | dono inválido | api_key sem ownerId; user com id de membro ativo |
400 NOTE_LIMIT | 1.000 notas no contato | — |
400 INVALID_DATE / INVALID_CURSOR / CURSOR_FILTER_MISMATCH | data, cursor ou filtros trocados | use o nextCursor com os mesmos filtros |
400 UNKNOWN_CURSOR | after malformado ou do futuro | use o nextAfter devolvido ou after=latest |
400 UNKNOWN_STAGE / 404 STAGE_NOT_FOUND | etapa não existe | releia GET /api/v1/pipelines |
404 CONTACT_NOT_FOUND | contato não existe no workspace | busque pelo lookup |
404 CONTACT_MERGED | contato mesclado | use mergedIntoId |
410 EVENTS_CURSOR_EXPIRED | cursor do feed saiu da retenção | siga a recuperação da seção 11 |
413 PAYLOAD_TOO_LARGE | corpo acima de 256 KB | envie menos dados |
415 UNSUPPORTED_MEDIA_TYPE | corpo que não é JSON | Content-Type: application/json |
400 INVALID_JSON | JSON malformado | corrija o JSON |
400 INVALID_STRING | caractere U+0000 ou surrogate sem par | limpe o texto |
429 RATE_LIMITED | limite do plano (por chave ou workspace) | espere Retry-After; acompanhe X-RateLimit-Remaining |
503 CRM_BUSY | CRM do workspace ocupado | espere Retry-After e repita (mesma Idempotency-Key) |
| 400 na criação de webhook | URL não HTTPS/interna, evento inválido, formulário de outro workspace, lote com submission.completed | corrija o campo citado em message |
| 403 no webhook | sem webhooks:manage, plano, payload completo sem contacts:read, reativar webhook de chave revogada | ajuste escopos/plano; reative pelo painel |
| 409 no teste / reenvio | teste já em andamento / reenvio já entregue ou webhook pausado | espere alguns segundos / reative antes |
21. Outras rotas v1 (recompensa e ManyChat)
| Rota | Escopo | O que faz |
|---|---|---|
GET /api/v1/forms/{formId}/reward-file | forms:read | Baixa o arquivo de recompensa principal |
PUT /api/v1/forms/{formId}/reward-file | forms:write | Troca o arquivo principal (multipart/form-data: file .zip ou .pdf até 50 MB, fileName opcional) |
GET /api/v1/forms/{formId}/reward-files | forms:read | Lista até 5 arquivos: { data: [{ slot, managedFileId, fileName, label, size, mimeType, isPrimary }], total, filesVersion } |
GET /api/v1/forms/{formId}/reward-files/{slot} | forms:read | Baixa o arquivo de um slot |
PUT /api/v1/forms/{formId}/reward-files/{slot} | forms:write | Cria 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:write | Desvincula o arquivo (o arquivo em si não é apagado) |
POST /api/v1/links | links:write | Cria (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:read | Estado do link (status, remaining_views, opened_at, progress_pct…) |
POST /api/v1/links/{token}/reset | links:write | Zera as visualizações do link |
GET /api/v1/pages | forms:read | Pá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/lookupa 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>