Example: healthcare navigation
A numbered-menu flow on a shared short code - keyword to start, plain number replies by conversation affinity, opt-out and restart - with the webhooks each step produces and a complete handler.
This walk-through builds a patient navigation line for a fictional clinic network on a shared short code. Patients text a keyword, get a numbered menu and answer with plain numbers. Swap the keyword, the menu and the answers for your own service.
| Country | Nigeria |
| Short code | 3450 (shared, operated by eSMS Africa) |
| Keyword | CLINIC |
| What patients are told | Text CLINIC to 3450 |
Build this in the sandbox first: use an esms_test_ key for
the send calls and the phone simulator (or POST /v1/two-way/sandbox/inbound) for the
patient's side. The webhooks are identical except for "sandbox": true.
The conversation
Patient -> 3450 CLINIC
3450 -> Patient CLINIC: Welcome! Reply 1 Emergency, 2 Maternity, 3 Pharmacy, 4 Talk to a nurse. Reply CLINIC STOP to opt out.
Patient -> 3450 2
3450 -> Patient Maternity: Reply 1 to find the nearest clinic, 2 to book an antenatal visit, 0 for the main menu.
Patient -> 3450 1
3450 -> Patient Reply with your area, e.g. Yaba.
Patient -> 3450 Surulere
3450 -> Patient Nearest maternity clinic: Example Health Centre, Main Road, open 24h. Reply 0 for the main menu.Step by step
1. The patient texts the keyword
The first message must contain the keyword, because the patient has no conversation with you yet. Your webhook receives:
{
"event": "message.inbound",
"id": "b7e1c2d4-0f3a-4c5b-8d6e-7f8091a2b3c4",
"keyword": "CLINIC",
"code": "3450",
"from": "+2348012345678",
"text": "CLINIC",
"body_without_keyword": "",
"conversation_id": 981,
"matched_by": "keyword",
"action": null,
"billed": true
}(Trimmed - the full payload is on the webhook reference.)
body_without_keyword is empty, so you treat it as "show the main menu". If the patient
had texted CLINIC 2, body_without_keyword would be "2" and you could jump straight to
Maternity.
2. You send the menu
curl -X POST "https://sms.esmsafrica.io/v1/two-way/messages" \
-H "Authorization: Bearer $ESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"keyword": "CLINIC", "to": "+2348012345678", "text": "CLINIC: Welcome! Reply 1 Emergency, 2 Maternity, 3 Pharmacy, 4 Talk to a nurse. Reply CLINIC STOP to opt out."}'{
"id": "6f1d2c3b-4a59-4e7d-8c6b-5a4f3e2d1c0b",
"status": "submitted",
"segments": 1,
"cost": 0.0068,
"currency": "USD",
"balance_after": 41.27,
"service_id": 42,
"keyword": "CLINIC",
"code": "3450",
"to": "+2348012345678",
"conversation_id": 981
}This send opens the conversation window: for the next 72 hours, a message from this
phone to 3450 without a keyword comes to CLINIC (unless another keyword on 3450
texts the patient later).
3. The patient replies with a plain number
{
"event": "message.inbound",
"keyword": "CLINIC",
"code": "3450",
"from": "+2348012345678",
"text": "2",
"body_without_keyword": "2",
"conversation_id": 981,
"matched_by": "affinity",
"billed": true
}matched_by: "affinity" tells you this arrived because of your open conversation. Look up
the patient's state by conversation_id (981): they were at the main menu, so 2 means
Maternity. Each reply you send restarts the 72-hour window.
4. Free text inside a flow
Answers do not have to be numbers. When the patient sends Surulere it also arrives with
matched_by: "affinity" because the window is open. One caution: if the free text happens
to start with another business's keyword on the same code, the keyword wins and it goes to
them. Ask for answers that are unlikely to collide (an area name, a date) or prefix them
(CLINIC Surulere always reaches you).
5. The patient comes back a week later
The window has expired. A bare 3 is unmatched: it is not delivered to you or
charged, and the patient gets the code's free hint asking them to start with a keyword.
That is why the menu above ends with ways to restart, and why your printed material says
Text CLINIC to 3450. CLINIC 3 would reach you and could jump straight to Pharmacy.
6. The patient opts out
CLINIC STOP opts the patient out of your keyword. You receive message.inbound with
action: "opted_out" and a two_way.opt_out event with scope: "service"; the platform
sends the confirmation for free. A bare STOP opts them out of every keyword on 3450
(scope: "global"). Either way, further sends return 403 recipient_opted_out until the
patient texts CLINIC START or START.
7. The patient asks for HELP
HELP while the conversation is open (or CLINIC HELP at any time) is answered by the
platform with your HELP text, free. Set it to something like "CLINIC by Example Health:
patient navigation. Text CLINIC to 3450 for the menu. Reply CLINIC STOP to opt out."
Complete handler
The handler verifies the signature, acknowledges at once, ignores compliance events and
sandbox traffic in production, keeps a tiny state machine per conversation_id, and
replies from the keyword.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.ESMS_WEBHOOK_SECRET;
const API_KEY = process.env.ESMS_API_KEY; // esms_test_... while building, esms_live_... in production
const LIVE = API_KEY.startsWith("esms_live_");
const state = new Map(); // conversation_id -> step. Use a database in production.
const seen = new Set(); // event ids already handled (retries reuse the id)
const MENU = "CLINIC: Welcome! Reply 1 Emergency, 2 Maternity, 3 Pharmacy, 4 Talk to a nurse. Reply CLINIC STOP to opt out.";
function verify(raw, header) {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
const a = Buffer.from(header || ""), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
async function send(to, text) {
const res = await fetch("https://sms.esmsafrica.io/v1/two-way/messages", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keyword: "CLINIC", to, text }),
});
if (!res.ok) console.error("send failed", res.status, await res.text());
}
function next(step, input) {
const t = input.trim().toLowerCase();
if (step === "menu" || t === "0" || t === "") {
if (t === "1") return ["done", "Emergency: call 112 now or go to the nearest emergency unit. Reply 0 for the main menu."];
if (t === "2") return ["maternity", "Maternity: Reply 1 to find the nearest clinic, 2 to book an antenatal visit, 0 for the main menu."];
if (t === "3") return ["done", "Pharmacy: reply with a medicine name and we will check stock near you."];
if (t === "4") return ["done", "A nurse will call you within 30 minutes. Reply 0 for the main menu."];
return ["menu", MENU];
}
if (step === "maternity" && t === "1") return ["area", "Reply with your area, e.g. Yaba."];
if (step === "maternity" && t === "2") return ["done", "Reply with a date for your antenatal visit, e.g. Tue 14 Oct."];
if (step === "area") return ["done", `Nearest maternity clinic to ${input.trim()}: Example Health Centre, open 24h. Reply 0 for the main menu.`];
return ["menu", MENU];
}
const app = express();
app.post("/hooks/esms", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(401);
res.sendStatus(200); // acknowledge first, work after
const ev = JSON.parse(req.body.toString());
if (LIVE && ev.sandbox) return; // never act on sandbox traffic in production
if (seen.has(ev.id)) return; seen.add(ev.id);
if (ev.event !== "message.inbound" || ev.action) return; // STOP/START/HELP are handled by the platform
const step = ev.matched_by === "keyword" ? "menu" : (state.get(ev.conversation_id) || "menu");
const [nextStep, reply] = next(step, ev.body_without_keyword);
state.set(ev.conversation_id, nextStep);
send(ev.from, reply);
});
app.listen(3000);What it costs
Each patient message delivered to you and each message you send is billed per segment at
Nigeria's two-way prices (the Basic SMS price unless a custom price is set; read the live
figures from GET /v1/two-way/pricing).
The conversation above is 4 inbound and 4 outbound single-segment messages. STOP, START
and HELP, the platform's confirmations, and the unmatched hint in step 5 are free. The
keyword itself is a one-time setup fee plus a monthly fee.
Related
Two-Way SMS
Your own keyword on an eSMS Africa shared short code. Customers text your keyword, you get a signed webhook, and you reply from the same code. Self-service in 8 countries, on request everywhere else.
Sandbox & phone simulator
Test a Two-Way SMS keyword end to end on simulated phones - the same matching as live traffic, signed sandbox webhooks, request bins and a fast-forward clock. Nothing is sent and nothing is charged.