Skip to content

Webhooks

PayWarden notifies your backend via HTTPS webhooks when payment events occur.

Events

PayWarden only delivers a webhook once a payment has been confirmed on-chain (after MIN_CONFIRMATIONS blocks). There is no separate "detected" or "expired" webhook — expiration and intermediate states are visible via the GET /api/v1/payments/:id endpoint.

EventWhen
payment.confirmedConfirmed on-chain, received amount matches expected
payment.underpaidConfirmed on-chain, received amount is less than expected
payment.overpaidConfirmed on-chain, received amount is greater than expected

Payload format

The payload is flat — no nested order object. Amounts are strings.

json
{
  "event": "payment.confirmed",
  "order_id": "550e8400-e29b-41d4-a716-446655440000",
  "external_id": "your-order-123",
  "amount_expected": "99.000000",
  "amount_received": "99.000000",
  "currency": "USDT",
  "network": "mainnet",
  "tx_hash": "abc123...",
  "from_address": "TPayer...",
  "to_address": "TXxx...",
  "status": "confirmed",
  "timestamp": "2026-01-01T00:05:00Z"
}

Signature verification

Every webhook includes an X-Signature header in the form sha256=<hex>, plus a stable X-Idempotency-Key and an X-Timestamp (unix seconds):

X-Signature: sha256=<hex>
X-Idempotency-Key: evt_<order-id>_<event>_<ts>
X-Timestamp: 1735689600

Always verify the signature before processing. This prevents replay attacks and spoofed events.

typescript
import crypto from 'crypto'

function verifyWebhook(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}
python
import hmac
import hashlib

def verify_webhook(raw_body: str, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        raw_body.encode(),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)
php
function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signature);
}

Retry logic

If your endpoint returns a non-2xx status (or the request errors/times out), PayWarden retries with exponential backoff: the delay roughly doubles each attempt, capped at 1 hour, for up to 10 total attempts.

After 10 failed attempts the order transitions to callback_failed. The payment is still on-chain and still visible via the API — only the notification was not acknowledged.

Idempotency

Each webhook delivery has a unique X-Idempotency-Key. If your endpoint receives the same key twice (due to network issues), it's safe to process only once.

typescript
// Store processed keys in Redis or your DB
const key = req.headers['x-idempotency-key']
if (await redis.sismember('processed_webhooks', key)) {
  return res.status(200).send('already processed')
}
await redis.sadd('processed_webhooks', key)
// ... process the event

Webhook endpoint requirements

  • Must be HTTPS in production
  • Must respond within the delivery timeout (10 seconds)
  • Must return HTTP 2xx to acknowledge receipt
  • Should be idempotent (safe to call multiple times — use X-Idempotency-Key)

Released under the BSL 1.1 License.