> Índice da documentação para agentes: https://gaio.social/developers/llms.txt

# Webhooks

Receba no seu sistema, na hora, o que acontece na conta: mensagens, comentários, contatos, execuções de automações e conexões, sem consultar a API.

## Como funciona

Um webhook é um endereço do seu sistema, o endpoint, que a Gaio chama quando algo acontece na conta: chega uma mensagem, um contato ganha uma etiqueta, uma automação termina. A Gaio envia um `POST` com o evento em JSON, assinado com um segredo que só a Gaio e o seu sistema conhecem. Assim o seu sistema reage na hora, sem consultar a API de tempos em tempos.

O evento traz o recurso como a API o responde: um `message.received` traz a mensagem como `GET /v1/messages/{messageId}`. Para agir, como responder à mensagem, use a API.

Os webhooks fazem parte da API: só funcionam numa conta com a API liberada (veja [Como obter acesso](https://gaio.social/developers/index.md#como-obter-acesso)). Se a API for desligada para a conta, a Gaio para de enviar.

## Cadastrar um endpoint

Cada conta tem até 10 endpoints, contando os desativados: exclua um para cadastrar outro. Cada endpoint recebe só os tipos de evento escolhidos para ele, e um evento vai para todos os endpoints que recebem o tipo dele.

### No console

No console da Gaio, vá em **Configurações › Desenvolvedores › Webhooks** e clique em **Adicionar endpoint**. Como as chaves, os webhooks só aparecem para o dono da conta, entrando com o próprio login: um endpoint continua recebendo os dados da conta depois que a sessão termina.

Preencha:

- **URL**: para onde a Gaio envia os eventos (veja [A URL](https://gaio.social/developers/webhooks.md#a-url)).
- **Descrição**: opcional, quem recebe os eventos, como "Integração com o CRM".
- **Eventos**: os [tipos de evento](https://gaio.social/developers/webhooks.md#eventos) que o endpoint recebe.

Depois de criar, o console mostra o segredo de assinatura uma única vez. Guarde-o no servidor que recebe os eventos, como a chave da API: é com ele que você [confere a assinatura](https://gaio.social/developers/webhooks.md#conferir-a-assinatura). Se perdê-lo, [gere outro](https://gaio.social/developers/webhooks.md#trocar-o-segredo).

### Pela API

Com uma chave que tenha o escopo `webhooks:write`:

```bash
curl https://api.gaio.social/v1/webhooks \
  -H "Authorization: Bearer $GAIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{
    "url": "https://seu-servidor.com/webhooks/gaio",
    "description": "Integração com o CRM",
    "event_types": ["message.received", "contact.tagged"]
  }'
```

Os campos: `url`, até 2048 caracteres; `description`, opcional, até 200 caracteres (em branco, fica sem descrição); `event_types`, de 1 a 50 tipos, guardados sem repetição e em ordem alfabética.

A resposta, `201`, é o endpoint com o `secret`, o segredo de assinatura:

```json
{
  "id": "…",
  "url": "https://seu-servidor.com/webhooks/gaio",
  "description": "Integração com o CRM",
  "event_types": ["contact.tagged", "message.received"],
  "status": "enabled",
  "disabled_reason": null,
  "disabled_at": null,
  "failing_since": null,
  "previous_secret_expires_at": null,
  "created_at": "…",
  "updated_at": "…",
  "secret": "whsec_…"
}
```

Só esta resposta e a da [troca do segredo](https://gaio.social/developers/webhooks.md#trocar-o-segredo) trazem o `secret`: guarde-o na hora. Repetir o cadastro com a mesma chave de idempotência devolve o mesmo endpoint, com o segredo atual dele.

As rotas dos webhooks:

| Rota | O que faz | Escopo |
| --- | --- | --- |
| `GET /v1/webhooks` | Lista os endpoints, do mais novo para o mais antigo, numa página só. | `webhooks:read` |
| `POST /v1/webhooks` | Cadastra um endpoint. | `webhooks:write` |
| `GET /v1/webhooks/{webhookId}` | Lê um endpoint. | `webhooks:read` |
| `PATCH /v1/webhooks/{webhookId}` | Muda `url`, `description`, `event_types` (a lista inteira) ou `status` (`enabled` ou `disabled`). | `webhooks:write` |
| `DELETE /v1/webhooks/{webhookId}` | Exclui um endpoint. | `webhooks:write` |
| `POST /v1/webhooks/{webhookId}/rotate-secret` | [Troca o segredo](https://gaio.social/developers/webhooks.md#trocar-o-segredo). | `webhooks:write` |
| `POST /v1/webhooks/{webhookId}/test` | [Envia um evento de teste](https://gaio.social/developers/webhooks.md#testar-um-endpoint). | `webhooks:write` |
| `GET /v1/webhooks/{webhookId}/deliveries` | Lista as [entregas](https://gaio.social/developers/webhooks.md#registro-de-entregas). | `webhooks:read` |
| `POST /v1/webhooks/{webhookId}/deliveries/{deliveryId}/retry` | [Reenvia uma entrega](https://gaio.social/developers/webhooks.md#reenviar-uma-entrega). | `webhooks:write` |

Nos [limites de uso](https://gaio.social/developers/index.md#limites-de-uso), o teste e o reenvio contam como envio, as leituras como leitura e o resto como escrita.

Para assinar um tipo de evento, a chave também precisa do escopo que lê os dados dele pela API, porque um webhook nunca entrega ao seu sistema o que a própria chave não poderia ler. Sem ele, a resposta é `403`, com `insufficient_scope` e o escopo que falta em `context.required_scope`.

| Eventos | Escopo de leitura |
| --- | --- |
| `message.*`, `comment.received` e `live_comment.received` | `conversations:read` |
| `contact.*` e `connection.*` | `contacts:read` |
| `workflow_run.ended` e `link.clicked` | `workflows:read` |

Desativar um endpoint (`status: disabled`) ou excluí-lo desiste do que ele ainda tinha para receber. Ativado de novo, ele recebe os eventos que acontecerem a partir daí. O `disabled_reason` diz por que o endpoint está desativado: `requested`, quando alguém o desativou (no console ou com `PATCH` e `{"status": "disabled"}`), ou `failing`, quando a Gaio o [desativou por falhas](https://gaio.social/developers/webhooks.md#endpoint-desativado-por-falhas).

Cadastrar, alterar, excluir um endpoint e trocar o segredo dele entra no log de auditoria da conta, com a chave como autora quando a mudança vem pela API: cadastrar um endpoint é a instrução da conta para enviar os dados dela àquele endereço.

### A URL

A Gaio só envia para uma URL:

- `https`, sem porta (a porta é sempre a 443);
- sem usuário e senha;
- num domínio que aponta para um endereço público, e não para uma rede privada, local ou reservada.

A URL é conferida no cadastro e de novo a cada envio. Uma URL recusada recebe `422`, com `invalid_webhook_url` e o motivo em `context.reason`:

| `reason` | O que significa |
| --- | --- |
| `invalid` | Não é uma URL. |
| `https_required` | Não é `https`. |
| `credentials` | Tem usuário ou senha. |
| `port` | Tem uma porta diferente da 443. |
| `unreachable` | O domínio não aponta para um endereço público. |

## O que chega

Cada evento chega num `POST` para a URL do endpoint, com o corpo em JSON:

```json
{
  "id": "…",
  "type": "message.received",
  "api_version": "v1",
  "created_at": "…",
  "data": {
    "id": "…",
    "conversation_id": "…",
    "channel": { "type": "instagram", "connection_id": "…" },
    "type": "message",
    "direction": "inbound",
    "sender": { "type": "contact", "user_id": null, "name": null },
    "text": "Oi! Vocês entregam em Curitiba?",
    "attachments": [],
    "template": null,
    "reply_to_message_id": null,
    "button": null,
    "post": null,
    "status": "received",
    "is_unsupported": false,
    "is_removed": false,
    "is_edited": false,
    "reactions": [],
    "read_at": null,
    "created_at": "…"
  }
}
```

| Campo | O que é |
| --- | --- |
| `id` | O id do evento. É o mesmo em todas as tentativas e nos reenvios: é por ele que você [descarta os repetidos](https://gaio.social/developers/webhooks.md#descarte-os-repetidos). |
| `type` | O tipo do evento. |
| `api_version` | A versão da API em que o `data` está escrito: `v1`. |
| `created_at` | Quando aconteceu. |
| `data` | O recurso, como a API o responde. |

E com estes cabeçalhos:

| Cabeçalho | O que é |
| --- | --- |
| `Content-Type` | `application/json`. |
| `User-Agent` | `Gaio-Webhooks/1.0`. |
| `Gaio-Signature` | A [assinatura](https://gaio.social/developers/webhooks.md#conferir-a-assinatura) do corpo. |
| `Gaio-Event-Id` | O `id` do evento. |
| `Gaio-Event-Type` | O `type` do evento. |
| `Gaio-Delivery-Attempt` | O número da tentativa: `1` na primeira, `2` na segunda, e assim por diante. |

## Conferir a assinatura

Qualquer um pode chamar a URL do seu endpoint. Confira a assinatura de cada entrega antes de confiar no conteúdo: ela prova que foi a Gaio que enviou aquele corpo, com o segredo do endpoint, há pouco tempo.

O cabeçalho `Gaio-Signature` tem o formato `t=1760020200,v1=…`:

- `t` é o momento da tentativa, em segundos desde 1970 (Unix).
- `v1` é o HMAC-SHA256, em hexadecimal, de `<t>.<corpo>`: o valor de `t`, um ponto e o corpo exatamente como chegou. A chave do HMAC é o segredo inteiro, com o `whsec_`.
- Durante uma [troca de segredo](https://gaio.social/developers/webhooks.md#trocar-o-segredo), vêm dois `v1`, um com cada segredo.

Para conferir:

1. Leia o corpo cru, os bytes que chegaram, antes de interpretar o JSON. Um JSON interpretado e escrito de novo muda espaços, ordem e acentos, e a assinatura não bate.
2. Calcule o HMAC-SHA256 de `<t>.<corpo>` com o segredo.
3. Compare o resultado com cada `v1`, em tempo constante, e aceite se um deles bater.
4. Recuse se `t` estiver a mais de 5 minutos do relógio do seu servidor: assim, quem capturou uma entrega não consegue reenviá-la depois. Mantenha o relógio sincronizado.

Cada tentativa é assinada de novo, com o seu próprio `t`: uma nova tentativa da mesma entrega traz outro `Gaio-Signature` e o mesmo `id`.

Responda com um erro, como `400`, a uma entrega que não passa. Se o problema for do seu lado, como um segredo desatualizado, a Gaio tenta de novo, e nada se perde enquanto você corrige.

Os exemplos abaixo usam só a biblioteca padrão para conferir a assinatura e leem o segredo da variável de ambiente `GAIO_WEBHOOK_SECRET`. `enqueue` é onde o seu sistema guarda o evento para processar depois, como uma fila ou uma tabela (veja [Responder e processar](https://gaio.social/developers/webhooks.md#responder-e-processar)).

### Node.js

Com Express, que entrega o corpo cru com `express.raw`:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const TOLERANCE_SECONDS = 5 * 60;

export function verifyGaioSignature(rawBody, header, secret) {
  const fields = (header ?? '').split(',').map((field) => field.trim().split('='));
  const timestamp = Number(fields.find(([key]) => key === 't')?.[1]);

  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();

  return fields.some(
    ([key, value]) =>
      key === 'v1' &&
      /^[0-9a-f]{64}$/.test(value ?? '') &&
      timingSafeEqual(expected, Buffer.from(value, 'hex'))
  );
}

const app = express();

app.post('/webhooks/gaio', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Gaio-Signature');
  const secret = process.env.GAIO_WEBHOOK_SECRET;

  if (!Buffer.isBuffer(req.body) || !verifyGaioSignature(req.body, header, secret)) {
    return res.sendStatus(400);
  }

  enqueue(JSON.parse(req.body.toString('utf8')));
  res.sendStatus(200);
});

app.listen(3000);
```

### Python

Com Flask, que entrega o corpo cru em `request.get_data()`:

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

TOLERANCE_SECONDS = 5 * 60


def verify_gaio_signature(raw_body: bytes, header: str, secret: str) -> bool:
    fields = [field.strip().partition("=") for field in header.split(",")]
    timestamp = next((value for key, _, value in fields if key == "t"), "")
    signatures = [value for key, _, value in fields if key == "v1"]

    if not (timestamp.isascii() and timestamp.isdigit()):
        return False
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return any(hmac.compare_digest(expected.encode(), value.encode()) for value in signatures)


app = Flask(__name__)


@app.post("/webhooks/gaio")
def gaio_webhook():
    raw_body = request.get_data()
    header = request.headers.get("Gaio-Signature", "")

    if not verify_gaio_signature(raw_body, header, os.environ["GAIO_WEBHOOK_SECRET"]):
        return "", 400

    enqueue(json.loads(raw_body))
    return "", 200
```

Em outro framework, o que muda é como ler o corpo cru: desligue o leitor de JSON na rota do webhook, ou leia o corpo antes dele.

## Testar um endpoint

No console, clique em **Enviar evento de teste** no endpoint. Pela API, use `POST /v1/webhooks/{webhookId}/test`. A Gaio envia na hora um evento do tipo `webhook.test`, assinado como qualquer outro, com `data` igual a `{"webhook_id": "…"}`, e responde como o endpoint o recebeu:

```json
{
  "delivered": true,
  "response_status": 200,
  "error": null,
  "duration_ms": 182
}
```

`delivered` diz se o endpoint respondeu `2xx`, `response_status` é o status que ele respondeu, quando respondeu, e `duration_ms` é quanto o envio levou, em milissegundos. Quando o envio falha, `delivered` é `false` e `error` traz um dos motivos de [`last_error`](https://gaio.social/developers/webhooks.md#registro-de-entregas); quando dá certo, `error` vem `null`. O teste vai para o endpoint mesmo desativado, não entra no registro de entregas e não é tentado de novo. Responda `2xx` a ele como a qualquer evento.

## Responder e processar

### Responda rápido

Responda `2xx` em até 10 segundos e processe depois: guarde o evento, responda e faça o trabalho em seguida. Uma chamada ao seu CRM ou a um modelo de IA antes de responder pode passar dos 10 segundos.

Qualquer outro resultado é uma tentativa que falhou: um status fora de `2xx`, inclusive um redirecionamento (a Gaio não segue redirecionamentos), uma resposta depois dos 10 segundos ou uma conexão que não se completa. O corpo da sua resposta é ignorado.

A Gaio faz até 5 entregas ao mesmo tempo para cada endpoint. Quando há mais eventos do que isso, eles esperam e saem aos poucos, até cerca de 100 por minuto.

### Descarte os repetidos

A entrega é pelo menos uma vez: o mesmo evento pode chegar mais de uma vez, por exemplo quando a sua resposta se perde no caminho. Descarte os repetidos pelo `id`, que é o mesmo em todas as tentativas e nos reenvios. Guarde os `id` processados por pelo menos 7 dias, o tempo em que uma entrega ainda pode ser [reenviada](https://gaio.social/developers/webhooks.md#reenviar-uma-entrega).

### Não conte com a ordem

Os eventos não chegam necessariamente na ordem em que aconteceram: uma nova tentativa pode chegar depois de um evento mais novo, e um `message.updated` pode chegar antes do `message.received` da mesma mensagem. Quando a ordem importa, compare o `created_at` ou leia o recurso de novo pela API, que responde o estado atual.

### Novas tentativas

Uma entrega que falhou é tentada de novo, até 10 tentativas, ao longo de uns 2 dias:

| Tentativa | Quando |
| --- | --- |
| 1ª | Logo depois do evento. |
| 2ª | 1 minuto depois da 1ª. |
| 3ª | 5 minutos depois da 2ª. |
| 4ª | 30 minutos depois da 3ª. |
| 5ª | 2 horas depois da 4ª. |
| 6ª | 5 horas depois da 5ª. |
| 7ª a 10ª | 10 horas depois da anterior. |

Depois da 10ª, a entrega fica com falha (`failed`), e você ainda pode [reenviá-la](https://gaio.social/developers/webhooks.md#reenviar-uma-entrega).

## Registro de entregas

Cada evento enviado a um endpoint é uma entrega, guardada por 7 dias. No console, abra **Entregas** no endpoint. Pela API, use `GET /v1/webhooks/{webhookId}/deliveries`, da mais recente para a mais antiga, com o filtro `status` (`pending`, `succeeded` ou `failed`):

```json
{
  "id": "…",
  "webhook_id": "…",
  "event": { "id": "…", "type": "message.received", "created_at": "…" },
  "status": "failed",
  "attempts": 10,
  "next_attempt_at": null,
  "last_attempt_at": "…",
  "last_response_status": 500,
  "last_error": "http_status",
  "delivered_at": null,
  "created_at": "…"
}
```

O `status` é `pending` (esperando uma tentativa, ou numa agora), `succeeded` (o endpoint respondeu `2xx`) ou `failed` (a Gaio desistiu). `last_response_status` é o status que o endpoint respondeu na última tentativa, quando respondeu, e `last_error` diz por que ela falhou:

| `last_error` | O que significa |
| --- | --- |
| `http_status` | O endpoint respondeu um status fora de `2xx`, inclusive um redirecionamento. |
| `timeout` | O endpoint não respondeu em 10 segundos. |
| `dns_failed` | O domínio não foi encontrado. |
| `connection_failed` | Não foi possível conectar ao servidor. |
| `tls_failed` | O certificado HTTPS do servidor foi recusado. |
| `unsafe_target` | O domínio passou a apontar para uma rede para a qual a Gaio não envia. |
| `endpoint_disabled` | O endpoint foi desativado ou excluído antes do envio. |
| `account_disabled` | A conta estava suspensa ou sem a API. |

### Reenviar uma entrega

No console, clique em **Reenviar** na entrega. Pela API, use `POST /v1/webhooks/{webhookId}/deliveries/{deliveryId}/retry`, que responde `202` com a entrega de volta na fila. Dá para reenviar uma entrega que falhou ou uma que deu certo e o seu sistema precisa de novo; uma entrega pendente é respondida como está.

O reenvio manda o mesmo evento, com o mesmo `id`. Se ele falhar, as novas tentativas continuam de onde a entrega parou, então uma entrega que já usou as 10 tentativas falha de novo sem outras. O endpoint precisa estar ativo (`409`, com `webhook_disabled`), e depois de 7 dias a entrega não existe mais (`404`, com `not_found`).

### Endpoint desativado por falhas

Se todas as tentativas para um endpoint falham por 3 dias, a Gaio desativa o endpoint (`status: disabled`, com `disabled_reason: failing`), desiste das entregas pendentes dele e avisa o dono da conta por e-mail. Enquanto as tentativas falham, `failing_since` diz desde quando, e uma entrega que dá certo zera a contagem.

Para voltar a receber, corrija o servidor, [envie um evento de teste](https://gaio.social/developers/webhooks.md#testar-um-endpoint) e ative o endpoint: no console, em **Ativar**, ou com `PATCH /v1/webhooks/{webhookId}` e `{"status": "enabled"}`. Ele recebe os eventos que acontecerem a partir daí; o que ficou para trás você [reenvia pelo registro](https://gaio.social/developers/webhooks.md#reenviar-uma-entrega), dentro dos 7 dias.

### Conta suspensa

Enquanto a conta está suspensa, ou sem a API, a Gaio não registra nem entrega eventos: o que acontece nesse período não gera evento, nem depois que a conta volta. As entregas que estavam pendentes, inclusive as da própria suspensão (como o `connection.disconnected` de cada conexão que ela desliga), ficam no [registro de entregas](https://gaio.social/developers/webhooks.md#registro-de-entregas) com falha (`account_disabled`) e não são tentadas de novo sozinhas. Quando a conta volta, você pode [reenviá-las](https://gaio.social/developers/webhooks.md#reenviar-uma-entrega) dentro dos 7 dias.

## Trocar o segredo

No console, clique em **Gerar novo segredo** no endpoint. Pela API, use `POST /v1/webhooks/{webhookId}/rotate-secret`, que responde o endpoint com o novo `secret`. Como no cadastro, o segredo só aparece nessa resposta.

O segredo anterior continua assinando por 24 horas, até o `previous_secret_expires_at` do endpoint: nesse tempo, cada entrega traz dois `v1`, um com cada segredo. Para trocar sem recusar nenhuma entrega:

1. Gere o novo segredo.
2. Passe o seu servidor para o novo segredo nas 24 horas seguintes. Os exemplos acima aceitam a entrega quando qualquer `v1` bate, então funcionam antes e depois dessa troca.

Gerar outro segredo dentro dessas 24 horas descarta na hora o mais antigo dos dois.

Se o segredo vazou, gere outro e passe o seu servidor para ele logo em seguida: a partir daí, o seu servidor recusa uma assinatura feita com o segredo vazado, mesmo que a Gaio ainda assine com ele até o fim das 24 horas.

## Eventos

O `type` diz o que aconteceu, e o `data` traz o recurso como a API o responde naquele momento. A lista de tipos e os campos de `data` crescem sem mudar de versão, como o resto da `/v1` (veja [Versionamento](https://gaio.social/developers/index.md#versionamento)): ignore os campos que você não conhece.

### Mensagens e comentários

O `data` é a mensagem como `GET /v1/messages/{messageId}` a responde naquele momento, com a conversa em `conversation_id`. Pedem o escopo `conversations:read`.

| Tipo | Quando é enviado |
| --- | --- |
| `message.received` | O contato mandou uma mensagem direta, respondeu ou mencionou um story, ou tocou num botão ou num quebra-gelo (o título dele vem em `text`), no Instagram ou no WhatsApp. |
| `comment.received` | O contato comentou num post ou num reel. |
| `live_comment.received` | O contato comentou numa live. |
| `message.sent` | A conta enviou uma mensagem: alguém da equipe pela Gaio, uma automação, uma integração pela API (a sua também) ou alguém no app do Instagram ou do WhatsApp. Inclui as respostas da conta a comentários, públicas (`data.type` igual a `comment_reply`) ou no direct (`comment_dm_reply`). |
| `message.updated` | O remetente editou a mensagem, ou alguém reagiu a ela ou tirou a reação. Só no Instagram. |
| `message.deleted` | O remetente apagou a mensagem no Instagram, ou a conta excluiu o comentário pela Gaio. |
| `message.failed` | O WhatsApp aceitou um envio e depois avisou que ele não foi entregue. |

Respostas e menções em story não têm um tipo próprio: chegam como `message.received`, com `data.type` igual a `story_message` (uma resposta a um story) ou `story_mention` (uma menção num story).

- O histórico não é enviado: as mensagens que a Gaio guarda ao reconectar uma conta, ou que importa do app WhatsApp Business, não geram evento. Os comentários que a Gaio encontra sozinha, quando o Instagram não os entregou, geram.
- Uma mensagem apagada antes de o evento dela ser entregue chega apagada: as tentativas que ainda faltam trazem a mensagem como a API a responde agora (`is_removed: true`, `text: null`). A exceção é o `message.updated`, de uma edição ou de uma reação: ele mantém a mensagem como estava naquela mudança.
- Reconheça os seus próprios envios pelo `id` que o envio respondeu, e não pelo `sender.type`: quando o canal informa a mensagem antes de a Gaio registrar quem a enviou, o `message.sent` vem com `unattributed`, e a mensagem lida depois mostra o remetente.
- A mídia de uma mensagem pode responder `404` por alguns segundos depois que o evento chega, enquanto a Gaio a guarda: tente de novo.
- Um contato bloqueado das automações continua gerando `message.received`, mas um envio pela API para ele é recusado, com `contact_blocked`.

### Contatos

O `data` traz o contato como `GET /v1/contacts/{contactId}` o responde depois da mudança. Pedem o escopo `contacts:read`.

| Tipo | Quando é enviado | `data` |
| --- | --- | --- |
| `contact.created` | Um contato foi criado, de qualquer jeito: alguém novo escreveu ou comentou, a conta escreveu para um número novo, uma conversa veio do histórico do canal, uma importação ou `POST /v1/contacts`. | O contato, com as etiquetas e os valores com que foi criado. |
| `contact.tagged` | Etiquetas foram postas num contato que não as tinha, por qualquer caminho: o console, uma automação, uma ação em massa, uma importação ou uma chave. | `{ "contact": …, "tags": […] }`, com as etiquetas postas. |
| `contact.untagged` | Etiquetas foram tiradas de um contato que as tinha. Tirar uma etiqueta tira de todos os contatos da mesma pessoa, e cada um que a tinha gera um evento. | `{ "contact": …, "tags": […] }`, com as etiquetas tiradas. |
| `contact.custom_fields_updated` | Valores de campos personalizados foram preenchidos ou limpos, por qualquer caminho, inclusive uma pergunta de automação que guarda um e-mail ou um telefone. | `{ "contact": …, "custom_fields": […] }`, uma entrada por campo que mudou, com `field_id`, `name`, `type` e `value` (`null` quando o valor foi limpo). |

- Um contato criado com etiquetas ou valores gera um único `contact.created`, que já os traz, e não também `contact.tagged` ou `contact.custom_fields_updated`.
- Gravar o que o contato já tem não gera evento. Um contato que volta depois de excluído mantém o `id` e não chega como criado.
- Excluir ou renomear uma etiqueta ou um campo personalizado não gera evento nos contatos que o tinham: se você guarda uma cópia, leia esses contatos de novo.
- Uma ação em massa ou uma importação gera um evento por contato que mudou. Esses eventos saem a cerca de 100 por minuto por endpoint: uma ação em 5.000 contatos chega ao endpoint ao longo de uns 50 minutos.

### Automações

Pedem o escopo `workflows:read`.

| Tipo | Quando é enviado | `data` |
| --- | --- | --- |
| `workflow_run.ended` | Uma execução terminou: chegou ao fim (`completed`), parou (`stopped`), por exemplo numa proteção da Gaio como a que impede laços, foi barrada (`blocked`), quando o Instagram recusa as respostas privadas no post, ou falhou e nada vai tentá-la de novo. Um envio adiado só termina a execução quando a Gaio desiste dele. | A execução como `GET /v1/workflows/{workflowId}/runs/{runId}` a responde, com os passos. |
| `link.clicked` | Um contato abriu um link que uma automação enviou, a cada vez que abre. | `workflow_id`, `run_id`, `contact_id`, `node_id`, `button_id`, `url` e `clicked_at`. |

- Uma execução esperando o contato (`waiting`) não terminou. Uma execução `stopped` que o contato retoma depois, respondendo, termina de novo e gera outro evento.
- O `data` do `workflow_run.ended` traz os passos dos trechos mais recentes da execução, com `steps_truncated` quando os mais antigos ficaram de fora. Numa execução iniciada por um comentário, a resposta pública ao comentário pode ser registrada depois do fim: lida depois, a execução a mostra entre os passos, com o mesmo `status`.
- No `link.clicked`, `url` é para onde o contato foi levado, `node_id` é o bloco que enviou o link, `button_id` é o `id` do botão no grafo (mantenha os ids ao reescrever um bloco) e `run_id` é a execução que enviou o link ao contato por último. Cada um vem `null` quando a Gaio não sabe, e `contact_id` vem `null` para um contato que a conta não tem mais.
- Toda abertura de um link conta, inclusive recarregar a página, como nas métricas de clique da automação. Um botão que passa a conversa para o WhatsApp não gera evento.
- O clique chega aos webhooks por melhor esforço: numa falha rara, um clique pode ficar sem evento. Um link aberto mais de um ano depois do último envio mostra a página de link expirado e não gera evento.

### Conexões

O `data` é a conexão como `GET /v1/connections` a lista depois da mudança, e o `created_at` do evento é quando ela mudou. Pedem o escopo `contacts:read`.

| Tipo | Quando é enviado |
| --- | --- |
| `connection.connected` | Uma conexão passou a conectada: no console, num login, quando a configuração de um número de WhatsApp termina ou quando uma fatura paga traz de volta o que uma suspensão desligou. |
| `connection.disconnected` | Uma conexão passou a desconectada: no console, porque o canal retirou o acesso, por uma suspensão, pela Gaio ou porque a conta não foi usada nos dias depois de conectar. |

O `disconnect_reason` da conexão diz por que ela está desconectada:

| `disconnect_reason` | O que significa |
| --- | --- |
| `authorization_lost` | O canal retirou o acesso da Gaio: o app foi removido, uma permissão foi revogada ou a senha mudou. Conectar de novo resolve. |
| `disconnected_by_user` | Alguém da conta desconectou. |
| `account_suspended` | A conta foi suspensa, ou a Gaio desligou a conexão. |
| `activation_expired` | A conta não foi usada nos dias depois de conectar. |
| `setup_incomplete` | Um número de WhatsApp com a configuração em andamento, com falha ou esperando o PIN da confirmação em duas etapas do número. |

Ele vem `null` numa conexão conectada, e também numa conexão desconectada antes de a Gaio passar a registrar o motivo. Outros valores podem aparecer.

- Um evento sai a cada vez que o status de uma conexão muda de verdade. Uma mudança que encontra a conexão já naquele status não gera evento, então a mesma mudança feita duas vezes, ao mesmo tempo ou numa nova tentativa, é um evento só.
- Um número de WhatsApp configurado de novo enquanto conectado gera `connection.disconnected`, com `setup_incomplete`, até a configuração terminar: nesse meio-tempo ele não envia mensagens.
- Remover uma conexão no console a desconecta antes, e isso gera evento; a remoção em si não gera, nem a troca de um número por outro.
- Os `connection.disconnected` de uma suspensão não são entregues (veja [Conta suspensa](https://gaio.social/developers/webhooks.md#conta-suspensa)).

## Erros

Os erros das rotas de webhooks seguem o formato de todas as rotas (veja [Erros](https://gaio.social/developers/index.md#erros)), com estes códigos próprios:

| Código | Status | Quando acontece | Campos extras em `context` |
| --- | --- | --- | --- |
| `invalid_webhook_url` | `422` | A URL não é uma para a qual a Gaio envia (veja [A URL](https://gaio.social/developers/webhooks.md#a-url)). | `reason` |
| `webhook_limit_reached` | `409` | A conta já tem o máximo de endpoints. | `limit` |
| `webhook_disabled` | `409` | Uma entrega reenviada para um endpoint desativado. Ative o endpoint antes. | — |
| `api_disabled` | `403` | A API foi desligada para a conta. | — |
| `service_unavailable` | `503` | Os webhooks não podem ser assinados agora. Tente de novo. | — |
