2026-09-09

How Crypto Payment Webhooks Work

A customer sends USDC. The transfer lands on Base. Your checkout cannot wait for an admin to refresh a block explorer, and your order system should not trust a browser callback that can be closed, replayed, or forged.

This is the practical answer to how crypto payment webhooks work: your payment infrastructure detects an on-chain state change, then delivers a server-to-server event your backend can verify and act on.

The blockchain is the settlement layer. A webhook is the application bridge. Keep those jobs separate. The chain establishes that funds moved — the webhook tells your systems that a payment event worth processing has occurred.

The crypto payment webhook flow

A well-designed flow begins when your server creates a charge. The charge defines what the customer must pay: the asset, network, amount, expiration rules, and a correlation id your own systems care about — an order ID or subscription invoice ID.

The payment API returns a charge identifier and a payment address. In a non-custodial design, that address is deterministic and derived for the merchant's settlement configuration. The customer sends funds directly on-chain. No processor receives the money first, pools it in an internal ledger, or decides when you may withdraw it.

Once the payment monitor observes a matching transaction, it evaluates the conditions attached to the charge. Did the correct asset arrive? On the expected network? At or above the required amount? Has the transaction reached the confirmation depth the network requires? Those answers move the charge through states: pending, partially_paid, confirmed, expired, or underpaid.

When a relevant state transition happens, the provider sends an HTTP POST to the webhook endpoint you registered. Your server receives structured event data, verifies that it came from the provider, records it safely, and triggers the next business action — marking an order paid, issuing access to a SaaS workspace, crediting a marketplace balance, or beginning fulfillment.

The webhook does not settle funds by itself, and charge.confirmed does not mean the money is in your wallet yet — it means the transfer landed on-chain at the address your charge generated. With Klappay, that address is a dedicated, ownerless routing contract: nobody, including Klappay, can redirect it. Payout to your actual wallet is a separate, slightly later step, tracked as its own settlementStatus and reported through its own charge.settled event. Subscribe to charge.confirmed for "the customer paid," and to charge.settled for "the funds are in my wallet" — they answer different questions.

What the webhook payload should contain

A useful payment event is more than a cheerful payment_received string. Your backend needs enough information to make a deterministic decision and enough identifiers to investigate disputes or retries later.

At minimum, expect a delivery ID, event type, creation timestamp, and the full charge object as data — not a thin summary of it. That means the charge ID, its status, the destination address, the accepted payment options, the transaction hash of the most recent detected transfer, and the external reference you supplied when creating the charge.

A typical event looks like this:

{
"id": "whd_01J...",
"event": "charge.confirmed",
"createdAt": "2026-09-06T14:32:18Z",
"data": {
"id": "ch_01J...",
"status": "confirmed",
"settlementStatus": "pending",
"externalRef": "order_8421",
"address": "0xdef...",
"txHash": "0xabc...",
"acceptedPayments": [{ "token": "USDC", "network": "base" }],
"amount": 49.0,
"amountReceived": 49.0
}
}

That data object is the same shape you'd get back from GET /v1/charges/{id} — a webhook is just that object pushed to you instead of polled. There is no separate "payment" sub-object and no sender address on it; if you need the payer's own address, that's a one-off lookup via POST /v1/charges/{id}/check, not something webhooks carry.

Treat the payload as a notification, not as an excuse to skip verification. A mature integration stores the event, validates its signature, and can retrieve or reconcile the canonical charge state through the API or SDK if needed. Webhooks make your system responsive. Your charge record remains the business object you reconcile against.

Signature verification is not optional

A public webhook URL will attract unwanted traffic. Anyone can send an HTTP request that looks like a payment event. If your endpoint marks orders paid based only on JSON fields, an attacker does not need to break a blockchain — they only need to call your endpoint.

Providers sign the raw request body using a webhook secret and include a signature header, usually with a timestamp. Your server calculates the expected signature from the raw body and compares it using a timing-safe method. It should also reject stale timestamps to reduce replay risk.

The detail that trips up most teams: verify the raw bytes before parsing or reserializing JSON. Middleware that transforms the body can make a valid signature fail. In Node.js, preserve the raw request body for the webhook route, then verify before touching the payload.

Here is what a complete handler looks like with Klappay:

app.post(
'/webhooks/klappay',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event
try {
event = klap.webhooks.constructEvent(
req.body,
req.headers['x-klappay-signature'],
process.env.KLAP_WEBHOOK_SECRET,
)
} catch {
return res.sendStatus(400)
}
const already = await db.processedEvents.findUnique({ where: { id: event.id } })
if (already) return res.sendStatus(200)
await db.processedEvents.create({ data: { id: event.id } })
if (event.event === 'charge.confirmed') {
await fulfillOrder(event.data.externalRef)
}
res.sendStatus(200)
},
)

Schema validation belongs here too. A signature proves the sender possessed the secret — it does not protect your code from bad assumptions about optional fields, unexpected event versions, or malformed data. Typed SDKs and Zod-based schemas close that gap.

Delivery is at least once — make processing idempotent

Webhooks are not a once-only messaging guarantee. Networks fail. Your endpoint may return a timeout after processing. A deployment may restart mid-request. The provider retries because it cannot prove your application completed the work.

That behavior is correct, but it means the same event can arrive multiple times. It may also arrive out of order — a charge.confirmed event can be delayed while a later one gets through first.

Use the event ID as an idempotency key. Store it with a unique constraint before or during processing. If it has already been handled, return a 200 without creating a second fulfillment, second credit, or duplicate email.

Idempotency should apply to the business action as well. Enforce that ord_8421 can transition from unpaid to paid only once. Event-level deduplication protects against retries — entity-level state rules protect against duplicate or related events representing the same commercial outcome.

Return a 2xx response quickly after durable acceptance. Do not make the webhook provider wait while you render an invoice, call five third-party services, or ship a physical order. Persist the event, enqueue background work, and let workers handle the slower tasks.

Confirmation depth isn't yours to tune, and that's fine

It's tempting to think of confirmation count as a dial your team turns per product — ship instantly on a cheap digital good, wait longer on a high-value order. In practice, reorg risk is a property of the chain and the specific block, not of what you're selling. Klappay fixes a minimum confirmation depth per network (Base and Optimism wait roughly 30 seconds, Ethereum around 12 blocks, and so on) and only marks a transfer confirmed once it clears that bar — there's no per-charge knob to loosen it.

What you do get is visibility into the wait: while a detected transfer is still short of that depth, the charge stream reports its progress (blocks seen versus blocks required) so you can render "confirming…" instead of leaving the customer looking at a blank spinner. Once it clears the depth, charge.confirmed fires and that's your signal — there's no intermediate "detected but maybe not real" state to build your own policy around.

The decision that actually is yours: what you do at confirmed versus settled. A low-value digital download can fulfill on charge.confirmed — the payment is real, and waiting for charge.settled (the payout to your own wallet actually landing) just adds latency. A high-value physical order might reasonably wait for charge.settled, since it confirms the funds truly left the routing contract and reached you.

Overpayments and underpayments need policies too. A webhook should surface those as distinct events — charge.underpaid and charge.overpaid — not force every transfer into a success path. Automatically fulfilling an underpaid invoice is not developer-friendly — it is an accounting bug.

Webhooks and SSE solve different problems

Klappay also exposes a live event stream per charge (GET /v1/charges/{id}/events) for status updates as they happen, instead of polling. It requires your secret API key, the same as every other endpoint — so it's meant to be consumed by your backend, not opened directly from a customer's browser. An SDK's waitForConfirmation()/waitForSettlement() use it under the hood.

If you want the checkout page itself to update live, your backend is the one holding that stream open; relay progress to the browser over your own channel — a WebSocket, your own SSE endpoint, or simple short-interval polling against your own API. The browser never sees a Klappay API key.

Use that live stream (via your backend) to show progress to the payer. Use webhooks to change your system of record. Use an API or SDK lookup for reconciliation and recovery. These are complementary paths, not interchangeable features.

Build for direct settlement, not processor dependency

The best crypto payment webhook architecture preserves the chain's strongest property: verifiable ownership. Your infrastructure can watch for payment, issue signed events, and help route proceeds among recipients without becoming the owner of merchant funds.

That is the model Klappay is built around. Charges, deterministic payment addresses, webhook events, SSE updates, and SDK methods all serve the integration. The funds still settle directly on-chain to the intended recipients.

Start narrow: create a charge, attach your internal order ID as externalRef, verify charge.confirmed events, and make fulfillment idempotent. Then add explicit handling for expiry, underpayment, and reconciliation.

A webhook endpoint should be boring under pressure — auditable, replay-safe, and incapable of confusing a notification with custody.