A signed-in user starts a 14-day free trial of your Pro plan (€19.00 a month). They pay on Zahlo’s hosted page, or in a form embedded in your page. The card is saved now and charged when the trial ends; Zahlo then renews the subscription every month. Your webhook handler stores the subscription, and access follows its status. Pick PHP (any PHP 8.1+ host with the 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.
No code: create a payment link with your plan’s recurring price under Links and share its URL. Each payment starts a subscription, which you manage in the dashboard and through the customer portal. To grant access in your app automatically, follow the steps below.
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 SetEnv for Apache or env[...] for PHP-FPM). Locally, put them in a .env file that stays out of version control:
.env
PHP reads them from the environment: 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 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:8000 (PHP) or localhost:3000 (Node.js) 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.
setup-catalog.php
4

Add sign-in, access and checkout

The API 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. Users are stored in SQLite; use your own database the same way, since what matters is the transaction.
zahlo.php
db.php
The sign-in is a stand-in so the quickstart runs on its own. Use your real auth instead.
auth.php
signin.php
Access is decided from the stored subscription, never from the customer returning from checkout:
access.php
account.php
subscribe_lib.php
Hosted checkout: the server creates the session and redirects to its url.
subscribe.php
Embedded checkout: the server gives the browser only the session ID and short-lived client token; the page loads /sdk/v1/zahlo.js and mounts the form. Its callbacks only change what the customer sees.
subscribe-session.php
subscribe.html
5

Handle webhooks

webhook.php
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.The handler stores the subscription when 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 server and note its https://….trycloudflare.com URL:
Add the endpoint in Developers → Webhooks, or with the API: the URL is 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.
A quick tunnel’s URL changes every time you start it; update the endpoint when it does.
6

Test the subscription

Open http://localhost:8000/account.php.
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.completed and subscription.created, and the account page shows Pro as active (trialing) after a reload;
  • the subscription appears under Customers in the dashboard.
You don’t have to wait 14 days. In the Sandbox, test helpers move a subscription forward. They change Zahlo’s records and send the real events, but they don’t go through the card network. Find the subscription ID in the dashboard or with GET /subscriptions, then:
After each step, reload the account page: it shows the status your handler stored from the events.
7

Let customers manage billing

Link to your no-code customer portal from the account page. Customers enter their email and get a single-use link to update their card, pay what is overdue or cancel. Its address is shown in the dashboard under Billing → Customer emails & portal, where you also choose what customers may change. Changes arrive as the usual subscription.* events, which your handler already stores. To open the portal without the email step, create a portal session from your server instead; see Customer portal.
8

Handle failed payments, refunds and disputes

  • Failed renewals: a failed card renewal is retried automatically. The subscription is past_due meanwhile, and hasAccess keeps access for your grace period (14 days by default). If it is still unpaid then, the subscription is cancelled with cancellationReason: "payment_failed" and access stops. See Failed payments and SaaS entitlements.
  • Cancellation: subscription.cancelled ends 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.
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.