# 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:

```http
x-api-key: fia_live_SUA_CHAVE
```

```http
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`):

```bash
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/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.

| 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: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:

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

```json
{
  "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).

```bash
curl -s "https://formia.cloud/api/v1/forms/$FORM_ID/responses?page=1&limit=50" -H "x-api-key: $FORMIA_KEY"
```

```json
{
  "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

```json
{
  "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â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) |

```bash
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"
```

```json
{
  "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`).

```bash
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"}'
```

```json
{
  "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) |

```bash
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"}}'
```

```json
{ "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`).

```json
{ "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.

```json
{ "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**:

```json
{ "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`).

```json
{
  "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`.

```json
{
  "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`

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

```bash
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):

```json
{
  "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).

```json
{ "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`)

```json
{
  "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`):

| 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:

```json
{
  "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):

```http
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):

```http
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):

```js
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):

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

```bash
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

| | 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/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ó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/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.

```text
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>
```
