> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Duro Integration

> The single seam between Quill and Duro: a DuroClient interface, an HTTP adapter that calls Duro's public REST API with idempotency keys, and a signed webhook receiver.

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:

```ts theme={null}
interface DuroClient {
  findCustomerByRef(reference: string): Promise<DuroCustomer | null>;
  createCustomer(input: DuroCustomerInput): Promise<DuroCustomer>;
  ensurePlan(input: DuroPlanInput): Promise<DuroPlan>;
  createCheckout(input: DuroCheckoutInput): Promise<DuroCheckoutSession>;
  getCheckoutSession(id: string): Promise<DuroCheckoutStatus>;
  getSubscription(id: string): Promise<DuroSubscription>;
  cancelSubscription(id: string, atPeriodEnd?: boolean): Promise<DuroSubscription>;
  pauseSubscription(id: string): Promise<DuroSubscription>;
  resumeSubscription(id: string): Promise<DuroSubscription>;
  createPayout(input: DuroPayoutInput): Promise<DuroPayout>;
}
```

`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

```mermaid theme={null}
flowchart TD
    BOOT["DuroService constructor"] --> MODE{"config duro.mode"}
    MODE -->|"live"| HTTP["HttpDuroAdapter(apiUrl, sk_test, pk_test)"]
    MODE -->|"mock (default)"| MOCK["MockDuroAdapter(frontendUrl, pk_test)"]
    HTTP --> REAL["real Duro sandbox<br/>x-api-key: sk_test_…"]
    MOCK --> DET["deterministic responses<br/>checkout auto-completes"]
```

* **`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`.

| `DuroClient` method      | HTTP call                                                                             | Idempotency-Key    |
| ------------------------ | ------------------------------------------------------------------------------------- | ------------------ |
| `findCustomerByRef(ref)` | `GET /customers/ref/{ref}` (404 → `null`)                                             | none (GET)         |
| `createCustomer(input)`  | `POST /customers` `{ email, name, merchantRef }`                                      | `cus_{reference}`  |
| `ensurePlan(input)`      | `POST /plans` `{ name, amount, currency, interval }`                                  | `pln_{reference}`  |
| `createCheckout(input)`  | `POST /checkout/sessions` `{ planId, currency, customerEmail, successUrl, metadata }` | `chk_{reference}`  |
| `getCheckoutSession(id)` | `GET /checkout/sessions/{id}`                                                         | none (GET)         |
| `getSubscription(id)`    | `GET /subscriptions/{id}`                                                             | none (GET)         |
| `cancelSubscription(id)` | `POST /subscriptions/{id}/cancel` `{ atPeriodEnd }`                                   | `cnl_{id}`         |
| `pauseSubscription(id)`  | `POST /subscriptions/{id}/pause`                                                      | `pause_{id}_{ts}`  |
| `resumeSubscription(id)` | `POST /subscriptions/{id}/resume`                                                     | `resume_{id}_{ts}` |
| `createPayout(input)`    | `POST /payouts` `{ amount, bankCode, accountNumber, accountName }`                    | `pout_{reference}` |

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.

```mermaid theme={null}
flowchart LR
    SUB["subscribe(user, pub)"] --> REF["reference = newReference('QSUB')"]
    REF --> CHK["createCheckout → Idempotency-Key: chk_{reference}"]
    CHK --> DURO["Duro dedups on the key"]
```

* **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](/api-reference/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`):

```mermaid theme={null}
flowchart TD
    SUB["subscribe(user, publication)"] --> PLAN{"publication.duroPlanId set?"}
    PLAN -->|"no"| ENSURE["ensurePlan({ name, amountKobo, currency, monthly })"]
    ENSURE --> SAVE["publications.setDuroPlanId(id)"]
    PLAN -->|"yes"| CUST
    SAVE --> CUST{"findCustomerByRef(user.id)"}
    CUST -->|"found"| CK["createCheckout(...)"]
    CUST -->|"null"| CREATE["createCustomer({ email, name, ref: user.id })"]
    CREATE --> CK
    CK --> ROW["persist sub (pending) with<br/>duroCustomerId + duroCheckoutId"]
```

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`).

```mermaid theme={null}
flowchart TD
    DURO["Duro POSTs signed event"] --> RAW["read req.rawBody"]
    RAW --> VER{"verifyWebhookSignature(raw, duro-signature)"}
    VER -->|"invalid"| U401["401 Unauthorized"]
    VER -->|"valid (or mock)"| REC["record event by eventId<br/>(unique → dedup)"]
    REC -->|"duplicate"| ACK["{ received: true }"]
    REC -->|"fresh"| ROUTE["switch(event.type)"]
    ROUTE --> RECON["reconcile subscription / payout"]
    RECON --> MARK["set webhook_events.processedAt"]
```

### 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}.${rawBody}`)`, and compares with `timingSafeEqual`. This is exactly Duro's outbound [signing scheme](/webhooks/delivery). 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`.

<Note>
  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).
</Note>

### 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`:

| Duro event                                                                                       | Quill action                                                                               |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| `subscription_payment_success`, `subscription_payment_recovered`                                 | record a ledger charge (recovered ones tagged `Subscription recovery`), advance the period |
| `subscription_activated`, `checkout_completed`, `subscription_recovered`, `subscription_resumed` | `grantActive` → set `active`, sync period dates                                            |
| `subscription_payment_failed`, `subscription_past_due`                                           | `markPastDue` → set `past_due`                                                             |
| `subscription_paused`                                                                            | `markPaused` → set `paused`                                                                |
| `subscription_cancelled`, `subscription_expired`, `subscription_unpaid`                          | `markCanceled` → set `canceled`                                                            |
| `payout_completed`                                                                               | `payouts.markStatusByDuroPayoutId(id, 'paid')`                                             |
| `payout_failed`                                                                                  | `payouts.markStatusByDuroPayoutId(id, 'failed')`                                           |

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](/quill/payouts-vas). Next: [Subscriptions & Recovery](/quill/subscriptions-recovery), the lifecycle these calls drive.
