The seven processes
Seven processes. Loopback inter-service traffic. nginx is the only thing listening on a public socket.core-api
The brain. Identity, jobs, payments, KYC, wallet, dashboards, and the orchestrator of every OPay call.What it owns
- Identity schema — users, sessions, OTPs, KYC records (envelope-encrypted), virtual accounts, mandates, payout destinations.
- Jobs schema — job postings, applications, attendance, reviews, escrow ledger entries.
- Payments schema — wallets, transactions, payouts, bills, audit log.
- OPay orchestration — every OPay call goes through
@guild/opay-client, wrapped by service classes in core-api (opay-mandate,opay-payout,opay-va,opay-bills,opay-sms, etc.).
Entry points
Boot sequence
1
Validate config
Zod runs against
process.env. Missing or malformed env crashes the process at boot, not at request 1,000.2
Connect to Postgres via pgbouncer
Prisma client initialised with multi-schema mode. Read replicas (when configured) are routed automatically for read-only queries.
3
Connect to Redis
For sessions, idempotency cache, and BullMQ queues.
4
Initialise OPay client
Caller-injected config (the client never reads env directly). Single axios instance with 10s timeout. Circuit breaker armed.
5
Start express server
Listen on the loopback port. Pino HTTP logger attached. Request-ID middleware emits a ULID per request.
6
Workers
BullMQ workers start for outbound notifications, webhook retries, scheduled escrow releases.
Five-layer discipline
Every endpoint follows the same shape:Scaling characteristics
- Cluster mode in PM2. Two instances by default; can scale to vCPU count.
- Stateless. No local file writes that outlive the process. Sessions in Redis, queues in Redis, file uploads in R2.
- Idempotent. Every write endpoint accepts an
Idempotency-Keyheader.
whatsapp-svc
The WhatsApp brain. Inbound message handling, the 23-intent engine, Flows, voice-note interpretation, multi-language copy.What it owns
- WhatsApp schema — conversations, messages, conversation state machines (onboarding, KYC, payment flows).
- Intent engine — deterministic intent routing across 23 intents in five languages.
- Outbound message templates — pre-approved with Meta, fired through internal endpoints.
- WhatsApp Flows — rich form-based UX inside the chat (KYC, job posting, withdrawal).
How a message flows
The 23 intents
The intent engine recognises 23 distinct conversational intents — wallet, withdraw, post a job, find work, my applications, complete profile, link bank, direct debit, buy airtime, buy data, pay electricity, link card, paid with card, link bank, cancel debit, show account, marketplace, my jobs, help, and so on. Routing is deterministic — instant classification, predictable cost, no LLM round-trip per message. When a message doesn’t match any intent, the conversation state machine takes over. If the user is mid-flow (e.g., halfway through a withdrawal), we keep them in the flow. If they’re idle, we show the home menu.Voice-note interpretation
Every voice note received in WhatsApp is dropped into the queue. Our transcription model returns text. The text runs through the intent engine the same way a typed message would. End user gets a typed reply — they spoke, the system understood, and they didn’t have to type a single character.Why a microservice and not part of core-api
Three reasons.- Meta requirements. Webhook verification has its own signature scheme. Isolating the WhatsApp endpoint from the rest of the API reduces blast radius.
- State machine complexity. Conversation state lives best near the channel that owns it. A schema migration in core-api shouldn’t ripple through WhatsApp flows.
- Independent scaling. WhatsApp traffic spikes (e.g., a marketing campaign) shouldn’t queue up against payment traffic.
voice-svc
Tola, our voice agent. Inbound calls, outbound calls, and the tool-call dispatcher that maps Tola’s intents to core-api actions.What it owns
- Voice schema — calls, transcripts (when captured), tool-call records, outbound campaign queue.
- Tool dispatcher — maps voice-agent tool calls (sign up, list jobs, get details, apply, update profile, set skills) to authenticated core-api requests on behalf of the caller.
- Outbound call orchestration — when our matching model wants to nudge a high-fit candidate, voice-svc places the call.
How an inbound call flows
The caller-resolver
Before Tola speaks, voice-svc looks up the caller’s phone number in identity. Three outcomes:Outbound calls
Voice-svc can place outbound calls too. The most common use case: when the matching model surfaces a high-fit Job Seeker for a new job (Trusted tier, strong recent activity, matching skill and location) and we want to nudge them faster than a WhatsApp message would.sms-svc
The thin bridge. Sits between core-api and our SMS gateway hardware. Doesn’t own any business logic.What it owns
- SMS schema — outbound message log, delivery receipts.
- Routing — outbound SMS request from core-api → through sms-svc → to the right backend.
Two outbound backends
sms-svc routes SMS by purpose.
The split lets us use the right tool for the right purpose. Transactional one-way uses OPay’s high-volume API. Conversational two-way uses physical SIM cards we control.
Why a separate service
sms-svc is intentionally thin. If we ever migrated SMS providers (e.g., split traffic across two SMS APIs based on delivery region), the rewrite happens here without touching core-api. See SMS infrastructure for the gateway side.ml-service
The Python service. FastAPI. Four models, each behind its own endpoint.What it owns
- ML schema — model artifacts metadata, feature snapshots for offline training, predictions log (when persisted), bandit state in Redis.
- Standing model — multi-class classifier predicting Job Seeker / trader tier (Building, Emerging, Trusted, Established) from ledger features.
- Matching model — lambdarank ranker for job ↔ Job Seeker pairs. Four factors: skill, location, language, economic context.
- Negotiation engine — Thompson sampling Beta(α, β) bandit per (segment × bucket). Online learner; posteriors update on every accept/decline.
- Transcription model — voice-note interpretation. Five languages: English, Pidgin, Yoruba, Igbo, Hausa.
Boot
FastAPI loads each model from its artifact path at startup. A boot-time feature-order sanity check confirms the loaded model’s feature schema matches what the application expects. Mismatch crashes the process so production never silently serves stale features.Why Python, not Node
The tooling. xgboost, lightgbm, scikit-learn, and the transcription ecosystem are Python-native. We use what’s mature.Why a separate service, not a Python module pulled into core-api
Process isolation. Models reload independently from API code. Heavy inference doesn’t compete with payment latency. Two different deployment cadences. See ML layer for the model deep dive.sms-server (Go SMS gateway)
A sibling process — same VPS, different runtime. Open-source (capcom6/sms-gateway), customized for our deployment.What it does
Pairs with one or more Android phones connected over USB or networked over LAN. Each Android phone has a Nigerian SIM. The Go server exposes a RESTful API on a loopback port; when sms-svc wants to send a conversational SMS, it POSTs to the gateway. The gateway dispatches via Firebase Cloud Messaging to the paired Android, which uses the SIM to send the SMS. Replies go the reverse path: SIM receives an SMS → Android relays to the gateway → gateway pushes inbound event to sms-svc → sms-svc forwards to core-api.Why hardware instead of an API
Three reasons.- Two-way deliverability. Nigerian SMS APIs are excellent for one-way transactional traffic; two-way conversational is messier. Owning the SIM gives us guaranteed two-way.
- Cost. Per-message cost on a SIM card is fractional. At scale, this beats any third-party SMS API for two-way traffic.
- Direct relationships with carriers. Each SIM is on MTN, GLO, Airtel, or 9Mobile. We control the rotation. We control the deliverability footprint.
sip-server (Asterisk)
A second sibling process. Asterisk on TLS 5061, dedicated to WhatsApp Calling.What it does
When a Guild user opens our WhatsApp contact and taps the call button, Meta’s WhatsApp Calling infrastructure initiates a SIP INVITE tosip.guild.com.ng:5061. Asterisk accepts the TLS call, transcodes Meta’s Opus codec to G.711 PCMU (the format our voice runtime accepts), and bridges to Tola via SDES-SRTP.
Why Asterisk specifically
We tried three alternatives before settling on Asterisk.
The path to “this works”:
- Meta dials Asterisk over TLS 5061 with Opus.
- Asterisk’s
chan_pjsipaccepts and identifies the endpoint by source IP. - Asterisk transcodes Opus to PCMU (drops Meta’s tricky Opus fmtp params).
- Asterisk bridges to the voice runtime via SDES-SRTP.
- Tola is on the other side.
Why this matters
WhatsApp Calling lands directly on Tola. A user doesn’t need to dial a separate number; they tap the call button in their existing WhatsApp chat with Guild and they’re talking to her. Most platforms don’t have this. We do. See SIP infrastructure for the deeper protocol detail.Shared @guild/* packages
Every service consumes from the same package set. Pinned versions across the monorepo.
Sixteen packages. Each one solves a problem so the four engineers building features don’t reinvent it.
What to read next
OPay integration
The 23 wrapped surfaces in detail. The reconcile guard. The five demo paths.
ML layer
Four models. Why each has the shape it does.
Infrastructure
VPS, CloudPanel, nginx, PM2, deploy.
Inter-service comms
HMAC svc-auth in depth.