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 For this quickstart, register
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.localhost:3000 in the Sandbox.3
Create the plan
Sandbox and live are separate, so their price IDs differ. Your code finds the price by
lookupKey instead. This script creates the plan, its monthly price with a 14-day trial, and the WELCOME15 coupon (15% off for 3 months) once per environment; running it again creates nothing:scripts/setup-catalog.mjs
4
Add the API client, database and sign-in
The client sends one The quickstart stores users in SQLite (The sign-in below is a stand-in so the quickstart runs on its own. Use your real auth instead.
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
node:sqlite, built into Node.js). Use your own database the same way: what matters is the transaction.lib/db.ts
lib/auth.ts
app/api/signin/route.ts
5
Start the subscription
lib/subscribe.ts
app/api/subscribe/route.ts
lib/access.ts
app/account/page.tsx
6
Handle webhooks
lib/webhook-signature.ts
app/api/webhooks/zahlo/route.ts
- Verify first. Check
Zahlo-Signatureon the raw request body and answer400when it is missing, wrong or older than 5 minutes. - 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. - Commit, then answer. Record the event ID and apply the change in one database transaction, and return
2xxonly 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, answer200, and do the work afterwards.
checkout.session.completed arrives (the session’s reference is your user ID) and refreshes it on every subscription.* event. If a subscription.* event arrives first, it changes nothing; the session event links the subscription a moment later.Webhooks need a public HTTPS URL. Start a tunnel to your dev server and note its https://….trycloudflare.com URL:secret once: put it in ZAHLO_WEBHOOK_SECRET and restart the dev server.7
Test the subscription
http://localhost:3000/account, sign in with any email, enter WELCOME15 and subscribe. Pay with the Sandbox test card 4111 1111 1111 1111, expiry 03/30, CVC 737. Nothing is charged during the trial. Check that:- your server logs
checkout.session.completedandsubscription.created, and/accountshows Pro as active (trialing) after a reload; - the subscription appears under Customers in the dashboard.
GET /subscriptions, then:After each step, reload
/account: it shows the status your handler stored from the events.8
Let customers manage billing
The account page’s Manage billing button posts to this route. It creates a single-use customer portal link (valid for one hour), where customers update their card, pay what is overdue or cancel. Create a fresh link on every click and never store it. You choose what customers may change in the dashboard under Billing → Customer emails & portal. Changes arrive as the usual
subscription.* events, which your handler already stores. See Customer portal.app/api/portal/route.ts
9
Handle failed payments, refunds and disputes
- Failed renewals: a failed card renewal is retried automatically. The subscription is
past_duemeanwhile, andhasAccesskeeps access for your grace period (14 days by default). If it is still unpaid then, the subscription iscancelledwithcancellationReason: "payment_failed"and access stops. See Failed payments and SaaS entitlements. - Cancellation:
subscription.cancelledends access; the handler stores the new status. - Refunds: refund a payment from the dashboard or with
POST /refunds. A refund doesn’t cancel the subscription; cancel it as well if the customer leaves. See Refunds. - Disputes: respond in the dashboard before the deadline. See Disputes.
10
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):- Create a live API key and set
ZAHLO_API_KEYto it (zk_live_…). Code that readsZAHLO_API_BASEsets it tohttps://merchant.zahlo.eu. - Run
setup-catalogagainst live. It creates the samelookupKeys and coupon codes, so your code needs no change. - Recreate your payment links; live links have new URLs.
- Add your live webhook endpoint, set
ZAHLO_WEBHOOK_SECRETto its new signing secret, and confirm one live delivery arrives. - Register your site’s checkout domain in live (HTTPS only), plus the Apple Pay domain if you embed checkout with wallets.
- Customers, subscriptions and saved payment methods don’t carry over; real checkouts create them.
- Make one small live payment, check that
payment.confirmedand your fulfilment ran, then refund it.