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.