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:

ArticleRequirementWhat to build
7Identify the sender and the source of the recipient's dataStore a consent source per number and print it in the message
9Functional, obvious opt-out based on words like stop, end, cancel, unsubscribe, quitOpt-out link plus keyword handling on any inbound channel
10Free, easy to use, statement in at least two languages used in RwandaBilingual opt-out label; no paid reply required
11Stop sending within 24 hours, unless the person keeps some categoriesSuppression list keyed by number and category, checked before send
12Confirm the opt-outOne confirmation message, then silence
20Keep logs including time, date and contentAppend-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 text you pass should already include the sender identity and the consent source, for example "You joined our club at Kimihurura", read from the consents table.
  • 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 optouts in one query, then call /api/messages/send-bulk with the remaining recipients and per-recipient vars.

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:

  1. collects opt-outs from every channel (call-centre CRM, two-way webhook, USSD log, email inbox) into optouts;
  2. pushes new numbers to the eSMS dashboard opt-out list (CSV import) if they are not already there;
  3. sends one confirmation SMS per new opt-out (Article 12) and sets confirmed_at;
  4. 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 sendPromo returns skipped.
  • 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

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.