Webhooks
Receive WhatsApp and helpdesk events on your own server, and prove they came from us.
Pingo has two independent webhook systems. They solve different problems, and you can use either or both.
- Connection webhooks deliver WhatsApp events — a message arrived, went out, was edited, deleted or changed delivery status, or a contact changed presence. This is what you want to drive your own logic on top of WhatsApp.
- Helpdesk webhooks deliver events from the shared inbox — a conversation was assigned, a label was added, an SLA was missed. This is what you want to sync the helpdesk into another system.
Connection webhooks
Register a URL and pick the events you care about:
curl -X POST https://api.pingonotify.com/v3/webhooks \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/pingo",
"events": ["messages.upsert", "messages.update"],
"connections": ["0195f3a0-1234-7890-abcd-ef0123456789"],
"hmacEnabled": true
}'
List every connection whose events this webhook should receive. If connections is omitted, the webhook is created without connection bindings and receives no events until connections are added with PATCH /v3/webhooks/{id}.
The events
These are the seven values events accepts:
| Event | Fires when |
|---|---|
messages.upsert | A message arrived from a contact. |
send.message | On an unofficial connection, a message went out — through the API, or typed on the phone. |
messages.update | A delivery status changed — sent, delivered, read. |
messages.edited | A message was edited. |
messages.delete | A message was deleted for everyone. |
presence.update | A contact came online or started typing. |
connection.update | The connection changed state. |
connection.update is accepted as a subscription, but delivers nothing today. The HTTP dispatch for that event is switched off on the server — it updates the connection state internally and feeds the live dashboard, but no POST ever reaches your URL. Do not build anything that depends on it; to learn that a connection dropped, poll GET /v3/connections.
The envelope
Every event arrives in the same envelope. What changes is data:
{
"event": "messages.upsert",
"connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
"remoteJid": "5511999998888@s.whatsapp.net",
"sender": "5511888887777@s.whatsapp.net",
"data": { }
}
| Field | What it is |
|---|---|
event | The event name — the same one you subscribed to. |
connectionId | The Pingo connection the event came from. |
remoteJid | The other side of the conversation: the contact, or the group. |
sender | Your own connection's number, as the provider reports it. |
data | The event body. |
On messages.upsert and send.message the data is assembled by us and has a closed shape — exactly the keys below, nothing more. On every other event data is passed through as the provider sent it, so extra fields may show up over time; treat the documented ones as the contract and ignore the rest.
Payloads by event
An incoming message. This is the event you will use most.
data always carries these eight keys, and messageType tells you which node to expect inside message:
{
"key": {
"remoteJid": "5511999998888@s.whatsapp.net",
"fromMe": false,
"id": "3EB0C767D097E9ECB4A5"
},
"pushName": "Ana",
"status": "DELIVERY_ACK",
"messageType": "conversation",
"message": { "conversation": "Hi! Has my order shipped?" },
"contextInfo": null,
"source": "android",
"isAiMessage": false
}
| Field | What it is |
|---|---|
key.id | The wamid — WhatsApp's own message id. Deduplicate on this. |
key.fromMe | false on an incoming message. |
pushName | The display name the contact chose. |
status | The provider ACK. Here it is always DELIVERY_ACK. |
messageType | Which node arrives inside message. |
contextInfo | Populated on a reply or when there are mentions; null in the ordinary case. |
source | Where the contact sent from: android, ios, web, unknown. |
isAiMessage | true when WhatsApp Business's native AI answered on its own — in that case aiMessageSource: "WHATSAPP_BUSINESS" rides along. |
The message payloads, per type, are right below in Message types.
Message types
Inside messages.upsert and send.message, messageType tells you which node arrives in message. Each tab below shows message exactly as it lands.
messageType: "conversation" — plain text, nothing around it.
{
"conversation": "Hi! Has my order shipped?"
}
Not every message type has a payload today. message is filtered through a fixed list of known nodes, and anything outside it is dropped — the event still arrives, messageType still names the right type, but message comes through as an empty {}.
This affects: button replies (buttonsResponseMessage), list choices (listResponseMessage), location (locationMessage), contact cards (contactMessage) and polls (pollCreationMessage).
In practice: if you send buttons with POST /chats/messages/send-button, the webhook will not tell you which button the contact tapped. Until that changes, handle these types by reading messageType and fetch the content from the conversation history.
Getting the media
You never have to talk to WhatsApp to fetch an attachment. Any media message carries a ready-to-use downloadMediaUrl — a signed link, valid for 7 days, that streams the raw bytes from Pingo:
curl -L "<downloadMediaUrl>" -o receipt.jpg
The signature is inside the URL, so this needs no apikey — which is what lets your webhook consumer fetch it directly, without holding a Pingo credential.
downloadMediaUrl is only attached when the media is referenceable. On an official connection that depends on Meta having returned a media_id; without one the field simply is not there. Always test for it before using it.
Batching
Set messageGroupDelay (1–300 seconds) and Pingo will hold a contact's messages for that long and deliver them as one array instead of one request each. Useful when people send five messages in a row and you would rather reason about all of them at once.
The envelope is the same — what changes is that data becomes a list of the very objects you would otherwise have received one by one:
{
"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": "Hi!" },
"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": "forgot to say: after 6pm works better" },
"contextInfo": null,
"source": "android",
"isAiMessage": false
}
]
}
With messageGroupDelay on, data is an array — not an object. A handler written for the simple case breaks silently the moment batching is enabled. Handle both with const messages = Array.isArray(body.data) ? body.data : [body.data].
Turn on enableSimulateTyping too and Pingo shows "typing…" to the contact while the batching window runs — the wait starts reading as deliberate rather than slow.
Verifying the signature
Set hmacEnabled: true and every delivery is signed. Read the secret back once, and store it:
curl https://api.pingonotify.com/v3/webhooks/{id}/secret \
-H "apikey: sk_live_..."
Each request then carries two headers:
| Header | Value |
|---|---|
X-Pingo-Signature-256 | sha256= followed by the hex HMAC-SHA256 |
X-Pingo-Timestamp | Unix seconds |
The signature is computed over the timestamp and the raw body, joined by a dot — the timestamp is inside the signed material precisely so an old, valid delivery cannot be replayed at you later.
signature = "sha256=" + HMAC_SHA256(`${timestamp}.${rawBody}`, signingSecret).hex()
Verify against the raw request body, exactly as it arrived. If your framework parses the JSON and you re-serialize it to check, key order or whitespace can differ and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, headers, secret) {
const timestamp = headers['x-pingo-timestamp'];
const received = headers['x-pingo-signature-256'];
// Reject anything older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
Rotate the secret at any time with POST /v3/webhooks/{id}/secret/rotate. The old secret stops verifying immediately.
Retries
Answer with any status below 400 and the delivery is done. Answer 4xx or 5xx, or time out, and Pingo retries — 3 attempts total, backing off exponentially from 5 seconds. The request times out after 10 seconds.
Make your handler idempotent: a retry can deliver a message you already processed. Deduplicate on the message id (data.key.id).
Helpdesk webhooks
These carry events from the shared inbox rather than from WhatsApp.
curl -X POST https://api.pingonotify.com/v3/helpdesk/webhooks \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/helpdesk",
"subscriptions": ["helpdesk.conversation.created", "helpdesk.message.created"],
"signingSecret": "a-secret-you-choose"
}'
Scope a webhook to a single inbox with inboxId, or omit it to receive events from the whole workspace.
Pingo generates a signingSecret if you omit one — but never returns it. If you want to verify signatures, supply your own secret at create time.
The helpdesk events
There are 21, and all of them are signable:
| Conversations | 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 |
| Messages | helpdesk.message.created · helpdesk.message.updated · helpdesk.message.deleted · helpdesk.message.status_changed · helpdesk.mention.created |
| Labels | helpdesk.label.created · helpdesk.label.updated · helpdesk.label.deleted |
| Other | helpdesk.csat.response_received · helpdesk.sla.missed · helpdesk.contact_sync.updated · helpdesk.conversation_sync.updated |
The envelope
Every helpdesk event arrives like this — and data is the event object itself, which always repeats its own type inside:
{
"event": "helpdesk.message.created",
"data": { "type": "helpdesk.message.created", "accountId": "0195f3a0-...", "...": "..." },
"deliveredAt": "2026-07-14T12:34:56.000Z"
}
Each tab below shows the full data for every event in that group.
helpdesk.conversation.created — a new conversation landed.
{
"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 — someone resolved, reopened or snoozed it.
{
"type": "helpdesk.conversation.status_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"fromStatus": "OPEN",
"toStatus": "RESOLVED",
"actorUserId": "0195f3e4-5f6a-7b8c-9d0e-3f4a5b6c7d8e"
}
The statuses are OPEN, PENDING, SNOOZED and RESOLVED. A silent: true shows up when the change was automatic (a snooze expiring, say) and left no activity message in the timeline.
helpdesk.conversation.assignee_changed — the conversation changed hands.
{
"type": "helpdesk.conversation.assignee_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"fromAssigneeUserId": null,
"toAssigneeUserId": "0195f3e4-...",
"fromTeamId": null,
"toTeamId": "0195f3f5-...",
"actorUserId": "0195f3e4-..."
}
When an AI bot takes the conversation or hands it back, fromAgentBotId and toAgentBotId ride along — the bot lives in its own column, separate from the human assignee.
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 — note that it delivers the delta, not the final list.
{
"type": "helpdesk.conversation.labels_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"addedLabelIds": ["0195f406-..."],
"removedLabelIds": [],
"actorUserId": "0195f3e4-..."
}
helpdesk.conversation.updated — a field changed that has no event of its own. changedFields names them.
{
"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 — WhatsApp Business's native AI took the chat (or gave it back). While aiAgentEnabled is true, nothing you send goes out on that conversation.
{
"type": "helpdesk.conversation.ai_agent_changed",
"accountId": "0195f3a0-...",
"conversationId": "0195f3b1-...",
"inboxId": "0195f3c2-...",
"aiAgentEnabled": true
}
Headers:
| Header | Value |
|---|---|
X-Helpdesk-Event | The event name |
X-Helpdesk-Signature | The hex HMAC-SHA256 of the body |
Here the signature covers the body alone — there is no timestamp in the signed material:
signature = HMAC_SHA256(rawBody, signingSecret).hex()
Retries follow the same policy as connection webhooks: 3 attempts, exponential backoff from 5 seconds, 10-second timeout.
Before going live, fire a test delivery at your endpoint — it runs immediately and reports back what your server answered:
curl -X POST https://api.pingonotify.com/v3/helpdesk/webhooks/{id}/test \
-H "apikey: sk_live_..."
{ "status": 200, "durationMs": 143 }
Sending messages into Pingo
The webhooks above are outbound. The API channel is the inbound direction: an inbox that is not a WhatsApp number at all, that your own application pushes messages into.
Create an inbox with channelType: "API". The create response returns the credential as inboundWebhookSecret; later reads of the API inbox return that same credential as channelConfig.hmacToken.
curl -X POST https://api.pingonotify.com/v3/helpdesk/inboxes \
-H "apikey: sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "name": "Web widget", "channelType": "API" }'
Then push a customer message in:
curl -X POST https://api.pingonotify.com/webhooks/helpdesk/{inboxId} \
-H "x-helpdesk-token: <inboundWebhookSecret>" \
-H "Content-Type: application/json" \
-d '{
"sourceId": "your-message-id-123",
"content": "Is my order shipped?",
"sender": { "name": "Ana", "email": "ana@example.com" }
}'
Set channelConfig.hmacMandatory: true on the inbox to require either x-helpdesk-token or x-helpdesk-signature. When it is false or omitted, a request with neither credential is accepted; a credential that is present but invalid is always rejected.
The contact is resolved or created automatically, a conversation opens, and your agents answer it in the shared inbox like any other.
To receive their replies, set channelConfig.webhookUrl on the inbox. Pingo will POST each outgoing message there, signed with the same secret:
{
"event": "message.created",
"data": {
"sourceId": "0195f3d3-...",
"recipientIdentifier": "ana@example.com",
"content": "Yes, it shipped this morning.",
"attachments": []
},
"deliveredAt": "2026-07-14T12:35:10.000Z"
}
Answer with { "sourceId": "your-own-id" } and Pingo will remember your id for that message — which is what lets you report delivery back later:
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": "your-own-id", "status": "READ" }'