Webhooks
Receba eventos do WhatsApp e do helpdesk no seu servidor, e prove que vieram de nós.
O Pingo tem dois sistemas de webhook independentes. Eles resolvem problemas diferentes, e você pode usar um, outro ou ambos.
- Webhooks de conexão entregam eventos do WhatsApp — uma mensagem chegou, saiu, foi editada, excluída ou mudou de status, ou um contato mudou de presença. É o que você quer para rodar a sua própria lógica em cima do WhatsApp.
- Webhooks do helpdesk entregam eventos da caixa compartilhada — uma conversa foi atribuída, uma etiqueta foi adicionada, um SLA estourou. É o que você quer para sincronizar o helpdesk com outro sistema.
Webhooks de conexão
Cadastre uma URL e escolha os eventos que te interessam:
curl -X POST https://api.pingonotify.com/v3/webhooks \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://exemplo.com/hooks/pingo",
"events": ["messages.upsert", "messages.update"],
"connections": ["0195f3a0-1234-7890-abcd-ef0123456789"],
"hmacEnabled": true
}'
Liste todas as conexões cujos eventos este webhook deve receber. Se connections for omitido, o webhook é criado sem vínculos com conexões e não recebe eventos até que elas sejam adicionadas com PATCH /v3/webhooks/{id}.
Os eventos
Estes são os sete valores que events aceita:
| Evento | Dispara quando |
|---|---|
messages.upsert | Chegou uma mensagem de um contato. |
send.message | Em uma conexão não oficial, uma mensagem saiu — enviada pela API ou pelo próprio celular. |
messages.update | Um status de entrega mudou — enviada, entregue, lida. |
messages.edited | Uma mensagem foi editada. |
messages.delete | Uma mensagem foi apagada para todos. |
presence.update | Um contato ficou online ou começou a digitar. |
connection.update | A conexão mudou de estado. |
connection.update é aceito na assinatura, mas hoje não entrega nada. O disparo HTTP desse evento está desativado no servidor — ele atualiza o estado da conexão internamente e alimenta o painel em tempo real, mas nenhum POST sai para a sua URL. Não construa nada que dependa dele; para saber se uma conexão caiu, faça polling em GET /v3/connections.
O envelope
Todo evento chega no mesmo envelope. O que muda é o data:
{
"event": "messages.upsert",
"connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
"remoteJid": "5511999998888@s.whatsapp.net",
"sender": "5511888887777@s.whatsapp.net",
"data": { }
}
| Campo | O que é |
|---|---|
event | O nome do evento — o mesmo que você assinou. |
connectionId | A conexão do Pingo que originou o evento. |
remoteJid | O outro lado da conversa: o contato, ou o grupo. |
sender | O número da sua própria conexão, como o provedor o reporta. |
data | O corpo do evento. |
Em messages.upsert e send.message o data é montado por nós e tem forma fechada — exatamente as chaves abaixo, nada mais. Nos demais eventos o data é repassado como veio do provedor, então campos extras podem aparecer com o tempo; trate os documentados como o contrato e ignore o resto.
Payloads por evento
Uma mensagem recebida. É o evento que você mais vai usar.
O data tem sempre estas oito chaves, e messageType diz qual nó esperar dentro de message:
{
"key": {
"remoteJid": "5511999998888@s.whatsapp.net",
"fromMe": false,
"id": "3EB0C767D097E9ECB4A5"
},
"pushName": "Ana",
"status": "DELIVERY_ACK",
"messageType": "conversation",
"message": { "conversation": "Oi! Meu pedido já saiu?" },
"contextInfo": null,
"source": "android",
"isAiMessage": false
}
| Campo | O que é |
|---|---|
key.id | O wamid — o id da mensagem no WhatsApp. Use-o para deduplicar. |
key.fromMe | false em mensagem recebida. |
pushName | O nome que o contato escolheu exibir. |
status | O ACK do provedor. Aqui é sempre DELIVERY_ACK. |
messageType | Qual nó vem dentro de message. |
contextInfo | Preenchido quando é uma resposta ou há menções; null no caso comum. |
source | De onde o contato mandou: android, ios, web, unknown. |
isAiMessage | true quando a IA nativa do WhatsApp Business respondeu sozinha — nesse caso vem também aiMessageSource: "WHATSAPP_BUSINESS". |
Os payloads de message, por tipo, estão logo abaixo em Tipos de mensagem.
Tipos de mensagem
Dentro de messages.upsert e send.message, o campo messageType diz qual nó vem em message. Cada aba abaixo mostra o message exatamente como ele chega.
messageType: "conversation" — o texto puro, sem nada em volta.
{
"conversation": "Oi! Meu pedido já saiu?"
}
Nem todo tipo de mensagem tem payload hoje. O message é filtrado por uma lista fixa de nós conhecidos, e o que não está nela é descartado — o evento chega, messageType diz o tipo certo, mas message vem {} vazio.
Isso afeta: respostas de botão (buttonsResponseMessage), escolhas de lista (listResponseMessage), localização (locationMessage), cartão de contato (contactMessage) e enquetes (pollCreationMessage).
Na prática: se você enviar botões com POST /chats/messages/send-button, o webhook não vai te dizer em qual botão o contato tocou. Enquanto isso não muda, trate esses tipos lendo o messageType e busque o conteúdo pelo histórico da conversa.
Baixando a mídia
Você nunca precisa falar com o WhatsApp para buscar um anexo. Toda mensagem de mídia traz um downloadMediaUrl pronto para uso — um link assinado, válido por 7 dias, que entrega os bytes brutos direto do Pingo:
curl -L "<downloadMediaUrl>" -o comprovante.jpg
A assinatura está dentro da URL, então isso não precisa de apikey — e é justamente o que permite ao consumidor do seu webhook baixar o arquivo diretamente, sem guardar uma credencial do Pingo.
O downloadMediaUrl só é anexado quando a mídia é referenciável. Em uma conexão oficial isso depende de a Meta ter devolvido um media_id; sem ele o campo simplesmente não aparece. Sempre teste a existência antes de usar.
Agrupamento
Defina messageGroupDelay (1 a 300 segundos) e o Pingo segura as mensagens de um contato por esse tempo e as entrega como um array, em vez de uma requisição por mensagem. Útil quando a pessoa manda cinco mensagens seguidas e você prefere raciocinar sobre todas de uma vez.
O envelope é o mesmo — o que muda é que data vira uma lista dos mesmos objetos que você receberia individualmente:
{
"event": "messages.upsert",
"connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
"remoteJid": "5511999998888@s.whatsapp.net",
"sender": "5511888887777@s.whatsapp.net",
"data": [
{
"key": { "remoteJid": "5511999998888@s.whatsapp.net", "fromMe": false, "id": "3EB0AAA" },
"pushName": "Ana",
"status": "DELIVERY_ACK",
"messageType": "conversation",
"message": { "conversation": "Oi!" },
"contextInfo": null,
"source": "android",
"isAiMessage": false
},
{
"key": { "remoteJid": "5511999998888@s.whatsapp.net", "fromMe": false, "id": "3EB0BBB" },
"pushName": "Ana",
"status": "DELIVERY_ACK",
"messageType": "conversation",
"message": { "conversation": "esqueci de dizer: pode ser depois das 18h" },
"contextInfo": null,
"source": "android",
"isAiMessage": false
}
]
}
Com messageGroupDelay ligado, data é um array — não um objeto. Um handler escrito para o caso simples quebra em silêncio ao ligar o agrupamento. Trate os dois com const mensagens = Array.isArray(body.data) ? body.data : [body.data].
Ligue também enableSimulateTyping e o Pingo mostra "digitando…" para o contato enquanto a janela de agrupamento corre — a espera passa a parecer intencional em vez de lentidão.
Verificando a assinatura
Defina hmacEnabled: true e toda entrega passa a ser assinada. Leia o segredo uma vez e guarde:
curl https://api.pingonotify.com/v3/webhooks/{id}/secret \
-H "apikey: sk_live_..."
Cada requisição passa a levar dois headers:
| Header | Valor |
|---|---|
X-Pingo-Signature-256 | sha256= seguido do HMAC-SHA256 em hexadecimal |
X-Pingo-Timestamp | Unix, em segundos |
A assinatura é calculada sobre o timestamp e o corpo bruto, unidos por um ponto — o timestamp entra no material assinado justamente para que uma entrega antiga e válida não possa ser reenviada contra você depois.
assinatura = "sha256=" + HMAC_SHA256(`${timestamp}.${corpoBruto}`, signingSecret).hex()
Verifique contra o corpo bruto da requisição, exatamente como ele chegou. Se o seu framework já fez o parse do JSON e você o re-serializa para conferir, a ordem das chaves ou os espaços podem diferir e a assinatura não vai bater.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verificar(corpoBruto, headers, segredo) {
const timestamp = headers['x-pingo-timestamp'];
const recebida = headers['x-pingo-signature-256'];
// Rejeita qualquer coisa com mais de cinco minutos.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const esperada = 'sha256=' + createHmac('sha256', segredo)
.update(`${timestamp}.${corpoBruto}`)
.digest('hex');
const a = Buffer.from(esperada);
const b = Buffer.from(recebida ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
Rotacione o segredo quando quiser com POST /v3/webhooks/{id}/secret/rotate. O segredo antigo para de validar imediatamente.
Retentativas
Responda com qualquer status abaixo de 400 e a entrega está concluída. Responda 4xx ou 5xx, ou estoure o tempo, e o Pingo retenta — 3 tentativas no total, com backoff exponencial a partir de 5 segundos. A requisição expira em 10 segundos.
Faça o seu handler ser idempotente: uma retentativa pode entregar uma mensagem que você já processou. Deduplique pelo id da mensagem (data.key.id).
Webhooks do helpdesk
Estes carregam eventos da caixa compartilhada, não do WhatsApp.
curl -X POST https://api.pingonotify.com/v3/helpdesk/webhooks \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://exemplo.com/hooks/helpdesk",
"subscriptions": ["helpdesk.conversation.created", "helpdesk.message.created"],
"signingSecret": "um-segredo-que-voce-escolhe"
}'
Restrinja um webhook a uma única caixa com inboxId, ou omita para receber eventos do workspace inteiro.
O Pingo gera um signingSecret se você omitir — mas nunca o devolve. Se você quer verificar assinaturas, forneça o seu próprio segredo na criação.
Os eventos do helpdesk
São 21, e todos são assináveis:
| Conversas | helpdesk.conversation.created · helpdesk.conversation.updated · helpdesk.conversation.status_changed · helpdesk.conversation.assignee_changed · helpdesk.conversation.priority_changed · helpdesk.conversation.labels_changed · helpdesk.conversation.deleted · helpdesk.conversation.read · helpdesk.conversation.ai_agent_changed |
| Mensagens | helpdesk.message.created · helpdesk.message.updated · helpdesk.message.deleted · helpdesk.message.status_changed · helpdesk.mention.created |
| Etiquetas | helpdesk.label.created · helpdesk.label.updated · helpdesk.label.deleted |
| Outros | helpdesk.csat.response_received · helpdesk.sla.missed · helpdesk.contact_sync.updated · helpdesk.conversation_sync.updated |
O envelope
Todo evento do helpdesk chega assim — e o data é o próprio objeto do evento, que sempre repete o type dentro de si:
{
"event": "helpdesk.message.created",
"data": { "type": "helpdesk.message.created", "accountId": "0195f3a0-...", "...": "..." },
"deliveredAt": "2026-07-14T12:34:56.000Z"
}
São 21 eventos. Cada aba abaixo mostra o data completo.
helpdesk.conversation.created — uma conversa nova entrou.
{
"type": "helpdesk.conversation.created",
"accountId": "0195f3a0-1c2d-7e3f-8a9b-1c2d3e4f5a6b",
"conversationId": "0195f3b1-2c3d-7e4f-8a9b-0c1d2e3f4a5b",
"inboxId": "0195f3c2-3d4e-7f5a-8b9c-1d2e3f4a5b6c",
"contactId": "0195f3d3-4e5f-7a6b-9c8d-2e3f4a5b6c7d",
"status": "OPEN",
"priority": null,
"assigneeUserId": null,
"teamId": null
}
helpdesk.conversation.status_changed — alguém resolveu, reabriu ou adiou.
{
"type": "helpdesk.conversation.status_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"fromStatus": "OPEN",
"toStatus": "RESOLVED",
"actorUserId": "0195f3e4-5f6a-7b8c-9d0e-3f4a5b6c7d8e"
}
Os status são OPEN, PENDING, SNOOZED e RESOLVED. Um silent: true aparece quando a mudança foi automática (o fim de um adiamento, por exemplo) e não gerou mensagem de atividade na linha do tempo.
helpdesk.conversation.assignee_changed — a conversa mudou de dono.
{
"type": "helpdesk.conversation.assignee_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"fromAssigneeUserId": null,
"toAssigneeUserId": "0195f3e4-...",
"fromTeamId": null,
"toTeamId": "0195f3f5-...",
"actorUserId": "0195f3e4-..."
}
Quando um bot de IA assume ou solta a conversa, vêm junto fromAgentBotId e toAgentBotId — o bot mora em uma coluna própria, separada do responsável humano.
helpdesk.conversation.priority_changed
{
"type": "helpdesk.conversation.priority_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"fromPriority": null,
"toPriority": "URGENT",
"actorUserId": "0195f3e4-..."
}
helpdesk.conversation.labels_changed — repare que ele entrega o delta, não a lista final.
{
"type": "helpdesk.conversation.labels_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"addedLabelIds": ["0195f406-..."],
"removedLabelIds": [],
"actorUserId": "0195f3e4-..."
}
helpdesk.conversation.updated — mudou algum campo que não tem evento próprio. changedFields diz quais.
{
"type": "helpdesk.conversation.updated",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"changedFields": ["customAttributes", "snoozedUntil"]
}
helpdesk.conversation.read · helpdesk.conversation.deleted
{
"type": "helpdesk.conversation.read",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"userId": "0195f3e4-...",
"assigneeLastSeenAt": "2026-07-14T12:34:56.000Z"
}
helpdesk.conversation.ai_agent_changed — a IA nativa do WhatsApp Business assumiu (ou soltou) o chat. Enquanto aiAgentEnabled for true, nenhum envio sai por aquela conversa.
{
"type": "helpdesk.conversation.ai_agent_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"aiAgentEnabled": true
}
Headers:
| Header | Valor |
|---|---|
X-Helpdesk-Event | O nome do evento |
X-Helpdesk-Signature | O HMAC-SHA256 do corpo, em hexadecimal |
Aqui a assinatura cobre apenas o corpo — não há timestamp no material assinado:
assinatura = HMAC_SHA256(corpoBruto, signingSecret).hex()
As retentativas seguem a mesma política dos webhooks de conexão: 3 tentativas, backoff exponencial a partir de 5 segundos, timeout de 10 segundos.
Antes de subir para produção, dispare uma entrega de teste no seu endpoint — ela roda na hora e reporta o que o seu servidor respondeu:
curl -X POST https://api.pingonotify.com/v3/helpdesk/webhooks/{id}/test \
-H "apikey: sk_live_..."
{ "status": 200, "durationMs": 143 }
Enviando mensagens para dentro do Pingo
Os webhooks acima são de saída. O canal de API é a direção contrária: uma caixa de entrada que não é um número de WhatsApp, na qual o seu próprio aplicativo injeta mensagens.
Crie uma caixa com channelType: "API". A resposta de criação devolve a credencial como inboundWebhookSecret; leituras posteriores da caixa de API devolvem a mesma credencial como channelConfig.hmacToken.
curl -X POST https://api.pingonotify.com/v3/helpdesk/inboxes \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Widget do site", "channelType": "API" }'
Depois injete a mensagem de um cliente:
curl -X POST https://api.pingonotify.com/webhooks/helpdesk/{inboxId} \
-H "x-helpdesk-token: <inboundWebhookSecret>" \
-H "Content-Type: application/json" \
-d '{
"sourceId": "seu-id-de-mensagem-123",
"content": "Meu pedido já foi enviado?",
"sender": { "name": "Ana", "email": "ana@exemplo.com" }
}'
Defina channelConfig.hmacMandatory: true na caixa para exigir x-helpdesk-token ou x-helpdesk-signature. Quando ele é false ou omitido, uma requisição sem nenhuma das credenciais é aceita; uma credencial presente, mas inválida, é sempre rejeitada.
O contato é resolvido ou criado automaticamente, uma conversa abre, e os seus agentes respondem na caixa compartilhada como em qualquer outra.
Para receber as respostas, defina channelConfig.webhookUrl na caixa. O Pingo vai fazer POST de cada mensagem de saída para lá, assinada com o mesmo segredo:
{
"event": "message.created",
"data": {
"sourceId": "0195f3d3-...",
"recipientIdentifier": "ana@exemplo.com",
"content": "Sim, saiu para entrega hoje de manhã.",
"attachments": []
},
"deliveredAt": "2026-07-14T12:35:10.000Z"
}
Responda com { "sourceId": "seu-proprio-id" } e o Pingo passa a guardar o seu id para aquela mensagem — o que permite reportar a entrega de volta depois:
curl -X POST https://api.pingonotify.com/webhooks/helpdesk/{inboxId} \
-H "x-helpdesk-token: <inboundWebhookSecret>" \
-H "Content-Type: application/json" \
-d '{ "event": "status_update", "sourceId": "seu-proprio-id", "status": "READ" }'