eSMS AfricaeSMS Africa
API ReferenceSMS APITwo-Way SMS

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.

EventWhen
message.inboundA message for your keyword arrived (including STOP, START and HELP, flagged in action).
two_way.opt_outA phone opted out of your keyword (scope: "service") or of the whole code (scope: "global").
two_way.opt_inA 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
  }
}
FieldTypeDescription
eventstringmessage.inbound.
id / message_idstringUnique ID of the inbound message (the same value). De-duplicate retries on it.
service_idintegerYour keyword's service ID.
keywordstring | nullYour keyword (null on a dedicated code without one).
code / tostringThe short code that was texted.
fromstringThe customer's number in international format. Reply to this.
textstringThe message. With strip_keyword on, keyword matches arrive without the keyword.
raw_textstringExactly what the customer sent.
body_without_keywordstringThe text with your keyword and its separator removed ("CLINIC: 2" gives "2"). For affinity and dedicated matches, the whole text.
received_atstringWhen eSMS received it (ISO 8601, UTC).
conversation_idintegerThe thread between this phone and your keyword on this code. Stable across messages; key session state on it.
matched_bystringkeyword (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).
actionstring | nullnull for a normal message. opted_out (STOP), opted_in (START) or help (HELP) for compliance words, which the platform has already answered.
opt_outbooleantrue when action is opted_out.
segmentsintegerBillable segments of the inbound message.
billedboolean | nulltrue 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_wayobjectservice_id, number (the code), keyword, type (shared or dedicated) and toll_free.

Examples by situation

Customer sendstextbody_without_keywordmatched_byaction
CLINICCLINIC""keywordnull
clinic: 2clinic: 22keywordnull
2 (inside your window)22affinitynull
CLINIC STOPCLINIC STOPSTOPkeywordopted_out
HELP (inside your window)HELPHELPaffinityhelp
STOP (bare, you had talked to this phone)STOPSTOPaffinityopted_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 }
}
FieldDescription
eventtwo_way.opt_out or two_way.opt_in.
idUnique event ID (evt_...).
message_idThe inbound message that caused it.
phone / fromThe phone that opted out or in.
scopeservice: 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_atWhen it happened (ISO 8601, UTC).
conversation_id, service_id, keyword, code, two_wayAs 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

HeaderValue
Content-Typeapplication/json
X-Webhook-Signaturesha256=<hex>: HMAC-SHA256 of the raw request body, keyed with your account signing secret.
X-Webhook-IDA unique ID for this delivery attempt (evt_...).
X-Esms-Environmentsandbox 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 2xx within 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-webhook

Posts 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-deliveries

Every 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.

On this page