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¶
{
"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 |
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:
hashcan benullon aFAILEDrecord, permanently. A withdrawal that failed before broadcast never had a hash. Do not render an explorer link unconditionally.decimalsis nullable. It isnullif the token has since been delisted. Fall back to a cached value from/api/v1/tokensrather 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.