Webhooks
Event types, signature verification recipe, retry ladder, dedup contract.
Webhooks are HMAC-signed POSTs to the URL on your partner record. JSON body, stable event discriminator, signature recomputed over the exact request body bytes. The signing key is your webhookSecret, not your apiSecret. For a working receiver in Node, Python, and Go, jump to the receiver scaffold.
Wire contract
Every delivery looks the same on the wire:
POST <partner.webhookUrl>
Content-Type: application/json
X-Partner-Webhook-Event: <event_type>
X-Partner-Webhook-Timestamp: <unix-seconds>
X-Partner-Webhook-Sign: <hex HMAC-SHA256(webhookSecret, raw body)>
{ "event": "<event_type>", "<resource>": { ... }, "ts": <unix-ms> }Trust the event field in the body, not the header. Validate the signature first, parse the JSON second.
Event types
Three event types are defined today. Each is opt-in. New partners receive order.status_changed by default; the others are enabled through your operator contact.
| event | resource key | When it fires |
|---|---|---|
| order.status_changed | order | The partner-facing status of one of your orders has changed. Body carries the full partner order DTO. |
| partner.paid_out | payout | The operator has marked your accumulated earnings as paid for a period. Accounting signal; no order context. |
| earnings.cleared | earnings | The operator has reset your accounting cycle. Audit-only, no money movement. |
Body — order.status_changed
{
"event": "order.status_changed",
"order": {
"id": "1f8c5e0b-9c92-4d6a-b0a8-3c5f8d6e7a4b",
"type": "float",
"status": "PENDING",
"time": { "reg": 1716800000, "expiration": 1716801800, "left": 1799, "update": 1716800123, "finish": null },
"from": { "code": "btc", "network": "Bitcoin", "amount": "0.01000000", "address": "bc1q...", "tx": { "id": "abcdef...", "amount": "0.01000000" } },
"to": { "code": "usdt_trc20", "network": "Tron", "amount": "619.987654", "address": "TXyz...", "tx": { "id": null, "amount": null } },
"back": { "address": "bc1q...refund", "tx": { "id": null } },
"emergency": { "status": [], "choice": "NONE" },
"rate": "62450.12345678",
"networkFee": "1.20",
"markupBps": 50
},
"ts": 1716800123456
}Order DTO fields match /v1/order exactly. The status field is frozen at enqueue time, so a rapid NEW → PENDING → EXCHANGE → DONE sequence produces four distinct deliveries instead of collapsing to repeated terminal rows.
Body — partner.paid_out
{
"event": "partner.paid_out",
"payout": {
"paidOutAt": 1716900000,
"periodFromTs": 1715000000,
"periodToTs": 1716900000,
"totalEarnedUsd": "1234.567890",
"txReference": "internal-payout-ref-42"
},
"ts": 1716900000456
}Body — earnings.cleared
{
"event": "earnings.cleared",
"earnings": {
"clearedAt": 1716900050,
"periodToTs": 1716900050,
"totalEarnedUsd": "1234.567890"
},
"ts": 1716900050000
}Signature verification
Compute HMAC-SHA256 over the exact request body bytes using your webhookSecret and compare against X-Partner-Webhook-Sign with a constant-time function. Reject deliveries whose X-Partner-Webhook-Timestamp is more than five minutes old or more than one minute in the future. Never re-serialise the body before verifying; any whitespace difference fails the check. For a full receiver, see the receiver scaffold.
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header_sign: str, header_ts: str, secret: str) -> bool:
"""Verify an inbound 0trace partner webhook.
Pass the exact request body bytes — not a re-serialised JSON object.
Any whitespace difference will invalidate the signature.
"""
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header_sign):
return False
try:
ts = int(header_ts)
except (TypeError, ValueError):
return False
now = int(time.time())
return -60 <= now - ts <= 300import crypto from 'node:crypto';
export function verifyWebhook(
rawBody, // Buffer
headerSign,
headerTs,
secret,
) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(headerSign, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
const ts = Number(headerTs);
const now = Math.floor(Date.now() / 1000);
return now - ts >= -60 && now - ts <= 300;
}Deduplication contract
One delivery per distinct partner-facing status transition. Background activity that doesn’t change the partner-facing status produces no webhook. Soft-deleted orders suppress all further deliveries silently. See Status mapping.
Retry ladder
Every delivery has a 10-second budget per attempt. On any non-2xx response, network error, or timeout, the next attempt is scheduled with this default backoff ladder:
| Attempt # | Wait before next attempt |
|---|---|
| 1 (initial fail) | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 4 hours |
| 5th failure | Abandon. No more retries; the event is logged for operator follow-up. |
The ladder above is the default. The operator can adjust it on request. The maximum attempt count (5) is fixed. Total delivery window from first attempt to abandon is ~4 hours 36 minutes (1 + 5 + 30 + 240 minutes).
Choosing your subscription
Default subscription on new partners: ["order.status_changed"]. To add partner.paid_out or earnings.cleared, request the change through your operator contact.