Apps parceiros

Tudo o que um aplicativo precisa para ler os dados de um projeto no CrazyLeads com a autorização da pessoa dona dele: do registro ao primeiro dado, passando pela autorização, pela API /v1, pelos tetos e pela revogação.

O modelo em uma tela

  1. A pessoa é dona do projeto. Os dados são dela. O seu app não é dono de nada, mesmo que tenha sido ele a trazê-la para o CrazyLeads.
  2. O app lê por concessão dela. A concessão é um token ligado a três coisas: o seu app, a pessoa e um projeto. Ela pode revogar quando quiser, sem avisar — e a chamada seguinte falha.
  3. O escopo é do projeto inteiro. Não existe autorização por fonte nem por tipo de dado: o que ela conectar depois de autorizar, o seu app também lê. Fonte nova não exige nova autorização.
  4. A autorização acontece na tela do CrazyLeads. Você redireciona; ela vê o nome do seu app, o que ele vai poder fazer, escolhe ou cria o projeto e autoriza. Seu app nunca vê a senha dela.

Do zero ao primeiro dado são seis passos: registrar e homologar → mandar autorizar → trocar o código por token → ler pela /v1 → pedir atualização → tratar a revogação. Cada um está abaixo, com o pedido e a resposta reais.

1. Registrar o aplicativo

Um app parceiro é registrado pela equipe do CrazyLeads, não por formulário público. Você manda os dados abaixo e recebe um client_id. Não existe segredo de cliente: a segurança do fluxo vem do PKCE, que é obrigatório.

O que informarRegra
NomeÉ o que a pessoa vê na tela de autorização. Escolha o nome que ela reconhece.
Site e logotipoURLs https absolutas. O logotipo aparece ao lado do do CrazyLeads na tela.
URIs de retornoAté 10, https absolutas e sem fragmento. http://localhost e http://127.0.0.1 valem só em desenvolvimento. A troca do código exige a MESMA URI usada ao autorizar, byte a byte.
Escopos que o app pode pedirDa lista de escopos abaixo. Peça só o que o seu app usa: na autorização, o concedido é o pedido ∩ o registrado — escopo que não está no registro não entra no token.
Identificador do parceiroUm nome curto da sua empresa (por exemplo, fluxer). Serve para agrupar os apps de um mesmo parceiro.

Homologação

O app nasce registrado, mas não homologado. A homologação é a aprovação do CrazyLeads, e ela é conferida a cada chamada, não só no momento em que a pessoa autoriza. Enquanto o app não está homologado:

  • /oauth/authorize recusa qualquer escopo de parceiro com a tela Invalid scope, antes mesmo de a pessoa entrar;
  • /api/oauth/token responde invalid_scope, na troca do código e na renovação;
  • a /v1 responde 403 insufficient_scope.

Suspender um app desfaz a homologação: o acesso cessa na chamada seguinte, sem precisar revogar token a token. Os tokens continuam existindo e voltam a valer se a homologação voltar.

Por que escopo de parceiro só vale para app homologado. O registro dinâmico (POST /api/oauth/register) continua aberto, mas emite somente mcp:read e mcp:write — é por ele que conectores como o Claude se registram. Um token mcp:* nunca vale na /v1. Quem lê dado de projeto de terceiros pela API precisa constar de uma lista conhecida: é o que o CrazyLeads declara nas revisões de uso de dados das plataformas de onde os dados vêm.

2. Mandar a pessoa autorizar (OAuth 2.1 + PKCE)

O fluxo é o authorization code do OAuth 2.1 com PKCE S256. Os endereços saem do documento de descoberta — leia-o em vez de escrevê-los na mão:

GET https://crazyleads.com.br/api/well-known/oauth-authorization-server

Hoje o único método de desafio é S256, o único tipo de resposta é code e o único método de autenticação do cliente é none. O campo scopes_supported desse documento lista apenas os escopos do MCP; os escopos de parceiro são os da tabela desta página.

Passo 1 — gere o verificador e o desafio

Um code_verifier aleatório de 43 a 128 caracteres, guardado no seu servidor, e o code_challenge = base64url do SHA-256 dele.

bash
code_verifier=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64)
code_challenge=$(printf '%s' "$code_verifier" \
  | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')

Passo 2 — abra a URL de autorização no navegador da pessoa

GET /oauth/authorize
https://crazyleads.com.br/oauth/authorize
  ?response_type=code
  &client_id=SEU_CLIENT_ID
  &redirect_uri=https://meuapp.com.br/callback
  &scope=project:read data:instagram:read data:analytics:read project:refresh offline_access
  &state=UM_VALOR_ALEATORIO_SEU
  &code_challenge=$code_challenge
  &code_challenge_method=S256
  &login_hint=ana@exemplo.com.br
  &project_name=Mentoria da Ana
ParâmetroO que faz
scopeOs escopos que o seu app quer, separados por espaço. Só os que estão no seu registro entram no token.
stateObrigatório, até 500 caracteres. Guarde e confira na volta — sem isso você aceita um retorno que não foi você quem começou.
login_hintO e-mail que o seu app já conhece da pessoa. Só preenche o campo da tela de entrada; não autentica ninguém. Quem prova a identidade é o link que chega no e-mail dela.
project_nameAté 60 caracteres. A opção “Criar um projeto novo” é sempre a última do seletor, tenha a pessoa projetos ou não; se ela a escolher, este é o nome sugerido. Ela pode trocar — o que vale é o que estiver na tela quando ela autorizar.

A pessoa entra (se ainda não estiver logada, a tela de entrada sabe que é o seu app que está esperando), vê o nome e o logotipo do app, a lista do que ele vai poder fazer — uma linha por escopo pedido, com a frase da tabela de escopos —, o endereço para onde o acesso será entregue, e escolhe o projeto entre os que ela participa, com qualquer papel — e a última opção do seletor é sempre “Criar um projeto novo”, tenha ela projetos ou não.

Na volta, chegam três parâmetros na sua URI de retorno:

https://meuapp.com.br/callback
  ?code=clc_…
  &state=UM_VALOR_ALEATORIO_SEU
  &iss=https://crazyleads.com.br

Confira state e iss antes de usar o código. Se a pessoa recusar, em vez de code vem error=access_denied — trate como resposta normal, não como falha. O código vale 10 minutos e serve uma única vez; trocá-lo duas vezes revoga tudo o que ele emitiu.

Passo 3 — troque o código por tokens

O corpo vai como formulário, não como JSON.

POST /api/oauth/token
curl -X POST https://crazyleads.com.br/api/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=O_CODIGO_QUE_VOLTOU \
  -d client_id=SEU_CLIENT_ID \
  -d redirect_uri=https://meuapp.com.br/callback \
  -d code_verifier=$code_verifier
200
{
  "access_token": "cla_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "clr_…",
  "scope": "data:analytics:read data:instagram:read offline_access project:read project:refresh"
}

Leia o campo scope: é o que foi concedido, e ele pode ser menor que o pedido. Guarde os dois tokens no servidor; nenhum deles é para o navegador.

O queValidade
Código de autorização10 minutos, uso único
Token de acesso1 hora (expires_in 3600)
Token de renovação90 dias, e gira a cada uso

Passo 4 — renove antes de o acesso expirar

POST /api/oauth/token
curl -X POST https://crazyleads.com.br/api/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=SEU_TOKEN_DE_RENOVACAO \
  -d client_id=SEU_CLIENT_ID

A resposta tem a mesma forma da troca: um token de acesso novo e um token de renovação novo. O anterior deixa de valer no mesmo instante, e todos os tokens de acesso emitidos por ele são revogados. Grave o novo antes de usá-lo.

Reenviar um token de renovação já usado revoga a cadeia inteira. A resposta é invalid_grant · refresh_token replay detected — entire chain revoked: todos os tokens daquela autorização morrem e a pessoa precisa autorizar de novo. É proteção contra token roubado, mas ela dispara sozinha quando duas instâncias do seu app renovam ao mesmo tempo ou quando uma renovação falha no meio e você tenta de novo com o token antigo. Deixe uma renovação por vez, e nunca use o token velho depois de receber o novo.

Passo 5 — a primeira chamada

curl https://crazyleads.com.br/api/v1/projects \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO"
200
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "name": "Mentoria da Ana",
      "status": "ready",
      "created_by_me": false
    }
  ],
  "next_cursor": null
}

O token vale para um projeto — o que a pessoa escolheu na tela. Esta lista tem zero ou um item, e o id é o que você usa nas rotas que levam o projeto no caminho.

Pedir que a pessoa conecte uma fonte

Quando o seu app precisa de uma fonte que o projeto ainda não tem, peça o escopo de conexão dela junto com os demais. Depois de autorizar, a pessoa vai direto para o fluxo de conexão daquela fonte, numa tela do CrazyLeads sem navegação, e volta ao seu app no fim, com o code, pela sua URI de retorno. Cada fonte segue o próprio caminho:

EscopoFonteO que a pessoa faz
source:instagram:connectInstagramEntra com a Meta e escolhe as contas profissionais e as Páginas.
source:meta_ads:connectFacebook AdsEntra com a Meta e escolhe a conta de anúncios.
source:hotmart:connectHotmartCola as credenciais da API da Hotmart.
source:kiwify:connectKiwifyCola as chaves da conta da Kiwify.
source:activecampaign:connectActiveCampaignCola a URL e a chave da conta do ActiveCampaign.
source:database:connectBanco de dadosInforma o Postgres, com um usuário somente leitura, e testa a conexão.
source:pixel:connectPixelCria o Pixel do projeto e recebe o código para colar no site. Conta como conectado ao ser criado; os eventos chegam depois da instalação.
source:google_ads:connectGoogle AdsEntra com a conta Google e escolhe a conta de anúncios.
source:tally:connectTallyCola a chave de API do Tally e escolhe os formulários.
source:webhook:connectWebhookCria um endereço de recebimento e o copia para a ferramenta que vai enviar os eventos.
source:form:connectFormulário do ActiveCampaignEscolhe o formulário do ActiveCampaign que o CrazyLeads vai receber.
source:zoom:connectZoomEntra com a conta do Zoom e autoriza a leitura de webinars e reuniões.
source:sendgrid:connectSendGridCola a chave de API do SendGrid e copia o endereço de eventos para o painel do SendGrid.

O escopo de conexão tem de estar no registro do seu app, como qualquer outro, e não libera nenhuma rota da /v1: ele só diz o que a pessoa vai conectar agora. Para ler o que chegar da fonte, peça também o escopo de leitura, como project:read ou data:instagram:read. Seu app nunca vê a credencial que a pessoa digita.

Pedido mais de um escopo de conexão, as fontes vêm uma depois da outra, na ordem da tabela, e a pessoa pode pular qualquer uma. A que já está conectada no projeto é pulada sozinha; se todas já estiverem, a autorização segue direto para o seu app, como sem escopo de conexão.

A autorização fica guardada enquanto a pessoa conecta: 30 minutos, renovados sozinhos enquanto ela está na tela, até 2 horas depois de autorizar. Passado isso, a tela avisa e ela precisa autorizar de novo; o que já foi conectado continua conectado. Para pedir outra fonte mais tarde, mande a pessoa de novo pelo /oauth/authorize com o escopo de conexão dela.

Por compatibilidade, data:instagram:read num projeto sem Instagram conectado também leva a pessoa à conexão do Instagram. Prefira pedir source:instagram:connect de forma explícita.

3. Os escopos, e o que a pessoa lê ao lado de cada um

A tela de consentimento mostra uma linha por escopo pedido, com a frase abaixo — nem mais, nem menos. As escritas que existem são estreitas — data:analytics:write só edita o que o seu app criou, e data:instagram:write só modera comentários da conta conectada — e nenhum escopo é desmarcável: ou a pessoa autoriza o que o seu app precisa para existir, ou cancela.

EscopoO que a pessoa vêO que libera
project:readLer os dados deste projetofontes, leads, eventos e métricas — as de hoje e as que você conectar depoisGET /api/v1/projects e GET /api/v1/sources
data:instagram:readLer os dados do Instagramcontas, publicações e métricas já coletadasAs rotas de instagram/accounts e media, e a leitura dos comentários (GET …/media/{media_id}/comments)
data:instagram:writeModerar os comentários do seu Instagramcomentar nos seus posts, responder, ocultar e apagar comentários em nome da sua contaPOST …/media/{media_id}/comments, POST …/comments/{comment_id}/replies, POST …/comments/{comment_id}/hide e DELETE …/comments/{comment_id}. Não inclui a leitura: peça também data:instagram:read
data:analytics:readLer as consultas e os datasets deste projetoos resultados que o CrazyLeads já calculou — ele lê, não cria nem apagaqueries, queries/{id}/result, datasets e datasets/{id}/rows
data:sql:readLer as tabelas deste projetoInclui os dados de contato dos seus leads. Ele lê; não muda nada.GET /api/v1/tables e POST /api/v1/sql/query
data:analytics:writeCriar e editar consultas neste projetoAs consultas ficam suas. Ele só edita as que ele mesmo criou.POST /api/v1/queries, PATCH …/queries/{id} e DELETE …/queries/{id}
data:leads:readLer os seus leadsnome, e-mail, telefone e o histórico de cada pessoa: páginas, campanhas e comprasGET /api/v1/leads, POST …/leads/search, …/leads/{id}, …/leads/{id}/timeline e GET /api/v1/lead-fields
data:audiences:readLer os seus públicosa regra, o status e o tamanho de cada umGET /api/v1/audiences e …/audiences/{id}; os membros (…/members) exigem também data:leads:read, porque são pessoas
data:envios:readLer os seus enviospara onde os eventos vão e se estão chegandoGET /api/v1/envios, …/envios/{id}/stats e GET /api/v1/destinations
data:commerce:readLer as suas vendasprodutos, pedidos, comissões, assinaturas e carrinhos abandonados da Hotmart e da Kiwify, sem dados do compradorGET /api/v1/commerce/products, …/orders, …/orders/{id}, …/commissions, …/subscription-events, …/abandoned-carts e …/summary
data:commerce:read_piiVer quem comprounome, e-mail e telefone de quem aparece nas vendas; documento e endereço nunca saemNenhuma rota sozinho: junto de data:commerce:read, inclui o comprador em …/orders, …/subscription-events e …/abandoned-carts
data:ads:readLer os seus anúncioscampanhas, conjuntos, anúncios e o desempenho diário do Meta Ads e do Google AdsGET /api/v1/ads/campaigns, …/adsets, …/ads, …/insights e …/breakdowns
project:refreshPedir que os dados sejam atualizadosele pede a atualização; o custo e o limite continuam sendo do seu projetoPOST …/queries/{id}/refresh e POST …/sources/{id}/sync
offline_accessManter o acesso quando você não estiver por pertoé o que permite acompanhar os dados fora de uma visitaPeça quando o seu app for ler dados sem a pessoa presente — é o que ela vê e aceita.
source:instagram:connectPedir a conexão de uma conta do Instagramele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Instagram. Veja pedir que a pessoa conecte uma fonte.
source:meta_ads:connectPedir a conexão da sua conta de anúncios da Metaele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Facebook Ads. Veja pedir que a pessoa conecte uma fonte.
source:hotmart:connectPedir a conexão da sua conta da Hotmartele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar a Hotmart. Veja pedir que a pessoa conecte uma fonte.
source:kiwify:connectPedir a conexão da sua conta da Kiwifyele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar a Kiwify. Veja pedir que a pessoa conecte uma fonte.
source:activecampaign:connectPedir a conexão da sua conta do ActiveCampaignele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o ActiveCampaign. Veja pedir que a pessoa conecte uma fonte.
source:database:connectPedir a conexão de um banco de dados seuele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o banco de dados. Veja pedir que a pessoa conecte uma fonte.
source:pixel:connectPedir a instalação do Pixel do CrazyLeads no seu siteele abre o pedido; quem instala continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Pixel. Veja pedir que a pessoa conecte uma fonte.
source:google_ads:connectPedir a conexão da sua conta do Google Adsele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Google Ads. Veja pedir que a pessoa conecte uma fonte.
source:tally:connectPedir a conexão da sua conta do Tallyele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Tally. Veja pedir que a pessoa conecte uma fonte.
source:webhook:connectPedir um endereço para receber eventos de outra ferramentaele abre o pedido; quem cria continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o webhook. Veja pedir que a pessoa conecte uma fonte.
source:form:connectPedir a conexão de um formulário do ActiveCampaignele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o formulário do ActiveCampaign. Veja pedir que a pessoa conecte uma fonte.
source:zoom:connectPedir a conexão da sua conta do Zoomele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o Zoom. Veja pedir que a pessoa conecte uma fonte.
source:sendgrid:connectPedir a conexão da sua conta do SendGridele abre o pedido; quem conecta continua sendo vocêNenhuma rota. Depois de autorizar, a pessoa vai direto conectar o SendGrid. Veja pedir que a pessoa conecte uma fonte.
project:createCriar um projeto novopara organizar os dados que ele vai lerReservado. Hoje o projeto é criado pela própria pessoa na tela de autorização — use project_name para sugerir o nome.

Escopo que o seu registro não inclui é descartado em silêncio na autorização; escopo que a tela ainda não sabe nomear aparece pelo nome técnico, nunca some. Em qualquer caso, o que vale é o scope da resposta do token.

4. Referência da API /v1

Base: https://crazyleads.com.br/api/v1. Toda chamada leva Authorization: Bearer SEU_TOKEN_DE_ACESSO. O projeto vem do token, nunca de um parâmetro seu; nas rotas que levam {prj} no caminho, ele tem de ser o mesmo projeto da concessão — outro qualquer é 404.

Cabeçalho de respostaSempre
X-Request-IdEm todo desfecho, sucesso ou erro. Cite-o ao falar com o suporte.
Cache-Control: private, no-storeNada daqui pode ser guardado por proxy ou navegador.
WWW-AuthenticateNos 401 e 403, no formato Bearer error="…" — é como o seu cliente OAuth descobre que precisa renovar em vez de tratar como falha de negócio.

Paginação

As listas paginam por cursor: limit de 1 a 100 (padrão 50) e next_cursor na resposta — repita passando ?cursor= até ele vir null. O cursor é opaco e assinado: não interprete o conteúdo nem monte um na mão. limit fora da faixa é 400, e não é aparado em silêncio — pedir 1.000 e receber 100 faria você concluir que a coleção acabou.

Datas

Todo horário sai em ISO 8601 em UTC. O deslocamento aparece como Z, +00:00 ou +0000 conforme a origem do campo (carimbos do CrazyLeads, do banco e da Meta, respectivamente). Use um parser que leia o deslocamento; não compare sufixo nem converta para o fuso de Brasília por conta própria. As demais convenções — erros, versão, idempotência — estão em Convenções da API.

As rotas

RotaEscopoO que faz
GET /api/v1/projectsproject:readO projeto da concessão (0 ou 1 item).
GET /api/v1/sourcesproject:readAs fontes conectadas ao projeto.
GET /api/v1/tablesdata:sql:readO catálogo de tabelas do projeto, com colunas e tipos.
POST /api/v1/sql/querydata:sql:readRoda um SELECT contido no projeto e devolve as linhas.
POST /api/v1/sources/{id}/syncproject:refreshPede a sincronização de uma fonte.
GET /api/v1/projects/{prj}/instagram/accountsdata:instagram:readAs contas de Instagram já coletadas.
GET /api/v1/projects/{prj}/instagram/accounts/{ig_id}/mediadata:instagram:readAs mídias de uma conta, com métricas.
GET /api/v1/projects/{prj}/instagram/accounts/{ig_id}/media/{media_id}/commentsdata:instagram:readOs comentários de uma mídia, com as respostas, lidos na hora da Meta.
POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/media/{media_id}/commentsdata:instagram:writeComenta numa mídia da própria conta. Exige Idempotency-Key.
POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}/repliesdata:instagram:writeResponde a um comentário. Exige Idempotency-Key.
POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}/hidedata:instagram:writeOculta ou volta a mostrar um comentário.
DELETE /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}data:instagram:writeApaga um comentário.
GET /api/v1/queriesdata:analytics:readAs consultas do projeto e o estado da última materialização.
GET /api/v1/queries/{id}/resultdata:analytics:readO resultado materializado: Parquet assinado ou JSON.
POST /api/v1/queriesdata:analytics:writeCria uma consulta neste projeto.
PATCH /api/v1/queries/{id}data:analytics:writeEdita nome, SQL ou cadência de uma consulta que o próprio app criou.
DELETE /api/v1/queries/{id}data:analytics:writeArquiva uma consulta que o próprio app criou.
POST /api/v1/queries/{id}/refreshproject:refreshPede uma nova materialização.
GET /api/v1/datasetsdata:analytics:readOs datasets ativos do projeto.
GET /api/v1/datasets/{id}/rowsdata:analytics:readAs linhas de um dataset.
GET /api/v1/leadsdata:leads:readOs leads do projeto, com o e-mail e o telefone principais. Serve de feed, com updated_since.
POST /api/v1/leads/searchdata:leads:readEncontra um lead por e-mail ou telefone, enviados no corpo.
GET /api/v1/leads/{id}data:leads:readUm lead: todos os e-mails, telefones e campos.
GET /api/v1/leads/{id}/timelinedata:leads:readO que o lead fez, do mais recente ao mais antigo.
GET /api/v1/lead-fieldsdata:leads:readOs campos personalizados de lead do projeto.
GET /api/v1/audiencesdata:audiences:readOs públicos, com status e tamanho.
GET /api/v1/audiences/{id}data:audiences:readUm público, com a regra.
GET /api/v1/audiences/{id}/membersdata:audiences:read + data:leads:readAs pessoas que estão no público agora.
GET /api/v1/enviosdata:envios:readOs envios do projeto, como a tela os mostra.
GET /api/v1/envios/{id}/statsdata:envios:readEntregas de um envio no período, dia a dia.
GET /api/v1/destinationsdata:envios:readOs destinos: tipo, nome, estado e saúde. O config nunca sai.

GET /api/v1/projects

Sem parâmetros. status é ready ou frozen — um projeto congelado continua listado, mas o dado dele não é atualizado. created_by_me diz se foi o seu app que criou o projeto; hoje é sempre false, porque a criação acontece na tela de autorização. O exemplo está no passo 5.

GET /api/v1/sources

Parâmetros: limit, cursor. Ordem por id.

200
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000002",
      "type": "META",
      "name": "Instagram da mentoria",
      "slug": "instagram_da_mentoria",
      "connected": true,
      "last_synced_at": "2026-09-16T11:04:12+00:00",
      "last_error": null
    }
  ],
  "next_cursor": null
}

type é o tipo da fonte no CrazyLeads (META, HOTMART, PIXEL, WEBHOOK, DATABASE, entre outros). connected falso com last_error preenchido é uma fonte que precisa da atenção da pessoa — o seu app não consegue reconectá-la.

POST /api/v1/sources/{id}/sync

Sem corpo. Pede ao CrazyLeads que sincronize a fonte agora, pelo mesmo caminho que o botão do produto usa. A resposta é 202: o pedido foi aceito e acontece em segundo plano; acompanhe pelo last_synced_at de GET /api/v1/sources.

202
{
  "accepted": true,
  "task": "5b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e",
  "last_synced_at": "2026-09-16T11:04:12+00:00"
}

Sujeito aos tetos de sincronização — veja o 429.

GET /api/v1/tables

O catálogo do projeto: cada tabela com as colunas, os tipos e a fonte que a alimenta. É por aqui que o seu app descobre o que existe antes de escrever qualquer consulta.

200
{
  "data": [
    {
      "name": "meta_jornal_mangueiral_instagram_media",
      "schema": "project__0f1e2d3c_4b5a_6978_8a9b_0c1d2e3f4a5b",
      "qualified_name": "project__0f1e2d3c_4b5a_6978_8a9b_0c1d2e3f4a5b.meta_jornal_mangueiral_instagram_media",
      "entity_type": "stream",
      "source": { "id": "8f2c…", "name": "Meta Organic", "type": "META_PAGES_INSTAGRAM", "slug": "jornal_mangueiral" },
      "row_count": 3412,
      "columns": [
        { "name": "object_id", "type": "text", "nullable": false },
        { "name": "timestamp", "type": "text", "nullable": true }
      ]
    }
  ]
}

row_count é ESTIMATIVA, lida do catálogo do Postgres. Contar linha por linha em toda tabela seria varredura no banco de escrita, e o número serve para dimensionar, não para conferir.

schema é o schema do projeto da concessão, e qualified_name é o nome que o SQL precisa usar: toda tabela citada numa consulta vai com o schema na frente. Use o qualified_name como veio, sem montar o nome à mão.

POST /api/v1/sql/query

Roda um SELECT no projeto e devolve as linhas. O SQL passa por uma guarda que só aceita leitura, exige que toda tabela venha com o schema do projeto (o qualified_name do catálogo) e trabalha com uma lista fechada de funções. Recusa é 400 com um reason de máquina, e é PERMANENTE: o mesmo texto será recusado sempre, então não vale retentar.

pedido
{
  "sql": "WITH recentes AS (SELECT media_type FROM project__0f1e…4a5b.meta_jornal_mangueiral_instagram_media WHERE data->>'timestamp' >= to_char(now() - interval '30 days', 'YYYY-MM-DD')) SELECT media_type, count(*) FROM recentes GROUP BY media_type",
  "limit": 500
}
200
{
  "data": {
    "columns": [{ "name": "campaign", "type": "text" }],
    "rows": [["black-friday", 128]],
    "row_count": 1,
    "truncated": false,
    "elapsed_ms": 412
  }
}

truncated verdadeiro quer dizer que o servidor cortou no teto dele: você NÃO leu tudo. Os reason possíveis na recusa são nao_parseia, mais_de_um_statement, nao_e_select, schema_fora_do_escopo, tabela_sem_schema, funcao_fora_da_allowlist e cte_recursiva quando a guarda recusou a FORMA do SQL, e recusado_pelo_banco quando ele passou pela guarda e foi o Postgres que recusou — nome de coluna que não existe, tipo incompatível. E tempo_esgotado quando a consulta é pesada demais e o servidor a interrompeu. São três ações diferentes: mudar a construção, conferir nomes e tipos, ou reduzir o recorte — período menor, mais filtro, agregar antes. Indisponibilidade não aparece aqui: ela é 503, não traz reason, e nela retentar É o certo.

O que a guarda aceitaDetalhe
WITHAceito, e o nome de uma CTE só vale dentro do alcance em que ela foi declarada. Tabela citada DENTRO da CTE também precisa do schema. WITH RECURSIVE é recusado com cte_recursiva: a recursão não tem limite de linhas por fora e rodaria até o tempo esgotar.
Funçõescount, sum, avg, min, max, coalesce, nullif, case, cast (::), substring, substr, left, right, lower, upper, length, char_length, trim, ltrim, rtrim, concat, split_part, replace, round, floor, ceil, ceiling, abs, greatest, least, date_trunc, extract, date_part, date, to_char, now, current_date, current_time, current_timestamp, localtimestamp, interval, EXISTS, os operadores de JSON -> e ->> e o ~ de expressão regular.
Recorte relativoPara os últimos N dias, compare com now() - interval 'N days' ou com current_date. Numa consulta agendada o recorte anda sozinho a cada atualização.
Recusado sempreEscrita em qualquer nível (inclusive dentro de CTE), FOR UPDATE, função chamada com schema (public.left), apelidos que o Postgres não conhece com esse nome (nvl, ifnull), conversão para tipos de catálogo (::regclass, ::oid) e funções que leem configuração, arquivo, sistema ou outro banco.

A resposta vem em data, como nas outras rotas. Por um tempo os mesmos campos saem também na raiz, para quem integrou antes; eles vão sair. Leia de data.

POST /api/v1/queries

Cria uma consulta no projeto, com a mesma guarda do SQL acima. A consulta fica sendo da PESSOA: o seu app entra como procedência, e é a procedência que decide o que ele pode editar depois. Guarde o id — é ele que as rotas de resultado e de atualização usam.

pedido
{
  "name": "Vendas por campanha",
  "sql": "SELECT …",
  "schedule": { "interval_minutes": 60, "enabled": true }
}

PATCH /api/v1/queries/{id}

Edita nome, SQL ou cadência. Só vale para consulta que o PRÓPRIO app criou. Qualquer outra responde 404, inclusive a que a pessoa escreveu na tela: dizer 403 revelaria que ela existe.

DELETE /api/v1/queries/{id}

Arquiva a consulta, não apaga. Vale a mesma regra de procedência do PATCH: o que o seu app não criou responde 404.

GET /api/v1/projects/{prj}/instagram/accounts

Parâmetros: limit, cursor. Ordem por id (o id da conta na Meta). Campo ausente significa que a Meta não informou — a resposta nunca inventa 0.

200
{
  "data": [
    {
      "id": "17840000000000000",
      "source_id": "00000000-0000-4000-8000-000000000002",
      "username": "exemplo_loja",
      "name": "Exemplo Loja",
      "biography": "Loja de exemplo. Atendimento de segunda a sexta.",
      "profile_picture_url": "https://…",
      "followers_count": 5000,
      "follows_count": 400,
      "media_count": 3000,
      "observed_at": "2026-09-16T11:04:12.318201+00:00"
    }
  ],
  "next_cursor": null
}

GET /api/v1/projects/{prj}/instagram/accounts/{ig_id}/media

Parâmetros: limit, cursor, since e until. Ordem: da mídia mais recente para a mais antiga (por timestamp, o instante da publicação na Meta).

curl "https://crazyleads.com.br/api/v1/projects/PRJ/instagram/accounts/17840000000000000/media?since=2026-09-01T00:00:00%2B0000&limit=50" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO"
200
{
  "data": [
    {
      "id": "17890000000000000",
      "kind": "carousel",
      "media_type": "CAROUSEL_ALBUM",
      "media_product_type": "FEED",
      "timestamp": "2026-09-15T16:33:11+0000",
      "permalink": "https://www.instagram.com/p/…/",
      "thumbnail_url": "https://…",
      "media_url": "https://…",
      "like_count": 40,
      "comments_count": 3,
      "insights": {
        "reach": 500,
        "views": 1200,
        "likes": 40,
        "comments": 3,
        "saved": 12,
        "shares": 5,
        "total_interactions": 60
      },
      "observed_at": "2026-09-16T11:04:40.102938+00:00"
    }
  ],
  "next_cursor": "eyJrIjoi…"
}
CampoLeitura
since / untilRecortam pelo timestamp da publicação e são comparados no MESMO formato em que a Meta o entrega: YYYY-MM-DDTHH:MM:SS+0000 (URL-codifique o +). Outro formato não dá erro — dá recorte errado.
kindreel, carousel, video ou photo, derivado do que a Meta mandou. Valor novo da Meta que não caiba nessa lista faz o campo sumir; media_type e media_product_type crus saem sempre.
media_url / thumbnail_urlmedia_url é o arquivo que a Meta entrega: a imagem numa foto, a do 1º item num carrossel e o vídeo num reel. thumbnail_url é a imagem de capa; em foto e carrossel sem capa própria ele repete o media_url, e nunca aponta para um vídeo. As duas URLs são da Meta e expiram: guarde a mídia, não o endereço.
insightsSempre um objeto, vazio quando ainda não há métrica. As que a coleta pede hoje são reach, views, likes, comments, saved, shares e total_interactions; métrica que a Meta não devolveu não aparece, e métrica nova aparece com o nome cru, sem aviso — leia as chaves que vierem e não valide contra uma lista fechada.
observed_atQuando o CrazyLeads coletou a linha pela última vez. É reescrito a cada sincronização — por isso a paginação não se apoia nele.

GET /api/v1/projects/{prj}/instagram/accounts/{ig_id}/media/{media_id}/comments

Os comentários de uma mídia, cada um com as suas respostas. Escopo data:instagram:read. Ao contrário de contas e mídias, que saem do que o CrazyLeads já coletou, os comentários são lidos na hora, da Meta — por isso aqui limit vai de 1 a 50 (padrão 20), e a ordem é a que a Meta devolve. Parâmetros: limit e cursor; o cursor só vale para a mesma mídia.

curl "https://crazyleads.com.br/api/v1/projects/PRJ/instagram/accounts/17840000000000000/media/17890000000000000/comments?limit=20" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO"
200
{
  "data": [
    {
      "id": "17900000000000001",
      "text": "Quando abre a próxima turma?",
      "username": "cliente_exemplo",
      "timestamp": "2026-09-22T14:03:11+0000",
      "like_count": 2,
      "hidden": false,
      "replies": [
        {
          "id": "17900000000000002",
          "text": "Semana que vem! Te chamo no direct.",
          "username": "exemplo_loja",
          "timestamp": "2026-09-22T14:10:40+0000",
          "like_count": 0,
          "hidden": false
        }
      ]
    }
  ],
  "next_cursor": "eyJrIjoi…"
}

A mídia tem de ser uma das que o CrazyLeads já coletou desta conta (as de …/media); outra qualquer é 404. Campo que a Meta não mandou não aparece, e replies vem sempre, vazio quando não há resposta.

POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/media/{media_id}/comments

Comenta numa mídia da própria conta, em nome dela. Escopo data:instagram:write. O corpo leva só message: texto de 1 a 2.200 caracteres, aparado nas pontas; outro campo é ignorado. O cabeçalho Idempotency-Key é obrigatório: sem ele, 400 (veja repetir sem comentar duas vezes).

curl -X POST "https://crazyleads.com.br/api/v1/projects/PRJ/instagram/accounts/17840000000000000/media/17890000000000000/comments" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO" \
  -H "Idempotency-Key: 6f1c1d2e-7a8b-4c9d-8e0f-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{"message": "Obrigado por comentar! As inscrições abrem segunda."}'
201
{ "id": "17900000000000003" }

POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}/replies

Responde a um comentário, em nome da conta. Mesmo escopo, mesmo corpo (message, de 1 a 2.200 caracteres) e a mesma Idempotency-Key obrigatória. A Meta não aninha mais de um nível: responder a uma resposta cai no mesmo fio.

curl -X POST "https://crazyleads.com.br/api/v1/projects/PRJ/instagram/accounts/17840000000000000/comments/17900000000000001/replies" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO" \
  -H "Idempotency-Key: 0b7e2a3c-4d5e-4f60-8a9b-1c2d3e4f5a6b" \
  -H "Content-Type: application/json" \
  -d '{"message": "Semana que vem! Te chamo no direct."}'
201
{ "id": "17900000000000004" }

POST /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}/hide

Oculta ({"hidden": true}) ou volta a mostrar ({"hidden": false}) um comentário. hidden é obrigatório e booleano: "true" em texto é 400. O comentário oculto some para o público, mas continua existindo e aparece na lista com "hidden": true.

pedido
{ "hidden": true }
200
{ "id": "17900000000000001", "hidden": true }

DELETE /api/v1/projects/{prj}/instagram/accounts/{ig_id}/comments/{comment_id}

Apaga o comentário na Meta, de vez: não há lixeira do lado do Instagram. Sem corpo.

200
{ "id": "17900000000000001", "deleted": true }

Moderação: o que não existe e o que pode dar errado

A API do Instagram não permite editar comentário — nem o da própria conta. Por isso não há PATCH aqui. Para corrigir um texto, apague e comente de novo.
SituaçãoResposta
Conta conectada sem Instagram Login (por exemplo, pela Página do Facebook)409 account_not_supported, com reason gerenciamento_indisponivel. Não repita: a Meta só modera comentário de conta conectada por Instagram Login.
Conexão caída, ou feita sem a permissão de comentários409 reconnect_required, com reconnect: true e reason token_invalido ou permissao_de_comentarios_ausente. Não é o seu token: a pessoa reconecta o Instagram no CrazyLeads.
Comentar ou responder sem Idempotency-Key; message vazia ou com mais de 2.200 caracteres; hidden que não é booleano; corpo que não é JSON400 invalid_request, sem chamar a Meta.
ig_id, media_id ou comment_id que não são só dígitos; conta que não é do projeto; mídia que o CrazyLeads não coletou404 not_found. Quando o CrazyLeads sabe o motivo, ele vem em reason (por exemplo, midia_nao_encontrada).
Limite de chamadas da própria Meta429 rate_limited, com reason limite_da_meta e retry_at null: a Meta não diz quando abre. Espere e tente de novo, com espera crescente.
Teto de ações externas do projeto429 rate_limited, com reason teto_de_execucoes_externas_por_minuto, retry_at e Retry-After. São 100 por minuto por projeto, somando todos os apps; comentar, responder, ocultar e apagar contam, ler não.
Meta fora do ar503 unavailable, com reason meta_indisponivel. Repita depois.

Repetir sem comentar duas vezes: Idempotency-Key

O cabeçalho Idempotency-Key é obrigatório para comentar e responder, e opcional para ocultar e apagar. Criar é publicar na rede da pessoa, e repetir sem chave publicaria duas vezes; ocultar e apagar só mudam o estado de um comentário, e repetir não duplica nada. Com a chave, a nova tentativa devolve a mesma resposta da primeira.

Mande uma chave nova por ação — um UUID serve — e, se a chamada estourar o tempo ou cair sem resposta, repita com a mesma chave e o mesmo corpo: se a primeira já tinha chegado, a resposta dela volta de novo e o comentário não é publicado duas vezes.

curl -X POST "https://crazyleads.com.br/api/v1/projects/PRJ/instagram/accounts/17840000000000000/comments/17900000000000001/hide" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO" \
  -H "Idempotency-Key: 3c9d0e1f-2a3b-4c5d-8e6f-7a8b9c0d1e2f" \
  -H "Content-Type: application/json" \
  -d '{"hidden": true}'
SituaçãoResposta
Comentar ou responder sem a chave400 invalid_request, dizendo que o cabeçalho é obrigatório. Nada é enviado à Meta.
Ocultar ou apagar sem a chaveFunciona normalmente; só não há proteção contra a repetição.
Mesma chave, mesmo pedido, já concluídoA mesma resposta da primeira vez (por exemplo, o 201 com o mesmo id). Nada é publicado de novo.
Mesma chave com outro corpo, ou em outra rota409 idempotency_conflict. Use uma chave nova para um pedido diferente.
Mesma chave enquanto o primeiro pedido ainda está em andamento409 idempotency_conflict com retry_at e Retry-After: repita depois desse instante.
Comentar ou responder voltou 503 porque a Meta não confirmou a publicação (o tempo estourou depois do envio)A primeira tentativa pode ter publicado, então a mesma chave fica em andamento por até 24 horas e a repetição com ela recebe 409, sem publicar de novo. Liste os comentários da mídia antes de decidir: se o comentário não saiu, publique com uma chave nova.
Chave fora da forma: vazia, com mais de 128 caracteres, ou com algo além de letras, dígitos, -, _, . e :400 invalid_request, sem chamar a Meta.

GET /api/v1/queries

As consultas que a pessoa criou pela tela do CrazyLeads — sobre Instagram, Hotmart, pixel, qualquer fonte. Parâmetros: limit, cursor. É aqui que o seu app descobre o que dá para ler.

200
{
  "data": [
    {
      "id": "cmfk3x9a80001l204h8v2p7qz",
      "name": "Vendas por produto",
      "status": "ready",
      "has_result": true,
      "last_materialized_at": "2026-09-16T09:00:41.000Z",
      "row_count": 2631,
      "bytes": 184320
    }
  ],
  "next_cursor": null
}
CampoLeitura
statusready, running, pending ou failed — o estado da ÚLTIMA rodada de materialização.
has_resultSe há um resultado para ler. É independente do status: uma rodada nova que falhou não apaga o Parquet anterior, então failed com has_result true significa "dá para ler, mas está velho" — olhe last_materialized_at.
row_count / bytesDo último resultado. Ausentes quando ninguém mediu ainda.

GET /api/v1/queries/{id}/result

O resultado materializado de uma consulta. Dois formatos, escolhidos por format; o padrão é o Parquet, porque o caso de uso é montar uma área analítica, não iterar página. Formato desconhecido é 400. Consulta sem resultado ainda, ou de outro projeto, é 404.

curl "https://crazyleads.com.br/api/v1/queries/cmfk3x9a80001l204h8v2p7qz/result?format=parquet" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO"
200 · format=parquet
{
  "format": "parquet",
  "url": "https://…/….parquet?X-Amz-Algorithm=AWS4-HMAC-SHA256&…",
  "expires_at": "2026-09-16T12:15:00+00:00",
  "materialized_at": "2026-09-16T09:00:41+00:00",
  "row_count": 2631,
  "bytes": 184320
}

A url é assinada e vale 15 minutos; baixe na hora e não a guarde nem a repasse — quem a tiver, lê. Peça uma nova a cada download. materialized_at é o carimbo do arquivo: é ele que responde “de quando é este dado”.

curl "https://crazyleads.com.br/api/v1/queries/cmfk3x9a80001l204h8v2p7qz/result?format=json&limit=100" \
  -H "Authorization: Bearer SEU_TOKEN_DE_ACESSO"
200 · format=json
{
  "format": "json",
  "materialized_at": "2026-09-16T09:00:41+00:00",
  "data": [
    { "produto": "Plano Anual", "vendas": 1830, "receita": 3641700.00 },
    { "produto": "Plano Mensal", "vendas": 801, "receita": 159399.00 }
  ],
  "next_cursor": "eyJrIjoi…"
}

O JSON pagina sobre o mesmo retrato do Parquet — nunca re-executa o SQL da pessoa. Se o resultado for materializado de novo no meio da sua paginação, a página seguinte responde 400 pedindo para recomeçar: é isso que garante que você nunca cola a página 3 do retrato novo na página 2 do antigo. As colunas são as que a pessoa escreveu na consulta.

POST /api/v1/queries/{id}/refresh

Sem corpo. Pede uma nova materialização pelo mesmo caminho que o botão “Atualizar agora” do produto. 202: aceito, acontece em segundo plano; acompanhe pelo status e last_materialized_at em GET /api/v1/queries.

202
{
  "accepted": true,
  "task": "3f4a5b6c7d8e9f0a1b2c3d4e5b0c1d2e",
  "materialized_at": "2026-09-16T09:00:41+00:00"
}

materialized_at é o carimbo do resultado atual, o que você continua lendo enquanto a nova rodada não termina; null se nunca houve um. Sujeito aos tetos de materialização — veja o 429.

GET /api/v1/datasets

Os datasets ativos do projeto. Parâmetros: limit, cursor.

200
{
  "data": [
    {
      "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "slug": "produtos",
      "name": "Produtos",
      "description": "Catálogo com preço de tabela",
      "row_count": 118,
      "version": 3,
      "updated_at": "2026-09-10T18:22:05+00:00"
    }
  ],
  "next_cursor": null
}

row_count é a estimativa que o banco mantém para a tabela, não uma contagem feita na hora: pode ficar defasada logo depois de uma importação grande e sai null num dataset que ainda não foi medido. Para o número exato, percorra rows.

GET /api/v1/datasets/{id}/rows

As linhas, na ordem do id de cada uma. Parâmetros: limit, cursor. As colunas são as do schema publicado do dataset. Dataset de outro projeto é 404.

200
{
  "data": [
    { "id": "8f14e45f-ceea-467a-9e6b-1f0e8f0b5d21", "produto": "Plano Anual", "valor": 1990.00 },
    { "id": "a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d", "produto": "Plano Mensal", "valor": 199.00 }
  ],
  "next_cursor": null
}

GET /api/v1/leads

Os leads do projeto, na ordem de updated_at e id. Parâmetros: limit, cursor e updated_since, um instante ISO 8601 COM fuso (sem fuso é 400). Use updated_since para buscar só o que mudou desde a última leitura: um lead alterado durante a paginação reaparece mais adiante, nunca some.

200
{
  "data": [
    {
      "id": "2f0c8a1e-6b1d-4c6e-9a51-3b7e9d0a1c22",
      "status": "active",
      "name": "Maria Souza",
      "email": "maria@exemplo.com.br",
      "phone": "+5561999990000",
      "is_identified": true,
      "created_at": "2026-09-01T10:00:00+00:00",
      "updated_at": "2026-09-18T10:00:00.123456+00:00"
    }
  ],
  "next_cursor": "eyJrIjoi…"
}

Encontra um lead por {"email": "…"} OU {"phone": "+55…"}, sempre no corpo: na URL, o contato ficaria nos registros de todo proxy do caminho. Igualdade exata, sem curinga, até 10 leads; data vem vazio quando ninguém tem aquele contato. Mandar os dois, ou nenhum, é 400.

GET /api/v1/leads/{id}

Além dos campos da lista, emails e phones (cada um com value e is_primary), attributes (namespace, key, value: o valor mais recente de cada campo) e merged_into, preenchido quando o lead foi unido a outro.

GET /api/v1/leads/{id}/timeline

O que o lead fez, do mais recente ao mais antigo, com limit e cursor. Cada item tem occurred_at, type, source, utm, transaction e page_url. O amount da transação sai como TEXTO decimal, nunca como número arredondado.

GET /api/v1/lead-fields

Os campos personalizados de lead do projeto: id, key, name, type, mode e description. É o que explica as chaves que aparecem em attributes.

GET /api/v1/audiences

Cada público traz id, slug, name, status, realtime, members (o tamanho), published_at e rule. O status é um dos estados que a tela de Públicos mostra, com o mesmo nome: calculando, sem_destino, sincronizada, sincronizando, pausada, bloqueada, falha ou erro_de_calculo.

GET /api/v1/audiences/{id}

Os mesmos campos da lista, mais description.

GET /api/v1/audiences/{id}/members

Os membros são pessoas, então esta rota exige data:audiences:read e data:leads:read; sem o segundo, a resposta é 403. Cada membro tem os campos de um lead e mais entered_at.

GET /api/v1/envios

Cada item é o Envio como a tela de Envios o mostra: o id é o mesmo que a tela usa e não muda quando a pessoa edita o envio, e um Envio com vários destinos é um item só, com os destinos em destination_ids. Os campos são id, name, signal_type, destination_ids, enabled e created_at.

GET /api/v1/envios/{id}/stats

A estatística recebe period (7d, 14d, 30d ou 90d; padrão 30d) e devolve os totais sent, failed, dead e skipped, last_sent_at e daily, dia a dia. O dia da estatística é contado em UTC: o total bate com a tela, mas o corte de cada dia pode não bater com o horário de Brasília.

GET /api/v1/destinations

Os destinos do projeto: id, type, name, status e health. O config de um destino nunca sai: é nele que fica a credencial da ferramenta.

5. Erros, e o que fazer em cada caso

Todo erro da /v1 tem o mesmo envelope. Decida pelo code; a message é para gente ler e pode mudar.

{
  "error": {
    "code": "insufficient_scope",
    "message": "missing scope data:analytics:read",
    "request_id": "0b7e2a3c-4d5e-4f60-8a9b-1c2d3e4f5a6b"
  }
}
StatuscodeQuandoO que fazer
400invalid_requestParâmetro fora do contrato: limit fora de 1 a 100, cursor inválido, format desconhecido, cursor de um retrato que já foi rematerializado — ou, no sync, uma fonte que não suporta sincronização manual (pixel, webhook, formulário) ou que está desconectada.Corrija a chamada. No caso do retrato trocado, recomece a paginação do resultado do zero. No caso da fonte, é condição permanente: não repita — a pessoa reconecta a fonte pela tela, ou a fonte simplesmente não sincroniza sob demanda.
401invalid_tokenToken ausente, desconhecido, expirado ou revogado — inclusive quando a pessoa revogou o acesso do seu app.Não é erro transitório: repetir a chamada com o mesmo token não resolve. Tente UMA renovação pelo refresh token; se ela responder invalid_grant, a pessoa revogou o seu app (ou a cadeia caiu) — marque a conexão como desfeita e ofereça autorizar de novo.
403insufficient_scopeO token não tem o escopo que a rota exige, é um token mcp:* (que nunca vale na /v1), ou o seu app deixou de estar homologado.Confira o campo scope da resposta do token. Se faltou escopo, peça-o numa nova autorização; se o app foi suspenso, fale com o CrazyLeads.
404not_foundO recurso não existe ou não pertence ao projeto da concessão. A resposta é a mesma nos dois casos, de propósito: a rota não confirma a existência do que não é seu.Trate como "não é meu". Não use 404 para descobrir identificadores.
409no_active_projectO token não carrega um projeto — acontece com tokens antigos emitidos sem escolha de projeto.Refaça a autorização: a tela atual sempre pede um projeto.
409idempotency_conflictA mesma Idempotency-Key foi usada com outro pedido, ou o primeiro pedido com ela ainda está em andamento (aí vem retry_at).Chave nova para pedido novo. Se era o mesmo pedido em andamento, repita com a mesma chave depois de retry_at.
409reconnect_requiredSó nas rotas de comentário do Instagram: a conexão da conta no CrazyLeads caiu (a Meta invalidou o acesso) ou foi feita sem a permissão de comentários. Vem com reconnect: true e com reason — token_invalido ou permissao_de_comentarios_ausente.Não é o seu token: não renove nada. Peça à pessoa que reconecte o Instagram no CrazyLeads; repetir antes disso recebe a mesma resposta.
409account_not_supportedSó nas rotas de comentário do Instagram: a conta não foi conectada por Instagram Login, e a Meta só permite moderar comentários por esse caminho (reason: gerenciamento_indisponivel).Condição permanente para esta conta: não repita. A pessoa precisa conectar a conta com Instagram Login.
429rate_limitedUm teto de atualização foi atingido. O corpo diz qual (reason), quando abre (retry_at — ausente quando não há quando: teto zerado pela administração), o limite e o quanto já foi observado.Espere até retry_at (ou o Retry-After, quando vier). Sem retry_at, não espere: a operação está bloqueada por decisão administrativa e só o CrazyLeads libera.
503unavailableO motor não conseguiu servir agora: o resultado materializado, a paginação ou o dado do Instagram estão temporariamente indisponíveis.Repita depois, com espera crescente. Não é erro do seu lado e não é 404: o dado pode existir.

O 429: os tetos

Materializar e sincronizar custam — uma materialização pode varrer centenas de gigabytes do projeto da pessoa, e a conta é dela, não do seu app — e ler lead expõe dado pessoal. Por isso essas rotas têm tetos. Eles são disjuntores, não limite de produto: existem para que um laço de retry no seu app não esvazie a cota de um projeto nem copie a base inteira de uma vez.

429 · um teto com hora para abrir
{
  "error": {
    "reason": "intervalo_minimo",
    "retry_at": "2026-09-16T09:15:41+00:00",
    "limit": 15,
    "observed": 1,
    "code": "rate_limited",
    "message": "(frase para gente ler — ilustrativa, pode mudar; decida por reason)",
    "request_id": "…"
  }
}
429 · teto zerado pela administração: não há quando
{
  "error": {
    "reason": "teto_diario_de_syncs",
    "limit": 0,
    "observed": 0,
    "code": "rate_limited",
    "message": "(ilustrativa)",
    "request_id": "…"
  }
}
CampoSignificado
reasonQual teto segurou, de uma lista fechada. No refresh de consulta: intervalo_minimo (o app já pediu ESTA consulta há menos de 15 minutos — outra consulta do mesmo projeto não conta) ou teto_diario_do_projeto (o projeto já gastou a cota do dia de materializações, somando todos os apps). No sync de fonte: intervalo_minimo_de_sync (o app já sincronizou ESTA fonte há pouco) ou teto_diario_de_syncs (a cota do dia de sincronizações do projeto). Nas duas rotas: indisponivel (o CrazyLeads não conseguiu conferir os tetos e recusou por segurança). Nas rotas de lead (lista, busca, detalhe, linha do tempo e membros de público): teto_diario_de_linhas_de_lead (o seu app já leu 50.000 linhas de lead deste projeto hoje) ou teto_de_leitura_por_minuto (120 leituras no último minuto). Nas rotas que moderam comentário do Instagram: teto_de_execucoes_externas_por_minuto (o projeto já fez 100 ações externas no último minuto, somando todos os apps) ou limite_da_meta (o limite é da própria Meta, e aí retry_at vem null).
retry_atQuando o pedido volta a ser aceito. Quando mais de um teto está segurando, é o que abre MAIS TARDE — repetir antes dele é recusa garantida. Pode NÃO vir: com um teto zerado pela administração não há quando, e o campo fica ausente (trate ausente e null do mesmo jeito). Aí esperar não adianta — a operação do seu app está bloqueada até o CrazyLeads mudar o número. Quando retry_at existe, a resposta pode trazer também o cabeçalho Retry-After, em segundos; o corpo é o que está garantido.
limit / observedlimit é o valor do teto que segurou (minutos nos intervalos; pedidos nos tetos diários); observed é quantos pedidos do dia já foram contados no projeto — materializações ou sincronizações, conforme a rota. No bloqueio administrativo os dois vêm 0.
TetoValorConta-se por
Intervalo mínimo entre materializações15 minutosapp × projeto × consulta
Materializações por dia100 materializaçõesprojeto, somando todos os apps
Consultas agendadas25 consultas agendadasapp × projeto
Intervalo mínimo entre sincronizações15 minutosapp × fonte
Sincronizações por dia50 sincronizaçõesprojeto, somando todos os apps
Linhas de lead por dia50.000 linhasapp × projeto, somando lista, busca, detalhe, linha do tempo e membros
Leituras de lead por minuto120 leiturasapp × projeto, nos últimos 60 segundos
Ações externas por minuto (comentar, responder, ocultar e apagar comentário no Instagram)100 açõesprojeto, somando todos os apps, nos últimos 60 segundos

O dia dos tetos diários é contado em UTC: a cota vira às 21h no horário de Brasília, não à meia-noite. Os valores são ajustáveis pelo CrazyLeads, para todos os apps ou para um app específico, sem novo deploy — e um teto em zero é o jeito de bloquear uma operação de um app. O teto diário de linhas é conferido antes da leitura e contado depois dela, porque o número de linhas só se conhece quando a página volta; por isso o dia pode passar do teto em até uma página por chamada simultânea. Um 404 (id que não existe no projeto) também conta como leitura. Rotas de leitura fora desta tabela não têm teto anunciado, mas trate 429 como resposta possível em qualquer rota.

6. Revogar

A pessoa revoga o acesso do seu app em Preferências → Aplicativos conectados (/account), a qualquer momento e sem avisar você. Ao revogar, todos os tokens do par — o seu app e ela — são marcados revogados de uma vez: os de acesso e os de renovação, inclusive os já expirados. Do seu lado, o efeito é um só: a chamada seguinte falha com 401 invalid_token, e a renovação pelo refresh token responde 400 invalid_grant. Trate isso como estado normal, não como erro transitório: pare de chamar, marque a conexão como desfeita no seu produto e ofereça autorizar de novo. Não repita a chamada, não tente outro token da mesma cadeia — nenhum deles volta a valer.

Quando é o seu app que encerra a relação — a pessoa desconectou a conta no seu produto —, revogue você mesmo. Deixar token vivo depois que ela saiu é o tipo de coisa que aparece numa auditoria:

POST /api/oauth/revoke
curl -X POST https://crazyleads.com.br/api/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d token=SEU_TOKEN_DE_RENOVACAO \
  -d token_type_hint=refresh_token \
  -d client_id=SEU_CLIENT_ID

Revogar o token de renovação derruba também todos os tokens de acesso emitidos por ele. A resposta é 200 sem corpo, inclusive para um token que já não valia. Há ainda o terceiro caminho: o CrazyLeads suspender o seu app — aí a resposta passa a ser 403 insufficient_scope, e ela volta a ser 200 se a homologação for restabelecida.