The customer picks a product and pays in a payment form that Zahlo renders inside your page; card details never touch your server. Your webhook handler marks the order paid. Every file below is complete; copy them into a Next.js 15+ App Router project. You need: a Sandbox account at https://merchant-demo.zahlo.eu and its zk_test_ API key (Developers → API keys), Node.js 22.5 or later, and cloudflared (brew install cloudflared, or a package from Cloudflare’s downloads page) to receive webhooks on your machine.
1

Set environment variables

Create .env.local in the project root. Keep it out of version control; the key never reaches the browser.
.env.local
2

Register your domains

Customers return to your site through Zahlo, which only sends them to hosts you registered. Register your site’s host once per environment, in the dashboard (Developers → Checkout domains) or with the API. It takes effect immediately.
A session whose successUrl or cancelUrl uses an unregistered host fails with checkout_domain_not_registered (400). A checkout_domain_pending (409) means Zahlo is still enabling checkout for your account, once, after approval; retry later or contact support.
Local development: in the Sandbox (zk_test_ keys) you can register localhost:3000 (or 127.0.0.1:3000) and use http:// return URLs. Live requires HTTPS.
For this quickstart, register localhost:3000 in the Sandbox.
Apple Pay in embedded checkout needs your page’s host registered with "applePay": true on a real HTTPS domain (not localhost or a quick tunnel). Cards and Google Pay work without it. Hosted checkout needs none of this.
3

Create the catalogue

Sandbox and live are separate, so their product and price IDs differ. Your code finds prices by lookupKey instead. This script creates them once per environment, and running it again creates nothing:
scripts/setup-catalog.mjs
4

Add the API client and database

The client sends one Idempotency-Key per operation and retries only network errors, 409 request_in_progress, 429 and 5xx, at most twice, with the same key.
lib/zahlo.ts
The quickstart stores orders in SQLite (node:sqlite, built into Node.js). Use your own database the same way: what matters is the transaction.
lib/db.ts
5

Create the Checkout Session

lib/checkout.ts
Your server creates the session and gives the browser only its ID and short-lived client token, never your API key:
app/api/checkout-session/route.ts
The page loads the browser SDK from /sdk/v1/zahlo.js and mounts the payment form. Its callbacks only change what the customer sees; the webhook fulfils the order.
app/checkout/page.tsx
The success page shows the order’s state. It never marks an order paid: anyone can open the success URL, and the customer may close the tab before returning.
lib/orders.ts
app/success/page.tsx
6

Handle webhooks

lib/webhook-signature.ts
app/api/webhooks/zahlo/route.ts
The webhook handler is the only place that fulfils orders or grants access. It follows three rules, which the code above implements:
  1. Verify first. Check Zahlo-Signature on the raw request body and answer 400 when it is missing, wrong or older than 5 minutes.
  2. Act on current state. Events can arrive late, twice or out of order. Re-fetch the object (GET /checkout-sessions/:id, GET /subscriptions/:id) and act on what it says now, not on the event payload.
  3. Commit, then answer. Record the event ID and apply the change in one database transaction, and return 2xx only after it commits. A duplicate event inserts nothing and changes nothing. If your server crashes before the commit, nothing is saved, and Zahlo’s retry (1 minute, 5 minutes, … up to 3 days) does the work again. Never record the event, answer 200, and do the work afterwards.
Work that can’t join the transaction, such as emails, goes into a job row inside it and runs afterwards.Webhooks need a public HTTPS URL. Start a tunnel to your dev server and note its https://….trycloudflare.com URL:
Add the endpoint in Developers → Webhooks, or with the API. The response shows the signing secret once: put it in ZAHLO_WEBHOOK_SECRET and restart the dev server.
A quick tunnel’s URL changes every time you start it; update the endpoint when it does.
7

Test a payment

Open http://localhost:3000/checkout, choose 3 T-shirts with the promo code WELCOME15, and pay in the embedded form with the Sandbox test card 4111 1111 1111 1111, expiry 03/30, CVC 737. Check that:
  • the embedded form shows €63.75 (3 × €25.00, minus 15%);
  • your server logs a checkout.session.completed delivery, and the success page shows the order as paid after a reload;
  • the payment appears under Payments in the dashboard, and the delivery under Developers → Webhooks.
Then check the failure paths: open a checkout and abandon it (the order stays pending, and checkout.session.expired marks it expired when the session ends), and resend a delivery from Developers → Webhooks (nothing changes the second time).
8

Handle refunds and disputes

The handler above already reacts to reversals:
  • refund.succeeded: the order is marked refunded. Refund from the dashboard or with POST /refunds; a refund is final only when refund.succeeded or refund.failed arrives. See Refunds.
  • dispute.created: the order is marked disputed. Hold the shipment and respond in the dashboard before the deadline. See Disputes.
9

Go live

Your live account starts empty, and Sandbox IDs, keys and secrets don’t work there. Recreate what this integration uses, in this order (details: Go live; your live dashboard’s Go live page at https://merchant.zahlo.eu/merchant/go-live tracks each step):
  1. Create a live API key and set ZAHLO_API_KEY to it (zk_live_…). Code that reads ZAHLO_API_BASE sets it to https://merchant.zahlo.eu.
  2. Run setup-catalog against live. It creates the same lookupKeys and coupon codes, so your code needs no change.
  3. Recreate your payment links; live links have new URLs.
  4. Add your live webhook endpoint, set ZAHLO_WEBHOOK_SECRET to its new signing secret, and confirm one live delivery arrives.
  5. Register your site’s checkout domain in live (HTTPS only), plus the Apple Pay domain if you embed checkout with wallets.
  6. Customers, subscriptions and saved payment methods don’t carry over; real checkouts create them.
  7. Make one small live payment, check that payment.confirmed and your fulfilment ran, then refund it.