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:

EventTriggerDescription
payment.confirmedPayment reaches the required number of block confirmationsPayment successfully confirmed — safe to fulfill the order
payment.expiredexpiresAt timestamp passed without a matching on-chain transactionNo payment received within the time window
payment.failedBlockchain transaction failedTechnical failure during on-chain processing
payment.testManually triggered via Settings → Webhooks → Send Test WebhookTests your webhook endpoint configuration

Payload Format#

Every webhook event is delivered as a JSON POST request with the following structure:

payment.confirmed#

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

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

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

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

FieldTypeDescription
eventstringEvent type — one of payment.confirmed, payment.expired, payment.failed, payment.test
payment_idstringUnique payment identifier (format: pay_...)
timestampstringISO 8601 timestamp of when the event was triggered
data.amountstringRequested payment amount (as specified when creating the payment intent)
data.exact_amountstringExact on-chain amount including micro-adjustment for unique matching
data.currencystringCurrency — currently USDC
data.chainstringBlockchain network identifier (e.g. "base", "ethereum", "arbitrum")
data.networkstringBlockchain network identifier — same value as chain
data.chain_idnumberEVM chain ID (e.g. 8453 for Base, 1 for Ethereum) — see Supported Networks
data.tx_hashstring | nullOn-chain transaction hash (null for expired/test events)
data.confirmationsnumberNumber 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#

  1. PayWatcher computes an HMAC-SHA256 hash of the raw request body using your webhook secret
  2. The hex-encoded hash is sent in the x-paywatcher-signature header
  3. Your server recomputes the hash with the same secret and compares the result

Node.js#

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

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"}, 401

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

AttemptDelay
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 2xx response 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-signature before processing events
  • Be idempotent — Your handler may receive the same event more than once (e.g. due to retries). Use payment_id + event to deduplicate
  • Respond quickly — Return 200 immediately, 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