USSD webhook reference
Ponitor calls this webhook on your server for every turn of a USSD session. This page covers the request, the response, signature checks and timing.
Registering your webhook
Set the endpoint on an approved code under USSD › Codes in the app.
- Your public HTTPS URL. Plain HTTP is rejected.Example:
https://example.com/ussd
Ponitor sends a type: "ping" request to your endpoint before it saves it. Registration succeeds only if you return a 2xx status. The body isn't checked, so 200 OK is enough.
A ping is signed with a secret you don't have yet. Ponitor shows you the secret only after the ping succeeds. If your handler verifies every signature, registration fails. Turning a disabled webhook back on also sends a ping. Skip verification when type is "ping". Verify start and input turns as usual.
The endpoint must be a public HTTPS URL. Redirects are rejected. Private, loopback and link-local addresses aren't allowed.
After registration, Ponitor shows the HMAC signing secret once. Copy it straight away. If you lose it, rotate it to get a new one.
Ponitor turns off a webhook that fails 5 deliveries in a row. Turning it back on sends another ping.
Request: what Ponitor sends you
For every turn, Ponitor sends a POST to your endpoint with Content-Type: application/json.
- The session ID. It is the same on every turn of a session. Use it to key your own session state.Example:
a3f1c9e2-7b44-4e91-9c3a-1d8f0b2e6c77 - The subscriber's phone number in international format.Example:
231770000000 - The USSD code the subscriber dialed.Example:
*123# - The network: orange or mtn.Example:
orange - start on the first turn, input on later turns, ping for registration checks.Example:
start - When Ponitor issued this request.Example:
2026-09-14T19:30:52.924Z - The subscriber's answer to your previous screen. For `options`, the chosen option's position, starting at 1. For `form`, what they typed. Always null on start and ping.Example:
1
Example: first dial
{
"sessionId": "a3f1c9e2-7b44-4e91-9c3a-1d8f0b2e6c77",
"msisdn": "231770000000",
"serviceCode": "*123#",
"carrier": "orange",
"type": "start",
"timestamp": "2026-09-14T19:30:52.924Z",
"input": null
}
Response: what you must return
Respond with Content-Type: application/json. Send only action, screen, options and form: any other field fails the turn. A continue turn has either a menu (options) or an input (form). Send exactly one.
Ponitor rejects a continue with neither. It shows the subscriber a fallback message, ends the session and counts a failed delivery, the same as a timeout. Watch for menus built from data that can be empty. If you have nothing to offer, return end with a screen that explains why.
00 returns the subscriber to the start on both networks. Ponitor sends your webhook a new type: "start" turn. Reset your state whenever a start turn arrives.
Ponitor has no Back key. 0 reaches you as ordinary input on both networks. To offer Back, add it as a numbered option and handle it yourself.
Treat every input as the answer to the last screen you returned for that sessionId.
- continue to show a screen and wait for a reply, or end to show a final screen and close the session.Example:
continue - The text shown to the subscriber. It appears above the menu with options, or as the prompt with form. Keep the whole screen, menu included, within 160 characters. USSD allows 182, but networks differ. Ponitor numbers each option on its own line, so long labels add up. Ponitor doesn't truncate. Past the limit, a handset may cut the text or drop the screen.Example:
Welcome to Acme - A menu of choices, with at least one entry. Required for continue unless you send form. On Orange each option becomes a native menu link. On Lonestar Cell MTN, Ponitor numbers the options on their own lines and the subscriber types the number. On both networks the chosen position comes back as input. Labels count toward the 160-character screen limit.
- Required in each option. The option's text. The number is added for you.Example:
Check balance - A text or number input. Required for continue unless you send options. Don't send both.
- Required when you send form. digits or text. On Orange this selects the matching keypad on the handset. Lonestar Cell MTN shows the prompt as plain text and the subscriber can type anything. Validate what you receive.Example:
digits - Set true for PINs, passwords and anything else Ponitor shouldn't store. See Data retention below. Defaults to false.Example:
true
Example: show a menu
{
"action": "continue",
"screen": "Welcome to Acme",
"options": [
{ "label": "Check balance" },
{ "label": "Transfer funds" }
]
}
If the subscriber picks "Transfer funds", the next turn has "input": "2". The value is the option's position in the array, starting at 1.
Example: ask for a PIN
{
"action": "continue",
"screen": "Enter your 4-digit PIN",
"form": { "type": "digits", "sensitive": true }
}
Example: close the session
{
"action": "end",
"screen": "Your balance is $248.00. Thank you for using Acme."
}
Ponitor doesn't check what the subscriber types. If a PIN or amount is invalid, send another continue to ask again.
Network differences
Ponitor uses the same request and response on both networks. These differences can still affect your code.
| Orange | Lonestar Cell MTN | |
|---|---|---|
| Menus | Native menu links, numbered and rendered by the handset | Ponitor numbers your options on their own lines and the subscriber types the number |
Home (00) | Ponitor sends a start turn | Ponitor sends a start turn and shows a 00 Home hint under the screen |
Back (0) | Reaches you as ordinary input | Reaches you as ordinary input |
form.type | digits selects a numeric input control | No effect. The subscriber can type anything |
| Screen budget | 160 characters, options included | 160 characters, options included |
- A
startturn can arrive mid-session when the subscriber presses Home. Reset your state on everystart. - Validate input. On Lonestar Cell MTN, a
digitsprompt doesn't restrict the keypad. - Identify the session by
sessionId, not byserviceCode.serviceCodeis set on every turn, but on MTN it's Ponitor that fills it in after the first turn, because MTN leaves it out.
Data retention
Ponitor keeps a record of every turn, with the request it sent and the response you returned. You can see it under USSD › Sessions in the app.
The record includes what the subscriber typed, unless the prompt had form: { "sensitive": true }. For a sensitive prompt:
- your webhook receives the real value;
- Ponitor stores and shows
"[redacted]"instead.
Use sensitive for PINs, passwords and similar values. Other input, such as menu choices, amounts and names, stays visible in the session history.
Ponitor can't tell a PIN from an amount. sensitive defaults to false.
Verifying the signature
Every request is signed with HMAC-SHA256 using your webhook's signing secret. The signature is in two headers.
- sha256=<hex digest> of the signed payload.Example:
sha256=5f8c3a1e... - Unix seconds at signing time. Part of the signed payload, to prevent replay.Example:
1757877052
The signed payload is {timestamp}.{raw request body}.
- Skip verification if
typeis"ping", and return2xx. See Registering your webhook. - Compute the HMAC over the payload with your secret.
- Compare it with a constant-time function.
- Reject timestamps more than 5 minutes old. Your server enforces this, not Ponitor.
Each example does all four.
- Node.js
- Python
- PHP
import crypto from 'node:crypto';
function verifyPonitorUssdWebhook({ type, signature, timestamp, rawBody, secret }) {
if (type === 'ping') return; // can't be verified; see docs
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 5 * 60) {
throw new Error('timestamp outside replay window');
}
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('invalid signature');
}
}
import hashlib
import hmac
import time
def verify_ponitor_ussd_webhook(type_: str, signature: str, timestamp: str, raw_body: bytes, secret: str) -> None:
if type_ == "ping":
return # can't be verified; see docs
try:
age_seconds = abs(time.time() - int(timestamp))
except (TypeError, ValueError):
raise ValueError("bad timestamp")
if age_seconds > 5 * 60:
raise ValueError("timestamp outside replay window")
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("invalid signature")
<?php
function verifyPonitorUssdWebhook(string $type, string $signature, string $timestamp, string $rawBody, string $secret): void
{
if ($type === 'ping') {
return; // can't be verified; see docs
}
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 5 * 60) {
throw new RuntimeException('timestamp outside replay window');
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
throw new RuntimeException('invalid signature');
}
}
Compute the HMAC over the exact bytes received, before JSON parsing. Re-serialising a parsed body changes whitespace and key order, and the check fails.
Timing and delivery
The network waits only about 5 seconds for each screen, so Ponitor doesn't retry USSD webhooks.
- Ponitor calls your webhook once per turn and waits up to about 3 seconds.
- A slow, unreachable or invalid response is a failed delivery. Ponitor shows the subscriber a fallback message, ends the session and counts the failure toward turning off your webhook.
- A response is invalid if it has a non-
2xxstatus, a body that isn't JSON, unknown fields, an emptyscreen, acontinuewith neither or both ofoptionsandform, an emptyoptionsarray, oroptionsorformon anend. - If the subscriber cancels or stops answering, your webhook isn't called again. There is no cancel turn. The network may resume the session if the subscriber reconnects within about 2 minutes. After 5 minutes without activity, Ponitor closes the session as if you had returned
end. Your logs may not show a final turn. - If a turn needs a slow lookup, return a response within the time limit anyway.
Next steps
- Read How USSD works for the session lifecycle.
- Set up your webhook under USSD › Codes in the app.