Skip to main content
Here’s the thing most platforms get wrong about SMS in Nigeria. Outbound transactional SMS — OTPs, balance alerts, payment confirmations — works great through a high-volume API. OPay’s SMS API is one of those. Set sender ID, fire-and-forget, sub-second delivery, predictable cost. Two-way conversational SMS — “Reply 1 to apply, 0 to skip” — is messier. Inbound deliverability depends on the carrier, the sender’s SIM, the destination route. Most third-party SMS APIs are excellent at outbound, mediocre at inbound. Reply delivery rates of 70-80% are common. Guild owns the SIMs. We have a 99%+ two-way deliverability rate because the SIM is in our hand. This page is how that works.

The system

Dashed lines are the reply path. Solid lines are the outbound path.

The routing decision

When sms-svc receives a send request, it routes by purpose.
The split is decided at the call site by the calling service. sms-svc enforces no override.

The Go gateway (sms-server)

Open-source codebase from capcom6/sms-gateway, deployed as a sibling process on the same VPS as our other services. The Go binary is small, fast, and battle-tested.

What it exposes

A RESTful API on a loopback port. The endpoints we use:
  • POST /api/messages — enqueue a new outbound message for delivery via a paired phone.
  • GET /api/messages/:id — check delivery status.
  • POST /api/webhook/inbound — (we register this) called when a reply arrives at a paired phone.
The gateway maintains a queue per phone. When a phone is online, the queue drains; when a phone is briefly offline, messages wait (FIFO).

How it talks to the phones

Firebase Cloud Messaging (FCM). The Go server, when it wants to dispatch an outbound, pushes a notification to the target Android phone via FCM. The phone receives the FCM, the Guild SMS app on the phone wakes up, and the SMS is sent through the SIM’s regular cellular SMS channel. Why FCM and not a direct connection: phones go in and out of network. FCM is reliable for waking phones up regardless of their app state. Battery and data usage are acceptable.

Inbound replies

A reply lands at the SIM. The Android SMS subsystem fires an intent. The Guild SMS app catches the intent, reads the SMS, posts it back to the Go server via the phone’s own internet connection (Wi-Fi when available, mobile data when not). The Go server fires a webhook to sms-svc. Inbound is asynchronous and reliable. The bottleneck is the phone’s network reachability, not the gateway’s.

The hardware

Paired Android phones. Currently four, one per carrier. Each phone:
  • Mid-range Android (e.g., Infinix Hot, Tecno Spark). Cost: under ₦150,000 each.
  • One Nigerian SIM card from the respective carrier.
  • Connected to the VPS host via USB hub for power and reliable network egress.
  • Wi-Fi to the local network for internet (mobile data as fallback).
  • Guild SMS app installed (we publish it as a private APK).
Adding capacity is mechanical: buy another phone, get another SIM, plug it in, register it in the gateway admin. No deploy required.

Why hardware ownership, not a third-party API

This is the question infrastructure judges ask first. The cost advantage compounds at scale. At 100K conversational SMS per month, the per-message-cost delta alone covers a phone. At 1M per month, it covers a small team to maintain the gateway. The deliverability advantage matters for the user experience. A Job Seeker who replies “YES” to a job alert and never gets through is a Job Seeker who doesn’t apply. Owning the SIM closes that gap.

Carrier rotation

The four-phone setup gives us one SIM per major Nigerian carrier (MTN, GLO, Airtel, 9Mobile). The gateway picks the outbound SIM by:
  1. Recipient’s carrier preference. If we know the recipient is on MTN (from their phone number’s prefix), we send from the MTN SIM. Intra-network SMS is faster, cheaper, and more reliable.
  2. Sender SIM health. A SIM with elevated bounce rate or temporary delivery slowdown rotates out automatically; we’d send from a different carrier.
  3. Load balancing. No single SIM exhausts its daily quota.
The carrier prefix lookup table is small (Nigerian mobile prefixes are well-defined). The routing decision is sub-millisecond.

What’s in an SMS

The shape is intentionally minimal. Two examples:

Job alert (conversational, replyExpected=true)

Guild: 5 bricklayer jobs in Bodija, ₦4k/day, 3 days. Reply 1 to apply, 0 to skip.
128 bytes. Single SMS. Reply expected within a few minutes; we ping back if no reply in 30 minutes.

Balance alert (transactional, replyExpected=false)

₦12,000 added by Adekunle Ogundimu. Balance: ₦12,000.
84 bytes. Single SMS. No reply expected; if the user replies anyway, the inbound is treated as a generic message and routed to the intent engine. The content is short on purpose. Most Nigerian carriers charge per 160-character SMS segment; multi-segment messages cost more and arrive less reliably. We compose to fit in one segment.

Reply handling

When a reply lands at sms-svc via the gateway webhook:
1

Verify gateway signature

The gateway signs its inbound webhook with a shared secret. We verify with timing-safe comparison before processing.
2

Resolve sender phone to a user

Lookup in identity. If unknown phone, this is a stray SMS to one of our numbers — log and drop.
3

Resolve conversation state

Did we send an SMS expecting a reply? If the user is mid-conversation (e.g., we asked them a question via SMS), we have state to honour.
4

Route to handler

For job alerts: parse “1” or “0”, apply or skip. For onboarding flow: advance the state machine. For ambient messages: route to the intent engine the same way a WhatsApp message would.
5

Send reply if applicable

Most flows respond. A “1” to a job alert gets a confirmation: “Applied to Adekunle’s bricklayer job. He’ll get back to you today.”
The state machine for SMS-based conversation is the same engine the WhatsApp service uses. The channel is different; the conversation model is shared. This is where @guild/messaging-core earns its keep.

Operational concerns

What this enables

Three concrete product moments that wouldn’t be possible without owning the SMS rails.
  1. A Job Seeker without WhatsApp gets work. They sign up by voice, receive job alerts by SMS, and reply with a single digit to apply. The whole loop works on a feature phone.
  2. An employer’s foreman marks attendance from the field. Reply-based attendance confirmation when WhatsApp isn’t reliable on the site. We send “Mark Musa present on Day 2? Reply YES or NO.” They reply. Attendance row written.
  3. A trader at Oje Market gets daily summaries by SMS. Some traders don’t open WhatsApp daily but read SMS constantly. The summary lands at 8 PM, they see the day’s total, they reply with a question if they have one.
These are the everyday moments where the platform meets the user where they actually are.

SIP infrastructure

The other unusual rail — WhatsApp Calling bridged to Tola.

Channels

The product-level SMS page.

Inter-service comms

How sms-svc talks to core-api.