API documentation
Push into PingPlug from your own systems — a till confirming an order, a shop backend adding a customer, a booking system sending a reminder. Every one of those is a WhatsApp message you already want to send and probably send by hand.
Before anything else
Create a key under Settings → API. The secret is shown once and stored only as a hash, so copy it then — if you lose it, revoke the key and make another.
Authentication
Send the key as a bearer token, or as X-API-Key if that is easier for your HTTP client. Both are accepted and identical.
curl https://www.pingplug.net/api/v1/messages \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+447700900123",
"template": "order_placed",
"params": ["Sam", "41"]
}'Versioning
The version is in the path — https://www.pingplug.net/api/v1. Not a header, because an integration is often a curl in a cron job or a URL field in somebody else's admin panel, and neither has anywhere to put one. A breaking change gets a new path; v1 keeps working.
Which needs saying plainly, because "breaking" on its own is not something you can build against. These happen inside v1 and your client has to tolerate them:
- • New fields on a response. Ignore ones you do not recognise.
- • New values in a set — a new `source`, a new webhook event, a new message `type`. Give your switch a default arm. This is not hypothetical: `source` gained three values in the week of 18 August 2026 alone.
- • New endpoints, and new optional query parameters or body fields.
- • Rate limits changing in either direction. The published number is the promise, not the number you measured — the send limit tightened on 21 August 2026 because three endpoints had been counting separately.
These get a new path instead: removing an endpoint or a field, renaming either, changing the type or meaning of an existing field, making an optional input required, or removing a value from a set you already receive.
Which channel a message arrived on
`message.received` and `contact.opted_out` both carry `channel`. The whole set, rendered from the same list the server uses:
whatsappinstagramfacebookRead it before you use `contact.phone`. On WhatsApp that field is E.164; on Instagram and Facebook there is no phone number to have, and it carries the platform handle instead — `instagram:17841…`. A receiver that dials it, or writes it into a phone column, gets that wrong without anything failing. Same rule as the set above: keep a default arm, because this list grew from one to three in August 2026.
Where a contact change came from
Every contact event carries `source`, so an integration can ignore the writes it made itself. The whole set, rendered from the same list the server uses:
apidashboardautomationwhatsappwhatsapp_inboundinstagram_inboundfacebook_inboundlead_adformmergeNot every event can carry every value — `contact.updated` only ever reports `api`, `dashboard`, `automation` or `whatsapp`, because those are the only paths that can change one. Treat the list above as what you must handle, not as what each event will send, and keep a default arm: it grew by three this week.
Scopes
A key carries exactly the scopes you tick. None implies another — contacts:write does not grant contacts:read — so a key for a till can send messages and read nothing.
messages:sendSend a template message. Spends money with Meta.messages:readRead message and delivery status.contacts:readRead contacts.contacts:writeCreate and update contacts.automations:triggerTrigger an automation.webhooks:manageCreate, list and delete webhook subscriptions. Its own scope because it grants having message bodies and contact details POSTed to a URL you choose.messages:contentRead what a message said, not only what happened to it. Separate from messages:read because that one is delivery status — this one is every conversation you have had.A scope marked no endpoint yet can be granted and nothing reads it. It is listed so you can see what is coming, not so you can build against it — that endpoint does not exist and will 404.
GET /v1/me
Start here. It confirms a key works and lists the scopes it actually has, without sending anything or spending anything. It is the only endpoint that requires no scope— a key with none is still a valid key, and asking “does this work?” through a scoped endpoint cannot tell that apart from a key that is wrong.
curl https://www.pingplug.net/api/v1/me \
-H "Authorization: Bearer pk_live_…"
{
"key": {
"id": "pk_live_8f2c…", // the public half; the secret is never returned
"name": "Kitchen till",
"scopes": ["messages:send"] // [] is a real answer: a key can have none
},
"account": { "id": "3693f194-…" }
}Prefer your tools to reading this page? Everything below is also published as an OpenAPI document at /api/v1/openapi.json. Import it into Postman or Insomnia, or generate a client from it — no key needed to read it.
A revoked or expired key is still refused here — skipping the permission check does not skip that one. And this stamps the key's last-used time like any other call, so after running it Settings → API shows a time against the key. That confirms both ends without messaging anybody.
GET /v1/conversations
The threads on the account, and how many messages are unread in each. Requires messages:read. Every webhook hands you a conversation.id; this is where you find out what those threads are.
curl "https://www.pingplug.net/api/v1/conversations?limit=50" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
# {
# "conversations": [
# {
# "id": "7c1f0f4e-6b2a-4b1e-9a3d-2f8c5d9e1b04",
# "contact_id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a",
# "channel": "whatsapp",
# "status": "open",
# "unread_count": 2,
# "last_message_at": "2026-08-21T09:14:22.104Z"
# }
# ],
# "next_cursor": null
# }No message text. The last line of each thread is not returned, for the same reason GET /v1/messages returns no bodies: a scope described as reading delivery status must not become a way to export every conversation a business has had, one line at a time. last_message_at tells you a thread is active without telling you what was said in it, and message.received carries the text for the threads the account holder chose to send you.
Filter with contact_id, status or channel. Ordered by creation, newest first, with the same cursor as every other list — not by last message, which moves while you page and would let a thread jump ahead of you and be missed.
GET /v1/conversations/{id}
One thread, by the id every webhook carries. `message.received`, `message.sent` and `message.status` all identify a conversation this way, and this is how that id becomes a person: the response carries `contact_id`.
It exists because `message.sent` deliberately does not carry a contact. A contact is not in hand at every place we write an outbound message, and fetching one would mean an extra read per message sent — so the event carries the thread and this turns the thread into the person, once, when you first see it.
curl https://www.pingplug.net/api/v1/conversations/7c1f0f4e-... \
-H "Authorization: Bearer $PINGPLUG_KEY"GET /v1/templates
What this account can send, and what each one needs. Requires messages:read. Read this before POST /v1/messages — that endpoint wants a template name, a language and an ordered list of parameters, and this is where all three come from.
curl "https://www.pingplug.net/api/v1/templates?status=approved" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
# {
# "templates": [
# {
# "name": "order_update",
# "language": "en_GB",
# "category": "Utility",
# "status": "Approved",
# "body": "Hi {{1}}, your order {{2}} is on its way.",
# "variables": ["1", "2"],
# "header_type": null,
# "has_buttons": false
# }
# ],
# "next_cursor": null
# }variables is the important field. Meta allows two placeholder styles and both are in use — numbered {{1}} and named {{customer_name}} — but either way the values travel as an ordered list, so knowing there are two placeholders is not enough. Send one entry in params per entry here, in this order. It is computed by the same code that fills the template in, so it cannot describe a different order from the one a send actually uses.
Pass language back verbatim. Meta matches a template by name and language, and a wrong code fails with #132001 even though the name is right.
A non-null header_type means the template carries an image, video or document at the top. You do not send it — it is set per template in Settings → Message Templates, and a send is refused if it has not been.
status is as of the last sync with Meta, not this second: a template can be paused or rejected there in between. POST /v1/messages re-checks with Meta on every send, so a stale row costs you a clear 409 rather than a message that quietly never arrives. Paging works exactly as it does for contacts.
It needs messages:read rather than a scope of its own, because a new scope would refuse every key that already exists — scopes are fixed when a key is created. A key that can only send cannot list templates; grant both.
POST /v1/messages
Sends one approved WhatsApp template. Requires messages:send. Address it with either to, a number in E.164, or contact_id, the id you already hold from a contact response or a webhook — you do not need to keep a phone number beside our id just to send.
Body
| Field | Required | What it is |
|---|---|---|
to | yes | Phone number in E.164, e.g. +447700900123. |
template | yes | The approved template name, exactly as WhatsApp has it. |
params | no | Values for the template placeholders, in order. |
language | no | Only used if we cannot reach WhatsApp to read the template. Normally the approved language is resolved for you, which is safer than guessing. |
contact_id | no | Writes the message into that contact's chat thread, so whoever answers the reply can see what was sent. |
Response — 202
{
"id": "wamid.HBgLNDQ3NzAwOTAwMTIz...",
"to": "+447700900123",
"template": "order_placed",
"language": "en",
"status": "sent"
}202, not 200, and the difference is real. WhatsApp has accepted the message; it has not delivered it. Delivery is reported afterwards and a message can still fail after this response — so treat the id as a receipt to reconcile against, not as proof it arrived.
What will stop a send
The API applies the same rules as the app, and refuses rather than dropping. Each of these comes back as invalid_request with a code_detail:
duplicate_phoneAnother contact on this account already holds that number, so the change was refused rather than made. Two contacts sharing a number split that person’s history — the inbound webhook files their messages against whichever row it matches first — which is why this is an error and not a warning. The message names the contact already using it.
opted_outThat person replied STOP. Nothing was sent, and this is refused rather than silently dropped.
template_not_approvedWhatsApp has paused, disabled or rejected the template, so it would not be delivered.
account_blockedWhatsApp is refusing this account’s marketing messages. Free-form replies inside the 24-hour window still work, and utility templates mostly do — but one has been refused with the same code, so treat a utility send during a block as worth checking rather than assuming it landed.
missing_header_mediaThe template has a media header and no hosted image is set for it.
meta_rejectedWhatsApp rejected the send. Their wording and code are passed through in message and meta_code.
window_closedFree-form only: more than 24 hours since the customer last wrote, so WhatsApp will not deliver it. Send an approved template instead. Carries last_inbound_at.
automation_inactiveThat automation is switched off. Turn it on in Automations before triggering it.
Opting out is the one worth designing for. Somebody who replied STOP is refused, not quietly skipped — a 200 for a message we never sent would have you record a delivery that did not happen.
GET /v1/messages/{id}
What actually happened to a message. Requires messages:read. The id is the one POST /v1/messages returned.
{
"id": "wamid.HBgLNDQ3NzAwOTAwMTIz...",
"status": "failed",
"reason": "[131042] Message failed to send because your WhatsApp Business account has unsettled payments. Visit https://...",
"template": "order_placed",
"created_at": "2026-08-13T09:14:22.104Z"
}status is WhatsApp's own word — sent, delivered, read or failed — and is null in the seconds after a send, before the first status arrives. That is normal, not a problem. reasonis only present on a failure and quotes WhatsApp verbatim, because "the number is not on WhatsApp" and "this account cannot send right now" need different responses from you.
An id this account never sent returns 404 rather than a made-up status — otherwise you would wait for a delivery that is never coming.
POST /v1/messages/text
Replies in your own words rather than with a template — a support bot answering "where is my order", or anything where the reply depends on what was said. Requires messages:send.
curl https://www.pingplug.net/api/v1/messages/text \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "the-contact-uuid",
"text": "Your order is with the driver now."
}'Only inside the 24-hour window.WhatsApp allows a free-form reply for 24 hours after the customer's last message, and refuses one after that. Outside it you get window_closed with last_inbound_at, rather than WhatsApp's #131047 — and the answer is to send an approved template with POST /v1/messages instead.
It takes a contact_id rather than a phone number, because the window is a property of the conversation. Use the id POST /v1/contacts gave you.
POST /v1/messages/media
Sends a photo, video, audio file or document — a receipt, today's menu, a picture of what the mechanic found. Requires messages:send.
curl https://www.pingplug.net/api/v1/messages/media \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"contact_id": "the-contact-uuid",
"type": "document",
"link": "https://yourshop.example/invoices/10482.pdf",
"filename": "Invoice 10482.pdf"
}'A URL is the better way when you have one.WhatsApp fetches the file itself, so the link has to be public and https — your invoice is probably already behind a URL, nothing travels through us, and no request of ours goes to an address you chose. There is no size limit beyond WhatsApp's own.
If you do not have one, post the file. Send the same endpoint a multipart/form-data body with the file as file and the other fields alongside it — useful for something you generated a moment ago and would otherwise have to publish somewhere first.
curl https://www.pingplug.net/api/v1/messages/media -H "Authorization: Bearer pk_live_YOUR_KEY" -F "contact_id=the-contact-uuid" -F "file=@invoice-10482.pdf"On an upload the file decides what it is, so typeis optional — and if you send one that disagrees with the file, the request is refused rather than one of the two answers quietly winning. The file's own name is kept for documents.
Posted files are capped at 4 MB, well below WhatsApp's own limits, because the whole body arrives in our memory. Over that you get a 413 saying so — send a link instead, which has no such ceiling. An uploaded file is kept on our side, so the Inbox can still open it after WhatsApp expires its copy; a linked one stays yours.
type is one of image, video, audio or document, and it is required — we do not guess it from the URL, because a link with a query string or no extension would be guessed wrong and rejected by WhatsApp minutes later.
caption works on an image or a video only. Sent with a document or an audio file the request is refused rather than the caption quietly dropped — otherwise you would believe the customer had read a line of explanation nobody ever saw. Send it as its own message instead. filename is for documents, and without it the recipient sees an identifier where a name should be.
Only inside the 24-hour window, exactly as for text — an attachment is free-form too. Outside it you get window_closed.
WhatsApp enforces the format and size limits, and its refusal is passed back word for word under meta_rejected — too large, wrong format and could not fetch the URL need three different fixes, and only its wording tells them apart.
The file stays yours. The message appears in the Inbox pointing at your URL, so if that link later expires the message remains and the preview stops loading. We do not copy attachments into our own storage.
Reading what was said
Both message reads return `text` and `has_media` — but only for a key granted messages:content. Without it the two fields are left out of the response entirely, rather than sent as null, so an absent field means not permitted and a null one means the message genuinely carries no text.
It is a second scope rather than part of messages:read on purpose. That one is documented as reading delivery status, and a key was granted it on that understanding; returning bodies under it would turn every status-reader into an export of every conversation the business has ever had.
This is also what makes a webhook you missed recoverable. message.received pushes inbound text at you once; if your server was down, or you subscribed afterwards, this is where you read it back.
Attachments are not served over the API yet. `has_media` tells you one exists so you can show a placeholder rather than an empty message.
GET /v1/messages
Lists messages, newest first, for reconciling a batch you sent. Requires messages:read. Same cursor paging as contacts, and status filters to sent, delivered, read or failed.
Narrow it with contact_id or conversation_id— the ids every webhook already hands you. Without them, answering “what did I miss from this customer” means paging the whole account newest-first, and that gets worse every day the account is used.
curl "https://www.pingplug.net/api/v1/messages?contact_id=2fb8b6ca-fd14-4f7e-9301-0c791709820a&limit=50" \
-H "Authorization: Bearer pk_live_YOUR_KEY"A contact can hold more than one conversation — the Inbox opens one per channel — so contact_id covers all of them. A contact you have never messaged returns an empty page rather than an error. Message bodies are still not returned here; inbound text arrives on the message.received webhook, which the account holder subscribes to deliberately.
curl "https://www.pingplug.net/api/v1/messages?status=failed&limit=100" \
-H "Authorization: Bearer pk_live_YOUR_KEY"Each entry carries the same fields as reading one by id, plus direction — inbound or outbound. An unknown status is refused rather than ignored, because a typo that quietly returns everything is how you conclude nothing failed.
It does not return message text. Neither does reading one by id. messages:read is for reconciling delivery, and exporting every conversation a business has had would be a much larger permission wearing the same name. If your system needs the content of an incoming message, subscribe to message.received, which the account holder turns on deliberately.
Keeping a copy in step
Two filters exist for mirroring a conversation into your own system.
`direction=inbound` or `direction=outbound`. Worth knowing why the second one matters: nothing fires a webhook when a message goes OUT — not from this API, not from a broadcast, not when a colleague replies in the Inbox — so the replies have to be polled for. Without this filter that means fetching every message and discarding the inbound ones, which on a busy account is most of your rate limit spent on data you already had.
`since=2026-08-21T09:00:00Z` returns messages created at or after that moment. It is inclusive, so you can pass the `created_at` of the newest message you hold without working out what a millisecond later looks like — you will see that one message again, which is a much smaller problem than missing one.
curl "https://www.pingplug.net/api/v1/messages?direction=outbound&since=2026-08-21T09:00:00Z" \
-H "Authorization: Bearer $PINGPLUG_KEY"A value neither of them recognises is a 400 naming what went wrong. A filter we could not read and quietly dropped would answer with your whole history, and a job polling every minute would re-import all of it every minute while believing it had asked for a window.
POST /v1/contacts
Creates a contact, or updates the one with that number. Requires contacts:write. Matched on phone, so calling it twice with the same number updates rather than duplicating.
curl https://www.pingplug.net/api/v1/contacts \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+447700900123",
"name": "Sam Okafor",
"email": "sam@example.com",
"company": "Okafor Ltd"
}'Only the fields you send are written. Updating a name will not blank an email you did not mention — an integration that ran nightly would otherwise erase whatever it does not know about. The response carries created, true or false, so you can tell a new customer from a returning one. 201 on create, 200 on update.
GET /v1/contacts
Look one up by number. Requires contacts:read.
curl "https://www.pingplug.net/api/v1/contacts?phone=%2B447700900123" \
-H "Authorization: Bearer pk_live_YOUR_KEY"404 when this account has no contact with that number. Note the + must be URL-encoded as %2B, or it arrives as a space.
POST /v1/automations/{id}/trigger
Runs one automation now, for one contact. Requires automations:trigger. The id is the automation's, from its URL in Automations.
curl https://www.pingplug.net/api/v1/automations/YOUR_AUTOMATION_ID/trigger \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "contact_id": "the-contact-uuid" }'This names the automation directly rather than firing a trigger type, so it runs that one and nothing else. A switched-off automation is refused with automation_inactiverather than silently skipped — the switch in the dashboard is somebody's decision, and an API that ignored it would make "off" mean "off unless a program asks".
202 means the run started. It does not promise every step succeeded — a step can fail mid-run, and that is recorded against the automation's logs in the app.
Listing contacts
Leave off phone and you get a page of contacts, newest first. Requires contacts:read.
curl "https://www.pingplug.net/api/v1/contacts?limit=50" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
# { "contacts": [ ... ], "next_cursor": "MjAyNi0wOC0xM..." }
# Hand it back to get the next page:
curl "https://www.pingplug.net/api/v1/contacts?limit=50&after=MjAyNi0wOC0xM..." \
-H "Authorization: Bearer pk_live_YOUR_KEY"Keep going while next_cursor is not null. It is a cursor rather than a page number on purpose: with page numbers, a contact saved while you are paging shifts every later page by one and a row is skipped entirely — silently, with no error. limitdefaults to 50 and is capped at 200.
GET /v1/contacts/{id}
The same contact, addressed by the id rather than the number. Requires contacts:read. This is the id every webhook carries as data.contact.id, so if you stored it you can use it here without keeping the phone number alongside.
curl https://www.pingplug.net/api/v1/contacts/2fb8b6ca-fd14-4f7e-9301-0c791709820a \
-H "Authorization: Bearer pk_live_YOUR_KEY"404 when no contact with that id is on your account — including when it belongs to somebody else's, so this cannot be used to find out whether an id exists.
PATCH /v1/contacts/{id}
Change a contact. Requires contacts:write. Only the fields you send are touched, so a call that sets a name cannot blank an email it never mentioned. Send an explicit null to clear one.
curl -X PATCH https://www.pingplug.net/api/v1/contacts/2fb8b6ca-fd14-4f7e-9301-0c791709820a \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Aisha Khan", "company": null }'You can change name, email and company. A body with none of them is a 400 rather than a 200 that changed nothing, so a typo in a field name tells you straight away.
The phone number cannot be changed here, and sending a different one is refused rather than ignored. It is the number inbound WhatsApp messages are matched on, so moving it decides which person future messages belong to — and it would be worse to answer 200 and leave you messaging the old number with nothing to explain why. Sending the number back unchanged is fine.
Webhooks
Rather than polling, have PingPlug POST to you when something happens. Subscribe with a key — see Subscribing below — or add an endpoint by hand under Settings → Webhooks. The URL must be https and reachable from the internet.
message.receivedA customer replied to you on WhatsApp, Instagram DMs or Facebook Messenger — `channel` says which. Read it before treating `contact.phone` as a number: on Instagram and Facebook there is none, and the field carries the platform handle instead. A customer replied to you. When they tapped a button rather than typing, `message.reply` carries `{ kind, id, title }` — the id being the payload your template attached, which is what says WHICH order they just confirmed. Null when they typed.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "message.received",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"message": {
"id": "wamid.HBgLNDQ3NzAwOTAwMTIzFQIAEhgU",
"text": "Yes",
"type": "text",
"reply": {
"kind": "button",
"id": "CONFIRM_ORDER_4471",
"title": "Yes"
}
},
"contact": {
"id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a",
"phone": "447700900123",
"name": "Sam Okafor"
},
"conversation": {
"id": "7c1f0f4e-6b2a-4b1e-9a3d-2f8c5d9e1b04"
},
"channel": "whatsapp"
}
}message.sentWe sent a message — from the Inbox, this API, a broadcast, a campaign, an automation, a drip or the auto-reply. `sender` is agent when a person typed it and bot when the system did, which is the one thing about an outbound message you cannot work out for yourself. It carries `conversation_id` rather than a contact: a contact is not in hand at every send path, and you already hold that mapping from message.received.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "message.sent",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"message": {
"id": "wamid.HBgLNDQ3NzAwOTAwMTIzFQIAERgS",
"text": "Your order is on its way.",
"type": "text",
"sender": "agent"
},
"conversation": {
"id": "7c1f0f4e-6b2a-4b1e-9a3d-2f8c5d9e1b04"
},
"channel": "whatsapp"
}
}message.statusA message you sent was delivered, read, or failed. Carries `conversation_id`, the same thread id `message.received` uses — worth having, because this also fires for messages your colleagues send from the PingPlug Inbox, whose ids you have never seen. It does not carry the text: a status fires up to three times per message, and the body is at GET /v1/messages/{id}.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "message.status",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"message": {
"id": "wamid.HBgLNDQ3NzAwOTAwMTIzFQIAEhgU",
"status": "failed",
"reason": "[131042] Message failed to send because there were one or more errors related to your payment method.",
"conversation_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
}
}automation.completedAn automation finished running.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "automation.completed",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"automation": {
"id": "a1d4c8e2-9f37-4b6a-8c15-3e7d2b904f6a",
"name": "Order confirmation"
},
"contact": {
"id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a"
},
"status": "success"
}
}contact.createdA contact was added: through the API, because someone new messaged you on any channel, from a Meta lead ad, or because an agent started a conversation with a number nobody had messaged before. `source` says which. Bulk imports are deliberately NOT announced — one event per row is thousands from a single file, and the CSV screen writes straight from the browser where no server code can see it, so announcing half of them would be worse than announcing none.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "contact.created",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"contact": {
"id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a",
"phone": "447700900123",
"name": "Sam Okafor",
"email": null,
"company": null,
"created_at": "2026-08-21T09:14:22.104Z",
"updated_at": "2026-08-21T09:14:22.104Z"
},
"source": "whatsapp_inbound"
}
}contact.updatedA contact’s name, number, email or company changed — by anyone. Carries `changed`, the list of field names that moved, and a `source` of api, dashboard, automation or whatsapp so an integration can ignore the edits it made itself. Field names only, never the old values.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "contact.updated",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"contact": {
"id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a",
"phone": "447700900123",
"name": "Sam Okafor",
"email": "sam@example.com",
"company": "Okafor Joinery",
"created_at": "2026-08-14T11:02:00.000Z",
"updated_at": "2026-08-21T09:20:41.882Z"
},
"changed": [
"email",
"company"
],
"source": "dashboard"
}
}contact.deletedA contact was removed. On a merge it carries `merged_into`, the id of the contact that survived — that case is two records turning out to be one person, not a record disappearing, and joining your own two beats dropping one. Deleting many at once sends one event per contact.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "contact.deleted",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"contact": {
"id": "5b90c1a7-3d28-42f9-8e60-91a4c7f3d215",
"phone": "447700900124",
"name": "S. Okafor",
"email": null,
"company": null,
"created_at": "2026-08-12T16:41:09.000Z",
"updated_at": "2026-08-12T16:41:09.000Z"
},
"source": "merge",
"merged_into": "2fb8b6ca-fd14-4f7e-9301-0c791709820a"
}
}template.status_changedMeta approved, rejected or paused a template. A rejection carries their reason code — campaigns using that template fail until it is fixed and resubmitted.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "template.status_changed",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"template": {
"id": "1234567890123456",
"name": "order_update",
"language": "en_GB"
},
"status": "REJECTED",
"reason": "INVALID_FORMAT"
}
}number.quality_changedMeta changed your number’s quality rating or sending limit. A flag is the step before sending is throttled and then stopped, so it is worth acting on rather than filing.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "number.quality_changed",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"phone_number": "+44 7700 900999",
"status": "FLAGGED",
"messaging_limit": "TIER_1K"
}
}contact.opted_outSomeone replied STOP. Suppress them on your side too: we tag the contact, but removing them from an audience still lives in each send path, so subscribing to this and acting on it is doing more than we currently do.
What arrives
{
"id": "9f2c1e5a-4d7b-4a1c-8f30-6e2b5c9d0a17",
"event": "contact.opted_out",
"created_at": "2026-08-21T09:14:22.104Z",
"data": {
"contact": {
"id": "2fb8b6ca-fd14-4f7e-9301-0c791709820a",
"phone": "447700900123",
"name": "Sam Okafor"
},
"message": {
"id": "wamid.HBgLNDQ3NzAwOTAwMTIzFQIAEhgV",
"text": "STOP"
},
"channel": "whatsapp",
"effect": "tagged_opted_out"
}
}One more, webhook.test, arrives only when somebody presses Send test on the endpoint in Settings. You cannot subscribe to it — every endpoint receives it — so handle an unrecognised event by ignoring it rather than erroring. Nothing happened to a customer when one arrives, and a test never counts toward the failures that switch an endpoint off.
Subscribing
Three operations, all under webhooks:manage. A key without that scope is refused — it is deliberately not part of contacts:read, because subscribing is how message bodies and phone numbers leave for a URL of your choosing.
POST /v1/webhooks
An unknown event name is refused rather than saved — stored, it would match nothing and the subscription would look configured and receive nothing for ever.
curl -X POST https://www.pingplug.net/api/v1/webhooks \
-H "Authorization: Bearer $PINGPLUG_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/hooks/pingplug",
"events": ["message.received", "message.status"],
"name": "Order updates"
}'The 201 carries secret, and it is the only response that ever will. Every delivery is signed with it and the signature cannot be checked without it, so store it before you discard the response. The list below leaves it out on purpose: a list call is the sort of thing that gets logged whole, and a signing secret in a log aggregator is a signing secret you have shared. If you lose one, read it in Settings or replace the subscription.
GET /v1/webhooks
Your subscriptions, newest first, with cursor pagination like every other list here. Worth reading on a schedule for one field: active goes false after 20consecutive failures, with disabled_reason saying why. A subscription we have switched off looks exactly like a quiet one from your side, which is how somebody finds out a week late that the orders stopped.
curl https://www.pingplug.net/api/v1/webhooks \
-H "Authorization: Bearer $PINGPLUG_KEY"The response also carries available_events, so you can offer the current list without hardcoding ours.
POST /v1/webhooks/{id}/rotate-secret
Replaces the signing secret and returns the new one once. Use this rather than deleting and re-creating a subscription: that also changes the id, and takes the failure history with it, so an endpoint that had been struggling comes back looking healthy.
curl -X POST https://www.pingplug.net/api/v1/webhooks/9f2c1e5a-.../rotate-secret \
-H "Authorization: Bearer $PINGPLUG_KEY"There is no grace period. The new secret applies to the very next delivery, and if you are verifying signatures you will reject everything until you have deployed it — a window you cannot avoid, because you cannot install a secret before it exists.
To lose nothing, switch the subscription off around the change:
PATCH /v1/webhooks/{id} {"active": false} # nothing is delivered
POST /v1/webhooks/{id}/rotate-secret # take the new secret
# deploy it
PATCH /v1/webhooks/{id} {"active": true} # deliveries resumeDELETE /v1/webhooks/{id}
Deliveries stop and the signing secret goes with it, so this is not a way to pause and resume — re-creating the same URL mints a new secret. An id that matches nothing on your account answers 404 rather than pretending: a silent success here would have you believe deliveries had stopped when they had not.
curl -X DELETE https://www.pingplug.net/api/v1/webhooks/9f2c1e5a-... \
-H "Authorization: Bearer $PINGPLUG_KEY"GET /v1/webhooks/{id}
One subscription, by the id `POST` gave you. This is the cheap way to run the check described above — reading `active` on the one you care about, rather than listing every subscription on the account and filtering.
curl https://www.pingplug.net/api/v1/webhooks/9f2c1e5a-... \
-H "Authorization: Bearer $PINGPLUG_KEY"PATCH /v1/webhooks/{id}
Send only what you want changed — url, events, name or active. Anything you leave out is left alone, and the signing secret does not change, so adding an event does not mean redeploying your receiver.
curl -X PATCH https://www.pingplug.net/api/v1/webhooks/9f2c1e5a-... \
-H "Authorization: Bearer $PINGPLUG_KEY" \
-H "Content-Type: application/json" \
-d '{"events": ["message.received", "message.status", "contact.created"]}'This is also how you turn a subscription back on. Twenty consecutive failures switch one off — a deploy, an expired certificate, an afternoon of 502s — and sending {"active": true} resets the failure count along with it. It has to: re-enabling without that would leave you one bad request from being switched off again, and the fix would look like it had not worked.
The signing secret cannot be changed here. Rotating one breaks every delivery already in flight, so it needs asking for explicitly rather than falling out of a general update — delete the subscription and create it again if you need a new one.
What arrives
{
"id": "9f2c1e5a-...", // unique per DELIVERY, not per event
"event": "message.status",
"created_at": "2026-08-13T09:14:22.104Z",
"data": {
"message": {
"id": "wamid.HBgLNDQ3NzAwOTAwMTIz...",
"status": "failed",
"reason": "[131042] ..." // only on a failure
}
}
}Deduplicate on the envelope id. It identifies the event rather than the attempt: every retry of one event carries the same id, including a retry that arrives days later and one you replay by hand. Record the ids you have handled and ignore a repeat.
This page used to say the opposite — that a retry carries a new envelope id, so you should dedupe on the ids inside data instead. That was wrong. If you built against it nothing breaks; deduping on the envelope id is simply simpler.
Verifying the signature
Every delivery carries x-pingplug-signature: t=<unix>,v1=<hex>. The signed string is `${t}.${rawBody}`, HMAC-SHA256 with your endpoint's secret. Check it against the raw body — parsing and re-serialising the JSON changes the bytes and the signature will not match.
import crypto from 'crypto'
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
)
const timestamp = Number(parts.t)
// Reject anything too old OR too far in the future: a future
// timestamp is how somebody buys a long replay window.
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}The timestamp is inside the signed string rather than beside it, so it cannot be edited without breaking the signature. Without that, one captured delivery could be replayed for ever.
Delivery, retries and failures
Answer 2xx quickly — anything else counts as a failure. Each attempt is given 8 seconds. A failure is tried 3 times within about 15 seconds. That is currently the whole of it.
Read this before you rely on retries. The durable queue — nine further attempts on a widening schedule over about three days, with every attempt listed and replayable — is built and deployed, but it needs a one-off database step that has not been done on this installation. Until it is, a receiver that is down for longer than about fifteen seconds misses those events, and nothing records that they were lost.
So if you cannot afford to miss one, reconcile with GET /v1/messages/{id} rather than trusting delivery. This notice is drawn from the database, not typed here, so it disappears by itself the moment the queue is switched on.
Only faults are retried. A 5xx, a 429, a 408 or a dropped connection gets another go; a 4xx does not, because that is your server saying it understood and refused, and repeating it for three days helps nobody.
Handlers still need to be idempotent — a retry inside those fifteen seconds can deliver the same event more than once, which is what the envelope id above is for.
Settings → Webhooks lists every delivery: what was sent, what your server answered, how many attempts it took, and when the next one is due. Anything that failed can be sent again from there.
After 20 consecutive failures the endpoint is switched off automatically and Settings → Webhooks says why — otherwise a dead endpoint would keep costing every message an 8-second wait. Turning it back on there clears the failure count.
Errors
Every failure has the same shape. Read code in software and message in a log — codes are stable, wording is not.
{
"error": {
"code": "insufficient_scope",
"message": "This API key does not have the \"messages:send\" scope.",
"required_scope": "messages:send",
"granted_scopes": ["contacts:read"]
}
}| code | HTTP | When |
|---|---|---|
missing_key | 401 | No key on the request. |
invalid_key | 401 | The key is malformed, unknown, or the secret is wrong. These are deliberately not distinguished. |
key_revoked | 401 | The key was revoked in Settings → API. |
key_expired | 401 | The key passed the expiry date it was given. |
insufficient_scope | 403 | Valid key, wrong scope. The response names the scope required and lists what the key has. |
rate_limited | 429 | Over the per-key limit. Carries retry_after_seconds. |
invalid_request | 400 / 409 / 502 | Bad input, or a condition that stops the send. See code_detail. |
not_found | 404 | No contact, message or automation with that id on this account. An id belonging to another account answers identically, so this cannot be used to discover what exists elsewhere. |
contact_limit_reached | 403 | The account is at its plan’s contact limit, so a NEW contact cannot be created. Updating an existing one still works — an update adds nobody. Nothing is refused for this reason on the inbound side: somebody messaging in is recorded whatever the plan says. |
internal_error | 503 | Something on our side failed. Safe to retry. |
Starter pack templates
If the account installed a starter pack from Settings → Templates, these are the templates it has and the order their params go in. The template names are exactly what POST /v1/messages expects.
Two things to check before wiring anything to them. A pack installs drafts, so the account holder still has to submit them in WhatsApp Manager and Meta still has to approve them — sending a name Meta has never seen fails every time with meta_rejected and #132001. And the bodies are editable, so confirm the placeholder order against GET /v1/templates rather than assuming this page describes what the account actually has — that endpoint reads the account itself and this table does not.
Restaurants & takeaways
order_confirmedUtilityparams[0] = Customer first name · params[1] = Restaurant name · params[2] = Order number · params[3] = Estimated time, e.g. 35 minutes
order_out_for_deliveryUtilityparams[0] = Customer first name · params[1] = Order number · params[2] = Driver first name · params[3] = Time, e.g. 10 minutes
order_deliveredUtilityparams[0] = Customer first name · params[1] = Order number
order_feedback_requestUtilityparams[0] = Customer first name · params[1] = Restaurant name
reorder_favouriteMarketingparams[0] = Customer first name · params[1] = Restaurant name · params[2] = Last order, e.g. the chicken karahi
Clinics & dentists
appointment_confirmedUtilityparams[0] = Patient first name · params[1] = Clinic name · params[2] = Date · params[3] = Time · params[4] = Practitioner name
appointment_reminderUtilityparams[0] = Patient first name · params[1] = Clinic name · params[2] = Date · params[3] = Time
appointment_cancelledUtilityparams[0] = Patient first name · params[1] = Clinic name · params[2] = Date · params[3] = Time
checkup_dueMarketingparams[0] = Patient first name · params[1] = Clinic name · params[2] = When, e.g. over a year ago
Retail & e-commerce
purchase_confirmedUtilityparams[0] = Customer first name · params[1] = Shop name · params[2] = Order number · params[3] = Order total, e.g. £42.50
order_shippedUtilityparams[0] = Customer first name · params[1] = Order number · params[2] = Carrier name · params[3] = Tracking number · params[4] = Expected date
delivery_attemptedUtilityparams[0] = Customer first name · params[1] = Carrier name · params[2] = Order number
basket_reminderMarketingparams[0] = Customer first name · params[1] = Item, e.g. the blue jacket · params[2] = Shop name
Salons & barbers
booking_confirmedUtilityparams[0] = Client first name · params[1] = Salon name · params[2] = Date · params[3] = Time · params[4] = Stylist name · params[5] = Service, e.g. cut and colour
booking_reminderUtilityparams[0] = Client first name · params[1] = Salon name · params[2] = Date · params[3] = Time
booking_cancelledUtilityparams[0] = Client first name · params[1] = Salon name · params[2] = Date · params[3] = Time
rebook_nudgeMarketingparams[0] = Client first name · params[1] = How long, e.g. eight weeks · params[2] = Salon name · params[3] = Stylist name
Education & coaching
enrolment_confirmedUtilityparams[0] = Student first name · params[1] = Course name · params[2] = School name · params[3] = Start date · params[4] = Time · params[5] = Location
class_reminderUtilityparams[0] = Student first name · params[1] = Course or class name · params[2] = Time · params[3] = Location
class_cancelledUtilityparams[0] = Student first name · params[1] = Course or class name · params[2] = Date · params[3] = Time · params[4] = What happens next, e.g. a replacement date
course_places_openMarketingparams[0] = Student first name · params[1] = Course name · params[2] = Start date · params[3] = School name
Logistics & couriers
shipment_collectedUtilityparams[0] = Customer first name · params[1] = Consignment reference · params[2] = Collection address or sender · params[3] = Tracking number · params[4] = Expected date
shipment_out_for_deliveryUtilityparams[0] = Customer first name · params[1] = Consignment reference · params[2] = Time window, e.g. 2pm and 5pm · params[3] = Driver first name
shipment_delivery_missedUtilityparams[0] = Customer first name · params[1] = Consignment reference · params[2] = Time attempted
shipment_deliveredUtilityparams[0] = Customer first name · params[1] = Consignment reference · params[2] = Time · params[3] = Where it was left, e.g. with a neighbour
Category matters at send time, not just at approval: during a billing block on the WhatsApp account, marketing templates are refused while utility ones mostly keep working. A till sending order updates should be sending the Utility ones.
Rate limits
One row per budget, not per endpoint. Where a row names several, they share the number between them — `POST /v1/messages`, `/v1/messages/text` and `/v1/messages/media` draw on one allowance of 120 a minute, because each of them puts the same billable WhatsApp message on the wire. Reading and writing contacts works the same way.
Changed 2026-08-21. The three send endpoints previously counted separately, so spreading work across them allowed 360 a minute while each one reported a limit of 120. If you built against that, you will now see 429s sooner — the number you were told was always 120.
Counted per key rather than per account, so one busy integration cannot starve another and you can see which key is noisy. Over the limit returns rate_limited with a Retry-After header, plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). The same number is in the body as retry_after_seconds, so either works — but most HTTP clients back off automatically on the header alone.
Those headers are sent on the 429 only, not on successful responses. That is deliberate rather than unfinished: the counter lives in the instance serving your request, so a “remaining” figure from one instance says nothing about the budget on the next. On a refusal the numbers describe something that actually happened. Treat them as instructions for retrying, not as a budget to plan against.
| Endpoint | Per minute | Why |
|---|---|---|
POST /v1/messagesPOST /v1/messages/textPOST /v1/messages/media | 120 | Each one is a billable WhatsApp message, so all three share one budget. Far past any real till. |
GET /v1/messages/{id} | 240 | Reads are cheap. A tight limit only makes you reconcile late. |
GET /v1/messages | 240 | Reads are cheap. A tight limit only makes you reconcile late. |
GET /v1/contactsPOST /v1/contactsGET /v1/contacts/{id}PATCH /v1/contacts/{id} | 120 | Enough for a checkout to write a contact per order. |
GET /v1/conversationsGET /v1/conversations/{id} | 240 | Reads are cheap, and an inbox mirror polls this more than anything else. |
GET /v1/templates | 120 | Reads are cheap, and callers often resolve a template per send. |
GET /v1/me | 60 | A check, not a workload. Reads one row and sends no message. |
GET /v1/webhooksPOST /v1/webhooksGET /v1/webhooks/{id}PATCH /v1/webhooks/{id}POST /v1/webhooks/{id}/rotate-secretDELETE /v1/webhooks/{id} | 60 | Setting up a subscription is a one-off, not a workload. |
POST /v1/automations/{id}/trigger | 60 | One call is a whole sequence — it can message, tag, create a deal and call a webhook. |
This table is rendered from the same values the routes enforce, so it cannot drift from what actually happens.
Not built yet
Listed so you can plan, and so nobody writes code against something that is not there. None of these exist today:
- • An idempotency key on sends. A request that times out and is retried can send the message twice, so use your own de-duplication before calling if a double send would matter.
- • Deleting a contact. Removing one takes its conversations and their messages with it, so this is a decision about erasure rather than a missing endpoint — ask us if you need it.
The durable webhook queue used to be on this list. It is written and deployed now; whether it is switched on is shown in the retries section above, which reads the answer rather than stating one.
Something here wrong or missing? Tell us. Documentation that disagrees with the API is worse than none.