curl and pdo_sqlite extensions) or Node.js (22.5 or later, no dependencies); every file is complete.
You need: a Sandbox account at https://merchant-demo.zahlo.eu and its zk_test_ API key (Developers → API keys), and cloudflared (brew install cloudflared, or a package from Cloudflare’s downloads page) to receive webhooks on your machine.
1
Set environment variables
Your server reads these; the key never reaches the browser. On a host, set them in the server configuration (for example PHP reads them from the environment:
SetEnv for Apache or env[...] for PHP-FPM). Locally, put them in a .env file that stays out of version control:.env
set -a; source .env; set +a before php -S. Node.js reads the file with --env-file=.env. With Node.js, use port 3000 (SITE_URL=http://localhost:3000).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:8000 (PHP) or localhost:3000 (Node.js) in the Sandbox.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.- PHP
- Node.js
setup-catalog.php
4
Add the order form and checkout
- PHP
- Node.js
The API client sends one The success page shows the order’s state. It never marks an order paid: anyone can open the success URL.
Idempotency-Key per operation and retries only network errors, 409 request_in_progress, 429 and 5xx, at most twice, with the same key. Orders are stored in SQLite; use your own database the same way, since what matters is the transaction.zahlo.php
db.php
index.html
checkout_lib.php
checkout.php
success.php
5
Handle webhooks
- PHP
- Node.js
webhook.php
- 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.
https://….trycloudflare.com URL:https://your-tunnel.trycloudflare.com/webhook.php for PHP and …/webhooks/zahlo for Node.js. The response shows the signing secret once: put it in ZAHLO_WEBHOOK_SECRET and restart the server.6
Test a payment
- PHP
- Node.js
WELCOME15, and pay with the Sandbox test card 4111 1111 1111 1111, expiry 03/30, CVC 737. Check that:- the hosted page shows €63.75 (3 × €25.00, minus 15%);
- your server logs a
checkout.session.completeddelivery, 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.
pending until checkout.session.expired marks it expired), and resend a delivery from Developers → Webhooks (nothing changes the second time).7
Handle refunds and disputes
The handler already reacts to reversals:
8
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.