Inbound Webhooks & Triggers: How to receive and route SMS messages with S-Mailer
Sending messages is only half of a messaging platform. The other half is what
happens when someone replies — a customer answering YES to a confirmation, an
M-Pesa payment notification landing on your number, a support request arriving at
2 a.m. Inbound webhooks are how S-Mailer hands those messages to your
application in real time.
This guide covers the payload, the routing rules, and how to wire it all up.
What is an inbound webhook?
When a message arrives on one of your senders, S-Mailer stores it and then makes
an HTTP POST to a URL you control, with the message as JSON. You don’t poll, you
don’t run a cron job — your endpoint gets called within a moment of the message
landing on the device.
That single mechanism unlocks a lot: confirm payments the instant the mobile money
SMS arrives, open a support ticket from a text message, let a customer reply
CANCEL to an order and have your backend act on it.
The payload is the same for every channel. SMS and WhatsApp today, email
tomorrow — from and to carry whatever addresses that channel uses, and a
channel field tells you how to read them. One parser handles all of them, so
adding a channel later doesn’t mean rewriting your receiver.
The webhook payload
Every delivery looks like this:
{
"body": "PAY 1500 order 4471",
"channel": "sms",
"client_id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
"from": "+258840000000",
"id": "9f2c1a7e-4b3d-4c8f-9e21-5a7b8c0d1e2f",
"payment_required": false,
"received_at": "2026-07-14T15:30:00+00:00",
"sender_id": "3c6f2b10-8d44-4a19-b7e5-2f9a1c4d6e88",
"to": "+258849999999"
}
Keys arrive alphabetically ordered, as shown. Don’t depend on it — parse by name, not by position — but that is what lands on the wire.
| Field | What it is | What you do with it |
|---|---|---|
id |
UUID of the stored inbound message | Your idempotency key — store it, and ignore a message you’ve already seen. |
from |
The address that sent the message | The customer’s number. Look up their account, reply to them. |
to |
The address that received it (your sender) | Which of your numbers it landed on — useful when you run several. |
channel |
sms, whatsapp, email, … |
Tells you how to interpret from/to and how to reply. |
body |
The message content | The part you actually parse: keywords, amounts, reference codes. |
received_at |
RFC 3339 UTC timestamp | Ordering and auditing. Don’t trust arrival order of HTTP requests — sort on this. |
sender_id |
UUID of the sender it arrived on | Route by sender when one backend serves several numbers. |
client_id |
UUID of the owning client | Your account id. Handy when one endpoint serves multiple S-Mailer accounts. |
payment_required |
true when your balance was too low to charge for the message |
The delivery is locked: from and body come through as null. See Locked messages. |
Deliberately absent: device ids, SIM slots, and other transport details. They mean nothing outside one provider, so they stay on the stored message’s metadata rather than in your contract.
Locked messages: when your balance runs out
An inbound message is never dropped over billing — but it is not handed over for free either. If a message arrives when your balance is too low to be charged for it, S-Mailer stores it in full and sends your webhook a locked notice instead of the message:
{
"body": null,
"channel": "sms",
"client_id": "017cb3ea-af74-5886-f818-b8a30743e222",
"from": null,
"id": "9f2c1a7e-4b3d-4c8f-9e21-5a7b8c0d1e2f",
"payment_required": true,
"received_at": "2026-07-14T15:30:00+00:00",
"sender_id": "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"to": "+258840000001"
}
The shape never changes. Every field is always present — a locked delivery
only nulls the ones you have not paid for, so you parse one payload and branch on
payment_required alone, never on which fields happen to exist.
from is withheld along with body: knowing who is contacting you, and how
often, is worth something on its own. to, channel and received_at describe
your own sender rather than the person who wrote to you, so they stay. The
content is safe in S-Mailer and readable in the dashboard under
Messages → Inbound, where the message is marked Locked.
To get the real delivery: top up, then hit Re-trigger on that message. S-Mailer
charges you for it and POSTs the same payload with from and body filled in and
payment_required: false. Re-triggering twice is refused, so you are never charged
twice for one message.
So a receiver needs one branch:
if (msg.payment_required) {
// Locked: the body is not here. Top up and re-trigger from the dashboard.
alertOps(`inbound ${msg.id} is locked — S-Mailer balance is empty`);
return;
}
Use cases
Payment confirmation (M-Pesa / eMola). The mobile money confirmation SMS lands
on your gateway number, your webhook parses the amount and reference out of body,
and the order is marked paid — no human refreshing a dashboard.
Customer support and ticketing. Inbound message in, ticket out. from is the
requester, body is the ticket text, received_at starts your SLA clock.
Order notifications. A customer texts STATUS 4471 and your backend replies
with the order state, using the S-Mailer send API to answer on the same number.
Two-factor and confirmations. You send “Reply YES to confirm”; the reply comes back through the webhook and your flow advances.
Multi-tenant routing. One phone number, several applications behind it. That’s what triggers are for.
Triggers: keyword routing
A webhook on your account catches everything that arrives on any of your senders. Triggers are finer-grained: a keyword plus a callback URL, attached to a specific sender.
The rule is simple: if the keyword appears anywhere in the message body, the
trigger fires. It’s a case-insensitive substring match — no prefix requirement, no
regex, no exact-match rules to fight with. Keyword pay matches PAY 1500,
Payment done, and i want to pay.
That looseness is a feature for catching real-world messages, and a trap if you pick
a keyword like a. Choose keywords distinctive enough that they can’t show up by
accident.
Shared senders
Triggers exist because one phone number can be shared between clients. Each client puts their own keywords on that shared sender, and each gets only the messages meant for them:
+258 84 999 9999 (shared sender)
|
inbound message arrives
|
+----------+----------+
| |
body contains "PAY" body contains "SUPPORT"
| |
v v
Client A's callback Client B's callback
https://a.example/pay https://b.example/tickets
A message reading PAY 1500 order 4471 goes to Client A. SUPPORT my line is
down goes to Client B. A message matching both keywords goes to both —
matching isn’t exclusive, and the first match doesn’t stop the rest.
How the two combine
For each inbound message, S-Mailer builds the list of destinations:
- Your account’s webhook URL, if you’ve set one.
- Every active trigger on the receiving sender whose keyword appears in the body.
The list is deduplicated by URL. Point your account webhook and a trigger
callback at the same URL and you get one POST, not two. Point them at different
URLs and each gets its own copy — so if both handlers write to the same database,
dedupe on id.
Setting it up in Dash
Everything lives under Dashboard → Webhooks.
That page also hosts a second field — the Status Webhook — for delivery-status updates on messages you send, described below.
Set your webhook URL. Paste an http(s) URL into the webhook field and save.
Every message arriving on any of your senders is POSTed there. Clear the field to
turn it off.
Add a trigger. Pick a sender (the dropdown lists the senders you own and the ones shared with you), type a keyword, give a callback URL, and save. You can pause a trigger without deleting it, and re-activate it later — a paused trigger never fires. One keyword per sender per client: adding the same keyword twice on the same sender is rejected.
Test it. Send an SMS containing your keyword to the sender number from any phone. During development, point the callback at a tunnel (ngrok, webhook.site) so you can see the raw request. If nothing arrives, check that the trigger is active, that the keyword really appears in the body you sent, and that your endpoint is reachable from the public internet.
Status webhooks: delivery updates on messages you send
Inbound webhooks fire on messages that arrive. The status webhook is the mirror image: it fires when a message you sent changes state — an Android gateway relaying a carrier delivery report, or WhatsApp acknowledging that a message reached the handset and was opened.
Set the default Status Webhook URL in the same place — Dashboard →
Webhooks, in its own field next to the inbound webhook. You can also override it
for a single message by passing webhook_url on the
send request. Set neither and no status webhook
fires.
The payload is its own shape — a status change, not a message:
{
"error_code": null,
"message_id": "7d3e9b21-6c44-4f18-a2e5-1b9c8d0f4a77",
"recipient": "+258840000000",
"status": { "current": "delivered", "old": "sent" },
"timestamp": "2026-07-14T15:31:04+00:00"
}
| Field | What it is |
|---|---|
message_id |
UUID of the message you sent — your correlation key back to the send call. |
recipient |
The address the update is about. |
status.old |
Status before this change (e.g. sent). |
status.current |
Status after — see the table below. |
error_code |
Why it failed, as reported by the provider. null on a delivery. |
timestamp |
When the change was recorded, RFC 3339 UTC. |
The statuses a message walks
| Status | Meaning | Channels |
|---|---|---|
sent |
Handed to the network. | all |
delivered |
Reached the recipient’s device. | all |
read |
The recipient opened it. | WhatsApp only |
failed |
The provider gave up. error_code says why. |
all |
read never arrives on SMS — the carrier has no way to know — and it only arrives
on WhatsApp when the recipient has read receipts switched on. A message that stops
at delivered is normal, not a fault.
Updates only ever move forward. Providers do not order their callbacks: WhatsApp
can hand over the delivered ack after the read one. S-Mailer drops any update
that would walk a message backwards, so you never get a webhook telling you a read
message un-read itself. failed is terminal — nothing supersedes it.
A status can fire more than once per message — one webhook per recipient per
change. Dedupe on message_id plus recipient plus status.current, not on
message_id alone.
Same handling rules as inbound: answer 2xx fast and protect the URL (it isn’t
signed). See turning an Android phone into a
gateway for where the
SMS delivery reports come from, and connecting
WhatsApp for the WhatsApp
side.
Handling a webhook
The contract is short: read the JSON, answer 2xx, do the work afterwards.
curl (to see the shape)
curl -X POST https://your-app.example/webhooks/s-mailer \
-H "Content-Type: application/json" \
-d '{
"body": "PAY 1500 order 4471",
"channel": "sms",
"client_id": "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
"from": "+258840000000",
"id": "9f2c1a7e-4b3d-4c8f-9e21-5a7b8c0d1e2f",
"payment_required": false,
"received_at": "2026-07-14T15:30:00+00:00",
"sender_id": "3c6f2b10-8d44-4a19-b7e5-2f9a1c4d6e88",
"to": "+258849999999"
}'
PHP
<?php
// POST /webhooks/s-mailer
$msg = json_decode(file_get_contents('php://input'), true);
if (!$msg || empty($msg['id'])) {
http_response_code(400);
exit;
}
// Acknowledge first — the work happens after the response.
http_response_code(204);
fastcgi_finish_request();
if (alreadyProcessed($msg['id'])) {
return; // duplicate delivery, nothing to do
}
markProcessed($msg['id']);
if (!empty($msg['payment_required'])) {
// Locked — there is no body to read. Top up, then re-trigger it.
error_log("inbound {$msg['id']} is locked — S-Mailer balance is empty");
return;
}
if (stripos($msg['body'], 'PAY') !== false) {
confirmPayment($msg['from'], $msg['body']);
}
Node.js (Express)
app.post('/webhooks/s-mailer', express.json(), async (req, res) => {
const msg = req.body;
if (!msg?.id) return res.sendStatus(400);
res.sendStatus(204); // ack immediately
if (await seen(msg.id)) return; // idempotency
await remember(msg.id);
if (msg.payment_required) {
// Locked — there is no body to read. Top up, then re-trigger it.
console.warn(`inbound ${msg.id} is locked — S-Mailer balance is empty`);
return;
}
if (msg.body.toLowerCase().includes('pay')) {
await confirmPayment(msg.from, msg.body);
}
});
Python (Flask)
@app.post("/webhooks/s-mailer")
def s_mailer_inbound():
msg = request.get_json(silent=True) or {}
if not msg.get("id"):
return "", 400
if seen(msg["id"]): # idempotency
return "", 204
remember(msg["id"])
if msg.get("payment_required"):
# Locked — there is no body to read. Top up, then re-trigger it.
app.logger.warning("inbound %s is locked — S-Mailer balance is empty", msg["id"])
return "", 204
# Hand off to a worker; keep the response fast.
queue.enqueue(process_inbound, msg)
return "", 204
Security & best practices
Answer fast, work later. S-Mailer waits 10 seconds for a response and then
gives up. Delivery is fire-and-forget: there is no retry. A slow endpoint or a
500 means that message is gone from your side — it stays stored in S-Mailer, but
your handler will not be called again. So acknowledge with a 2xx first and push
the real work onto a queue or a background task.
Deduplicate on id. Two destinations can point at the same handler, and a
proxy in front of you may replay a request. Keep a table of processed message ids
and make the second delivery a no-op.
Protect the endpoint. S-Mailer does not sign webhook requests today — the POST
carries only Content-Type: application/json, no signature header. Until it does,
your callback URL is the secret, so treat it like one:
- Serve it over HTTPS, never plain HTTP.
- Use an unguessable path or query token
(
https://your-app.example/hooks/s-mailer/8f3a…) and reject anything that doesn’t present it. - Validate the payload before acting on it — check that
client_idandsender_idare yours, and never trustbodystraight into a shell, a query, or an eval. - If your infrastructure allows it, restrict the endpoint to S-Mailer’s egress IP.
Handle payment_required. When it’s true the delivery is locked, because your
balance was too low to charge for the message (see
Locked messages). The payload keeps
its usual shape, so check the flag before you read anything — from and body
arrive as null, and code that reaches straight for body will fall over on a
null rather than a missing key. Treat it as an operational alert: log it, page
whoever owns the account, and top up. Then re-trigger the message from the
dashboard to receive it in full. Better still, set a low-balance alert under
Dashboard → Profile and never see a locked delivery at all.
Keywords are substrings. pay inside repayment will fire your trigger. Pick
keywords with enough shape to avoid accidents, and validate the full body in your
handler rather than trusting that the match was meaningful.
Controlling what your gateway forwards
When your inbound number is an Android gateway, the phone forwards the SMS it receives up to S-Mailer — which is what fires the webhooks and triggers above. But a gateway SIM also receives plenty of noise you don’t want reaching your endpoint: bank OTPs, carrier notices, marketing blasts. The gateway app lets you decide exactly what gets forwarded, so only the messages you care about ever hit your webhook.
Open the app and you’ll find the controls split across two Settings tabs.
Settings → Forwarding
- SMS Forwarding — the master switch. Off means nothing this phone receives is forwarded (it still sends normally). Leave it on to use the finer controls.
- Custom senders & short codes — most noise comes from named senders (like
VodacomorMPESA) and short codes (like1234), not real phone numbers. Turn this off and the gateway forwards only messages from an actual phone number, quietly dropping OTP and marketing traffic. Leave it on if you want those (say, a service that reads OTPs on your behalf).
Settings → Blocklist
Need to silence one specific sender while forwarding everything else? Add it in the Blocklist tab — each number or name becomes a removable chip:
+258 84 000 0000
MyBank
1234
Anything on the blocklist is never forwarded, even with the switches on. Phone
numbers match however they’re written — +258…, 0…, or the local form all match
the same entry, so you don’t have to guess the exact format.
How they combine
A received message is forwarded — and therefore reaches your webhook — only when all of these hold:
- SMS Forwarding is on, and
- the sender is not on your blocklist, and
- it’s a real phone number or “Custom senders & short codes” is on.
Tune these so your endpoint only ever sees the traffic your workflow actually needs.
Next steps
You now have the whole picture: one channel-agnostic payload, an account-wide webhook for everything, and keyword triggers for routing off shared senders.
To get started:
- Register a sender — turn an Android phone into a gateway if you don’t have one yet.
- Stand up an endpoint that answers
2xxand queues its work. - Set the webhook URL under Dashboard → Webhooks, text your number, and watch it arrive.
- Add triggers once you need to split traffic across applications or tenants.
Then close the loop: reply to inbound messages with the send API and you have a full two-way conversation running on a phone number.