Skip to content

Balances and withdrawals

Money that has been swept out of an invoice or a static wallet lands on your balance. Withdrawing moves it from there to an address you name.

Balances

curl https://api.pay.nullswap.com/api/v1/merchant/balances \
  -H "x-api-key: sk_live_..."
{
  "items": [
    {
      "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
      "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
      "walletId": "b2c3d4e5-f6a7-4890-8b1c-2d3e4f5a6b7c",
      "kind": "CLEAN",
      "amount": "125000000",
      "decimals": 6
    },
    {
      "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
      "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
      "walletId": "b2c3d4e5-f6a7-4890-8b1c-2d3e4f5a6b7c",
      "kind": "DIRTY",
      "amount": "4000000",
      "decimals": 6
    }
  ]
}

Note the envelope: { "items": [...] }, not the { "data": [...], "pagination": {...} } used elsewhere. There is no pagination and there are no filters — you get every row you have.

One row is one (token, wallet, kind) combination, and a token with both clean and flagged funds on it appears twice, as above. Rows only exist once there has been something to hold; a token you have never been paid in is absent rather than zero.

CLEAN and DIRTY

kind Meaning
CLEAN Passed screening, or screening did not apply. This is what you can withdraw
DIRTY Traceable to a payment that failed AML screening. Held separately

Never add the two together

They are not two halves of one number you can spend. A withdrawal draws on CLEAN only. Summing them gives you a figure you cannot move, and the shortfall will not surface until the withdrawal has already been accepted and later fails.

There is no route to convert DIRTY into CLEAN. If a flagged balance is wrong, raise it with the amlResultId from the payment that caused it.

amount is a base-unit integer as a string; divide by 10^decimals to display it. As always, only sum amounts within one tokenId — see conventions.

Raising a withdrawal

curl -X POST https://api.pay.nullswap.com/api/v1/merchant/withdrawals \
  -H "x-api-key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
    "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
    "to": "0xfedcba0987654321fedcba0987654321fedcba09",
    "amount": "50000000",
    "externalId": "payout-2026-01-14-001"
  }'
Field Type Required Notes
chainId uuid yes
tokenId uuid yes Must be a token on that chain
to string yes Destination address, in the chain's native format
amount string yes Base units. "50000000" is 50 USDT at 6 decimals
externalId string no Your reference. Idempotency key — see below
{
  "transferRequestId": "9c0d1e2f-3a4b-45c6-8d78-9e0f1a2b3c4d",
  "status": "PENDING",
  "hash": null
}

201 means accepted for processing, not sent. Nothing has touched the chain yet, hash is null, and the outcome arrives asynchronously.

The destination address is never validated for you

We do not check that to is a plausible address for chainId, and we do not check that it is an address you control. A withdrawal to a malformed or wrong address is a normal, well-formed request that we will attempt. Validate it on your side, and prefer an address book over free-text entry.

externalId really is an idempotency key here

This is the one write in the API where a repeated externalId is safe rather than an error. Send the same one twice — a retry after a socket timeout, a duplicated job — and the second call returns the existing withdrawal instead of raising a second one. It is scoped to your merchant.

Contrast invoices, where a duplicate externalId is a 409. The asymmetry is deliberate: a duplicate invoice is a nuisance, a duplicate withdrawal is lost money.

Always send one

Generate it from something stable in your own system — the payout row id, not a timestamp or a random value. An externalId you cannot reproduce after a crash protects nothing.

Errors

Status message Cause
400 InvalidRequestBodyError Missing field, or amount is not a numeric string
403 MerchantIsBlockedError The merchant is blocked
404 ChainWithIdNotFoundError Unknown chainId
404 TokenWithIdNotFoundError Unknown tokenId, or that token is not on that chain
404 MerchantWalletForChainTypeNotFoundError No wallet configured for that chain family
502 WalletServiceError The wallet service was unreachable. Retry with the same externalId

Insufficient balance is not necessarily rejected at request time

Do not read 201 as "the funds were there". A withdrawal for more than your CLEAN balance can be accepted and then fail later, surfacing as status: "FAILED" on the record with a reason in error. Check the balance yourself before you raise it, and treat the terminal status as the answer rather than the response to the POST.

Watching a withdrawal

curl "https://api.pay.nullswap.com/api/v1/merchant/withdrawals?limit=50" \
  -H "x-api-key: sk_live_..."

Only offset, limit and order — no filtering by status, token or externalId. Ordered by createdAt DESC by default.

{
  "id": "9c0d1e2f-3a4b-45c6-8d78-9e0f1a2b3c4d",
  "walletId": "b2c3d4e5-f6a7-4890-8b1c-2d3e4f5a6b7c",
  "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
  "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
  "toAddress": "0xfedcba0987654321fedcba0987654321fedcba09",
  "amount": "50000000",
  "decimals": 6,
  "status": "CONFIRMED",
  "hash": "0x9a8b7c6d5e4f30211a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70819",
  "error": null,
  "createdAt": "2026-01-14T09:00:00.000Z",
  "updatedAt": "2026-01-14T09:04:00.000Z"
}

id is the transferRequestId you were handed at creation — the same value, renamed.

status Meaning
PENDING Queued. Nothing on chain, hash is null
BROADCAST Signed and published. hash is set. Not yet confirmed
CONFIRMED Confirmed to the chain's required depth. Terminal, and final
FAILED Not sent, or sent and reverted. Terminal. Read error
stateDiagram-v2
    [*] --> PENDING: POST accepted
    PENDING --> BROADCAST: signed and published
    PENDING --> FAILED: could not be sent
    BROADCAST --> CONFIRMED: confirmed on chain
    BROADCAST --> FAILED: reverted or dropped
    CONFIRMED --> [*]
    FAILED --> [*]

Withdrawals emit no webhooks

Nothing in the event catalogue covers a withdrawal. Poll this endpoint until every record you care about is CONFIRMED or FAILED.

Two details worth encoding in that poller:

  • hash can be null on a FAILED record, permanently. A withdrawal that failed before broadcast never had a hash. Do not render an explorer link unconditionally.
  • decimals is nullable. It is null if the token has since been delisted. Fall back to a cached value from /api/v1/tokens rather than defaulting to 18.

error is free-text and only populated on FAILED — values like InsufficientGasError show up there. Log it and surface it to a human; do not branch on its content, as the set is not a stable enum.