Sample code
Working USSD backends you can copy, run in a minute and edit for your own service. Every sample on this page is tested against signed requests exactly like the ones Sendexa sends.
When someone dials your code, Sendexa posts each step of the session to your URL: first with action: "start", then with action: "continue" and what they typed in userInput. Each request carries only the latest input, so your server keeps the rest of the session under its sessionId.
You reply with { "ussdString": "…", "end": false } to show a screen and wait for an answer, or end: true on the last screen.
The samples handle all of that for you with a small menu engine. Screens are described in menu.json; your code only fills in actions, like looking up an order or starting a payment. The same menu.json works with every language below.
1. Describe your menu
This is an online shop: track an order, pay for one, or get support. Copy it next to your server file as menu.json, then change the words and options to suit your business.
{"start": "main","screens": {"main": {"text": "Welcome to JustBuy\n1. Track my order\n2. Pay for an order\n3. Talk to us","options": { "1": "track", "2": "pay", "3": "support" }},"track": {"text": "Enter your order number:","input": "orderId","pattern": "^[0-9]{3,10}$","error": "Order numbers are 3 to 10 digits.","next": "track_result"},"track_result": {"action": "trackOrder","text": "Order #{orderId} is {status}.\nThank you for shopping with JustBuy.","end": true},"pay": {"text": "Enter the order number to pay for:","input": "orderId","pattern": "^[0-9]{3,10}$","error": "Order numbers are 3 to 10 digits.","next": "pay_confirm"},"pay_confirm": {"action": "quoteOrder","text": "Pay GHS {amount} for order #{orderId}?\n1. Yes, pay now\n0. Back","options": { "1": "pay_done", "0": "back" }},"pay_done": {"action": "requestPayment","text": "Approve the MoMo prompt on {phone} to pay GHS {amount}. You'll get an SMS once it's paid.","end": true},"not_found": {"text": "We couldn't find order #{orderId}. Check the number and dial again.","end": true},"support": {"text": "Call or WhatsApp us on 024 000 0000, 8am to 8pm every day.","end": true}}}
Screen fields
| Field | Type | Required | Description |
|---|---|---|---|
| text | string | required | What the dialer sees. Use \n for new lines and {name} to insert a value: {phone}, anything they typed, or anything an action returned. |
| options | object | optional | Menu choices: what they press → the screen it opens. "back" returns to the previous screen. |
| input | string | optional | Instead of options: save what they type under this name, then go to next. |
| next | string | optional | The screen to show after a valid input. |
| pattern | regex | optional | What a valid input looks like, e.g. ^[0-9]{4}$ for a 4-digit PIN. A wrong entry shows error and asks again. |
| error | string | optional | Shown above the same screen when the input doesn't match pattern. |
| action | string | optional | A function in your server to run before the screen is shown. It returns values for {placeholders}, or { goto: "screen" } to jump elsewhere. |
| end | boolean | optional | The last screen: show it and close the session. |
Remembering which option they picked
options only decides where to go next. To keep the choice itself (a ticket type, a service), use an input with a pattern like ^[12]$; the menu looks the same to the dialer. The ticket and balance templates below do this.2. Run the server
One complete file per language. Edit the actions at the top to use your own database and payments; you shouldn't need to touch the engine below them. Each file says how to install and run it.
// ussd.js — a Sendexa USSD backend in Node.js (Express).//// npm install express// SENDEXA_SIGNING_KEY=ussd_your_key node ussd.js//// Edit menu.json for your screens and the `actions` below for your data.const crypto = require("crypto");const express = require("express");const menu = require("./menu.json");const SIGNING_KEY = process.env.SENDEXA_SIGNING_KEY; // Dashboard → USSD → Codes → Backend & signing keyconst PORT = process.env.PORT || 3000;// ── Your business logic ───────────────────────────────────────────────────// Each action receives the session's values ({ phone, ...what they typed })// and returns values to show on the screen, or { goto: "screen" } to jump.const actions = {async trackOrder({ orderId }) {const order = await findOrder(orderId);if (!order) return { goto: "not_found" };return { status: order.status };},async quoteOrder({ orderId }) {const order = await findOrder(orderId);if (!order) return { goto: "not_found" };return { amount: order.amount.toFixed(2) };},async requestPayment({ orderId, amount, phone }) {// Start the MoMo charge here (Paystack, Hubtel, your bank…), but don't// wait for the customer to approve it: replies must arrive within 8s.console.log(`Charge ${phone} GHS ${amount} for order ${orderId}`);return {};},};// Demo data so the sample runs as-is. Replace with your database.const ORDERS = {1042: { status: "out for delivery", amount: 120 },1043: { status: "being packed", amount: 45.5 },};async function findOrder(id) {return ORDERS[id] ?? null;}// ── Menu engine (no changes needed below) ─────────────────────────────────// Sessions live in memory. Running more than one server? Use Redis instead.const sessions = new Map();const SESSION_TTL_MS = 5 * 60 * 1000;const fill = (text, vars) => text.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? "");async function show(session, name, notice) {const screen = menu.screens[name];if (!screen) throw new Error(`menu.json has no screen "${name}"`);if (screen.action) {const action = actions[screen.action];if (!action) throw new Error(`No action "${screen.action}" in ussd.js`);const { goto, ...values } = (await action(session.vars)) ?? {};Object.assign(session.vars, values);if (goto) return show(session, goto);}const text = (notice ? `${notice}\n` : "") + fill(screen.text, session.vars);if (screen.end) {sessions.delete(session.id);return { ussdString: text, end: true };}session.screen = name;session.at = Date.now();sessions.set(session.id, session);return { ussdString: text, end: false };}async function handle({ sessionId, msisdn, action, userInput, ussdString }) {let session = sessions.get(sessionId);if (session && Date.now() - session.at > SESSION_TTL_MS) session = undefined;// A new dial (or a session we no longer have): show the first screen.if (action === "start" || !session) {session = { id: sessionId, screen: menu.start, vars: { phone: msisdn }, history: [] };return show(session, menu.start);}const screen = menu.screens[session.screen];const input = String(userInput ?? ussdString ?? "").trim();if (screen.options) {const target = screen.options[input];if (!target) return show(session, session.screen, "Invalid choice.");if (target === "back") return show(session, session.history.pop() ?? menu.start);session.history.push(session.screen);return show(session, target);}if (screen.input) {if (screen.pattern && !new RegExp(screen.pattern).test(input)) {return show(session, session.screen, screen.error ?? "That doesn't look right.");}session.vars[screen.input] = input;session.history.push(session.screen);return show(session, screen.next);}return show(session, menu.start);}// X-Sendexa-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">function signatureIsValid(rawBody, header) {const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");if (!SIGNING_KEY || !match) return false;const [, t, v1] = match;if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // older than 5 minutesconst expected = crypto.createHmac("sha256", SIGNING_KEY).update(`${t}.${rawBody}`).digest("hex");return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));}const app = express();app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf.toString("utf8")) }));app.post("/ussd", async (req, res) => {if (!signatureIsValid(req.rawBody, req.get("x-sendexa-signature"))) {return res.status(401).json({ error: "Invalid signature" });}try {res.json(await handle(req.body));} catch (error) {console.error(error);res.json({ ussdString: "Sorry, something went wrong. Please dial again.", end: true });}});app.listen(PORT, () => console.log(`USSD backend listening on :${PORT}/ussd`));
More than one server, or serverless?
gunicorn -w 4, keep sessions in Redis so every request finds its session. The Next.js sample shows the swap for Upstash Redis.3. Try it on your computer
Start your server with SENDEXA_SIGNING_KEY set to any test value, then run one of these scripts with the same value. It dials your server and types the answers you give it, one screen at a time.
#!/usr/bin/env bash# test-ussd.sh — dial your USSD backend like Sendexa does, signed and all.## SENDEXA_SIGNING_KEY=ussd_your_key ./test-ussd.sh http://localhost:3000/ussd 1 1042## Everything after the URL is what the dialer types, one screen at a time.URL="${1:-http://localhost:3000/ussd}"shiftKEY="${SENDEXA_SIGNING_KEY:?Set SENDEXA_SIGNING_KEY to the signing key of your code}"PHONE="${PHONE:-233244000000}"SESSION="test-$(date +%s)"send() {local body t sigif [ -z "$1" ]; thenbody=$(printf '{"sessionId":"%s","msisdn":"%s","ussdString":"","messageType":"0","action":"start","ussdCode":"144","fullCode":"*889*144#"}' "$SESSION" "$PHONE")elsebody=$(printf '{"sessionId":"%s","msisdn":"%s","ussdString":"%s","messageType":"0","action":"continue","userInput":"%s","ussdCode":"144","fullCode":"*889*144#"}' "$SESSION" "$PHONE" "$1" "$1")fit=$(date +%s)sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$KEY" | sed 's/^.*= //')curl -s "$URL" -H "Content-Type: application/json" -H "X-Sendexa-Signature: t=$t,v1=$sig" -d "$body"echo}echo "> (dial)"send ""for input in "$@"; doecho "> $input"send "$input"done
With the shop menu, paying for order 1042 looks like this:
$ SENDEXA_SIGNING_KEY=test ./test-ussd.sh http://localhost:3000/ussd 2 1042 1> (dial){"ussdString":"Welcome to JustBuy\n1. Track my order\n2. Pay for an order\n3. Talk to us","end":false}> 2{"ussdString":"Enter the order number to pay for:","end":false}> 1042{"ussdString":"Pay GHS 120.00 for order #1042?\n1. Yes, pay now\n0. Back","end":false}> 1{"ussdString":"Approve the MoMo prompt on 233244000000 to pay GHS 120.00. You'll get an SMS once it's paid.","end":true}
4. Go live
- Deploy your server somewhere with https (Render, Railway, a VPS, Vercel for Next.js, or your PHP hosting). Sendexa only calls public https URLs.
- In the dashboard, open USSD → Codes, choose your code and click Connect your backend. Paste the URL, e.g.
https://yourapp.com/ussd. - In the same dialog, click Show next to the signing key and set it as
SENDEXA_SIGNING_KEYon your server. Requests without a valid signature get a 401. - Dial your code, e.g.
*889*144#. Each session appears under USSD → Sessions with every screen, so you can see exactly what the dialer saw.
About the signature
X-Sendexa-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body> with your signing key. Check it against the raw body, before parsing the JSON. The samples also refuse signatures older than 5 minutes. More on verifying requests →Ready-made menus
Swap any of these in as your menu.json, then add the actions it lists to your server, next to the shop ones. Every template is checked screen by screen against the engine above.
Awards and pageant voting
Dialers enter a nominee code, choose how many votes, and pay by MoMo. Works for awards shows, pageants and polls.
{"start": "main","screens": {"main": {"text": "Ghana Music Awards voting\nEnter the nominee's code (e.g. AY01):","input": "code","pattern": "^[A-Za-z]{2,4}[0-9]{1,3}$","error": "Codes look like AY01.","next": "nominee"},"nominee": {"action": "findNominee","text": "{nominee}\n{category}\nHow many votes? GHS {price} each:","input": "votes","pattern": "^[1-9][0-9]{0,3}$","error": "Enter 1 to 9999 votes.","next": "confirm"},"confirm": {"action": "priceVotes","text": "{votes} votes for {nominee}: GHS {total}\n1. Pay and vote\n0. Back","options": {"0": "back","1": "done"}},"done": {"action": "castVotes","text": "Approve the MoMo prompt on {phone}. Your {votes} votes count once it's paid.","end": true},"unknown": {"text": "No nominee has the code {code}. Check it and dial again.","end": true}}}
| Action to write | Returns |
|---|---|
| findNominee | { nominee, category, price } or { goto: "unknown" } |
| priceVotes | { total }: votes × price, as text, e.g. "20.00" |
| castVotes | {}: start the MoMo charge; count the votes when it's paid |
Tips for a good USSD service
- Keep each screen short. Around 160 characters fits every phone; longer menus get cut off.
- Answer fast. Sendexa waits up to 8 seconds for your reply. Start slow work (payments, SMS) and return straight away rather than waiting for it.
- Put "0. Back" on confirm screens so people can fix a mistake without dialling again.
- Never show a PIN or full account number back on screen. Hash PINs, and lock an account after a few wrong tries (the balance template has a locked screen for this).
- Sendexa masks inputs longer than two characters in your session logs, since they're often PINs. Your backend still receives exactly what was typed.
- Plain letters and numbers only: some phones can't show emoji or accented characters.