Webhooks
The service notifies the merchant about events — a payment is detected, a payment is confirmed, an invoice has expired — with a POST request to the webhook URL from the project settings. An event is recorded in the same database transaction as the change it reports, and it is sent after that transaction is committed.
The main rule
Do not trust the webhook body. When you receive a webhook, verify the signature, then request the current state of the object from the API and act on the API response. The webhook body is a "go and check" signal, not a source of truth.
The reason: if the webhook secret leaks or signature verification has a bug, an attacker could send a forged "paid". If orders are fulfilled only based on the API response, a forgery achieves nothing.
Which request confirms an event:
| Event | Request |
|---|---|
invoice.* |
GET /v1/invoices/{id} with data.invoice_id — see retrieve an invoice |
deposit.* with data.invoice_id |
GET /v1/invoices/{id} with data.invoice_id: a confirmed transfer is in deposits with the same tx_hash. The transfer itself is in GET /v1/deposits/{tx_hash} with data.tx_hash |
deposit.* with data.invoice_id = null |
GET /v1/deposits/{tx_hash} with data.tx_hash: find the transfer by data.log_index and credit the top-up only if it has status = confirmed and credited = true — see verify a transfer |
payout.* |
GET /v1/payouts/{id} with data.payout_id — see retrieve a payout |
Events
| Event | When it arrives |
|---|---|
invoice.seen |
The invoice status became seen: a transfer that pays the invoice landed in a block before the deadline. There are no confirmations yet: do not fulfill the order. After a reorganization, and after invoice.partially_paid when the next transfer arrives, the same invoice can receive invoice.seen again, as a new event with a new id |
invoice.partially_paid |
The invoice status became partially_paid: before the deadline, confirmed transfers cover only part of amount_expected, and no transfer is waiting for confirmations. Do not fulfill the order: the customer can send the rest to the same address before the deadline — see partial payment. It arrives on every transition to this status, including again after invoice.seen |
invoice.confirmed |
Confirmed transfers made before the deadline — by the time of their block — cover amount_expected. Fulfill the order after checking the status through the API |
invoice.overpaid |
Together with invoice.confirmed, as a separate event with the same data, if at the moment of confirmation amount_received is greater than amount_expected. A transfer to an invoice that is already confirmed produces only deposit.confirmed |
invoice.underpaid |
The deadline has passed; the confirmed transfers made before it cover only part of amount_expected, and no transfer made after the deadline is confirmed |
invoice.expired |
The deadline has passed without a confirmed transfer. It also arrives for an invoice whose customer did not choose a token before the deadline |
invoice.paid_late |
The deadline has passed without amount_expected covered by transfers made before it, and a transfer made after the deadline was confirmed. It usually follows invoice.expired or invoice.underpaid |
deposit.confirmed |
A payment reached the number of confirmations its asset requires. One event per transfer. In per_user mode it also arrives for a top-up without an invoice: then invoice_id is null |
deposit.orphaned |
A detected payment that was not confirmed yet disappeared from the chain in a reorganization, or its transaction turned out to be unsuccessful. The transfer stops counting toward the invoice. If it lands in the chain again, it counts again, and deposit.confirmed arrives when it is confirmed |
payout.confirmed |
The network confirmed the payout: the funds went to the recipient. Final state |
payout.failed |
The payout became failed: the funds did not leave. Either its transaction landed in a block as unsuccessful, or the signer refused to sign it, or the signer ran out of attempts and none of its transactions can reach a block any more. The payout is not retried automatically — a new attempt requires a new reference. See payout statuses |
Invoice statuses and the transitions between them are described in invoices. invoice.* events arrive only when the invoice status changes: a transfer that does not change the status produces none, and deposit.confirmed reports every confirmed payment. The transition to settled has no event: for the merchant it is the same as confirmed. The return to created after a reorganization has no invoice.* event of its own — deposit.orphaned reports the transfer that disappeared. Creating an invoice and the customer's choice of a token have no events either.
Dust — a transfer below the minimum deposit of its asset, other than the rest of an invoice payment already started (see partial payment) — a transfer of another asset, and a token outside the service's catalog produce no events.
New event types are announced in the changelog. Respond 2xx to an event type your handler does not process: any other response makes the service retry the delivery until the attempts run out.
Body
{
"id": "0b7e6f2a-8c1d-4e5f-9a3b-2c4d6e8f0a1b",
"type": "invoice.confirmed",
"created_at": "2026-09-11T20:05:41.118Z",
"data": {
"invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
"order_id": "ord_1001",
"status": "confirmed",
"asset": "USDT_TRON",
"network": "tron",
"amount_requested": "49.900000",
"amount_expected": "49.900000",
"amount_received": "49.900000",
"amount_credited": "49.401000",
"fees": {
"service": { "payer": "merchant", "percent": "1.00", "fixed": "0.000000", "amount": "0.499000" },
"network": { "payer": "merchant", "quote": null, "quote_rate": null, "actual": null, "status": "included" }
},
"external_user_id": null
}
}
The examples in this document are formatted for reading. The body that arrives is the same JSON object, but the order of its keys and the whitespace can differ: verify the signature against the bytes received, and read fields by name.
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Event identifier. A redelivery of the event arrives with the same id: the handler uses it to drop duplicates. invoice.confirmed and invoice.overpaid of one change are two events with different id values |
type |
string | An event from the table above |
created_at |
string | When the event was recorded, ISO 8601 in UTC. It does not change on redelivery |
data |
object | The event object, fields below |
data fields of invoice.* events:
| Field | Type | Description |
|---|---|---|
invoice_id |
string (UUID) | Invoice identifier |
order_id |
string | The merchant's order identifier |
status |
string | Invoice status after the change: seen, partially_paid, confirmed, underpaid, expired, or paid_late. In invoice.overpaid it is confirmed |
asset |
string or null | Asset; null for an invoice whose deadline passed without a token being chosen |
network |
string or null | Network of the asset: the network id, such as tron, as in network of the invoice; null for an invoice whose deadline passed without a token being chosen |
amount_requested |
string or null | The merchant's price, with all the decimals of the asset |
amount_expected |
string or null | The amount the customer must pay, with all the decimals of the asset: the price plus the service fee if the customer pays it |
amount_received |
string or null | Total of confirmed transfers, made before and after the deadline, at the moment of the event |
amount_credited |
string or null | amount_received minus fees.service.amount |
fees |
object | The service fee — payer, percent, fixed, and amount withheld — and the network fee line, as in the invoice |
external_user_id |
string or null | The merchant's user or null |
The data has no address, price, currencies, amount_remaining, checkout_url, expires_at, or deposits: they are in GET /v1/invoices/{id}.
An invoice whose token the customer chooses (invoices) can receive only invoice.expired before the choice. In it, asset, network, amount_requested, amount_expected, amount_received, amount_credited, fees.service.fixed, and fees.service.amount are null. There is no separate event for the token choice: the chosen token is visible in GET /v1/invoices/{id} and in the events that follow.
The fee on a payment is posted in the same transaction as the payment's confirmation, so invoice.confirmed already carries the withheld fee and the amount_credited after it.
data fields of deposit.* events:
| Field | Type | Description |
|---|---|---|
tx_hash |
string | Transaction hash: on TRON and TON, 64 hexadecimal characters; on EVM networks, 0x and 64; both in lower case. On Solana it is the first signature of the transaction: 86 to 88 base58 characters, whose case carries meaning and is kept exactly as the network writes it — lower-casing it destroys the value — see addresses on Solana. On TON, the transaction in which the deposit address, or its jetton wallet, received the money — see verify a transfer |
log_index |
integer | Number of the transfer within the transaction: together with tx_hash and network it uniquely identifies the payment. Negative for a transfer of a native coin: -1 is the value of the transaction itself, and on TRON and EVM networks -2, -3 and so on are transfers made by contracts within it. On TON, 0 for a token and -1 for GRAM. Treat the pair tx_hash + log_index as an opaque key: what the number means differs between networks, and on Solana a token transfer is told apart by the token account whose balance changed, not by a position in a list of logs |
asset |
string | Asset |
network |
string | Network of the transfer, such as tron. The same transaction hash can exist in two networks |
amount |
string | Transfer amount with all the decimals of the asset |
address |
string | Deposit address, in full, in the same form as in POST /v1/addresses and in the invoice: on TON, user-friendly and non-bounceable — see addresses on TON |
invoice_id |
string (UUID) or null | The invoice the transfer pays, or null for a top-up in per_user mode |
external_user_id |
string or null | The user of a per_user address; always null for a per_invoice address |
block_time |
string | Time of the block with the transfer, ISO 8601 in UTC |
confirmations |
integer | How many blocks have been added on top of the block with the transfer: in deposit.confirmed, at least the number the asset requires; in deposit.orphaned, the last depth the service saw |
deposit.* events carry no fee fields: the fee of an invoice payment is in invoice.* events and in the invoice, and the fee of a top-up is visible in the project balance. The same fields, with the current confirmations and with kind, status, block_number and credited, are in the response of GET /v1/deposits/{tx_hash}.
Example of deposit.confirmed:
{
"id": "5d2c9e41-7b3a-4c8f-a1e6-9f0b2d4c6e8a",
"type": "deposit.confirmed",
"created_at": "2026-09-11T20:05:41.118Z",
"data": {
"tx_hash": "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c",
"log_index": 0,
"asset": "USDT_TRON",
"network": "tron",
"amount": "49.900000",
"address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"invoice_id": "6f1d3c1e-2f7a-4f0e-9a51-0c8b3e2d7a44",
"external_user_id": null,
"block_time": "2026-09-11T20:04:38.000Z",
"confirmations": 20
}
}
data fields of payout.* events:
| Field | Description |
|---|---|
payout_id |
Payout identifier |
reference |
Payout identifier on the merchant's side |
status |
confirmed or failed |
asset |
Asset |
amount |
Payout amount with all the decimals of the asset |
fees |
Payout fee: network.payer, network.percent, network.fixed, network.amount, network.charged — as in the payout. For payout.confirmed, network.charged equals network.amount; for payout.failed, it is zero |
to_address |
Recipient address, in full |
tx_hash |
Transaction hash. For payout.failed, the last transaction of the payout, which moved no funds; null if the signer signed nothing |
reason |
Only for payout.failed: what exactly happened, as English text for a human. Do not parse it |
Example of payout.confirmed:
{
"id": "9c4b1a2e-7d3f-4a5b-8c6d-0e1f2a3b4c5d",
"type": "payout.confirmed",
"created_at": "2026-09-12T12:15:03.402Z",
"data": {
"payout_id": "4c2f1a7e-93d5-4c0b-8a2e-5f6b7c8d9e01",
"reference": "wd_501",
"status": "confirmed",
"asset": "USDT_TRON",
"amount": "100.500000",
"fees": { "network": { "payer": "merchant", "percent": "1.00", "fixed": "1.700000", "amount": "2.705000", "charged": "2.705000" } },
"to_address": "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH",
"tx_hash": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
}
}
Nothing is deducted from the payout amount: the recipient receives exactly amount. The payout fee — the percentage and the fixed amount — is charged to the project balance in the same operation as the confirmation, so payout.confirmed already carries it in fees.network.charged.
Signature
Every webhook includes these headers:
Content-Type: application/json
User-Agent: STONS webhooks
X-Timestamp: <send time of this attempt, Unix seconds>
X-Signature: sha256=<HMAC-SHA256 in lowercase hex>
The signature is an HMAC-SHA256 over the string <X-Timestamp>.<raw request body>, with the project's webhook secret as the key. The key is the secret string exactly as the cabinet (Dashboard) showed it: do not decode it from base64url. It is the raw body that is signed: the bytes exactly as they arrived, before JSON parsing. JSON that has been parsed and serialized again can differ in key order and whitespace, and the signature will not match.
Each attempt is signed anew: a redelivery has the same body and id, but its own X-Timestamp and X-Signature.
The recipient must:
- reject the webhook if
X-Timestampdiffers from the current time by more than 5 minutes: this way an intercepted webhook cannot be replayed later; - compare signatures in constant time, not with an ordinary string comparison.
Verification in Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody is a Buffer with the raw request body, before JSON.parse.
export function verifyWebhook({ rawBody, signatureHeader, timestampHeader, secret, nowMs = Date.now() }) {
if (typeof timestampHeader !== "string" || !/^\d{1,12}$/.test(timestampHeader)) return false;
if (Math.abs(nowMs / 1000 - Number(timestampHeader)) > TOLERANCE_SECONDS) return false;
const match = /^sha256=([0-9a-f]{64})$/.exec(signatureHeader ?? "");
if (match === null) return false;
const expected = createHmac("sha256", secret).update(`${timestampHeader}.`).update(rawBody).digest();
return timingSafeEqual(Buffer.from(match[1], "hex"), expected);
}
Verification in Python:
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
# raw_body is bytes with the raw request body, before json.loads.
def verify_webhook(raw_body, signature_header, timestamp_header, secret, now=None):
if timestamp_header is None or re.fullmatch(r"[0-9]{1,12}", timestamp_header) is None:
return False
current = time.time() if now is None else now
if abs(current - int(timestamp_header)) > TOLERANCE_SECONDS:
return False
match = re.fullmatch(r"sha256=([0-9a-f]{64})", signature_header or "")
if match is None:
return False
signed = timestamp_header.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(1))
An example to test your implementation against. With the secret example-webhook-secret, X-Timestamp: 1789300000, and the body {"id":"0b7e6f2a-8c1d-4e5f-9a3b-2c4d6e8f0a1b","type":"invoice.confirmed"} without a trailing newline, the header is X-Signature: sha256=de0f803a28ef65326019f2500bd933b61882216dedb6e22ea2a03c32c9b272c0. A test of the service checks this example.
Delivery
- A webhook counts as delivered if the merchant's URL responded with any
2xxcode within 10 seconds. The response body is not read. - Otherwise the send is retried 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours after the previous attempt: 7 attempts in total, the last one no earlier than 8 hours 36 minutes after the first. An attempt fails on a
3xx,4xx, or5xxresponse, on a network error, and when there is no response within 10 seconds. After the last failure the webhook is marked as undelivered, and the operator gets an alert. - Delivery is "at least once": the same event can arrive several times. The handler must be idempotent — processing an event again must not fulfill the order again.
- An event is written in the same database transaction as the change it reports. So an event is not lost if a failure happens between the change and the send. Events are sent in parallel, and the delivery order is not guaranteed:
invoice.confirmedcan arrive beforeinvoice.seen. Rely on the state from the API. - Each attempt goes to the webhook URL in effect at the moment of the attempt: after the owner changes the URL, pending retries go to the new one.
- For a project without a webhook URL — for example, one created in the cabinet for invoices issued manually — events are not recorded at all. Invoices are still paid and credited as usual. Events that happened before the URL was set are not sent after it appears: get the state from the API. A URL that has been set can be replaced but not removed.
Respond 2xx right after verifying the signature, and do long processing asynchronously: otherwise the 10-second timeout leads to unnecessary retries.
A 3xx response does not count as success, and the service does not follow it: a signed body must not go to another address. If you change your URL, change it in the project settings in the cabinet.
Every delivery attempt is recorded: the response code, the duration, and the error. The project owner sees the events of an invoice on its page in the cabinet. A delivery marked "Not delivered" can be sent again with the "Send again" button: that is one more attempt with the same body and id, and if it fails, the delivery is marked as undelivered again. A delivered event is not sent again. The operator can resend an undelivered event too.