How you find out a payment settled when nobody is polling.
How delivery works
When a deposit or a payout settles, we POST a signed JSON body to your callback URL — configured
per merchant in the admin panel, or overridden per session (or per payout) with
callback_url.
Deliveries are queued to a persistent outbox and sent off the request path, so a slow or failing
endpoint on your side never blocks a settlement. Any 2xx counts as delivered.
Anything else is retried with exponential backoff — 30s → 1m → 2m → … → 1h, up to 8
attempts — before the delivery is marked failed. An operator can queue a fresh one from the admin
panel at any point.
An API key is required to receive webhooks at all. Every callback is signed with
your key's secret, and a merchant without a key is not sent one — an unsigned settlement notice is
unauthenticated, and anyone who learned your callback URL could forge it. Issue a key and queued
callbacks go out on the next attempt.
Events
Event
Sent when
What you do
payment.completed
A session deposit reached the required confirmation depth on-chain.
Credit the order's amount to the player, once per external_order_id.
payment.failed
A session deposit settled as failed.
Close the order; credit nothing.
deposit.confirmed
A transfer to a player's wallet address is final on-chain.
Credit credited, once per deposit_id.
payout.completed
A payout's relay transaction confirmed.
Mark the withdrawal paid.
payout.failed
A payout's relay transaction reverted or settled as failed.
Nothing left your wallet: return the funds to the player's balance, or pay again under a new external_id.
Headers
Header
Meaning
X-Webhook-Event
The event name, e.g. payment.completed.
X-Webhook-Id
Id of this delivery. Stable across retries of it.
X-Idempotency-Key
Stable key for the logical event, derived from the session or payout plus the event. Identical across retries and across an operator resend. Deduplicate on this one.
X-Webhook-Timestamp
Unix seconds when this attempt was signed. Part of the signed value.
X-Webhook-Signature
Lowercase hex HMAC-SHA256. Omitted only when signing is disabled in dev.
X-Webhook-Key-Id
Which of your keys signed it.
Verifying the signature
Webhooks are signed with your API key's secret — the same one you sign requests
to us with. The canonical string is simpler than the request one: no nonce, no method, no path,
just the timestamp and the exact body.
Accept only when expected equals X-Webhook-Signature (compared in constant
time) and the timestamp is within 5 minutes of your clock. Every retry is signed afresh, so a
legitimate delivery is never older than that; an old one is a replay.
import hashlib
import hmac
import time
def verify_webhook(secret, timestamp, signature, raw_body, now=None):
now = time.time() if now is None else now
if not (timestamp or "").isdigit() or abs(now - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"\n" + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
Verify against the raw body. Re-serializing the parsed JSON changes the bytes —
key order, spacing, escaping — and the signature will never match.
Test vector
The delivery in Payloads below, signed with the secret
test-secret. Your verifier should accept it when its clock reads
1785933667 (pass now explicitly), and reject it with any single byte of the
body changed, or when the clock is more than 300 seconds later.
Verify, record, answer. The handler only stores the event — keyed by
X-Idempotency-Key, so a repeat is a no-op — and answers 204; the
processing below runs afterwards, from your own queue or a worker reading that table. A slow
handler turns into retries.
CREATE TABLE time2pay_webhooks (
idempotency_key text PRIMARY KEY,
body text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
One switch over event. Each branch must be idempotent on its own business key —
external_order_id, deposit_id, external_id — because an
operator resend or your own reprocessing can hand you the same event twice.
Ledger is your code.
export async function processEvent(ledger, raw) {
const e = JSON.parse(raw);
switch (e.event) {
case "payment.completed":
return ledger.creditDeposit(e.external_order_id, e.token, e.amount);
case "payment.failed":
return ledger.failDeposit(e.external_order_id);
case "deposit.confirmed":
return ledger.creditWalletDeposit(e.deposit_id, e.player_id, e.token, e.credited);
case "payout.completed":
return ledger.completeWithdrawal(e.external_id ?? e.payout_id, e.tx_hash);
case "payout.failed":
return ledger.failWithdrawal(e.external_id ?? e.payout_id, e.failure_reason ?? "");
}
}
Payout events carry external_id when you sent one, and always payout_id.
Batch items also carry batch_id and batch_index.
Payloads
A delivery on the wire, exactly as it arrives — the body is one line of JSON and it is those bytes
that are signed, so this is what your handler has to hash before it parses anything:
A payout delivery carries the same headers with X-Webhook-Event: payout.completed,
its own X-Idempotency-Key and X-Webhook-Id, and a timestamp and signature
of its own. The bodies of each event are below, formatted.
Which fields are always there. A deposit event always carries
event, session_id, merchant_id, status and
occurred_at; a payout event the same five with payout_id in place of
session_id; a wallet deposit event, deposit_id,
player_id, the amounts and occurred_at. Everything else is omitted when
empty rather than sent as null — payment.failed, for instance, usually
arrives with no tx_hash at all. Read them as optional and your handler survives an
event that never got that far.
POSTpayment.completedno signature
A deposit settled
Sent to your callback URL when a deposit session reaches the required confirmation depth, or settles as failed (event payment.failed). Signed with your API key secret. Deduplicate on X-Idempotency-Key.
Any 2xx marks the delivery successful. Anything else is retried with backoff: 30s, 1m, 2m … 1h, up to 8 attempts.
POSTdeposit.confirmedno signature
A player funded their wallet address
Sent when a transfer to a player's wallet address is final on-chain. Tron is scanned only up to its solidified block, so this event means the funds cannot be reversed — there is no pending stage and no later reversal to handle. Signed with your API key secret. Deduplicate on deposit_id, which is stable for one transfer forever; a transaction carrying two transfers to the same address produces two events with different deposit_id values. Credit the player credited: it is amount, what arrived on chain, minus the processing fee for that token, which is in fee. Your merchant commission is not taken from the player and is not in this event; it is settled with you separately. A transfer at or below the processing fee is not credited and sends no event: the whole amount is kept as the fee. amount_raw is credited in base units.
Any 2xx marks the delivery successful. Anything else is retried with backoff: 30s, 1m, 2m … 1h, up to 8 attempts.
POSTpayout.completedno signature
A payout settled
Sent when a payout's relay transaction confirms, or reverts (event payout.failed). Carries the payout in place of the session. An item of a batch also carries batch_id and batch_index.
Verify the signature first, before parsing or trusting anything in the body.
Deduplicate on X-Idempotency-Key. You will receive the same logical event more than once — that is the retry policy working, not a bug.
Answer fast. Return 2xx as soon as you have durably recorded the event, and do your own work afterwards.
Credit on the webhook, not on a browser redirect or an iframe event. Those tell you what to show the player; this one tells you what happened.
Reconcile what never arrived. A deposit or payout still open well after it should have settled can be read with the session or payout status endpoint and processed the same way.