> For the complete documentation index, see [llms.txt](https://docs.dutchmanbridge.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.dutchmanbridge.com/partner-integration/partner-quickstart.md).

# Partner quickstart

This guide takes you from an empty account to a customer who has been verified, funded, and has moved money — with a `curl` call and the SDK equivalent for every step. Every request below hits the Partner API under `/api/v1/partner/v1/...` and is authenticated with your API key.

## 1. Get access

1. **Sign up as a business** at [dutchmanbridge.com](https://dutchmanbridge.com). The console is where you manage your account; your customers never see it.
2. **Complete KYB.** Upload your company documents from the console. Compliance reviews them and tells you the outcome by webhook once you have one registered (`partner.approved`, `partner.rejected`, or `partner.resubmission_requested` with a reason) and in the console. Approval is a condition of calling the API at all: until compliance approves your business, the console does not reveal your API key and every `/partner/v1` request returns `403` with `"Your partner account is not yet approved…"`. Build against the OpenAPI spec and the SDK's types in the meantime.
3. **Issue an API key** from *Dashboard → API key*. Rotating it from the same screen invalidates the previous key immediately.

The key identifies your whole business, not one customer. Keep it on your servers: **never embed it in a browser, mobile app, or public repository.** If it leaks, rotate it.

### SDK

If you write TypeScript, `dutchman-bridge` wraps every route in this guide. It is on the public npm registry (source: [`Dutchman-io/dutchman-bridge-sdk`](https://github.com/Dutchman-io/dutchman-bridge-sdk)):

```bash
npm install dutchman-bridge
```

Configure it once with the base URL and your key; methods are grouped by resource (`Customers`, `Kyc`, `Wallet`, `Ramp`, `Send`, `Webhook`, ...). The SDK snippets below show which resource and method each step maps to; they are illustrative of the `<resource>.<action>` naming, not exact signatures — check the [SDK README](https://github.com/Dutchman-io/dutchman-bridge-sdk#readme) for the real method names, argument shapes and return types before copying them.

```ts
import { BridgeClient } from 'dutchman-bridge';

const bridge = new BridgeClient({
  baseUrl: 'https://app.dutchmanbridge.com',
  apiKey: process.env.DUTCHMAN_API_KEY, // sent as the x-api-key header
});
```

## 2. Base URL, auth, and the response envelope

|                |                                                                                     |
| -------------- | ----------------------------------------------------------------------------------- |
| Base URL       | `https://app.dutchmanbridge.com/api/v1`                                             |
| Partner routes | `/partner/v1/...` under that base                                                   |
| Auth header    | `x-api-key: <your key>` on every call                                               |
| Content type   | `application/json` (except the KYC document upload, which is `multipart/form-data`) |

The examples below use two shell variables:

```bash
export BASE="https://app.dutchmanbridge.com/api/v1/partner/v1"
export API_KEY="db_live_5f8c2a7e91d34b0c8a6e1f9d2b7c4e3a"
```

### Success envelope

Every successful response is `{ success: true, data }`. `data` is the resource itself — an object for single resources, an array for lists.

```json
{
  "success": true,
  "data": {
    "id": "c0a8012e-7d1b-4f3a-9c2e-5b6d8e9f0a1b",
    "firstName": "Adaeze",
    "...": "..."
  }
}
```

Creates return **201**, reads and updates **200**, deletes **204 with no body**. There is no `status` or `message` field inside the body — read the HTTP status.

### Errors

Errors are `success: false` with the real HTTP status, a stable `code`, and a human-readable `message`:

```json
{
  "success": false,
  "statusCode": 400,
  "code": "BAD_REQUEST",
  "message": "bvn must be exactly 11 digits",
  "error": "BadRequestException",
  "timestamp": "2026-09-10T13:00:00.000Z",
  "path": "/api/v1/partner/v1/customers/c0a8012e-7d1b-4f3a-9c2e-5b6d8e9f0a1b/kyc/tier1"
}
```

Branch on `statusCode` and `code`; treat `message` as display text that may change.

### Retrying safely

Every call that moves money takes an optional `reference` in the body — your own request id. Send the same value again and you get the original record back; nothing moves twice.

| Endpoint                         | Creates          |
| -------------------------------- | ---------------- |
| `POST .../wallet/withdraw`       | a withdrawal     |
| `POST .../send`                  | a crypto send    |
| `POST .../wallet/funding/charge` | a funding charge |
| `POST .../global-send/initiate`  | a global send    |

```json
{ "amount": 50000, "reference": "req_01HZY4K8N2" }
```

Letters, digits, dot, underscore and hyphen; 120 characters max. It is scoped to the endpoint, so the same value on a withdrawal and on a send are two different records — and it is yours to choose, so make it unique per intended operation (a request id, not a customer id).

**A timeout is not a failure.** If a request times out or returns 5xx, retry it with the same `reference` rather than assuming nothing happened — that is the only way to find out which. Without one, a retry is a second withdrawal.

Buy and sell are covered by their quote instead: a `quoteId` executes exactly once, including when two requests arrive together. A second execute is a 400, and an execute that fails (insufficient balance, say) leaves the quote usable until it expires.

### Pagination

Lists that grow with usage (transactions, withdrawals, funding charges, receiving activity, webhook deliveries) accept `?limit` (1–100, default 20) and `?cursor`, and add `meta.nextCursor`. Loop until it is `null`. An invalid cursor is a 400.

```json
{
  "success": true,
  "data": [
    {
      "id": "…",
      "type": "CREDIT",
      "reason": "deposit",
      "amount": 25,
      "currency": "USDT",
      "…": "…"
    }
  ],
  "meta": { "nextCursor": "eyJpZCI6IjAxOTFmM2Q0In0" }
}
```

Reference lists (countries, banks, networks, KYC records, balances) are plain arrays with no `meta`.

## 3. The golden path

The customer id you get in step 3.1 goes into the path of every later call (`/customers/{customerId}/...`). Never put `customerId` in a request body — it is ignored.

### 3.1 Register a customer

`POST /partner/v1/customers` — the customer never logs into Dutchman Bridge; this only attributes an id to your account. `phoneNumber` is E.164, `country` is ISO alpha-2 and decides the customer's fiat currency.

```bash
curl -X POST "$BASE/customers" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Adaeze",
    "lastName": "Okonkwo",
    "email": "adaeze.okonkwo@example.com",
    "phoneNumber": "+2348012345678",
    "country": "NG"
  }'
```

```json
{
  "success": true,
  "data": {
    "id": "c0a8012e-7d1b-4f3a-9c2e-5b6d8e9f0a1b",
    "firstName": "Adaeze",
    "lastName": "Okonkwo",
    "middleName": null,
    "email": "adaeze.okonkwo@example.com",
    "phoneNumber": "+2348012345678",
    "country": "NG",
    "verifiedAt": null,
    "createdAt": "2026-09-10T13:00:00.000Z",
    "updatedAt": "2026-09-10T13:00:00.000Z"
  }
}
```

```ts
const customer = await bridge.customers.register({
  firstName: 'Adaeze',
  lastName: 'Okonkwo',
  email: 'adaeze.okonkwo@example.com',
  phoneNumber: '+2348012345678',
  country: 'NG',
});
const customerId = customer.id;
```

Store `data.id` — it is the `customerId` for everything that follows. A `customer.registered` webhook fires as well.

### 3.2 Submit Tier 1 KYC

`POST /partner/v1/customers/{customerId}/kyc/tier1` with both the customer's `bvn` and `nin` (11 digits each). Bridge verifies them independently against the issuing registries and name-matches them to the profile — passing your own check is not enough. Both numbers must resolve for auto-approval.

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/kyc/tier1" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "bvn": "22345678901", "nin": "31234567890" }'
```

```json
{
  "success": true,
  "data": {
    "status": "REVIEW",
    "record": {
      "id": "5e2f7a1c-93b4-4d6e-8f0a-1b2c3d4e5f60",
      "customerId": "c0a8012e-7d1b-4f3a-9c2e-5b6d8e9f0a1b",
      "level": 1,
      "status": "REVIEW",
      "idType": "BVN",
      "rejectionReason": null,
      "createdAt": "2026-09-10T13:01:12.000Z"
    }
  }
}
```

```ts
const submission = await bridge.kyc.submitTier1(customerId, {
  bvn: '22345678901',
  nin: '31234567890',
});
// submission.status is 'APPROVED' or 'REVIEW'
```

`status` is `APPROVED` when the checks cleared immediately. It is `REVIEW` when a reviewer has to decide — a partial name match, a registry timeout, and similar. **Do not poll.** Wait for the [`kyc.status_updated`](/partner-integration/partner-webhooks.md#kyc) webhook, which carries the final `APPROVED` or `REJECTED` (with a `reason`). `GET .../kyc` returns the records if you need the current state on demand. Some rails are gated on an approved Tier 1 (an NGN virtual account, for one), so finish this step before funding.

#### Check where a customer stands

`GET /partner/v1/customers/{customerId}/verification` is the recommended single read for a customer's verification state — use it instead of combining `GET .../kyc` with `GET .../kyc/reverification`. It reports the highest approved tier, each tier's status, and exactly what to submit next.

```bash
curl "$BASE/customers/$CUSTOMER_ID/verification" -H "x-api-key: $API_KEY"
```

```json
{
  "success": true,
  "data": {
    "currentTier": 1,
    "tiers": {
      "1": {
        "status": "APPROVED",
        "updatedAt": "2026-09-10T13:01:12.000Z",
        "rejectionReason": null
      },
      "2": {
        "status": "NOT_STARTED",
        "updatedAt": null,
        "rejectionReason": null
      },
      "3": {
        "status": "NOT_STARTED",
        "updatedAt": null,
        "rejectionReason": null
      }
    },
    "nextRequired": ["ID_DOCUMENT", "SELFIE"],
    "reverification": null,
    "records": [
      {
        "id": "5e2f7a1c-93b4-4d6e-8f0a-1b2c3d4e5f60",
        "customerId": "c0a8012e-7d1b-4f3a-9c2e-5b6d8e9f0a1b",
        "level": 1,
        "status": "APPROVED",
        "idType": "BVN",
        "rejectionReason": null,
        "createdAt": "2026-09-10T13:01:12.000Z"
      }
    ]
  }
}
```

* `currentTier` is the highest tier with status `APPROVED` (`0` when none is).
* Each tier's `status` is `NOT_STARTED`, `PENDING`, `REVIEW`, `APPROVED` or `REJECTED` (with `rejectionReason`). Tiers 1 and 2 reflect the newest submission for that tier; tier 3 reflects the cross-border verification partners' decision.
* `nextRequired` names the inputs to collect next: `BVN` and `NIN` for `POST .../kyc/tier1`, `ID_DOCUMENT` and `SELFIE` for `POST .../kyc/tier2`, `DATE_OF_BIRTH` and `ADDRESS` for `POST .../kyc/tier3`. It is empty while the next tier is `PENDING` or in `REVIEW` — wait for `kyc.status_updated` — and once every tier is approved. If an admin has an open re-verification request, its `items` (which can also include `COUNTRY` or `ADDRESS_PROOF`) come first, and the request itself is in `reverification`.

#### International verification and its fee

Sending abroad needs the customer verified by our cross-border payout partner: `POST /partner/v1/customers/{customerId}/kyc/tier3`. Read `GET .../global-send/international-status` first. It says whether the customer is `eligible` (Tier 2 approved, or KYB approved for a business), which submission to send (`customerType`), and, on each of the `groups` (`americas_europe`, `china`), that group's `registrationFee`:

```json
"groups": [
  { "key": "china", "status": "NOT_STARTED", "countries": ["CN"],
    "registrationFee": { "amount": "3", "currency": "USDT", "paidBy": "CUSTOMER", "paid": false } }
]
```

* Each group verifies the customer separately, so the fee is charged once per customer **per group**, when that group's payout partner creates them as its customer on the first submission. By default it is **3 USDT for an individual, 11 USDT for a business**, but a group can be priced differently: always read the amount from the group. A first submission the partner refuses to create costs nothing. The top-level `registrationFee` is deprecated; it repeats the `americas_europe` group's fee for older clients. Once taken, the fee is non-refundable, and a re-submission after a refusal is free.
* It comes from the customer's USDT balance or, if that is short, the equivalent in their local currency at the live rate. The balance is checked before anything is sent: if neither covers the fee, the submission is refused with `INTERNATIONAL_FEE_INSUFFICIENT_BALANCE` and nothing is sent.
* Each group also lists `optedInCountries`: the destinations the customer has chosen. Choosing another country in a group that is already `APPROVED` (`POST .../kyc/tier3` with that `destinationCountry`) only records the choice: nothing goes to review and no fee is charged. A first send to a country records it too.
* If a group's partner asks again for details the customer already gave (address, document, selfie), `POST .../global-send/international-resend?group=china` sends what we hold to the same enrolment, free. It answers as international-status. A group that cannot be re-sent this way answers 400: the customer re-submits `POST .../kyc/tier3` instead.
* To send an unapproved enrolment back to the customer to fix (say, a postal code only they can give): `POST .../global-send/international-reject?group=china` with `{ "reason": "…" }`, worded for the customer. The group then reads `NEEDS_ATTENTION` with that `reason` and no `action`, so show them the form to re-submit `POST .../kyc/tier3`; re-submitting clears it.
* To pay it for your customers from your own balance: `PUT /partner/v1/fees/international-verification` with `{ "paysForCustomers": true }`. `paidBy` then reads `PARTNER`. The response lists each group's amounts under `groups`.

A **business** customer (`customerType: "BUSINESS"`) sends a `business` object with its submission: `businessType`, `description`, `accountPurpose`, `sourceOfFunds`, a `formationDocumentUrl`, and `associatedPersons`. Each person is an owner (25%+) or a control person, with their date of birth, address and ID. At least one person must control the business (give their `title`) and one must be able to authorise payments. Upload each document with `POST .../kyc/documents` and pass the returned `url`. Also include an `ownershipDocumentUrl`, unless a control person confirms the ownership with `attestOwnershipStructure: true`. A business with no website on its profile also needs a `natureOfBusinessDocumentUrl`. The registered name, number and address come from the business profile. When a refused business re-submits, it may omit `business`, and what it sent last time is used.

Each corridor in `GET .../global-send/corridors` carries a `region` (`africa`, `asia`, `europe`, `middle_east`, `north_america`, `south_america`, `oceania`), so you can list destinations grouped by continent and let customers search by it.

Some corridors describe their recipient form as data: `recipientSchema`, one entry per field (`key`, `label`, `required`, and optionally `pattern`, `patternHint`, `options`, `keyboard`, `placeholder`). Render it, send each value as `beneficiary[key]` (for example `phone` for the African corridors), and a recipient that does not match is refused before anything is debited.

#### Paying a supplier in China

China payouts are trade payments: the payout partner refuses one without the details below, whatever the reason given. `GET .../global-send/corridors` lists them on the China corridor (`recipientFields`, `requiredDocuments`, `allowedPurposes`, `minAssetAmount`), and a send missing any is refused before anything is debited.

* `purpose` must be `GOODS_PAYMENT`.
* On `beneficiary`: `bankCode` (the bank's 12-digit CNAPS code), `nameInLocalLanguage` (the recipient's name in Chinese characters), `nationalIdNumber` (their 18-character resident ID), and `logisticsTracking` (at least one `{ trackingNumber, carrierName, carrierCode? }`).
* `beneficiary.documents`: an `invoice` and a `contract`, each `{ type, url }` with the `url` from `POST .../kyc/documents` (JPEG, PNG or PDF, 5 MB at most).
* The send must come to at least `minAssetAmount` (26 USDT) after fees.

### 3.3 Fund the customer

Two ways to put fiat on the balance. Both credit the customer's local currency (from `country`) and emit `funding.received` when the money lands.

**Virtual account** — `GET /partner/v1/customers/{customerId}/virtual-account`. A dedicated bank account in the customer's name, issued on first read and identical on every read after. Anything transferred into it is credited. For NGN it is only issued once Tier 1 is approved.

```bash
curl "$BASE/customers/$CUSTOMER_ID/virtual-account" -H "x-api-key: $API_KEY"
```

```json
{
  "success": true,
  "data": {
    "accountNumber": "9012345678",
    "accountName": "Adaeze Okonkwo",
    "bankName": "Example Microfinance Bank",
    "bankCode": "090123",
    "currency": "NGN",
    "status": "active"
  }
}
```

```ts
const account = await bridge.wallet.getVirtualAccount(customerId);
```

**Funding charge** — `POST /partner/v1/customers/{customerId}/funding/charge` with an `amount` in the customer's fiat. Returns either a one-off bank account to pay into (`type: "bank_transfer"`, use `narration` as the transfer reference) or a `redirectUrl` the customer authorises the payment on (`type: "eft"`). It expires at `expiresAt`.

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/funding/charge" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 150000 }'
```

```json
{
  "success": true,
  "data": {
    "id": "8d4b2f6a-1c3e-4a5b-9d7f-0e1a2b3c4d5e",
    "reference": "fund_3f2a9c1e",
    "type": "bank_transfer",
    "currency": "NGN",
    "amount": 150000,
    "status": "PENDING",
    "accountNumber": "7001234567",
    "accountName": "Dutchman Bridge / fund_3f2a9c1e",
    "bankName": "Example Bank",
    "bankCode": "000123",
    "narration": "fund_3f2a9c1e",
    "expiresAt": "2026-09-10T14:05:00.000Z",
    "redirectUrl": null,
    "createdAt": "2026-09-10T13:05:00.000Z"
  }
}
```

```ts
const charge = await bridge.wallet.createFundingCharge(customerId, {
  amount: 150000,
});
```

Crypto deposits work too: `GET .../addresses` lists the customer's deposit addresses per network, and a confirmed USDT/USDC deposit emits `deposit.received`.

### 3.4 Check the balance

`GET /partner/v1/customers/{customerId}/balance` — one entry per currency the customer holds.

```bash
curl "$BASE/customers/$CUSTOMER_ID/balance" -H "x-api-key: $API_KEY"
```

```json
{
  "success": true,
  "data": [
    { "currency": "NGN", "balance": 150000 },
    { "currency": "USDT", "balance": 0 }
  ]
}
```

```ts
const balances = await bridge.wallet.getBalance(customerId);
```

`GET .../transactions` is the paginated ledger behind these numbers; `GET .../balance/stream` is an SSE feed if you want live updates instead of polling.

### 3.5 Buy or sell stablecoin

Both directions are quote-then-execute. Get a quote, show it to the customer, and execute it before `expiresIn` seconds elapse — an expired quote is rejected and you must re-quote. Buying converts fiat balance to USDT; selling converts USDT balance to fiat. The quote request is the same shape either way: `type` is `"buy"` or `"sell"`. Neither direction touches a chain (both move balances), so no gas is charged; the only sell-side fee is the exchange's fixed execution fee, and buys are fee-free.

**Buy**

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/buy/quote" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100000,
    "sourceCurrency": "NGN",
    "destinationCurrency": "USDT",
    "type": "buy"
  }'
```

```json
{
  "success": true,
  "data": {
    "id": "q_7b2e4d9f1a3c",
    "sourceAmount": 100000,
    "sourceCurrency": "NGN",
    "destinationAmount": 61.35,
    "destinationCurrency": "USDT",
    "rate": 1630,
    "exchangeFee": 0,
    "expiresIn": 60
  }
}
```

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/buy/execute" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "quoteId": "q_7b2e4d9f1a3c" }'
```

```json
{
  "success": true,
  "data": {
    "reference": "buy_0a9b3c7d",
    "fiatCurrency": "NGN",
    "fiatSpent": 100000,
    "usdtCredited": 61.35
  }
}
```

**Sell**

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/sell/quote" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 25,
    "sourceCurrency": "USDT",
    "destinationCurrency": "NGN",
    "type": "sell"
  }'
```

```json
{
  "success": true,
  "data": {
    "id": "q_2c8f1e5a7d4b",
    "sourceAmount": 25,
    "sourceCurrency": "USDT",
    "destinationAmount": 40150,
    "destinationCurrency": "NGN",
    "rate": 1610,
    "exchangeFee": 100,
    "expiresIn": 60
  }
}
```

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/sell/execute" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "quoteId": "q_2c8f1e5a7d4b" }'
```

```json
{
  "success": true,
  "data": {
    "reference": "sale_4c1d8e2f",
    "usdtSold": 25,
    "fiatCurrency": "NGN",
    "fiatCredited": 40150
  }
}
```

```ts
const quote = await bridge.ramp.sellQuote(customerId, {
  amount: 25,
  sourceCurrency: 'USDT',
  destinationCurrency: 'NGN',
  type: 'sell',
  network: 'tron',
});
const sale = await bridge.ramp.sellExecute(customerId, { quoteId: quote.id });
// bridge.ramp.buyQuote / bridge.ramp.buyExecute mirror this for a buy
```

`destinationAmount` is net of every fee listed. A sell always lands on the customer's fiat balance; pay it out with a withdrawal (3.7) when you choose — the withdrawal fee is charged then, not here. `GET .../rate` gives indicative buy/sell rates without locking a quote. Both rates are all-in: the spread and fees are already folded in, so `amount * rate` is what the customer pays or receives. The fixed USDT exchange fee can only be spread over a known amount, so pass `?amount=25` (USDT) to have `sellRate` include it for that amount; without it, `sellRate` carries the spread only.

### 3.6 Send stablecoin on-chain

Three calls: list networks, quote, send.

`GET /partner/v1/customers/{customerId}/send/networks` — the chains the customer can send on, each with its flat fee, minimum, and supported assets. `recommended` marks the cheapest.

```bash
curl "$BASE/customers/$CUSTOMER_ID/send/networks" -H "x-api-key: $API_KEY"
```

```json
{
  "success": true,
  "data": [
    {
      "network": "tron",
      "displayName": "TRON",
      "evm": false,
      "flatFeeUsdt": 1,
      "minSendUsdt": 5,
      "recommended": true,
      "assets": ["USDT"]
    },
    {
      "network": "polygon",
      "displayName": "Polygon",
      "evm": true,
      "flatFeeUsdt": 1.5,
      "minSendUsdt": 5,
      "recommended": false,
      "assets": ["USDT", "USDC"]
    }
  ]
}
```

`POST .../send/quote` — checks the amount against the minimum and the balance without moving anything. Only proceed when `valid` is `true`.

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/send/quote" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "asset": "USDT", "amount": 20, "network": "tron" }'
```

```json
{
  "success": true,
  "data": {
    "asset": "USDT",
    "network": "tron",
    "amount": 20,
    "fee": 1,
    "netAmount": 19,
    "minSendUsdt": 5,
    "balance": 61.35,
    "meetsMin": true,
    "sufficientBalance": true,
    "valid": true
  }
}
```

`POST .../send` — same body plus `toAddress`. Returns 201 as soon as the send is accepted; `txHash` fills in once it is broadcast.

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/send" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "USDT",
    "amount": 20,
    "network": "tron",
    "toAddress": "TXk8rQSAvPvBBNtqSoY6nCfsXWCSSpTVQF"
  }'
```

```json
{
  "success": true,
  "data": {
    "reference": "csend_1b2c9d4e",
    "asset": "USDT",
    "network": "tron",
    "amount": 20,
    "fee": 1,
    "netAmount": 19,
    "toAddress": "TXk8rQSAvPvBBNtqSoY6nCfsXWCSSpTVQF",
    "txHash": null,
    "status": "PENDING"
  }
}
```

```ts
const networks = await bridge.send.listNetworks(customerId);
const sendQuote = await bridge.send.quote(customerId, {
  asset: 'USDT',
  amount: 20,
  network: 'tron',
});
if (sendQuote.valid) {
  const send = await bridge.send.send(customerId, {
    asset: 'USDT',
    amount: 20,
    network: 'tron',
    toAddress: 'TXk8rQSAvPvBBNtqSoY6nCfsXWCSSpTVQF',
  });
}
```

Track it through the `send.pending`, `send.confirmed` and `send.failed` webhooks. Every send opens with `send.pending` -- the exchange that custodies the funds broadcasts it, so there is no transaction hash at submission. On failure the reserved amount returns to the balance.

### 3.7 Withdraw fiat to a bank account

`POST /partner/v1/customers/{customerId}/withdraw` pays fiat balance to the customer's **default bank account**, so add one first: `GET .../banks?country=NG` for bank codes, `POST .../bank/verify-account` to resolve the account name, then `POST .../accounts` to save it (the first account becomes the default).

A withdrawal carries a fee, taken **out of** the amount: the balance is debited the amount you send, and the bank receives that less the fee. The fee is a percentage of the amount plus a fixed charge for the currency. In the response, `amount` is what the bank receives and `totalDebited` what left the balance (the amount you asked for). The bank must receive at least NGN 100 after the fee; a smaller withdrawal is refused before anything moves, with the smallest amount that works. Price it first so the customer can confirm:

```bash
curl "$BASE/customers/$CUSTOMER_ID/withdraw/quote?amount=40000" \
  -H "x-api-key: $API_KEY"
```

```json
{
  "success": true,
  "data": {
    "currency": "NGN",
    "amount": 39750,
    "fee": 250,
    "totalDebited": 40000,
    "feeBps": 50,
    "feeFlat": 50
  }
}
```

```bash
curl -X POST "$BASE/customers/$CUSTOMER_ID/withdraw" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 40000 }'
```

```json
{
  "success": true,
  "data": {
    "id": "b7e3d1f9-2a4c-4e6b-8d0f-1c2e3a4b5c6d",
    "reference": "wd_9d1c5e7a",
    "amount": "39750.00000000",
    "fee": "250.00000000",
    "totalDebited": "40000.00000000",
    "currency": "NGN",
    "status": "PROCESSING",
    "failureReason": null,
    "bankAccount": {
      "bankName": "Example Bank",
      "accountName": "Adaeze Okonkwo",
      "accountNumberLast4": "6789"
    },
    "createdAt": "2026-09-10T13:20:00.000Z",
    "updatedAt": "2026-09-10T13:20:00.000Z"
  }
}
```

```ts
const withdrawal = await bridge.wallet.withdraw(customerId, { amount: 40000 });
```

Note `amount`, `fee` and `totalDebited` come back as decimal strings. A withdrawal that fails or is reversed puts the whole `totalDebited` back on the balance, fee included. `status` is `PROCESSING` until the bank confirms. Completion arrives as `withdrawal.completed` (or `withdrawal.failed`, with the amount back on the balance). `GET .../withdrawals` is the paginated history.

### 3.8 Register your webhook

`PUT /partner/v1/webhook` — set the URL once. Bridge mints a signing secret, returns it **in this response only**, and immediately sends a signed `webhook.test` event so you can confirm the endpoint before anything real fires.

```bash
curl -X PUT "$BASE/webhook" \
  -H "x-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://api.example.com/dutchman/webhooks" }'
```

```json
{
  "success": true,
  "data": {
    "url": "https://api.example.com/dutchman/webhooks",
    "secret": "3f9a6c1e8b2d4f7a9c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d1f3a",
    "test": { "delivered": true, "statusCode": 200, "error": null }
  }
}
```

```ts
const webhook = await bridge.webhook.setUrl({
  url: 'https://api.example.com/dutchman/webhooks',
});
await saveSecret(webhook.secret); // shown once
```

Store `secret` immediately — it cannot be read back, only rotated with `POST .../webhook/rotate-secret`. Verifying the `X-Dutchman-Signature` header, the retry schedule, and the full event catalogue are in [Webhooks](/partner-integration/partner-webhooks.md).

## 4. Going live checklist

* **KYB approved.** You received `partner.approved` (or the console shows *Approved*). Do not put real customers through until it is.
* **API key server-side only.** No key in front-end bundles, mobile apps, or logs. Rotate on any suspicion.
* **Webhook verified.** `PUT /webhook` came back with `test.delivered: true`, your handler checks the signature and the timestamp tolerance, and you answer `2xx` before doing the work.
* **Idempotent by webhook `id`.** Retries and replays reuse the same `id`; dedupe on it and `2xx` duplicates without acting.
* **`REVIEW` is handled.** A Tier 1 submission that returns `REVIEW` is not a failure. Show the customer a pending state and act on `kyc.status_updated`. Handle `REJECTED` (show `reason`) and `kyc.reverification_requested` too.
* **Quotes expire.** Execute within `expiresIn` seconds or re-quote; never cache a quote id across user sessions. Treat `GET .../rate` as indicative.
* **Read `data`, branch on HTTP status.** Success bodies are `{ success, data, meta? }`; 204s have no body; errors carry `code`.
* **Pagination loops terminate on `meta.nextCursor === null`**, with `limit` ≤ 100.
* **You authenticate your customer.** Dutchman Bridge checks only your API key; any 2FA, PIN or session check before a withdrawal or send is your app's to enforce.
* **No hardcoded currency.** A customer's fiat comes from their `country`; read `currency` off the balance, virtual account and quote responses rather than assuming one.

## 5. Reference

* [API reference](/api-reference/api-reference.md) — every route with request and response schemas, and the OpenAPI spec to import into Postman or Insomnia.
* [`Dutchman-io/dutchman-bridge-sdk`](https://github.com/Dutchman-io/dutchman-bridge-sdk) — the TypeScript SDK and its README.
* [Webhooks](/partner-integration/partner-webhooks.md) — signature verification, retries, replay, and the event catalogue.
* [Authentication](/partner-integration/authentication.md) — which credential goes where.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.dutchmanbridge.com/partner-integration/partner-quickstart.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
