2026-09-16
Real-Time Crypto Payment Notifications: How to Build Them Right

A customer sends USDC, their wallet confirms the send, and your checkout page is still showing a spinner. That gap — real, but usually only a few seconds — is where trust in a payment flow quietly erodes. Real-time crypto payment notifications exist to close it: to tell your product what happened on-chain, when it happened, and whether it's actually safe to act on.
For a team building on top of this, it's not a cosmetic checkout detail. It's the line between an on-chain transfer and an actual business action — granting SaaS access, releasing a download, marking an invoice paid, crediting a marketplace balance. Get that boundary wrong in one direction and customers wait longer than they should. Get it wrong in the other direction and you've shipped goods against a payment you can't actually account for.
"Real-time" starts as a backend problem, not a UI problem
A blockchain transaction is public, which makes it easy to assume "just watch the chain" is a complete answer. It isn't. Watching the chain tells you a transfer happened somewhere. It doesn't tell you which of your charges it belongs to, whether the amount actually matches what you expected, or whether you've already processed this exact event once before.
If every customer paid into one shared address, you'd be reconciling transfers against orders by amount and timing — fragile the moment two customers pay the same price in the same minute, and actively broken the moment someone underpays. The fix is a charge, created server-side, that already knows the chain, the accepted asset, the exact amount, the expiry, and its own deterministic payment address. Once that exists, a matching transfer doesn't need to be reverse-engineered — it's an event tied to a charge ID you already have.
Why polling stops being fine
Nearly every first integration starts the same way: a loop that asks the chain every few seconds whether anything's changed. It's honestly fine for a weekend project. It gets expensive and fragile the moment it's carrying real traffic.
Polling means your application owns state it shouldn't have to: the last block you checked, how to avoid double-counting a transfer you already saw, what "downtime" does to that bookkeeping when your process restarts mid-poll. None of that is your product's problem — it's plumbing a payments provider should already have solved once, correctly, instead of every integrator solving it slightly differently.
Event-driven delivery flips the shape of the problem. Instead of repeatedly asking "did anything happen," your backend receives a structured event the moment something does. You still want query endpoints for recovery and audit — reconciliation doesn't go away — but the default path stops being a loop.
Speed and policy are two separate decisions, and it's worth keeping them separate in your head. A $2 digital download on a fast network can probably fulfill the moment a transfer is detected. A five-figure invoice probably shouldn't. Getting notified fast doesn't obligate you to act fast — it just means the decision is actually yours to make instead of being made for you by however long your polling interval happened to be.
The lifecycle is already defined — use it instead of inventing one
It's tempting to sketch your own state machine — pending → detected → confirmed → completed — but Klappay's Charge already has a real one, and matching it instead of layering your own on top saves you a translation step that's just another place to introduce a bug. A charge's status is one of five values: pending, partially_paid, confirmed, expired, underpaid. Payout to your own wallet is tracked separately, as settlementStatus (pending / completed / failed), because "the transfer landed on-chain" and "the money is actually in your wallet" are genuinely two different moments — reported through two different events, charge.confirmed and charge.settled.
The webhook event types you'll actually see: charge.created, charge.partially_paid, charge.confirmed, charge.underpaid, charge.overpaid, charge.expired, charge.settled, charge.settlement_failed, plus charge.escrow_released/charge.escrow_refunded if you're using escrow. That's the real vocabulary — worth using directly instead of relabeling it.
A handler for it doesn't need to be clever:
async function handlePaymentEvent(event: { id: string; event: string; data: Charge }) {const already = await events.exists(event.id)if (already) returnawait events.store(event.id)if (event.event !== 'charge.confirmed') returnawait orders.markPaid({orderId: event.data.externalRef,transactionHash: event.data.txHash,})}
Deliberately plain. A confirmed-payment event should arrive as a typed object with an event ID, a charge ID, a status, a transaction hash, and whatever externalRef you attached when you created the charge — not something your business logic has to reconstruct by scraping a block explorer or guessing which order a bare transfer was meant to pay. Put your own order ID in externalRef at charge-creation time and you never have to guess later.
SSE tells the screen. Webhooks tell the business.
A checkout page needs to feel alive — "waiting for payment" becoming "payment detected" without the customer refreshing out of superstition. Server-sent events are the right tool for exactly that: cheap, one connection, no client polling loop of your own to maintain.
But a browser tab is not an authority on anything. It can be closed, it can lose its connection, and anything running in it is, in principle, something a motivated user could tamper with. Treat SSE strictly as a UX channel — it updates what the customer sees and nothing else. The decision to actually ship a product, grant access, or release funds belongs to a channel your customer's browser has no part in.
That's what webhooks are for. A webhook handler that's actually production-ready verifies the signature against the raw request body before it trusts a single field, stores the event ID before doing anything else, and returns success only once the write is durable — if your database is briefly down, the right move is to return an error and let the sender retry, not to silently swallow the event:
app.post('/webhooks/klappay',express.raw({ type: 'application/json' }),async (req, res) => {let eventtry {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)},)
You'll also want a recovery path independent of both channels: periodically query charges your system still considers open and reconcile them against the API directly. SSE gives you low latency. Webhooks give you durability. Reconciliation is what covers you when a deploy, a network blip, or someone else's infrastructure has a bad five minutes at exactly the wrong time.
Verify what was actually paid, not just that something arrived
A notification is only as good as what it lets you check. At minimum, an event should let you confirm the destination address, network, token contract, amount, transaction hash, and confirmation status all matched what the charge expected — not just that a transaction happened near the right time. Treat display strings and floating-point numbers as UI conveniences, never as the value you compare against for settlement.
Decide your mismatch policy before launch, not while a confused customer is on the line. Underpaid charges can stay open for a top-up, get flagged for manual review, or get rejected outright — Klappay reports underpaid as its own status precisely so you can build a real branch for it instead of an afterthought. Overpayments (isOverpaid, its own charge.overpaid event) need a policy too — usually not an automatic refund to whatever address happened to send the extra funds. And a payment that lands after a charge has already expired needs a documented answer, because "ignore it" is a policy, but it should be a chosen one.
Fast shouldn't mean someone else is holding your money
Some processors deliver notifications quickly while still inserting themselves into the actual movement of funds — they receive the payment, keep their own ledger, and settle to you afterward on their schedule. That can make their side of the reconciliation simpler. It also means the "real-time" event you're so happy about is reporting on money that isn't in your wallet yet, and won't be until a system you don't control decides to move it.
Klappay's model keeps those separate on purpose: charge.confirmed tells you a qualifying transfer landed on-chain, at an address only your business ever controlled. charge.settled tells you payout to your actual wallet completed — a distinct, independently monitored step, not a formality. Direct settlement means the notification you're building your product around is reporting on money that was already yours, not money you're now waiting on someone else to release.
Build the notification pipeline as an honest description of what's actually happening on-chain, and the checkout screen on top of it gets to be simple, because it isn't hiding anything.
Ready to wire this into your own backend? Start at klappay.com/developers.