DuroClient, implemented twice: HttpDuroAdapter (real Duro over HTTPS) and MockDuroAdapter (deterministic, offline). DuroService selects between them at boot from DURO_MODE and re-exports the interface plus webhook verification. This is the entire integration surface; there is no Duro logic anywhere else in the codebase.
The client interface
DuroClient (src/duro/types.ts) is the contract Quill programs against:
DuroService implements the same interface by delegating to the active adapter, and adds the non-DuroClient helpers used by other modules: vasPurchase, vasLookup, listBanks, resolveAccount, verifyWebhookSignature, and isMock. Source: src/duro/duro.service.ts.
Mode selection: mock vs live
mock(DURO_MODE=mock, the default) is a real offline dev mode: it returns deterministic objects with no network, socreateCheckoutpoints at the Quill frontend’s/checkout/:reference,getCheckoutSessionimmediately reportscompleted, andcreatePayoutreturnspaid. This is what lets Quill run fully offline and reproducibly. Source:src/duro/adapters/mock-duro.adapter.ts.liveconstructsHttpDuroAdapterwith the Duro API URL, ansk_testsecret key, and apk_testpublishable key. Config keys:DURO_API_URL,DURO_SECRET_KEY,DURO_PUBLIC_KEY,DURO_WEBHOOK_SECRET(src/config/configuration.ts).
live mode Quill drives the real Duro HTTP integration, pointed at Duro’s sandbox with test keys, so the two products run against each other end to end with no real funds moved. Duro itself selects its own test (Nomba sandbox) or live (Nomba production) environment by the key prefix on the Duro side.
The HTTP adapter’s calls
HttpDuroAdapter is an axios instance with baseURL = DURO_API_URL, a 15s timeout, and an x-api-key header carrying the secret key. It maps Quill’s kobo-based inputs onto Duro’s request bodies and normalises Duro’s { ok, data } envelope back into Quill’s types. Source: src/duro/adapters/http-duro.adapter.ts.
Two adapter details worth noting:
ensurePlanmaps Quill’sinterval: 'monthly'to Duro’smonth(viaINTERVAL_MAP), and reads the planamountin kobo straight from Duro’s response.createPayoutnormalises Duro’s payoutstatus(completed/paid/processing/pending/failed) throughPAYOUT_STATUS_MAP, defaulting unknown values toprocessing.
Idempotency on every POST
Every mutating Duro call carries anIdempotency-Key header, and the keys are derived from Quill’s own references, not random per attempt. That is deliberate: if Quill retries subscribe for the same publication, it produces the same chk_{reference} key, so Duro replays the original checkout session instead of opening a second one.
- Deterministic keys: customer (
cus_{userId}via reference), plan (pln_{publicationId}), checkout (chk_{QSUB reference}), payout (pout_{QPO reference}), cancel (cnl_{subId}). These are stable across retries of the same intent. - Timestamped keys: pause/resume use
..._{id}_{Date.now()}, because pausing then resuming then pausing again are distinct intents that must each be honoured.
Ensuring the customer and plan
Before opening a checkout,subscribe() guarantees Duro has both a plan and a customer, caching the plan id so it is created once per publication (src/subscriptions/subscriptions.service.ts):
The customer is keyed to Quill’s user.id as the merchant reference, so a returning reader resolves to the same Duro customer across publications. The checkout carries metadata (quillReference, publicationId, subscriberId) and a callbackUrl back to Quill’s /checkout/{reference} page.
The webhook receiver
Duro is the source of truth for subscription state, so the durable path into Quill is the webhook. The receiver is a@Public() controller at POST /api/v1/webhooks/duro (src/webhooks/webhooks.controller.ts).
Signature verification (HMAC over t.body)
verifyWebhookSignature parses the duro-signature header into t (timestamp) and v1 (hex HMAC), rejects anything older than a 300s tolerance, recomputes HMAC-SHA256(webhookSecret, ), and compares with timingSafeEqual. This is exactly Duro’s outbound signing scheme. The raw body is read from RawBodyRequest (main.ts enables rawBody: true) so the signed bytes are the exact bytes Duro hashed. In mock mode, verification short-circuits to true. Source: src/duro/duro.service.ts.
Duro’s outbound signature header is
Duro-Signature; Quill reads it case-insensitively as duro-signature. The signed payload is t + "." + rawBody, and the secret is Quill’s DURO_WEBHOOK_SECRET (the endpoint secret shown once when the webhook is registered in the Duro dashboard).Idempotent processing and event routing
Every event is inserted intowebhook_events keyed by a unique eventId; a duplicate insert throws and is swallowed, so redelivered events are acknowledged but not reprocessed. Fresh events route by type:
The full set of consumed types is enumerated as
DuroWebhookType in src/duro/types.ts. Correlation is by Duro id: subscription events by duroSubscriptionId, payout events by duroPayoutId.
Simulating events
Becausemock mode does not emit real webhooks, the controller also exposes POST /api/v1/webhooks/duro/simulate (guarded to mock-only). It builds a synthetic DuroWebhookEvent (buildSimulatedEvent) and runs it through the same handleEvent path, so the activation/recovery flow can be demonstrated without a live Duro emitting events. The frontend surfaces this as API_ROUTES.webhooks.simulate.
Money-out and bills go through Duro too
DuroService also fronts the non-subscription money flows, all with the same x-api-key / idempotency discipline:
- Payouts call
createPayout(POST /payouts); bank lists come fromlistBanks(GET /payouts/banks, falling back to a bundled Nigerian bank list) and name resolution fromresolveAccount(POST /payouts/banks/resolve). - VAS calls
vasPurchase(POST /vas/{category}) andvasLookup(POST /vas/{kind}/lookup), again idempotency-keyed off the Quill reference.