Webhooks
The message.inbound, two_way.opt_out and two_way.opt_in events - every field, headers, HMAC-SHA256 signature verification, full handlers in five languages, the 1m/5m/30m/2h/8h retry schedule, test events and the delivery log.
Two-Way SMS pushes events to your server as signed JSON POST requests.
| Event | When |
|---|---|
message.inbound | A message for your keyword arrived (including STOP, START and HELP, flagged in action). |
two_way.opt_out | A phone opted out of your keyword (scope: "service") or of the whole code (scope: "global"). |
two_way.opt_in | A phone opted back in. |
Where events go: the keyword's webhook_url if set (in the request, under Manage
in the app, or with PATCH /v1/two-way/services/{id}),
otherwise your account MO URL under Settings > Webhooks. If your account webhooks
are switched off, or there is no URL, nothing is posted and messages wait in your
inbox. A suspended keyword stores messages but posts nothing.
message.inbound
{
"event": "message.inbound",
"id": "a3c5e8f0-6b1d-4c2e-9f7a-1d2e3f4a5b6c",
"message_id": "a3c5e8f0-6b1d-4c2e-9f7a-1d2e3f4a5b6c",
"service_id": 42,
"keyword": "CLINIC",
"code": "3450",
"from": "+2348012345678",
"to": "3450",
"text": "CLINIC book a visit",
"raw_text": "CLINIC book a visit",
"body_without_keyword": "book a visit",
"received_at": "2026-10-12T09:30:00+00:00",
"conversation_id": 981,
"matched_by": "keyword",
"opt_out": false,
"action": null,
"segments": 1,
"billed": true,
"two_way": {
"service_id": 42,
"number": "3450",
"keyword": "CLINIC",
"type": "shared",
"toll_free": false
}
}| Field | Type | Description |
|---|---|---|
event | string | message.inbound. |
id / message_id | string | Unique ID of the inbound message (the same value). De-duplicate retries on it. |
service_id | integer | Your keyword's service ID. |
keyword | string | null | Your keyword (null on a dedicated code without one). |
code / to | string | The short code that was texted. |
from | string | The customer's number in international format. Reply to this. |
text | string | The message. With strip_keyword on, keyword matches arrive without the keyword. |
raw_text | string | Exactly what the customer sent. |
body_without_keyword | string | The text with your keyword and its separator removed ("CLINIC: 2" gives "2"). For affinity and dedicated matches, the whole text. |
received_at | string | When eSMS received it (ISO 8601, UTC). |
conversation_id | integer | The thread between this phone and your keyword on this code. Stable across messages; key session state on it. |
matched_by | string | keyword (first word was your keyword), affinity (plain reply inside your conversation window), dedicated (dedicated code) or admin (an unmatched message our team passed to you). |
action | string | null | null for a normal message. opted_out (STOP), opted_in (START) or help (HELP) for compliance words, which the platform has already answered. |
opt_out | boolean | true when action is opted_out. |
segments | integer | Billable segments of the inbound message. |
billed | boolean | null | true charged at your inbound price, false stored unbilled because your balance was too low (you are notified), null not billable (STOP/START/HELP, or passed to you by our team). |
two_way | object | service_id, number (the code), keyword, type (shared or dedicated) and toll_free. |
Examples by situation
| Customer sends | text | body_without_keyword | matched_by | action |
|---|---|---|---|---|
CLINIC | CLINIC | "" | keyword | null |
clinic: 2 | clinic: 2 | 2 | keyword | null |
2 (inside your window) | 2 | 2 | affinity | null |
CLINIC STOP | CLINIC STOP | STOP | keyword | opted_out |
HELP (inside your window) | HELP | HELP | affinity | help |
STOP (bare, you had talked to this phone) | STOP | STOP | affinity | opted_out |
two_way.opt_out and two_way.opt_in
Sent together with the message.inbound for the same STOP or START.
{
"event": "two_way.opt_out",
"id": "evt_5f2a9c1b7d3e4a60",
"message_id": "a3c5e8f0-6b1d-4c2e-9f7a-1d2e3f4a5b6c",
"service_id": 42,
"keyword": "CLINIC",
"code": "3450",
"phone": "+2348012345678",
"from": "+2348012345678",
"scope": "global",
"occurred_at": "2026-10-12T09:31:00+00:00",
"conversation_id": 981,
"two_way": { "service_id": 42, "number": "3450", "keyword": "CLINIC", "type": "shared", "toll_free": false }
}| Field | Description |
|---|---|
event | two_way.opt_out or two_way.opt_in. |
id | Unique event ID (evt_...). |
message_id | The inbound message that caused it. |
phone / from | The phone that opted out or in. |
scope | service: CLINIC STOP / CLINIC START (your keyword only). global: a bare STOP / START to the code (every keyword on it; sent to each keyword the phone has talked to). |
occurred_at | When it happened (ISO 8601, UTC). |
conversation_id, service_id, keyword, code, two_way | As in message.inbound. |
While a phone is opted out, sends to it from your keyword fail with
403 recipient_opted_out; you do not need to keep your own suppression list, but you may.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | sha256=<hex>: HMAC-SHA256 of the raw request body, keyed with your account signing secret. |
X-Webhook-ID | A unique ID for this delivery attempt (evt_...). |
X-Esms-Environment | sandbox on sandbox webhooks only. |
Your signing secret is shown and rotated under Settings > Webhooks (the same secret
signs every eSMS webhook on your account). The service object's webhook_signing.secret_hint
shows a masked hint of it.
Verifying the signature
Compute HMAC-SHA256 over the raw bytes of the request body (before any JSON parsing or
re-serialising), prefix sha256=, and compare with X-Webhook-Signature in constant
time. Reject the request with 401 if it does not match.
import crypto from "node:crypto";
export function verifyEsms(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header || "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Full handler examples
Each handler verifies the signature, acknowledges with 200 straight away, skips
duplicates, compliance events and (in production) sandbox traffic, and replies FROM the
keyword with POST /v1/two-way/messages.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.ESMS_WEBHOOK_SECRET;
const API_KEY = process.env.ESMS_API_KEY;
const LIVE = API_KEY.startsWith("esms_live_");
const seen = new Set(); // use your database in production
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);
}
const app = express();
app.post("/hooks/esms", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(401);
res.sendStatus(200);
const ev = JSON.parse(req.body.toString());
if ((LIVE && ev.sandbox) || seen.has(ev.id)) return;
seen.add(ev.id);
if (ev.event === "two_way.opt_out") return console.log("opted out", ev.phone, ev.scope);
if (ev.event !== "message.inbound" || ev.action) return;
await fetch("https://sms.esmsafrica.io/v1/two-way/messages", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ service_id: ev.service_id, to: ev.from, text: `You said: ${ev.body_without_keyword}` }),
});
});
app.listen(3000);Delivery and retries
- Respond with any
2xxwithin 15 seconds. Do slow work after responding. - If the request fails (timeout, connection error or an HTTP error status), the same payload is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours: six attempts in total. Retries are stored on our side and survive restarts.
- Retries carry the same body, so de-duplicate on
id. - Webhook URLs must resolve to a public address. Private, loopback and cloud-metadata addresses are blocked.
- Every attempt is recorded in the delivery log.
Send a test webhook
POST https://sms.esmsafrica.io/v1/two-way/services/{service_id}/test-webhookPosts a signed sample message.inbound (with "test": true) to the keyword's effective
webhook URL and tells you what your endpoint answered. Returns 400 no_webhook if neither
the keyword nor the account has a URL. Scope: two_way.
{ "ok": true, "status": 200, "url": "https://example.com/hooks/esms" }{ "ok": false, "status": 500, "error": "HTTP 500", "url": "https://example.com/hooks/esms" }curl -X POST "https://sms.esmsafrica.io/v1/two-way/services/42/test-webhook" \
-H "Authorization: Bearer $ESMS_API_KEY"To test full flows (keyword, affinity, STOP, HELP) with real payloads, use the sandbox instead.
Webhook delivery log
GET https://sms.esmsafrica.io/v1/two-way/services/{service_id}/webhook-deliveriesEvery attempt for this keyword's inbound and opt-out events, newest first, with the HTTP
status, error, a snippet of your response and the next scheduled retry. limit 1 to 200,
default 50. Scope: two_way or query.
{
"data": [
{
"id": 5512,
"event": "message.inbound",
"message_id": "a3c5e8f0-6b1d-4c2e-9f7a-1d2e3f4a5b6c",
"url": "https://example.com/hooks/esms",
"attempt": 2,
"success": false,
"status_code": 502,
"error": null,
"response_snippet": "Bad Gateway",
"next_retry_at": "2026-10-12T09:36:00+00:00",
"created_at": "2026-10-12T09:31:00+00:00"
}
]
}curl "https://sms.esmsafrica.io/v1/two-way/services/42/webhook-deliveries?limit=50" \
-H "Authorization: Bearer $ESMS_API_KEY"Sandbox webhooks
Webhooks from the sandbox have the same shape and signature,
plus "sandbox": true, "environment": "sandbox", "livemode": false, "billed": false,
"would_bill", and the header X-Esms-Environment: sandbox. They retry quickly (after 1
and 3 seconds) and are recorded on the sandbox message, not in the delivery log above.
Ignore them in production.
Related
Conversations
One thread per phone per keyword - list conversations with unread counts and the conversation-affinity window, read a thread, and mark it read.
Sandbox
The Two-Way SMS sandbox API - simulated phones, simulated inbound with the live matching rules and a debug record, sends to simulated phones, the sandbox clock, a sandbox webhook URL, reset and request bins.