openapi: 3.1.0
info:
  title: Merchant Payment API
  version: 1.0.0
  summary: Header-signed merchant integration API (deposits, withdrawals, banks, balance).
  description: |
    # Overview

    A header-authenticated, HMAC-signed REST API for creating and querying
    **deposit** (pay-in) and **withdrawal** (pay-out) orders.

    * **Every request is signed** — there is no unsigned mode.
    * **Replay-protected** — each request carries a fresh timestamp and a single-use nonce.
    * **Constant-time verification** — a missing key and an unknown key return the
      identical `INVALID_MERCHANT` response, so the API never reveals whether a key exists.

    All endpoints are under `/api/v2` and all requests and responses are JSON.

    > **Reading the field tables:** a field marked **required** is rejected with
    > `INVALID_PARAMS` when missing. Everything else is optional — send it only if
    > you need it. Conditional fields state the exact condition.

    # 1 · Get your API credentials

    You need two values. They are issued as a pair:

    | Credential | Format | Used for |
    |---|---|---|
    | **API key** | `pk_` followed by 24 characters (27 total) | Sent openly in the `X-Api-Key` header. It identifies your account — it is not a password. |
    | **API secret** | 48 characters | The HMAC key used to sign each request. **Never send it in a request** and never expose it in browser or mobile code. |

    **To retrieve them:** sign in to the merchant portal → **Profile** → reveal API
    credentials. You must enter a current 6-digit code from your authenticator app.

    > **The secret is shown once.** Copy it into your server configuration
    > immediately. Reopening the page will not show it again.
    >
    > **Lost the secret?** It cannot be recovered — contact support to have the pair
    > reset. A reset issues a **new key and a new secret**; the old pair stops working
    > as soon as the reset completes, so plan a short changeover.

    API access must also be switched on for your account. If it is not, every request
    returns `API_V2_NOT_ENABLED` — contact support to enable it.

    If you have supplied an IP whitelist, requests from any other address are rejected
    with `IP_RESTRICTION`.

    # 2 · Sign a request, step by step

    Every request carries these four headers:

    | Header | Value |
    |---|---|
    | `X-Api-Key` | Your API key (`pk_…`). |
    | `X-Timestamp` | Current unix time in **seconds**, digits only. Must be within ±300 s (5 min) of server time, so keep your clock in sync (NTP). |
    | `X-Nonce` | A fresh random string, 16–64 characters, for this request only. Reusing one within 10 minutes is rejected. |
    | `X-Signature` | The signature produced below, lowercase hex. |

    ### Step 1 — Serialise the body, once

    Build the exact JSON bytes you will send and keep that **one** string. You will
    hash it and transmit it, and the two must be byte-identical.

    > This is the single most common integration bug: hashing one JSON string and
    > letting your HTTP client re-serialise a different one (reordered keys, changed
    > spacing) produces `INVALID_SIGNATURE`. Hash the exact bytes you send.

    For `GET` requests the body is the empty string `""`.

    ### Step 2 — Hash the body

    `bodyHash = sha256_hex(body)` — lowercase hex.

    For an empty body this is always:
    `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`

    ### Step 3 — Build the canonical string

    Join five parts with newline (`\n`) characters, after a fixed `v2:` prefix:

    ```
    "v2:" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + bodyHash
    ```

    | Part | Rule |
    |---|---|
    | `METHOD` | Uppercase HTTP method — `POST` or `GET`. |
    | `PATH` | Path only, with a leading slash and **no query string** or domain — e.g. `/api/v2/deposits`. For a query-by-id, include the id: `/api/v2/deposits/your-tx-id`. |
    | `TIMESTAMP` | Byte-identical to your `X-Timestamp` header. |
    | `NONCE` | Byte-identical to your `X-Nonce` header. |
    | `bodyHash` | From step 2. |

    The `v2:` prefix and the `/api/v2` path segment are fixed parts of the wire
    format. Treat them as literal constants, not as a version to update.

    ### Step 4 — Sign it

    ```
    X-Signature = hmac_sha256_hex( yourApiSecret, canonicalString )
    ```

    Lowercase hex. Send it in the `X-Signature` header with the other three.

    ### Step 5 — Verify against the worked example

    Before pointing your code at live data, reproduce this exactly. If your output
    matches, your signing is correct.

    Given API key `pk_test000000000000000000000`, secret
    `v2secretv2secretv2secretv2secretv2secretv2secret`, `X-Timestamp: 1753776000`,
    `X-Nonce: abcdef1234567890`, and this body for `POST /api/v2/deposits`:

    ```json
    {"channel":"bank","amount":"100.00","tx_id":"v2-golden-000001","callback_url":"https://merchant.example/cb","redirect_url":"https://merchant.example/rd"}
    ```

    * **bodyHash** = `d5d9e95ba808488ddc9802b5d4ba0beece0f0744c2b25942c41f2e55418ef0b9`
    * **canonical string** (5 lines):
      ```
      v2:POST
      /api/v2/deposits
      1753776000
      abcdef1234567890
      d5d9e95ba808488ddc9802b5d4ba0beece0f0744c2b25942c41f2e55418ef0b9
      ```
    * **X-Signature** = `cc77eded5990e920a71464a52f5e108c334a9ef9399e4c4ecca4142d1566e36b`

    A `GET /api/v2/balance` with the same timestamp and nonce and an empty body signs
    to `c3a922528796d81abb54e97cb2abb0f98ba9c1fb0a5cf651d08aa9e7843e44d9`.

    Paste your own values into the **[interactive signature tester](tester.html)** to
    compare the body hash, canonical string and signature side by side with these
    reference values.

    ### Troubleshooting

    | Response | Almost always means |
    |---|---|
    | `INVALID_SIGNATURE` | The body you hashed differs from the body you sent, or `PATH` included the domain or a query string, or the timestamp/nonce in the canonical string differs from the headers. |
    | `SIGNATURE_EXPIRED` | Server clock skew over 5 minutes, a non-numeric timestamp, or a nonce that is the wrong length or already used. |
    | `INVALID_MERCHANT` | Unknown or missing `X-Api-Key`. |
    | `API_V2_NOT_ENABLED` | Credentials are valid but API access is not switched on for the account. |

    # 3 · What happens next

    Once signing works, a deposit runs like this:

    1. **Create the order** — `POST /api/v2/deposits`. You get back an `order_id`,
       the fee breakdown, and `data.payment_details`.
    2. **Send your customer to pay** — redirect them to
       `data.payment_details.redirect_url`. Depending on how your account is
       configured, `payment_details` may also contain the receiving account details
       directly, so you can render your own payment screen instead. See
       [Create a deposit order](#/operations/createDeposit) for both shapes.
    3. **Receive the result** — we `POST` a signed callback to your `callback_url`
       when the order reaches a final state. Verify it (see **Callbacks** below),
       then mark the order paid in your system.
    4. **Reconcile if needed** — `GET /api/v2/deposits/{tx_id}` returns the current
       state at any time. Use it to recover from a missed callback; do not poll it in
       place of handling callbacks.

    A withdrawal is the same minus the customer step: create it, then wait for the
    callback that tells you whether the payout settled.

    > Treat the callback as the source of truth for money movement, and make your
    > callback handler **idempotent** — the same order may be delivered more than once.

    # Response envelope

    Every response (success or failure) uses the same JSON envelope:

    ```json
    {
      "status": 1,
      "code": "SUCCESS",
      "message": null,
      "data": { ... },
      "request_id": "3f6c1e0a-..."
    }
    ```

    * `status` — `1` on success, `0` on failure.
    * `code` — machine-readable code (see the error-code table below).
    * `message` — human-readable detail, may be `null`.
    * `data` — result payload (object or array; `[]` / `{}` on failures).
    * `request_id` — UUID unique to this request; quote it when contacting support.

    Authentication failures return HTTP **403**, validation failures **422**,
    rate limiting **429**, unhandled server errors **500** with `code: "FAIL"`
    (exception details are never leaked).

    # Error codes

    | Code | Meaning |
    |---|---|
    | `SUCCESS` | Request succeeded. |
    | `FAIL` | Generic/unhandled failure (server error, or unclassified order failure). |
    | `INVALID_MERCHANT` | `X-Api-Key` missing or unknown. |
    | `MERCHANT_BLOCKED` | Merchant account is not active. |
    | `API_V2_NOT_ENABLED` | Credentials are valid but API access is not enabled for your account — contact support. |
    | `SIGNATURE_EXPIRED` | `X-Timestamp` non-numeric or outside the ±300 s window; **also** returned for a bad nonce (wrong length or replayed within 10 minutes). |
    | `INVALID_SIGNATURE` | `X-Signature` does not match the canonical string. |
    | `IP_RESTRICTION` | IP whitelist enabled and the caller IP is not whitelisted. |
    | `INVALID_PARAMS` | Request body/params failed validation (`message` has the first error). |
    | `DUPLICATE_TRANSACTION` | `tx_id` already used by your merchant account (or too many pending orders for the same `customer_id`). |
    | `SERVICE_NOT_AVAILABLE` | Deposit (or withdrawal) service disabled for your merchant. |
    | `PAYMENT_METHOD_NOT_SUBSCRIBE` | Your account is not subscribed to the requested channel/tier. |
    | `MERCHANT_INSUFFICIENT_BALANCE` | Withdrawal amount + fee exceeds your balance. |
    | `TRANSACTION_NOT_FOUND` | No order with that `tx_id` for your merchant account. |
    | `NO_SERVICE_PROVIDED` | No receiving account could be allocated for the order. Retry later or contact support. |
    | `SERVICE_UNDER_MAINTENANCE` | The selected channel is temporarily unavailable. |
    | `PAYMENT_GATEWAY_MAINTENENCE` | Signature subsystem under maintenance (rare). Spelling is as returned by the API. |
    | `TOO_MANY_REQUEST` | Rate limited. |
    | `PATH_NOT_FOUND` | Unknown internal method (should not occur through documented endpoints). |

    # Rate limits

    300 requests/minute per merchant (keyed by the merchant your `X-Api-Key` resolves
    to). Requests whose key does not resolve share a small 20/minute per-IP bucket.

    # Callbacks

    Order status changes are POSTed to your `callback_url` — see the **Webhooks**
    section. Each delivery carries `X-Timestamp`, `X-Nonce` and `X-Signature` headers
    so you can verify the payload is genuinely from us before acting on it.
  contact:
    name: Merchant support
servers:
  - url: https://api.5dpayz.com
    description: v2 gateway
tags:
  - name: Deposits
    description: Create deposit (pay-in) orders and query their status.
  - name: Withdrawals
    description: Create withdrawal (pay-out) orders and query their status.
  - name: Merchant
    description: Reference data and account state.

security:
  - ApiKey: []
    Timestamp: []
    Nonce: []
    Signature: []

paths:
  /api/v2/deposits:
    post:
      tags: [Deposits]
      operationId: createDeposit
      summary: Create a deposit order
      description: |
        Creates a pay-in order and returns the order id, the fee breakdown, and
        `data.payment_details` — the receiving-account details to show your customer.

        ## Response format

        `data.payment_details` is returned in this format:

        ```json
        "payment_details": {
          "bank_name": "Example Bank",
          "account_name": "EXAMPLE TRADING LTD",
          "account_number": "012345678901",
          "allocated_amount": "100.00",
          "redirect_url": "https://links.example/redirect/DP1753776000GOLDEN01"
        }
        ```

        Show `bank_name`, `account_name`, `account_number` and `allocated_amount` to
        your customer so they can transfer, or send them to `redirect_url` to use our
        hosted payment page instead. Individual field notes are in the response
        schema below.

        ## Notes

        * `tx_id` must be unique per merchant (case-insensitive); reuse returns
          `DUPLICATE_TRANSACTION`.
        * `amount` is validated against your account's configured min/max deposit
          limits, 2 decimal places max.
        * Always show the customer `allocated_amount` (or `order_amount`) rather than
          the amount you submitted — the exact figure to transfer can differ by a cent.
        * The channel tier is chosen automatically from the amount; your account must
          be subscribed to the resulting tier or the request fails with
          `PAYMENT_METHOD_NOT_SUBSCRIBE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositCreateRequest'
            examples:
              goldenVector:
                summary: Golden-vector request (matches the signature tester)
                description: |
                  With key `pk_test000000000000000000000`, secret
                  `v2secretv2secretv2secretv2secretv2secretv2secret`,
                  `X-Timestamp: 1753776000`, `X-Nonce: abcdef1234567890`, this exact
                  body signs to
                  `X-Signature: cc77eded5990e920a71464a52f5e108c334a9ef9399e4c4ecca4142d1566e36b`.
                value:
                  channel: bank
                  amount: "100.00"
                  tx_id: v2-golden-000001
                  callback_url: https://merchant.example/cb
                  redirect_url: https://merchant.example/rd
              typical:
                summary: Typical request
                value:
                  channel: bank
                  amount: "100.00"
                  tx_id: dp-2026-000123
                  callback_url: https://merchant.example/cb
                  redirect_url: https://merchant.example/rd
                  transferror_name: CHAN TAI MAN
      responses:
        '200':
          description: Order created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/DepositCreateData'
              examples:
                created:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data:
                      order_id: DP1753776000GOLDEN01
                      tx_id: v2-golden-000001
                      order_amount: "100.00"
                      service_change: "2.00"
                      final_amount: "98.00"
                      transferror_name: ""
                      payment_details:
                        bank_name: Example Bank
                        account_name: EXAMPLE TRADING LTD
                        account_number: "012345678901"
                        allocated_amount: "100.00"
                        redirect_url: https://links.example/redirect/DP1753776000GOLDEN01
                    request_id: 3f6c1e0a-58a4-4b64-9df1-2f0d43b2b101
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/deposits/{tx_id}:
    get:
      tags: [Deposits]
      operationId: getDeposit
      summary: Query a deposit order
      description: |
        Returns the current state of a deposit order by **your** `tx_id`
        (case-insensitive; scoped to your merchant account).
        Remember: the signed `PATH` includes the `tx_id`, e.g.
        `/api/v2/deposits/v2-golden-000001`.
      parameters:
        - $ref: '#/components/parameters/TxId'
      responses:
        '200':
          description: Order state (or `TRANSACTION_NOT_FOUND` in the envelope).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/DepositInquiryData'
              examples:
                found:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data:
                      order_id: DP1753776000GOLDEN01
                      tx_id: v2-golden-000001
                      transferror_name: "-"
                      status: COMPLETE
                      order_amount: "100.00"
                      service_change: "2.00"
                      final_amount: "98.00"
                    request_id: 6e0d1a4c-7f3b-4d43-9f92-b1a52c4c9a77
                notFound:
                  value:
                    status: 0
                    code: TRANSACTION_NOT_FOUND
                    message: null
                    data: []
                    request_id: 6e0d1a4c-7f3b-4d43-9f92-b1a52c4c9a77
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/withdrawals:
    post:
      tags: [Withdrawals]
      operationId: createWithdrawal
      summary: Create a withdrawal order
      description: |
        Creates a pay-out order. Pick one of the two channels and send the fields for
        that channel — see the **Bank transfer** and **FPS** request examples below.

        | Channel | Required fields |
        |---|---|
        | `bank` | `channel`, `amount`, `tx_id`, `callback_url`, `bank_code`, `account_number`, `account_name` |
        | `fps` | `channel`, `amount`, `tx_id`, `callback_url`, `mobile_no` |

        ## Amounts

        The service charge is **added** to the order amount: your balance is debited
        `final_amount = amount + service_change`. Insufficient balance returns
        `MERCHANT_INSUFFICIENT_BALANCE`.

        `amount` must have **exactly** 2 decimal places here (`500.00`, not `500` or
        `500.5`) — stricter than the deposit endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WithdrawCreateRequest'
            examples:
              bank:
                summary: Bank transfer (channel = bank)
                value:
                  channel: bank
                  amount: "500.00"
                  tx_id: wd-2026-000123
                  callback_url: https://merchant.example/cb
                  account_number: "1234567890"
                  account_name: CHAN TAI MAN
                  bank_code: "004"
              fps:
                summary: FPS (channel = fps)
                value:
                  channel: fps
                  amount: "500.00"
                  tx_id: wd-2026-000124
                  callback_url: https://merchant.example/cb
                  mobile_no: "51234567"
      responses:
        '200':
          description: Order created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WithdrawCreateData'
              examples:
                created:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data:
                      order_id: WT1753776000ABCDEF0001
                      tx_id: wd-2026-000123
                      order_amount: "500.00"
                      service_change: "10.00"
                      final_amount: "510.00"
                    request_id: 9a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9
        '403': { $ref: '#/components/responses/AuthError' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/withdrawals/{tx_id}:
    get:
      tags: [Withdrawals]
      operationId: getWithdrawal
      summary: Query a withdrawal order
      description: |
        Returns the current state of a withdrawal order by your `tx_id`
        (case-insensitive; scoped to your merchant account).
      parameters:
        - $ref: '#/components/parameters/TxId'
      responses:
        '200':
          description: Order state (or `TRANSACTION_NOT_FOUND` in the envelope).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/WithdrawInquiryData'
              examples:
                found:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data:
                      order_id: WT1753776000ABCDEF0001
                      tx_id: wd-2026-000123
                      status: COMPLETE
                      order_amount: "500.00"
                      service_change: "10.00"
                      final_amount: "510.00"
                    request_id: 0c1d2e3f-4051-6273-8495-a6b7c8d9e0f1
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/banks:
    get:
      tags: [Merchant]
      operationId: listBanks
      summary: List supported banks
      description: |
        Returns the active bank list. Use `bank_code` from this list in withdrawal
        requests (`channel: bank`).
      responses:
        '200':
          description: Active banks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Bank'
              examples:
                banks:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data:
                      - { id: 1, name: HSBC, bank_code: "004" }
                      - { id: 2, name: Bank of China (Hong Kong), bank_code: "012" }
                    request_id: 1f2e3d4c-5b6a-7089-9dae-bfc0d1e2f3a4
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/v2/balance:
    get:
      tags: [Merchant]
      operationId: getBalance
      summary: Get merchant balance
      description: |
        Returns your current merchant balance, rounded to 2 decimal places.

        Golden vector: with key `pk_test000000000000000000000`, secret
        `v2secretv2secretv2secretv2secretv2secretv2secret`,
        `X-Timestamp: 1753776000`, `X-Nonce: abcdef1234567890` and an empty body,
        `GET /api/v2/balance` signs to
        `X-Signature: c3a922528796d81abb54e97cb2abb0f98ba9c1fb0a5cf651d08aa9e7843e44d9`.
      responses:
        '200':
          description: Current balance.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          balance:
                            type: number
                            description: Current balance, 2 decimal places.
                            examples: [12345.67]
              examples:
                balance:
                  value:
                    status: 1
                    code: SUCCESS
                    message: null
                    data: { balance: 12345.67 }
                    request_id: 2a3b4c5d-6e7f-8091-a2b3-c4d5e6f70819
        '403': { $ref: '#/components/responses/AuthError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

webhooks:
  depositCallback:
    post:
      tags: [Deposits]
      operationId: depositCallback
      summary: Deposit status callback
      description: |
        POSTed to the `callback_url` you supplied when the deposit order reaches a
        final state (and on admin-triggered re-sends). The body is bare JSON — it is
        **not** wrapped in the response envelope used by the endpoints above.

        Treat this callback as the source of truth for money movement, and make your
        handler **idempotent**: the same order can be delivered more than once.

        **Verification** — every delivery carries three headers:

        | Header | Value |
        |---|---|
        | `X-Timestamp` | Unix seconds when the callback was signed. |
        | `X-Nonce` | Random 32-char lowercase string, fresh per delivery. |
        | `X-Signature` | Lowercase-hex HMAC-SHA256 of the callback canonical string, keyed with your API secret. |

        Callback canonical string (note: **no** method/path — your callback URL may
        be rewritten by your own proxies, so the binding is over the exact body bytes):

        ```
        "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(rawJsonBody)
        ```

        Verify by recomputing the HMAC over the **raw request body bytes exactly as
        received** (do not re-serialize the parsed JSON) and comparing in constant
        time. Reject callbacks whose timestamp is outside your own tolerance window
        and treat repeated nonces as replays.

        Golden vector: timestamp `1753776000`, nonce `abcdef1234567890`, body
        `{"order_id":"DP1753776000GOLDEN01","tx_id":"v2-golden-000001","transferror_name":"-","status":"COMPLETE","order_amount":"100.00","service_change":"2.00","final_amount":"98.00"}`
        → body sha256 `3ebcb27243ec898cdcc80e6309fffaf70bf4369e07c610f70445d1dbb361906c`,
        `X-Signature` `4d1e566dc184e84770dae832f4f62317887b1a02b73f54dadaec4c2107b5772c`.
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DepositCallbackBody'
            examples:
              goldenVector:
                summary: Golden-vector callback body
                value:
                  order_id: DP1753776000GOLDEN01
                  tx_id: v2-golden-000001
                  transferror_name: "-"
                  status: COMPLETE
                  order_amount: "100.00"
                  service_change: "2.00"
                  final_amount: "98.00"
      responses:
        '200':
          description: |
            Respond with HTTP 200 to acknowledge receipt. Non-200 responses may be
            retried by gateway operators.

  withdrawCallback:
    post:
      tags: [Withdrawals]
      operationId: withdrawCallback
      summary: Withdrawal status callback
      description: |
        POSTed to the withdrawal's `callback_url` when the order reaches a final
        state. Bare JSON body, not wrapped in the response envelope — the same shape
        as the deposit callback minus `transferror_name`, which is deposit-only.

        Carries the same `X-Timestamp` / `X-Nonce` / `X-Signature` verification
        headers, computed over

        ```
        "v2-callback:" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(rawJsonBody)
        ```

        with your API secret. See the deposit callback for full verification
        guidance; the recipe is identical. Make your handler idempotent.
      parameters:
        - $ref: '#/components/parameters/CallbackTimestamp'
        - $ref: '#/components/parameters/CallbackNonce'
        - $ref: '#/components/parameters/CallbackSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WithdrawCallbackBody'
            examples:
              complete:
                value:
                  order_id: WT1753776000ABCDEF0001
                  tx_id: wd-2026-000123
                  status: COMPLETE
                  order_amount: "500.00"
                  service_change: "10.00"
                  final_amount: "510.00"
      responses:
        '200':
          description: Respond with HTTP 200 to acknowledge receipt.

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Your API key (`pk_` + 24 characters). Public identifier, not a secret.
    Timestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: Unix seconds, digits only, within ±300 s of server time.
    Nonce:
      type: apiKey
      in: header
      name: X-Nonce
      description: Random 16–64 char string; single-use per merchant for 10 minutes.
    Signature:
      type: apiKey
      in: header
      name: X-Signature
      description: |
        Lowercase-hex HMAC-SHA256 over
        `"v2:" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + sha256hex(body or "")`
        keyed with your API secret.

  parameters:
    TxId:
      name: tx_id
      in: path
      required: true
      description: Your transaction reference (6–200 chars, matched case-insensitively).
      schema:
        type: string
        minLength: 6
        maxLength: 200
      example: v2-golden-000001
    CallbackTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: Unix seconds when the callback was signed.
      schema: { type: string }
      example: "1753776000"
    CallbackNonce:
      name: X-Nonce
      in: header
      required: true
      description: Fresh random nonce, unique per delivery.
      schema: { type: string }
      example: abcdef1234567890
    CallbackSignature:
      name: X-Signature
      in: header
      required: true
      description: HMAC-SHA256 hex over the callback canonical string.
      schema: { type: string }
      example: 4d1e566dc184e84770dae832f4f62317887b1a02b73f54dadaec4c2107b5772c

  responses:
    AuthError:
      description: |
        Authentication failure. `code` is one of `INVALID_MERCHANT`,
        `MERCHANT_BLOCKED`, `SIGNATURE_EXPIRED`, `INVALID_SIGNATURE`,
        `IP_RESTRICTION`, or `SERVICE_NOT_AVAILABLE` (service disabled for the merchant).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            invalidSignature:
              value:
                status: 0
                code: INVALID_SIGNATURE
                message: null
                data: []
                request_id: 5f6e7d8c-9b0a-4123-8456-789abcdef012
    ValidationError:
      description: Validation failure — `message` carries the first validator error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            invalidParams:
              value:
                status: 0
                code: INVALID_PARAMS
                message: "channel must be one of: bank, fps"
                data: []
                request_id: 5f6e7d8c-9b0a-4123-8456-789abcdef012
    RateLimited:
      description: Rate limited (300/min per merchant; 20/min per IP for unresolved keys).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
    ServerError:
      description: Unhandled server error — always masked as `code:"FAIL"`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Envelope'
          examples:
            fail:
              value:
                status: 0
                code: FAIL
                message: null
                data: []
                request_id: 5f6e7d8c-9b0a-4123-8456-789abcdef012

  schemas:
    Envelope:
      type: object
      description: Standard response envelope.
      required: [status, code, data, request_id]
      properties:
        status:
          type: integer
          enum: [0, 1]
          description: 1 = success, 0 = failure.
        code:
          $ref: '#/components/schemas/ErrorCode'
        message:
          type: [string, 'null']
          description: Human-readable detail; often null on success.
        data:
          description: Result payload; empty array/object on failures.
        request_id:
          type: string
          format: uuid
          description: Unique id for this request — quote it to support.

    ErrorCode:
      type: string
      description: Machine-readable result code.
      enum:
        - SUCCESS
        - FAIL
        - INVALID_MERCHANT
        - MERCHANT_BLOCKED
        - API_V2_NOT_ENABLED
        - SIGNATURE_EXPIRED
        - INVALID_SIGNATURE
        - IP_RESTRICTION
        - INVALID_PARAMS
        - DUPLICATE_TRANSACTION
        - SERVICE_NOT_AVAILABLE
        - PAYMENT_METHOD_NOT_SUBSCRIBE
        - MERCHANT_INSUFFICIENT_BALANCE
        - TRANSACTION_NOT_FOUND
        - NO_SERVICE_PROVIDED
        - SERVICE_UNDER_MAINTENANCE
        - PAYMENT_GATEWAY_MAINTENENCE
        - TOO_MANY_REQUEST
        - PATH_NOT_FOUND

    DepositChannel:
      type: string
      description: |
        Deposit payment channel. Two bank rails are supported: `bank` (bank
        transfer) and `fps` (Faster Payment System). Any other value is rejected
        with `INVALID_PARAMS`.
      enum: [bank, fps]

    WithdrawChannel:
      type: string
      description: Withdrawal payment channel.
      enum: [bank, fps]

    OrderStatus:
      type: string
      description: |
        Order status.

        | Status | Meaning |
        |---|---|
        | `PENDING` | Not yet settled — still in progress. |
        | `COMPLETE` | Settled successfully. This is the only status that means the money moved. |
        | `REJECT` | Rejected or failed. |
        | `OVERTIME` | Deposits only — the order expired before it was paid. |

        Treat `COMPLETE` and `REJECT` as final. Treat anything else, including any
        value not listed here, as still in progress and wait for a further callback —
        never as a failure.
      enum: [PENDING, COMPLETE, REJECT, OVERTIME]

    DepositCreateRequest:
      type: object
      required: [channel, amount, tx_id, callback_url, redirect_url, transferror_name]
      properties:
        channel:
          $ref: '#/components/schemas/DepositChannel'
        amount:
          type: string
          description: |
            Deposit amount; numeric with at most 2 decimal places. Must be within
            your merchant's configured min/max deposit limits. Send as a string to
            avoid float formatting surprises (numbers are accepted too).
          examples: ["100.00"]
        tx_id:
          type: string
          minLength: 6
          maxLength: 200
          description: Your unique transaction reference (unique per merchant, case-insensitive).
        callback_url:
          type: string
          format: uri
          description: URL that receives the status callback (see Webhooks).
        redirect_url:
          type: string
          format: uri
          description: URL the customer is sent to after the hosted payment page.
        transferror_name:
          type: string
          maxLength: 200
          description: |
            The payer's name. Send the name of the person or company that will be
            making the transfer.
    DepositCreateData:
      type: object
      description: Payload of a successful deposit creation.
      properties:
        order_id:
          type: string
          description: Gateway order id (`DP...`).
        tx_id:
          type: string
        order_amount:
          type: string
          description: Order amount ("100.00").
        service_change:
          type: string
          description: Service charge amount deducted from the deposit.
        final_amount:
          type: string
          description: Net amount credited (order_amount − service_change).
        transferror_name:
          type: string
          description: |
            The payer name stored on the order, or `""` when none was stored —
            including when you sent one but payer-side name entry is enabled.
        payment_details:
          type: object
          description: |
            The receiving-account details for this order, plus the hosted payment
            page link. Show these to your customer so they can complete the transfer.
          properties:
            bank_name:
              type: string
              description: Receiving bank name. Show this to your customer.
            account_name:
              type: string
              description: Receiving account holder name. The transfer must be made to exactly this name.
            account_number:
              type: string
              description: Receiving account number. Always treat as a string — leading zeros are significant.
            allocated_amount:
              type: [string, 'null']
              description: |
                The exact amount your customer must transfer. May be `null` — fall
                back to `order_amount` when it is.
            name:
              type: string
              description: Payee name, returned in place of `account_name` on some FPS orders.
            mobile_no:
              type: string
              description: Payee mobile number / FPS identifier, returned on some FPS orders.
            redirect_url:
              type: string
              format: uri
              description: |
                Always present. Our hosted payment page for this order — send your
                customer here instead of rendering your own screen.
          additionalProperties: true

    DepositInquiryData:
      type: object
      description: Deposit order state (same shape as the deposit callback body).
      properties:
        order_id: { type: string }
        tx_id: { type: string }
        transferror_name:
          type: string
          description: Payer name, "-" when not set.
        status:
          $ref: '#/components/schemas/OrderStatus'
        order_amount:
          type: string
          description: |
            The amount actually credited for this order. This can differ from the
            `order_amount` returned when the order was created if the customer
            transferred a different figure.
        service_change: { type: string }
        final_amount: { type: string }

    WithdrawCreateRequest:
      type: object
      required: [channel, amount, tx_id, callback_url]
      properties:
        channel:
          $ref: '#/components/schemas/WithdrawChannel'
        amount:
          type: string
          description: |
            Withdrawal amount; exactly 2 decimal places, within your merchant's
            min/max withdrawal limits. Fee is added on top of this amount.
          examples: ["500.00"]
        tx_id:
          type: string
          minLength: 6
          maxLength: 200
          description: Your unique transaction reference.
        callback_url:
          type: string
          format: uri
          description: URL that receives the status callback.
        bank_code:
          type: string
          description: |
            **Required for `channel: bank`** — the receiving bank, given as a
            `bank_code` from `GET /api/v2/banks`. Not used for `channel: fps`.
        account_number:
          type: string
          description: '**Required for `channel: bank`** — beneficiary account number. Send as a string; leading zeros matter.'
        account_name:
          type: string
          description: '**Required for `channel: bank`** — beneficiary account holder name, as registered with the bank.'
        mobile_no:
          type: string
          description: '**Required for `channel: fps`** — the FPS mobile number or identifier of the beneficiary.'
        redirect_url:
          type: string
          format: uri
          description: '**Optional.** Stored with the order; not used for payout routing.'

    WithdrawCreateData:
      type: object
      description: Payload of a successful withdrawal creation.
      properties:
        order_id:
          type: string
          description: Gateway order id (`WT...`).
        tx_id: { type: string }
        order_amount:
          type: string
          description: Requested withdrawal amount.
        service_change:
          type: string
          description: Service charge added on top.
        final_amount:
          type: string
          description: Total debited from your balance (order_amount + service_change).

    WithdrawInquiryData:
      type: object
      description: Withdrawal order state (same shape as the withdrawal callback body).
      properties:
        order_id: { type: string }
        tx_id: { type: string }
        status:
          $ref: '#/components/schemas/OrderStatus'
        order_amount: { type: string }
        service_change: { type: string }
        final_amount: { type: string }

    DepositCallbackBody:
      type: object
      description: Deposit callback JSON body.
      required: [order_id, tx_id, status, order_amount, service_change, final_amount]
      properties:
        order_id: { type: string }
        tx_id: { type: string }
        transferror_name:
          type: string
          description: Payer name, "-" when not set.
        status:
          $ref: '#/components/schemas/OrderStatus'
        order_amount: { type: string }
        service_change: { type: string }
        final_amount: { type: string }

    WithdrawCallbackBody:
      type: object
      description: Withdrawal callback JSON body.
      required: [order_id, tx_id, status, order_amount, service_change, final_amount]
      properties:
        order_id: { type: string }
        tx_id: { type: string }
        status:
          $ref: '#/components/schemas/OrderStatus'
        order_amount: { type: string }
        service_change: { type: string }
        final_amount: { type: string }

    Bank:
      type: object
      properties:
        bank_code:
          type: string
          description: The value to send as `bank_code` when creating a bank withdrawal.
          examples: ["004"]
        name:
          type: string
          description: Bank display name — use this in your own UI.
          examples: ["Example Bank"]
        id:
          type: integer
          description: Internal identifier. Ignore it; always use `bank_code`.
