USSD callback mode

We POST every step of a live USSD session to your endpoint as form-encoded fields; your plain-text reply is what renders on the handset. One request-response pair per screen.

What we send you

POSThttps://your-app.example.com/ussd (your configured URL)
Request body (per step) — application/x-www-form-urlencoded
sessionId=ATUid_8d2c31
phoneNumber=254712345678
networkCode=1
serviceCode=*657*45*1*2500#
text=1*2500

Parameters

FieldTypeRequiredDescription
sessionIdstringNoStable for the whole session. Key your state on it.
phoneNumberstringNoThe dialling subscriber, E.164 without +.
networkCodestringNoCarrier code. 1 = Safaricom, 2 = Airtel, 3 = Telkom, 4 = Equitel.
serviceCodestringNoThe full dialled string, e.g. *657*45*1*2500#: your shared code with everything the subscriber has entered spliced in.
textstringNoOnly what was typed after your extension, *-separated ("1*2500"). Empty on the first screen of a session.

Form-encoded, not JSON

Fields arrive as application/x-www-form-urlencoded, the same contract shared USSD has always used, so existing integrations keep working unchanged. Read them the way your framework reads a form post.

Verify it came from us

Every step we POST carries X-MobileSasa-Secret and X-MobileSasa-Signature headers, so your endpoint can reject anyone simulating dials at your URL. The portal's Send test button signs its test exactly like a live step. See Webhook security for verification examples.

What you reply

Response body — plain text
CON Confirm loan of KES 2,500?
1. Yes
2. No

Parameters

FieldTypeRequiredDescription
CON …prefixYesKeeps the session open: the text is shown and the subscriber can reply.
END …prefixYesShows the text and hangs up.

JSON replies also work

A body with no CON/END prefix is treated as CON. If you prefer JSON, reply{ "response": "…", "end_session": false } and we will render it the same way.

Speed matters

Carriers time sessions out fast. Answer in well under 3 seconds: precompute what you can, avoid slow lookups on the hot path. We give up after 10 seconds and do not retry a step: if your endpoint errors or times out, the subscriber sees “Service temporarily unavailable” and the session ends. TLS on your side is not verified, so self-signed certificates work; a wrong hostname or expired certificate does not break the call either.

Test your endpoint before going live

POST/api/v1/ussd/test-callback

Sends one signed, realistic first step to a URL and reports what came back. This is the same call behind the portal's Send testbutton, in the extension wizard and on the extension page.

# Paste your token once (it starts with mbs_):
export MOBILESASA_TOKEN="mbs_your_token_here"

curl -X POST https://api.mobilesasa.com/api/v1/ussd/test-callback \
  -H "Authorization: Bearer $MOBILESASA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://example.com/ussd"
  }'
Response: 200
{
  "success": true,
  "data": {
    "ok": true,
    "status_code": 200,
    "ms": 412,
    "body": "CON Welcome\n1. Balance",
    "payload": { "sessionId": "test-…", "phoneNumber": "254700000000", "networkCode": "63902", "serviceCode": "*657*45#", "text": "" }
  }
}

ok is false when the URL is unreachable, answers with a non-2xx status, or takes longer than 10 seconds; error then says which.

A minimal handler

// express.urlencoded() is required: we post form fields, not JSON.
app.use(express.urlencoded({ extended: false }));

app.post("/ussd", (req, res) => {
  const { sessionId, phoneNumber, text } = req.body;
  const input = (text ?? "").split("*").filter(Boolean);

  res.type("text/plain");

  if (input.length === 0) {
    return res.send("CON Welcome to Mobile Sasa\n1. Check balance\n2. Talk to us");
  }
  if (input[0] === "1") {
    return res.send("END Your balance is KES 12,340. Thank you!");
  }
  return res.send("END Call us on 0700 000 000. Goodbye!");
});