Skip to main content

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.

  • #endpointstringRequired
    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.

Don't verify the signature on a ping

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.

The signing secret is shown once

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.

  • #sessionIdstring (uuid)Required
    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
  • #msisdnstringRequired
    The subscriber's phone number in international format.
    Example: 231770000000
  • #serviceCodestringRequired
    The USSD code the subscriber dialed.
    Example: *123#
  • #carrierstring (enum)Required
    The network: orange or mtn.
    Example: orange
  • #typestring (enum)Required
    start on the first turn, input on later turns, ping for registration checks.
    Example: start
  • #timestampstring (ISO 8601)Required
    When Ponitor issued this request.
    Example: 2026-09-14T19:30:52.924Z
  • #inputstring | nullRequired
    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.

Every continue needs options or form

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.

Ponitor handles Home. You handle Back

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.

  • #actionstring (enum)Required
    continue to show a screen and wait for a reply, or end to show a final screen and close the session.
    Example: continue
  • #screenstringRequired
    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
  • #optionsarrayOptional
    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.
  • #options[].labelstringRequired
    Required in each option. The option's text. The number is added for you.
    Example: Check balance
  • #formobjectOptional
    A text or number input. Required for continue unless you send options. Don't send both.
  • #form.typestring (enum)Required
    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
  • #form.sensitivebooleanOptional
    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."
}
Validate input yourself

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.

OrangeLonestar Cell MTN
MenusNative menu links, numbered and rendered by the handsetPonitor numbers your options on their own lines and the subscriber types the number
Home (00)Ponitor sends a start turnPonitor sends a start turn and shows a 00 Home hint under the screen
Back (0)Reaches you as ordinary inputReaches you as ordinary input
form.typedigits selects a numeric input controlNo effect. The subscriber can type anything
Screen budget160 characters, options included160 characters, options included
  • A start turn can arrive mid-session when the subscriber presses Home. Reset your state on every start.
  • Validate input. On Lonestar Cell MTN, a digits prompt doesn't restrict the keypad.
  • Identify the session by sessionId, not by serviceCode. serviceCode is 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.

Set sensitive yourself

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.

  • #X-Webhook-SignaturestringRequired
    sha256=<hex digest> of the signed payload.
    Example: sha256=5f8c3a1e...
  • #X-Webhook-TimestampstringRequired
    Unix seconds at signing time. Part of the signed payload, to prevent replay.
    Example: 1757877052

The signed payload is {timestamp}.{raw request body}.

  1. Skip verification if type is "ping", and return 2xx. See Registering your webhook.
  2. Compute the HMAC over the payload with your secret.
  3. Compare it with a constant-time function.
  4. Reject timestamps more than 5 minutes old. Your server enforces this, not Ponitor.

Each example does all four.

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');
}
}
Use the raw body

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-2xx status, a body that isn't JSON, unknown fields, an empty screen, a continue with neither or both of options and form, an empty options array, or options or form on an end.
  • 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.