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:- Redis fast-path. The first place we check. Sub-millisecond.
- Postgres durable store. Survives Redis flushes. Source of truth.
No retries inside the wrapper
Deliberate choice. The OPay client does not retry on failure. The wrapper classifies errors asTRANSIENT 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 onopossum.
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:- The dashboard’s request is signed by
@guild/svc-clientHMAC. Replays in transit are caught. - Idempotency check at core-api. Replays from a retried browser fetch are caught.
- OPay’s response is validated before we mark anything in the ledger.
- Webhook signature is verified when it arrives.
- Reconcile guard runs before we mark the escrow funded.
- The escrow audit row is append-only. Nobody can edit it after.
What we don’t do (and why)
- No retries inside the OPay client. Callers own retry policy.
- No USSD inbound. Only
ussdDebitfor 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.
What to read next
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.