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.
| Event | When |
|---|---|
payment.confirmed | Confirmed on-chain, received amount matches expected |
payment.underpaid | Confirmed on-chain, received amount is less than expected |
payment.overpaid | Confirmed on-chain, received amount is greater than expected |
Payload format
The payload is flat — no nested order object. Amounts are strings.
{
"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: 1735689600Always verify the signature before processing. This prevents replay attacks and spoofed events.
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)
)
}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)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.
// 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 eventWebhook 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)