2026-07-10 · 6 min read

Handling x402 payment webhooks: settlement, payouts, and reconciliation

The 402 challenge handles the moment of payment: an agent calls, gets a price, pays, retries, and your route serves the result. But your application still needs to know things after the fact — that a payment settled, that a payout was sent, that one failed — so it can update a ledger, trigger fulfillment, or alert someone. That's what webhooks are for. This post is how to consume them correctly, using the same discipline you'd apply to any payment webhook: verify, dedupe, respond fast, reconcile.

What are x402 payment webhooks? They're signed HTTP callbacks a payment platform sends to a URL you register, telling your app about payment lifecycle events — a payment settling, a payout sending or failing — so your systems can react without polling. Each delivery is signed so you can verify it's genuine, and carries a stable event ID so you can process it exactly once.

The events

A payment platform emits a small set of lifecycle events. For x402 payments through Paywall, there are four:

Event Fires when Use it to
payment.verified A payment is verified (ahead of on-chain settlement in live mode) Optimistically unlock or log intent
payment.settled Funds have settled Credit your ledger, trigger fulfillment — the one to reconcile revenue on
payout.sent A payout to your wallet succeeded Mark the payout received
payout.failed A payout attempt failed Alert and investigate

The event to build your accounting on is payment.settled — it's the one that means money is actually yours. Treat payment.verified as an early signal, not a settlement guarantee.

The payload

Every webhook shares one envelope, so your handler can parse the shape once:

{
  "event": "payment.settled",
  "event_id": "evt_9f8c2b1a-...",
  "created_at": "2026-07-10T12:00:00Z",
  "data": {
    "payment_id": "...",
    "merchant_id": 123,
    "amount_cents": 5,
    "net_cents": 4,
    "mode": "live",
    "status": "settled"
  }
}

Two things worth noting about the fields:

  • Amounts are integer cents (amount_cents gross, net_cents after the platform fee). Payout events instead carry payout_batch_id, net_cents, amount_atomic (a stringified token amount), and wallet. Don't expect floating-point currency.
  • event_id (evt_ + UUID) is stable and unique per event. It's the key you dedupe on — more on that below.

Step 1: verify the signature

Never trust a webhook body until you've verified it came from the platform and not an attacker who found your endpoint. Each delivery is signed with HMAC-SHA256 over the raw request body, using a per-endpoint secret, and the signature travels in the X-X402-Signature header (lowercase hex). Two more headers ride along: X-X402-Event (the event name) and X-X402-Delivery-Id.

Recompute the HMAC over the raw bytes — not the re-serialized JSON, which may differ — and compare in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(rawBody, signatureHeader, endpointSecret) {
  const expected = createHmac("sha256", endpointSecret)
    .update(rawBody)           // the exact bytes received
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Capture the raw body before any JSON middleware parses it, or the bytes you hash won't match the bytes that were signed. The signing secret (a whsec_… value) is shown to you once, when you create the endpoint — store it like any other secret. One caveat worth knowing: the signature covers the body only, with no timestamp, so it doesn't give you a built-in replay window the way some providers do — lean on the signature plus idempotency (next) rather than a time-based check.

Step 2: process idempotently

Webhook delivery is at-least-once: on a timeout or a non-2xx response, the platform retries, so you will occasionally receive the same event twice. If your handler isn't idempotent, a retry double-credits a ledger or double-fulfills an order.

Dedupe on event_id. Before processing, check whether you've already handled that ID; after success, record it. A unique constraint on event_id in your database is the simplest enforcement:

-- store processed events; the unique key makes reprocessing a no-op
INSERT INTO processed_webhooks (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING;
-- if zero rows were affected, you've seen this event — skip it

Step 3: respond 200 fast, work asynchronously

Return 200 as soon as you've verified the signature and recorded the event — before running slow business logic. Webhook senders treat a slow or non-2xx response as a failure and retry, which just multiplies load. Acknowledge quickly, then do the real work (fulfillment, emails, ledger math) in a background job.

This matters more than usual with x402 because the delivery timeout is short — 10 seconds — and retries follow an exponential backoff (roughly 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours, up to six attempts before a delivery is marked failed). A handler that does heavy work inline will trip that timeout and generate retries you didn't need.

Step 4: reconcile — don't rely on webhooks alone

Webhooks are the fast path, not the source of truth. Any callback system can miss a delivery — your endpoint was down for the whole retry window, a deploy dropped a request, a bug returned a 500. The discipline every payments team learns is: treat webhooks as a real-time signal, and periodically reconcile against an authoritative record.

In practice: consume payment.settled events to update your ledger in real time, and on a schedule, pull the platform's own record of settled payments and payouts and compare. Anything present there but missing from your ledger is a delivery you dropped — replay it. With per-route attribution on the platform side, you can also see which endpoints earned, independent of whether every webhook landed.

What to build, in one list

  • Verify every delivery: HMAC-SHA256 of the raw body against X-X402-Signature, constant-time compare.
  • Dedupe on event_id with a unique constraint.
  • Acknowledge with 200 before heavy work; process async.
  • Act on payment.settled for revenue; payout.sent/payout.failed for payout state.
  • Reconcile on a schedule against the platform's settled-payment record.
  • Store the signing secret securely — it's shown once at endpoint creation.

FAQ

How do I verify an x402 webhook is genuine? Recompute HMAC-SHA256 over the raw request body using your endpoint's signing secret, and compare it in constant time to the X-X402-Signature header. If they don't match, reject the request.

Why did I receive the same webhook twice? Delivery is at-least-once. A timeout or a non-2xx response triggers a retry, and a retry can arrive after your handler actually succeeded. Dedupe on event_id so reprocessing is a no-op.

Which event means I've been paid? payment.settled means funds have settled; it's the event to reconcile revenue on. payment.verified is an earlier signal, and payout.sent confirms a payout to your wallet succeeded.

What happens if my endpoint is down? The platform retries with exponential backoff (about 1m, 5m, 30m, 2h, 12h) up to six attempts, then marks the delivery failed. Because deliveries can still be missed across a long outage, reconcile periodically rather than trusting webhooks alone.

Are amounts in dollars or cents? Integer cents — amount_cents (gross) and net_cents (after the platform fee). Payout events also include a stringified token amount (amount_atomic) and the destination wallet.

Next steps