Webhooks API
PayWarden delivers webhooks via HTTP POST to the callback_url set when the order was created. This page documents the payload envelope, signature verification, and how to retry a failed delivery.
No webhook listing endpoint
There is no GET /admin/webhooks or standalone webhook retry endpoint. Webhook retries are driven per order via POST /api/v1/admin/orders/:id/retry-webhook (see the Admin API).
Delivery transport
- Method:
POST - Content-Type:
application/json - Timeout: 10 seconds per attempt
- Retries: exponential backoff (
2^nseconds, capped at 1 hour), up to 10 attempts total - Success criterion: HTTP
2xxresponse. Anything else (non-2xx, timeout, network error) is a failure and will be retried until the attempt budget is exhausted
After the budget is exhausted the order transitions to callback_failed. An admin can then force a new delivery via the retry-webhook endpoint referenced above.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body, signed with the merchant's webhook_secret |
X-Idempotency-Key | Stable string of the form evt_<order_id>_<event>_<ts>. The same key is sent on every retry of the same logical event — dedupe on this |
X-Timestamp | Unix timestamp (seconds) of the current delivery attempt |
Payload schema
All events share the exact same flat envelope:
interface WebhookPayload {
event: string // e.g. "payment.confirmed"
order_id: string // PayWarden order UUID
external_id: string // your merchant-side order id
amount_expected: string // requested amount, string
amount_received: string // observed amount, string
currency: string // "USDT"
network: string // "mainnet" | "nile" | "shasta"
tx_hash: string | null // TRON tx hash, null before payment is seen
from_address: string | null // payer address, null when unknown
to_address: string // the per-order receive address
status: string // current order status
timestamp: string // ISO 8601, when the payload was built
}Example body:
{
"event": "payment.confirmed",
"order_id": "550e8400-e29b-41d4-a716-446655440000",
"external_id": "order_123",
"amount_expected": "99.000000",
"amount_received": "99.000000",
"currency": "USDT",
"network": "nile",
"tx_hash": "def456abc789...",
"from_address": "TPayerAddressXxxx",
"to_address": "TReceiveAddressYyyy",
"status": "confirmed",
"timestamp": "2026-04-08T00:05:00.000Z"
}Event types
PayWarden emits webhooks only when a payment reaches a terminal on-chain status. The event field is one of:
| Event | Trigger | status field |
|---|---|---|
payment.confirmed | Confirmations reached and amount_received matches amount_expected | confirmed |
payment.underpaid | Confirmations reached but amount_received < amount_expected | underpaid |
payment.overpaid | Confirmations reached but amount_received > amount_expected | overpaid |
Expiry, sweep, and intermediate detected transitions are not delivered as webhooks — observe them via the Payments API.
Verifying the signature
Compute HMAC-SHA256(raw_body, webhook_secret) as lowercase hex and compare against the X-Signature header after stripping the sha256= prefix. Use a constant-time comparison.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Use the raw body
HMAC must be computed over the exact bytes you received. Re-serializing the parsed JSON will change whitespace/key order and break verification.
Idempotency
Every retry of the same logical event carries the same X-Idempotency-Key. Your handler should:
- Record the key on first successful processing.
- On any re-delivery with an already-seen key, return
2xximmediately without reprocessing.
This is what guarantees exactly-once business-level effects on your side despite PayWarden's at-least-once HTTP delivery.
Retrying a failed webhook
When an order is stuck in callback_failed and you want to redeliver:
POST /api/v1/admin/orders/:id/retry-webhook
Cookie: admin_token=<jwt>
Content-Type: application/json
{ "callback_url": "https://sandbox.example.com/hooks" }The body is optional. When provided, callback_url is a one-shot override used for this retry only — it is never persisted on the order. See Admin API → Retry webhook for full details.
Testing webhooks locally
Use ngrok or Cloudflare Tunnel to expose a local endpoint, then pass the public URL as callback_url when creating a test order:
ngrok http 3001
curl -X POST http://localhost:3000/api/v1/payments \
-H "X-API-Key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"external_id": "test_001",
"amount": "1.00",
"currency": "USDT",
"callback_url": "https://abc123.ngrok.io/webhooks/paywarden"
}'On Nile testnet you can use the faucet to send test USDT to the returned address and exercise the full delivery path.