Skip to main content
Everything Quill does with money goes through one interface, 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, so createCheckout points at the Quill frontend’s /checkout/:reference, getCheckoutSession immediately reports completed, and createPayout returns paid. This is what lets Quill run fully offline and reproducibly. Source: src/duro/adapters/mock-duro.adapter.ts.
  • live constructs HttpDuroAdapter with the Duro API URL, an sk_test secret key, and a pk_test publishable key. Config keys: DURO_API_URL, DURO_SECRET_KEY, DURO_PUBLIC_KEY, DURO_WEBHOOK_SECRET (src/config/configuration.ts).
In 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:
  • ensurePlan maps Quill’s interval: 'monthly' to Duro’s month (via INTERVAL_MAP), and reads the plan amount in kobo straight from Duro’s response.
  • createPayout normalises Duro’s payout status (completed/paid/processing/pending/failed) through PAYOUT_STATUS_MAP, defaulting unknown values to processing.

Idempotency on every POST

Every mutating Duro call carries an Idempotency-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.
This matches Duro’s documented idempotency contract, where keys are scoped per tenant and a repeated key replays the stored response.

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, t.{t}.), 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 into webhook_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

Because mock 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 from listBanks (GET /payouts/banks, falling back to a bundled Nigerian bank list) and name resolution from resolveAccount (POST /payouts/banks/resolve).
  • VAS calls vasPurchase (POST /vas/{category}) and vasLookup (POST /vas/{kind}/lookup), again idempotency-keyed off the Quill reference.
Both are covered in Payouts & VAS. Next: Subscriptions & Recovery, the lifecycle these calls drive.