Webhooks#
PayWatcher sends webhook events to your configured URL whenever a payment status changes. Use webhooks to automate order fulfillment, send customer notifications, or update your internal systems in real time.
Configure your webhook URL in the Dashboard under Settings → Webhooks. You can send a test webhook at any time to verify your endpoint.
Event Types#
PayWatcher dispatches the following event types:
| Event | Trigger | Description |
|---|---|---|
payment.confirmed | Payment reaches the required number of block confirmations | Payment successfully confirmed — safe to fulfill the order |
payment.expired | expiresAt timestamp passed without a matching on-chain transaction | No payment received within the time window |
payment.failed | Blockchain transaction failed | Technical failure during on-chain processing |
payment.test | Manually triggered via Settings → Webhooks → Send Test Webhook | Tests your webhook endpoint configuration |
Payload Format#
Every webhook event is delivered as a JSON POST request with the following structure:
payment.confirmed#
{
"event": "payment.confirmed",
"payment_id": "pay_7f2a3b4c-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"timestamp": "2026-02-20T10:00:00Z",
"data": {
"amount": "49.00",
"exact_amount": "49.000042",
"currency": "USDC",
"chain": "base",
"network": "base",
"chain_id": 8453,
"tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f",
"confirmations": 6
}
}payment.expired#
{
"event": "payment.expired",
"payment_id": "pay_7f2a3b4c-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"timestamp": "2026-02-20T11:00:00Z",
"data": {
"amount": "49.00",
"exact_amount": "49.000042",
"currency": "USDC",
"chain": "base",
"network": "base",
"chain_id": 8453,
"tx_hash": null,
"confirmations": 0
}
}payment.failed#
{
"event": "payment.failed",
"payment_id": "pay_7f2a3b4c-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"timestamp": "2026-02-20T10:05:00Z",
"data": {
"amount": "49.00",
"exact_amount": "49.000042",
"currency": "USDC",
"chain": "base",
"network": "base",
"chain_id": 8453,
"tx_hash": "0x9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c",
"confirmations": 2
}
}payment.test#
{
"event": "payment.test",
"payment_id": "pay_test_00000000-0000-0000-0000-000000000000",
"timestamp": "2026-02-20T10:00:00Z",
"data": {
"amount": "1.00",
"exact_amount": "1.000001",
"currency": "USDC",
"chain": "base",
"network": "base",
"chain_id": 8453,
"tx_hash": null,
"confirmations": 0
}
}Field Reference#
| Field | Type | Description |
|---|---|---|
event | string | Event type — one of payment.confirmed, payment.expired, payment.failed, payment.test |
payment_id | string | Unique payment identifier (format: pay_...) |
timestamp | string | ISO 8601 timestamp of when the event was triggered |
data.amount | string | Requested payment amount (as specified when creating the payment intent) |
data.exact_amount | string | Exact on-chain amount including micro-adjustment for unique matching |
data.currency | string | Currency — currently USDC |
data.chain | string | Blockchain network identifier (e.g. "base", "ethereum", "arbitrum") |
data.network | string | Blockchain network identifier — same value as chain |
data.chain_id | number | EVM chain ID (e.g. 8453 for Base, 1 for Ethereum) — see Supported Networks |
data.tx_hash | string | null | On-chain transaction hash (null for expired/test events) |
data.confirmations | number | Number of block confirmations at the time of the event |
Webhook payloads use snake_case field names (e.g. exact_amount, tx_hash). This differs from the API responses which use camelCase (e.g. exactAmount, txHash). Make sure your webhook handler uses the correct format.
Signature Verification#
Every webhook request includes an x-paywatcher-signature header containing an HMAC-SHA256 signature. Always verify the signature before processing an event to ensure the request was sent by PayWatcher and hasn't been tampered with.
How It Works#
- PayWatcher computes an HMAC-SHA256 hash of the raw request body using your webhook secret
- The hex-encoded hash is sent in the
x-paywatcher-signatureheader - Your server recomputes the hash with the same secret and compares the result
Node.js#
import crypto from "node:crypto";
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const signatureBuf = Buffer.from(signature, "hex");
if (expectedBuf.length !== signatureBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, signatureBuf);
}
// In your webhook handler:
const rawBody = req.body; // Use raw body, not parsed JSON
const signature = req.headers["x-paywatcher-signature"];
const secret = process.env.PAYWATCHER_WEBHOOK_SECRET;
if (!signature || !secret) {
return res.status(401).json({ error: "Missing signature or secret" });
}
if (!verifySignature(rawBody, signature, secret)) {
return res.status(401).json({ error: "Invalid signature" });
}Python#
import hmac
import hashlib
import os
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
# In your webhook handler:
raw_body = request.get_data() # Raw bytes, not parsed JSON
signature = request.headers.get("x-paywatcher-signature")
secret = os.environ.get("PAYWATCHER_WEBHOOK_SECRET")
if not signature or not secret:
return {"error": "Missing signature or secret"}, 401
if not verify_signature(raw_body, signature, secret):
return {"error": "Invalid signature"}, 401Always use timing-safe comparison (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks. Never use === or == for signature comparison.
Retry Behavior#
If your webhook endpoint does not respond with a 2xx status code, PayWatcher retries delivery up to 5 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | ~1 second |
| 2nd retry | ~2 seconds |
| 3rd retry | ~4 seconds |
| 4th retry | ~8 seconds |
| 5th retry | ~16 seconds |
- Delivery stops immediately when a
2xxresponse is received - After 5 failed retries, the delivery is marked as failed
- Failed deliveries are visible in the Dashboard under Settings → Webhooks → Delivery Log
Your endpoint should return a 200 response as quickly as possible. Process the webhook payload asynchronously (e.g. via a job queue) to avoid timeouts.
Best Practices#
- Verify signatures — Always check
x-paywatcher-signaturebefore processing events - Be idempotent — Your handler may receive the same event more than once (e.g. due to retries). Use
payment_id+eventto deduplicate - Respond quickly — Return
200immediately, then process the event asynchronously. Long-running handlers risk timeouts and unnecessary retries - Log everything — Store the raw payload and processing result for debugging. Check the Delivery Log in your Dashboard for failed attempts
- Use HTTPS — Your webhook URL must use HTTPS in production to protect the payload in transit