On standard A2P routes in Rwanda, recipients cannot reply to your sender ID, so "reply STOP" does not work. To meet RURA's opt-out rules, give every promotional SMS a free opt-out by link, USSD or a two-way number, record the opt-out in your own suppression list, check that list before every send and stop within 24 hours. This guide shows how to build that in Node.js on top of the eSMS Africa SMS API.
Last reviewed: 27 October 2026. Code samples are illustrative; adapt them to your stack and security policies.
The requirements you are building for
RURA's Regulation No 001/R/STD-CMR/RURA/018 on unsolicited commercial communications sets out what an opt-out must do. In engineering terms:
| Article | Requirement | What to build |
|---|---|---|
| 7 | Identify the sender and the source of the recipient's data | Store a consent source per number and print it in the message |
| 9 | Functional, obvious opt-out based on words like stop, end, cancel, unsubscribe, quit | Opt-out link plus keyword handling on any inbound channel |
| 10 | Free, easy to use, statement in at least two languages used in Rwanda | Bilingual opt-out label; no paid reply required |
| 11 | Stop sending within 24 hours, unless the person keeps some categories | Suppression list keyed by number and category, checked before send |
| 12 | Confirm the opt-out | One confirmation message, then silence |
| 20 | Keep logs including time, date and content | Append-only log of sends and opt-outs |
Transactional messages such as OTPs and receipts are excluded from the regulation by Article 4, so your suppression check should apply to the promotional category, not block a member's payment receipt. For a walkthrough of each article, see Rwanda's 7pm to 7am SMS rule explained.
Why replies do not reach you
A registered alphanumeric sender ID such as KGBASKET is one-way. Compliance guides on Rwanda note that standard A2P routes do not support two-way SMS, so a "STOP" reply either fails or goes nowhere. You have three practical options, and you can combine them.
Option 1: a per-recipient opt-out link
Put a short link in every promotional message. The link carries a token that identifies the recipient without exposing their number in the URL. One tap opens a page with a single "Unsubscribe" button. This is free for the recipient (beyond their data), works on smartphones and fits in a segment with a short domain.
Option 2: a USSD opt-out menu
Rwandans already use USSD every day for MTN MoMo (*182#) and Airtel Money (*500#). If you have a USSD code, an opt-out option in the menu works on any handset, including feature phones without data. The USSD gateway calls your backend with the caller's number; you write the opt-out and return a confirmation screen.
Option 3: a two-way number
With a two-way number, recipients can text STOP and the message arrives at your webhook. eSMS Africa provides two-way numbers in Rwanda on request; STOP replies to a two-way number are recorded as opt-outs on your account. Check that replying is free or clearly priced for the recipient, since Article 10 requires the opt-out to be at no cost.
Data model
Store consent and opt-out as events, not a single boolean. You need to prove where a number came from (Article 7) and when it opted out (Article 11).
CREATE TABLE consents ( msisdn TEXT NOT NULL, -- +2507XXXXXXXX category TEXT NOT NULL, -- 'promo', 'newsletter', ... source TEXT NOT NULL, -- 'Kimihurura store signup form' collected_at TIMESTAMP NOT NULL, PRIMARY KEY (msisdn, category) ); CREATE TABLE optouts ( msisdn TEXT NOT NULL, category TEXT NOT NULL, -- 'promo' or '*' for everything optional channel TEXT NOT NULL, -- 'link', 'ussd', 'sms', 'call-centre' received_at TIMESTAMP NOT NULL, confirmed_at TIMESTAMP, PRIMARY KEY (msisdn, category) );
The opt-out link endpoint
This Express handler validates a signed token, records the opt-out and queues a confirmation. Using an HMAC-signed token means nobody can unsubscribe someone else by guessing numbers.
import express from 'express';
import crypto from 'node:crypto';
import { db } from './db.js';
const SECRET = process.env.OPTOUT_SECRET;
const app = express();
export function optoutToken(msisdn, category) {
const payload = Buffer.from(`${msisdn}|${category}`).toString('base64url');
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url').slice(0, 16);
return `${payload}.${sig}`;
}
function readToken(token) {
const [payload, sig] = String(token).split('.');
const expected = crypto.createHmac('sha256', SECRET).update(payload ?? '').digest('base64url').slice(0, 16);
if (!sig || sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const [msisdn, category] = Buffer.from(payload, 'base64url').toString().split('|');
return { msisdn, category };
}
app.post('/stop/:token', async (req, res) => {
const t = readToken(req.params.token);
if (!t) return res.status(400).send('Invalid link');
await db.query(
`INSERT INTO optouts (msisdn, category, channel, received_at)
VALUES ($1, $2, 'link', now()) ON CONFLICT DO NOTHING`,
[t.msisdn, t.category]
);
res.send('You are unsubscribed. / Vous etes desabonne.');
});
Serve the page itself on GET with a button that POSTs, so link previewers and security scanners that open URLs do not unsubscribe people by accident. Write the confirmation text on that page in two languages, and have your own team check any Kinyarwanda wording before it goes live.
Check your own list before calling the eSMS API
This is the core of the system: no promotional send leaves your application without passing the suppression check. The eSMS Africa API call itself is a single POST.
const ESMS_KEY = process.env.ESMS_API_KEY; // esms_test_... in development
async function isSuppressed(msisdn, category) {
const { rows } = await db.query(
`SELECT 1 FROM optouts WHERE msisdn = $1 AND category IN ($2, '*') LIMIT 1`,
[msisdn, category]
);
return rows.length > 0;
}
function inQuietHours(date = new Date()) {
// Rwanda is UTC+2 all year. RURA Article 15: no commercial SMS 19:00-07:00.
const hour = (date.getUTCHours() + 2) % 24;
return hour >= 19 || hour < 7;
}
export async function sendPromo(msisdn, text, category = 'promo') {
if (await isSuppressed(msisdn, category)) return { skipped: 'opted-out' };
if (inQuietHours()) return { skipped: 'quiet-hours' };
const link = `https://kgbasket.rw/s/${optoutToken(msisdn, category)}`;
const body = {
to: msisdn,
text: `${text} Opt out/Se desabonner: ${link}`,
sender_id: 'KGBASKET'
};
const res = await fetch('https://sms.esmsafrica.io/api/messages/send', {
method: 'POST',
headers: { Authorization: `Bearer ${ESMS_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`eSMS send failed: ${res.status}`);
return res.json();
}
Notes on this sample:
- The
textyou pass should already include the sender identity and the consent source, for example "You joined our club at Kimihurura", read from theconsentstable. - The long token makes the link longer. In production, store a short random ID mapped to the token, or use a short-link domain, so the message stays within 160 characters.
- For bulk campaigns, filter the recipient list against
optoutsin one query, then call/api/messages/send-bulkwith the remaining recipients and per-recipientvars.
Belt and braces: the eSMS opt-out list
eSMS Africa also keeps an opt-out list per account in the dashboard, and sends to numbers on that list are blocked. Treat this as a second safety net, not your primary record: add numbers to it when they opt out so that a campaign uploaded by hand from a spreadsheet cannot reach them either. Your own database remains the system of record for consent sources and timestamps.
The 24-hour job
Article 11 gives you 24 hours to stop. Opt-outs can arrive through channels that are not real-time: a call-centre note, a branch form, an email. Run a scheduled job at least every hour that:
- collects opt-outs from every channel (call-centre CRM, two-way webhook, USSD log, email inbox) into
optouts; - pushes new numbers to the eSMS dashboard opt-out list (CSV import) if they are not already there;
- sends one confirmation SMS per new opt-out (Article 12) and sets
confirmed_at; - alerts the team if any opt-out is older than 12 hours without confirmation.
The confirmation itself is a one-off service message. Do not include any offer in it.
Handling a two-way STOP webhook
If you add a two-way number, inbound messages arrive at your webhook. Normalise the text and match the Article 9 keywords, plus the French and any Kinyarwanda equivalents your audience uses:
const STOP_WORDS = ['stop', 'end', 'cancel', 'unsubscribe', 'quit', 'arret', 'desabonner'];
app.post('/webhooks/inbound', express.json(), async (req, res) => {
const { from, text } = req.body;
const word = String(text).trim().toLowerCase().split(/\s+/)[0];
if (STOP_WORDS.includes(word)) {
await db.query(
`INSERT INTO optouts (msisdn, category, channel, received_at)
VALUES ($1, '*', 'sms', now()) ON CONFLICT DO NOTHING`,
[from]
);
}
res.sendStatus(200);
});
Field names in the inbound payload depend on your configuration; check the two-way documentation for the exact shape. More on two-way numbers is on our two-way SMS page.
Testing
- Use an
esms_test_...key: sends are simulated without charge, so you can run the whole flow in CI. - Write a test that opts out a number, then asserts
sendPromoreturnsskipped. - Freeze the clock at 18:59 and 19:00 Kigali time to test the quiet-hours guard.
- Check that transactional sends (receipts, OTPs) bypass the promotional suppression check.
For the API reference, see the HTTP API docs and our Rwanda SMS API page. Sector examples are on the retail and e-commerce in Rwanda page.
Frequently asked questions
Is an opt-out link enough on its own?
The regulation asks for a functional, obvious, free opt-out, and does not mandate a channel. A link works for smartphone users. If many of your recipients use feature phones, add a USSD option or a phone line, and mention it in the message or on your website.
Do OTPs need an opt-out link?
No. OTPs confirm a transaction the user started, which Article 4 excludes from the regulation. Adding a link to an OTP also makes it look like marketing and can confuse users.
What counts as "two languages" for the opt-out statement?
Article 10 says at least two languages used in Rwanda. Kinyarwanda, English, French and Kiswahili are all used in Rwanda. A short bilingual label such as "Opt out/Se desabonner" is a compact way to comply.
How long should we keep opt-out records?
Keep the opt-out for as long as you hold the number, so it is never re-added by a later import. Law No 058/2021 limits how long you keep personal data, so define a retention period in your records of processing.
Sources
- RURA - Regulation No 001/R/STD-CMR/RURA/018 governing unsolicited commercial communications (PDF)
- Telerivet - Rwanda SMS compliance: A2P replies and opt-out alternatives
- DLA Piper - Data protection laws of Rwanda
- eSMS Africa - HTTP API documentation
Building SMS into a Rwandan product? Create a free eSMS Africa account, grab a test key and wire up the suppression check before your first campaign. Sample messages by sector are on our Rwanda use-case pages.


