Payouts
A payout is a transfer of the merchant's funds to an address added to its whitelist in advance. An error in accepting payments leaves money with the service; an error in payouts sends it to someone else. That is why payouts are controlled more strictly: an address only from the whitelist and only after a delay, three limits and an auto-approval threshold, and a person's approval for a payout that exceeds any of them.
Where payouts are paid from
Only from a dedicated hot wallet. Not from deposit addresses and not from the cold treasury. The hot wallet balance has an upper bound and is topped up manually from the cold treasury: this caps the losses if the signer is compromised.
Create a payout
POST /v1/payouts
Request
| Field | Type | Required | Description |
|---|---|---|---|
reference |
string | yes | Payout identifier on the merchant's side, unique within the merchant: 1–200 visible ASCII characters, no spaces. Idempotency key |
asset |
string | yes | Asset, for example USDT_TRON. The service does not make payouts in every asset it accepts: see the Payouts column in assets |
to_address |
string | yes | The full recipient address, 20–100 letters and digits. Must be on the merchant's whitelist for this asset |
amount |
string | yes | Amount in units of the asset: "100.5", up to 100 characters. No more decimal places than the asset has, no sign, exponent, or spaces. Zero is not accepted |
The body accepts no other fields: an unknown field gets 400 validation_failed.
Response
202 means the payout was created, 200 means a payout with this reference had already been created earlier. The body is the same in both cases:
| Field | Type | Description |
|---|---|---|
payout_id |
string (UUID) | Payout identifier |
reference |
string | Payout identifier on the merchant's side |
status |
string | Status, see below |
asset |
string | Asset |
network |
string | Network of the asset, for example tron |
network_name |
string | Name of the network for people, from the service's network registry, for example TRON |
to_address |
string | The full recipient address |
amount |
string | Amount with all the decimals of the asset: "100.500000". The recipient receives all of it |
fees |
object | Payout fee: fees.network.payer, fees.network.percent, fees.network.fixed, fees.network.amount, fees.network.charged — see payout fee |
review_reasons |
array of string | Which rules the payout exceeded and therefore why it waits for a human — values in limits. Empty for a payout that passed within the rules |
tx_hash |
string or null | Transaction hash, once the transaction is sent |
explorer_url |
string or null | The page of the transaction on the explorer of the network; null before the transaction is sent, or if the network has no explorer link |
created_at |
string | Creation time, ISO 8601 in UTC |
updated_at |
string | Time of the last change, ISO 8601 in UTC |
A created payout gets the status queued if it passed all the rules, and pending_review if it did not. The second case is not an error: the payout is accepted and waits for a human decision, and review_reasons says what exactly it is waiting for.
Retrieve a payout
GET /v1/payouts/{id}
Responds with the same body. Payouts of other merchants do not exist for the caller: the response is 404 payout_not_found. An identifier that is not a UUID returns 400 validation_failed.
Statuses
pending_review → approved → queued → signing → broadcast → confirmed
↓ ↓
rejected failed
| Status | Meaning |
|---|---|
pending_review |
The payout exceeded a limit or a threshold and waits for human approval |
approved |
A human approved the payout. Set only by manual approval: a payout within the rules does not pass through this status |
queued |
The payout is accepted for execution and waits for signing |
signing |
The signer has taken the payout into work |
broadcast |
The transaction was sent to the network and waits for confirmations |
confirmed |
The network confirmed the transaction. Final status |
rejected |
A human rejected the payout. Final status: a retry requires a new reference |
failed |
The payout was not carried out, and the service no longer tries to send it. Final status: there are no automatic retries |
A payout becomes failed in three cases:
- its transaction landed in a block as unsuccessful;
- the signer refused to sign it — for example, the address failed the signer's independent whitelist check;
- the signer ran out of attempts, and none of the transactions it signed for this payout can reach a block any more.
A payout in failed is not retried automatically: sending it again without certainty that the earlier transaction did not reach the network would mean a double payout. A new attempt is a new payout with a new reference.
If a network node answered the broadcast with an error or did not answer at all, the payout does not become failed: the money may have left. It goes back to queued with the same tx_hash. The signer waits until it finds the transaction in the chain, or makes sure the transaction expired without reaching a block. Only then does the payout become broadcast or failed, or get a new transaction.
The transitions to confirmed and to failed come with a payout.confirmed or payout.failed webhook, see events. The event is written in the same database transaction as the status change, and there is one per payout, whichever service made the change. The service pays for the network from the hot wallet, and nothing is deducted from the payout amount: the recipient receives exactly amount (ADR-0023). On confirmation, the payout fee is charged to the project balance.
Payout fee
For every confirmed payout, STONS charges the project balance a fee: a percentage of the payout amount and a fixed amount, both known in advance and set for each asset separately, as is common practice in the market. How much the network actually burned does not affect the fee: the platform bears the difference. The fee fields are still in fees.network: the name is left from the time when it was a fixed network fee (changelog).
- Fee = fixed amount + ⌊
amount× percentage⌋. The percentage is rounded down to the last decimal place of the asset, in the project's favor. - The fee is recorded in the payout when it is created:
fees.network.amount, and next to it the percentage and the fixed amount it is made of. A tariff change does not change payouts already created. - It is charged on confirmation of the payout, in the same operation as the payout itself. Until confirmation,
fees.network.chargedis zero. - A payout in
failedand a rejected payout carry no fee: the fee is charged only on confirmation. - The project always pays. The recipient receives exactly
amount: the option "the recipient pays the fee" is not supported. - The operator sets the percentage and the amount. Until the operator has set them, the fee is zero.
- The balance is not checked when a payout is created, as before: payouts are constrained by the whitelist and the limits. The fee, like the payout itself, is charged on confirmation and can take the project balance below zero. The limits compare only
amount: the fee does not go to the recipient.
| Field | Description |
|---|---|
fees.network.payer |
Always merchant |
fees.network.percent |
The percentage recorded in the payout at creation, with two decimal places: "1.00" |
fees.network.fixed |
The fixed amount recorded in the payout at creation, with all the decimals of the asset |
fees.network.amount |
The whole fee recorded in the payout at creation: the fixed amount plus the percentage of amount, with all the decimals of the asset |
fees.network.charged |
How much of the fee was charged to the project balance for this payout, not counting the payout amount itself: fees.network.amount after confirmation, otherwise zero |
Example: a payout of 100.5 USDT with a fee of 1% and 1.70 USDT. The fee is 1.70 + 1.005 = 2.705 USDT. The recipient receives 100.5 USDT, and after confirmation the project balance decreases by 103.205 USDT.
Where you can pay
Only to addresses on the merchant's whitelist. The whitelist is kept by the STONS operator: neither the API nor the cabinet (Dashboard) can add an address to it.
- An address added to the whitelist can be used only after a delay:
WHITELIST_COOLDOWN_HOURS, 24 hours by default. The "usable from" moment is calculated when the address is added and is stored in the row, so changing the setting does not speed up addresses that were already added. Until that moment a payout is rejected withaddress_not_whitelisted, and the message names the moment from which the address becomes usable. - Adding an address is signed offline with the operator key. The signature covers the merchant, the asset, the full address, and the moment of adding, so it cannot be moved to another address or another merchant. A row without a valid signature is accepted neither by the command nor by the signer.
- Adding an address whose first 4 and last 4 characters match an address already on the merchant's whitelist is blocked until it is explicitly confirmed with both addresses shown in full. This cuts off substitution with a look-alike address (address poisoning).
- A payout address is never taken from transaction history.
- The address is checked against the whitelist twice: when the API creates the payout, and once more, independently, by the signer before signing. The payout keeps a reference to the whitelist entry that allowed it at creation, and the signer checks exactly that entry: its address matches the payout address in full, the entry is not revoked, the delay has passed, and the operator's signature on it is valid.
- Revoking an address requires no signature: it only removes the permission. A revoked address can be added again, and the delay starts over.
Limits
Four values apply. All of them are set in a single unit of account — dollars — because the daily limit of the service adds up payouts across all assets, and minimum units of assets with different decimals cannot be added together. Hence the rule: the service makes payouts only in dollar-pegged assets.
| Variable | What it limits |
|---|---|
PAYOUT_MAX_AMOUNT |
A single payout |
PAYOUT_AUTO_APPROVE_LIMIT |
The threshold below which human approval is not needed |
PAYOUT_DAILY_LIMIT_MERCHANT |
The total of one merchant's payouts per day |
PAYOUT_DAILY_LIMIT_TOTAL |
The total of the whole service's payouts per day |
The operator sets the values; they are not returned by the API. Daily limits are calculated over a rolling 24-hour window, not by calendar days: midnight resets nothing. The total includes payouts whose money can still leave or has already left, including those awaiting approval; rejected and failed payouts are not included. The limit caps the total together with the payout being created — that is, the overall outflow, not each payout separately.
Exceeding a limit does not reject the payout — it hands the decision to a human. One or more values appear in review_reasons:
| Value | When |
|---|---|
above_transaction_limit |
The amount is greater than PAYOUT_MAX_AMOUNT |
above_auto_approve_limit |
The amount is greater than PAYOUT_AUTO_APPROVE_LIMIT |
above_merchant_daily_limit |
With this payout, the merchant would exceed its daily limit |
above_service_daily_limit |
With this payout, the service as a whole would exceed the daily limit |
Manual approval
A human approves and rejects a payout with a command on the server: the admin API and the admin panel have no payout routes yet. In short:
- The operator reviews payouts in
pending_review:payout:list. The output shows the full address, the amount in minimum units of the asset, and the limits in effect. - The operator compares the recipient address character by character with what the merchant expects, and checks the reason for the review.
payout:approve --actor <who> --id <payout>moves the payout toapproved.payout:reject --actor <who> --id <payout> --note <why>moves it torejected, permanently.
Separation of duties. Whoever added an address to the whitelist cannot approve a payout to that address: the command refuses and names who added it. A single account must not be able both to choose an address and to release money to it. Rejection has no such restriction: anyone can stop a payout.
Every approval, rejection, and whitelist addition goes into the immutable audit log together with the full address.
Idempotency
A payout is created with the merchant's identifier reference, unique within the merchant. A repeated request with the same reference, asset, address, and amount does not create a second payout and does not return an error: the response is the payout already created, with code 200. This makes it safe to retry the request after a timeout, and a retry works even when payouts are halted. Concurrent requests with the same reference also create a single payout.
The amount is compared by value: "100.5" and "100.500000" are the same amount. The address is compared exactly, character by character.
Before looking up the payout already created, the service checks a retry the same way as the first request: the shape of the fields, whether the service makes payouts in the asset (422 asset_not_payable), and the format of the amount (400 validation_failed). A retry that fails these checks gets an error, not the payout. The other checks — the payout switch of the asset and the whitelist — come after the lookup and do not stop a retry of a payout that was already created.
A request with the same reference but a different amount, asset, or address is rejected with 409 reference_conflict: it is a different payout, not a retry.
Halting payouts
A halt applies to the payouts of one asset or to all of them at once. Reconciliation halts the payouts of an asset when the ledger for that asset diverges from the network or its postings do not add up. The circuit breaker halts all payouts when too many payouts have failed within a short time or their volume has jumped well above the usual. The operator halts payouts manually with an emergency command — for one asset or for all. While a halt is in effect, new payouts in the affected assets are not accepted — code 503 payouts_halted — and payouts created earlier are not signed and wait for the halt to be lifted; a retry with the same reference after the halt is lifted creates the payout. The reason for the halt is not disclosed: it describes an internal incident. A halt can be lifted only manually, with an entry in the audit log — see halting payouts.
Errors
| HTTP | error.code |
When |
|---|---|---|
| 400 | validation_failed |
A field is missing, unexpected, or malformed; the amount has too many decimal places; the amount is not greater than zero; the payout identifier in GET /v1/payouts/{id} is not a UUID |
| 401 | unauthorized |
The API key is missing or invalid |
| 403 | merchant_disabled |
The merchant is disabled |
| 404 | payout_not_found |
The payout does not exist or belongs to another merchant |
| 409 | reference_conflict |
A payout with this reference already exists and describes a different payout |
| 422 | asset_not_payable |
The service does not make payouts in this asset |
| 422 | asset_payouts_disabled |
The operator has temporarily disabled payouts in this asset. A retry with an existing reference returns the payout |
| 422 | address_not_whitelisted |
The address is not on the whitelist for this asset, or its delay has not passed |
| 429 | rate_limited |
The request limit is exceeded |
| 503 | payouts_halted |
Payouts are halted |
Example
curl -s -X POST https://api.stons.io/v1/payouts \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"reference": "wd_501", "asset": "USDT_TRON", "to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", "amount": "100.5"}'
{
"payout_id": "4c2f1a7e-93d5-4c0b-8a2e-5f6b7c8d9e01",
"reference": "wd_501",
"status": "queued",
"asset": "USDT_TRON",
"network": "tron",
"network_name": "TRON",
"to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"amount": "100.500000",
"fees": { "network": { "payer": "merchant", "percent": "1.00", "fixed": "1.700000", "amount": "2.705000", "charged": "0.000000" } },
"review_reasons": [],
"tx_hash": null,
"explorer_url": null,
"created_at": "2026-09-12T12:00:00.000Z",
"updated_at": "2026-09-12T12:00:00.000Z"
}
A payout that waits for a human responds with the same code 202:
{
"payout_id": "7e1b2c3d-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
"reference": "wd_502",
"status": "pending_review",
"asset": "USDT_TRON",
"network": "tron",
"network_name": "TRON",
"to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"amount": "900.000000",
"fees": { "network": { "payer": "merchant", "percent": "1.00", "fixed": "1.700000", "amount": "10.700000", "charged": "0.000000" } },
"review_reasons": ["above_auto_approve_limit"],
"tx_hash": null,
"explorer_url": null,
"created_at": "2026-09-12T12:05:00.000Z",
"updated_at": "2026-09-12T12:05:00.000Z"
}