Skip to content

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^n seconds, capped at 1 hour), up to 10 attempts total
  • Success criterion: HTTP 2xx response. 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

HeaderValue
Content-Typeapplication/json
X-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body, signed with the merchant's webhook_secret
X-Idempotency-KeyStable 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-TimestampUnix timestamp (seconds) of the current delivery attempt

Payload schema

All events share the exact same flat envelope:

typescript
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:

json
{
  "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:

EventTriggerstatus field
payment.confirmedConfirmations reached and amount_received matches amount_expectedconfirmed
payment.underpaidConfirmations reached but amount_received < amount_expectedunderpaid
payment.overpaidConfirmations reached but amount_received > amount_expectedoverpaid

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.

ts
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:

  1. Record the key on first successful processing.
  2. On any re-delivery with an already-seen key, return 2xx immediately 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:

http
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:

bash
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.

Released under the BSL 1.1 License.