Skip to content

Build a checkout flow ​

The five pieces from Charges, Webhooks, and How it works, assembled into the flow most integrations actually build: create a charge, send the payer to pay it, and mark the order paid once it resolves — without polling.

What the payer sees ​

Using Klap's hosted checkout (charge.checkoutUrl) means you don't build any of this UI yourself. It's a mockup below, not a live screenshot, but it's the real layout: the amount, a method picker if the charge accepts more than one (token, network) pair, the QR code for whichever one is selected, and a status badge that updates live over the same SSE stream covered in Real-time status — no refresh needed once the payer sends the transfer.

Pay $49.90Waiting for payment
USDC · BaseUSDT · Base
0x1111…1111

Illustrative mockup, not a live screenshot — real layout, placeholder data.

If checkoutUrl comes back null, or you'd rather build your own UI, skip straight to building your own UI from address/ acceptedPayments — steps 2 onward below don't change.

1. Create the charge ​

Tie it to your own order with externalRef, and set redirectUrl so the payer lands back on your site once it resolves:

ts
const charge = await klap.charges.create({
  amount: order.total,
  acceptedPayments: [{ token: 'USDC', network: 'base' }],
  expiresIn: 1800, // 30 minutes
  externalRef: order.id, // your own id — this is what ties the webhook back to this order
  redirectUrl: `https://your-site.com/orders/${order.id}/thank-you`,
})

await db.orders.update(order.id, { chargeId: charge.id, status: 'awaiting_payment' })

2. Send the payer to checkout ​

ts
res.redirect(charge.checkoutUrl)

That's the entire frontend for this flow — the checkout page, the QR code, the live status, all served by Klap.

3. Register a webhook (once, not per-charge) ​

A one-time setup step, not something you do per order — see Webhooks for the full subscription options:

ts
await klap.webhooks.create({
  url: 'https://your-site.com/webhooks/klap',
  events: ['charge.confirmed', 'charge.expired', 'charge.underpaid'],
})

charge.confirmed is "the payer paid" — usually the signal to fulfill the order. If you specifically need "the money is in my wallet," not just "the payer paid," subscribe to charge.settled instead (or in addition) — see How it works for why those are two different moments.

4. Handle the webhook ​

Always verify the signature — this endpoint is a public URL:

ts
app.post('/webhooks/klap', (req, res) => {
  try {
    const event = klap.webhooks.constructEvent(
      req.rawBody,
      req.headers['x-klappay-signature'],
      process.env.KLAP_WEBHOOK_SECRET,
    )

    if (event.event === 'charge.confirmed') {
      // event.data.externalRef is the order.id you set in step 1
      db.orders.update(event.data.externalRef, { status: 'paid' })
    } else if (event.event === 'charge.expired' || event.event === 'charge.underpaid') {
      db.orders.update(event.data.externalRef, { status: 'payment_failed' })
    }

    res.sendStatus(200)
  } catch {
    res.sendStatus(400) // invalid signature — reject, don't process
  }
})

Delivery is at-least-once — this handler needs to be safe to run twice on the same event.id (an upsert-style update, like above, already is; an operation like "increment a counter" would need its own dedupe). See Webhooks for the full delivery/retry mechanics.

No public endpoint yet? ​

If you're building this locally and don't have a public URL to register, skip webhooks for now and await charge.waitForConfirmation() in the same request/job that created the charge instead — see Charges. Switch to the webhook once you deploy somewhere reachable; the two aren't mutually exclusive; production integrations often use both: webhooks for correctness (never miss an event, even across a restart) and waitForConfirmation()/waitFor() where you already have an open request/job you're waiting on.

Testing before going live ​

Run this whole flow with a test key — real testnet, no real money — and use Sandbox to force a charge straight to confirmed, underpaid, or expired on demand, instead of waiting for real transfers, so you can verify the webhook handler and both branches of the order-status update above without round-tripping actual on-chain activity.

See also ​

  • How it works — the model behind checkoutUrl and the confirmed/settled distinction.
  • Charges — the full resource, including waitForSettlement().
  • Webhooks — the full event vocabulary and signature scheme.
  • Sandbox — triggering every event this guide's webhook handler needs to cover.

Klap API, Node.js SDK and types reference.