Balances and withdrawals¶
Money that has been swept out of an invoice or a static wallet lands on your balance, less the merchant fee and the network fee of moving it. Withdrawing moves it from there to an address you name.
Balances¶
{
"items": [
{
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"currency": "USDT",
"decimals": 6,
"amount": "125000000",
"balanceId": "b2c3d4e5-f6a7-4890-8b1c-2d3e4f5a6b7c"
}
]
}
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. A token you have never been paid in is normally absent rather than zero, but
a row can also read "0" — after you have withdrawn everything, for example — so treat an absent
row and "0" alike. A balance in a token that is no longer listed in
/api/v1/tokens is not reported.
| Field | Meaning |
|---|---|
tokenId, chainId |
The token, and the chain a withdrawal of it leaves on |
currency |
The token's currency symbol |
decimals |
Scale of amount |
amount |
What you hold, in base units. All of it can be withdrawn |
balanceId |
Our reference for this balance. Quote it to support |
The whole of amount is yours to withdraw. Only money that has confirmed and cleared screening is
ever swept onto your balance; funds that failed screening never reach it and stay on the deposit or
static address they arrived on. Read them per payment, through amlStatus on
invoice payments and settlement: WITHHELD on
static wallet payments.
Paid is not yet on your balance
A payment is credited here only once it has been swept.
Between invoice.paid and the credit there is a sweep on chain, and on an invoice created with
autoSweep: false there is no credit at all until you sweep it yourself.
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.
| Status | message |
Cause |
|---|---|---|
502 |
INTERNAL_ERROR |
The balance could not be read. Safe to retry |
502 |
INTERNAL_ERROR |
The tokens could not be looked up. Safe to retry |
The merchant fee¶
We charge each merchant a fee on its payments: a rate in basis points, shown as feePercentage on
GET /api/v1/merchant. We set it, and an API key can never change it. The fee
is withheld when a sweep settles onto your balance: of what arrived, the fee is kept and your balance
is credited the rest.
amount is what the sweep delivered, in base units of the token it was credited in. The fee is
rounded down to a whole base unit, so a rounding remainder always stays with you. At "0" nothing is
withheld for the merchant fee.
The network fee is the cost of moving the money on a
token sweep, valued in the credited token and withheld next to the merchant fee. It is zero on a
sweep of a chain's native coin, on UTXO chains and on Monero, where the sweep pays its own way out of
what it moves. It has no field of its own: it is the difference between amount − fee and
credited.
Each sweep fixes its rate when it is opened. A rate change applies to sweeps opened after it, never to one already on its way.
A SWEPT sweep is not credited its amount
Your balance grows by credited, not by amount, and on a token sweep not by amount − fee
either. Reconcile a sweep against your balance with credited from the list below, never with
the sweep's own figures.
The withdrawal fee is a separate charge, taken when you withdraw. Your balances are already net of every merchant fee and sweep network fee.
List the fees withheld¶
curl "https://api.pay.nullswap.com/api/v1/merchant/fees?settledFrom=2026-01-01T00:00:00.000Z&settledTo=2026-02-01T00:00:00.000Z&limit=50" \
-H "x-api-key: sk_live_..."
| Query | Type | Required | Notes |
|---|---|---|---|
chainId |
uuid | no | Repeatable. Only sweeps on these chains |
tokenId |
uuid | no | Repeatable. Only sweeps of these tokens |
settledFrom |
ISO-8601 | no | Only sweeps settled at or after this moment. Inclusive |
settledTo |
ISO-8601 | no | Only sweeps settled before this moment. Exclusive |
offset, limit |
integer | no | See pagination. Send a limit |
order |
ASC | DESC |
no | By settlement time. Default DESC, newest first |
{
"data": [
{
"id": "0b1c2d3e-4f50-4617-8a29-3b4c5d6e7f80",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"walletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"staticWalletId": null,
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"transferRequestId": "4d5e6f70-8192-4a3b-9c4d-5e6f708192a3",
"amount": "25500000",
"credited": "25117500",
"fee": "382500",
"feePercentage": "150",
"feeUsd": "382576500000000000",
"settledAt": "2026-01-01T12:14:00.000Z",
"creditedTokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"dstTokenId": null,
"amountIn": null,
"amountOut": null,
"conversionSkipReason": null
}
],
"pagination": { "offset": 0, "limit": 50, "total": 1, "order": "DESC" }
}
One row is one settled sweep. Every sweep that settled onto your balance since the merchant fee was
introduced is listed, a sweep at a zero rate included with fee: "0"; sweeps still on their way are
not.
| Field | Meaning |
|---|---|
id |
The sweep id: the id of a sweep returned by POST /api/v1/wallets/{walletId}/sweep, and the sweepId of a static wallet payment |
walletId |
The address swept: an invoice's depositWalletId or a static wallet's id |
invoiceId, staticWalletId |
Which kind of address it was. Exactly one is set |
transferRequestId |
Our reference for the underlying transfer. Quote it to support |
tokenId |
The token that was swept off the address |
creditedTokenId |
The token your balance was credited in: tokenId, or the target token when an auto-conversion rule converted the sweep |
amount |
What arrived, in base units of creditedTokenId |
credited |
What your balance was credited, in base units of creditedTokenId: amount − fee, less the network fee on a token sweep |
fee |
The merchant fee withheld, in base units of creditedTokenId |
feePercentage |
The rate applied, in basis points, fixed when the sweep was opened |
feeUsd |
fee in USD at settlement, multiplied by 1018. null until it has been priced |
settledAt |
When the sweep settled onto your balance |
dstTokenId, amountIn, amountOut, conversionSkipReason |
The auto-conversion of the sweep, as on the sweep itself. null when no rule covered it |
feeUsd is for reporting only. A fee whose token had no price at settlement reads null and is
priced later, at the price of that later moment. Sum fee per tokenId for exact figures — see
conventions.
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_QUERY_PARAMETER |
chainId or tokenId is not a UUID |
400 |
INVALID_DATE_FILTER |
settledFrom or settledTo could not be parsed |
401 |
INVALID_API_KEY |
Missing, malformed, unknown or revoked x-api-key |
403 |
CALLER_IP_NOT_ALLOWED |
The key is valid but the source address is not on its whitelist |
403 |
MERCHANT_BLOCKED |
The merchant is blocked |
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. On RIPPLE a classic r… address, or an X-address, which is read as the address and the tag it packs |
toTag |
integer | null | no | RIPPLE only: the recipient's destination tag, 0–4294967295. Refused on any other chain, and when it differs from the tag of an X-address in to. See Withdrawing to RIPPLE |
amount |
string | yes | Base units. "50000000" is 50 USDT at 6 decimals |
externalId |
string | null | no, but strongly recommended | Your reference, 1–255 characters. Idempotency key — see below. Without it a retry pays out twice |
subtractFeeFromAmount |
boolean | no | Default false. Take the fee out of amount instead of charging it on top. See the fee |
maxFee |
string | null | no | The most you accept to pay as the fee, in base units of tokenId. Above it the call is refused with 422 FEE_EXCEEDS_MAX_FEE |
{
"transferRequestId": "9c0d1e2f-3a4b-45c6-8d78-9e0f1a2b3c4d",
"status": "PENDING",
"hash": null,
"amount": "50000000",
"fee": "1350000"
}
201 means accepted for processing, not sent. Nothing has touched the chain yet, hash is
null, and the outcome arrives asynchronously. What has happened is the debit: the amount and the
fee are taken off your balance when the withdrawal is accepted, and a withdrawal your balance does
not cover is refused there and then with 422 INSUFFICIENT_MERCHANT_BALANCE.
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.
Two checks do run on to before anything is debited:
- Screening. The destination is screened when the withdrawal is placed. An address that fails
is refused with
422 RECIPIENT_ADDRESS_BLOCKED;SKIPPEDlets the withdrawal through. Monero destinations are not screened. - Your withdrawal whitelist, if you turned it on. The merchant's owner can restrict payouts to a
list of trusted addresses in the cabinet, under Settings → Whitelists. With the restriction
on, a
tothat is not on the list for that chain is refused with422 WITHDRAWAL_ADDRESS_NOT_WHITELISTED— API calls included — and with the list empty every payout is refused. The list is per chain: an address added for one EVM network is not trusted on another. OnRIPPLEan entry is an address and a destination tag together, and a payout matches only the entry with exactly itstoTag— an entry without a tag matches only a payout without one.
Withdrawing to RIPPLE¶
On RIPPLE, the XRP Ledger, most exchanges and custodians receive on one account shared by all their
customers and tell them apart by a destination tag. Send the recipient's tag as toTag, or send the
X-address they gave you as to. A payout to such an account without the tag reaches the exchange and
nobody's balance there.
Two more checks read the recipient's account off the ledger before anything is debited:
- A required tag. An account that refuses payments without a destination tag, paid with no
toTag, is refused with422 DESTINATION_TAG_REQUIRED. - An account that does not exist yet. An XRP Ledger account is created by its first payment, and
only if that payment is at least the network's base reserve — currently 1 XRP. A payout to an
account that does not exist, for less than that, is refused with
422 DESTINATION_NOT_ACTIVATED, anddetails.minAmountsays how much it takes, in drops:
{
"statusCode": 422,
"message": "DESTINATION_NOT_ACTIVATED",
"timestamp": "2026-01-14T09:00:00.000Z",
"details": {
"minAmount": "1000000"
}
}
Read the figure from details.minAmount rather than hard-coding it: the reserve is the network's to
change. Neither refusal debits anything, and the same externalId can be sent again once the request
is fixed.
The fee¶
A withdrawal costs the network fee of sending it, and you pay it in the token you withdraw,
converted at that token's current price, with a small margin for movement between the estimate and
the send. fee in the response is what you were charged and amount is what the recipient is
sent, both in base units of tokenId. There are no other charges, and no gas to keep on hand: you
never need a balance in the chain's native coin to withdraw a token.
subtractFeeFromAmount |
Recipient gets | Taken off your balance |
|---|---|---|
false (default) |
amount |
amount + fee |
true |
amount − fee |
amount |
true is how you withdraw everything: send your whole balance as amount and the fee comes out of
it. With false, withdrawing your whole balance is refused for want of the fee.
The fee is priced again at the moment of the withdrawal, so it can differ from any estimate you were
shown. Pass maxFee to cap it: a withdrawal whose fee comes out higher is refused with
422 FEE_EXCEEDS_MAX_FEE, and nothing is debited.
Only tokens with a known price can be withdrawn
The fee is converted at the token's price. A token we have no price for cannot be withdrawn, and
the call answers 422 TOKEN_PRICE_UNAVAILABLE. The same applies to the fee quote.
Quote the fee¶
curl "https://api.pay.nullswap.com/api/v1/merchant/withdrawals/fee-quote?chainId=1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10&tokenId=3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908" \
-H "x-api-key: sk_live_..."
| Query | Type | Required | Notes |
|---|---|---|---|
chainId |
uuid | yes | |
tokenId |
uuid | yes | The token you intend to withdraw |
{
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"nativeTokenId": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"nativeFee": "2800000",
"fee": "1350000"
}
fee is what a withdrawal of tokenId would be charged right now, in its base units, margin
included. nativeFee is the network cost it was converted from, in base units of the chain's native
coin nativeTokenId.
This is an estimate only. Nothing is reserved and the price is not held: send the fee you showed
as maxFee on the withdrawal if you need the charge to stay within it.
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_QUERY_PARAMETER |
chainId or tokenId is missing or not a UUID |
404 |
CHAIN_NOT_FOUND |
Unknown chainId |
404 |
TOKEN_NOT_FOUND |
Unknown tokenId, or that token is not on that chain |
422 |
TOKEN_PRICE_UNAVAILABLE |
No price to convert the fee into this token |
502 |
INTERNAL_ERROR |
The fee could not be estimated. Safe to retry |
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 with the same parameters — 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.
externalId is optional, but leave it out only if you never retry. Without it every call is a new
withdrawal: two identical requests are two payouts, and a retry after a timeout whose answer you
never saw pays out twice.
The same externalId with any parameter changed — chainId, tokenId, to, toTag, amount,
subtractFeeFromAmount or maxFee — is refused with 409 WITHDRAWAL_EXTERNAL_ID_CONFLICT, and
nothing is paid out. It means your system reused one reference for two different payouts; fix the
reference rather than retrying.
Contrast invoices, where a duplicate externalId is a 409. The asymmetry is
deliberate: a duplicate invoice is recoverable, a duplicate withdrawal pays out twice.
Always send one, and make it reproducible
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.
A repeat returns the existing withdrawal whatever became of it, including a FAILED one. To try a
failed withdrawal again, send a new externalId. A withdrawal refused with a 422 was never
recorded, so the same externalId can be used again once the cause is fixed. After a 5xx the
externalId stays taken: a retry with it returns whatever became of the first attempt.
Errors¶
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_REQUEST_BODY |
A field is missing or malformed: externalId is sent but empty, blank, not a string or longer than 255 characters, chainId or tokenId is not a UUID, to is empty, amount is not a positive integer string, maxFee is present but not a non-negative integer string, subtractFeeFromAmount is not a boolean, toTag is not a whole number 0–4294967295, a toTag is sent on a chain other than RIPPLE, to is an X-address for another network, or its tag differs from toTag |
403 |
MERCHANT_BLOCKED |
The merchant is blocked |
404 |
CHAIN_NOT_FOUND |
Unknown chainId |
404 |
TOKEN_NOT_FOUND |
Unknown tokenId, or that token is not on that chain |
409 |
WITHDRAWAL_EXTERNAL_ID_CONFLICT |
You already used that externalId for a withdrawal with different parameters |
422 |
WITHDRAWAL_ADDRESS_NOT_WHITELISTED |
Your withdrawal whitelist is on and to is not on it for that chain |
422 |
INSUFFICIENT_MERCHANT_BALANCE |
Your balance does not cover what would be taken off it — amount, plus fee unless subtractFeeFromAmount |
422 |
TOKEN_PRICE_UNAVAILABLE |
The token has no price to charge the fee in |
422 |
FEE_EXCEEDS_MAX_FEE |
The fee came out above your maxFee |
422 |
AMOUNT_DOES_NOT_COVER_FEE |
subtractFeeFromAmount is set and the fee is not smaller than amount |
422 |
RECIPIENT_ADDRESS_BLOCKED |
The destination address failed screening. Nothing was debited |
422 |
DESTINATION_TAG_REQUIRED |
RIPPLE: the recipient account refuses payments without a destination tag, and no toTag was given |
422 |
DESTINATION_NOT_ACTIVATED |
RIPPLE: the recipient account does not exist yet and the payout is below what creating it takes. details.minAmount is that amount, in drops |
502 |
INTERNAL_ERROR |
The withdrawal could not be placed, or it is not known whether it was. Retry with the same externalId; if you sent none, check your withdrawals before trying again |
201 is not the end of it
An accepted withdrawal can still fail on its way to the chain, surfacing as status: "FAILED"
on the record with a reason in error. 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",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"toAddress": "0xfedcba0987654321fedcba0987654321fedcba09",
"toTag": null,
"amount": "50000000",
"fee": "1350000",
"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. amount is
what the recipient is sent — already net of the fee if you set subtractFeeFromAmount — and fee
what you were charged on top of it. toTag is the destination tag the payout was sent with on
RIPPLE, null when it had none and on every other chain; toAddress is always the classic address,
also when you sent an X-address. fee is null on a payout made before withdrawal fees were
charged in the withdrawn token.
status |
Meaning |
|---|---|
PENDING |
Queued. Nothing on chain, hash is null |
BROADCASTING |
Being signed and sent right now. hash is usually still 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 --> BROADCASTING: being sent
BROADCASTING --> BROADCAST: published
BROADCASTING --> FAILED: could not be sent
PENDING --> FAILED: could not be sent
BROADCAST --> CONFIRMED: confirmed on chain
BROADCAST --> FAILED: reverted
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 to handle in that poller:
hashcan benullon aFAILEDrecord, permanently. A withdrawal that failed before broadcast never had a hash. Check the field before rendering an explorer link.decimalsis nullable. It isnullif the token has since been delisted. Fall back to a cached value from/api/v1/tokensrather than defaulting to 18.
A withdrawal that fails before it reaches the chain gives back both the amount and the fee to
your balance. One that was sent and then reverted gives back the amount only: the network fee was
spent.
Two cases are not settled automatically, and neither looks different from the outside:
- A
FAILEDwithdrawal that may have reached the chain. When a send times out in a way that leaves it unknown whether the transaction went out, the record isFAILEDbut nothing is given back, because the payout may still land. The record does not say which kind ofFAILEDit is: compare your balance, and contact support with the withdrawalidif the money did not return. - A
BROADCASTwithdrawal the chain dropped. It staysBROADCAST, with nothing given back. If one staysBROADCASTwell past the chain's confirmation time, contact support with itsidandhash.
error is set only on FAILED, as a code: WITHDRAWAL_REVERTED — the transaction reached the chain
and was reverted, the amount is back on your balance and the network fee is spent; WITHDRAWAL_FAILED —
anything else, nothing was sent. Treat an unknown code as WITHDRAWAL_FAILED; more may be added.