Skip to main content
If you read one architecture page, make it this one. Guild’s value proposition turns on OPay. The receipts that lenders trust, the direct-debit rails that let employers commit funds before work starts, the payouts that put money in a worker’s bank in seven seconds — every one of those depends on OPay being the spine. The next pages document exactly how we wrap that spine.

The 23 surfaces

The five green tiles fire in every payment story you’ll read on the docs site. The amber tile (verifyTransaction) is the reconcile guard’s authoritative call — it doesn’t fire in the user story but it fires in the security story, every time. The other 17 cover card linking, direct-charge fallbacks, bills, and reconciliation. Wired today. Not all demoed live, but all in production behaviour.

Why a wrapper instead of raw axios calls

We could have called OPay directly from each service. We don’t. The wrapper exists for five concrete reasons. The package never reads process.env directly. Configuration is caller-injected. This makes it testable, makes it swappable, and makes it impossible for one service’s OPay credentials to leak into another’s process.

The reconcile guard (defence-in-depth on webhooks)

Most payment platforms verify webhook signatures and credit the wallet. That’s correct, until the day the webhook secret leaks. Then an attacker can forge any payload they want. We added a second check. Hold on — this is where the architecture earns its keep 👀. What this gives us: even if an attacker holds our webhook secret, they cannot fake a successful payment. To get money credited, OPay itself must confirm the transaction at our re-verification call. OPay’s authoritative endpoint is the source of truth, and we never trust the webhook body alone. This is the difference between “we validate signatures” and “we will not credit anything we cannot independently verify.” Subtle. Material.

Three signature schemes we accept

OPay has a few signature schemes in the wild. Our middleware accepts all three. The middleware tries schemes in order, returning success on the first match. Every comparison uses crypto.timingSafeEqual — no early-return string compare anywhere. A constant-time check defends against timing oracles. If none of the schemes verify, we return 401 and write an audit row. We do not silently swallow.

Idempotency, everywhere

Every OPay-bound action carries a ULID idempotency key. Two layers:
  1. Redis fast-path. The first place we check. Sub-millisecond.
  2. Postgres durable store. Survives Redis flushes. Source of truth.
A retried request that arrives within minutes hits Redis and returns the cached response without ever touching OPay. A retry that arrives hours later (Redis evicted, Postgres still has it) gets the same response from Postgres. A retry that finds neither falls through and executes — but this only happens for cold-cache states beyond our retention window. This means we will never double-debit an employer, double-credit a Job Seeker, or fire a duplicate OPay call for the same logical action. The platform is end-to-end idempotent.

No retries inside the wrapper

Deliberate choice. The OPay client does not retry on failure. The wrapper classifies errors as TRANSIENT or PERMANENT. Callers decide. Some retry with backoff (requestPayout on a TRANSIENT error from a OPay blip). Some don’t (a PERMANENT validation error on a malformed mandate request should not be retried — it’ll fail the same way). We do retries at the caller layer because the right retry behaviour depends on the operation. The wrapper stays clean.

Circuit breakers

Around every OPay call. Built on opossum. When the breaker trips, the wrapper returns a TRANSIENT error immediately without ever hitting OPay. This gives us a fast-fail path during OPay outages and prevents thundering-herd retries from making the situation worse. For read-only endpoints (getTransaction, lookupPayoutAccount), the breaker has a cached-response fallback — if the breaker is open and a recent successful response is in cache, we serve it. This is the demo-resilience play: during the live walkthrough, even if OPay sandbox burps for a few seconds, our read paths stay functional.

The five demo-critical surfaces in product context

The other 18 in product context

The OPay service classes in core-api

The wrapper alone doesn’t capture business logic. Each OPay surface has a service class in core-api that wraps it with our specific semantics. Each service class is owned by the opay-integration code path. Changes here are reviewed jointly between the payments engineer and the integration engineer.

A concrete example: the escrow debit flow

When an employer posts a job, ₦60,000 needs to leave their Access Bank account and land in escrow inside seconds. Here is exactly what happens at every layer. Notice the layers of safety:
  1. The dashboard’s request is signed by @guild/svc-client HMAC. Replays in transit are caught.
  2. Idempotency check at core-api. Replays from a retried browser fetch are caught.
  3. OPay’s response is validated before we mark anything in the ledger.
  4. Webhook signature is verified when it arrives.
  5. Reconcile guard runs before we mark the escrow funded.
  6. The escrow audit row is append-only. Nobody can edit it after.
Six checks. Every transaction. End to end.

What we don’t do (and why)

  • No retries inside the OPay client. Callers own retry policy.
  • No USSD inbound. Only ussdDebit for outbound. We don’t accept USSD as a user-facing channel.
  • No card storage outside OPay’s tokenization. We never see raw card numbers. OPay’s saved-token IDs are the only thing we hold.
  • No fee markup on OPay transactions. What OPay charges us is what we charge through. (At v1; future product tiers may differ.)
  • No multi-account sweep. A wallet has one balance, one source. Sweep flows are future product, not platform.

Inter-service comms

The HMAC layer that signs every internal call.

Resilience & scale

Circuit breakers, demo-resilience tooling, the partition path.

ML layer

The other half of the spine — the data product that reads the receipts.

Webhook security

The security page with the full signature spec.