Skip to content

Receiving messages

Every VAS flow starts the same way: a subscriber texts a keyword to your shortcode, and we POST it to your webhook.

{
"type": "sms",
"telco": "mtn",
"from": "2348020000000",
"to": "48974",
"message": "games28",
"product_id": "103",
"session_id": "67543uhn3",
"sent_date": "2026-07-14 23:50:37"
}
FieldMeaning
typesms for VAS shortcode traffic, two-way sms for conversational two-way. Branch on this.
telcoOriginating network — mtn, airtel, glo, 9mobile.
fromThe subscriber’s MSISDN.
toYour shortcode.
messageWhat they typed — usually a keyword like games28.
product_idThe product the keyword maps to. Present on VAS traffic.
session_idConversation ID. Echo this on your reply.
sent_dateWhen they sent it.
app.post('/webhooks/9bits/inbound', async (req, res) => {
const evt = req.body;
// 1. Acknowledge immediately — anything slow goes in a background job.
res.sendStatus(200);
// 2. De-duplicate. Retries mean you WILL see this twice.
const fresh = await cache.set(
`mo:${evt.session_id}:${evt.sent_date}`,
1,
{ NX: true, EX: 86400 },
);
if (!fresh) return;
// 3. Route on the keyword — trimmed and lower-cased.
const keyword = evt.message.trim().toLowerCase();
switch (keyword) {
case 'games28':
await subscribe(evt.from, evt.product_id);
await reply(evt, 'Welcome to Games Daily! Reply STOP to cancel.');
break;
case 'stop':
await unsubscribe(evt.from);
await reply(evt, 'You have been unsubscribed. Sorry to see you go.');
break;
default:
await reply(evt, 'Sorry, we did not recognise that. Send HELP for options.');
}
});

POSThttps://api.9bits.net/vas/ng/v1/service/two-way/sendsms

FieldRequiredDescription
fromYour shortcode.
toSubscriber MSISDN. Must be 10–15 digits, optionally +-prefixed.
textThe reply body.
session_idEcho from the inbound message to thread the conversation.
Terminal window
curl -X POST https://api.9bits.net/vas/ng/v1/service/two-way/sendsms \
-H "Authorization: Bearer $NINEBITS_TOKEN" \
-d from=48974 \
-d to=2348020000000 \
-d text="Welcome to Games Daily! Reply STOP to cancel." \
-d session_id=67543uhn3

curl -d sends form-encoded by default — which is exactly what this endpoint wants. Don’t reach for -H "Content-Type: application/json" here.

{
"status_code": 200,
"message": "Submitted to SMSC"
}

“Submitted to SMSC” means the operator accepted it for delivery — not that it reached the handset.

{
"status": "error",
"errors": "invalid 'to' MSISDN format: \"3131\""
}

The to MSISDN is validated before anything else. A common mistake is echoing your shortcode back into to instead of the subscriber’s number — that gets rejected immediately with a 400.

Note the error shape here (status / errors) is different from the SMS error shape (error.type / error.code). Don’t share a parser between them.

Keywords map to products in the dashboard, and product_id on the inbound payload tells you which one they hit. Route on product_id where you can — it survives a marketing rename, where a hard-coded keyword table doesn’t.

This endpoint does not support Idempotency-Key. Retrying a reply after a timeout may send it twice. Track sent replies on your side, keyed by session_id.