Webhooks: eventos, payloads, verificação da assinatura e entregas
# Webhooks (gestão)
> Gerencie webhooks pela API da TopTalking - listar, criar (com o secret de assinatura), buscar, atualizar, reativar, excluir, enviar evento de teste (ping) e ver o histórico de entregas.
Estes endpoints gerenciam os **endereços** que recebem eventos. Para entender os eventos em si, como validar a assinatura e como funcionam as retentativas, veja a seção [Webhooks](/webhooks/visao-geral/). Você também pode fazer tudo isso no painel, em [Configurações → Desenvolvedor → Webhooks](https://toptalking.com.br/configuracoes/desenvolvedor). ## O objeto webhook
```json
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"url": "https://meusistema.com.br/webhooks/toptalking",
"description": "CRM interno",
"events": ["message.received", "contact.stage_changed"],
"enabled": true,
"disabled_reason": null,
"last_delivery_at": "2026-11-11T14:12:41Z",
"last_delivery_status": 200,
"created_at": "2026-11-01T10:00:00Z",
"updated_at": "2026-11-01T10:00:00Z"
}
```
| Campo | Tipo | Descrição | | ---------------------- | ----------------- | ----------------------------------------------------------------------- | | `id` | UUID | ID do webhook. | | `object` | string | Sempre `webhook_endpoint`. | | `url` | string | Endereço HTTPS que recebe os eventos. | | `description` | string ou `null` | Descrição livre. | | `events` | lista | Eventos assinados, ou `["*"]` para todos. | | `enabled` | booleano | Se está recebendo eventos. | | `disabled_reason` | string ou `null` | Preenchido quando a TopTalking desativou o webhook por falhas seguidas. | | `last_delivery_at` | data ou `null` | Última tentativa de entrega. | | `last_delivery_status` | inteiro ou `null` | Status HTTP que o seu sistema respondeu na última entrega. | | `created_at` | data | Criação. | | `updated_at` | data | Última alteração. | ### Eventos disponíveis Valores aceitos em `events`: | Valor | Quando chega | | -------------------------- | ---------------------------------------------------------------------------------------- | | `*` | Todos os eventos. | | `message.received` | Um contato mandou mensagem. | | `message.sent` | Uma mensagem saiu pela conta. | | `message.status_updated` | O status de entrega de uma mensagem enviada mudou. | | `contact.created` | Um contato novo entrou na base. | | `contact.updated` | Mudou nome, e-mail, etapa, responsável, IA, arquivamento ou finalização. | | `contact.stage_changed` | O card mudou de etapa no Kanban. | | `scheduled_message.sent` | Uma mensagem agendada foi enviada. | | `scheduled_message.failed` | Uma mensagem agendada falhou. | | `channel.status_updated` | Um WhatsApp conectou, caiu ou foi desconectado. | | `campaign.status_updated` | Uma campanha mudou de status (aprovada, ajustes pedidos, enviando, pausada, concluída…). | O evento de teste `ping` não precisa ser assinado: ele sempre é entregue quando você pede um teste. Os payloads de cada evento estão em [Eventos e payloads](/webhooks/eventos/). ## Listar webhooks GET`/v1/webhooks`Testar Todos os webhooks da organização (no máximo 10). Use para conferir se o seu endereço já está cadastrado antes de criar outro. Não tem parâmetros. A lista vem inteira numa página (`has_more: false`). * curl
```bash
curl https://platform.toptalking.com.br/v1/webhooks \
-H "Authorization: Bearer $TOPTALKING_API_KEY"
```
* JavaScript
```js
const res = await fetch('https://platform.toptalking.com.br/v1/webhooks', {
headers: { Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}` },
});
const { data: webhooks } = await res.json();
const desativados = webhooks.filter((w) => !w.enabled);
```
* Python
```python
import os
import requests
res = requests.get(
"https://platform.toptalking.com.br/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
webhooks = res.json()["data"]
```
#### Resposta `200`
```json
{
"object": "list",
"data": [
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"url": "https://meusistema.com.br/webhooks/toptalking",
"description": "CRM interno",
"events": ["message.received", "contact.stage_changed"],
"enabled": true,
"disabled_reason": null,
"last_delivery_at": "2026-11-11T14:12:41Z",
"last_delivery_status": 200,
"created_at": "2026-11-01T10:00:00Z",
"updated_at": "2026-11-01T10:00:00Z"
}
],
"has_more": false,
"next_cursor": null
}
```
#### Erros Só os gerais: `401` (chave) e `429` (limite por minuto). Veja [Erros](/comecando/erros/). ## Criar webhook POST`/v1/webhooks`Testar Cadastra um endereço do seu sistema para receber eventos. A resposta traz o **`secret`** (só desta vez), usado para [validar a assinatura](/webhooks/assinatura/). Máximo de **10 webhooks por organização**. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ----------------- | --------- | -------- | ----------- | ------------------------------------------------------------------------ | | `url` | corpo | string | sim | Endereço **HTTPS público** do seu sistema. | | `events` | corpo | lista | não | [Eventos](#eventos-dispon%C3%ADveis) a receber. Padrão: `["*"]` (todos). | | `description` | corpo | string | não | Descrição (até 200 caracteres). | | `enabled` | corpo | booleano | não | Padrão: `true`. | | `Idempotency-Key` | cabeçalho | string | não | Evita criar dois webhooks numa retentativa. | * curl
```bash
curl https://platform.toptalking.com.br/v1/webhooks \
-H "Authorization: Bearer $TOPTALKING_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-crm-interno" \
-d '{
"url": "https://meusistema.com.br/webhooks/toptalking",
"events": ["message.received", "contact.stage_changed"],
"description": "CRM interno"
}'
```
* JavaScript
```js
const res = await fetch('https://platform.toptalking.com.br/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'webhook-crm-interno',
},
body: JSON.stringify({
url: 'https://meusistema.com.br/webhooks/toptalking',
events: ['message.received', 'contact.stage_changed'],
description: 'CRM interno',
}),
});
const webhook = await res.json();
// Guarde webhook.secret AGORA (ex.: na variável TOPTALKING_WEBHOOK_SECRET).
```
* Python
```python
import os
import requests
res = requests.post(
"https://platform.toptalking.com.br/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}",
"Idempotency-Key": "webhook-crm-interno",
},
json={
"url": "https://meusistema.com.br/webhooks/toptalking",
"events": ["message.received", "contact.stage_changed"],
"description": "CRM interno",
},
timeout=30,
)
res.raise_for_status()
webhook = res.json()
# Guarde webhook["secret"] AGORA (ex.: na variável TOPTALKING_WEBHOOK_SECRET).
```
#### Resposta `201` O webhook criado **com o `secret`**:
```json
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"url": "https://meusistema.com.br/webhooks/toptalking",
"description": "CRM interno",
"events": ["message.received", "contact.stage_changed"],
"enabled": true,
"disabled_reason": null,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-11-01T10:00:00Z",
"updated_at": "2026-11-01T10:00:00Z",
"secret": "whsec_d8d10826ecb93b4679de7409553d10982e864a172f6df97b"
}
```
O secret só vem nesta resposta A API não devolve o `secret` de novo. Se perder, revele ou gere um novo no painel (**Revelar segredo** / **Gerar novo segredo**, no menu do webhook). #### Erros | HTTP | `code` | Quando acontece | | ---- | ---------------------- | ------------------------------------------------------------------------------------------- | | 400 | `missing_field` | Faltou `url`. | | 400 | `invalid_field` | URL sem HTTPS ou não pública (localhost, rede interna), ou evento desconhecido em `events`. | | 403 | `read_only_key` | Chave somente leitura. | | 409 | `idempotency_conflict` | `Idempotency-Key` reutilizada com outro corpo. | | 422 | `limit_reached` | A organização já tem 10 webhooks. | 422 limit_reached
```json
{
"error": {
"type": "invalid_request_error",
"code": "limit_reached",
"message": "Limite de 10 webhooks por organização atingido.",
"request_id": "req_6f1d2c3b4a5e"
}
}
```
## Buscar webhook GET`/v1/webhooks/{webhook_id}`Testar Um webhook pelo ID, sem o `secret`. Útil para conferir `enabled`, `disabled_reason` e o resultado da última entrega. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ------------ | ------- | ---- | ----------- | -------------- | | `webhook_id` | caminho | UUID | sim | ID do webhook. | * curl
```bash
curl https://platform.toptalking.com.br/v1/webhooks/f03b0773-5912-4a45-9139-c29375485843 \
-H "Authorization: Bearer $TOPTALKING_API_KEY"
```
* JavaScript
```js
const id = 'f03b0773-5912-4a45-9139-c29375485843';
const res = await fetch(`https://platform.toptalking.com.br/v1/webhooks/${id}`, {
headers: { Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}` },
});
const webhook = await res.json();
if (!webhook.enabled) console.warn('Webhook desativado:', webhook.disabled_reason);
```
* Python
```python
import os
import requests
webhook_id = "f03b0773-5912-4a45-9139-c29375485843"
res = requests.get(
f"https://platform.toptalking.com.br/v1/webhooks/{webhook_id}",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
webhook = res.json()
```
#### Resposta `200`
```json
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"url": "https://meusistema.com.br/webhooks/toptalking",
"description": "CRM interno",
"events": ["message.received", "contact.stage_changed"],
"enabled": true,
"disabled_reason": null,
"last_delivery_at": "2026-11-11T14:12:41Z",
"last_delivery_status": 200,
"created_at": "2026-11-01T10:00:00Z",
"updated_at": "2026-11-01T10:00:00Z"
}
```
#### Erros | HTTP | `code` | Quando acontece | | ---- | -------------------- | --------------------- | | 404 | `resource_not_found` | O webhook não existe. | ## Atualizar webhook PATCH`/v1/webhooks/{webhook_id}`Testar Muda o endereço, os eventos ou a descrição, ou pausa/reativa o webhook — inclusive um que a TopTalking desativou por falhas seguidas. Envie só o que quer mudar. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ------------- | ------- | ---------------- | ----------- | --------------------------------------------- | | `webhook_id` | caminho | UUID | sim | ID do webhook. | | `url` | corpo | string | não | Novo endereço HTTPS. | | `events` | corpo | lista | não | Nova lista de eventos (substitui a anterior). | | `description` | corpo | string ou `null` | não | Nova descrição. | | `enabled` | corpo | booleano | não | `false` pausa; `true` reativa. | * curl
```bash
# Reativar e passar a receber também os eventos de conexão
curl -X PATCH https://platform.toptalking.com.br/v1/webhooks/f03b0773-5912-4a45-9139-c29375485843 \
-H "Authorization: Bearer $TOPTALKING_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"events": ["message.received", "contact.stage_changed", "channel.status_updated"]
}'
```
* JavaScript
```js
const id = 'f03b0773-5912-4a45-9139-c29375485843';
const res = await fetch(`https://platform.toptalking.com.br/v1/webhooks/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
enabled: true,
events: ['message.received', 'contact.stage_changed', 'channel.status_updated'],
}),
});
const webhook = await res.json();
```
* Python
```python
import os
import requests
webhook_id = "f03b0773-5912-4a45-9139-c29375485843"
res = requests.patch(
f"https://platform.toptalking.com.br/v1/webhooks/{webhook_id}",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
json={
"enabled": True,
"events": ["message.received", "contact.stage_changed", "channel.status_updated"],
},
timeout=30,
)
res.raise_for_status()
webhook = res.json()
```
#### Resposta `200`
```json
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"url": "https://meusistema.com.br/webhooks/toptalking",
"description": "CRM interno",
"events": ["message.received", "contact.stage_changed", "channel.status_updated"],
"enabled": true,
"disabled_reason": null,
"last_delivery_at": "2026-11-11T14:12:41Z",
"last_delivery_status": 200,
"created_at": "2026-11-01T10:00:00Z",
"updated_at": "2026-11-11T15:05:30Z"
}
```
#### Erros | HTTP | `code` | Quando acontece | | ---- | ------------------------------- | ----------------------------------------------------- | | 400 | `missing_field` | O corpo não tem nenhum campo para atualizar. | | 400 | `invalid_json`, `invalid_field` | Corpo inválido, URL sem HTTPS ou evento desconhecido. | | 403 | `read_only_key` | Chave somente leitura. | | 404 | `resource_not_found` | O webhook não existe. | ## Excluir webhook DELETE`/v1/webhooks/{webhook_id}`Testar Remove o webhook: o endereço para de receber eventos imediatamente. Para só pausar, use [`PATCH`](#atualizar-webhook) com `enabled: false`. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ------------ | ------- | ---- | ----------- | -------------- | | `webhook_id` | caminho | UUID | sim | ID do webhook. | * curl
```bash
curl -X DELETE https://platform.toptalking.com.br/v1/webhooks/f03b0773-5912-4a45-9139-c29375485843 \
-H "Authorization: Bearer $TOPTALKING_API_KEY"
```
* JavaScript
```js
const id = 'f03b0773-5912-4a45-9139-c29375485843';
const res = await fetch(`https://platform.toptalking.com.br/v1/webhooks/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}` },
});
const { deleted } = await res.json(); // true
```
* Python
```python
import os
import requests
webhook_id = "f03b0773-5912-4a45-9139-c29375485843"
res = requests.delete(
f"https://platform.toptalking.com.br/v1/webhooks/{webhook_id}",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
timeout=30,
)
res.raise_for_status()
```
#### Resposta `200`
```json
{
"id": "f03b0773-5912-4a45-9139-c29375485843",
"object": "webhook_endpoint",
"deleted": true
}
```
#### Erros | HTTP | `code` | Quando acontece | | ---- | -------------------- | ---------------------- | | 403 | `read_only_key` | Chave somente leitura. | | 404 | `resource_not_found` | O webhook não existe. | ## Enviar evento de teste POST`/v1/webhooks/{webhook_id}/test`Testar Coloca na fila um evento [`ping`](/webhooks/eventos/#ping) para o endereço — **mesmo se o webhook estiver desativado**. É o mesmo que o botão **Enviar teste** do painel. Use para conferir se o seu endpoint recebe e valida a assinatura. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ------------ | ------- | ---- | ----------- | -------------- | | `webhook_id` | caminho | UUID | sim | ID do webhook. | * curl
```bash
curl -X POST https://platform.toptalking.com.br/v1/webhooks/f03b0773-5912-4a45-9139-c29375485843/test \
-H "Authorization: Bearer $TOPTALKING_API_KEY"
```
* JavaScript
```js
const id = 'f03b0773-5912-4a45-9139-c29375485843';
const res = await fetch(`https://platform.toptalking.com.br/v1/webhooks/${id}/test`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}` },
});
const entrega = await res.json(); // status 202
```
* Python
```python
import os
import requests
webhook_id = "f03b0773-5912-4a45-9139-c29375485843"
res = requests.post(
f"https://platform.toptalking.com.br/v1/webhooks/{webhook_id}/test",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
timeout=30,
)
entrega = res.json() # res.status_code == 202
```
#### Resposta `202` A entrega criada, ainda na fila:
```json
{
"id": "d94d8ee3-4eb6-41f1-8c44-44bd0907d99f",
"object": "webhook_delivery",
"webhook_id": "f03b0773-5912-4a45-9139-c29375485843",
"event_id": "252a2c18-7b60-47a6-9feb-9c8322f2ba50",
"event_type": "ping",
"status": "pending",
"attempts": 0,
"response_status": null,
"error": null,
"duration_ms": null,
"next_attempt_at": "2026-11-11T15:10:00Z",
"delivered_at": null,
"created_at": "2026-11-11T15:10:00Z"
}
```
#### Erros | HTTP | `code` | Quando acontece | | ---- | -------------------- | ---------------------- | | 403 | `read_only_key` | Chave somente leitura. | | 404 | `resource_not_found` | O webhook não existe. | ## Histórico de entregas GET`/v1/webhooks/{webhook_id}/deliveries`Testar As últimas tentativas de entrega deste webhook, com [paginação](/comecando/paginacao/). Os eventos ficam guardados por **30 dias**. Use para investigar por que o seu sistema não recebeu um evento. | Parâmetro | Onde | Tipo | Obrigatório | Descrição | | ------------ | ------- | ------- | ----------- | -------------------------------------- | | `webhook_id` | caminho | UUID | sim | ID do webhook. | | `limit` | query | inteiro | não | Itens por página, 1 a 100 (padrão 20). | | `cursor` | query | string | não | `next_cursor` da página anterior. | * curl
```bash
curl "https://platform.toptalking.com.br/v1/webhooks/f03b0773-5912-4a45-9139-c29375485843/deliveries?limit=20" \
-H "Authorization: Bearer $TOPTALKING_API_KEY"
```
* JavaScript
```js
const id = 'f03b0773-5912-4a45-9139-c29375485843';
const res = await fetch(
`https://platform.toptalking.com.br/v1/webhooks/${id}/deliveries?limit=20`,
{ headers: { Authorization: `Bearer ${process.env.TOPTALKING_API_KEY}` } },
);
const { data: entregas } = await res.json();
const comFalha = entregas.filter((e) => e.status !== 'succeeded');
```
* Python
```python
import os
import requests
webhook_id = "f03b0773-5912-4a45-9139-c29375485843"
res = requests.get(
f"https://platform.toptalking.com.br/v1/webhooks/{webhook_id}/deliveries",
headers={"Authorization": f"Bearer {os.environ['TOPTALKING_API_KEY']}"},
params={"limit": 20},
timeout=30,
)
res.raise_for_status()
entregas = res.json()["data"]
```
#### Resposta `200`
```json
{
"object": "list",
"data": [
{
"id": "7c8f5d3c-c721-44aa-a161-3fe74f6ad736",
"object": "webhook_delivery",
"webhook_id": "f03b0773-5912-4a45-9139-c29375485843",
"event_id": "597b7d23-208d-4b5b-82a2-fb1703a81d63",
"event_type": "message.received",
"status": "pending",
"attempts": 2,
"response_status": 500,
"error": "HTTP 500",
"duration_ms": 1240,
"next_attempt_at": "2026-11-11T14:18:42Z",
"delivered_at": null,
"created_at": "2026-11-11T14:12:41Z"
},
{
"id": "d94d8ee3-4eb6-41f1-8c44-44bd0907d99f",
"object": "webhook_delivery",
"webhook_id": "f03b0773-5912-4a45-9139-c29375485843",
"event_id": "252a2c18-7b60-47a6-9feb-9c8322f2ba50",
"event_type": "ping",
"status": "succeeded",
"attempts": 1,
"response_status": 200,
"error": null,
"duration_ms": 184,
"next_attempt_at": null,
"delivered_at": "2026-11-11T15:10:01Z",
"created_at": "2026-11-11T15:10:00Z"
}
],
"has_more": false,
"next_cursor": null
}
```
| Campo | Tipo | Descrição | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `id` | UUID | ID da entrega (o mesmo do cabeçalho `X-TopTalking-Delivery-Id`). | | `object` | string | Sempre `webhook_delivery`. | | `webhook_id` | UUID | Webhook de destino. | | `event_id` | UUID | ID do evento (o mesmo em todas as tentativas). | | `event_type` | string | Tipo do evento (ex.: `message.received`). | | `status` | string | `pending` (na fila ou esperando nova tentativa), `processing` (enviando), `succeeded` (seu sistema respondeu 2xx) ou `failed`. | | `attempts` | inteiro | Quantas tentativas já foram feitas. | | `response_status` | inteiro ou `null` | Status HTTP que o seu sistema respondeu na última tentativa. | | `error` | string ou `null` | Motivo da falha (timeout, erro de conexão, status não 2xx…). | | `duration_ms` | inteiro ou `null` | Quanto o seu sistema demorou para responder. | | `next_attempt_at` | data ou `null` | Quando será a próxima tentativa, se houver. | | `delivered_at` | data ou `null` | Quando foi entregue com sucesso. | | `created_at` | data | Quando a entrega foi criada. | Entregas que falharam podem ser reenviadas pelo painel (botão **Reenviar**, em **Ver entregas**). Veja [Entregas e novas tentativas](/webhooks/entregas/). #### Erros | HTTP | `code` | Quando acontece | | ---- | -------------------- | --------------------- | | 404 | `resource_not_found` | O webhook não existe. |
# Visão geral dos webhooks
> Receba eventos da TopTalking em tempo real (mensagem recebida, status de entrega, contato criado ou alterado, mudança de etapa no Kanban, mensagem agendada, conexão do WhatsApp e status de campanha) no seu sistema por webhook HTTPS assinado.
Com webhooks, **a TopTalking avisa o seu sistema** quando algo acontece na conta — sem você precisar consultar a API o tempo todo. Cada evento é um `POST` com JSON para um endereço HTTPS seu, assinado com HMAC-SHA256. Exemplos de uso: * Gravar no seu banco cada mensagem recebida (`message.received`). * Atualizar o CRM quando um card muda de etapa no Kanban (`contact.stage_changed`). * Saber quando uma mensagem foi entregue ou lida (`message.status_updated`). * Avisar a equipe quando um lembrete agendado falhar (`scheduled_message.failed`). * Saber na hora que um WhatsApp foi conectado pelo QR Code — ou que caiu (`channel.status_updated`). * Acompanhar a aprovação e o envio de uma campanha (`campaign.status_updated`). A lista completa, com exemplos de JSON, está em [Eventos e payloads](/webhooks/eventos/). ## Configurar no painel 1. Abra [Configurações → Desenvolvedor](https://toptalking.com.br/configuracoes/desenvolvedor) e vá até o cartão **Webhooks**. 2. Clique em **Novo webhook**. 3. Informe a **URL** do seu sistema (HTTPS), uma descrição e escolha os eventos — ou **Todos os eventos**. 4. Copie o **segredo de assinatura** (`whsec_...`) e guarde como variável de ambiente no seu servidor (ex.: `TOPTALKING_WEBHOOK_SECRET`). Você vai usá-lo para [verificar a assinatura](/webhooks/assinatura/). 5. No menu do webhook, clique em **Enviar teste**. O seu endpoint deve receber um evento `ping` e responder `2xx`. 6. Em **Ver entregas**, confira o status HTTP e o tempo de resposta de cada envio. Prefere automatizar? Dá para fazer tudo pela API: veja [Webhooks (gestão)](/referencia/webhooks/). ## Requisitos do endereço * **HTTPS** com certificado válido. * **Público na internet:** `localhost`, IPs de rede interna e endereços `.local` não recebem eventos. Para testar na sua máquina, use um túnel HTTPS (como ngrok ou Cloudflare Tunnel). * Até **10 webhooks** por organização. Cada um pode assinar eventos diferentes. ## Como chega um evento
```http
POST /webhooks/toptalking HTTP/1.1
Host: meusistema.com.br
Content-Type: application/json
User-Agent: TopTalking-Webhooks/1.0
X-TopTalking-Signature: t=1794409800,v1=946dcacfdaee2f0eca7a2194d9919092cac50f554189d6651b1ce5acca155cdc
X-TopTalking-Event: ping
X-TopTalking-Event-Id: 252a2c18-7b60-47a6-9feb-9c8322f2ba50
X-TopTalking-Delivery-Id: d94d8ee3-4eb6-41f1-8c44-44bd0907d99f
{"id":"252a2c18-7b60-47a6-9feb-9c8322f2ba50","object":"event","type":"ping","created_at":"2026-11-11T15:10:00Z","organization_id":"e044a615-e879-44bb-8510-948b10b5e93c","data":{"webhook_id":"f03b0773-5912-4a45-9139-c29375485843","message":"Evento de teste enviado pela TopTalking."}}
```
| Cabeçalho | Descrição | | -------------------------- | -------------------------------------------------------------------------------------------------------------- | | `Content-Type` | Sempre `application/json`. | | `User-Agent` | `TopTalking-Webhooks/1.0`. | | `X-TopTalking-Signature` | `t=,v1=`. Veja [Verificar a assinatura](/webhooks/assinatura/). | | `X-TopTalking-Event` | O tipo do evento (ex.: `message.received`). | | `X-TopTalking-Event-Id` | ID do evento. **É o mesmo em todas as retentativas** — use para não processar duas vezes. | | `X-TopTalking-Delivery-Id` | ID da entrega para este endereço. | ### O corpo (envelope) Todo evento tem o mesmo envelope; o conteúdo muda em `data`:
```json
{
"id": "597b7d23-208d-4b5b-82a2-fb1703a81d63",
"object": "event",
"type": "message.received",
"created_at": "2026-11-11T14:12:40Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"message": { "...": "..." },
"contact": { "...": "..." }
}
}
```
| Campo | Descrição | | ----------------- | ------------------------------------------------------------------ | | `id` | ID do evento (igual ao cabeçalho `X-TopTalking-Event-Id`). | | `object` | Sempre `event`. | | `type` | Tipo do evento. | | `created_at` | Quando o evento aconteceu. | | `organization_id` | Organização de origem. | | `data` | Os dados do evento. Veja [Eventos e payloads](/webhooks/eventos/). | ## Como responder * Responda com **qualquer status `2xx` em até 10 segundos**. O corpo da resposta é ignorado. * **Valide a assinatura antes de tudo** e responda `401` se ela não bater. * Faça o trabalho pesado **depois de responder** (fila, job em segundo plano). Se você demorar mais de 10 segundos ou responder fora de `2xx`, a TopTalking considera falha e [tenta de novo](/webhooks/entregas/). * Seja **idempotente**: o mesmo evento pode chegar mais de uma vez. Guarde os `id` já processados. * Não dependa da **ordem** de chegada: use `created_at` e os dados do próprio evento. Esqueleto de um receptor (Node.js + Express)
```js
import express from 'express';
import { verificarAssinatura } from './toptalking-assinatura.js'; // veja "Verificar a assinatura"
const app = express();
// Importante: o corpo CRU (sem JSON.parse) é necessário para validar a assinatura.
app.post('/webhooks/toptalking', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verificarAssinatura(req.body, req.get('X-TopTalking-Signature'), process.env.TOPTALKING_WEBHOOK_SECRET)) {
return res.status(401).send('assinatura inválida');
}
const evento = JSON.parse(req.body.toString('utf8'));
// Responda rápido; processe depois.
res.sendStatus(200);
await filaDeEventos.adicionar(evento); // sua fila / job em segundo plano
});
app.listen(3000);
```
[Eventos e payloads](/webhooks/eventos/)Todos os eventos com exemplos de JSON. [Verificar a assinatura](/webhooks/assinatura/)Código pronto para Node.js, Python e Supabase Edge Functions. [Entregas e novas tentativas](/webhooks/entregas/)Prazos, retentativas, desativação automática e histórico. [Receber webhooks no Lovable](/ia/lovable/#receber-webhooks-numa-edge-function)Edge Function que valida a assinatura e salva numa tabela.
# Eventos e payloads
> Todos os eventos de webhook da TopTalking com exemplos de JSON - message.received, message.sent, message.status_updated, contact.created, contact.updated, contact.stage_changed, scheduled_message.sent, scheduled_message.failed, channel.status_updated, campaign.status_updated e ping.
Todo evento chega no mesmo [envelope](/webhooks/visao-geral/#o-corpo-envelope) (`id`, `object: "event"`, `type`, `created_at`, `organization_id`, `data`). Os objetos dentro de `data` têm exatamente o mesmo formato da API REST: [mensagem](/referencia/mensagens/#o-objeto-mensagem), [contato](/referencia/contatos/#o-objeto-contato), [mensagem agendada](/referencia/mensagens-agendadas/#o-objeto-mensagem-agendada), [canal](/referencia/canais/#o-objeto-canal) e [campanha](/referencia/campanhas/#o-objeto-campanha). | Evento | Quando dispara | `data` | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------- | | [`message.received`](#messagereceived) | Um contato mandou mensagem (WhatsApp, grupo ou Chat do site). | `message`, `contact` | | [`message.sent`](#messagesent) | Uma mensagem saiu pela conta (equipe, IA, campanha, Agenda ou API). | `message`, `contact` | | [`message.status_updated`](#messagestatus_updated) | O status de entrega de uma mensagem enviada mudou. | `message`, `contact`, `previous_status` | | [`contact.created`](#contactcreated) | Um contato novo entrou na base. | `contact` | | [`contact.updated`](#contactupdated) | Mudou nome, e-mail, etapa, responsável, IA, arquivamento ou finalização. | `contact`, `changed_fields`, `previous` | | [`contact.stage_changed`](#contactstage_changed) | O card mudou de etapa no Kanban. | `contact`, `stage`, `previous_stage` | | [`scheduled_message.sent`](#scheduled_messagesent) | Uma mensagem agendada foi enviada. | `scheduled_message` | | [`scheduled_message.failed`](#scheduled_messagefailed) | Uma mensagem agendada falhou. | `scheduled_message` | | [`channel.status_updated`](#channelstatus_updated) | Um WhatsApp conectou (o QR Code foi lido), caiu ou foi desconectado. | `channel`, `previous_status` | | [`campaign.status_updated`](#campaignstatus_updated) | Uma campanha foi aprovada, teve ajustes pedidos, começou a enviar, foi pausada, concluída ou falhou. | `campaign`, `previous_status` | | [`ping`](#ping) | Teste pedido no painel (**Enviar teste**) ou pela API. | `webhook_id`, `message` | Ao criar o webhook, assine eventos específicos ou `*` para receber todos. O `ping` sempre é entregue quando você pede um teste. ## `message.received` Um contato mandou uma mensagem para a empresa — no WhatsApp, num grupo ou no Chat do site. `message.direction` é sempre `inbound`.
```json
{
"id": "597b7d23-208d-4b5b-82a2-fb1703a81d63",
"object": "event",
"type": "message.received",
"created_at": "2026-11-11T14:12:40Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"message": {
"id": "b7fa4c19-3e25-4ce9-9794-4e7f60efa4ba",
"object": "message",
"contact_id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"direction": "inbound",
"type": "text",
"text": "Vocês entregam no sábado?",
"media": null,
"status": "delivered",
"error": null,
"reply_to_id": null,
"reaction": null,
"sender_name": null,
"transcript": null,
"created_at": "2026-11-11T14:12:40Z",
"edited_at": null
},
"contact": {
"id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"object": "contact",
"name": "Maria Souza",
"phone": "5511999998888",
"email": "maria@exemplo.com",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": {
"id": "9b2f5a8e-6a3b-4d0e-9a51-2a7f4f1d0c11",
"name": "Negociação",
"is_final": false
},
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": "4083deb8-f96e-4093-8598-ebdd4dee786b",
"pinned": false,
"unread_count": 1,
"last_message_at": "2026-11-11T14:12:40Z",
"last_message_preview": "Vocês entregam no sábado?",
"resolved_at": null,
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-10-28T19:03:55Z",
"updated_at": "2026-11-11T14:12:40Z"
}
}
}
```
Um áudio recebido chega com `type: "audio"`, o arquivo em `media` e, quando disponível, a transcrição em `transcript`:
```json
{
"id": "3f08322f-2c8f-4633-a478-5d25a5342f66",
"object": "message",
"contact_id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"direction": "inbound",
"type": "audio",
"text": null,
"media": {
"url": "https://arquivos.exemplo.com/audios/3f08322f.ogg",
"mime_type": "audio/ogg",
"filename": null
},
"status": "delivered",
"error": null,
"reply_to_id": null,
"reaction": null,
"sender_name": null,
"transcript": "Oi, queria saber se ainda tem o tênis 39 em estoque.",
"created_at": "2026-11-11T14:16:02Z",
"edited_at": null
}
```
Em grupos, `contact.is_group` é `true` e `message.sender_name` diz quem mandou. ## `message.sent` Uma mensagem saiu pela conta — enviada pela equipe no painel, pela IA, por uma campanha, pela Agenda ou pela API. `message.direction` é sempre `outbound`. O evento pode chegar com `status: "queued"` (ainda na fila). Para saber se foi entregue, acompanhe [`message.status_updated`](#messagestatus_updated).
```json
{
"id": "62b273bc-c0b1-4b61-b1d4-de8d115483cd",
"object": "event",
"type": "message.sent",
"created_at": "2026-11-11T14:30:12Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"message": {
"id": "03ab9a84-86b0-4a1c-a9b2-bbfa205cc52e",
"object": "message",
"contact_id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"direction": "outbound",
"type": "text",
"text": "Entregamos sim! Sábado das 8h às 14h.",
"media": null,
"status": "queued",
"error": null,
"reply_to_id": "b7fa4c19-3e25-4ce9-9794-4e7f60efa4ba",
"reaction": null,
"sender_name": null,
"transcript": null,
"created_at": "2026-11-11T14:30:12Z",
"edited_at": null
},
"contact": {
"id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"object": "contact",
"name": "Maria Souza",
"phone": "5511999998888",
"email": "maria@exemplo.com",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": {
"id": "9b2f5a8e-6a3b-4d0e-9a51-2a7f4f1d0c11",
"name": "Negociação",
"is_final": false
},
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": "4083deb8-f96e-4093-8598-ebdd4dee786b",
"pinned": false,
"unread_count": 0,
"last_message_at": "2026-11-11T14:30:12Z",
"last_message_preview": "Entregamos sim! Sábado das 8h às 14h.",
"resolved_at": null,
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-10-28T19:03:55Z",
"updated_at": "2026-11-11T14:30:12Z"
}
}
}
```
## `message.status_updated` O status de entrega de uma mensagem **enviada** mudou: `queued` → `sent` → `delivered` → `read`, ou `failed`. Só existe para mensagens `outbound`. `previous_status` traz o status anterior.
```json
{
"id": "a6d85050-d17c-4ce6-ae54-4639a7f38536",
"object": "event",
"type": "message.status_updated",
"created_at": "2026-11-11T14:31:05Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"message": {
"id": "03ab9a84-86b0-4a1c-a9b2-bbfa205cc52e",
"object": "message",
"contact_id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"direction": "outbound",
"type": "text",
"text": "Entregamos sim! Sábado das 8h às 14h.",
"media": null,
"status": "read",
"error": null,
"reply_to_id": "b7fa4c19-3e25-4ce9-9794-4e7f60efa4ba",
"reaction": null,
"sender_name": null,
"transcript": null,
"created_at": "2026-11-11T14:30:12Z",
"edited_at": null
},
"contact": {
"id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"object": "contact",
"name": "Maria Souza",
"phone": "5511999998888",
"email": "maria@exemplo.com",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": {
"id": "9b2f5a8e-6a3b-4d0e-9a51-2a7f4f1d0c11",
"name": "Negociação",
"is_final": false
},
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": "4083deb8-f96e-4093-8598-ebdd4dee786b",
"pinned": false,
"unread_count": 0,
"last_message_at": "2026-11-11T14:30:12Z",
"last_message_preview": "Entregamos sim! Sábado das 8h às 14h.",
"resolved_at": null,
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-10-28T19:03:55Z",
"updated_at": "2026-11-11T14:30:12Z"
},
"previous_status": "delivered"
}
}
```
Quando a entrega falha, `message.status` é `failed` e `message.error` explica o motivo. ## `contact.created` Um contato novo entrou na base — por uma mensagem recebida, pelo Chat do site, pelo painel, pela API ou por outra origem (veja `contact.source`).
```json
{
"id": "2d3339bd-e78f-4ebd-84b5-46afdb2b348d",
"object": "event",
"type": "contact.created",
"created_at": "2026-11-11T15:01:18Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"contact": {
"id": "6586daec-7197-478e-b0ff-5b6659c11d26",
"object": "contact",
"name": "Ana Lima",
"phone": "5531991234567",
"email": null,
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": null,
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": null,
"pinned": false,
"unread_count": 0,
"last_message_at": null,
"last_message_preview": null,
"resolved_at": null,
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-11-11T15:01:18Z",
"updated_at": "2026-11-11T15:01:18Z"
}
}
}
```
## `contact.updated` Disparado quando muda um **campo de negócio** do contato: | Valor em `changed_fields` | O que mudou | | ------------------------- | ---------------------------------------------------------------------------------- | | `name` | Nome. | | `email` | E-mail. | | `stage` | Etapa do Kanban (também dispara [`contact.stage_changed`](#contactstage_changed)). | | `assigned_to` | Responsável. | | `ai_enabled` | IA ligada ou desligada na conversa. | | `archived_at` | Arquivado ou desarquivado. | | `resolved_at` | Finalizado ou reaberto. | Ele **não** dispara a cada mensagem nova (contador de não lidas, prévia da última mensagem etc.) — para isso existem os eventos de mensagem. `previous` traz os **valores anteriores só dos campos que mudaram**. Para `stage`, o valor anterior é um objeto de etapa (`id`, `name`, `is_final`) ou `null`.
```json
{
"id": "417bc8d0-a2e2-492b-b54a-2d1c7a982e85",
"object": "event",
"type": "contact.updated",
"created_at": "2026-11-11T14:35:21Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"contact": {
"id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"object": "contact",
"name": "Maria Souza",
"phone": "5511999998888",
"email": "maria@exemplo.com",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": {
"id": "3c671a88-07fb-4ccc-80db-184be834360c",
"name": "Finalizado",
"is_final": true
},
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": "4083deb8-f96e-4093-8598-ebdd4dee786b",
"pinned": false,
"unread_count": 0,
"last_message_at": "2026-11-11T14:30:12Z",
"last_message_preview": "Entregamos sim! Sábado das 8h às 14h.",
"resolved_at": "2026-11-11T14:35:21Z",
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-10-28T19:03:55Z",
"updated_at": "2026-11-11T14:35:21Z"
},
"changed_fields": ["stage", "resolved_at"],
"previous": {
"stage": {
"id": "9b2f5a8e-6a3b-4d0e-9a51-2a7f4f1d0c11",
"name": "Negociação",
"is_final": false
},
"resolved_at": null
}
}
}
```
## `contact.stage_changed` O card mudou de etapa no Kanban — pelo painel, pela IA ou pela API. `stage` é a etapa nova e `previous_stage` a anterior (qualquer uma pode ser `null`, quando o card entra ou sai do Kanban). Se `stage.is_final` for `true`, o card foi para a etapa final e a conversa foi finalizada.
```json
{
"id": "d11785e0-67f1-45b8-9796-ccf867b500ba",
"object": "event",
"type": "contact.stage_changed",
"created_at": "2026-11-11T14:35:21Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"contact": {
"id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"object": "contact",
"name": "Maria Souza",
"phone": "5511999998888",
"email": "maria@exemplo.com",
"channel_id": "d781e759-4047-4c35-bfcd-eae8c666369a",
"channel_type": "whatsapp",
"is_group": false,
"stage": {
"id": "3c671a88-07fb-4ccc-80db-184be834360c",
"name": "Finalizado",
"is_final": true
},
"ai_enabled": true,
"ai_agent_id": null,
"assigned_to": "4083deb8-f96e-4093-8598-ebdd4dee786b",
"pinned": false,
"unread_count": 0,
"last_message_at": "2026-11-11T14:30:12Z",
"last_message_preview": "Entregamos sim! Sábado das 8h às 14h.",
"resolved_at": "2026-11-11T14:35:21Z",
"archived_at": null,
"source": "whatsapp",
"created_at": "2026-10-28T19:03:55Z",
"updated_at": "2026-11-11T14:35:21Z"
},
"stage": {
"id": "3c671a88-07fb-4ccc-80db-184be834360c",
"name": "Finalizado",
"is_final": true
},
"previous_stage": {
"id": "9b2f5a8e-6a3b-4d0e-9a51-2a7f4f1d0c11",
"name": "Negociação",
"is_final": false
}
}
}
```
## `scheduled_message.sent` Uma [mensagem agendada](/referencia/mensagens-agendadas/) (Agenda) foi enviada. `message_id` aponta para a mensagem gerada — que também gera um [`message.sent`](#messagesent).
```json
{
"id": "a5ad15a7-bccf-4ff7-ac3c-64168d032258",
"object": "event",
"type": "scheduled_message.sent",
"created_at": "2026-11-10T13:00:41Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"scheduled_message": {
"id": "d3440bed-1a6f-4470-9d89-cec62fe2fc67",
"object": "scheduled_message",
"contact_id": "721bfe94-e549-4354-b90c-a8d679e9b271",
"to": "5521987654321",
"title": "Renovação do plano",
"text": "Oi {{nome}}! Seu plano vence na sexta. Quer que a gente renove?",
"media": [],
"send_at": "2026-11-10T13:00:00Z",
"status": "sent",
"sent_at": "2026-11-10T13:00:41Z",
"message_id": "8e395744-cc4c-4eae-9ccd-06c8dde834e0",
"error": null,
"created_at": "2026-11-03T18:22:10Z",
"updated_at": "2026-11-10T13:00:41Z"
}
}
}
```
## `scheduled_message.failed` Uma mensagem agendada não pôde ser enviada. `error` explica o motivo (ex.: WhatsApp desconectado no horário do envio).
```json
{
"id": "d161f19b-c31d-4378-88f7-3f6c23c3e135",
"object": "event",
"type": "scheduled_message.failed",
"created_at": "2026-11-12T12:00:38Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"scheduled_message": {
"id": "603916c7-604c-4c56-8a58-f3f76886634b",
"object": "scheduled_message",
"contact_id": "3f1c2b9a-7d4e-4c55-8a0e-1b2c3d4e5f60",
"to": "5511999998888",
"title": "Lembrete de consulta",
"text": "Oi {{nome}}, passando para lembrar da sua consulta amanhã às 9h.",
"media": [],
"send_at": "2026-11-12T12:00:00Z",
"status": "failed",
"sent_at": null,
"message_id": null,
"error": "WhatsApp desconectado.",
"created_at": "2026-11-11T14:50:00Z",
"updated_at": "2026-11-12T12:00:38Z"
}
}
}
```
## `channel.status_updated` A conexão de um WhatsApp mudou: o celular leu o QR Code (ou digitou o código de pareamento) e o canal **conectou**, a sessão **caiu** ou alguém **desconectou** o número. `data.channel` é o [canal](/referencia/canais/#o-objeto-canal) já com o status novo e `previous_status` é o status anterior (`connected`, `connecting`, `disconnected` ou `null`). É o jeito mais rápido de saber que o pareamento terminou — sem consultar `GET /channels/{channel_id}/qr` — e de avisar a equipe quando um número cai. * Só dispara para canais `whatsapp` (o Chat do site não gera este evento). * **Não** dispara quando o QR Code só é renovado durante o pareamento.
```json
{
"id": "d6778bf5-f7d2-40d4-ba5e-0edaafd4af41",
"object": "event",
"type": "channel.status_updated",
"created_at": "2026-11-11T15:21:14Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"channel": {
"id": "742539b3-2c69-4acc-988f-fcd836e565a9",
"object": "channel",
"type": "whatsapp",
"name": "Pós-venda",
"phone": "5511955554444",
"status": "connected",
"is_primary": false,
"created_at": "2026-11-11T15:20:00Z"
},
"previous_status": "connecting"
}
}
```
Quando um número cai, chega `channel.status` igual a `disconnected` e `previous_status` igual a `connected`: avise a equipe e gere um novo QR Code com [`POST /channels/{channel_id}/connect`](/referencia/canais/#reconectar-um-whatsapp-desconectado). ## `campaign.status_updated` Uma [campanha](/referencia/campanhas/) mudou de status: foi **aprovada** pela TopTalking, teve **ajustes pedidos**, **começou a enviar**, foi **pausada**, **concluída** ou **falhou**. `data.campaign` é a [campanha](/referencia/campanhas/#o-objeto-campanha) atualizada e `previous_status` o status anterior.
```json
{
"id": "a6624a26-d283-4db2-9585-58d26bbf213d",
"object": "event",
"type": "campaign.status_updated",
"created_at": "2026-11-11T16:20:00Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"campaign": {
"id": "f2143187-f4f8-43b0-9c03-57a26c575f41",
"object": "campaign",
"name": "Black Friday 2026",
"status": "approved",
"review_notes": null,
"template": {
"name": "promo_black_friday",
"language": "pt_BR",
"category": "MARKETING"
},
"totals": {
"recipients": 3,
"pending": 3,
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0
},
"pricing": {
"unit_price": 0.35,
"currency": "BRL",
"estimated_total": 1.05,
"charged_total": 0
},
"created_at": "2026-11-11T16:00:00Z",
"submitted_at": "2026-11-11T16:00:00Z",
"reviewed_at": "2026-11-11T16:20:00Z",
"started_at": null,
"completed_at": null
},
"previous_status": "pending_approval"
}
}
```
Quando a revisão pede ajustes, `campaign.status` vem `changes_requested` e `campaign.review_notes` explica o que mudar:
```json
{
"id": "c3d40a38-2062-4259-b999-047a297ba3aa",
"object": "event",
"type": "campaign.status_updated",
"created_at": "2026-11-11T16:18:40Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"campaign": {
"id": "1eaa64c7-7f3c-40e6-9554-754d3e4b097c",
"object": "campaign",
"name": "Black Friday 2026 - lista comprada",
"status": "changes_requested",
"review_notes": "A lista parece incluir pessoas que nunca falaram com a loja. Envie só clientes que aceitaram receber mensagens.",
"template": {
"name": "promo_black_friday",
"language": "pt_BR",
"category": "MARKETING"
},
"totals": {
"recipients": 1500,
"pending": 1500,
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0
},
"pricing": {
"unit_price": 0.35,
"currency": "BRL",
"estimated_total": 525,
"charged_total": 0
},
"created_at": "2026-11-11T15:50:00Z",
"submitted_at": "2026-11-11T15:50:00Z",
"reviewed_at": "2026-11-11T16:18:40Z",
"started_at": null,
"completed_at": null
},
"previous_status": "pending_approval"
}
}
```
## `ping` Evento de teste. Enviado quando você clica em **Enviar teste** no painel ou chama [`POST /webhooks/{webhook_id}/test`](/referencia/webhooks/#enviar-evento-de-teste) — mesmo que o webhook esteja desativado e mesmo que ele não assine `*`. Responda `2xx` e ignore o conteúdo.
```json
{
"id": "252a2c18-7b60-47a6-9feb-9c8322f2ba50",
"object": "event",
"type": "ping",
"created_at": "2026-11-11T15:10:00Z",
"organization_id": "e044a615-e879-44bb-8510-948b10b5e93c",
"data": {
"webhook_id": "f03b0773-5912-4a45-9139-c29375485843",
"message": "Evento de teste enviado pela TopTalking."
}
}
```
## Tratando eventos no código Roteando por tipo (Node.js)
```js
async function processarEvento(evento) {
if (await jaProcessado(evento.id)) return; // a mesma entrega pode chegar mais de uma vez
switch (evento.type) {
case 'message.received':
await salvarMensagem(evento.data.message, evento.data.contact);
break;
case 'message.status_updated':
await atualizarStatus(evento.data.message.id, evento.data.message.status);
break;
case 'contact.stage_changed':
await sincronizarEtapaNoCrm(evento.data.contact.id, evento.data.stage?.name ?? null);
break;
case 'channel.status_updated':
if (evento.data.channel.status === 'disconnected') await avisarEquipe(evento.data.channel.name);
break;
case 'campaign.status_updated':
await atualizarCampanha(evento.data.campaign.id, evento.data.campaign.status);
break;
case 'ping':
break; // teste: nada a fazer
default:
break; // ignore tipos que você não usa (novos eventos podem surgir)
}
await marcarComoProcessado(evento.id);
}
```
# Verificar a assinatura
> Como validar o cabeçalho X-TopTalking-Signature (HMAC-SHA256 de "t.corpo" com o secret whsec_) dos webhooks da TopTalking, com código pronto para Node.js (Express), Python (Flask e FastAPI) e Deno / Supabase Edge Functions (Lovable).
Qualquer pessoa que descobrir a URL do seu webhook pode mandar um `POST` para ela. A **assinatura** prova que o evento veio da TopTalking e não foi alterado no caminho. **Sempre valide antes de processar.** ## Como a assinatura é feita Cada entrega traz o cabeçalho:
```http
X-TopTalking-Signature: t=1794409800,v1=946dcacfdaee2f0eca7a2194d9919092cac50f554189d6651b1ce5acca155cdc
```
* `t` — momento do envio, em Unix timestamp (segundos). * `v1` — assinatura em hexadecimal:
```text
v1 = hex( HMAC_SHA256( chave = secret, mensagem = t + "." + corpo_cru ) )
```
O **secret** é o segredo de assinatura do webhook (`whsec_...`), usado **inteiro** (com o prefixo `whsec_`) como chave. Ele aparece quando o webhook é criado; no painel dá para **Revelar segredo** de novo ou **Gerar novo segredo**. ## Passo a passo 1. Leia o **corpo cru** da requisição, exatamente como chegou (bytes ou texto), **antes** de qualquer `JSON.parse`. Reformatar o JSON muda os bytes e quebra a assinatura. 2. Separe `t` e `v1` do cabeçalho `X-TopTalking-Signature`. 3. Rejeite se `t` estiver a mais de **300 segundos** do horário atual (protege contra reenvio de eventos antigos). 4. Calcule `HMAC-SHA256(secret, ".")` em hexadecimal. 5. Compare com `v1` usando **comparação em tempo constante**. 6. Se não bater, responda `401` e não processe nada. ## Código pronto * Node.js (Express) toptalking-assinatura.js
```js
import crypto from 'node:crypto';
const TOLERANCIA_SEGUNDOS = 300; // 5 minutos
/**
* Confere o cabeçalho X-TopTalking-Signature.
* @param {Buffer|string} corpoCru corpo EXATO recebido (antes de qualquer JSON.parse)
* @param {string|undefined} cabecalho valor de X-TopTalking-Signature
* @param {string} secret segredo do webhook (whsec_...)
*/
export function verificarAssinatura(corpoCru, cabecalho, secret) {
if (!cabecalho || !secret) return false;
// Formato: t=,v1=
let t = null;
const assinaturas = [];
for (const parte of cabecalho.split(',')) {
const i = parte.indexOf('=');
if (i === -1) continue;
const chave = parte.slice(0, i).trim();
const valor = parte.slice(i + 1).trim();
if (chave === 't') t = valor;
else if (chave === 'v1' && valor) assinaturas.push(valor);
}
if (!t || !/^\d+$/.test(t) || assinaturas.length === 0) return false;
// Rejeita eventos antigos (proteção contra replay).
const agora = Math.floor(Date.now() / 1000);
if (Math.abs(agora - Number(t)) > TOLERANCIA_SEGUNDOS) return false;
// v1 = HMAC-SHA256(chave = secret, mensagem = ".")
const esperado = crypto.createHmac('sha256', secret).update(`${t}.`).update(corpoCru).digest();
// Comparação em tempo constante.
return assinaturas.some((hex) => {
const recebido = Buffer.from(hex, 'hex');
return recebido.length === esperado.length && crypto.timingSafeEqual(recebido, esperado);
});
}
```
server.js
```js
import express from 'express';
import { verificarAssinatura } from './toptalking-assinatura.js';
const app = express();
// express.raw entrega o corpo como Buffer, sem parse. Não use express.json() nesta rota.
app.post('/webhooks/toptalking', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verificarAssinatura(
req.body,
req.get('X-TopTalking-Signature'),
process.env.TOPTALKING_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).send('assinatura inválida');
const evento = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // responda rápido…
setImmediate(() => processarEvento(evento)); // …e processe depois (ideal: uma fila)
});
app.listen(3000);
```
Cuidado com o `express.json()` global Se o seu app usa `app.use(express.json())`, ele consome o corpo antes da sua rota. Registre a rota do webhook **antes** do `express.json()` ou exclua o caminho dela do parser. * Python (Flask) app.py
```python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TOPTALKING_WEBHOOK_SECRET"]
TOLERANCIA_SEGUNDOS = 300
def verificar_assinatura(corpo: bytes, cabecalho, secret: str) -> bool:
"""corpo = bytes EXATOS recebidos; cabecalho = X-TopTalking-Signature."""
if not cabecalho or not secret:
return False
t, assinaturas = None, []
for parte in cabecalho.split(","):
chave, _, valor = parte.strip().partition("=")
if chave == "t":
t = valor
elif chave == "v1" and valor:
assinaturas.append(valor)
if not t or not t.isdigit() or not assinaturas:
return False
if abs(time.time() - int(t)) > TOLERANCIA_SEGUNDOS:
return False
esperado = hmac.new(secret.encode(), f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperado.encode(), a.encode()) for a in assinaturas)
@app.post("/webhooks/toptalking")
def webhook_toptalking():
corpo = request.get_data() # bytes crus
if not verificar_assinatura(corpo, request.headers.get("X-TopTalking-Signature"), SECRET):
abort(401)
evento = request.get_json()
# Enfileire o processamento e responda rápido.
fila.enqueue(processar_evento, evento)
return "", 200
```
* Python (FastAPI) main.py
```python
import hashlib
import hmac
import json
import os
import time
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["TOPTALKING_WEBHOOK_SECRET"]
TOLERANCIA_SEGUNDOS = 300
def verificar_assinatura(corpo: bytes, cabecalho, secret: str) -> bool:
if not cabecalho or not secret:
return False
t, assinaturas = None, []
for parte in cabecalho.split(","):
chave, _, valor = parte.strip().partition("=")
if chave == "t":
t = valor
elif chave == "v1" and valor:
assinaturas.append(valor)
if not t or not t.isdigit() or not assinaturas:
return False
if abs(time.time() - int(t)) > TOLERANCIA_SEGUNDOS:
return False
esperado = hmac.new(secret.encode(), f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperado.encode(), a.encode()) for a in assinaturas)
@app.post("/webhooks/toptalking")
async def webhook_toptalking(request: Request, background: BackgroundTasks):
corpo = await request.body() # bytes crus, antes de qualquer parse
if not verificar_assinatura(corpo, request.headers.get("x-toptalking-signature"), SECRET):
raise HTTPException(status_code=401, detail="assinatura inválida")
evento = json.loads(corpo)
background.add_task(processar_evento, evento) # roda depois da resposta
return {"ok": True}
```
* Deno / Supabase Edge Function Ideal para apps do **Lovable** e projetos Supabase. Usa só Web Crypto (sem dependências). supabase/functions/toptalking-webhook/index.ts
```ts
// Recebe os webhooks da TopTalking e valida a assinatura.
// Secret necessário: TOPTALKING_WEBHOOK_SECRET (o whsec_... do webhook).
const SECRET = Deno.env.get('TOPTALKING_WEBHOOK_SECRET') ?? '';
const TOLERANCIA_SEGUNDOS = 300;
const encoder = new TextEncoder();
function hexParaBytes(hex: string): Uint8Array | null {
if (hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
async function verificarAssinatura(corpo: string, cabecalho: string | null, secret: string) {
if (!cabecalho || !secret) return false;
// Formato: t=,v1=
let t: string | null = null;
const assinaturas: string[] = [];
for (const parte of cabecalho.split(',')) {
const i = parte.indexOf('=');
if (i === -1) continue;
const chave = parte.slice(0, i).trim();
const valor = parte.slice(i + 1).trim();
if (chave === 't') t = valor;
else if (chave === 'v1' && valor) assinaturas.push(valor);
}
if (!t || !/^\d+$/.test(t) || assinaturas.length === 0) return false;
// Rejeita eventos com mais de 5 minutos de diferença (replay).
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCIA_SEGUNDOS) return false;
const chave = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const dados = encoder.encode(`${t}.${corpo}`);
for (const hex of assinaturas) {
const assinatura = hexParaBytes(hex);
// crypto.subtle.verify já compara em tempo constante.
if (assinatura && (await crypto.subtle.verify('HMAC', chave, assinatura, dados))) return true;
}
return false;
}
Deno.serve(async (req) => {
if (req.method !== 'POST') return new Response('Método não permitido', { status: 405 });
const corpo = await req.text(); // corpo CRU: não use req.json() antes de validar
const valido = await verificarAssinatura(corpo, req.headers.get('X-TopTalking-Signature'), SECRET);
if (!valido) return new Response('assinatura inválida', { status: 401 });
const evento = JSON.parse(corpo);
// Faça aqui o que precisar (ex.: salvar numa tabela). Mantenha rápido: < 10 s.
console.log('Evento recebido:', evento.type, evento.id);
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
});
```
A TopTalking não envia o token de login da Supabase, então **desligue a verificação de JWT** desta função: supabase/config.toml
```toml
[functions.toptalking-webhook]
verify_jwt = false
```
A URL para cadastrar no painel fica assim: `https://.supabase.co/functions/v1/toptalking-webhook`. O guia completo, com tabela e prompt pronto, está em [Lovable](/ia/lovable/#receber-webhooks-numa-edge-function). ## Teste a sua implementação Use estes valores para conferir se o seu código calcula a assinatura certa (desligue a checagem de tempo só neste teste, pois `t` é fixo): | Item | Valor | | ------------- | ------------------------------------------------------------------ | | secret | `whsec_d8d10826ecb93b4679de7409553d10982e864a172f6df97b` | | `t` | `1794409800` | | corpo cru | o JSON abaixo, exatamente assim (uma linha, sem espaços) | | `v1` esperado | `946dcacfdaee2f0eca7a2194d9919092cac50f554189d6651b1ce5acca155cdc` |
```json
{"id":"252a2c18-7b60-47a6-9feb-9c8322f2ba50","object":"event","type":"ping","created_at":"2026-11-11T15:10:00Z","organization_id":"e044a615-e879-44bb-8510-948b10b5e93c","data":{"webhook_id":"f03b0773-5912-4a45-9139-c29375485843","message":"Evento de teste enviado pela TopTalking."}}
```
Depois, clique em **Enviar teste** no painel e confira em **Ver entregas** se o seu endpoint respondeu `200`. ## Problemas comuns | Sintoma | Causa provável | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | A assinatura nunca bate | O corpo foi convertido em objeto e depois em texto de novo (`JSON.stringify(req.body)`). Use o corpo cru. | | Bate no teste, falha em produção | Um middleware (body parser, proxy, framework) alterou o corpo antes da sua rota. | | Falha só às vezes | Relógio do servidor fora de hora. Sincronize com NTP; a tolerância é de 300 s. | | Parou de bater depois de um tempo | Alguém clicou em **Gerar novo segredo**. Atualize a variável de ambiente. | | `401` vindo da Supabase, não do seu código | A função está com `verify_jwt` ligado. Desligue para esta função. |
# Entregas e novas tentativas
> Como a TopTalking entrega webhooks - prazo de 10 segundos, até 6 tentativas (1 min, 5 min, 30 min, 2 h, 6 h), deduplicação pelo id do evento, ordem não garantida, desativação automática após 3 dias de falhas, histórico e reenvio.
## Quando uma entrega é bem-sucedida A entrega conta como **bem-sucedida** quando o seu endpoint responde **qualquer status `2xx` em até 10 segundos**. O corpo da resposta é ignorado. Conta como **falha**: status fora de `2xx`, demora maior que 10 segundos ou erro de conexão (DNS, certificado, servidor fora do ar). ## Novas tentativas Se a entrega falhar, a TopTalking tenta de novo. São até **6 tentativas no total**: | Tentativa | Quando | | --------- | ----------------- | | 1ª | Imediatamente | | 2ª | 1 minuto depois | | 3ª | 5 minutos depois | | 4ª | 30 minutos depois | | 5ª | 2 horas depois | | 6ª | 6 horas depois | Depois da última falha, a entrega fica com status `failed`. Você pode reenviá-la pelo painel (veja abaixo). ## Deduplicação O **`id` do evento é o mesmo em todas as tentativas** (também no cabeçalho `X-TopTalking-Event-Id`). Como uma entrega pode chegar mais de uma vez — por exemplo, se o seu servidor processou mas demorou a responder —, guarde os IDs já processados e ignore repetições: Tabela simples de controle (PostgreSQL)
```sql
create table eventos_toptalking (
id uuid primary key, -- evento.id
tipo text not null,
recebido_em timestamptz not null default now()
);
-- No receptor: só processe se o insert funcionar.
insert into eventos_toptalking (id, tipo) values ($1, $2)
on conflict (id) do nothing
returning id;
```
## Ordem não garantida Eventos podem chegar **fora de ordem** (retentativas, paralelismo). Exemplos: * um `message.status_updated` com `read` pode chegar antes do de `delivered`; * dois `contact.updated` seguidos podem chegar invertidos. Para não voltar um estado no tempo, compare `created_at` do evento (ou `updated_at` do contato) com o que você já tem salvo antes de sobrescrever. Se precisar do estado mais atual com certeza, busque o recurso na API (ex.: [`GET /contacts/{contact_id}`](/referencia/contatos/#buscar-contato)). ## Desativação automática Se um endpoint **só falhar por 3 dias seguidos**, a TopTalking o **desativa automaticamente**: `enabled` vira `false` e `disabled_reason` explica o motivo. Enquanto estiver desativado, ele não recebe novos eventos. Para reativar, corrija o problema e: * no painel, reative o webhook em Configurações → Desenvolvedor → Webhooks; ou * pela API: [`PATCH /webhooks/{webhook_id}`](/referencia/webhooks/#atualizar-webhook) com `{ "enabled": true }`. Antes de reativar, use **Enviar teste** para confirmar que o endpoint voltou a responder `2xx`. ## Histórico e reenvio * **No painel:** menu do webhook → **Ver entregas**. Mostra cada tentativa com o status HTTP, o tempo de resposta e o erro. Entregas com falha têm o botão **Reenviar**. * **Pela API:** [`GET /webhooks/{webhook_id}/deliveries`](/referencia/webhooks/#hist%C3%B3rico-de-entregas). * Os eventos ficam guardados por **30 dias**. ## Boas práticas * **Responda primeiro, processe depois.** Valide a assinatura, coloque o evento numa fila ou tabela e responda `200`. Processamentos longos (chamar outra API, gerar PDF, rodar IA) devem rodar em segundo plano. * **Seja idempotente** com o `id` do evento. * **Não confie na ordem**; use `created_at`. * **Monitore** `last_delivery_status` e `disabled_reason` em [`GET /webhooks`](/referencia/webhooks/#listar-webhooks). * **Ignore tipos desconhecidos** com `2xx`: novos eventos podem ser adicionados no futuro.