Skip to content

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
  }'
bash

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:

EventFires when
messages.upsertA message arrived from a contact.
send.messageOn an unofficial connection, a message went out — through the API, or typed on the phone.
messages.updateA delivery status changed — sent, delivered, read.
messages.editedA message was edited.
messages.deleteA message was deleted for everyone.
presence.updateA contact came online or started typing.
connection.updateThe connection changed state.
flow.data_exchangeA 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": { }
}
json
FieldWhat it is
eventThe event name — the same one you subscribed to.
connectionIdThe Pingo connection the event came from.
remoteJidThe other side of the conversation: the contact, or the group.
senderYour own connection's number, as the provider reports it.
dataThe 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
}
json
FieldWhat it is
key.idThe wamid — WhatsApp's own message id. Deduplicate on this.
key.fromMefalse on an incoming message.
pushNameThe display name the contact chose.
statusThe provider ACK. Here it is always DELIVERY_ACK.
messageTypeWhich node arrives inside message.
messageTimestampWhen the contact sent it, in seconds since the Unix epoch. This is what you order by.
sourceWhere the contact sent from: android, ios, web, unknown.
isAiMessagetrue 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 keyShows up when
contextInfothe message is a reply or carries mentions
aiMessageSourceisAiMessage is true
albumthe message is part of an album — see Photo albums

The message payloads, per type, are right below in Message types.

A message sent from an unofficial connection — via the API, or typed on the phone. Official connections do not produce this event. data has exactly the same shape as messages.upsert, with two differences that matter:

  • key.fromMe is true.
  • status is the send ACK (PENDING, SERVER_ACK…), not DELIVERY_ACK.
{
  "event": "send.message",
  "connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
  "remoteJid": "5511999998888@s.whatsapp.net",
  "sender": "5511888887777@s.whatsapp.net",
  "data": {
    "key": {
      "remoteJid": "5511999998888@s.whatsapp.net",
      "fromMe": true,
      "id": "3EB0F1A2B3C4D5E6F708"
    },
    "pushName": "Example Store",
    "status": "PENDING",
    "messageType": "conversation",
    "message": { "conversation": "It went out for delivery this morning!" },
    "messageTimestamp": 1773494400,
    "contextInfo": null,
    "source": "unknown",
    "isAiMessage": false
  }
}
json

Subscribe to send.message if you need to record what your own team sent. If you only want to react to customers, subscribe to messages.upsert alone — subscribing to both and forgetting to check fromMe is the classic way to build an infinite reply loop.

A message's delivery status changed. This is how you learn that your message landed, or was read.

{
  "event": "messages.update",
  "connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
  "remoteJid": "5511999998888@s.whatsapp.net",
  "sender": "5511888887777@s.whatsapp.net",
  "data": {
    "keyId": "3EB0F1A2B3C4D5E6F708",
    "remoteJid": "5511999998888@s.whatsapp.net",
    "fromMe": true,
    "status": "READ"
  }
}
json

keyId is the wamid of the affected message — the key that ties this event back to the message you already hold. Statuses climb the ladder PENDING → SENT → DELIVERED → READ, arriving as separate events as the contact receives and opens the chat.

The contact edited a message they had already sent.

{
  "event": "messages.edited",
  "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": "3EB0C767D097E9ECB4A5"
    }
  }
}
json

key.id identifies the original message. This is one of the events passed through raw from the provider, so the edited content may ride along — re-read the message from history if you need the final text with certainty.

A message was deleted for everyone.

{
  "event": "messages.delete",
  "connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
  "remoteJid": "5511999998888@s.whatsapp.net",
  "sender": "5511888887777@s.whatsapp.net",
  "data": {
    "id": "3EB0C767D097E9ECB4A5",
    "remoteJid": "5511999998888@s.whatsapp.net",
    "fromMe": false
  }
}
json

Here the wamid arrives in data.id — not data.keyId as in messages.update, and not data.key.id as in messages.edited. The three events name the same field three different ways; it is inherited from the provider.

The contact came online or started typing.

{
  "event": "presence.update",
  "connectionId": "0195f3a0-1234-7890-abcd-ef0123456789",
  "remoteJid": "5511999998888@s.whatsapp.net",
  "sender": "5511888887777@s.whatsapp.net",
  "data": {
    "id": "5511999998888@s.whatsapp.net",
    "presences": {
      "5511999998888@s.whatsapp.net": { "lastKnownPresence": "composing" }
    }
  }
}
json

lastKnownPresence is composing (typing) or available (online). The presences map is keyed by JID because in a group it carries several participants at once.

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?"
}
json

messageType: "extendedTextMessage" — when the text carries a link, WhatsApp attaches the page preview and the node changes from conversation to extendedTextMessage.

{
  "extendedTextMessage": {
    "text": "Found this one: https://example.com/product/42",
    "matchedText": "https://example.com/product/42",
    "title": "Ergonomic Chair — Example",
    "description": "Adjustable lumbar support, free shipping."
  }
}
json

If you just want the message text, read message.conversation ?? message.extendedTextMessage?.text. A text message can arrive in either node — it depends on whether it has a link, a quote, or a mention.

When the contact replies to a message, the text arrives in extendedTextMessage and contextInfo — at the root of data, not inside the node — points at the quoted message.

{
  "messageType": "extendedTextMessage",
  "message": {
    "extendedTextMessage": { "text": "Yes, please send it!" }
  },
  "contextInfo": {
    "stanzaId": "3EB0F1A2B3C4D5E6F708",
    "participant": "5511888887777@s.whatsapp.net",
    "quotedMessage": {
      "conversation": "Can I ship today to the old address?"
    }
  }
}
json

stanzaId is the wamid of the quoted message — that is how you tie the answer back to the question.

messageType: "imageMessage". The caption, when there is one, arrives in caption.

{
  "imageMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7118-24/...",
    "mimetype": "image/jpeg",
    "caption": "Here is the receipt",
    "fileLength": 187432,
    "height": 1280,
    "width": 960,
    "directPath": "/v/t62.7118-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

Use downloadMediaUrl — not url. url is WhatsApp's encrypted blob and you could not decrypt it on your own.

Sent five photos at once? That is five of these events, one per photo — see Photo albums.

messageType: "videoMessage". seconds is the duration.

{
  "videoMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7161-24/...",
    "mimetype": "video/mp4",
    "caption": "Listen to the noise it is making",
    "fileLength": 2847193,
    "seconds": 14,
    "height": 848,
    "width": 480,
    "directPath": "/v/t62.7161-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

messageType: "audioMessage" with ptt: true. The one recorded by holding the microphone.

{
  "audioMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7117-24/...",
    "mimetype": "audio/ogg; codecs=opus",
    "fileLength": 8432,
    "seconds": 7,
    "ptt": true,
    "directPath": "/v/t62.7117-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

ptt is what separates a voice note from a music file. Both arrive as audioMessage; only the flag tells them apart. If you transcribe audio, this is where you decide what is worth sending to the model.

messageType: "audioMessage" with ptt: false — the contact attached an audio file instead of recording one.

{
  "audioMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7114-24/...",
    "mimetype": "audio/mpeg",
    "fileLength": 3214567,
    "seconds": 201,
    "ptt": false,
    "directPath": "/v/t62.7114-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

messageType: "documentMessage". fileName is the original file name, and pageCount shows up on PDFs.

{
  "documentMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7119-24/...",
    "mimetype": "application/pdf",
    "fileName": "invoice-4821.pdf",
    "caption": "My invoice",
    "fileLength": 94213,
    "pageCount": 2,
    "directPath": "/v/t62.7119-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

messageType: "stickerMessage". Always WebP.

{
  "stickerMessage": {
    "url": "https://mmg.whatsapp.net/v/t62.7161-24/...",
    "mimetype": "image/webp",
    "fileLength": 12874,
    "height": 512,
    "width": 512,
    "isAnimated": false,
    "isAvatar": false,
    "isAiSticker": false,
    "isLottie": false,
    "stickerSentTs": 1773494400123,
    "directPath": "/v/t62.7161-24/...",
    "mediaKeyTimestamp": 1773494400,
    "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
  }
}
json

messageType: "reactionMessage" — the contact reacted to an existing message with an emoji.

{
  "reactionMessage": {
    "text": "👍",
    "key": { "id": "3EB0F1A2B3C4D5E6F708" }
  }
}
json

key.id is the wamid of the message that received the reaction, not of the reaction itself. A removed reaction arrives with an empty text.

messageType: "locationMessage" — a point on the map.

{
  "locationMessage": {
    "degreesLatitude": -23.5613,
    "degreesLongitude": -46.6565,
    "name": "Av. Paulista, 1578",
    "address": "Av. Paulista, 1578 — Bela Vista, São Paulo - SP",
    "url": "https://foursquare.com/v/4b0587a2f964a520f68222e3",
    "isLive": false,
    "accuracyInMeters": 12,
    "comment": "I'm right outside"
  }
}
json

Only the two coordinates are guaranteed: name, address, url, accuracyInMeters and comment show up only when the contact sends them. A live location arrives in the sibling node liveLocationMessage, with the same coordinates plus speedInMps and sequenceNumber (which climbs on every update).

The map thumbnail (jpegThumbnail) is stripped from the payload on purpose: it is tens of KB of binary on every single event, and it says nothing the latitude and longitude do not already say.

messageType: "contactMessage" — the contact shared a card from their address book.

{
  "contactMessage": {
    "displayName": "Bruno — Repairs",
    "vcard": "BEGIN:VCARD\nVERSION:3.0\nN:;Bruno;;;\nFN:Bruno — Repairs\nTEL;type=CELL;waid=5511977776666:+55 11 97777-6666\nEND:VCARD"
  }
}
json

vcard is the raw vCard 3.0 string, with the line breaks escaped as \n — we do not parse it. The number you want sits in the waid parameter of the TEL field: that one, not the formatted text, is what becomes a JID. When the contact sends several cards at once the node is contactsArrayMessage, with a contacts array of these same objects.

messageType: "pollCreationMessage" — the poll and its options, as the contact created it.

{
  "pollCreationMessage": {
    "name": "Which delivery window works best?",
    "selectableOptionsCount": 1,
    "options": [
      { "optionName": "Morning", "optionHash": "MzA5N0YyQ0E4..." },
      { "optionName": "Afternoon", "optionHash": "QjE0RDI3RUYx..." }
    ]
  }
}
json

selectableOptionsCount is how many options can be ticked (1 = single choice). The poll's encryption key (encKey) is not exposed.

Depending on the contact's WhatsApp version the same content arrives as pollCreationMessageV2 or pollCreationMessageV3 — messageType follows along. The body is identical in all three, so handle them together: message.pollCreationMessage ?? message.pollCreationMessageV2 ?? message.pollCreationMessageV3.

The vote never arrives — not even as an event. On an unofficial connection the provider drops it before it can become a webhook: no messages.upsert is emitted when someone answers the poll. You get to know that the poll was created, and nothing else. (The vote travels end-to-end encrypted, so even if it did arrive there would be no reading which option was picked.) If you need the answer, ask with buttons or with a list — those come back.

messageType: "buttonsResponseMessage" — the contact tapped one of the buttons you sent.

{
  "buttonsResponseMessage": {
    "selectedButtonId": "confirm-delivery",
    "selectedDisplayText": "Confirm delivery",
    "type": "DISPLAY_TEXT"
  }
}
json

selectedButtonId is the id you sent in POST /chats/messages/send-button — route on it, not on the displayed text. selectedDisplayText is the label the contact saw, and type is the provider's enum, which can arrive as a name (DISPLAY_TEXT) or as a number (1); do not build logic on it.

On an official connection the same tap arrives under another name. A template button comes as templateButtonReplyMessage, with selectedId instead of selectedButtonId (plus selectedDisplayText and selectedIndex). An interactive-message button comes as interactiveResponseMessage, with the label in body.text and the data in nativeFlowResponseMessage.paramsJson — JSON that WhatsApp serialises and hands over as a string. Read all three nodes, or replies from the official channel vanish silently.

messageType: "listResponseMessage" — the contact picked a row from the list.

{
  "listResponseMessage": {
    "title": "Afternoon",
    "description": "Between 12pm and 6pm",
    "listType": "SINGLE_SELECT",
    "singleSelectReply": { "selectedRowId": "window-afternoon" }
  }
}
json

What identifies the choice is singleSelectReply.selectedRowId — the rowId you set when you built the list. title and description are the row's text, as the contact saw it. listType, like the button's type, is the provider's enum and can arrive as a name or as a number.

On an official connection a list choice arrives as interactiveResponseMessage, not here — see the warning in the Button reply tab.

messageType: "templateButtonReplyMessage" — the contact tapped a button on a template, on an official connection. This is how a carousel card's buttons come back.

{
  "templateButtonReplyMessage": {
    "selectedId": "add:SKU-FONE-ANC",
    "selectedDisplayText": "Add"
  }
}
json

selectedId is the payload you sent for that button in send-template. On a carousel it is the only way to tell which card was tapped: the webhook carries no card index, so the payload has to name both the product and the action. That is why a carousel send sets one payload per card and per button:

{ "type": "button", "sub_type": "quick_reply", "index": 0,
  "parameters": [{ "type": "payload", "payload": "add:SKU-FONE-ANC" }] }
json

A URL button produces no webhook at all — the contact simply opens the link.

messageType: "interactiveResponseMessage" — the contact submitted a Flow form. It arrives whether the Flow is static (navigate) or dynamic (data_exchange); the data-exchange endpoint only handles the conversation during filling.

{
  "interactiveResponseMessage": {
    "body": { "text": "Sent" },
    "nativeFlowResponseMessage": {
      "name": "flow",
      "paramsJson": "{\"nome\":\"Ana\",\"email\":\"ana@exemplo.com\",\"flow_token\":\"lead-8812\"}"
    }
  }
}
json

paramsJson is a string, not an object. WhatsApp serialises it and we pass it through byte for byte, so you have to JSON.parse it before reading a field. Inside it are the keys from your complete action's payload, plus flow_token.

flow_token is the only thread back to the send — the webhook does not carry the Flow id. Send your own (an order id, a ticket id, a lead id) in send-flow; if you omit it we generate pingo_<uuid> and return it in the send response.

body.text is the label WhatsApp shows in the chat bubble ("Sent"), not content of yours. And the same node carries button and list taps from interactive messages on the official channel — check nativeFlowResponseMessage.name to tell them apart.

Some sends do not change the message's type — they wrap it. messageType is the wrapper's, and the real content sits one level down, inside message.

{
  "viewOnceMessageV2": {
    "message": {
      "imageMessage": {
        "mimetype": "image/jpeg",
        "downloadMediaUrl": "https://api.pingonotify.com/v3/connections/media/eyJjIjoiMDE5NWYzYTAt.../download?token=1789012345.9f3a1c7e"
      }
    }
  }
}
json

The wrappers are viewOnceMessage, viewOnceMessageV2 and viewOnceMessageV2Extension (view once), ephemeralMessage (disappearing message), editedMessage (an edit) and documentWithCaptionMessage — that last one is how WhatsApp Web sends every document that has a caption, so it is the most common of the five.

Unwrap before you look at the type. Without it an imageMessage handler simply never fires for a view-once photo, with no error at all:

const inner =
  m.viewOnceMessageV2?.message ??
  m.viewOnceMessageV2Extension?.message ??
  m.viewOnceMessage?.message ??
  m.ephemeralMessage?.message ??
  m.editedMessage?.message ??
  m.documentWithCaptionMessage?.message ??
  m
js

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
bash

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 inside data. 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 }
}
json

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"
    }
  }
}
json

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
    }
  ]
}
json

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_..."
bash

Each request then carries two headers:

HeaderValue
X-Pingo-Signature-256sha256= followed by the hex HMAC-SHA256
X-Pingo-TimestampUnix 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()
text

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);
}
js

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"
  }'
bash

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:

Conversationshelpdesk.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
Messageshelpdesk.message.created · helpdesk.message.updated · helpdesk.message.deleted · helpdesk.message.status_changed · helpdesk.mention.created
Labelshelpdesk.label.created · helpdesk.label.updated · helpdesk.label.deleted
Otherhelpdesk.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"
}
json

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
}
json

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"
}
json

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-..."
}
json

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-..."
}
json

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-..."
}
json

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"]
}
json

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"
}
json

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
}
json

helpdesk.message.created — the main one. The message arrives hydrated in message, in the same shape the REST API returns, so you can render it without a second request.

{
  "type": "helpdesk.message.created",
  "accountId": "0195f3a0-...",
  "messageId": "0195f417-...",
  "conversationId": "0195f3b1-...",
  "inboxId": "0195f3c2-...",
  "channelType": "WHATSAPP",
  "messageType": "INCOMING",
  "status": "DELIVERED",
  "isPrivate": false,
  "senderUserId": null,
  "senderContactId": "0195f3d3-...",
  "message": {
    "id": "0195f417-...",
    "conversationId": "0195f3b1-...",
    "content": "Hi! Has my order shipped?",
    "contentType": "TEXT",
    "messageType": "INCOMING",
    "status": "DELIVERED",
    "private": false,
    "sender": { "id": "0195f3d3-...", "name": "Ana" },
    "attachments": [],
    "createdAt": "2026-07-14T12:34:56.000Z"
  }
}
json

messageType is INCOMING, OUTGOING, ACTIVITY or TEMPLATE. A private note is not a type of its own: it is an OUTGOING with isPrivate: true.

helpdesk.message.status_changed — the delivery lifecycle. This is how you learn that the message you sent actually landed.

{
  "type": "helpdesk.message.status_changed",
  "accountId": "0195f3a0-...",
  "messageId": "0195f417-...",
  "conversationId": "0195f3b1-...",
  "inboxId": "0195f3c2-...",
  "fromStatus": "PENDING",
  "toStatus": "FAILED",
  "externalError": "131030: Recipient not in allowed list"
}
json

Statuses go PENDING → SENT → DELIVERED → READ, with FAILED as the off-ramp. externalError only appears when toStatus is FAILED — it is the provider's raw reason, ideal for a tooltip without waiting for a reload.

helpdesk.message.updated · helpdesk.message.deleted

{
  "type": "helpdesk.message.deleted",
  "accountId": "0195f3a0-...",
  "messageId": "0195f417-...",
  "conversationId": "0195f3b1-...",
  "inboxId": "0195f3c2-...",
  "actorUserId": "0195f3e4-...",
  "deletedAt": "2026-07-14T12:40:00.000Z"
}
json

helpdesk.mention.created — someone was @mentioned in a private note.

{
  "type": "helpdesk.mention.created",
  "accountId": "0195f3a0-...",
  "conversationId": "0195f3b1-...",
  "messageId": "0195f417-...",
  "mentionedUserId": "0195f428-...",
  "mentionerUserId": "0195f3e4-..."
}
json

helpdesk.label.created and helpdesk.label.updated carry the complete state, so you can upsert without a refetch.

{
  "type": "helpdesk.label.created",
  "accountId": "0195f3a0-...",
  "labelId": "0195f406-...",
  "name": "Urgent",
  "description": "Needs an answer today",
  "color": "#DC2626",
  "showOnSidebar": true
}
json

helpdesk.label.deleted

{
  "type": "helpdesk.label.deleted",
  "accountId": "0195f3a0-...",
  "labelId": "0195f406-...",
  "actorUserId": "0195f3e4-..."
}
json

helpdesk.csat.response_received — the contact answered the satisfaction survey.

{
  "type": "helpdesk.csat.response_received",
  "accountId": "0195f3a0-...",
  "csatSurveyResponseId": "0195f439-...",
  "conversationId": "0195f3b1-...",
  "messageId": "0195f417-...",
  "rating": 5,
  "contactId": "0195f3d3-...",
  "assignedAgentUserId": "0195f3e4-..."
}
json

helpdesk.sla.missed — a deadline blew. eventType says which: first response, next response, or resolution.

{
  "type": "helpdesk.sla.missed",
  "accountId": "0195f3a0-...",
  "conversationId": "0195f3b1-...",
  "inboxId": "0195f3c2-...",
  "appliedSlaId": "0195f44a-...",
  "slaPolicyId": "0195f45b-...",
  "assigneeUserId": "0195f3e4-...",
  "eventType": "FIRST_RESPONSE"
}
json

helpdesk.sla.missed is the only event that is never broadcast over the websocket. It reaches your webhook and raises an in-app notification — but no socket event fires for it. If you depend on SLA in real time, the webhook is the only path.

These two are progress snapshots: the same event fires repeatedly during a run, with the counters climbing. Upsert on runId and react to status.

helpdesk.contact_sync.updated

{
  "type": "helpdesk.contact_sync.updated",
  "accountId": "0195f3a0-...",
  "runId": "0195f46c-...",
  "connectionId": "0195f3a0-...",
  "status": "running",
  "totalCount": 1240,
  "processedCount": 380,
  "createdCount": 350,
  "updatedCount": 28,
  "skippedCount": 2,
  "failedCount": 0,
  "errorMessage": null
}
json

helpdesk.conversation_sync.updated — the history backfill. Here the counters count messages (skippedCount are the ones deduplicated by wamid), and conversationsCount counts the conversations touched.

{
  "type": "helpdesk.conversation_sync.updated",
  "accountId": "0195f3a0-...",
  "runId": "0195f47d-...",
  "inboxId": "0195f3c2-...",
  "connectionId": "0195f3a0-...",
  "status": "completed",
  "totalCount": 8400,
  "processedCount": 8400,
  "createdCount": 8112,
  "updatedCount": 0,
  "skippedCount": 288,
  "failedCount": 0,
  "conversationsCount": 214,
  "errorMessage": null
}
json

Headers:

HeaderValue
X-Helpdesk-EventThe event name
X-Helpdesk-SignatureThe 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()
text

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_..."
bash
{ "status": 200, "durationMs": 143 }
json

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" }'
bash

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" }
  }'
bash

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"
}
json

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" }'
bash

© 2026 Pingo Notify. All rights reserved.

pingonotify.com ·Built with Nuxt and Scalar