Webhooks
Webhooks tell your server about payments the moment they happen, so you don't need to poll.
Set up
- Dashboard → Webhooks → enter your HTTPS endpoint, for example
https://yourshop.com/webhooks/usdt. - Save. A signing secret
whsec_…is shown once. Store it asGATEWAY_WEBHOOK_SECRET. - Press Send test event. A
pingevent is delivered within seconds and appears in the deliveries list with its HTTP status.
Requirements: a public https:// URL (private and local network addresses are refused), a 2xx answer within 10 seconds, and no redirects (they are not followed).
Events
| Type | When | Typical action |
|---|---|---|
deposit.detected | A transfer to a customer's address appears on chain | Show "payment detected, confirming…" |
deposit.confirmed | account-configured confirmations confirmations reached and verified; your balance is credited | Credit the customer / fulfil the order |
deposit.collected | Funds swept to the gateway treasury | Optional bookkeeping |
settlement.approved | Your payout request was approved | Optional |
settlement.paid | Your payout was sent; data.payout_tx_hash is set | Reconcile your wallet |
ping | You pressed "Send test event" | Answer 200 |
Payload
POST /webhooks/usdt HTTP/1.1
Content-Type: application/json
User-Agent: USDT-Gateway-Webhook/1.0
X-Gateway-Event: deposit.confirmed
X-Gateway-Delivery: evt_7731
X-Gateway-Signature: t=1792915965,v1=5b0c9d…
{"id":"evt_7731","type":"deposit.confirmed","created":1792915965,"data":{ …deposit object… }}
For deposit.* events, data is the deposit object. For settlements, data is {id, amount, destination_address, status, payout_tx_hash}.
Verify the signature
The v1 value is HMAC-SHA256(secret, t + "." + raw_body) in hex. Always:
- Use the raw request body exactly as received, before any JSON parsing.
- Reject a timestamp more than 5 minutes old (this stops replays).
- Compare with a constant-time function.
PHP
$raw = file_get_contents('php://input');
if (!preg_match('/t=(\d+),v1=([0-9a-f]{64})/', $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '', $m)) { http_response_code(400); exit; }
if (abs(time() - (int) $m[1]) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, getenv('GATEWAY_WEBHOOK_SECRET'));
if (!hash_equals($expected, $m[2])) { http_response_code(401); exit; }
Node.js (Express): register the route with express.raw({ type: 'application/json' }) so you get the raw body:
const [, t, v1] = (req.get('X-Gateway-Signature') || '').match(/t=(\d+),v1=([0-9a-f]{64})/) || [];
const expected = crypto.createHmac('sha256', process.env.GATEWAY_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
const ok = t && Math.abs(Date.now() / 1000 - Number(t)) <= 300 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
Python (Flask)
raw = request.get_data()
m = re.match(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("X-Gateway-Signature", ""))
expected = hmac.new(SECRET.encode(), f"{m[1]}.".encode() + raw, hashlib.sha256).hexdigest()
ok = m and abs(time.time() - int(m[1])) <= 300 and hmac.compare_digest(expected, m[2])
Retries, ordering, duplicates
- A delivery that does not answer 2xx within 10 seconds is retried after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h and 24 h: 8 attempts over about two days. After that it is marked failed (visible in your deliveries list) and you should reconcile with
GET /api/v1/deposits. - Deliveries can arrive more than once and out of order (for example
deposit.confirmedbeforedeposit.detectedafter a retry). De-duplicate on the depositdata.id, and treat the depositstatusas the source of truth. - Answer quickly: store the event, return 200, and process it in the background.
Rotating the secret
Webhooks → tick Generate a new signing secret → Save. Update GATEWAY_WEBHOOK_SECRET on your server right away; events sent after the rotation are signed with the new secret.
