Skip to content
STONS

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.charged is zero.
  • A payout in failed and 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 with address_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:

  1. 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.
  2. The operator compares the recipient address character by character with what the merchant expects, and checks the reason for the review.
  3. payout:approve --actor <who> --id <payout> moves the payout to approved. payout:reject --actor <who> --id <payout> --note <why> moves it to rejected, 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"
}