Webhook events
Every event Ponitor sends, and what's in its data. Team webhooks receive the events you pick. Sender webhooks receive message.received and message.status for their sender.
For delivery and signing, see Receiving webhooks. New events and fields may be added. Ignore any you don't recognise.
Team webhook events
Messages
| Event | When |
|---|---|
message.sent | The network accepted an SMS or WhatsApp message. |
message.delivered | The network confirmed it reached the phone. |
message.failed | The message wasn't delivered. data.status is EXPIRED, UNDELIVERED or FAILED, and failureCode and failedReason say why (Errors). |
message.received | A customer replied to your Short Code or WhatsApp number. Only this event includes the message content, with contact and replyTo as in Sender webhook events. |
A message that ends as UNKNOWN sends no event. It may have arrived. See Delivery statuses.
Every message, campaign and airtime event's data has workspace: { "id", "handle" } of the workspace it belongs to, or null for Main.
{
"id": "c9bf9e57-1685-4c89-bafb-ff5af830be8a",
"type": "message.failed",
"createdAt": "2026-10-05T09:31:12.000Z",
"teamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "SMS",
"direction": "OUTBOUND",
"sender": "ACME",
"recipient": "+231770000000",
"status": "EXPIRED",
"failedReason": "Recipient unreachable: phone was off or out of coverage until the message expired",
"failureCode": "E11001",
"externalId": "order-42",
"workspace": null,
"createdAt": "2026-10-05T09:30:00.000Z"
}
}
Campaigns
| Event | When |
|---|---|
campaign.started | A campaign started sending to its group or list. |
campaign.failed | A campaign couldn't start, and nothing was sent. error says why. |
data has id, name, groupId or listId, sender, batchId and error. groupId is set for a contact group and listId for a saved list. The other is null. batchId is the send the campaign started, or null.
Sender IDs and USSD codes
| Event | When |
|---|---|
sender_id.approved | A Sender ID or Short Code you requested was approved. |
sender_id.rejected | A Sender ID or Short Code was rejected. reason says why. |
ussd_code.approved | A USSD code you requested was approved. |
ussd_code.rejected | A USSD code was rejected. reason says why. |
{
"type": "sender_id.approved",
"data": {
"requestId": "1b7e4f2c-0d6a-4c51-9a83-2f6e1c0b9d47",
"productType": "SMS_SENDER_ID",
"status": "APPROVED",
"reason": null,
"senderId": "ACME",
"shortcodes": null,
"serviceCodes": null
}
}
productType is SMS_SENDER_ID, TWO_WAY_SMS_SHORTCODE or USSD_SERVICE_CODE. The codes are in senderId, shortcodes or serviceCodes to match.
Airtime
| Event | When |
|---|---|
airtime.purchase.delivered | A purchase of airtime or a data bundle succeeded. This covers purchases from the API and from the app. |
airtime.purchase.failed | A purchase failed. Its cost was refunded to your account. |
airtime.batch.finished | A bulk purchase from the app finished, was stopped, or failed. |
airtime.schedule.skipped | A scheduled bulk purchase didn't run, for example because the balance was too low. |
A data bundle purchase that failed:
{
"type": "airtime.purchase.failed",
"data": {
"id": "6bdffde4-b7e7-4950-881f-44620c2da9af",
"externalId": "order-8844",
"batchId": null,
"workspace": null,
"msisdn": "231880000001",
"carrier": "LONESTAR",
"amount": 9,
"cost": 9,
"status": "FAILED",
"failureReason": "Already purchased",
"refunded": true,
"createdAt": "2026-10-20T19:41:12.000Z",
"type": "DATA",
"product": {
"id": "prod_p4nzx9xp335h",
"code": "mtn-data-15gb-30d",
"name": "Lonestar Cell MTN 15GB, 30 days",
"data": "15GB",
"validity": "30 days",
"price": 9,
"currency": "USD"
},
"instructions": null
}
}
What a purchase event's data holds:
| Field | What it is |
|---|---|
type | AIRTIME or DATA. This isn't the event's own type, such as airtime.purchase.delivered. |
externalId | Your reference, for a purchase made through the API. Null for a purchase made in the app. |
batchId | The bulk purchase it was part of. Null for a single purchase. |
amount, cost | What you paid. For airtime the two are equal. For a data bundle both are the bundle's price. No fees are added. See Airtime and data bundles. |
product | For a data bundle, the bundle as it was when bought: its ID, code, name, data allowance, validity, price and currency. Null for airtime. |
instructions | Text from the network for the subscriber, such as how to use a bundle. Usually null. Pass it on when it's set. |
failureReason, refunded | On a failed purchase, why it failed and whether its cost went back to your account. |
What airtime.batch.finished holds:
| Field | What it is |
|---|---|
id, label | The bulk purchase and the name you gave it. |
status, error | COMPLETED, CANCELLED or FAILED, and on a failure, why. |
scheduleId | The schedule that started it. Null if you started it yourself. |
total, sent, failed, skipped, pending | How many numbers ended up in each state. Each number still pending sends its own purchase event when it settles. |
reservedAmount, spentAmount, refundedAmount | What was held when it started, what was spent, and what went back to your account. |
createdAt, completedAt | When it started and when it finished. |
What airtime.schedule.skipped holds:
| Field | What it is |
|---|---|
scheduleId, name | The schedule. |
scheduledFor | When it was meant to run. |
status, reason | SKIPPED or FAILED, and why. |
paused | True if the schedule stopped itself. It won't run again until you resume it. |
nextRunAt | When it runs next, or null while it's paused. |
Account
| Event | When | data |
|---|---|---|
wallet.topped_up | Money was added to your account. | amount, balance, source (top_up or invoice), reference |
wallet.low_balance | Your balance fell below your low-balance alert. | balance, thresholdUsd |
wallet.overdraft_limit_reached | The account reached its overdraft limit, so sending stopped. | balance, overdraftFloorUsd |
Amounts are in US dollars. The event names use wallet, an older name for your account. The names won't change.
Sender webhook events
A sender webhook receives these two events for its own sender. The envelope has senderId and channel instead of teamId.
| Event | When | data |
|---|---|---|
message.received | A customer replied to this sender. | The reply, who sent it and to which number. See its fields. |
message.status | One of this sender's messages changed status, from QUEUED when you send it to its final status. | id, externalId, status |
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "message.received",
"createdAt": "2026-10-05T09:40:00.000Z",
"senderId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"channel": "SMS",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"sender": "+231770001234",
"recipient": "4455",
"conversationId": "9b2e7c41-0d3a-4f6e-8a51-2c7d9e0b6f13",
"contact": { "id": null, "name": null, "whatsapp": null },
"replyTo": null,
"createdAt": "2026-10-05T09:40:00.000Z",
"text": "Yes, please deliver tomorrow",
"content": "Yes, please deliver tomorrow",
"externalId": null,
"status": "DELIVERED"
}
}
The data when a WhatsApp customer taps a button on your message:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sender": "+231770123456",
"recipient": "+231770000000",
"conversationId": "9b2e7c41-0d3a-4f6e-8a51-2c7d9e0b6f13",
"contact": {
"id": "0f3a9c2e-5b7d-4e1a-8c6f-2d9b3a4e5f60",
"name": "Musu Kollie",
"whatsapp": {
"name": "Musu",
"username": null,
"phoneNumber": "+231770123456",
"userId": "LR.00000000000000000000"
}
},
"replyTo": "6f1c2a3e-8b4d-4c1a-9e2f-3a4b5c6d7e8f",
"createdAt": "2026-10-05T09:41:00.000Z",
"text": "Yes",
"reply": { "id": "yes", "title": "Yes" },
"content": { "type": "text", "text": { "body": "Yes" }, "button": { "text": "Yes", "payload": "yes" } },
"externalId": null,
"status": "DELIVERED"
}
message.received fields
| Field | What it is |
|---|---|
id, createdAt | Ponitor's ID for the reply, and when it arrived. |
sender | The customer who replied, not your Sender ID. |
recipient | Your Short Code or WhatsApp number. |
conversationId | The same for every message between you and this customer on this number. |
contact | Who the customer is. id and name are your saved contact for their number, or null if you haven't saved them. whatsapp is what WhatsApp says about them, and null on SMS. |
replyTo | Ponitor's ID for your message, when the customer replied to it. Otherwise null. |
text, image, video, … | The message, in the field named after its type, as in Send a message. That's text, image, video, audio, document, sticker, location, contacts or order. A media field's url works for an hour. |
reply | Set when the customer tapped a button or quick reply, or picked a list row. It has the id you sent, the title they saw, and a row's description. Match on id, which stays the same when the words change. |
referral | Set when the customer wrote from your click-to-WhatsApp ad. It has the ad's headline, body and sourceUrl. |
forwarded | FORWARDED or FREQUENTLY_FORWARDED when the customer forwarded the message. |
content | Deprecated. The same message in the older shape. It's still sent so code written against it keeps working. Read text or the field named after the type in new code. |
externalId, status | For a reply, always null and DELIVERED. |
Team webhooks use the same names.
On WhatsApp, contact.whatsapp has the name and username the customer set, their phoneNumber, and WhatsApp's userId for them. WhatsApp can hide a customer's number if they set a username and haven't written to you recently. Then phoneNumber is null, and sender is the ID WhatsApp gives them for your business, such as LR.00000000000000000000: two letters for their country, a dot, then letters and digits. Use it as the recipient in Send a message. When their number shows again, their messages come from it.
To mark a message read and show "typing…", use Mark a message read.
To see why a message wasn't delivered, fetch it with Get a message and read failureCode and failedReason.
webhook.ping
Checks that your endpoint is reachable. It's sent from Send Test Event on a team webhook, and when you save a sender webhook. data is empty. Return 2xx.