eSMS AfricaeSMS Africa
API ReferenceSMS API

Two-Way SMS

Pricing, services, inbound messages, replies from your short code, keyword checks and the message.inbound webhook.

Overview

The Two-Way SMS API manages short code services - dedicated or shared (keyword), standard rated or toll free - and lets you read inbound messages and reply from your short code. Inbound messages are pushed to your backend with a signed message.inbound webhook. For the product overview, commercials and onboarding, see the Two-Way SMS guide.

MethodPathDescription
GET/api/two-way/pricingPer-country two-way pricing (public, no auth)
GET/api/two-way/servicesList your services
POST/api/two-way/servicesRequest a service
GET/api/two-way/services/{id}Get a service
PATCH/api/two-way/services/{id}Update a service
POST/api/two-way/services/{id}/cancelCancel a service
GET/api/two-way/services/{id}/messagesList messages
POST/api/two-way/services/{id}/replyReply from the short code
GET/api/two-way/keywords/checkCheck keyword availability

Every path is also served under /v1/two-way/... (for example GET https://sms.esmsafrica.io/v1/two-way/services) - the two prefixes are identical.

Authentication

Every endpoint except GET /pricing needs a signed-in dashboard session or an API key from Developers → API Keys:

Authorization: Bearer esms_live_your_api_key
EndpointsAPI key scope
GET /services, GET /services/{id}, GET /services/{id}/messages, GET /keywords/checktwo_way or query
POST /services, PATCH /services/{id}, POST /services/{id}/canceltwo_way
POST /services/{id}/replytwo_way or send

New API keys get send, query and webhooks by default, so they can already read services and reply from a short code. To request or change services over the API, use a key that also has the two_way scope.

A key without the required scope gets 403 insufficient_scope. Requests on a suspended account get 403 account_suspended.

Get pricing

GET https://sms.esmsafrica.io/api/two-way/pricing

Public - no authentication. Cached for up to 5 minutes. Returns every country where Two-Way SMS is offered, in USD.

Per-SMS prices follow each country's Basic SMS rate (the USD per-SMS figure shown as Basic on esmsafrica.io/pricing) unless a custom two-way price is set for that country. inbound_price_usd and outbound_price_usd are always the effective prices you are charged; price_source says where they come from. Prices in the examples on this page are illustrative - read live values from this endpoint.

Response

{
  "currency": "USD",
  "countries": [
    {
      "country_code": "NG",
      "country_name": "Nigeria",
      "setup_fee_usd": 100,
      "monthly_fee_usd": 50,
      "shared_keyword_monthly_fee_usd": 50,
      "sms_basic_price_usd": 0.0185,
      "price_source": "basic",
      "inbound_price_usd": 0.0185,
      "outbound_price_usd": 0.0185,
      "toll_free_available": false,
      "toll_free_inbound_price_usd": null,
      "networks": ["MTN", "Airtel", "Glo", "9mobile"],
      "is_available": true
    }
  ]
}
FieldTypeDescription
country_codestringISO 3166-1 alpha-2 code.
country_namestringDisplay name.
setup_fee_usdnumberOne-time setup fee, charged on approval.
monthly_fee_usdnumberMonthly maintenance for a dedicated code, VAT inclusive. Charged on approval, then every 30 days.
shared_keyword_monthly_fee_usdnumberMonthly fee for a keyword on a shared code.
sms_basic_price_usdnumber | nullThe country's Basic SMS rate in USD per SMS. null when the country has no SMS route yet.
price_sourcestring"basic" - per-SMS prices follow the Basic SMS rate. "custom" - a custom two-way price is set for this country.
inbound_price_usdnumber | nullEffective price per incoming SMS, charged to the service owner (custom price, or the Basic SMS rate).
outbound_price_usdnumber | nullEffective price per outgoing reply SMS, per segment (custom price, or the Basic SMS rate). If your account has a per-customer SMS price for the country, replies use that instead.
toll_free_availablebooleanWhether toll free short codes are offered in this country.
toll_free_inbound_price_usdnumber | nullPrice per incoming SMS on a toll free code. null means the same as inbound_price_usd.
networksstring[]Mobile networks served.
is_availablebooleanWhether new services can be requested in this country.

A country with no Basic SMS rate and no custom price returns null per-SMS prices and is_available: false.

Example

curl https://sms.esmsafrica.io/api/two-way/pricing

The service object

{
  "id": 42,
  "status": "active",
  "country_code": "NG",
  "type": "shared",
  "keyword": "JOIN",
  "toll_free": false,
  "number": "32811",
  "use_case": "marketing",
  "company_name": "Acme Foods Ltd",
  "webhook_url": "https://example.com/hooks/two-way",
  "auto_reply_enabled": true,
  "auto_reply_text": "Thanks! You are on the Acme list. Reply STOP to opt out.",
  "forward_email": "[email protected]",
  "setup_fee_usd": 100,
  "monthly_fee_usd": 50,
  "inbound_price_usd": 0.0185,
  "outbound_price_usd": 0.0185,
  "next_billing_at": "2026-11-07T09:00:00+00:00",
  "created_at": "2026-10-06T14:12:00+00:00"
}
FieldDescription
idService ID used in /services/{id} paths.
statuspending (under review), active, past_due (monthly fee unpaid, retried daily), suspended (inbound stored but not forwarded), rejected or cancelled.
typededicated or shared.
keywordYour keyword on a shared code (null for dedicated).
toll_freetrue for a toll free code - you pay for incoming and outgoing SMS; the end user pays nothing.
numberThe short code assigned to the service. null until approved.
webhook_urlWhere message.inbound events for this service are posted. When null, events go to your account mo_url from Webhooks.
auto_reply_enabled / auto_reply_textAutomatic reply sent from the short code to every inbound message.
forward_emailOptional address that receives a copy of each inbound message.
*_usdThe prices that apply to this service. Per-SMS prices are the effective prices (the country's Basic SMS rate unless a custom two-way price is set).

List services

GET https://sms.esmsafrica.io/api/two-way/services

Scope: two_way or query. Returns all your services, newest first.

{
  "services": [
    { "id": 42, "status": "active", "country_code": "NG", "type": "shared", "keyword": "JOIN", "number": "32811", "...": "..." }
  ]
}
curl https://sms.esmsafrica.io/api/two-way/services \
  -H "Authorization: Bearer esms_live_your_api_key"

Request a service

POST https://sms.esmsafrica.io/api/two-way/services

Scope: two_way. Creates a service request in pending status for review. Nothing is charged now - the setup fee and first monthly fee are taken from your wallet when the request is approved, and a rejected request costs nothing.

Request body

{
  "country_code": "NG",
  "type": "shared",
  "keyword": "JOIN",
  "toll_free": false,
  "use_case": "marketing",
  "company_name": "Acme Foods Ltd",
  "kyc_document_urls": [
    "https://files.acme.example/kyc/certificate-of-incorporation.pdf",
    "https://files.acme.example/kyc/director-id.pdf"
  ],
  "webhook_url": "https://example.com/hooks/two-way"
}
FieldTypeRequiredDescription
country_codestringYesISO alpha-2 code of a country where is_available is true.
typestringYesdedicated or shared.
keywordstringFor sharedOne word, letters and digits only. Check it first with keyword availability.
toll_freebooleanNoRequest a toll free code (default false). Only where toll_free_available is true.
use_casestringYesWhat the service is for, e.g. survey, marketing, support, with any detail you can add.
company_namestringYesRegistered business name.
kyc_document_urlsstring[]YesLinks to your KYC documents (business registration, tax registration, director ID, authorisation letter).
webhook_urlstringNoHTTPS endpoint for message.inbound events. Can be set later.

Response (201)

The new service object with status: "pending" and number: null.

Examples

curl -X POST https://sms.esmsafrica.io/api/two-way/services \
  -H "Authorization: Bearer esms_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "country_code": "NG",
    "type": "shared",
    "keyword": "JOIN",
    "toll_free": false,
    "use_case": "marketing",
    "company_name": "Acme Foods Ltd",
    "kyc_document_urls": ["https://files.acme.example/kyc/certificate-of-incorporation.pdf"],
    "webhook_url": "https://example.com/hooks/two-way"
  }'

Get a service

GET https://sms.esmsafrica.io/api/two-way/services/{id}

Scope: two_way or query. Returns the service object. 404 if the service does not exist or is not yours.

curl https://sms.esmsafrica.io/api/two-way/services/42 \
  -H "Authorization: Bearer esms_live_your_api_key"

Update a service

PATCH https://sms.esmsafrica.io/api/two-way/services/{id}

Scope: two_way. Send only the fields you want to change.

FieldTypeDescription
webhook_urlstring | nullHTTPS endpoint for message.inbound events. null falls back to your account mo_url.
auto_reply_textstringText of the automatic reply sent from the short code.
auto_reply_enabledbooleanTurn the automatic reply on or off.
forward_emailstring | nullEmail address that receives a copy of each inbound message. null turns it off.
curl -X PATCH https://sms.esmsafrica.io/api/two-way/services/42 \
  -H "Authorization: Bearer esms_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://example.com/hooks/two-way",
    "auto_reply_enabled": true,
    "auto_reply_text": "Thanks! We got your message and will reply shortly."
  }'

Returns the updated service object. Webhook URLs must be publicly reachable - private, loopback and metadata addresses are refused.

Cancel a service

POST https://sms.esmsafrica.io/api/two-way/services/{id}/cancel

Scope: two_way. Cancels a pending request or an active service. Future monthly charges stop and the short code or keyword is released. Returns the service object with status: "cancelled".

curl -X POST https://sms.esmsafrica.io/api/two-way/services/42/cancel \
  -H "Authorization: Bearer esms_live_your_api_key"

List messages

GET https://sms.esmsafrica.io/api/two-way/services/{id}/messages

Scope: two_way or query. Inbound messages received on the service and the replies you sent from it, newest first. Every inbound message is stored - including ones received while the service was suspended or your balance was too low (billed: false).

ParameterTypeDescription
limitintegerMax messages to return.

Response

{
  "messages": [
    {
      "id": "mo_def456",
      "direction": "inbound",
      "from": "+2348031234567",
      "to": "32811",
      "text": "JOIN promo",
      "keyword": "JOIN",
      "phone": "+2348031234567",
      "status": "received",
      "billed": true,
      "cost": 0.0185,
      "auto_reply": false,
      "created_at": "2026-10-07T10:15:02+00:00"
    },
    {
      "id": "msg_abc123",
      "direction": "outbound",
      "from": "32811",
      "to": "+2348031234567",
      "text": "Welcome to Acme deals! Reply STOP to opt out.",
      "keyword": null,
      "phone": "+2348031234567",
      "status": "delivered",
      "billed": null,
      "cost": 0.0185,
      "auto_reply": false,
      "created_at": "2026-10-07T10:15:04+00:00"
    }
  ],
  "total": 2
}

Examples

curl "https://sms.esmsafrica.io/api/two-way/services/42/messages?limit=50" \
  -H "Authorization: Bearer esms_live_your_api_key"

Reply from the short code

POST https://sms.esmsafrica.io/api/two-way/services/{id}/reply

Scope: two_way or send. Sends an SMS from the service's short code to a recipient. Billed per segment at the service's outgoing rate (outbound_price_usd - the country's Basic SMS rate unless a custom two-way price is set, or your per-customer SMS price for the country if you have one). Recipients on your opt-out list are not messaged.

Request body

{
  "to": "+2348031234567",
  "text": "Welcome to Acme deals! Reply STOP to opt out."
}
FieldTypeRequiredDescription
tostringYesRecipient in international format. Must be in the service's country.
textstringYesMessage content. 160 GSM-7 / 70 Unicode characters per segment.

Response

{
  "id": "msg_abc123",
  "status": "submitted",
  "segments": 1,
  "cost": 0.0185,
  "currency": "USD",
  "balance_after": 41.27
}

Delivery reports for replies arrive on your dlr_url like any other message - see Webhooks.

Examples

curl -X POST https://sms.esmsafrica.io/api/two-way/services/42/reply \
  -H "Authorization: Bearer esms_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+2348031234567", "text": "Welcome to Acme deals! Reply STOP to opt out." }'

Check keyword availability

GET https://sms.esmsafrica.io/api/two-way/keywords/check?number={number}&keyword={keyword}

Scope: two_way or query. Checks whether a keyword is free on a shared short code before you request it. Matching is case-insensitive.

ParameterTypeRequiredDescription
numberstringNoThe shared short code, e.g. 32811. Pass this or country_code.
country_codestringNoCheck the keyword across the shared codes in a country, e.g. NG.
keywordstringYesThe keyword you want, e.g. JOIN.
{
  "keyword": "JOIN",
  "available": true,
  "reason": null
}
curl "https://sms.esmsafrica.io/api/two-way/keywords/check?number=32811&keyword=JOIN" \
  -H "Authorization: Bearer esms_live_your_api_key"

Inbound webhook

When a user texts your short code, eSMS posts a message.inbound event to the service's webhook_url (or, if it has none, your account mo_url from Webhooks). Two-way events carry a two_way block that identifies the service the message arrived on.

{
  "event": "message.inbound",
  "message_id": "mo_def456",
  "from": "+2348031234567",
  "to": "32811",
  "text": "JOIN promo",
  "keyword": null,
  "opt_out": false,
  "two_way": {
    "service_id": 42,
    "number": "32811",
    "keyword": "JOIN",
    "type": "shared",
    "toll_free": false
  }
}
FieldDescription
message_idUnique ID of the inbound message. Use it to de-duplicate retries.
fromThe sender's phone number in international format.
toThe short code that was texted.
textThe full message text.
keyword / opt_outSet when the message is a STOP/START keyword - see Opt-outs.
two_way.service_idYour service ID - use it in /services/{id}/reply.
two_way.numberThe short code of the service.
two_way.keywordThe matched keyword on a shared code (null on a dedicated code).
two_way.typededicated or shared.
two_way.toll_freetrue if the message arrived on a toll free code.

Headers

HeaderDescription
X-Webhook-IDUnique event ID (evt_...).
X-Webhook-Signaturesha256=<hex> HMAC-SHA256 of the raw request body, keyed by your signing_secret.
Content-Typeapplication/json.

The signing_secret is the same one used for all your webhooks - read it with GET /api/webhooks and rotate it with POST /api/webhooks/rotate-secret.

Verifying the signature and replying

Compute HMAC-SHA256 over the raw request body with your signing_secret, compare it in constant time to X-Webhook-Signature, then reply from the short code.

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.ESMS_WEBHOOK_SECRET;
const API_KEY = process.env.ESMS_API_KEY;

function verify(raw, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
  const a = Buffer.from(header || "");
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/hooks/two-way", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(401);
  res.sendStatus(200); // acknowledge fast, work after

  const event = JSON.parse(req.body.toString());
  if (event.event !== "message.inbound" || !event.two_way || event.opt_out) return;

  await fetch(`https://sms.esmsafrica.io/api/two-way/services/${event.two_way.service_id}/reply`, {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ to: event.from, text: "Thanks! You are subscribed." }),
  });
});

app.listen(3000);

Delivery & retries

Respond with a 2xx status within a few seconds and do slow work in the background. If your endpoint fails or times out, the event is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours. Each attempt is listed in your webhook delivery log (event type mo). Retries reuse the same message_id, so de-duplicate on it.

If a service is suspended (monthly fee unpaid for 7 days), inbound messages are still stored and readable with List messages, but not posted to your webhook until the service is active again.

Errors

Errors use the standard error envelope:

{
  "error": {
    "code": "conflict",
    "message": "Keyword JOIN is already taken on 32811",
    "request_id": "3f9a1c2b4d5e6f708192a3b4c5d6e7f8"
  }
}
HTTPcodeWhen it happens
400bad_requestUnknown or unavailable country, missing keyword on a shared request, toll free not offered, invalid number.
401unauthorizedMissing or invalid API key or session.
402insufficient_balanceThe wallet cannot cover a reply (required, available in details).
403insufficient_scopeThe API key lacks the scope this endpoint needs (two_way, or query/send where listed).
403account_suspendedThe account is suspended.
403recipient_opted_outThe reply recipient is on your STOP list.
404not_foundThe service does not exist or is not yours.
409conflictThe keyword is taken on that number, or the service cannot change from its current status (for example replying on a service that is not active).
422validation_errorThe request body failed validation (details.errors lists the fields).
429rate_limitedToo many requests - retry with backoff.

On this page