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

# Disbursements (Payouts)

> Disburse funds from a client wallet to a bank account via the LauncX V3 payout API.

Disburse funds from a client wallet to a bank account. All endpoints require the standard `x-api-key` + `x-timestamp` headers — and the **Quote** and **Create** endpoints additionally require the `x-signature` request signature. See [Authentication](/authentication) for both.

Typical flow: **list banks → quote → create → poll get**. Status updates after create (`SUCCESS`/`FAILED`) also arrive via [Disbursement Webhooks](/disbursement-webhooks).

## 1. List available banks

`GET /api/v3/wallets/{wallet_id}/banks`

Call this first to obtain valid `bank_code` values for the wallet's payout vendor.

```json theme={null}
{
  "banks": [
    { "code": "BCA", "name": "Bank Central Asia", "swift": "CENAIDJA" }
  ],
  "total_count": 1
}
```

| Field           | Type    |
| --------------- | ------- |
| `banks[].code`  | string  |
| `banks[].name`  | string  |
| `banks[].swift` | string  |
| `total_count`   | integer |

<Note>
  Depending on the wallet's payout vendor, the list can include **e-wallets** (e.g. `dana`, `gopay`, `ovo`, `linkaja`, `shopeepay`) alongside banks. E-wallets are disbursed through the same endpoints — pick the e-wallet's `code` as `bank_code` and use the recipient's registered phone number as `account_no` (see [Account number for e-wallets](#account-number-for-e-wallets)).
</Note>

## 2. Quote a disbursement (non-binding fee preview)

`POST /api/v3/disbursements/quote`

<Warning>
  **Required header:** `x-signature` — the HMAC-SHA256 request signature (`t=<ms>,v1=<hex>`). Requests without a valid signature are rejected with `401`. See [Signing a request](/authentication#signing-a-request); validate your implementation with the [signature-check endpoint](/authentication#testing-your-signature) before going live.
</Warning>

### Request

| Field              | Type             | Required | Notes                                                                                                                                            |
| ------------------ | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `client_wallet_id` | string (UUID)    | Yes      |                                                                                                                                                  |
| `amount`           | string (decimal) | Yes      |                                                                                                                                                  |
| `bank_code`        | string           | Yes      | From [List available banks](#1-list-available-banks).                                                                                            |
| `account_no`       | string           | Yes      | Destination account number; for e-wallet codes, the registered phone number — see [Account number for e-wallets](#account-number-for-e-wallets). |
| `transfer_type`    | string           | Yes      | `BIFAST` or `RTOL`. Missing or invalid → `400`.                                                                                                  |

### Account number for e-wallets

`account_no` is forwarded to the payout vendor **as-is** (no reformatting on our side) and verified in real time via the vendor's account inquiry — both Quote and Create run this inquiry, and the verified `account_holder_name` is echoed back so you can confirm the recipient before money moves.

* **Bank codes:** the recipient's bank account number, digits only.
* **E-wallet codes** (`dana`, `gopay`, `ovo`, …): the phone number the e-wallet account is registered with, digits only in local Indonesian format — e.g. `081234567890` (leading `0`, no `+62`, no spaces or dashes).

If the account or phone number can't be resolved you get `422` — `"bank account verification failed"` for an unknown account, `"bank account is inactive"` for a known-but-inactive one. Because Quote runs the same inquiry with **no side effects**, it's the safe way to validate a recipient (and their name) before creating the disbursement.

### Response

```json theme={null}
{
  "amount": "100000",
  "platform_fee": "2500",
  "net_amount": "97500",
  "currency": "IDR",
  "bank_code": "BCA",
  "bank_name": "Bank Central Asia",
  "account_no": "1234567890",
  "account_holder_name": "JOHN DOE"
}
```

Quote has no side effects (no funds locked, nothing persisted).

## 3. Create a disbursement

`POST /api/v3/disbursements`

<Warning>
  **Required headers:**

  * `Idempotency-Key` (max 64 chars). Missing → `400`. Retrying with the same key (and the same parameters) returns the original outcome rather than creating a duplicate — see [Idempotency & replay semantics](#idempotency--replay-semantics).
  * `x-signature` — the HMAC-SHA256 request signature (`t=<ms>,v1=<hex>`), computed over the **exact body string you send**. Missing or invalid → `401`. See [Signing a request](/authentication#signing-a-request).

  When `x-signature` is present, `x-timestamp` is ignored — the `t` inside the signature is what's checked against the ±5-minute window.
</Warning>

### Request

| Field              | Type             | Required | Notes                                                                                                                                                                                                                    |
| ------------------ | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `client_wallet_id` | string (UUID)    | Yes      |                                                                                                                                                                                                                          |
| `amount`           | string (decimal) | Yes      |                                                                                                                                                                                                                          |
| `bank_code`        | string           | Yes      |                                                                                                                                                                                                                          |
| `account_no`       | string           | Yes      | Destination account number; for e-wallet codes, the registered phone number — see [Account number for e-wallets](#account-number-for-e-wallets).                                                                         |
| `transfer_type`    | string           | Yes      | `BIFAST` or `RTOL`. Missing or invalid → `400`.                                                                                                                                                                          |
| `memo`             | string           | No       | Max 255 chars.                                                                                                                                                                                                           |
| `reference_id`     | string           | No       | Max 64 chars. Your own business reference (order/payout number). Non-unique — retry safety stays with `Idempotency-Key`. Echoed in responses and in the [disbursement webhook](/disbursement-webhooks) as `referenceId`. |

```bash theme={null}
# body must be the exact string that was signed — see /authentication#signing-a-request
# sig = lowercase hex of HMAC_SHA256("<t>.<body>", SIGNING_SECRET)
curl -X POST https://live.launcx.com/api/v3/disbursements \
  -H "Content-Type: application/json" \
  -H "x-api-key: {{your-v3-api-key}}" \
  -H "x-signature: t={{unix_timestamp_in_milliseconds}},v1={{sig}}" \
  -H "Idempotency-Key: {{unique-key-per-request}}" \
  -d '{
    "client_wallet_id": "....",
    "amount": "100000",
    "bank_code": "BCA",
    "account_no": "1234567890",
    "transfer_type": "BIFAST",
    "memo": "payout #123",
    "reference_id": "ORD-2026-0042"
  }'
```

### Response

| Field                 | Type             | Notes                                                |
| --------------------- | ---------------- | ---------------------------------------------------- |
| `id`                  | string (UUID)    |                                                      |
| `client_id`           | string (UUID)    |                                                      |
| `wallet_id`           | string (UUID)    |                                                      |
| `status`              | string           | See [Status values](#5-disbursement-status-values).  |
| `amount`              | string (decimal) |                                                      |
| `platform_fee`        | string (decimal) |                                                      |
| `net_amount`          | string (decimal) |                                                      |
| `currency`            | string           | `IDR`.                                               |
| `bank_code`           | string           |                                                      |
| `bank_name`           | string           |                                                      |
| `account_no`          | string           |                                                      |
| `account_holder_name` | string           |                                                      |
| `memo`                | string \| null   |                                                      |
| `reference_id`        | string           | Echoes the request field; omitted when not supplied. |
| `idempotency_key`     | string           | Echoes the header.                                   |
| `failed_reason`       | string \| null   |                                                      |

Status codes:

* `201 Created` — fresh disbursement created.
* `200 OK` with header `Idempotent-Replayed: true` — the `Idempotency-Key` was already used and the disbursement is `SUCCESS` or `REQUESTED`; the stored result is returned. Replays of unsuccessful disbursements answer with an error status instead — see below.

### Idempotency & replay semantics

A replay is **faithful**: retrying with the same `Idempotency-Key` and the **same parameters** answers exactly as the original attempt did — same HTTP status, same body — with the `Idempotent-Replayed: true` response header as the only addition. Do **not** treat every replay as a success; key your success handling off the HTTP status:

| Stored status           | Replay response                      | What to do                                                             |
| ----------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
| `SUCCESS` / `REQUESTED` | `200` + full response body           | Money moved (or is in flight). Done.                                   |
| `FAILED`                | `503` "Vendor payout request failed" | No money moved. Retry with a **new** key if you still want the payout. |
| `EXPIRED`               | `410` "Withdrawal lock has expired"  | No money moved. Submit a fresh request with a **new** key.             |
| `CANCELLED`             | `409` "disbursement was cancelled"   | No money moved. Use a **new** key if you want to resubmit.             |

**Parameter fingerprint.** Reusing a key with **different parameters** returns `422` and performs nothing — this protects you from believing a new payload was disbursed when only the old one was. The compared fields are `client_wallet_id`, `amount` (scale-insensitive: `"100000"` equals `"100000.00"`), `bank_code` (case-insensitive), `account_no` (whitespace-trimmed), and `transfer_type`. `memo` is not compared. On a `422`, either resend the *original* payload with that key, or use a new key for the new payload.

**Concurrency.** Two simultaneous creates with the same key can race; the loser may get `409` "concurrent request with the same Idempotency-Key; retry" — safe to retry with the **same** key.

Other error codes on Create:

| Code  | Meaning                                                                                                                                                                                        |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | Validation error (missing `Idempotency-Key`, invalid amount, missing/invalid `transfer_type`, …).                                                                                              |
| `401` | Missing/invalid API key or `x-signature`.                                                                                                                                                      |
| `402` | Insufficient balance.                                                                                                                                                                          |
| `403` | Client withdrawal capability is suspended. (Replays of already-finalised disbursements still answer normally while suspended, so you can always learn the outcome of money already submitted.) |
| `404` | Client, wallet, or configuration not found (also returned when the wallet belongs to another client).                                                                                          |
| `409` | Concurrent same-key request (retry, same key), or replay of a `CANCELLED` disbursement.                                                                                                        |
| `410` | Balance lock expired (fresh attempt or replay of `EXPIRED`) — submit again with a **new** key.                                                                                                 |
| `422` | Bank code unsupported, account inactive, amount below minimum, or `Idempotency-Key` reused with different parameters.                                                                          |
| `503` | Vendor rejected the payout (also on replay of `FAILED`), or the payout gateway is temporarily unavailable.                                                                                     |

## 4. Get a disbursement

`GET /api/v3/disbursements/{id}`

Returns the disbursement if it belongs to your client; otherwise `404`. `x-signature` is optional here (and on List available banks) — sign it if you want, unsigned requests with a valid `x-timestamp` work too.

| Field                 | Type             | Notes                                               |
| --------------------- | ---------------- | --------------------------------------------------- |
| `id`                  | string (UUID)    |                                                     |
| `client_id`           | string (UUID)    |                                                     |
| `wallet_id`           | string (UUID)    |                                                     |
| `amount`              | string (decimal) |                                                     |
| `currency`            | string           | `IDR`.                                              |
| `platform_fee`        | string (decimal) |                                                     |
| `net_amount`          | string (decimal) |                                                     |
| `status`              | string           | See [Status values](#5-disbursement-status-values). |
| `failed_reason`       | string \| null   |                                                     |
| `memo`                | string \| null   |                                                     |
| `reference_id`        | string           | Present when supplied at create.                    |
| `bank_code`           | string           |                                                     |
| `bank_name`           | string           |                                                     |
| `account_no`          | string           |                                                     |
| `account_holder_name` | string           |                                                     |
| `requested_at`        | string \| null   | ISO 8601 UTC.                                       |
| `completed_at`        | string \| null   | ISO 8601 UTC.                                       |

## 5. Disbursement status values

`PENDING_VERIFICATION` → `VERIFIED` → `REQUESTED` → `SUCCESS`, with terminal failure states `FAILED`, `EXPIRED`, and `CANCELLED`.

## 6. FAQ — prerequisites & common rejections

### Why did I get `402` "Insufficient balance"?

Your wallet's **available balance** must cover the **full `amount`** of the disbursement (the gross amount — the platform fee is deducted from it, not added on top).

Available balance is:

```text theme={null}
available = settled wallet balance − balance locked by in-flight disbursements
```

Two things commonly surprise integrators:

* **Incoming payments only count after settlement.** A QR payment that has been *paid* but not yet *settled* does **not** increase your available balance. Settlement typically lands the next business day (T+1). To top up: receive QR payments into the wallet, then wait for settlement before disbursing.
* **In-flight disbursements lock their full amount.** Every disbursement that has not reached a terminal state (`SUCCESS`/`FAILED`/`EXPIRED`/`CANCELLED`) holds a lock on its amount, reducing what's available for new ones. Locks are released when the disbursement finishes or expires.

Check your current balance via the [Wallets](/wallets) endpoints before creating large disbursements.

### Why did I get `422` "bank account verification failed" or "bank account is inactive"?

`account_no` is verified against the receiving bank **in real time** (a bank inquiry) before the disbursement is created — an invalid or inactive account is rejected up front, and nothing is charged.

Common causes:

| Cause                                        | Fix                                                                |
| -------------------------------------------- | ------------------------------------------------------------------ |
| Typo in `account_no`                         | Double-check the digits; whitespace is trimmed automatically       |
| `bank_code` doesn't match the account's bank | Pick the code from [List available banks](#1-list-available-banks) |
| Account is dormant, closed, or blocked       | The holder must resolve this with their bank — retrying won't help |
| E-wallet number in the wrong format          | See [Account number for e-wallets](#account-number-for-e-wallets)  |

**Tip:** the [Quote](#2-quote-a-disbursement-non-binding-fee-preview) endpoint runs the same verification without creating anything — use it to validate the account (and preview the account holder's name) before the real create.

### What are the minimum and maximum disbursement amounts?

**Minimum.** The minimum applies to the **net amount** (your `amount` minus the platform fee) and depends on `transfer_type`:

| `transfer_type` | Minimum net amount |
| --------------- | ------------------ |
| `BIFAST`        | 10,000 IDR         |
| `RTOL`          | 25,000 IDR         |

Because the minimum is on the *net* amount, the minimum `amount` you can send is slightly higher and depends on your fee configuration. You don't need to compute it yourself:

* A too-small create (or quote) fails with `422` `"withdrawal amount is below minimum: <X>"` — where `<X>` is the exact minimum **gross** `amount` for your account and that transfer type.
* The [Quote](#2-quote-a-disbursement-non-binding-fee-preview) endpoint returns your fee and net amount for any candidate `amount`, so you can verify before creating.

**Maximum.** The platform does not enforce a maximum of its own — the effective ceiling per transaction comes from the payout network and the receiving bank:

* `BIFAST` transfers are subject to the BI-FAST network's per-transaction cap set by Bank Indonesia.
* `RTOL` (online transfer) caps are lower and vary per receiving bank.

A disbursement above the applicable cap is rejected by the payout vendor and answers `503` `"Vendor payout request failed"` — no money moves and your balance is unaffected (the balance lock is released when the disbursement fails). For amounts near or above network caps, split the payout into multiple disbursements — each with its **own** `Idempotency-Key` — or contact support to confirm the current limits for your route.

Your practical ceiling is also bounded by your **available balance** (see the [`402` question](#why-did-i-get-402-insufficient-balance) above) — the full `amount` must be covered at create time.
