Receiving messages
Every VAS flow starts the same way: a subscriber texts a keyword to your
shortcode, and we POST it to your webhook.
The inbound payload
Section titled “The inbound payload”{ "type": "sms", "telco": "mtn", "from": "2348020000000", "to": "48974", "message": "games28", "product_id": "103", "session_id": "67543uhn3", "sent_date": "2026-07-14 23:50:37"}| Field | Meaning |
|---|---|
type | sms for VAS shortcode traffic, two-way sms for conversational two-way. Branch on this. |
telco | Originating network — mtn, airtel, glo, 9mobile. |
from | The subscriber’s MSISDN. |
to | Your shortcode. |
message | What they typed — usually a keyword like games28. |
product_id | The product the keyword maps to. Present on VAS traffic. |
session_id | Conversation ID. Echo this on your reply. |
sent_date | When they sent it. |
Handling it
Section titled “Handling 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.'); }});Replying
Section titled “Replying”POSThttps://api.9bits.net/vas/ng/v1/service/two-way/sendsms
| Field | Required | Description |
|---|---|---|
from | ✅ | Your shortcode. |
to | ✅ | Subscriber MSISDN. Must be 10–15 digits, optionally +-prefixed. |
text | ✅ | The reply body. |
session_id | — | Echo from the inbound message to thread the conversation. |
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=67543uhn3curl -d sends form-encoded by default — which is exactly what this endpoint
wants. Don’t reach for -H "Content-Type: application/json" here.
async function reply(inbound, body) { // URLSearchParams → form-encoded. NOT JSON.stringify. const form = new URLSearchParams({ from: inbound.to, // your shortcode to: inbound.from, // the subscriber text: body, // NOT `content`, NOT `message` session_id: inbound.session_id, });
const res = await fetch( 'https://api.9bits.net/vas/ng/v1/service/two-way/sendsms', { method: 'POST', headers: { Authorization: `Bearer ${process.env.NINEBITS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded', }, body: form, }, );
if (!res.ok) { logger.error({ status: res.status }, 'VAS reply failed'); }}Note from and to swap round: their to (your shortcode) becomes your from.
def reply(inbound: dict, body: str) -> None: # `data=` sends form-encoded. Using `json=` here will fail. requests.post( "https://api.9bits.net/vas/ng/v1/service/two-way/sendsms", headers={"Authorization": f"Bearer {os.environ['NINEBITS_TOKEN']}"}, data={ "from": inbound["to"], # your shortcode "to": inbound["from"], # the subscriber "text": body, # NOT "content" / "message" "session_id": inbound["session_id"], }, timeout=10, ).raise_for_status()Response
Section titled “Response”{ "status_code": 200, "message": "Submitted to SMSC"}“Submitted to SMSC” means the operator accepted it for delivery — not that it reached the handset.
Errors
Section titled “Errors”{ "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.
Keyword routing
Section titled “Keyword routing”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.
No idempotency key
Section titled “No idempotency key”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.