Skip to content

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

curl https://api.pay.nullswap.com/api/v1/merchant/balances \
  -H "x-api-key: sk_live_..."
{
  "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.

fee      = floor(amount × feePercentage / 10000)
credited = amount − fee − network fee

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; SKIPPED lets 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 to that is not on the list for that chain is refused with 422 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. On RIPPLE an entry is an address and a destination tag together, and a payout matches only the entry with exactly its toTag — 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 with 422 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, and details.minAmount says 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:

  • hash can be null on a FAILED record, permanently. A withdrawal that failed before broadcast never had a hash. Check the field before rendering an explorer link.
  • 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.

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 FAILED withdrawal 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 is FAILED but nothing is given back, because the payout may still land. The record does not say which kind of FAILED it is: compare your balance, and contact support with the withdrawal id if the money did not return.
  • A BROADCAST withdrawal the chain dropped. It stays BROADCAST, with nothing given back. If one stays BROADCAST well past the chain's confirmation time, contact support with its id and hash.

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.