Beta fechado

API da Gaio

Conecte os seus sistemas à sua conta da Gaio: leia e atualize contatos, acompanhe conversas, envie mensagens e opere as suas automações por código.

Contatos, conversas, automações e webhooks já estão no beta fechado: veja o que a API cobre.

O pedido de acesso abre uma conversa com o suporte da Gaio no WhatsApp.

Nesta página

Como obter acesso

Durante o beta fechado, a API é liberada conta por conta.

  1. Fale com a gente

    Chame o suporte da Gaio no WhatsApp e diga o @ da conta que vai usar a API.

    Abrir o WhatsApp
  2. Liberamos o acesso

    Liberamos a API para a sua conta e avisamos você.

  3. Crie a sua chave

    O dono da conta cria a chave no console da Gaio, em Configurações › Desenvolvedores › Chaves de API.

O que a API cobre

Tudo isto já está disponível na /v1, no beta fechado:

  • Contatos, etiquetas e campos personalizados: listar, buscar e sincronizar contatos, criar contatos do WhatsApp, pôr e tirar etiquetas, preencher e limpar campos personalizados, listar, criar, renomear e excluir etiquetas, saber quem tem uma etiqueta, e listar as conexões e os campos personalizados.
  • Conversas e mensagens: ler as conversas, as mensagens e a mídia delas, e enviar mensagens com texto, botões de link e de resposta, mídia, cards e templates do WhatsApp.
  • Automações: criar, editar, validar, publicar, despublicar e excluir, consultar as versões, acompanhar as execuções e iniciar uma execução para um contato.
  • Pastas e listas de respostas das automações: criar, renomear, mover e excluir pastas, pôr as automações nelas, e criar, alterar e excluir as listas de respostas públicas aos comentários.
  • Webhooks: o seu sistema é avisado na hora quando algo acontece na conta.

A API cresce dentro da /v1: uma parte nova chega como rotas novas e aparece na referência da API assim que estiver no ar.

Configuração

Criar uma chave

No console da Gaio, vá em Configurações › Desenvolvedores › Chaves de API. O item Desenvolvedores só aparece para o dono da conta, e só depois que a API é liberada para ela.

Clique em Criar chave e preencha:

  • Nome: onde a chave vai ser usada, como "Integração com o CRM". É o que identifica a chave na lista.
  • Permissões: os escopos da chave. Marque só o que a integração precisa.
  • Validade: 30, 60, 90 ou 180 dias, ou 1 ano. Toda chave expira: não existe chave sem validade.

Só o dono da conta, entrando com o próprio login, cria e revoga chaves. Membros da equipe não têm essa opção, e o suporte da Gaio também não, mesmo quando está acessando a sua conta: a chave continua funcionando depois que a sessão termina, então só quem responde pela conta pode criá-la.

Cada conta pode ter até 25 chaves ativas ao mesmo tempo. Chaves revogadas ou expiradas não contam para esse limite.

Guardar a chave

A chave aparece completa uma única vez, logo depois de criada. Copie e guarde em um gerenciador de segredos ou em uma variável de ambiente do servidor que vai chamar a API. A Gaio não guarda a chave em si, então não tem como mostrá-la de novo: se você perder a chave, revogue e crie outra.

Nunca coloque a chave:

  • em código que roda no navegador ou no celular de quem usa o seu produto;
  • em um repositório de código, mesmo que seja privado;
  • em uma URL, porque URLs ficam registradas em históricos e logs.

Quem tem a chave age em nome da sua conta, dentro das permissões dela. Se uma chave vazar, revogue na hora.

A chave tem o formato gaio_ seguido de 46 letras e números. O prefixo fixo ajuda a encontrar uma chave vazada, por exemplo com uma regra na varredura de segredos do seu repositório.

Fazer a primeira chamada

A URL base da API é https://api.gaio.social. Envie a chave no cabeçalho Authorization, no formato Bearer:

curl https://api.gaio.social/v1/me \
  -H "Authorization: Bearer $GAIO_API_KEY"

A rota GET /v1/me não pede escopo e funciona com qualquer chave válida. Ela responde com a conta e a chave usadas na chamada:

{
  "account": {
    "id": "…",
    "name": "…",
    "alias": "…"
  },
  "api_key": {
    "id": "…",
    "name": "…",
    "scopes": ["contacts:read"],
    "expires_at": "…"
  }
}

A API é feita para chamadas de servidor para servidor. Ela não libera CORS, então uma página aberta no navegador não consegue chamá-la.

Trocar a chave

Para trocar uma chave sem interromper a integração:

  1. Crie uma chave nova, com as mesmas permissões.
  2. Passe o seu sistema para a chave nova e confira que as chamadas funcionam. A rota GET /v1/me serve para isso.
  3. Revogue a chave antiga.

Faça a troca antes de a chave expirar. A data de expiração aparece na lista de chaves do console e no campo api_key.expires_at de GET /v1/me, que o seu sistema pode acompanhar.

Revogar uma chave

No console, clique em Revogar ao lado da chave. Revogar é definitivo: para reconectar, é preciso criar uma chave nova.

A revogação vale em até 1 minuto. O mesmo vale quando a API é desligada para a conta. Nesse intervalo, chamadas com a chave ainda podem ser aceitas.

Escopos

Cada chave só faz o que os escopos dela permitem. No console, eles aparecem como permissões, na criação da chave.

EscopoNo consoleO que a chave pode fazer
contacts:readLer contatosLer os contatos, as etiquetas, os campos personalizados e as conexões da conta (as contas do Instagram e os números do WhatsApp).
contacts:writeCriar e editar contatosCriar contatos do WhatsApp, pôr e tirar etiquetas, preencher e limpar campos personalizados, e criar, renomear e excluir etiquetas.
conversations:readLer conversasLer as conversas, as mensagens e a mídia delas.
messages:sendEnviar mensagensEnviar mensagens nas conversas (texto, botões de link e de resposta, mídia, cards e templates do WhatsApp), subir a mídia que elas enviam e iniciar uma automação para um contato. As mensagens chegam de verdade.
workflows:readLer automaçõesLer as automações, as versões e as execuções delas, as pastas e as listas de respostas, validar uma automação e ler o catálogo de blocos.
workflows:writeCriar e editar automaçõesCriar automações, editar o rascunho delas e excluí-las, e criar, alterar e excluir pastas e listas de respostas.
workflows:publishPublicar automaçõesPublicar uma automação, que a partir daí responde os seus contatos, e tirá-la do ar. Excluir uma automação publicada também pede este escopo.
webhooks:readLer webhooksLer os endpoints de webhook da conta e as entregas deles.
webhooks:writeGerenciar webhooksCadastrar, alterar e excluir endpoints de webhook, trocar o segredo deles, enviar um evento de teste e reenviar entregas. Cada tipo de evento também pede o escopo que lê os dados dele.

Uma integração com um CRM que sincroniza contatos, põe etiquetas e responde conversas, por exemplo, precisa de contacts:read, contacts:write, conversations:read e messages:send. Para iniciar uma automação para um contato basta messages:send; para encontrar a automação e acompanhar as execuções, workflows:read.

Uma chave fica com exatamente os escopos com que foi criada: não dá para adicionar um escopo depois. Para ter um escopo novo, crie uma chave nova com ele e revogue a antiga.

Uma chamada para uma rota que exige um escopo que a chave não tem recebe 403, com o código insufficient_scope (veja Erros).

Convenções

Formato

As requisições e as respostas são JSON, com os nomes dos campos em snake_case, como contact_id e next_cursor. Os ids nos caminhos são UUIDs, e os filtros de data recebem data e hora no formato ISO 8601, como 2026-10-05T14:30:00Z. Codifique os valores dos parâmetros de consulta; nos exemplos, o --data-urlencode do curl faz isso.

Toda resposta de sucesso traz o cabeçalho Cache-Control: no-store: ela vale só para a chave que fez a chamada, e nenhum cache no caminho deve guardá-la.

Listas

As listas são paginadas por cursor, com os parâmetros limit, de 1 a 100 (o padrão é 50), e cursor. A resposta tem o formato { "data": [...], "next_cursor": "…" }.

Para ler a página seguinte, envie o next_cursor como cursor. Na última página, next_cursor vem null. O cursor é opaco: devolva exatamente o que a página anterior respondeu. Um cursor que não veio daquela lista recebe 400, com o código invalid_cursor.

A exceção é GET /v1/webhooks: a conta tem no máximo 10 endpoints, então a lista vem numa página só, com next_cursor: null. Ela não aceita limit nem cursor, que recebem 400 como qualquer parâmetro de consulta desconhecido.

Idempotência

As rotas que alteram algo aceitam o cabeçalho Idempotency-Key, a chave de idempotência: um texto de 1 a 255 caracteres que identifica a operação. Um UUID é uma boa escolha. Gere uma chave de idempotência nova para cada operação e repita a mesma só nas novas tentativas dela.

Testar um webhook (POST /v1/webhooks/{webhookId}/test) e reenviar uma entrega dele não têm o cabeçalho e podem ser repetidos sem risco: o teste não guarda nada, e um reenvio repetido encontra a entrega já na fila.

Enviar uma mensagem e iniciar uma automação para um contato exigem o cabeçalho, porque o efeito chega a pessoas de fora da conta. A referência marca o cabeçalho como obrigatório nessas duas rotas, e sem ele a resposta é 400, como qualquer requisição fora do formato da rota.

Durante 24 horas, na mesma conta:

  • a mesma chave de idempotência com a mesma requisição devolve a primeira resposta, sem fazer nada duas vezes;
  • a mesma chave de idempotência com uma requisição diferente recebe 422, com idempotency_key_reused;
  • a mesma chave de idempotência enquanto a primeira requisição ainda está em andamento recebe 409, com idempotency_in_progress;
  • uma requisição que falhou libera a chave de idempotência, então repetir com ela executa a requisição de novo.

A exceção é o outcome_unknown, quando não dá para saber se a operação aconteceu: o canal ou o motor de automações não confirmou a tempo depois de receber o pedido (502), ou a primeira requisição com a chave de idempotência parou antes de responder (409). A chave de idempotência guarda essa resposta, e repetir com ela devolve o mesmo erro. Confira o resultado, como as mensagens da conversa ou as execuções da automação, antes de tentar de novo com uma chave de idempotência nova.

Se a chave de idempotência não puder ser conferida, a resposta é 503, com service_unavailable, e nada é executado sem a garantia: tente de novo.

O que fica registrado

As alterações que uma chave faz nas automações (criar, editar, publicar, despublicar e excluir) e nos webhooks (cadastrar, alterar, excluir e trocar o segredo) entram no Log de auditoria da conta como uma alteração feita no console, com o dono da chave como usuário e a própria chave como autora (actor_type: api_key).

As alterações nas etiquetas, nas pastas, nas listas de respostas, nos contatos e na mídia não entram no log de auditoria, como as mesmas alterações feitas no console também não entram. As etiquetas e os valores de campos que a chave muda em um contato aparecem no histórico do contato, no console, com o nome da chave.

Uma mensagem enviada pela API também não entra no log de auditoria: a própria mensagem registra a chave com que foi enviada, e a API a mostra com sender.type: "api".

Contatos

Um contato é uma pessoa em um canal. channel.type diz o canal (instagram ou whatsapp; outros podem aparecer) e channel.connection_id, a conexão da conta por onde o contato chegou. Um contato do Instagram tem instagram.id, que vale para aquela conexão, e instagram.username; um do WhatsApp tem whatsapp.phone, no formato E.164. A mesma pessoa no Instagram e no WhatsApp são dois contatos com o mesmo person_id.

As conexões da conta, as contas do Instagram e os números do WhatsApp, estão em GET /v1/connections.

Sincronizar os contatos

Para a primeira cópia, leia GET /v1/contacts até o fim. A lista vai do contato com a atividade mais antiga para o mais recente.

curl -G https://api.gaio.social/v1/contacts \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -d limit=100
{
  "data": [
    {
      "id": "…",
      "channel": { "type": "whatsapp", "connection_id": "…" },
      "instagram": null,
      "whatsapp": { "phone": "+5511987654321" },
      "name": "…",
      "person_id": "…",
      "tags": [{ "id": "…", "name": "Cliente" }],
      "custom_fields": [
        {
          "field_id": "…",
          "name": "Plano",
          "type": "dropdown",
          "value": "pro"
        }
      ],
      "last_interaction_at": "…",
      "created_at": "…",
      "updated_at": "…"
    }
  ],
  "next_cursor": "…"
}

Enquanto next_cursor não vier null, peça a página seguinte com ele:

curl -G https://api.gaio.social/v1/contacts \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -d limit=100 \
  --data-urlencode "cursor=$NEXT_CURSOR"

Nas sincronizações seguintes, leia só o que mudou com updated_since, marcado alguns minutos antes do início da sincronização anterior, e grave cada contato pelo id, atualizando o que já existe. Um contato que mudou durante a sincronização volta na próxima: ele nunca fica de fora.

curl -G https://api.gaio.social/v1/contacts \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -d limit=100 \
  --data-urlencode "updated_since=2026-10-05T13:55:00Z"

O updated_since tem limites, porque o updated_at é a última atividade do contato (uma mensagem ou uma edição no console), e não a última mudança nos dados dele:

  • as mudanças feitas pela API não movem o updated_at, e importações e ações em massa também não;
  • uma etiqueta ou um valor alterado por uma automação, por uma ação em massa, por outra chave ou por uma remoção em outro contato da mesma pessoa não traz o contato de volta;
  • um contato excluído também não volta.

Os eventos de contatos dos webhooks avisam cada etiqueta e cada valor que muda num contato, venha a mudança de onde vier, menos a exclusão ou a renomeação da própria etiqueta ou do campo, que não gera evento nos contatos. Se você copia as etiquetas e os campos personalizados, faça também uma leitura completa de tempos em tempos.

Para encontrar um contato, filtre por phone, instagram_username ou instagram_id. No phone, um celular brasileiro é encontrado com ou sem o nono dígito, e um número de fora do Brasil precisa do +. O instagram_username vale com ou sem o @.

curl -G https://api.gaio.social/v1/contacts \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  --data-urlencode "phone=+5511987654321"

Etiquetar um contato

Cada etiqueta tem um id. Liste as etiquetas da conta com GET /v1/tags, ou crie uma com POST /v1/tags:

curl https://api.gaio.social/v1/tags \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Cliente"}'

Os nomes de etiqueta não se repetem, nem com maiúsculas e minúsculas diferentes. Se a conta já tem uma etiqueta com o nome, a resposta é 409, com o código tag_already_exists e o id dela em context.tag_id.

Para pôr a etiqueta no contato:

curl https://api.gaio.social/v1/contacts/$CONTACT_ID/tags/$TAG_ID \
  -X PUT \
  -H "Authorization: Bearer $GAIO_API_KEY"

A resposta traz as etiquetas do contato:

{
  "contact_id": "…",
  "tags": [{ "id": "…", "name": "Cliente" }]
}

Pôr uma etiqueta que o contato já tem não muda nada, então repetir a chamada é seguro. Para tirar a etiqueta, use DELETE no mesmo caminho: ela sai de todos os contatos da mesma pessoa, como no console.

Gerenciar etiquetas

Para saber quem tem uma etiqueta, use GET /v1/tags/{tagId}/contacts, na ordem em que os contatos ganharam a etiqueta; com tagged_since, só os que a ganharam a partir daquele momento. Tirar e pôr de novo a etiqueta leva o contato para o fim da lista.

Para renomear uma etiqueta, envie o name novo em PATCH /v1/tags/{tagId}: os contatos que a têm continuam com ela, com o nome novo, e um nome que outra etiqueta já tem recebe tag_already_exists, como na criação. DELETE /v1/tags/{tagId} exclui a etiqueta e a tira de todos os contatos que a têm. Nenhum dos dois move o updated_at dos contatos nem gera eventos de webhook neles.

Uma etiqueta que uma automação da conta usa, num bloco de etiquetas ou numa condição de etiqueta, no rascunho ou na versão publicada, não é renomeada nem excluída: a resposta é 409, com tag_in_use e as automações em context.workflow_ids. A automação procura a etiqueta pelo nome, então até uma troca só de maiúsculas e minúsculas é recusada. Mude as automações antes; as excluídas e os templates não contam.

Campos personalizados

Os valores dos campos personalizados de um contato são gravados pelo field_id de cada campo (GET /v1/custom-fields): PATCH /v1/contacts/{contactId}/custom-fields preenche alguns valores, e DELETE /v1/contacts/{contactId}/custom-fields/{fieldId} limpa um. Um campo number recebe um número JSON ("1.234" é recusado); text e dropdown recebem um texto de 1 a 1000 caracteres, e o dropdown aceita o value ou o label de uma opção. A requisição é conferida inteira: se um valor for recusado, nenhum é gravado.

Criar campos personalizados continua no console.

Criar um contato do WhatsApp

POST /v1/contacts cria um contato do WhatsApp, como a importação de uma planilha de uma linha: num número de WhatsApp conectado da conta e com consent: true, que fica registrado no histórico do contato. Se a conta já tem um contato com o telefone, ele é atualizado, mantém o id e a resposta é 200, em vez de 201.

O corpo leva phone, name, connection_id e consent e, opcionais, tag_ids (até 20 etiquetas) e custom_fields (até 50 valores, como no PATCH dos campos personalizados). Num contato que já existe, o name substitui o nome, as etiquetas e os valores enviados são acrescentados, e as etiquetas e os valores que não vieram no corpo ficam como estão.

Criar o contato não abre a janela de 24 horas para enviar mensagens (veja Enviar uma mensagem). Um contato criado assim só é cobrado depois que a primeira mensagem com ele é registrada, enviada ou recebida.

Conversas e mensagens

Uma conversa é a troca de mensagens de um contato com a conta, no canal do contato. O id da conversa é opaco; o do contato está em contact.id. A mesma pessoa no Instagram e no WhatsApp são duas conversas com o mesmo person_id.

Acompanhar as conversas

GET /v1/conversations lista o que o Live Chat do console lista, pela última mensagem, da mais recente para a mais antiga. Um contato que só comentou em um post não tem conversa até mandar uma mensagem direta ou até a conta escrever para ele. Os filtros são channel, unread (true: com mensagens não lidas ou marcada como não lida) e last_message_after.

Para sincronizar, leia em ordem crescente a partir do maior last_message_at que você já viu, até next_cursor vir null:

curl -G https://api.gaio.social/v1/conversations \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -d order=asc \
  -d limit=100 \
  --data-urlencode "last_message_after=2026-10-05T13:55:00Z"

Em ordem crescente, uma conversa que recebe uma mensagem enquanto você lê vai para o fim da lista, em vez de ficar de fora. As mensagens recebidas chegam com o horário do canal e alguns segundos de atraso: comece cada sincronização alguns minutos antes do último last_message_at e descarte as mensagens repetidas pelo id.

Para saber de cada mensagem quando ela chega, sem consultar a lista, use os eventos de mensagens dos webhooks.

Uma conversa sozinha está em GET /v1/conversations/{conversationId}, que lê também as que a lista deixa de fora. As mensagens dela estão em GET /v1/conversations/{conversationId}/messages, da mais recente para a mais antiga (order=asc para importar a conversa; created_after para só as posteriores a um momento). sender.type diz quem enviou: contact, user (alguém da equipe), workflow, api ou unattributed (a conta, sem registro de como, por exemplo uma mensagem digitada no app do Instagram ou do WhatsApp).

Um toque num botão é uma mensagem do contato com o rótulo em text e button (id, title e source: api, workflow, ice_breaker ou other).

A mídia de cada anexo tem uma url da própria API, que pede a mesma chave e redireciona (302) para um download válido por 5 minutos. A mídia recebida fica guardada por até 90 dias, então baixe ao sincronizar. Logo depois que a mensagem chega, a mídia ainda pode estar sendo guardada (404): tente de novo em instantes.

Enviar uma mensagem

Envie a mensagem na conversa do contato, com uma chave de idempotência:

curl https://api.gaio.social/v1/conversations/$CONVERSATION_ID/messages \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{"text": "Olá! Seu pedido saiu para entrega."}'

O corpo tem um destes formatos:

CorpoInstagramWhatsApp
textAté 1000 caracteres.Até 4096 caracteres.
text e buttonsTexto de até 640 caracteres e de 1 a 3 botões, de link ou de resposta.Texto de até 1024 caracteres e 1 botão de link, ou de 1 a 3 botões de resposta com títulos diferentes.
attachments com uma mídia (image, video, audio ou file)Sem text.O text é a legenda, de até 1024 caracteres, numa imagem, num vídeo ou num arquivo.
attachments com cardsDe 1 a 10 cards, sem text, cada um com até 3 botões, de link ou de resposta.De 2 a 10 cards, cada um com imagem e 1 botão de link ou de 1 a 3 botões de resposta, o mesmo tipo e o mesmo número em todos, e um text obrigatório de até 1024 caracteres acima deles.
templateNão existe.Um template aprovado da conta, sozinho.

Um botão tem um rótulo (title) de até 20 caracteres e é de um destes tipos:

  • {"type": "open_url", "title": "…", "url": "…"}, um botão de link: abre uma URL https absoluta, enviada como está, sem contar os toques.
  • {"type": "reply", "id": "…", "title": "…"}, um botão de resposta: o id é seu, de 1 a 64 letras, dígitos, _ e -, sem repetir entre os botões de resposta da mensagem, cards incluídos (invalid_message, com reason: duplicate_button_id).

O toque num botão de resposta volta como uma mensagem do contato, com o rótulo em text, button: { id, title, source: "api" } e a mensagem do botão em reply_to_message_id, e também como um evento message.received dos webhooks. Ele não inicia nem retoma nenhuma automação.

Os limites de cada card estão na referência da API. O que um canal não leva recebe 422: content_not_supported, ou text_too_long para um texto longo demais naquele lugar, com o campo em context.field e o limite em context.max_length. No WhatsApp, content_not_supported também recusa botões de link e de resposta juntos, dois botões de resposta com o mesmo título e cards com botões diferentes.

A resposta, 201, é a mensagem enviada, como GET /v1/messages/{messageId} a lê, com sender.type: "api". Um texto com botões aparece como um anexo cards com um card só, cujo title é o texto (o text vem null), como as mensagens com botões das automações. Nos cards, um botão de resposta aparece com type: "postback" e o id dele, e os outros botões com id: null. Um template aparece com o corpo já preenchido em text, e o nome e o idioma dele em template. O status é sent quando o canal aceitou a mensagem. Um envio pelo WhatsApp ainda pode falhar depois disso: GET /v1/messages/{messageId} passa a mostrar failed, e os webhooks enviam um message.failed.

Mídia. Para enviar um arquivo, crie a mídia com POST /v1/media, informando o tipo do arquivo:

curl https://api.gaio.social/v1/media \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content_type": "image/jpeg"}'

A resposta traz o id, que o envio usa como media_id, e a upload_url. Mande o arquivo para a upload_url com PUT em até 5 minutos, com o mesmo Content-Type:

curl "$UPLOAD_URL" \
  -X PUT \
  -H "Content-Type: image/jpeg" \
  --data-binary @foto.jpg

Depois, envie {"attachments": [{"type": "image", "media_id": "…"}]} na conversa. A mídia fica guardada enquanto a conta existir, então o mesmo media_id pode ser enviado quantas vezes você quiser. Criar a mídia pede o escopo messages:send e conta como escrita nos limites de uso.

O tamanho não é conferido no upload, e sim no envio, pelo que o canal da conversa aceita:

TipoInstagramWhatsApp
imageJPEG, PNG ou GIF, até 8 MBJPEG, PNG ou WebP, até 5 MB
videoMP4, OGG, WebM, AVI ou QuickTime, até 25 MBMP4, até 16 MB
audioAAC, MP4, M4A ou WAV, até 25 MBAAC, MP3 ou MP4, até 16 MB
filePDF, até 25 MBPDF, até 100 MB

Uma mídia fora disso recebe media_not_supported, com o motivo em context.reason: type_mismatch (o arquivo não é do type informado), content_type ou too_large.

Templates. No WhatsApp, template envia um template aprovado da conta, pelo name e pelo language (como pt_BR), com um valor em body_variables para cada {{1}}, {{2}}… do corpo. O template vai sozinho, sem text, attachments ou buttons. A API não envia um template que precise de outros parâmetros, como mídia ou variáveis no cabeçalho, variáveis nos botões, carrossel, variáveis com nome ou código de autenticação: a resposta é 422, com template_not_supported.

A janela de 24 horas. Fora o template, a API só envia dentro da janela de mensagens do canal: 24 horas a partir da última mensagem do contato naquela conexão. O campo reply_window da conversa antecipa a resposta, com state (open ou closed) e closes_at, quando a janela fecha; o envio confere de novo. Fora da janela, a resposta é 422, com o código messaging_window_closed. No WhatsApp, o código é template_required: só um template alcança o contato, e a janela abre de novo quando ele responde. A API não usa a tag de agente humano do Instagram, que declara que uma pessoa escreveu a mensagem, então no Instagram a janela também fecha em 24 horas.

A mensagem sai pela conexão da própria conversa. Quando o plano da conta inclui a assinatura da Gaio, ela é acrescentada dentro do limite do canal a um texto sozinho e a um texto acima de botões, nunca a uma legenda, a cards ou a um template, e a resposta traz o que foi entregue.

Como uma resposta digitada no console, o envio marca como lido o que o contato mandou até ali e passa os cards pendentes dele no inbox para em andamento. Ele não dispara as automações de "mensagem enviada" do Instagram.

Quando o resultado é incerto. Com outcome_unknown, a mensagem pode ter saído: confira as mensagens da conversa antes de tentar com uma chave nova, para não enviar duas vezes (veja Idempotência). No envio, o 502 diz que o canal não confirmou o envio, e context.provider_accepted: true, que ele aceitou a mensagem. No Instagram, uma recusa que o Instagram não explica também responde outcome_unknown, porque o erro genérico dele pode vir depois de a mensagem ter sido entregue. Um vídeo no Instagram também pode responder outcome_unknown mesmo tendo saído: o Instagram busca o vídeo pelo link e pode demorar mais para confirmar o envio do que a API espera.

Automações

Uma automação tem um rascunho e uma versão publicada. Editar o grafo, o gatilho ou o canal muda o rascunho, e nada muda no que está rodando até a publicação, que guarda o grafo como uma versão. As execuções em andamento terminam com a cópia com que começaram, depois de uma publicação ou de uma despublicação.

Como escrever o corpo de uma automação (o canal, o gatilho e o grafo de blocos), com o catálogo dos blocos, as variáveis e exemplos prontos, está em Montar automações pela API.

Editar e publicar uma automação

Toda resposta que traz uma automação tem o cabeçalho ETag, com a revision dela. Para alterar a automação (PATCH /v1/workflows/{workflowId}), envie essa revisão no cabeçalho If-Match. Só os campos enviados mudam:

curl https://api.gaio.social/v1/workflows/$WORKFLOW_ID \
  -X PATCH \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "7"' \
  -d '{"title": "Boas-vindas"}'

Sem If-Match, a resposta é 428, com precondition_required. Se a automação mudou depois daquela revisão, a resposta é 412, com version_conflict e a revisão atual em context.current_revision: leia a automação de novo e refaça a alteração.

O editor do console salva sem revisão. Ele pode sobrescrever uma mudança feita pela API, enquanto uma mudança enviada pela API depois de uma feita no editor é recusada, em vez de sobrescrevê-la.

Antes de publicar, valide. POST /v1/workflows/{workflowId}/validate confere o rascunho salvo e responde a revision que conferiu; POST /v1/workflows/validate confere uma definição sem salvá-la. Os errors são exatamente o que faz a publicação ser recusada (workflow_invalid); os warnings não impedem a publicação, mas provavelmente não fazem o que você queria. As duas validações contam como leitura.

Depois, publique com POST /v1/workflows/{workflowId}/publish, que pede o escopo workflows:publish. Aqui o If-Match é opcional: com a revision da validação, você publica exatamente o que validou. Sem ele, a publicação leva o rascunho como está e responde 409, com workflow_changed, se ele continuar mudando durante a publicação.

Ao reescrever um grafo, devolva como recebeu os tipos de bloco e os campos que você não conhece. Mantenha o id dos botões, porque as métricas de clique são contadas por ele, e a ordem das opções de um randomizer que já rodou, porque ele conta as escolhas pela posição (veja O grafo).

Cada publicação guarda o grafo como uma versão: GET /v1/workflows/{workflowId}/versions lista as versões, da mais recente para a mais antiga, e GET /v1/workflows/{workflowId}/versions/{versionId} lê uma, com o grafo que ela pôs no ar.

POST /v1/workflows/{workflowId}/unpublish tira a automação do ar: nada mais a inicia, e o rascunho fica como está. DELETE /v1/workflows/{workflowId} exclui a automação, tira do ar a que está publicada e para as execuções em andamento; excluir uma automação publicada também pede o escopo workflows:publish. Os dois aceitam um If-Match opcional: com ele, uma automação que mudou depois daquela revisão recebe 412, com version_conflict.

Iniciar uma automação para um contato

Encontre a automação em GET /v1/workflows (filtros status, channel, connection_id, trigger_event e folder_id) e inicie uma execução para o contato, com uma chave de idempotência:

curl https://api.gaio.social/v1/workflows/$WORKFLOW_ID/runs \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d "{\"contact_id\": \"$CONTACT_ID\"}"

A resposta é 202, com o id da execução:

{
  "id": "…",
  "workflow_id": "…",
  "contact_id": "…",
  "status": "queued",
  "created_at": "…"
}

A automação precisa estar publicada (workflow_not_published). A execução começa no canal da automação como um início simples, sem comentário para responder e sem texto de mensagem; por isso, se a automação envia mensagens, a janela de 24 horas do contato nesse canal precisa estar aberta (messaging_window_closed). O início conta para o mesmo limite de execuções por hora, por contato, de um gatilho (run_limit_reached), e um contato no meio de uma execução da mesma automação é recusado (run_in_progress).

A execução aparece em GET /v1/workflows/{workflowId}/runs alguns segundos depois, ou mais quando a fila da conta está cheia; filtre por contact_id para ver as do contato, e por started_after e started_before para um período. Uma execução esperando o contato fica waiting. O histórico de execuções guarda 90 dias e serve para acompanhar, não como registro de cobrança. Para saber quando a execução termina, sem consultar a lista, use o workflow_run.ended dos webhooks.

Com 502 e outcome_unknown, confira as execuções da automação para o contato antes de tentar com uma chave nova (veja Idempotência).

Pastas e listas de respostas

As pastas (/v1/folders) organizam as automações como no console: uma automação entra numa pasta pelo folder_id, e GET /v1/workflows?folder_id=… lista as que estão direto nela, sem as das subpastas. As listas de respostas (/v1/replies-lists) são o que uma automação do Instagram publica embaixo do comentário que a iniciou, e o gatilho aponta para uma pelo replies_list_id. Como criar e usar as duas está em Pastas e Listas de respostas.

Limites de uso

Os limites valem por conta, para todas as chaves dela juntas, em janelas de 1 minuto. Cada tipo de chamada tem o seu próprio limite, então um pico de leituras não bloqueia uma escrita.

Tipo de chamadaLimite por minuto
Leitura: toda rota GET e as validações de automação120
Escrita: as outras rotas que alteram algo60
Envio: enviar uma mensagem, iniciar uma automação para um contato, testar um webhook ou reenviar uma entrega dele20

Criar mais chaves não aumenta o limite.

Toda resposta de sucesso traz estes cabeçalhos:

CabeçalhoO que informa
RateLimit-LimitO limite por minuto do tipo da chamada.
RateLimit-RemainingQuantas chamadas desse tipo ainda cabem na janela atual.
RateLimit-ResetEm quantos segundos a janela atual termina.

Acima do limite, a resposta é 429, com o código rate_limited, os mesmos cabeçalhos e o cabeçalho Retry-After, em segundos.

Para lidar com o limite:

  • Acompanhe RateLimit-Remaining e diminua o ritmo antes de chegar a zero.
  • Ao receber 429, espere o tempo de Retry-After antes de tentar de novo.
  • Nas novas tentativas, aumente a espera a cada falha e some um intervalo aleatório (backoff exponencial com jitter), para que várias instâncias do seu sistema não voltem todas no mesmo instante.

Nem todo 429 é o limite da conta: run_limit_reached é o limite de execuções de uma automação por contato, e provider_throttled é o canal segurando os envios da conta. Decida pelo context.code.

Além do limite por conta, a API inteira tem um teto de chamadas por segundo. Se ele for atingido, o 429 vem do gateway, sem Retry-After e sem o corpo de erro descrito abaixo. Trate do mesmo jeito, com backoff.

Antes do gateway, a borda da API bloqueia um IP com má reputação ou com mais de 1200 chamadas por minuto, somadas todas as contas que chamam dele. O bloqueio responde 403, também sem o corpo de erro descrito abaixo. Quem chama a API por muitas contas de um mesmo servidor precisa ficar abaixo desse teto.

Erros

Os erros das rotas têm sempre este formato:

{
  "type": "error",
  "message": "The API key does not have the contacts:read scope.",
  "context": {
    "code": "insufficient_scope",
    "required_scope": "contacts:read"
  }
}

Use context.code para decidir o que fazer, e não só o status HTTP: o mesmo status tem códigos diferentes. A message é um texto para quem está depurando e pode mudar a qualquer momento: nunca dependa dela.

Comuns às rotas

CódigoStatusQuando aconteceCampos extras em context
insufficient_scope403A chave não tem o escopo que a rota exige.required_scope
rate_limited429A conta usou todas as chamadas daquele tipo no minuto.kind (read, write ou send), limit, retry_after
not_found404O recurso não existe nesta conta. Um recurso de outra conta também responde assim.—
invalid_cursor400O cursor não veio daquela lista.—
idempotency_key_reused422A chave de idempotência já foi usada com outra requisição.—
idempotency_in_progress409A primeira requisição com a chave de idempotência ainda está em andamento.—
outcome_unknown502 ou 409Não dá para saber se a operação aconteceu. Confira antes de tentar de novo (veja Idempotência).—
service_unavailable503A chave de idempotência não pôde ser conferida, ou o armazenamento de mídia ou o motor de automações não respondeu. Tente de novo.—

Outras respostas de erro:

  • 400: a chamada não segue o formato da rota, por exemplo com um campo desconhecido, um tipo errado, um parâmetro de consulta que a rota não aceita ou sem um cabeçalho obrigatório. Essa resposta não tem context.code: o que está errado vem em context.details. Um parâmetro de consulta desconhecido é recusado, e não ignorado, para que um filtro com erro de digitação não devolva a lista inteira como se estivesse filtrada.
  • 401, com o corpo {"message": "Unauthorized"}: a chave não foi enviada, não existe, foi revogada ou expirou.
  • 403, com o corpo {"message": "Forbidden"}: a conta está suspensa ou não tem acesso à API.

Essas respostas 401 e 403 vêm do gateway, antes de a chamada chegar à rota, e por isso têm um corpo próprio, sem type nem context. Um 403 com context.code é falta de escopo na chave; um 403 com o corpo {"message": "Forbidden"} é a conta sem acesso à API. Um 403 sem um corpo JSON vem da borda da API, que bloqueia um IP com má reputação ou com mais de 1200 chamadas por minuto, somadas todas as contas que chamam dele (veja Limites de uso).

Contatos

CódigoStatusQuando aconteceCampos extras em context
invalid_phone422Um telefone que nenhum contato pode ter. Um número de fora do Brasil precisa do +.—
invalid_name422Um nome em branco: de contato ou de etiqueta.—
invalid_connection422connection_id não é um número de WhatsApp conectado da conta.—
consent_required422Criar um contato sem consent: true.—
tag_not_found422Ids de etiqueta no corpo que não são etiquetas da conta.tag_ids
custom_field_not_found422Ids de campo no corpo que não são campos da conta.field_ids
invalid_custom_field_value422Valores que o campo recusa. Nenhum valor foi gravado.errors, com field_id e reason (expected_number, expected_string, invalid_number, invalid_option ou duplicate_field)
tag_already_exists409A conta já tem uma etiqueta com esse nome, com quaisquer maiúsculas e minúsculas.tag_id
tag_in_use409Uma automação da conta usa a etiqueta, que não pode ser renomeada nem excluída.workflow_ids (até 50), workflow_count

Conversas e mensagens

CódigoStatusQuando aconteceCampos extras em context
media_expired410A mídia não está mais guardada.—
invalid_message422A mensagem não serve para nenhum canal: vazia, um template com mais conteúdo, botões sem texto ou com anexo, uma URL que não é https ou dois botões de resposta com o mesmo id.reason (empty, template_with_content, buttons_without_text, buttons_with_attachment, invalid_url ou duplicate_button_id), field
content_not_supported422O canal da conversa não leva a mensagem ou uma parte dela, como um template no Instagram, uma legenda num áudio ou, no WhatsApp, botões de link e de resposta juntos, dois botões de resposta com o mesmo título ou cards com botões diferentes.channel, field
text_too_long422O texto é mais longo do que o canal da conversa entrega naquele lugar.channel, field, max_length
media_not_found422O media_id não é uma mídia criada pela conta com POST /v1/media.media_id
media_not_uploaded422Nada foi enviado para a upload_url da mídia. Se a URL expirou, crie outra mídia.media_id
media_not_supported422O canal não aceita a mídia: o tipo, o formato ou o tamanho.channel, field, media_id, reason (type_mismatch, content_type ou too_large), content_type, size, max_size
template_not_found422A conta não tem um template do WhatsApp com esse name e esse language.name, language
template_not_approved422O WhatsApp não aprovou o template.status
template_not_supported422O template precisa de parâmetros que a API não envia.reason (header_media, header_variables, button_variables, carousel, named_variables ou authentication)
template_variables_mismatch422body_variables não tem um valor para cada {{n}} do corpo do template.expected, received
channel_disconnected422A conexão da conversa está desconectada.channel, e reason: authorization_lost quando é preciso reconectá-la
messaging_window_closed422O contato escreveu pela última vez há mais de 24 horas.channel, last_inbound_at
template_required422O mesmo, no WhatsApp, onde só um template alcança o contato.channel, last_inbound_at
contact_blocked422A conta bloqueou o contato das automações.—
conversation_not_owned422Outro app controla a conversa do Instagram no momento. Ela volta sozinha.—
recipient_unavailable422A conversa ou o contato não existe mais no canal.—
provider_rejected422O canal recusou a mensagem de vez, por um motivo sem código próprio.channel, provider_message (o texto do canal)
provider_throttled429O canal está segurando os envios da conta. Espere antes de tentar de novo.channel
outcome_unknown502O canal não confirmou o envio, e a mensagem pode ter saído.provider_accepted (true quando o canal aceitou)
provider_unavailable503O Instagram falhou sem enviar a mensagem. Tente de novo.channel
service_unavailable503O armazenamento de mídia não respondeu ao ler a mídia do envio. Tente de novo.—

Toda recusa desta tabela, menos o outcome_unknown, libera a chave de idempotência.

Automações

CódigoStatusQuando aconteceCampos extras em context
invalid_graph422O grafo não segue o contrato.errors, cada um com node_id e path
invalid_channel422O canal não pode ser vinculado.reason (required, not_found ou type_mismatch)
invalid_trigger422O gatilho não é coerente.reason
precondition_required428Uma alteração enviada sem If-Match.—
version_conflict412O If-Match não é a revisão atual.current_revision
workflow_published409A alteração exige despublicar a automação antes, como trocar o canal.—
folder_not_found422O folder_id não é uma pasta de automações da conta.field (folder_id)
workflow_invalid422As verificações da publicação recusaram a automação.errors, os mesmos da validação
workflow_not_publishable422O motor de automações recusou a automação compilada.details
workflow_changed409Sem If-Match, a automação continuou mudando durante a publicação. Leia de novo e tente outra vez.—
workflow_not_published409Uma execução pedida para uma automação que não está publicada.—
channel_disconnected422O canal da automação não está conectado.connection_id
contact_not_on_channel422O contato nunca chegou pelo canal da automação.connection_id
messaging_window_closed422A automação envia mensagens, e a janela de 24 horas do contato nesse canal está fechada.last_inbound_at
contact_automations_paused409A conta pausou as automações para o contato.—
integration_disconnected422A automação usa uma integração que não está conectada.integration_type
run_in_progress409O contato está no meio de uma execução da automação.—
run_limit_reached429O contato atingiu o limite de execuções por hora da automação, o mesmo de um gatilho.—
outcome_unknown502O motor não confirmou o início da execução, que pode ter acontecido.—

Pastas e listas de respostas

CódigoStatusQuando aconteceCampos extras em context
invalid_name422Um nome de pasta ou de lista em branco.—
folder_not_found422O parent_id não é uma pasta de automações da conta.field (parent_id)
invalid_parent422Uma pasta não vai para dentro dela mesma nem de uma subpasta dela.—
replies_list_already_exists409A conta já tem uma lista de respostas com esse nome, com quaisquer maiúsculas e minúsculas.replies_list_id
replies_list_in_use409Uma automação da conta usa a lista de respostas, que não pode ser excluída.workflow_ids (até 50), workflow_count
invalid_reply422Uma resposta em branco na lista.index
service_unavailable503O motor de automações não respondeu ao salvar ou excluir uma lista de respostas, e nada mudou. Tente de novo.—

Webhooks

Os códigos próprios das rotas de webhooks estão em Webhooks › Erros.

Versionamento

A versão principal fica no caminho da URL: /v1. Dentro de uma versão, o contrato só cresce.

Pode chegar a qualquer momento dentro da /v1:

  • rotas novas;
  • campos novos nas respostas;
  • campos opcionais novos nas requisições;
  • valores novos em enumerações, como um canal ou um status novo;
  • códigos de erro novos.

Por isso, o seu código deve ignorar os campos que não conhece e tratar valores de enumeração desconhecidos sem quebrar.

Nunca acontece dentro da /v1, só em uma versão nova, como a /v2:

  • remover ou renomear uma rota, um campo, um valor de enumeração ou um código de erro;
  • mudar o tipo, o formato ou o valor padrão de um campo;
  • mudar a ordenação ou a paginação de uma lista;
  • tornar obrigatório um campo opcional da requisição, ou validar um campo existente com mais rigor;
  • mudar o status ou o código de um erro que já existe.

O catálogo de blocos das automações evolui fora da versão do caminho. Ele cresce por adição: um tipo de bloco, um campo opcional, um valor. Uma mudança que recusaria um grafo aceito hoje é anunciada com 90 dias de antecedência, e nesse prazo a validação avisa sobre os grafos afetados.

Quando uma rota for aposentada:

  • ela passa a responder com os cabeçalhos Deprecation e Sunset, este com a data em que ela deixa de funcionar;
  • a mudança é anunciada nesta documentação;
  • o dono de cada chave que usou a rota nos últimos 30 dias recebe um e-mail.

Quando sair uma versão principal nova, a anterior continua no ar por 6 meses.

Referência da API

Todas as rotas da API, com parâmetros, respostas e autenticação, geradas a partir do documento OpenAPI. Com o documento, você também pode gerar um cliente na sua linguagem ou importar a API no seu cliente HTTP.

Para agentes de código

Se um agente de código, como Claude Code, Codex, Cursor ou GitHub Copilot, vai escrever a sua integração, aponte-o para a documentação em Markdown, feita para ele ler:

Para começar, peça ao agente: "Leia https://gaio.social/developers/llms.txt antes de escrever código para a API da Gaio."

Nunca cole a chave no chat do agente nem num arquivo do repositório. Guarde-a na variável de ambiente GAIO_API_KEY, onde o código vai ler.

O trecho para o AGENTS.md

O agente lê as instruções do repositório a cada tarefa. Com o trecho abaixo no AGENTS.md do repositório que chama a API, ele segue as regras da API sem que você precise lembrá-lo: usar a chave de idempotência, validar antes de publicar, conferir a assinatura dos webhooks, não enviar mensagens de verdade para testar.

FerramentaOnde pôr o trecho
Codex, Cursor e GitHub CopilotNo AGENTS.md da raiz do repositório.
Claude CodeNo AGENTS.md, com uma linha @AGENTS.md no CLAUDE.md, que importa o arquivo.

O mesmo trecho está em AGENTS.md. As linhas BEGIN e END marcam onde ele começa e termina, para que uma atualização troque só ele.

<!-- BEGIN:gaio-api-agent-rules -->
## API da Gaio

Este projeto chama a API pública da Gaio, em beta fechado, que não está nos seus dados de treino. Antes de escrever ou mudar código que a usa, leia https://gaio.social/developers/llms.txt e a página da tarefa. As rotas e os corpos estão em https://gaio.social/developers/openapi.yml: uma rota que não está lá não existe.

Base `https://api.gaio.social/v1`, JSON em snake_case, `Authorization: Bearer $GAIO_API_KEY`, só de servidor para servidor (sem CORS).

### Chave
- Leia a chave só da variável de ambiente `GAIO_API_KEY`, no servidor. Nunca a ponha em código, repositório, URL, código de navegador ou de app, log ou mensagem de erro, e mascare o cabeçalho `Authorization` nos logs de HTTP.
- Não peça a chave no chat: peça para exportarem `GAIO_API_KEY`.
- `GET /v1/me` mostra os escopos e a validade (`expires_at`) da chave.

### Efeitos reais
- Não há modo de teste. Enviar mensagem (`POST /v1/conversations/{id}/messages`), iniciar uma automação (`POST /v1/workflows/{id}/runs`), publicar, despublicar e excluir atingem pessoas e a conta de verdade. Não chame essas rotas para explorar nem em testes automatizados (simule a API), e peça confirmação a uma pessoa antes.
- Contatos e mensagens são dados pessoais: não os copie para logs, fixtures ou prompts.
- Envie `consent: true` em `POST /v1/contacts` só com o opt-in daquela pessoa registrado. Nunca fixe esse valor no código.

### Envios
- Sempre com `Idempotency-Key`: derivada do evento de negócio (`pedido-enviado:<id>`) ou um UUID guardado antes da primeira tentativa. Repita a mesma chave nas novas tentativas da mesma operação e nunca a gere dentro do laço de novas tentativas.
- Confira `reply_window.state` da conversa antes de enviar. `messaging_window_closed` e `template_required` não se resolvem repetindo.
- `outcome_unknown` (502, ou 409 com a mesma chave): o efeito pode ter acontecido, e repetir com a mesma chave devolve o mesmo erro. Não reenvie sozinho: confira as mensagens da conversa ou as execuções da automação e, na dúvida, deixe para uma pessoa.
- `idempotency_in_progress` (409) e `service_unavailable` (503): espere e repita com a mesma chave. `idempotency_key_reused` (422) é erro na geração da chave: não repita.

### Automações
- Monte o corpo pelo guia https://gaio.social/developers/automacoes.md, partindo dos exemplos dele, e pelo catálogo atual (`GET /v1/workflow-graph-schema`).
- Valide antes de salvar e de publicar: `POST /v1/workflows/validate` (só `channel`, `trigger` e `graph`; com `title` ou `description`, a resposta é 400) e `POST /v1/workflows/{id}/validate` (o rascunho salvo). Os `errors` impedem a publicação; leia também os `warnings`.
- No `PATCH`, envie o `ETag` da leitura em `If-Match` (sem ele, 428). `graph` e `trigger` substituem o valor inteiro. Em `412 version_conflict`, leia de novo e reaplique a mudança; nunca sobrescreva às cegas.
- Publique com `If-Match` igual ao `ETag` da validação do rascunho.
- Devolva como vieram os tipos de bloco e os campos que você não conhece. Mantenha o `id` dos botões e a ordem das opções de um `randomizer` que já rodou.

### Webhooks
- Monte o receptor pelo guia https://gaio.social/developers/webhooks.md.
- Confira o `Gaio-Signature` sobre o corpo cru, antes de interpretar o JSON: HMAC-SHA256 de `<t>.<corpo>` com o segredo inteiro (`whsec_…`), comparado em tempo constante com cada `v1`, e recuse um `t` a mais de 5 minutos. Leia o segredo de uma variável de ambiente, como a chave.
- Responda `2xx` em até 10 segundos e processe depois, por uma fila. Descarte os eventos repetidos pelo `id` e não conte com a ordem: quando ela importa, leia o recurso de novo pela API.
- Cadastrar um endpoint ou mudar a URL dele passa a enviar os dados da conta para aquele endereço: peça confirmação a uma pessoa antes.

### Erros e limites
- Decida pelo `context.code`, nunca pela `message` nem só pelo status. `401` e `403` sem `context` vêm do gateway: chave inválida ou conta sem acesso.
- Limites por conta, por minuto: leitura 120, escrita 60, envio 20. No `429`, espere o `Retry-After`; sem ele, use backoff exponencial com jitter.
- Listas usam cursor opaco e `limit` até 100, menos `GET /v1/webhooks`, que vem numa página só e não aceita `limit` nem `cursor`. Um parâmetro de consulta desconhecido dá `400`.
- A `/v1` só cresce: tolere campos, valores de enumeração e códigos de erro novos.

Para atualizar, troque o que está entre os marcadores pelo conteúdo de https://gaio.social/developers/AGENTS.md.
<!-- END:gaio-api-agent-rules -->