The system
Dashed lines are the reply path. Solid lines are the outbound path.The routing decision
Whensms-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 fromcapcom6/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.
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 tosms-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).
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:- 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.
- Sender SIM health. A SIM with elevated bounce rate or temporary delivery slowdown rotates out automatically; we’d send from a different carrier.
- Load balancing. No single SIM exhausts its daily quota.
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.”
@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.- 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.
- 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.
- 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.
What to read next
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.