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 webhooks | Sender webhooks | |
|---|---|---|
| Receives | Events for your whole team: messages, campaigns, Sender ID and USSD code decisions, airtime, and your account balance | Messages to and from one Sender ID or Short Code |
| Set up in the app | Settings › Developers › Webhooks | SMS › Sender IDs or SMS › Short Codes, then the sender's Webhook |
| How many | Up to 5, each with the events you pick | One per sender |
| Use it when | You want all your events in one place | One 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.
- Open Settings › Developers › Webhooks and choose New Webhook.
- Give it a Name, and enter the Endpoint URL.
- Under Events, pick the events to send to it.
- Choose Create Webhook.
- Copy the Signing Secret. It's shown once. You need it to check the signature.
- 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"
}
}
| Field | Meaning |
|---|---|
id | The event's ID. A repeated event has the same id. Skip it. |
type | What happened, such as message.delivered. See Webhook events. |
createdAt | When it happened, in UTC. |
teamId or senderId | teamId for team webhooks, senderId for sender webhooks. |
channel | SMS or WHATSAPP. Sender webhooks only. |
data | The 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 retries timeouts, connection errors,
429and5xxup to 5 times, and waits longer before each attempt. - Ponitor doesn't retry other
4xxanswers 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
httpsURL. 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.
| Header | Value |
|---|---|
X-Webhook-Signature | sha256= followed by the signature |
X-Webhook-Timestamp | When it was sent, in Unix seconds |
The signature is HMAC-SHA256 of timestamp + "." + body, keyed with your secret.
- Compute it over the raw body exactly as received. Parsing and re-serialising the JSON changes it.
- Compare it in constant time.
- Reject requests more than 5 minutes old.
Each example does all three. Pass it the raw body, the two headers and your secret.
- Node.js
- Python
- PHP
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);
// });
import hashlib
import hmac
import time
def verify_ponitor_webhook(signature: str, timestamp: str, raw_body: bytes, secret: str) -> None:
try:
age = abs(time.time() - int(timestamp))
except (TypeError, ValueError):
raise ValueError("bad timestamp")
if age > 5 * 60:
raise ValueError("too old")
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(f"sha256={digest}".encode(), (signature or "").encode()):
raise ValueError("bad signature")
# With Flask:
# @app.post("/webhooks/ponitor")
# def ponitor_webhook():
# verify_ponitor_webhook(
# request.headers.get("X-Webhook-Signature", ""),
# request.headers.get("X-Webhook-Timestamp", ""),
# request.get_data(),
# os.environ["PONITOR_WEBHOOK_SECRET"],
# )
# return "", 200
<?php
function verifyPonitorWebhook(string $signature, string $timestamp, string $rawBody, string $secret): void
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 5 * 60) {
throw new RuntimeException('too old');
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('bad signature');
}
}
verifyPonitorWebhook(
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
file_get_contents('php://input'),
getenv('PONITOR_WEBHOOK_SECRET')
);
http_response_code(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.