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

# Cancel an unfunded escrow

> Kill an unfunded hold (awaiting_funding only). No money moves. Reason is stored as cancelledReason. Webhook: escrow.cancelled. If the payer still completes an old checkout after this, that money credits the payer’s available. It does not reopen the hold. After funding, use refund instead.



## OpenAPI

````yaml /openapi.json post /escrows/{escrowId}/cancel
openapi: 3.0.0
info:
  title: Demat Escrow API
  description: >

    Learn how to integrate Demat Escrow into your app. This reference covers the
    **secret key API** your backend calls with `sk_test_` / `sk_live_`.


    > **Before you begin**

    > Create a merchant account in Demat, complete onboarding, and copy your
    **test secret key** (`sk_test_...`). Use test keys until your account is
    verified and you are ready to go live.


    The Escrow API is a RESTful, JSON API. Most routes require authentication.
    Use it to create customers, take checkout payments, lock money in a hold,
    release or refund that hold, and send payouts to a bank.


    Read [How it works](#tag/how-it-works) first. That page is the full
    lifecycle: statuses, who the money belongs to, and which webhook to wait
    for. You should not need a separate walkthrough.


    All requests must be made over **HTTPS**. Unencrypted HTTP is not supported
    in production.


    ## Base URL


    ```

    https://prod.demat.shop/api/v1

    ```


    Use this same base URL for test and live. Test vs live is selected by your
    secret key, not by changing the host.


    A test key (`sk_test_`) always uses sandbox data. A live key (`sk_live_`)
    uses live data and requires a verified merchant account that has been
    switched to live mode.


    ## Environments and API keys


    Each merchant has test and live keys. The key selects the data plane
    (sandbox vs live).


    | Environment | Secret key | Publishable key | When to use |

    | --- | --- | --- | --- |

    | Test | `sk_test_` | `pk_test_` | Integration work. Sandbox data / dummy
    providers. |

    | Live | `sk_live_` | `pk_live_` | Production. Requires a verified account
    in live mode. |


    Publishable keys (`pk_`) are for client side checkout. **Never send a `pk_`
    key as a Bearer token.** See [Authentication](#tag/authentication).


    ## HTTP methods


    | Method | Description |

    | --- | --- |

    | `GET` | Retrieve a resource or a list. |

    | `POST` | Create a resource or perform an action (release, refund, payout).
    |

    | `PATCH` | Update a resource (for example `PATCH /customers/{id}`). |

    | `DELETE` | Remove a resource (for example `DELETE /customers/{id}`). |


    ## Making requests


    Send JSON and include your secret key:


    ```

    Authorization: Bearer sk_test_your_secret_key

    Content-Type: application/json

    ```


    POST routes that create or move money also require `Idempotency-Key`. See
    [Idempotency](#tag/idempotency).


    ## Requests and responses


    Request and response bodies are JSON. **Field names are camelCase**
    (`requestId`, `customerId`, `limit`, `offset`), not snake_case.


    Successful responses use this envelope:


    ```json

    {
      "success": true,
      "data": {},
      "meta": {
        "requestId": "req_01hxyz"
      }
    }

    ```


    | Field | Type | Description |

    | --- | --- | --- |

    | `success` | boolean | `true` when the request was processed as intended.
    Combine this with the HTTP status code. |

    | `data` | object | Result of the call. A fetch returns the resource. A list
    returns a named array of records (for example `customers`) plus a
    `pagination` object. |

    | `meta.requestId` | string | Correlate logs and support requests. Echoed
    from `X-Request-Id` when you send one. |


    Failed requests use:


    ```json

    {
      "success": false,
      "error": {
        "code": "INVALID_REQUEST",
        "message": "A human readable explanation",
        "details": {}
      },
      "meta": {
        "requestId": "req_01hxyz"
      }
    }

    ```


    See [Errors](#tag/errors).


    ## Money


    Amounts are **integers in naira**. Send `200` for ₦200. Do not send kobo.


    Two customer balances matter:


    | Balance | Meaning |

    | --- | --- |

    | `available` | The customer can withdraw this, or you can lock it with
    `debitPayer: true`. |

    | `held` | Locked on one or more open escrows. Not withdrawable until
    released or refunded. |


    Release does **not** send money to a bank. It credits the payee’s
    `available`. A bank payout is `POST /withdrawals`.
  version: '1.0'
  contact: {}
servers:
  - url: '{baseUrl}'
    description: API base URL. type your host here
    variables:
      baseUrl:
        default: https://prod.demat.shop/api/v1
        description: >-
          Full base URL including /api/v1. Change this for local development if
          needed.
security: []
tags:
  - name: How it works
    description: >

      This is the product, end to end. You are the merchant. A **payer** is
      paying. A **payee** is receiving on release. Amounts are naira integers.


      Money in a hold is **ours to move by API**: not the customer’s bank.
      Release credits the payee’s `available`. A bank payout is a later `POST
      /withdrawals`.


      ## 1. Create customers


      `POST /customers` for the **payee** (and for a registered payer). You get
      identity, `walletId`, and zero balances. `account` is `null`. No webhook.


      The **payer** may instead be a **guest** on create. Pass `guest: { email,
      name?, phone? }` on the payer party. No Demat customer or wallet is
      created for guests.


      Look up NIN/BVN with `POST /identity/lookups` if you need the name. We do
      not store KYC on the customer.


      ## 2. Create the hold


      `POST /escrows` with your unique `reference`, `amount`, `currency`, and
      `parties`: one `payer`, one `payee`. Payee must use `customerId`. Payer
      uses `customerId` **or** `guest`, never both.


      Default status is `awaiting_funding`. Nothing is locked. Webhook:
      `escrow.created`.


      `debitPayer: true` locks a **registered** payer’s `available` now. Not
      allowed for guest payers. They must already have enough or the request
      fails **422** and **no hold is created**. Success: `escrow.created` then
      `escrow.funded`. Do not also pass `checkout: true`.


      Pass `checkout: true` on the same create to also return a checkout URL
      (uses the payer’s email, including guest email).


      A transfer into a NUBAN never locks this hold. To lock that money later,
      create a new hold with `debitPayer: true`.


      ## 3. Fund it (if you did not debit)


      If you did not pass `checkout: true` on create, `POST /checkout/sessions`
      with `escrowId`. Amount and currency must match. The payer opens
      `checkoutUrl` (card, bank, bank_transfer, ussd).


      A second create for the same unfunded hold returns the **same** pending
      session. Use this to retry a failed checkout or start one later. List
      sessions with `GET /checkout/sessions`.


      Wait for webhooks, not the browser callback. Paid + hold still awaiting →
      `payment.succeeded` then `escrow.funded` (status `held`). Paid after you
      cancelled → `wallet.credited` on the payer; the hold stays cancelled.


      ## 4. After it is held


      | You want | Call | Money | Webhook |

      | --- | --- | --- | --- |

      | Payee earned it | `POST /escrows/{id}/release` | Held → payee
      `available` | `escrow.released` or `escrow.partially_released` |

      | Payer gets it back | `POST /escrows/{id}/refund` | Registered: held →
      payer `available`. Guest: provider refund, then on `refund.processed`
      liability → clearing | `escrow.refunded` (after processed for guests) |

      | Deal never funded | `POST /escrows/{id}/cancel` | Nothing |
      `escrow.cancelled` |

      | Freeze while you decide | `POST /escrows/{id}/disputes` | Stays held |
      `dispute.opened` |

      | Unfreeze and move | `POST .../disputes/{id}/resolve` | Per `action` |
      `dispute.resolved` plus the money-move events |


      Omit `amount` on release/refund to move everything still held. Pass a
      smaller amount to leave the rest. You can release 250 then refund 150.


      Release is not a bank transfer. Withdraw from `available` later.


      ## 5. Pay out to a bank


      `POST /withdrawals` with `customerId`, `accountNumber`, `bankCode`,
      `amount`, and `reference`. We resolve the account and debit that
      customer’s `available`. Listen for `withdrawal.completed` or
      `withdrawal.failed`. Optional: preview the name first with `POST
      /identity/bank-accounts/verify`.


      ## Status cheat sheet


      Escrow, dispute, checkout, and withdrawal statuses are listed on the
      **escrows**, **checkout**, and **payouts** tags. Rule of thumb: if the
      hold is `disputed`, you cannot release or refund until you resolve. If it
      is `awaiting_funding`, you cannot release or refund. Cancel or wait for
      funding.
  - name: Authentication
    description: >

      All secret key requests must be authenticated. The key identifies the
      merchant and the environment (test or live).


      Use the **Bearer** scheme:


      ```

      Authorization: Bearer sk_test_your_secret_key

      ```


      Requests without a valid secret key return **401 Unauthorized**.


      ## Key types


      | Type | Prefix | Where it is used |

      | --- | --- | --- |

      | Secret | `sk_test_` / `sk_live_` | Backend calls to this API **and**
      verifying [webhooks](#tag/webhooks) (`x-demat-signature`). Keep it on the
      server only. |

      | Publishable | `pk_test_` / `pk_live_` | Frontend / checkout. Cannot call
      secret endpoints. |


      Copy your keys from Demat dashboard after you create a merchant account.
      Keep the secret key in your server environment. never in a browser or
      mobile app.


      ## Test vs live


      Use the same base URL for both. The secret key selects the data plane.


      | | Test | Live |

      | --- | --- | --- |

      | Prefix | `sk_test_` / `pk_test_` | `sk_live_` / `pk_live_` |

      | Money | Sandbox / dummy providers | Real settlements |

      | Access | Test secret key | Live secret key, after admin verification
      **and** live mode |


      A test key always uses sandbox data. A live key requires admin
      verification **and** live mode.


      ## Protect the secret key


      - Do not put `sk_` keys in frontend code, mobile apps, or git.

      - Store them in environment variables or a secrets manager.

      - Rotate a key immediately if you think it leaked.


      ## SSL


      Call the API over HTTPS. Do not disable TLS verification (`VERIFY_PEER` /
      equivalent) in production.
  - name: Fees
    description: >

      Demat adds payment provider fees and a Demat platform fee on collections
      (checkout) and transfers (withdrawals), and deducts the provider's deposit
      fee on inbound dedicated NUBAN credits. Fees are **local NGN only**.
      Amounts in this API are naira.


      ## Pass the business amount only


      For **collection** / **transfer**: send the hold, top-up, or payout amount
      you intend. Demat adds fees on top and charges `chargedAmount` to the
      customer (or debits it on withdrawal).


      For **deposit** (NUBAN inbound): pass the **gross** amount the customer
      transfers in. `netAmount` is what credits the wallet after the provider's
      deposit fee.


      If you already baked fees into `amount` for collection/transfer, Demat
      still adds fees again. Always quote with `GET /fees` first.


      ## Fee types


      | Fee | Who it is | How it is calculated |

      | --- | --- | --- |

      | `providerFee` | Payment provider (card / transfer / NUBAN deposit) | See
      bands below |

      | `platformFee` | Demat | **One quarter** of `providerFee` (`providerFee /
      4`). **0 for deposit** |

      | `merchantFee` | Optional surcharge you set on escrow or withdrawal |
      Fixed naira you pass (ignored for deposit) |


      `totalFees = providerFee + platformFee + merchantFee`  

      Collection/transfer: `chargedAmount = amount + totalFees`, `netAmount =
      amount`  

      Deposit: `chargedAmount = amount` (gross inbound), `netAmount = amount -
      providerFee`


      ## Collection (checkout)


      Provider fee on the business amount:


      - **1.5%** of amount

      - Plus **₦100** when amount is **₦2,500 or more**

      - Capped at **₦2,000** per charge

      - The ₦100 flat fee is **waived** under ₦2,500


      ### Example: fund a ₦400 hold


      1. `providerFee` = round(400 × 1.5%) = **₦6** (no ₦100 flat; under ₦2,500)

      2. `platformFee` = 6 / 4 = **₦1.5**

      3. `merchantFee` = 0 (unless you set one on the escrow)

      4. `chargedAmount` = 400 + 6 + 1.5 = **₦407.5**

      5. Checkout initializes for **₦407.5**. After success, the hold / wallet
      credit is **₦400** (`netAmount`).


      Webhook `payment.succeeded` / related events include:


      ```json

      {
        "amount": 400,
        "chargedAmount": 407.5,
        "netAmount": 400,
        "fees": {
          "providerFee": 6,
          "platformFee": 1.5,
          "merchantFee": 0,
          "totalFees": 7.5
        }
      }

      ```


      `chargedAmount` is what the payer paid. `netAmount` is what landed on the
      hold or wallet after fees.


      ## Transfer (customer withdrawal)


      Provider transfer fee bands (Nigeria):


      | Amount | Provider fee |

      | --- | --- |

      | ≤ ₦5,000 | ₦10 |

      | ₦5,001 to ₦50,000 | ₦25 |

      | > ₦50,000 | ₦50 |


      `platformFee` is still **one quarter** of that provider fee.


      ### Example: withdraw ₦400 to a bank


      1. `providerFee` = **₦10**

      2. `platformFee` = 10 / 4 = **₦2.5**

      3. Debit from customer available = **₦412.5**

      4. Bank receives **₦400**


      ## Deposit (dedicated NUBAN inbound)


      When a customer transfers into their dedicated virtual account, the
      provider charges a **deposit fee** on the gross amount before funds
      settle. Escrow credits the wallet with the **net** only (same rule as
      micropayment).


      - **1%** of the gross inbound amount

      - Capped at **₦300**

      - Demat `platformFee` = **0** (no Demat cut on NUBAN deposits)


      Prefer the fee the provider reports on the webhook when present; otherwise
      use the schedule above.


      ### Example: transfer ₦500 into a NUBAN


      1. Gross inbound = **₦500**

      2. `providerFee` = 500 × 1% = **₦5**

      3. Wallet credit (`netAmount`) = **₦495**

      4. `wallet.credited` meta includes `grossAmount`, `providerFee`, and
      `netAmount`


      ## Merchant surcharge


      Optional `merchantFee` on `POST /escrows` and `POST /withdrawals`. It is
      added into `chargedAmount`. When the payment or payout completes, that
      surcharge credits your **settlement balance**. Ignored for `type=deposit`.


      ## Quote endpoint


      `GET /fees?amount=400&type=collection`  

      `GET /fees?amount=400&type=transfer&merchantFee=10`  

      `GET /fees?amount=500&type=deposit`


      Returns the full breakdown before you create an escrow, checkout,
      withdrawal, or estimate a NUBAN credit.
  - name: Rate Limits
    description: >

      Rate limiting keeps the Escrow API stable. There is a limited number of
      requests you can make per time window for each endpoint family. Going
      beyond that limit returns **HTTP 429** with `error.code` `RATE_LIMITED`.


      Limits apply **per merchant**, with a **separate quota for test and live**
      (your `sk_test_` / `sk_live_` key). Hitting the limit on one family does
      not freeze other families.


      ## Rate limit headers


      On success and on 429, read these response headers:


      | Header | Meaning |

      | --- | --- |

      | `x-ratelimit-limit` | Max requests permitted in the current window for
      this family. |

      | `x-ratelimit-remaining` | Requests left in the current window. |

      | `x-ratelimit-reset` | Seconds until the current window ends. when you
      can retry. |


      On 429 you also get `Retry-After` (seconds), matching `x-ratelimit-reset`.


      ## Error body


      ```json

      {
        "success": false,
        "error": {
          "code": "RATE_LIMITED",
          "message": "Rate limit exceeded",
          "details": {
            "nextStep": "Wait 42 seconds and try again",
            "resetInSeconds": 42,
            "limit": 100,
            "family": "standard",
            "endpoint": "Standard secret key routes"
          }
        },
        "meta": {
          "requestId": "req_01hxyz"
        }
      }

      ```


      ## Standard limits


      Most secret key routes share the standard family.


      | Mode | Limit |

      | --- | --- |

      | Test (`sk_test_`) | **100** requests per 60 seconds |

      | Live (`sk_live_`) | **300** requests per 60 seconds |


      Test mode is for development and integration testing, not load testing.


      ## Extended limits


      These foundational routes have their own higher buckets in **live** mode
      so critical flows keep moving. In **test** mode they use the standard
      limit above.


      | Endpoint family | Live limit (per 60 seconds) |

      | --- | --- |

      | `GET /identity/banks` | **600** |

      | `POST /identity/bank-accounts/verify` | **300** |

      | `POST /checkout/sessions` | **200** |

      | Escrow writes (`POST /escrows`, release, refund, cancel, disputes,
      resolve) | **200** |

      | `POST /withdrawals` | **150** |


      ## Handling a 429


      1. Read `x-ratelimit-reset` (seconds).

      2. Pause requests to **that** endpoint family until the window ends.

      3. If you see sustained 429s across many families, pause and investigate.
      an unchecked poll loop is usually the cause.


      ## Staying within limits


      - Prefer [webhooks](#tag/webhooks) over polling `GET /escrows/{id}` after
      checkout or payout.

      - Cache stable lookups such as `GET /identity/banks`.

      - Page through lists (default 20, max 100). See
      [Pagination](#tag/pagination).

      - Use [idempotency keys](#tag/idempotency) on money moving POSTs so
      timeouts do not double-release or double-pay.


      If you need higher sustained limits for production traffic, contact Demat
      support with the endpoint family, expected peak rate, and a short
      description of your integration.
  - name: Pagination
    description: >-
      List endpoints return a page of results, not the entire collection.
      Pagination uses `limit` and `offset`, the same pattern as other Demat
      APIs.


      ## Query parameters


      | Parameter | Type | Description |

      | --- | --- | --- |

      | `limit` | integer | Page size. Default **20**, minimum **1**, maximum
      **100**. |

      | `offset` | integer | Number of records to skip. Default **0**. Use `0`,
      then `20`, then `40`, and so on. |

      | `status` | string | List filter where supported. Escrows: escrow status.
      Checkouts: session status. |

      | `customerId` | string | Escrow, checkout, wallet, and withdrawal lists.
      Escrows: payer or payee. Checkouts: that customer. Wallets: that wallet.
      Withdrawals: transfers that debit that customer. |

      | `escrowId` | string | Checkout list only. Sessions that fund this hold.
      |


      ## Response shape


      ```json

      {
        "success": true,
        "data": {
          "customers": [{ "customerId": "cus_01hxyz" }],
          "pagination": {
            "total": 42,
            "limit": 20,
            "offset": 0,
            "hasMore": true
          }
        },
        "meta": {
          "requestId": "req_01hxyz"
        }
      }

      ```


      | Field | Description |

      | --- | --- |

      | named array | The page of records. The key matches the resource:
      `customers`, `transactions`, `escrows`, `checkouts`, `wallets`,
      `withdrawals`, `bankAccounts`. |

      | `pagination.total` | Total matching records. |

      | `pagination.limit` | Page size used for this request. |

      | `pagination.offset` | How many records were skipped. |

      | `pagination.hasMore` | `true` when `offset + limit < total`. |


      ## Walking a list


      1. Call the list endpoint with `limit=20` and `offset=0` (or omit both for
      those defaults).

      2. If `pagination.hasMore` is `true`, call again with the same `limit` and
      `offset` set to the previous `offset + limit`.

      3. Stop when `hasMore` is `false`.


      Escrow list accepts `status` and `customerId`. Checkout list accepts
      `status`, `escrowId`, and `customerId`. Wallet list accepts `customerId`.
      Withdrawal list accepts `status` and `customerId`.
  - name: Errors
    description: >

      The API uses HTTP status codes plus a JSON error envelope. Always read
      both the status code and `error.code`.


      ## HTTP status codes


      | Code | Meaning |

      | --- | --- |

      | **200** | Success. |

      | **201** | A resource was created. |

      | **400** | Invalid request, failed validation, or business rule (for
      example insufficient balance). |

      | **401** | Missing or invalid secret key / credentials. |

      | **403** | Authenticated, but not allowed (live key before verification
      or live mode, or suspended account). |

      | **404** | Resource does not exist for this merchant. |

      | **409** | Conflict (duplicate reference, idempotency key reused with a
      different body, already released). |

      | **422** | Semantic validation failed (malformed payload that passed
      basic checks). |

      | **429** | Rate limited (`RATE_LIMITED`). Back off and retry. See [Rate
      Limits](#tag/rate-limits). |

      | **502** | Upstream provider error (banks, checkout provider). |


      ## Error envelope


      ```json

      {
        "success": false,
        "error": {
          "code": "INVALID_REQUEST",
          "message": "Idempotency-Key header is required for this operation",
          "details": {}
        },
        "meta": {
          "requestId": "req_01hxyz"
        }
      }

      ```


      | Field | Description |

      | --- | --- |

      | `error.code` | Stable machine readable code. Branch on this, not on the
      message text. |

      | `error.message` | Human-readable explanation. |

      | `error.details` | Optional extra fields (for example `available` and
      `requested` on insufficient balance). |

      | `meta.requestId` | Include this when you contact support. |


      ## Common codes


      | `error.code` | Typical HTTP | When it happens |

      | --- | --- | --- |

      | `UNAUTHORIZED` | 401 | No Bearer token, bad JWT, or unknown secret key.
      |

      | `FORBIDDEN` | 403 | Live key used before verification or live mode, or
      action not allowed for this account. |

      | `NOT_FOUND` | 404 | Unknown customer, escrow, wallet, or withdrawal for
      this merchant. |

      | `INVALID_REQUEST` | 400 | Missing idempotency key, bad environment, or
      other client error. |

      | `INSUFFICIENT_BALANCE` | 400 / 422 | Withdrawal larger than available,
      or `debitPayer: true` when the payer cannot cover the hold. |

      | `GUEST_REFUND_NO_PAYMENT` | 409 | Guest refund when no successful
      checkout payment exists for the hold. |

      | `GUEST_REFUND_PENDING` | 409 | Guest refund / release / dispute while
      `pendingProviderRefund` is set. |

      | `AMOUNT_MISMATCH` | 400 | Checkout or payment amount/currency does not
      match the hold or wallet. |

      | `ESCROW_NOT_PAYABLE` | 409 | Checkout or debit against a hold that is
      not `awaiting_funding`. |

      | `ESCROW_NOT_CANCELLABLE` | 409 | Cancel called after the hold was
      funded. Use refund. |

      | `ESCROW_INVALID_STATE` | 409 | Release or refund while the hold is not
      `held` / `partially_released` (including while `disputed`). |

      | `INVALID_RELEASE` / `INVALID_REFUND` | 400 | Amount exceeds remaining
      held funds. |

      | `INVALID_DISPUTE` | 409 / 400 | Dispute opened on an unfunded hold,
      already resolved, or a split that does not equal remaining held. |

      | `DISPUTE_EXISTS` | 409 | An open dispute already exists on this hold. |

      | `DUPLICATE_REFERENCE` | 409 | Escrow or withdrawal `reference` already
      used on this merchant. |

      | `CUSTOMER_HAS_ACCOUNT` | 409 | `DELETE /customers/{id}` when the
      customer already has a NUBAN. |

      | `CUSTOMER_HAS_ESCROW` | 409 | `DELETE /customers/{id}` when the customer
      is payer, payee, or party on an escrow. |

      | `BVN_NAME_MISMATCH` | 400 | BVN name does not match the customer on
      `POST /wallets`. |

      | `PROVIDER_ERROR` | 502 | Bank resolve, identity lookup, or checkout
      could not be started. |

      | `RATE_LIMITED` | 429 | Too many requests for this merchant. Back off. |

      | Duplicate / named conflicts | 409 | Email already used, or idempotency
      key reused with a different body. |


      Do not retry **400 / 401 / 403 / 404 / 422** blindly. Retry **502** (and
      timeouts) with the **same** `Idempotency-Key` on money moving POSTs.
  - name: Idempotency
    description: >-
      Money-moving `POST`s require an `Idempotency-Key` header so a retry does
      not create a second escrow, release, or payout.


      ## When it is required


      Send `Idempotency-Key` on:


      - `POST /escrows`

      - `POST /escrows/{id}/release`

      - `POST /escrows/{id}/refund`

      - `POST /escrows/{id}/cancel`

      - `POST /escrows/{id}/disputes`

      - `POST /escrows/{id}/disputes/{id}/resolve`

      - `POST /checkout/sessions`

      - `POST /wallets`

      - `POST /wallets/{walletId}/credits`

      - `POST /withdrawals`


      Omitting the header on those routes returns **400** `INVALID_REQUEST`.


      ## Behaviour


      - The key is scoped to the merchant.

      - Replaying the **same** key with the **same** method, path, and body
      returns the original response.

      - Reusing a key with a **different** body returns **409**.


      Generate a unique key per logical action (UUID is fine). Store it with the
      action in your database so timeouts can retry safely.
  - name: Webhooks
    description: >

      When something happens on Demat (an escrow is funded, a wallet is
      credited, a payout completes), we POST camelCase JSON to **your**
      `webhookUrl`.


      ## What you set up


      1. Expose any public `POST` path you want. The path is yours. Example:
      `https://example.com/hooks`. It must be `https://` on a public host (not
      localhost, not an IP, not `http`).

      2. Configure that full URL as your merchant `webhookUrl` in your Demat
      dashboard.

      3. Verify the signature with the **same secret API key** you already use
      for REST (`sk_test_...` or `sk_live_...`). There is no separate webhook
      secret. Do not use `pk_` keys.


      Customer create does not send a webhook. A simulated NUBAN credit uses the
      same `wallet.credited` event as a live inbound transfer. It does not lock
      a hold.


      ## Verify the signature


      We sign the raw body with your secret key and send the hex digest as
      `x-demat-signature`:


      ```

      HMAC-SHA512(sk_test_or_sk_live, raw_body)

      ```


      Use the raw body bytes. Do not serialize a parsed object again. Compare
      with a constant time compare.


      Every request is `Content-Type: application/json`. `x-demat-signature` is
      the HMAC. `x-demat-event` is optional and matches `body.type`.


      Respond with **2xx** within a few seconds. We time out at 5 seconds and
      retry a failed delivery **3 times**, 15 seconds apart. Deduplicate on
      `body.id` (`evt_...`).


      ```js

      const crypto = require('crypto');


      function verifyDematWebhook(secret, rawBody, signature) {
        const expected = crypto
          .createHmac('sha512', secret)
          .update(rawBody)
          .digest('hex');
        return (
          expected.length === signature.length &&
          crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
        );
      }


      app.post('/hooks', express.raw({ type: 'application/json' }), (req, res)
      => {
        const rawBody = req.body.toString('utf8');
        const signature = req.header('x-demat-signature');
        if (!signature || !verifyDematWebhook(process.env.DEMAT_SECRET_KEY, rawBody, signature)) {
          return res.status(401).end();
        }

        const event = JSON.parse(rawBody);
        res.status(200).json({ received: true });

        if (alreadySeen(event.id)) return;

        switch (event.type) {
          case 'escrow.funded':
            markOrderHeld(event.data);
            break;
          case 'wallet.credited':
            applyCredit(event.data, event.meta);
            break;
          default:
            break;
        }
      });

      ```


      ## Body


      Every delivery uses the same shape. REST responses are unchanged. This is
      webhooks only.


      `id` is the event id (ignore a replay of the same `evt_...`). `type` is
      the event name. `createdAt` is when we created it. `data` is the resource
      snapshot (same fields as the matching GET). `meta` is event-only context
      and is always present (`{}` when there is nothing extra). Amounts are
      naira. Fields are camelCase.


      `data` on escrow events matches `GET /escrows/{id}`. `data.amount` is the
      hold total. A release or refund puts this movement in
      `meta.movementAmount`.


      ```json

      {
        "id": "evt_01hxyz",
        "type": "escrow.created",
        "createdAt": "2026-09-18T12:00:00.000Z",
        "data": {},
        "meta": {}
      }

      ```


      ## Events we send


      These are the events POSTed to your `webhookUrl`. Customer create does
      **not** send one. Treat this list as the catalog. Do not infer extra
      events. Payload shapes are shown in the examples below.


      | Event | Description |

      | --- | --- |

      | `payment.pending` | Checkout session created (or reused; only the first
      create sends this). |

      | `payment.succeeded` | Checkout payment succeeded. We always book the
      money after this. |

      | `wallet.credited` | Available increased: NUBAN inbound, `customerId`
      checkout, test simulate, or a checkout success that could not fund the
      hold (cancelled / already held). |

      | `escrow.created` | Hold created. Status is still `awaiting_funding`
      unless `debitPayer` then immediately funded. |

      | `escrow.funded` | Hold locked via checkout with `escrowId`, or
      `debitPayer: true`. |

      | `escrow.partially_released` | Some held funds moved to the payee;
      `remainingAmount` > 0. |

      | `escrow.released` | Remaining held hit zero and some funds went to the
      payee. Status `released`. |

      | `escrow.refunded` | Held funds returned to the payer's available. |

      | `escrow.cancelled` | Unfunded hold cancelled. |

      | `dispute.opened` | Hold frozen. Escrow status is `disputed`. |

      | `dispute.resolved` | Dispute settled and remaining held moved. |

      | `withdrawal.created` | Payout accepted and available reserved. |

      | `withdrawal.processing` | Transfer sent to the bank. |

      | `withdrawal.completed` | Bank transfer succeeded. |

      | `withdrawal.failed` | Bank transfer failed; available restored. |


      A dispute resolve also emits the matching `escrow.released` /
      `escrow.partially_released` / `escrow.refunded` events from the money
      movement. Deduplicate on `id`.


      `escrow.refunded` always means “this refund movement happened”. Final
      escrow `data.status` may be `refunded`, `partially_released`, or
      `released` depending on what was already released.


      ## Example: `escrow.created`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "escrow.created",
        "createdAt": "2026-09-18T12:00:00.000Z",
        "data": {
          "escrowId": "esc_01hxyz",
          "reference": "order_12345",
          "status": "awaiting_funding",
          "amount": 400,
          "currency": "NGN",
          "releasedAmount": 0,
          "refundedAmount": 0,
          "remainingAmount": 400,
          "feeAmount": 0,
          "parties": [
            { "customerId": "cus_payer", "role": "payer" },
            { "customerId": "cus_payee", "role": "payee" }
          ],
          "description": "iPhone 13",
          "cancelledReason": null,
          "fundedAt": null,
          "releasedAt": null,
          "refundedAt": null
        },
        "meta": {
          "debitPayer": false
        }
      }

      ```


      ## Example: `escrow.funded`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "escrow.funded",
        "createdAt": "2026-09-18T12:05:00.000Z",
        "data": {
          "escrowId": "esc_01hxyz",
          "reference": "order_12345",
          "status": "held",
          "amount": 400,
          "currency": "NGN",
          "releasedAmount": 0,
          "refundedAmount": 0,
          "remainingAmount": 400,
          "feeAmount": 7.5,
          "merchantFee": 0,
          "parties": [
            { "customerId": "cus_payer", "role": "payer" },
            { "customerId": "cus_payee", "role": "payee" }
          ],
          "description": "iPhone 13",
          "cancelledReason": null,
          "fundedAt": "2026-09-18T12:05:00.000Z",
          "releasedAt": null,
          "refundedAt": null
        },
        "meta": {
          "source": "checkout",
          "paymentId": "pay_01hxyz",
          "providerReference": "T427271382724143",
          "chargedAmount": 407.5,
          "netAmount": 400,
          "fees": {
            "providerFee": 6,
            "platformFee": 1.5,
            "merchantFee": 0,
            "totalFees": 7.5
          }
        }
      }

      ```


      ## Example: `payment.succeeded`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "payment.succeeded",
        "createdAt": "2026-09-24T13:29:00.000Z",
        "data": {
          "paymentId": "pay_01hxyz",
          "checkoutId": "chk_01hxyz",
          "status": "succeeded",
          "amount": 400,
          "chargedAmount": 407.5,
          "netAmount": 400,
          "currency": "NGN",
          "escrowId": "esc_01hxyz",
          "walletId": null,
          "provider": "paystack",
          "paidAt": "2026-09-24T13:29:00.000Z",
          "fees": {
            "providerFee": 6,
            "platformFee": 1.5,
            "merchantFee": 0,
            "totalFees": 7.5
          }
        },
        "meta": {}
      }

      ```


      `chargedAmount` is the total the payer paid (what we sent to the
      provider). `netAmount` / `amount` is what funded the hold or wallet after
      provider, Demat platform, and optional merchant fees.


      ## Example: `escrow.released`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "escrow.released",
        "createdAt": "2026-09-18T13:00:00.000Z",
        "data": {
          "escrowId": "esc_01hxyz",
          "status": "released",
          "amount": 400,
          "releasedAmount": 400,
          "refundedAmount": 0,
          "remainingAmount": 0,
          "feeAmount": 0
        },
        "meta": {
          "movementAmount": 400
        }
      }

      ```


      ## Example: `dispute.opened`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "dispute.opened",
        "createdAt": "2026-09-18T12:30:00.000Z",
        "data": {
          "disputeId": "dsp_01hxyz",
          "escrowId": "esc_01hxyz",
          "status": "open",
          "openedBy": "payer",
          "reasonCode": "ITEM_NOT_RECEIVED",
          "description": "Package not delivered",
          "resolution": null,
          "releasedAmount": 0,
          "refundedAmount": 0
        },
        "meta": {
          "escrow": {
            "escrowId": "esc_01hxyz",
            "status": "disputed",
            "remainingAmount": 400
          }
        }
      }

      ```


      ## Example: `wallet.credited`


      ```json

      {
        "id": "evt_01hxyz",
        "type": "wallet.credited",
        "createdAt": "2026-09-18T12:00:00.000Z",
        "data": {
          "walletId": "wal_01hxyz",
          "customerId": "cus_01hxyz",
          "balances": { "available": 200, "held": 0 },
          "account": {
            "accountNumber": "0123456789",
            "accountName": "Jane Doe",
            "bankName": "Wema Bank",
            "bankCode": "035",
            "currency": "NGN"
          }
        },
        "meta": {
          "amount": 200,
          "currency": "NGN",
          "source": "nuban",
          "channel": "dedicated_nuban",
          "accountNumber": "0123456789"
        }
      }

      ```


      Do not expose `sk_test_` or `sk_live_` in client apps.
  - name: Callback URL
    description: >-
      Checkout has one merchant browser return URL: `callbackUrl`. After the
      customer pays (or leaves), they may be sent back to this page. **Money
      movement is still confirmed by webhooks**, not by this redirect. do not
      treat a visit to your callback URL as paid.


      ## How to set it


      You can set `callbackUrl` in either place:


      1. **Per checkout**: pass `callbackUrl` on `POST /checkout/sessions`, or
      on `POST /escrows` when `checkout` is true.

      2. **Account default**: set it once on your merchant account in Demat.


      If both are set, the value on the checkout request wins. If neither is
      set, the payer is not sent to a return URL you control.


      The URL must be `https://` with a public domain. not localhost, not an IP,
      not `http`. Send `null` or an empty string on the account to clear the
      default.


      Use a page you control. Wait for `payment.succeeded`, `escrow.funded`, or
      `wallet.credited` before marking an order paid.
  - name: customers
    description: >-
      A customer is a person on **your** merchant account. How you use them in
      your product is up to you. we do not assign a global buyer or seller.
      Roles exist only on each escrow (`payer` / `payee`).


      ## Create


      `POST /customers` with `firstName`, `lastName`, `email`, and `phone`.
      Email must be unique per merchant. You get identity plus:


      | Field | After create |

      | --- | --- |

      | `walletId` | Wallet row for this customer. Use on `GET
      /wallets/{walletId}`. |

      | `balances.available` | `0` |

      | `balances.held` | `0` |

      | `account` | `null` until `POST /wallets` assigns a NUBAN |


      A zero-balance wallet row is created in the background so later
      `debitPayer` or a NUBAN can attach to it. **No webhook** on customer
      create.


      ## Update


      `PATCH /customers/{customerId}` with any of `firstName`, `lastName`,
      `phone`. **Email cannot be changed.** Sending `email` returns **400**.


      ## Delete


      `DELETE /customers/{customerId}` removes the customer and their empty
      wallet row. It is allowed only when **both** are true:


      - No NUBAN. `account` is still `null`

      - No escrow. they are not `payer`, `payee`, or any other party on a hold


      Otherwise **409** `CUSTOMER_HAS_ACCOUNT` or `CUSTOMER_HAS_ESCROW`. **No
      webhook.**


      ## KYC


      We do **not** store KYC status on the customer. There is no submit-KYC
      route. Look up NIN or BVN with `POST /identity/lookups` (result is
      returned, not saved). Send `bvn` on `POST /wallets` when you provision a
      NUBAN. that verification is for the account, not a customer KYC flag.


      ## What they can do later


      - Be `payer` or `payee` on `POST /escrows`

      - Receive NUBAN credits or `customerId` checkout into `available`

      - Receive release into `available` (payee) or refund into `available`
      (payer)

      - Withdraw `available` to a verified bank account

      - Review money activity with `GET /transactions?customerId=`
    x-displayName: Customers
  - name: escrows
    description: >

      The hold is the product. `payer` is whose money is locked. `payee` is who
      receives it on release. Those roles are **per escrow**, not global on the
      customer.


      Read [How it works](#tag/how-it-works) for the full story. This page is
      the field-level reference.


      ## Escrow statuses


      | Status | Meaning | What you can do |

      | --- | --- | --- |

      | `awaiting_funding` | Hold exists. No money locked. | Fund with `POST
      /checkout/sessions` (`escrowId`) or cancel. Not release, refund, or
      dispute. |

      | `held` | Full amount locked. | Release, refund, or open a dispute. Not
      cancel. |

      | `partially_released` | Some funds already moved; `remainingAmount` > 0.
      | Release or refund the rest, or open a dispute. |

      | `disputed` | An open dispute froze the remaining held funds. | Resolve
      the dispute. Direct release/refund is rejected. |

      | `released` | Remaining held is 0, and some amount went to the payee. |
      Terminal for money movement. |

      | `refunded` | Remaining held is 0, and **all** of the amount went back to
      the payer (nothing was released). | Terminal. |

      | `cancelled` | Unfunded hold was cancelled. | Terminal. A late checkout
      success credits the payer’s `available`; it does not reopen this hold. |


      If you released 250 and refunded 150 of a 400 hold, final status is
      `released` (because some money went to the payee). `refunded` means the
      whole hold went back to the payer.


      Response fields:


      | Field | Meaning |

      | --- | --- |

      | `amount` | Original hold, naira. |

      | `releasedAmount` | Cumulative naira sent to the payee’s available
      (before fee). |

      | `refundedAmount` | Cumulative naira returned to the payer’s available. |

      | `remainingAmount` | Still locked: `amount - releasedAmount -
      refundedAmount`. |

      | `feeAmount` | Total fees at funding (provider + platform + merchant),
      naira. Checkout webhooks also include the breakdown under `meta.fees`. |

      | `cancelledReason` | Set when you cancel; otherwise `null`. |


      ## Create


      `POST /escrows` requires one `payer` and one `payee`, unique `reference`,
      `amount` in naira, `currency`. Payee must use `customerId`. Payer uses
      `customerId` or `guest` (`email` required; `name` / `phone` optional),
      never both. Guest payers do not create a Demat customer or wallet. Extra
      party roles are stored as-is.


      Default: `awaiting_funding`, webhook `escrow.created`.


      `debitPayer: true`: debit a registered payer’s `available` immediately.
      Not allowed for guests. They need a wallet with available ≥ `amount`, or
      **422** `INSUFFICIENT_BALANCE` and **no row is created**. Webhooks:
      `escrow.created` then `escrow.funded`.


      `checkout: true`: also return a checkout URL on the create response (guest
      email is used when the payer is a guest).


      A NUBAN credit does **not** fund this hold.


      ## Fund (checkout)


      `POST /checkout/sessions` with `escrowId`. See [checkout](#tag/checkout).
      Listen for `escrow.funded`. Do not pass `customerId` if you mean to lock
      this hold. That path only credits `available`.


      ## Release


      `POST /escrows/{id}/release` from `held` or `partially_released` only.


      - Omit `amount` → move all `remainingAmount` to the payee’s `available`.

      - Pass a smaller amount → partial; status becomes `partially_released` if
      anything remains.

      - This is **not** a bank payout.

      - Webhook: `escrow.released` (nothing left) or
      `escrow.partially_released`. `meta.movementAmount` is this call.


      Blocked while `disputed`.


      ## Refund


      `POST /escrows/{id}/refund` from `held` or `partially_released` only.


      - Registered payer: omit `amount` → return all remaining to the payer’s
      `available` immediately. Webhook `escrow.refunded`.

      - Guest payer: start a provider refund for the **net** held amount (fees
      are not refunded). Escrow stays held with `pendingProviderRefund` until
      provider `refund.processed`, then we reverse `payer_escrow_liability` →
      `provider_clearing` and send `escrow.refunded`. On `refund.failed` the
      pending flag clears and funds stay held. Sandbox completes immediately.
      Needs a successful checkout payment (`GUEST_REFUND_NO_PAYMENT` otherwise).

      - No Demat fee refund.

      - Not the same as cancel. Cancel is only for `awaiting_funding`.


      Blocked while `disputed` or while `pendingProviderRefund` is set.


      ## Cancel


      `POST /escrows/{id}/cancel` only when `awaiting_funding`. No ledger
      movement. `reason` is stored as `cancelledReason`. Webhook:
      `escrow.cancelled`.


      If the payer pays an old checkout after cancel, that money credits their
      `available`. The hold stays cancelled.


      ## Disputes


      A dispute is a freeze, then a decision. It is not a card chargeback.


      ### Dispute statuses


      | Status | Meaning |

      | --- | --- |

      | `open` | Hold is `disputed`. Release and refund APIs are blocked. |

      | `resolved` | You already moved the remaining held funds. `resolution` is
      `release`, `refund`, or `split`. |


      Only one `open` dispute per hold. Open only from `held` or
      `partially_released`. `openedBy` is **your** label (for example `payer`,
      `payee`, `support`). We do not verify it. `reasonCode` is yours (for
      example `ITEM_NOT_RECEIVED`).


      `GET /escrows/{id}/disputes` returns the open dispute, or the latest
      resolved one. 404 if this hold has never had a dispute.


      Webhook on open: `dispute.opened`. `data` is the dispute. `meta.escrow` is
      the escrow snapshot (`status`: `disputed`).


      ### Resolve


      `POST /escrows/{id}/disputes/{disputeId}/resolve`:


      | `action` | What happens |

      | --- | --- |

      | `release` | Remaining held → payee `available` (same rules as a normal
      release, including fees). |

      | `refund` | Remaining held → payer `available`. |

      | `split` | `releaseAmount` + `refundAmount` **must equal** remaining
      held. Part to payee, part to payer. |


      Webhook: `dispute.resolved`, plus the usual escrow money-move events.
      After resolve you cannot resolve again.


      ## What you cannot do


      | Action | Rejected when |

      | --- | --- |

      | Release / refund | `awaiting_funding`, `cancelled`, `disputed`, or
      remaining is 0 |

      | Cancel | Anything except `awaiting_funding` |

      | Dispute | Unfunded, already empty, or an open dispute exists |

      | Checkout `escrowId` | Hold is not `awaiting_funding` |
    x-displayName: Escrows
  - name: checkout
    description: >-
      Hosted checkout. The payer opens `checkoutUrl`. You confirm money with
      **webhooks**, not the browser redirect. See [Callback
      URL](#tag/callback-url).


      ## Two paths (XOR)


      | Body | Effect |

      | --- | --- |

      | `escrowId` | Fund that `awaiting_funding` hold. Amount and currency must
      match. Status becomes `held`. `escrow.funded`. |

      | `customerId` | Credit that customer’s `available`. Does **not** lock a
      hold. `wallet.credited`. |


      Do not send both.


      ## Session statuses


      | Status | Meaning |

      | --- | --- |

      | `pending` | Checkout URL is live. Not paid yet. |

      | `completed` | Payment succeeded and we booked the money. |

      | `failed` | Checkout could not be started. Create again. |

      | `expired` | Reserved. We do **not** expire a session, and we never
      reject a successful payment because of time. |


      ## Reuse (escrow funding)


      If a `pending` session with a `checkoutUrl` already exists for that
      unfunded hold, `POST /checkout/sessions` returns **that** session. No
      second charge.


      - Same `Idempotency-Key` + same body → interceptor replays the first JSON.

      - Same key + different body → **409**.

      - Different key + same `escrowId` → still the existing pending session.


      After the hold is funded or cancelled, a new create with that `escrowId`
      fails `ESCROW_NOT_PAYABLE`.


      Wallet top-ups (`customerId`) may have more than one open session.


      ## Channels and callback


      `paymentMethods`: `card`, `bank`, `bank_transfer`, `ussd`. If provided,
      that list is used for this session only. If omitted, the channels enabled
      on your Demat dashboard are used. Card is on by default. enable bank, bank
      transfer, or USSD there if you want them.


      `callbackUrl` resolution: request → merchant account → none (so the payer
      is not sent to a Demat page).


      ## After payment succeeds


      We always book the money:


      1. Hold still `awaiting_funding` → fund it (`escrow.funded`).

      2. Hold cancelled or already held → credit the payer’s `available`
      (`wallet.credited`).


      Webhooks: `payment.succeeded`, then one of the above. Do not poll.
    x-displayName: Checkout
  - name: transactions
    description: >

      Customer money activity for your merchant account. A statement view, not
      the raw double-entry ledger.


      ## List


      `GET /transactions` with optional filters:


      | Param | Use |

      | --- | --- |

      | `customerId` | Only this customer’s rows |

      | `type` | `wallet_credit`, `wallet_funded`, `escrow_funded`,
      `escrow_released`, `escrow_refunded`, `withdrawal`, `withdrawal_reversed`
      |

      | `direction` | Wallet: `in` / `out`. Escrow: `locked` (into hold) /
      `unlocked` (payer release or refund) |

      | `relatedId` | Escrow or withdrawal id |

      | `startDate` / `endDate` | `createdAt` range (ISO 8601) |

      | `limit` / `offset` | Pagination |


      Each list row includes `customerId`, `type`, `direction`, `status`,
      `amount`, and optional `relatedId`.


      `wallet_credit` is NUBAN / provider inbound funding only. On escrow
      **release**, payer and payee get different statement rows for the same
      ledger event:


      | Party | `type` | `direction` |

      | --- | --- | --- |

      | Payer (held leaves escrow) | `escrow_released` | `unlocked` |

      | Payee (available credited) | `wallet_funded` | `in` |


      ## Get one


      `GET /transactions/{transactionId}` returns the event plus an `entries`
      array. One entry per customer involved. Releases include both payer and
      payee with the types/directions above. `eventType` is the ledger-level
      event (`escrow_released`).


      ## Export


      `GET /transactions/export` with the same filters. Pass `format=csv`
      (default) or `format=pdf`. Up to 5000 rows.
    x-displayName: Transactions
  - name: wallets
    description: >-
      Every customer gets a wallet row at create time (available 0, no NUBAN).
      `walletId` is on the customer. `GET /wallets?customerId=` returns that
      wallet. `POST /wallets` attaches a virtual account.


      ## Balances


      | Field | Meaning |

      | --- | --- |

      | `balances.available` | Withdrawable. Can be locked with `debitPayer:
      true`. |

      | `balances.held` | Locked on open escrows. Not withdrawable. |

      | `account` | NUBAN details, or `null`. |


      ## Provision a NUBAN


      `POST /wallets` with `customerId` and 11-digit `bvn`. The name on the BVN
      must match the customer. If an account already exists, we return it.


      Test secret keys (`sk_test_`) get a dummy Test Bank number. Live keys get
      a real dedicated virtual account.


      ## What credits `available`


      - Live transfer into the NUBAN (`wallet.credited`, `meta.source`: `nuban`)

      - Test `POST /wallets/{walletId}/credits` (same event; test keys only)

      - Checkout with `customerId`

      - Checkout with `escrowId` after the hold was cancelled or already held

      - Refund of a held escrow


      None of the inbound rails **lock** a hold. Checkout with `escrowId` (while
      awaiting) is what funds the hold. To lock NUBAN money later, `POST
      /escrows` with `debitPayer: true`.


      ## What debits `available`


      - `debitPayer: true` on create

      - `POST /withdrawals` (customer available → a bank account you pass on the
      request)
    x-displayName: Wallets
  - name: payouts
    description: >-
      A payout is a bank transfer from a customer’s `available` balance. It is
      **not** escrow release. Release only credits available. You send the
      destination on the request. we do not store a customer bank book.


      ## Send


      `POST /withdrawals` with `Idempotency-Key`:


      | Field | Rule |

      | --- | --- |

      | `customerId` | Must exist on your account. We debit their `available`. |

      | `accountNumber` | 10-digit NUBAN. We resolve the account name on this
      call. |

      | `bankCode` | From `GET /identity/banks`. |

      | `amount` | Naira integer. Cannot exceed that customer’s available. |

      | `reference` | Unique per merchant. |

      | `currency` | Optional. Defaults to `NGN`. |


      Optional preview: `POST /identity/bank-accounts/verify`. name only,
      nothing saved.


      ### Withdrawal statuses


      | Status | Meaning | Webhook |

      | --- | --- | --- |

      | `processing` | Accepted; transfer submitted. Available already reserved.
      | `withdrawal.created`, then `withdrawal.processing` |

      | `completed` | Bank accepted the transfer. | `withdrawal.completed` |

      | `failed` | Transfer failed. Available restored. See `failureReason`. |
      `withdrawal.failed` |


      `GET /withdrawals` and `GET /withdrawals/{id}` return `last4`, `bankCode`,
      and `accountName`. not the full account number. Filter the list with
      `status` or `customerId`.


      Do not treat the create response as paid. Wait for `withdrawal.completed`.
    x-displayName: Payouts
  - name: fees
    description: >

      Quote provider and Demat fees before you charge. Pass the business amount
      for collection/transfer (fees added on top), or the gross inbound amount
      for deposit. See the Fees guide for bands and examples.
    x-displayName: Fees
  - name: identity
    description: >-
      Standalone lookups. Nothing here is attached to a customer, and we do not
      store the numbers.


      | Call | Use |

      | --- | --- |

      | `POST /identity/lookups` | `type`: `nin` or `bvn`, plus the 11-digit
      number. Returns name, optional date of birth, gender, phone, and
      `environment` (`sandbox` or `live`). |

      | `GET /identity/banks` | Nigerian banks. Cache this. Use `code` as
      `bankCode`. |

      | `POST /identity/bank-accounts/verify` | `bankCode` + 10-digit
      `accountNumber`. Returns `accountName`. Does not save the account. |


      NUBAN provisioning still verifies BVN on `POST /wallets`. Sending money
      out is `POST /withdrawals` with the destination on that request. we do not
      keep a customer bank book.
    x-displayName: Identity
paths:
  /escrows/{escrowId}/cancel:
    post:
      tags:
        - escrows
      summary: Cancel an unfunded escrow
      description: >-
        Kill an unfunded hold (awaiting_funding only). No money moves. Reason is
        stored as cancelledReason. Webhook: escrow.cancelled. If the payer still
        completes an old checkout after this, that money credits the payer’s
        available. It does not reopen the hold. After funding, use refund
        instead.
      operationId: EscrowController_cancel
      parameters:
        - name: escrowId
          required: true
          in: path
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelEscrowDto'
        description: Optional reason for the cancellation.
      responses:
        '201':
          description: Resource created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    $ref: '#/components/schemas/EscrowResponseDto'
                  meta:
                    $ref: '#/components/schemas/EnvelopeMetaDto'
        '400':
          description: Invalid request or validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponseDto'
        '401':
          description: Missing or invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponseDto'
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponseDto'
        '409':
          description: Conflict with current resource state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponseDto'
      security:
        - bearer: []
components:
  schemas:
    CancelEscrowDto:
      type: object
      properties:
        reason:
          type: string
          description: Why this unfunded escrow is being cancelled.
    EscrowResponseDto:
      type: object
      properties:
        escrowId:
          type: string
          example: esc_01hxyz
        reference:
          type: string
          example: order_12345
        status:
          type: string
          enum:
            - awaiting_funding
            - held
            - partially_released
            - released
            - refunded
            - cancelled
            - disputed
          description: >-
            awaiting_funding: nothing locked. held: full amount locked.
            partially_released: some moved, remainingAmount > 0. disputed:
            frozen until resolve. released: remaining 0 and some went to the
            payee. refunded: remaining 0 and all went back to the payer.
            cancelled: unfunded hold killed.
        amount:
          type: number
          example: 400
        currency:
          type: string
          example: NGN
        releasedAmount:
          type: number
          example: 0
        refundedAmount:
          type: number
          example: 0
        remainingAmount:
          type: number
          example: 400
        feeAmount:
          type: number
          example: 107.5
          description: >-
            Total fees charged at funding (provider + platform + merchant),
            naira. Same as meta.fees.totalFees on escrow.funded.
        merchantFee:
          type: number
          example: 100
          description: Merchant surcharge included in feeAmount (naira).
        parties:
          type: array
          items:
            $ref: '#/components/schemas/EscrowPartySummaryDto'
        description:
          type: string
          nullable: true
        cancelledReason:
          type: string
          nullable: true
        fundedAt:
          format: date-time
          type: string
          nullable: true
          example: null
        releasedAt:
          format: date-time
          type: string
          nullable: true
          example: null
        refundedAt:
          format: date-time
          type: string
          nullable: true
          example: null
        pendingProviderRefund:
          nullable: true
          description: >-
            Guest payer only. Set while a provider refund is in flight; cleared
            on refund.processed or refund.failed. Ledger and escrow.refunded
            wait for processed.
          example: null
          type: object
          properties:
            amount:
              type: number
            providerReference:
              type: string
      required:
        - escrowId
        - reference
        - status
        - amount
        - currency
        - releasedAmount
        - refundedAmount
        - remainingAmount
        - feeAmount
        - merchantFee
        - parties
    EnvelopeMetaDto:
      type: object
      properties:
        requestId:
          type: string
          example: req_01hxyz
      required:
        - requestId
    ErrorResponseDto:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          $ref: '#/components/schemas/ErrorBodyDto'
        meta:
          $ref: '#/components/schemas/EnvelopeMetaDto'
      required:
        - success
        - error
        - meta
    EscrowPartySummaryDto:
      type: object
      properties:
        customerId:
          type: string
          nullable: true
          example: cus_01hxyz
          description: Present for registered parties. Absent for guest payers.
        role:
          type: string
          example: payer
        guest:
          $ref: '#/components/schemas/EscrowGuestPayerSummaryDto'
          description: Present when the payer is a guest (no Demat customer).
      required:
        - customerId
        - role
    ErrorBodyDto:
      type: object
      properties:
        code:
          type: string
          example: INVALID_REQUEST
        message:
          type: string
          example: Invalid request
        details:
          type: object
          additionalProperties: true
          example: {}
      required:
        - code
        - message
        - details
    EscrowGuestPayerSummaryDto:
      type: object
      properties:
        email:
          type: string
          example: buyer@example.com
        name:
          type: string
          example: John Doe
        phone:
          type: string
          example: '+2348012345678'
      required:
        - email
  securitySchemes:
    bearer:
      scheme: bearer
      bearerFormat: Secret Key
      type: http
      description: >-
        Secret API key. Use `sk_test_...` for sandbox data or `sk_live_...` for
        live data (after verification and live mode). Do not use `pk_` keys.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.