Skip to main content

Receiving webhooks

A webhook is a URL on your server that Ponitor calls when something happens, such as a delivery report or a customer's reply. Each call is a signed JSON POST.

Two kinds​

Team webhooksSender webhooks
ReceivesEvents for your whole team: messages, campaigns, Sender ID and USSD code decisions, airtime, and your account balanceMessages to and from one Sender ID or Short Code
Set up in the appSettings › Developers › WebhooksSMS › Sender IDs or SMS › Short Codes, then the sender's Webhook
How manyUp to 5, each with the events you pickOne per sender
Use it whenYou want all your events in one placeOne sender's messages go to their own system, such as a Short Code's replies to your helpdesk

Both kinds are signed and retried the same way. They work independently. If both are set up for the same message, Ponitor calls both. If they point at the same server, use the message's data.id to detect the second copy.

Webhook events lists every event and its fields. USSD works differently: Ponitor calls your USSD webhook to build each screen, and waits for the answer. See How USSD works.

Set up a team webhook​

You need a public https URL on your server, and a role that can manage developer settings: Owner, Admin, or a custom role with Manage API Keys and Webhooks.

  1. Open Settings › Developers › Webhooks and choose New Webhook.
  2. Give it a Name, and enter the Endpoint URL.
  3. Under Events, pick the events to send to it.
  4. Choose Create Webhook.
  5. Copy the Signing Secret. It's shown once. You need it to check the signature.
  6. Choose Send Test Event, and check that your endpoint answers 2xx.

The sandbox and live have their own webhooks. Set them up in Test mode and in Live mode.

What you receive​

Each call is a POST with a JSON body.

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"type": "message.delivered",
"createdAt": "2026-10-05T09:30:05.000Z",
"teamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "DELIVERED",
"externalId": "order-42"
}
}
FieldMeaning
idThe event's ID. A repeated event has the same id. Skip it.
typeWhat happened, such as message.delivered. See Webhook events.
createdAtWhen it happened, in UTC.
teamId or senderIdteamId for team webhooks, senderId for sender webhooks.
channelSMS or WHATSAPP. Sender webhooks only.
dataThe event's details.

Answer quickly​

Return a 2xx status as soon as you've stored the event. Do the processing afterwards. Ponitor waits up to 10 seconds. Other answers are retried as described below.

Ponitor posts a signed message.delivered event to your endpoint; the first attempt answers 500, and after a short wait the same event, with the same id, is sent again and gets 200 OK.
PonitorYour endpointWaits about 5 seconds, then longer each timePOST message.delivered (signed)500 Server ErrorSame event, same id200 OK: done
  • Ponitor retries timeouts, connection errors, 429 and 5xx up to 5 times, and waits longer before each attempt.
  • Ponitor doesn't retry other 4xx answers or redirects.
  • If 5 events in a row fail every retry, Ponitor turns the webhook off and emails your team. Fix your endpoint, then turn the webhook back on in the app.
  • The endpoint must be a public https URL. Private and local addresses aren't allowed.

Check the signature​

Every call is signed with your webhook's signing secret. The secret is shown once, when you create the webhook or rotate the secret. Check the signature before you trust the body.

HeaderValue
X-Webhook-Signaturesha256= followed by the signature
X-Webhook-TimestampWhen it was sent, in Unix seconds

The signature is HMAC-SHA256 of timestamp + "." + body, keyed with your secret.

  1. Compute it over the raw body exactly as received. Parsing and re-serialising the JSON changes it.
  2. Compare it in constant time.
  3. Reject requests more than 5 minutes old.

Each example does all three. Pass it the raw body, the two headers and your secret.

import crypto from 'node:crypto';

export function verifyPonitorWebhook({ signature, timestamp, rawBody, secret }) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 5 * 60) throw new Error('too old');

const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');

const a = Buffer.from(expected);
const b = Buffer.from(signature || '');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('bad signature');
}

// With Express, read the body raw on this route:
// app.post('/webhooks/ponitor', express.raw({ type: 'application/json' }), (req, res) => {
// verifyPonitorWebhook({
// signature: req.get('X-Webhook-Signature'),
// timestamp: req.get('X-Webhook-Timestamp'),
// rawBody: req.body.toString('utf8'),
// secret: process.env.PONITOR_WEBHOOK_SECRET,
// });
// res.sendStatus(200);
// });

If a secret leaks, rotate it in the app. The old secret stops working at once. Changing only the URL keeps the secret.

Test your endpoint​

A webhook.ping event checks that your endpoint is reachable. Its data is empty. Return 2xx.

  • For a team webhook, choose Send Test Event on the webhook in the app.
  • For a sender webhook, Ponitor sends a ping when you save, update or turn the webhook back on. It saves the webhook only if your endpoint returns 2xx.