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 eight 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. |
flow.data_exchange | A WhatsApp Flow screen is asking your backend for data while the customer fills the form. |
`flow.data_exchange` is the one synchronous event
Every other event is a notification: we POST it, ignore whatever you answer, and retry on failure. This one is a question, and the body you return is the next screen the customer sees. Answer within about five seconds, with a JSON object — a bare 200 breaks the form in the customer's hand. It never goes through the retry queue, and the webhook must have HMAC enabled; the subscription is refused otherwise.
Only subscribe an endpoint that implements the Flows data-exchange protocol. And note what does not arrive here: the submitted form lands on messages.upsert, like any other reply. See Flow reply.
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.
These eight keys always come through, 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?" },
"messageTimestamp": 1773494400,
"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. |
messageTimestamp | When the contact sent it, in seconds since the Unix epoch. This is what you order by. |
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. |
On top of those eight there are three conditional keys — when they do not apply they are not null: they are simply not there.
| Conditional key | Shows up when |
|---|---|
contextInfo | the message is a reply or carries mentions |
aiMessageSource | isAiMessage is true |
album | the message is part of an album — see Photo albums |
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.
Two cases the list below does not settle on its own. Several photos sent at once are not a type of their own: they arrive as separate events, one per photo — see Photo albums. And with messageGroupDelay on, these same objects start arriving inside an array — see Batching.
messageType: "conversation" — plain text, nothing around it.
{
"conversation": "Hi! Has my order shipped?"
}
These payloads arrive filled in — location, contact cards, polls, button replies and list choices included. What still holds is the filter: message goes through a fixed list of known nodes, and anything outside it is dropped. Then the event still arrives, with the right messageType and an empty {} for message — which is what you see when WhatsApp ships a brand-new type.
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.
Photo albums
When the contact picks five photos and sends them in one go, WhatsApp shows an album — but the album is the app's own assembly. What reaches the webhook is five independent messages.upsert events, each with its own wamid. There is no single envelope holding the five.
Two practical consequences:
- Order is not guaranteed. Between the contact's phone and your URL there are three concurrent hops — the publish onto the queue, the consumer (no prefetch) and the webhook dispatch — and any of them can swap two events around. If order matters, sort them yourself.
- Sort on
messageTimestamp. Every event carries the provider's stamp insidedata. It has second granularity, so photos from the same album usually tie: it is good for ordering, not for grouping.
Some clients also announce the album before the photos, as a separate content-less event with messageType: "albumMessage". It carries no media — only the count of what is coming:
{
"albumMessage": { "expectedImageCount": 5, "expectedVideoCount": 0 }
}
Treat it as a heads-up, never as a requirement: it does not always arrive, and the photos are valid on their own.
When the provider sends the album association, data also carries an album object (trimmed here; the rest of data is what it always is):
{
"key": { "remoteJid": "5511999998888@s.whatsapp.net", "fromMe": false, "id": "3EB0BBB" },
"pushName": "Ana",
"messageTimestamp": 1773494401,
"messageType": "imageMessage",
"album": { "id": "3EB0AAA", "index": 1 },
"message": {
"imageMessage": {
"mimetype": "image/jpeg",
"downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
}
}
}
album.id is the wamid of the album's parent message — that is the key you group on — and album.index is the photo's position inside it.
That album is the flattened form of a node the provider sends inside message, at messageContextInfo.messageAssociation. It rides along, but prefer album: same data without the nesting, and it is the field we document.
album only shows up when the provider sends the association, and it does not always send it. Test for it before using it (if (data.album)) and keep a path for the ordinary case, where the photos arrive loose and all you have is messageTimestamp and the gap between them.
None of this is messageGroupDelay: that one is a sliding delivery window, per contact, that fires a few seconds after the last message and can mix photos and text in the same array. See Batching.
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!" },
"messageTimestamp": 1773494400,
"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" },
"messageTimestamp": 1773494412,
"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" }'