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:

Request shape
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.

eventresource keyWhen it fires
order.status_changedorderThe partner-facing status of one of your orders has changed. Body carries the full partner order DTO.
partner.paid_outpayoutThe operator has marked your accumulated earnings as paid for a period. Accounting signal; no order context.
earnings.clearedearningsThe operator has reset your accounting cycle. Audit-only, no money movement.

Body — order.status_changed

POST body
{
  "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

POST body
{
  "event": "partner.paid_out",
  "payout": {
    "paidOutAt": 1716900000,
    "periodFromTs": 1715000000,
    "periodToTs": 1716900000,
    "totalEarnedUsd": "1234.567890",
    "txReference": "internal-payout-ref-42"
  },
  "ts": 1716900000456
}

Body — earnings.cleared

POST body
{
  "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.

Python (FastAPI / Flask)
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 <= 300
Node.js
import 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
25 minutes
330 minutes
44 hours
5th failureAbandon. 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.

Partner API.
Same engine as 0trace.

A private partner integration surface. Signed quotes, server-side pricing, webhook delivery, multiple reference codes, and a self-serve cabinet — all backed by the production exchange engine.

Support

Questions? Answers.

Get the latest updates

Follow us on X

The 0trace team will never ask for KYC or AML. We retain no logs, metadata, or tracking cookies.

Learn more