# Zahlo Merchant Developer Docs Source: https://docs-demo.zahlo.eu/introduction Zahlo gives merchants one API for checkout, payments, subscriptions, payment links, and commerce integrations. Use hosted checkout when you want the fastest launch, or use in-page checkout and the SDK when the payment experience must stay inside a custom website. ## Choose your path | Goal | Start with | | --- | --- | | Launch a first payment | [Quickstart](/quickstart) | | Add checkout to a website | [Checkout guide](/guides/checkout) | | Start recurring billing | [Subscriptions](/guides/subscriptions) | | Integrate an online store | [WooCommerce](/integrations/woocommerce) | | Explore every endpoint | [API Reference](/api-reference/overview) | ## Common use cases | Use case | Building blocks | | --- | --- | | Online store | Catalog, line items, dynamic methods, tax-inclusive totals, webhooks, fulfillment | | SaaS billing | Customers, recurring prices, trials, retries, portal, subscriptions | | Sales | Payment links, hosted checkout, customer metadata, refunds | | Custom checkout | Checkout sessions, in-page SDK, appearance, locale, allowed origins | | WordPress store | WooCommerce integration, server-side correlation, signed events | ## Integration principles - Keep merchant API keys on your server. - Use short-lived client tokens for browser checkout state. - Treat webhooks as the source of truth for fulfillment and subscription state. - Send an idempotency key when retrying create requests. - Never collect or store raw card data in your application. # Choose your integration Source: https://docs-demo.zahlo.eu/start/choose-integration Answer three questions, then follow the one quickstart in the matching cell. Every quickstart is self-contained: it lists every file you need, how to test it in the Sandbox, and how to go live. ## 1. What do you sell? - **E-commerce**: one-time payments for orders (physical or digital goods, bookings, donations). - **SaaS**: recurring subscriptions with trials, coupons, a customer portal and access that follows the subscription. ## 2. Where does the customer pay? - **Hosted**: your server creates a Checkout Session and redirects the customer to its `url`. Fastest to build; no payment form on your site. - **Embedded**: the checkout renders inside your page with the browser SDK (`/sdk/v1/zahlo.js`). The customer never leaves your site. - **No-code link**: create a payment link in the dashboard and share or paste its URL. No server code. ## 3. What is your site built with? | | Hosted | Embedded | No-code link | |---|---|---|---| | **E-commerce, custom storefront** (Next.js, React) | [Hosted checkout with Next.js](/quickstarts/ecommerce-hosted-nextjs) | [Embedded checkout with Next.js](/quickstarts/ecommerce-embedded-nextjs) | [Payment links](/quickstarts/ecommerce-no-code) | | **E-commerce, custom website** (HTML + PHP or Node backend) | [Hosted checkout on a website](/quickstarts/ecommerce-hosted-website) | [Embedded checkout on a website](/quickstarts/ecommerce-embedded-website) | [Payment links](/quickstarts/ecommerce-no-code) | | **E-commerce, WooCommerce** | [WooCommerce](/quickstarts/ecommerce-woocommerce) | [WooCommerce](/quickstarts/ecommerce-woocommerce) | Use the WooCommerce plugin instead | | **SaaS, custom app** (Next.js, React) | [Subscriptions with hosted checkout](/quickstarts/saas-subscriptions-hosted-nextjs) | [Subscriptions with embedded checkout](/quickstarts/saas-subscriptions-embedded-nextjs) | [Subscriptions on a website or by link](/quickstarts/saas-subscriptions-website) | | **SaaS, custom website** (HTML + PHP or Node backend) | [Subscriptions on a website](/quickstarts/saas-subscriptions-website) | [Subscriptions with embedded checkout](/quickstarts/saas-subscriptions-embedded-nextjs) (the server parts work with any backend) | [Subscriptions on a website or by link](/quickstarts/saas-subscriptions-website) | | **SaaS on WooCommerce** | [Not supported](/unsupported#woocommerce-subscriptions) | [Not supported](/unsupported#woocommerce-subscriptions) | [Subscriptions by link](/quickstarts/saas-subscriptions-website) | Subscriptions paid by **SEPA Direct Debit** follow [SEPA subscriptions](/quickstarts/saas-sepa). ## Which should I pick? - **Hosted** is the fastest and the safest default: Zahlo hosts the payment page, and you only create a session and handle one webhook. - **Embedded** keeps the customer on your page. It needs the browser SDK and one more registration if you want Apple Pay. - **A payment link** needs no code. Use it when you sell a few fixed items or plans, or to test demand before you build. Whatever you choose, orders are fulfilled and access is granted from **webhooks**, never from the customer returning to your success page. Every quickstart shows the handler. ## Not on this page? Marketplaces with split payouts, WooCommerce Subscriptions, usage-based billing, tax calculation and other cases are listed on [Not supported](/unsupported), each with the recommended workaround. Building with an AI coding agent? Start at [Build with AI](/ai). # Quickstarts Source: https://docs-demo.zahlo.eu/quickstart Not sure which one fits? [Choose your integration](/start/choose-integration) asks three questions and points to one. ## E-commerce | Quickstart | Platform | Checkout | |---|---|---| | [Hosted checkout with Next.js](/quickstarts/ecommerce-hosted-nextjs) | Next.js App Router | Hosted | | [Embedded checkout with Next.js](/quickstarts/ecommerce-embedded-nextjs) | Next.js App Router | Embedded | | [Hosted checkout on a website](/quickstarts/ecommerce-hosted-website) | HTML + PHP or Node.js | Hosted | | [Embedded checkout on a website](/quickstarts/ecommerce-embedded-website) | HTML + PHP or Node.js | Embedded | | [Payment links](/quickstarts/ecommerce-no-code) | Any, no code | Link | | [WooCommerce](/quickstarts/ecommerce-woocommerce) | WooCommerce plugin, no code | Hosted or embedded | ## SaaS subscriptions | Quickstart | Platform | Checkout | |---|---|---| | [Subscriptions with hosted checkout](/quickstarts/saas-subscriptions-hosted-nextjs) | Next.js App Router | Hosted | | [Subscriptions with embedded checkout](/quickstarts/saas-subscriptions-embedded-nextjs) | Next.js App Router | Embedded | | [Subscriptions on a website](/quickstarts/saas-subscriptions-website) | HTML + PHP or Node.js | Hosted or embedded | | [SEPA Direct Debit subscriptions](/quickstarts/saas-sepa) | Next.js App Router | Hosted | Every quickstart follows the same shape: register your domains, create the catalogue with a re-runnable script, add the files, handle webhooks crash-safely, test in the Sandbox, handle refunds and disputes, and go live. # Hosted checkout with Next.js Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-hosted-nextjs The customer picks a product on your site, pays on Zahlo's hosted payment page, and returns to your success page. 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. Create `.env.local` in the project root. Keep it out of version control; the key never reaches the browser. ```bash .env.local ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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: ```javascript scripts/setup-catalog.mjs // Creates the store's products, prices and coupon once per environment (sandbox, then live). // Run: node --env-file=.env.local scripts/setup-catalog.mjs // Safe to run again: it finds existing prices by lookupKey and coupons by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PRODUCTS = [ { lookupKey: "tshirt", name: "T-shirt", unitAmountMinor: 2500 }, { lookupKey: "mug", name: "Mug", unitAmountMinor: 1200 }, ]; const COUPONS = [{ code: "WELCOME15", name: "Welcome 15%", percentOff: 15, duration: "once" }]; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); for (const item of PRODUCTS) { if (prices.some((price) => price.active && price.lookupKey === item.lookupKey)) { console.log(`price ${item.lookupKey}: exists`); continue; } const product = await call("POST", "/products", { name: item.name }, `setup-product-${item.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: item.lookupKey, productId: product.id, type: "one_time", unitAmountMinor: item.unitAmountMinor, }, `setup-price-${item.lookupKey}` ); console.log(`price ${item.lookupKey}: created`); } const coupons = await listAll("/coupons"); for (const coupon of COUPONS) { if (coupons.some((existing) => existing.code === coupon.code)) { console.log(`coupon ${coupon.code}: exists`); continue; } await call("POST", "/coupons", coupon, `setup-coupon-${coupon.code}`); console.log(`coupon ${coupon.code}: created`); } ``` ```bash node --env-file=.env.local scripts/setup-catalog.mjs ``` 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. ```typescript lib/zahlo.ts // Server-only Zahlo API client. Never import this file from a client component. const apiKey = process.env.ZAHLO_API_KEY ?? ""; /** zk_test_ keys use the sandbox, zk_live_ keys use live. */ export const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); export class ZahloError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null ) { super(message); } } const MAX_RETRIES = 2; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt: number, response?: Response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; /** Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. */ const retryable = (status: number, code: string) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ export async function zahlo( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, options: { body?: unknown; idempotencyKey?: string } = {} ): Promise { const idempotencyKey = method === "POST" ? (options.idempotencyKey ?? crypto.randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response: Response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) { return (response.status === 204 ? undefined : await response.json()) as T; } const payload = await response.json().catch(() => ({})); const code: string = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ export async function listAll( path: string ): Promise { const items: T[] = []; let startingAfter = ""; for (;;) { const page = await zahlo<{ data: T[]; hasMore: boolean }>( "GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}` ); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) { return items; } startingAfter = last.id; } } ``` The quickstart stores orders in SQLite (`node:sqlite`, built into Node.js). Use your own database the same way: what matters is the transaction. ```typescript lib/db.ts // Any database with transactions works; SQLite keeps the quickstart self-contained. export const db = new DatabaseSync(process.env.DATABASE_PATH ?? "store.db"); db.exec(` CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } export type Order = { id: string; items: string; status: string; session_id: string | null; payment_id: string | null; }; ``` ```typescript lib/checkout.ts type Price = { id: string; lookupKey: string | null; active: boolean }; type Coupon = { id: string; code: string | null; active: boolean }; export type CheckoutSession = { id: string; url: string | null; clientToken: string | null; status: "open" | "complete" | "expired" | "failed"; paymentStatus: "unpaid" | "paid" | "no_payment_required"; paymentId: string | null; reference: string | null; }; const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; /** * Saves a pending order and creates its Checkout Session. Prices are found by lookupKey, so the same * code works in sandbox and live, where every ID differs. */ export async function createOrderSession(input: { items: { lookupKey: string; quantity: number }[]; promoCode?: string; mode: "hosted" | "embedded"; }): Promise { const prices = await listAll("/prices"); const lineItems = input.items.map((item) => { const price = prices.find((p) => p.active && p.lookupKey === item.lookupKey); if (!price) { throw new Error(`No price with lookupKey ${item.lookupKey}; run scripts/setup-catalog.mjs`); } return { priceId: price.id, quantity: item.quantity }; }); let couponId: string | undefined; if (input.promoCode) { const coupons = await listAll("/coupons"); couponId = coupons.find( (c) => c.active && c.code?.toUpperCase() === input.promoCode?.toUpperCase() )?.id; } const orderId = crypto.randomUUID(); db.prepare("INSERT INTO orders (id, items) VALUES (?, ?)").run( orderId, JSON.stringify(input.items) ); const session = await zahlo("POST", "/checkout-sessions", { // One key per order: a retried request returns the same session instead of a second one. idempotencyKey: `order-${orderId}`, body: { type: "payment", mode: input.mode, lineItems, couponId, reference: orderId, successUrl: `${siteUrl}/success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/`, }, }); db.prepare("UPDATE orders SET session_id = ? WHERE id = ?").run(session.id, orderId); return session; } /** Reads an item list from a form: lookupKey, quantity and an optional promo code. */ export function cartFromForm(form: FormData) { return { items: [ { lookupKey: String(form.get("lookupKey") ?? "tshirt"), quantity: Math.max(1, Number(form.get("quantity") ?? 1)), }, ], promoCode: String(form.get("promoCode") ?? "").trim() || undefined, }; } ``` ```tsx app/page.tsx // The shop page: a plain form, so it works without client JavaScript. export default function ShopPage() { return (

Shop

); } ``` ```typescript app/api/checkout/route.ts // Hosted checkout: create the session on the server, then send the browser to session.url. export async function POST(request: Request) { try { const session = await createOrderSession({ ...cartFromForm(await request.formData()), mode: "hosted", }); return Response.redirect(session.url ?? "", 303); } catch (error) { if (error instanceof ZahloError) { console.error("checkout failed", error.code, error.requestId); return new Response(error.message, { status: error.status >= 500 ? 502 : 400 }); } throw error; } } ``` 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. ```typescript lib/orders.ts /** * What the success page shows. It reads state and never fulfils: only the webhook marks an order paid, * because a customer can open the success URL without paying. */ export async function orderForSuccessPage(sessionId: string) { const session = await zahlo("GET", `/checkout-sessions/${encodeURIComponent(sessionId)}`); const order = db.prepare("SELECT * FROM orders WHERE session_id = ?").get(session.id) as Order | undefined; return { order, paid: order?.status === "paid" }; } ``` ```tsx app/success/page.tsx export const dynamic = "force-dynamic"; export default async function SuccessPage({ searchParams, }: { searchParams: Promise<{ session_id?: string }>; }) { const { session_id: sessionId } = await searchParams; if (!sessionId) return

Missing session.

; const { order, paid } = await orderForSuccessPage(sessionId); if (!order) return

Order not found.

; return paid ? (

Thank you! Order {order.id} is paid.

) : ( // The webhook usually arrives within seconds; reload to check again.

Payment processing. Order {order.id} will be confirmed by email.

); } ```
```typescript lib/webhook-signature.ts export type ZahloEvent = { id: string; type: string; livemode: boolean; createdAt: string; data: { object: { id: string } & Record }; }; /** * Checks `Zahlo-Signature: t=,v1=` against the raw request body and returns the parsed event, * or null when the signature is missing, wrong or older than 5 minutes. During a secret rotation the * header carries one v1 per secret, so any matching v1 is accepted. */ export function verifyZahloEvent( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300 ): ZahloEvent | null { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return null; } const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex") ); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? (JSON.parse(rawBody) as ZahloEvent) : null; } ``` ```typescript app/api/webhooks/zahlo/route.ts type Refund = { id: string; paymentId: string; status: "pending" | "succeeded" | "failed" }; type Dispute = { id: string; paymentId: string; status: string }; export async function POST(request: Request) { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = await request.text(); const event = verifyZahloEvent( rawBody, request.headers.get("zahlo-signature"), process.env.ZAHLO_WEBHOOK_SECRET ?? "" ); if (!event) return new Response("Invalid signature", { status: 400 }); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. const id = encodeURIComponent(event.data.object.id); let apply: () => void = () => {}; if (event.type === "checkout.session.completed" || event.type === "checkout.session.expired") { const session = await zahlo("GET", `/checkout-sessions/${id}`); apply = () => { if (session.paymentStatus === "paid") { const paid = db .prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'") .run(session.paymentId, session.id); if (paid.changes === 1) { db.prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)").run( session.reference ); } } else if (session.status === "expired") { db.prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'").run(session.id); } }; } else if (event.type === "refund.succeeded") { const refund = await zahlo("GET", `/refunds/${id}`); apply = () => { if (refund.status === "succeeded") { db.prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?").run(refund.paymentId); } }; } else if (event.type === "dispute.created") { const dispute = await zahlo("GET", `/disputes/${id}`); apply = () => { // Hold the shipment and answer the dispute in the dashboard. db.prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'").run(dispute.paymentId); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return new Response("ok"); } ``` 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: ```bash cloudflared tunnel --url http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/api/webhooks/zahlo", "enabledEvents": ["checkout.session.completed", "checkout.session.expired", "refund.succeeded", "dispute.created"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash npm run dev ``` Open `http://localhost:3000`, buy 3 T-shirts with the promo code `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.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). 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](/guides/refunds). - **`dispute.created`**: the order is marked `disputed`. Hold the shipment and respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Embedded checkout with Next.js Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-embedded-nextjs 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. Create `.env.local` in the project root. Keep it out of version control; the key never reaches the browser. ```bash .env.local ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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: ```javascript scripts/setup-catalog.mjs // Creates the store's products, prices and coupon once per environment (sandbox, then live). // Run: node --env-file=.env.local scripts/setup-catalog.mjs // Safe to run again: it finds existing prices by lookupKey and coupons by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PRODUCTS = [ { lookupKey: "tshirt", name: "T-shirt", unitAmountMinor: 2500 }, { lookupKey: "mug", name: "Mug", unitAmountMinor: 1200 }, ]; const COUPONS = [{ code: "WELCOME15", name: "Welcome 15%", percentOff: 15, duration: "once" }]; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); for (const item of PRODUCTS) { if (prices.some((price) => price.active && price.lookupKey === item.lookupKey)) { console.log(`price ${item.lookupKey}: exists`); continue; } const product = await call("POST", "/products", { name: item.name }, `setup-product-${item.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: item.lookupKey, productId: product.id, type: "one_time", unitAmountMinor: item.unitAmountMinor, }, `setup-price-${item.lookupKey}` ); console.log(`price ${item.lookupKey}: created`); } const coupons = await listAll("/coupons"); for (const coupon of COUPONS) { if (coupons.some((existing) => existing.code === coupon.code)) { console.log(`coupon ${coupon.code}: exists`); continue; } await call("POST", "/coupons", coupon, `setup-coupon-${coupon.code}`); console.log(`coupon ${coupon.code}: created`); } ``` ```bash node --env-file=.env.local scripts/setup-catalog.mjs ``` 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. ```typescript lib/zahlo.ts // Server-only Zahlo API client. Never import this file from a client component. const apiKey = process.env.ZAHLO_API_KEY ?? ""; /** zk_test_ keys use the sandbox, zk_live_ keys use live. */ export const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); export class ZahloError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null ) { super(message); } } const MAX_RETRIES = 2; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt: number, response?: Response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; /** Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. */ const retryable = (status: number, code: string) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ export async function zahlo( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, options: { body?: unknown; idempotencyKey?: string } = {} ): Promise { const idempotencyKey = method === "POST" ? (options.idempotencyKey ?? crypto.randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response: Response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) { return (response.status === 204 ? undefined : await response.json()) as T; } const payload = await response.json().catch(() => ({})); const code: string = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ export async function listAll( path: string ): Promise { const items: T[] = []; let startingAfter = ""; for (;;) { const page = await zahlo<{ data: T[]; hasMore: boolean }>( "GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}` ); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) { return items; } startingAfter = last.id; } } ``` The quickstart stores orders in SQLite (`node:sqlite`, built into Node.js). Use your own database the same way: what matters is the transaction. ```typescript lib/db.ts // Any database with transactions works; SQLite keeps the quickstart self-contained. export const db = new DatabaseSync(process.env.DATABASE_PATH ?? "store.db"); db.exec(` CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } export type Order = { id: string; items: string; status: string; session_id: string | null; payment_id: string | null; }; ``` ```typescript lib/checkout.ts type Price = { id: string; lookupKey: string | null; active: boolean }; type Coupon = { id: string; code: string | null; active: boolean }; export type CheckoutSession = { id: string; url: string | null; clientToken: string | null; status: "open" | "complete" | "expired" | "failed"; paymentStatus: "unpaid" | "paid" | "no_payment_required"; paymentId: string | null; reference: string | null; }; const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; /** * Saves a pending order and creates its Checkout Session. Prices are found by lookupKey, so the same * code works in sandbox and live, where every ID differs. */ export async function createOrderSession(input: { items: { lookupKey: string; quantity: number }[]; promoCode?: string; mode: "hosted" | "embedded"; }): Promise { const prices = await listAll("/prices"); const lineItems = input.items.map((item) => { const price = prices.find((p) => p.active && p.lookupKey === item.lookupKey); if (!price) { throw new Error(`No price with lookupKey ${item.lookupKey}; run scripts/setup-catalog.mjs`); } return { priceId: price.id, quantity: item.quantity }; }); let couponId: string | undefined; if (input.promoCode) { const coupons = await listAll("/coupons"); couponId = coupons.find( (c) => c.active && c.code?.toUpperCase() === input.promoCode?.toUpperCase() )?.id; } const orderId = crypto.randomUUID(); db.prepare("INSERT INTO orders (id, items) VALUES (?, ?)").run( orderId, JSON.stringify(input.items) ); const session = await zahlo("POST", "/checkout-sessions", { // One key per order: a retried request returns the same session instead of a second one. idempotencyKey: `order-${orderId}`, body: { type: "payment", mode: input.mode, lineItems, couponId, reference: orderId, successUrl: `${siteUrl}/success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/`, }, }); db.prepare("UPDATE orders SET session_id = ? WHERE id = ?").run(session.id, orderId); return session; } /** Reads an item list from a form: lookupKey, quantity and an optional promo code. */ export function cartFromForm(form: FormData) { return { items: [ { lookupKey: String(form.get("lookupKey") ?? "tshirt"), quantity: Math.max(1, Number(form.get("quantity") ?? 1)), }, ], promoCode: String(form.get("promoCode") ?? "").trim() || undefined, }; } ``` Your server creates the session and gives the browser only its ID and short-lived client token, never your API key: ```typescript app/api/checkout-session/route.ts // Embedded checkout: return only the session id and its short-lived client token to the browser. export async function POST(request: Request) { try { const session = await createOrderSession({ ...cartFromForm(await request.formData()), mode: "embedded", }); return Response.json({ apiUrl: apiBase, clientToken: session.clientToken, sessionId: session.id }); } catch (error) { if (error instanceof ZahloError) { console.error("checkout failed", error.code, error.requestId); return Response.json( { error: { code: error.code, message: error.message } }, { status: error.status >= 500 ? 502 : 400 } ); } throw error; } } ``` 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. ```tsx app/checkout/page.tsx "use client"; type Mounted = { mount(selector: string): Promise; destroy(): void }; declare global { interface Window { ZahloCheckout: new (config: { apiUrl: string; sessionId: string; clientToken: string; onSuccess?: (sessionId: string) => void; onError?: (error: Error) => void; }) => Mounted; } } const loadScript = (src: string) => new Promise((resolve, reject) => { if (window.ZahloCheckout) return resolve(); const script = Object.assign(document.createElement("script"), { src, async: true }); script.onload = () => resolve(); script.onerror = () => reject(new Error(`Could not load ${src}`)); document.head.append(script); }); // Embedded checkout: the payment form renders inside this page. Callbacks only change what the // customer sees; the order is fulfilled by the webhook. export default function CheckoutPage() { const [error, setError] = useState(""); const checkout = useRef(null); async function start(form: FormData) { setError(""); const response = await fetch("/api/checkout-session", { body: form, method: "POST" }); const body = await response.json(); if (!response.ok) return setError(body.error?.message ?? "Checkout is unavailable"); await loadScript(`${body.apiUrl}/sdk/v1/zahlo.js`); checkout.current?.destroy(); checkout.current = new window.ZahloCheckout({ apiUrl: body.apiUrl, sessionId: body.sessionId, clientToken: body.clientToken, onSuccess: (sessionId) => window.location.assign(`/success?session_id=${sessionId}`), onError: (failure) => setError(failure.message), }); await checkout.current.mount("#zahlo-checkout"); } useEffect(() => () => checkout.current?.destroy(), []); return (

Checkout

{error &&

{error}

}
); } ``` 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. ```typescript lib/orders.ts /** * What the success page shows. It reads state and never fulfils: only the webhook marks an order paid, * because a customer can open the success URL without paying. */ export async function orderForSuccessPage(sessionId: string) { const session = await zahlo("GET", `/checkout-sessions/${encodeURIComponent(sessionId)}`); const order = db.prepare("SELECT * FROM orders WHERE session_id = ?").get(session.id) as Order | undefined; return { order, paid: order?.status === "paid" }; } ``` ```tsx app/success/page.tsx export const dynamic = "force-dynamic"; export default async function SuccessPage({ searchParams, }: { searchParams: Promise<{ session_id?: string }>; }) { const { session_id: sessionId } = await searchParams; if (!sessionId) return

Missing session.

; const { order, paid } = await orderForSuccessPage(sessionId); if (!order) return

Order not found.

; return paid ? (

Thank you! Order {order.id} is paid.

) : ( // The webhook usually arrives within seconds; reload to check again.

Payment processing. Order {order.id} will be confirmed by email.

); } ```
```typescript lib/webhook-signature.ts export type ZahloEvent = { id: string; type: string; livemode: boolean; createdAt: string; data: { object: { id: string } & Record }; }; /** * Checks `Zahlo-Signature: t=,v1=` against the raw request body and returns the parsed event, * or null when the signature is missing, wrong or older than 5 minutes. During a secret rotation the * header carries one v1 per secret, so any matching v1 is accepted. */ export function verifyZahloEvent( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300 ): ZahloEvent | null { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return null; } const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex") ); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? (JSON.parse(rawBody) as ZahloEvent) : null; } ``` ```typescript app/api/webhooks/zahlo/route.ts type Refund = { id: string; paymentId: string; status: "pending" | "succeeded" | "failed" }; type Dispute = { id: string; paymentId: string; status: string }; export async function POST(request: Request) { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = await request.text(); const event = verifyZahloEvent( rawBody, request.headers.get("zahlo-signature"), process.env.ZAHLO_WEBHOOK_SECRET ?? "" ); if (!event) return new Response("Invalid signature", { status: 400 }); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. const id = encodeURIComponent(event.data.object.id); let apply: () => void = () => {}; if (event.type === "checkout.session.completed" || event.type === "checkout.session.expired") { const session = await zahlo("GET", `/checkout-sessions/${id}`); apply = () => { if (session.paymentStatus === "paid") { const paid = db .prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'") .run(session.paymentId, session.id); if (paid.changes === 1) { db.prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)").run( session.reference ); } } else if (session.status === "expired") { db.prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'").run(session.id); } }; } else if (event.type === "refund.succeeded") { const refund = await zahlo("GET", `/refunds/${id}`); apply = () => { if (refund.status === "succeeded") { db.prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?").run(refund.paymentId); } }; } else if (event.type === "dispute.created") { const dispute = await zahlo("GET", `/disputes/${id}`); apply = () => { // Hold the shipment and answer the dispute in the dashboard. db.prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'").run(dispute.paymentId); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return new Response("ok"); } ``` 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: ```bash cloudflared tunnel --url http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/api/webhooks/zahlo", "enabledEvents": ["checkout.session.completed", "checkout.session.expired", "refund.succeeded", "dispute.created"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash npm run dev ``` 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). 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](/guides/refunds). - **`dispute.created`**: the order is marked `disputed`. Hold the shipment and respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Hosted checkout on a website Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-hosted-website The customer fills in an order form on your website, pays on Zahlo's hosted payment page, and returns to your success page. Your webhook handler marks the order paid. 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. 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: ```bash .env ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:8000 ``` 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`). 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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 setup-catalog.php 'tshirt', 'name' => 'T-shirt', 'unitAmountMinor' => 2500], ['lookupKey' => 'mug', 'name' => 'Mug', 'unitAmountMinor' => 1200], ]; $coupons = [['code' => 'WELCOME15', 'name' => 'Welcome 15%', 'percentOff' => 15, 'duration' => 'once']]; $prices = zahlo_list_all('/prices'); foreach ($products as $item) { foreach ($prices as $price) { if (!empty($price['active']) && ($price['lookupKey'] ?? null) === $item['lookupKey']) { echo "price {$item['lookupKey']}: exists\n"; continue 2; } } $product = zahlo('POST', '/products', ['name' => $item['name']], "setup-product-{$item['lookupKey']}"); zahlo('POST', '/prices', [ 'currency' => 'EUR', 'lookupKey' => $item['lookupKey'], 'productId' => $product['id'], 'type' => 'one_time', 'unitAmountMinor' => $item['unitAmountMinor'], ], "setup-price-{$item['lookupKey']}"); echo "price {$item['lookupKey']}: created\n"; } $existing = zahlo_list_all('/coupons'); foreach ($coupons as $coupon) { foreach ($existing as $found) { if (($found['code'] ?? null) === $coupon['code']) { echo "coupon {$coupon['code']}: exists\n"; continue 2; } } zahlo('POST', '/coupons', $coupon, "setup-coupon-{$coupon['code']}"); echo "coupon {$coupon['code']}: created\n"; } ``` ```bash set -a; source .env; set +a php setup-catalog.php ``` ```javascript setup-catalog.mjs // Creates the store's products, prices and coupon once per environment (sandbox, then live). // Run: node --env-file=.env setup-catalog.mjs // Safe to run again: it finds existing prices by lookupKey and coupons by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PRODUCTS = [ { lookupKey: "tshirt", name: "T-shirt", unitAmountMinor: 2500 }, { lookupKey: "mug", name: "Mug", unitAmountMinor: 1200 }, ]; const COUPONS = [{ code: "WELCOME15", name: "Welcome 15%", percentOff: 15, duration: "once" }]; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); for (const item of PRODUCTS) { if (prices.some((price) => price.active && price.lookupKey === item.lookupKey)) { console.log(`price ${item.lookupKey}: exists`); continue; } const product = await call("POST", "/products", { name: item.name }, `setup-product-${item.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: item.lookupKey, productId: product.id, type: "one_time", unitAmountMinor: item.unitAmountMinor, }, `setup-price-${item.lookupKey}` ); console.log(`price ${item.lookupKey}: created`); } const coupons = await listAll("/coupons"); for (const coupon of COUPONS) { if (coupons.some((existing) => existing.code === coupon.code)) { console.log(`coupon ${coupon.code}: exists`); continue; } await call("POST", "/coupons", coupon, `setup-coupon-${coupon.code}`); console.log(`coupon ${coupon.code}: created`); } ``` ```bash node --env-file=.env setup-catalog.mjs ``` 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. Orders are stored in SQLite; use your own database the same way, since what matters is the transaction. ```php zahlo.php = 500 || ($status === 409 && $code === 'request_in_progress'); } function zahlo_sleep(int $attempt, ?string $retryAfter): void { $seconds = $retryAfter !== null && is_numeric($retryAfter) ? (float) $retryAfter : 0; usleep((int) (($seconds > 0 ? $seconds : 0.5 * (2 ** $attempt)) * 1_000_000)); } /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ function zahlo(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): ?array { $headers = ['content-type: application/json', 'x-api-key: ' . (getenv('ZAHLO_API_KEY') ?: '')]; if ($method === 'POST') { $headers[] = 'idempotency-key: ' . ($idempotencyKey ?? bin2hex(random_bytes(16))); } for ($attempt = 0;; $attempt++) { $retryAfter = null; $ch = curl_init(zahlo_base() . '/api/v1' . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) { if (stripos($line, 'retry-after:') === 0) { $retryAfter = trim(substr($line, 12)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $networkError = curl_error($ch); if ($raw === false) { if ($attempt < ZAHLO_MAX_RETRIES) { zahlo_sleep($attempt, null); continue; } throw new RuntimeException('Zahlo request failed: ' . $networkError); } $payload = json_decode($raw, true); if ($status >= 200 && $status < 300) { return $status === 204 ? null : $payload; } $code = $payload['error']['code'] ?? 'unknown'; if ($attempt < ZAHLO_MAX_RETRIES && zahlo_retryable($status, $code)) { zahlo_sleep($attempt, $retryAfter); continue; } throw new ZahloError($status, $code, $payload['error']['message'] ?? 'Request failed', $payload['requestId'] ?? null); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ function zahlo_list_all(string $path): array { $items = []; $after = ''; for (;;) { $page = zahlo('GET', $path . '?limit=100' . ($after !== '' ? '&startingAfter=' . rawurlencode($after) : '')); $items = array_merge($items, $page['data']); $last = end($page['data']); if (empty($page['hasMore']) || !$last) { return $items; } $after = $last['id']; } } ``` ```php db.php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA busy_timeout = 5000'); $pdo->exec(" CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); "); } return $pdo; } /** Runs $fn in one transaction: all of its writes commit together, or none do. */ function transaction(callable $fn): mixed { $pdo = db(); $pdo->exec('BEGIN IMMEDIATE'); try { $result = $fn($pdo); $pdo->exec('COMMIT'); return $result; } catch (Throwable $error) { $pdo->exec('ROLLBACK'); throw $error; } } ``` ```html index.html Shop

Shop

Pay without leaving the page

``` ```php checkout_lib.php prepare('INSERT INTO orders (id, items) VALUES (?, ?)') ->execute([$orderId, json_encode([['lookupKey' => $lookupKey, 'quantity' => $quantity]])]); $body = [ 'type' => 'payment', 'mode' => $mode, 'lineItems' => [['priceId' => $price['id'], 'quantity' => $quantity]], 'reference' => $orderId, 'successUrl' => site_url() . '/success.php?session_id={CHECKOUT_SESSION_ID}', 'cancelUrl' => site_url() . '/', ]; if ($couponId !== null) { $body['couponId'] = $couponId; } // One key per order: a retried request returns the same session instead of a second one. $session = zahlo('POST', '/checkout-sessions', $body, "order-$orderId"); db()->prepare('UPDATE orders SET session_id = ? WHERE id = ?')->execute([$session['id'], $orderId]); return $session; } /** Runs $respond with the new session; Zahlo errors become a 400 (bad request) or 502 (Zahlo unavailable). */ function checkout_or_fail(string $mode, callable $respond): void { try { $respond(create_order_session($mode)); } catch (ZahloError $error) { error_log("checkout failed {$error->errorCode} {$error->requestId}"); http_response_code($error->status >= 500 ? 502 : 400); header('content-type: application/json'); echo json_encode(['error' => ['code' => $error->errorCode, 'message' => $error->getMessage()]]); } } ``` ```php checkout.php Missing session.

'; exit; } $session = zahlo('GET', '/checkout-sessions/' . rawurlencode($sessionId)); $stmt = db()->prepare('SELECT * FROM orders WHERE session_id = ?'); $stmt->execute([$session['id']]); $order = $stmt->fetch(); if (!$order) { echo '

Order not found.

'; } elseif ($order['status'] === 'paid') { echo '

Thank you! Order ' . htmlspecialchars($order['id']) . ' is paid.

'; } else { // The webhook usually arrives within seconds; reload to check again. echo '

Payment processing. Order ' . htmlspecialchars($order['id']) . ' will be confirmed by email.

'; } ```
One file serves the pages and the API routes. Its 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. The success page shows the order's state and never marks an order paid. Orders are stored in SQLite (`node:sqlite`); use your own database the same way. ```javascript server.mjs // One-file Zahlo store: static pages, hosted and embedded checkout, success page and webhook. // Needs Node 22.5+ and no dependencies. Run: node --env-file=.env server.mjs // Env: ZAHLO_API_KEY, ZAHLO_API_BASE (optional), ZAHLO_WEBHOOK_SECRET, SITE_URL, DATABASE_PATH, PORT const apiKey = process.env.ZAHLO_API_KEY ?? ""; const webhookSecret = process.env.ZAHLO_WEBHOOK_SECRET ?? ""; const port = Number(process.env.PORT ?? 3000); const siteUrl = process.env.SITE_URL ?? `http://localhost:${port}`; // zk_test_ keys use the sandbox, zk_live_ keys use live. const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public"); // ---- Database (any database with transactions works; SQLite keeps this self-contained) ---- const db = new DatabaseSync(process.env.DATABASE_PATH ?? "store.db"); db.exec(` CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ function transaction(fn) { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } // ---- Zahlo API client ---- class ZahloError extends Error { constructor(status, code, message, requestId) { super(message); this.status = status; this.code = code; this.requestId = requestId; } } const MAX_RETRIES = 2; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt, response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; // Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. const retryable = (status, code) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** Every POST carries one Idempotency-Key, reused on each retry, so a retry can never create a second object. */ async function zahlo(method, path, { body, idempotencyKey } = {}) { const key = method === "POST" ? (idempotencyKey ?? randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(key ? { "idempotency-key": key } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) return response.status === 204 ? undefined : await response.json(); const payload = await response.json().catch(() => ({})); const code = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ async function listAll(path) { const items = []; let startingAfter = ""; for (;;) { const page = await zahlo("GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}`); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) return items; startingAfter = last.id; } } // ---- Webhook signature ---- /** Checks `Zahlo-Signature: t=,v1=` on the raw body; any matching v1 passes (secret rotation). */ function verifyZahloEvent(rawBody, header, secret, toleranceSeconds = 300) { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return null; const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? JSON.parse(rawBody) : null; } // ---- Checkout ---- /** Saves a pending order and creates its Checkout Session. Prices are found by lookupKey, so the same code works in sandbox and live. */ async function createOrderSession(form, mode) { const lookupKey = form.get("lookupKey") || "tshirt"; const quantity = Math.max(1, Number(form.get("quantity") ?? 1) || 1); const promoCode = String(form.get("promoCode") ?? "").trim(); const price = (await listAll("/prices")).find((p) => p.active && p.lookupKey === lookupKey); if (!price) throw new Error(`No price with lookupKey ${lookupKey}; run setup-catalog.mjs`); let couponId; if (promoCode) { couponId = (await listAll("/coupons")).find( (c) => c.active && c.code?.toUpperCase() === promoCode.toUpperCase() )?.id; } const orderId = randomUUID(); db.prepare("INSERT INTO orders (id, items) VALUES (?, ?)").run(orderId, JSON.stringify([{ lookupKey, quantity }])); const session = await zahlo("POST", "/checkout-sessions", { // One key per order: a retried request returns the same session instead of a second one. idempotencyKey: `order-${orderId}`, body: { type: "payment", mode, lineItems: [{ priceId: price.id, quantity }], couponId, reference: orderId, successUrl: `${siteUrl}/success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/`, }, }); db.prepare("UPDATE orders SET session_id = ? WHERE id = ?").run(session.id, orderId); return session; } async function readBody(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); return Buffer.concat(chunks); } const send = (response, status, body, headers = {}) => { response.writeHead(status, { "content-type": "text/html; charset=utf-8", ...headers }); response.end(body); }; const sendJson = (response, status, body) => send(response, status, JSON.stringify(body), { "content-type": "application/json" }); const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); /** Zahlo errors become 400 (bad request) or 502 (Zahlo unavailable); anything else is a 500. */ function checkoutFailed(response, error) { if (!(error instanceof ZahloError)) throw error; console.error("checkout failed", error.code, error.requestId); sendJson(response, error.status >= 500 ? 502 : 400, { error: { code: error.code, message: error.message } }); } // ---- Routes ---- async function handle(request, response) { const url = new URL(request.url ?? "/", siteUrl); const route = `${request.method} ${url.pathname}`; if (route === "GET /" || route === "GET /index.html" || route === "GET /pay.html") { const file = url.pathname === "/pay.html" ? "pay.html" : "index.html"; return send(response, 200, await readFile(join(publicDir, file))); } // Hosted checkout: create the session on the server, then send the browser to session.url. if (route === "POST /checkout") { try { const form = new URLSearchParams((await readBody(request)).toString()); const session = await createOrderSession(form, "hosted"); return send(response, 303, "", { location: session.url }); } catch (error) { return checkoutFailed(response, error); } } // Embedded checkout: return only the session id and its short-lived client token to the browser. if (route === "POST /checkout-session") { try { const body = await readBody(request); const type = request.headers["content-type"] ?? ""; // The page posts FormData (multipart); a plain HTML form posts urlencoded. const form = type.startsWith("multipart/form-data") ? await new Response(body, { headers: { "content-type": type } }).formData() : new URLSearchParams(body.toString()); const session = await createOrderSession(form, "embedded"); return sendJson(response, 200, { apiUrl: apiBase, clientToken: session.clientToken, sessionId: session.id }); } catch (error) { return checkoutFailed(response, error); } } // Reads state and never fulfils: only the webhook marks an order paid, because a customer can open this URL without paying. if (route === "GET /success") { const sessionId = url.searchParams.get("session_id"); if (!sessionId) return send(response, 200, "

Missing session.

"); const session = await zahlo("GET", `/checkout-sessions/${encodeURIComponent(sessionId)}`); const order = db.prepare("SELECT * FROM orders WHERE session_id = ?").get(session.id); if (!order) return send(response, 200, "

Order not found.

"); return send( response, 200, order.status === "paid" ? `

Thank you! Order ${escapeHtml(order.id)} is paid.

` : // The webhook usually arrives within seconds; reload to check again. `

Payment processing. Order ${escapeHtml(order.id)} will be confirmed by email.

` ); } if (route === "POST /webhooks/zahlo") { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = (await readBody(request)).toString("utf8"); const event = verifyZahloEvent(rawBody, request.headers["zahlo-signature"] ?? null, webhookSecret); if (!event) return send(response, 400, "Invalid signature"); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. const id = encodeURIComponent(event.data.object.id); let apply = () => {}; if (event.type === "checkout.session.completed" || event.type === "checkout.session.expired") { const session = await zahlo("GET", `/checkout-sessions/${id}`); apply = () => { if (session.paymentStatus === "paid") { const paid = db .prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'") .run(session.paymentId, session.id); if (paid.changes === 1) { db.prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)").run( session.reference ); } } else if (session.status === "expired") { db.prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'").run(session.id); } }; } else if (event.type === "refund.succeeded") { const refund = await zahlo("GET", `/refunds/${id}`); apply = () => { if (refund.status === "succeeded") { db.prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?").run(refund.paymentId); } }; } else if (event.type === "dispute.created") { const dispute = await zahlo("GET", `/disputes/${id}`); apply = () => { // Hold the shipment and answer the dispute in the dashboard. db.prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'").run( dispute.paymentId ); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return send(response, 200, "ok"); } return send(response, 404, "Not found"); } createServer((request, response) => { handle(request, response).catch((error) => { console.error("request failed", error); if (!response.headersSent) send(response, 500, "Internal error"); }); }).listen(port, () => console.log(`Listening on ${siteUrl}`)); ``` ```html public/index.html Shop

Shop

Pay without leaving the page

```
```php webhook.php ,v1=` against the raw body. During a secret rotation the header * carries one v1 per secret, so any matching v1 is accepted. Rejects timestamps older than 5 minutes. */ function verify_zahlo_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } // 1. Verify the signature on the raw body before trusting anything in it. $rawBody = file_get_contents('php://input'); if (!verify_zahlo_signature($rawBody, $_SERVER['HTTP_ZAHLO_SIGNATURE'] ?? '', getenv('ZAHLO_WEBHOOK_SECRET') ?: '')) { http_response_code(400); echo 'Invalid signature'; exit; } $event = json_decode($rawBody, true); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. $id = rawurlencode($event['data']['object']['id'] ?? ''); $apply = function (PDO $pdo): void {}; if ($event['type'] === 'checkout.session.completed' || $event['type'] === 'checkout.session.expired') { $session = zahlo('GET', "/checkout-sessions/$id"); $apply = function (PDO $pdo) use ($session): void { if ($session['paymentStatus'] === 'paid') { $paid = $pdo->prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'"); $paid->execute([$session['paymentId'], $session['id']]); if ($paid->rowCount() === 1) { $pdo->prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)") ->execute([$session['reference']]); } } elseif ($session['status'] === 'expired') { $pdo->prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'") ->execute([$session['id']]); } }; } elseif ($event['type'] === 'refund.succeeded') { $refund = zahlo('GET', "/refunds/$id"); $apply = function (PDO $pdo) use ($refund): void { if ($refund['status'] === 'succeeded') { $pdo->prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?")->execute([$refund['paymentId']]); } }; } elseif ($event['type'] === 'dispute.created') { $dispute = zahlo('GET', "/disputes/$id"); $apply = function (PDO $pdo) use ($dispute): void { // Hold the shipment and answer the dispute in the dashboard. $pdo->prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'") ->execute([$dispute['paymentId']]); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. try { transaction(function (PDO $pdo) use ($event, $apply) { $fresh = $pdo->prepare('INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING'); $fresh->execute([$event['id']]); if ($fresh->rowCount() === 1) { $apply($pdo); } }); } catch (Throwable $error) { error_log('webhook failed: ' . $error->getMessage()); http_response_code(500); // not 2xx: Zahlo retries the event echo 'error'; exit; } echo 'ok'; ``` The handler is the `POST /webhooks/zahlo` route in `server.mjs` above. 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 server and note its `https://….trycloudflare.com` URL: ```bash cloudflared tunnel --url http://localhost:8000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/webhook.php", "enabledEvents": ["checkout.session.completed", "checkout.session.expired", "refund.succeeded", "dispute.created"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash set -a; source .env; set +a php -S localhost:8000 ``` ```bash node --env-file=.env server.mjs ``` Open the site, buy 3 T-shirts with the promo code `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.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: abandon a checkout (the order stays `pending` until `checkout.session.expired` marks it `expired`), and resend a delivery from **Developers → Webhooks** (nothing changes the second time). The handler already reacts to reversals: - **`refund.succeeded`**: the order is marked `refunded`. A refund is final only when `refund.succeeded` or `refund.failed` arrives. See [Refunds](/guides/refunds). - **`dispute.created`**: the order is marked `disputed`. Hold the shipment and respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Embedded checkout on a website Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-embedded-website The customer pays in a form that Zahlo renders inside your page, loaded with one script tag (`/sdk/v1/zahlo.js`); card details never touch your server. Your webhook handler marks the order paid. 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. 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: ```bash .env ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:8000 ``` 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`). 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. **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. 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 setup-catalog.php 'tshirt', 'name' => 'T-shirt', 'unitAmountMinor' => 2500], ['lookupKey' => 'mug', 'name' => 'Mug', 'unitAmountMinor' => 1200], ]; $coupons = [['code' => 'WELCOME15', 'name' => 'Welcome 15%', 'percentOff' => 15, 'duration' => 'once']]; $prices = zahlo_list_all('/prices'); foreach ($products as $item) { foreach ($prices as $price) { if (!empty($price['active']) && ($price['lookupKey'] ?? null) === $item['lookupKey']) { echo "price {$item['lookupKey']}: exists\n"; continue 2; } } $product = zahlo('POST', '/products', ['name' => $item['name']], "setup-product-{$item['lookupKey']}"); zahlo('POST', '/prices', [ 'currency' => 'EUR', 'lookupKey' => $item['lookupKey'], 'productId' => $product['id'], 'type' => 'one_time', 'unitAmountMinor' => $item['unitAmountMinor'], ], "setup-price-{$item['lookupKey']}"); echo "price {$item['lookupKey']}: created\n"; } $existing = zahlo_list_all('/coupons'); foreach ($coupons as $coupon) { foreach ($existing as $found) { if (($found['code'] ?? null) === $coupon['code']) { echo "coupon {$coupon['code']}: exists\n"; continue 2; } } zahlo('POST', '/coupons', $coupon, "setup-coupon-{$coupon['code']}"); echo "coupon {$coupon['code']}: created\n"; } ``` ```bash set -a; source .env; set +a php setup-catalog.php ``` ```javascript setup-catalog.mjs // Creates the store's products, prices and coupon once per environment (sandbox, then live). // Run: node --env-file=.env setup-catalog.mjs // Safe to run again: it finds existing prices by lookupKey and coupons by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PRODUCTS = [ { lookupKey: "tshirt", name: "T-shirt", unitAmountMinor: 2500 }, { lookupKey: "mug", name: "Mug", unitAmountMinor: 1200 }, ]; const COUPONS = [{ code: "WELCOME15", name: "Welcome 15%", percentOff: 15, duration: "once" }]; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); for (const item of PRODUCTS) { if (prices.some((price) => price.active && price.lookupKey === item.lookupKey)) { console.log(`price ${item.lookupKey}: exists`); continue; } const product = await call("POST", "/products", { name: item.name }, `setup-product-${item.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: item.lookupKey, productId: product.id, type: "one_time", unitAmountMinor: item.unitAmountMinor, }, `setup-price-${item.lookupKey}` ); console.log(`price ${item.lookupKey}: created`); } const coupons = await listAll("/coupons"); for (const coupon of COUPONS) { if (coupons.some((existing) => existing.code === coupon.code)) { console.log(`coupon ${coupon.code}: exists`); continue; } await call("POST", "/coupons", coupon, `setup-coupon-${coupon.code}`); console.log(`coupon ${coupon.code}: created`); } ``` ```bash node --env-file=.env setup-catalog.mjs ``` 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. Orders are stored in SQLite; use your own database the same way, since what matters is the transaction. ```php zahlo.php = 500 || ($status === 409 && $code === 'request_in_progress'); } function zahlo_sleep(int $attempt, ?string $retryAfter): void { $seconds = $retryAfter !== null && is_numeric($retryAfter) ? (float) $retryAfter : 0; usleep((int) (($seconds > 0 ? $seconds : 0.5 * (2 ** $attempt)) * 1_000_000)); } /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ function zahlo(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): ?array { $headers = ['content-type: application/json', 'x-api-key: ' . (getenv('ZAHLO_API_KEY') ?: '')]; if ($method === 'POST') { $headers[] = 'idempotency-key: ' . ($idempotencyKey ?? bin2hex(random_bytes(16))); } for ($attempt = 0;; $attempt++) { $retryAfter = null; $ch = curl_init(zahlo_base() . '/api/v1' . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) { if (stripos($line, 'retry-after:') === 0) { $retryAfter = trim(substr($line, 12)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $networkError = curl_error($ch); if ($raw === false) { if ($attempt < ZAHLO_MAX_RETRIES) { zahlo_sleep($attempt, null); continue; } throw new RuntimeException('Zahlo request failed: ' . $networkError); } $payload = json_decode($raw, true); if ($status >= 200 && $status < 300) { return $status === 204 ? null : $payload; } $code = $payload['error']['code'] ?? 'unknown'; if ($attempt < ZAHLO_MAX_RETRIES && zahlo_retryable($status, $code)) { zahlo_sleep($attempt, $retryAfter); continue; } throw new ZahloError($status, $code, $payload['error']['message'] ?? 'Request failed', $payload['requestId'] ?? null); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ function zahlo_list_all(string $path): array { $items = []; $after = ''; for (;;) { $page = zahlo('GET', $path . '?limit=100' . ($after !== '' ? '&startingAfter=' . rawurlencode($after) : '')); $items = array_merge($items, $page['data']); $last = end($page['data']); if (empty($page['hasMore']) || !$last) { return $items; } $after = $last['id']; } } ``` ```php db.php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA busy_timeout = 5000'); $pdo->exec(" CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); "); } return $pdo; } /** Runs $fn in one transaction: all of its writes commit together, or none do. */ function transaction(callable $fn): mixed { $pdo = db(); $pdo->exec('BEGIN IMMEDIATE'); try { $result = $fn($pdo); $pdo->exec('COMMIT'); return $result; } catch (Throwable $error) { $pdo->exec('ROLLBACK'); throw $error; } } ``` ```php checkout_lib.php prepare('INSERT INTO orders (id, items) VALUES (?, ?)') ->execute([$orderId, json_encode([['lookupKey' => $lookupKey, 'quantity' => $quantity]])]); $body = [ 'type' => 'payment', 'mode' => $mode, 'lineItems' => [['priceId' => $price['id'], 'quantity' => $quantity]], 'reference' => $orderId, 'successUrl' => site_url() . '/success.php?session_id={CHECKOUT_SESSION_ID}', 'cancelUrl' => site_url() . '/', ]; if ($couponId !== null) { $body['couponId'] = $couponId; } // One key per order: a retried request returns the same session instead of a second one. $session = zahlo('POST', '/checkout-sessions', $body, "order-$orderId"); db()->prepare('UPDATE orders SET session_id = ? WHERE id = ?')->execute([$session['id'], $orderId]); return $session; } /** Runs $respond with the new session; Zahlo errors become a 400 (bad request) or 502 (Zahlo unavailable). */ function checkout_or_fail(string $mode, callable $respond): void { try { $respond(create_order_session($mode)); } catch (ZahloError $error) { error_log("checkout failed {$error->errorCode} {$error->requestId}"); http_response_code($error->status >= 500 ? 502 : 400); header('content-type: application/json'); echo json_encode(['error' => ['code' => $error->errorCode, 'message' => $error->getMessage()]]); } } ``` The server creates the session and gives the browser only its ID and short-lived client token, never your API key: ```php checkout-session.php zahlo_base(), 'clientToken' => $session['clientToken'], 'sessionId' => $session['id']]); }); ``` The page loads the browser SDK and mounts the payment form. Its callbacks only change what the customer sees; the webhook fulfils the order. ```html pay.html Checkout

Checkout

``` The success page shows the order's state. It never marks an order paid: anyone can open the success URL. ```php success.php Missing session.

'; exit; } $session = zahlo('GET', '/checkout-sessions/' . rawurlencode($sessionId)); $stmt = db()->prepare('SELECT * FROM orders WHERE session_id = ?'); $stmt->execute([$session['id']]); $order = $stmt->fetch(); if (!$order) { echo '

Order not found.

'; } elseif ($order['status'] === 'paid') { echo '

Thank you! Order ' . htmlspecialchars($order['id']) . ' is paid.

'; } else { // The webhook usually arrives within seconds; reload to check again. echo '

Payment processing. Order ' . htmlspecialchars($order['id']) . ' will be confirmed by email.

'; } ```
One file serves the pages and the API routes. Its 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. The success page shows the order's state and never marks an order paid. Orders are stored in SQLite (`node:sqlite`); use your own database the same way. ```javascript server.mjs // One-file Zahlo store: static pages, hosted and embedded checkout, success page and webhook. // Needs Node 22.5+ and no dependencies. Run: node --env-file=.env server.mjs // Env: ZAHLO_API_KEY, ZAHLO_API_BASE (optional), ZAHLO_WEBHOOK_SECRET, SITE_URL, DATABASE_PATH, PORT const apiKey = process.env.ZAHLO_API_KEY ?? ""; const webhookSecret = process.env.ZAHLO_WEBHOOK_SECRET ?? ""; const port = Number(process.env.PORT ?? 3000); const siteUrl = process.env.SITE_URL ?? `http://localhost:${port}`; // zk_test_ keys use the sandbox, zk_live_ keys use live. const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public"); // ---- Database (any database with transactions works; SQLite keeps this self-contained) ---- const db = new DatabaseSync(process.env.DATABASE_PATH ?? "store.db"); db.exec(` CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, items TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | expired | refunded | disputed session_id TEXT UNIQUE, payment_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- Work that cannot join the transaction (emails, shipping APIs) is queued here inside it. CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY, kind TEXT NOT NULL, order_id TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, UNIQUE (kind, order_id) ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ function transaction(fn) { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } // ---- Zahlo API client ---- class ZahloError extends Error { constructor(status, code, message, requestId) { super(message); this.status = status; this.code = code; this.requestId = requestId; } } const MAX_RETRIES = 2; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt, response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; // Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. const retryable = (status, code) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** Every POST carries one Idempotency-Key, reused on each retry, so a retry can never create a second object. */ async function zahlo(method, path, { body, idempotencyKey } = {}) { const key = method === "POST" ? (idempotencyKey ?? randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(key ? { "idempotency-key": key } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) return response.status === 204 ? undefined : await response.json(); const payload = await response.json().catch(() => ({})); const code = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ async function listAll(path) { const items = []; let startingAfter = ""; for (;;) { const page = await zahlo("GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}`); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) return items; startingAfter = last.id; } } // ---- Webhook signature ---- /** Checks `Zahlo-Signature: t=,v1=` on the raw body; any matching v1 passes (secret rotation). */ function verifyZahloEvent(rawBody, header, secret, toleranceSeconds = 300) { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return null; const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? JSON.parse(rawBody) : null; } // ---- Checkout ---- /** Saves a pending order and creates its Checkout Session. Prices are found by lookupKey, so the same code works in sandbox and live. */ async function createOrderSession(form, mode) { const lookupKey = form.get("lookupKey") || "tshirt"; const quantity = Math.max(1, Number(form.get("quantity") ?? 1) || 1); const promoCode = String(form.get("promoCode") ?? "").trim(); const price = (await listAll("/prices")).find((p) => p.active && p.lookupKey === lookupKey); if (!price) throw new Error(`No price with lookupKey ${lookupKey}; run setup-catalog.mjs`); let couponId; if (promoCode) { couponId = (await listAll("/coupons")).find( (c) => c.active && c.code?.toUpperCase() === promoCode.toUpperCase() )?.id; } const orderId = randomUUID(); db.prepare("INSERT INTO orders (id, items) VALUES (?, ?)").run(orderId, JSON.stringify([{ lookupKey, quantity }])); const session = await zahlo("POST", "/checkout-sessions", { // One key per order: a retried request returns the same session instead of a second one. idempotencyKey: `order-${orderId}`, body: { type: "payment", mode, lineItems: [{ priceId: price.id, quantity }], couponId, reference: orderId, successUrl: `${siteUrl}/success?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/`, }, }); db.prepare("UPDATE orders SET session_id = ? WHERE id = ?").run(session.id, orderId); return session; } async function readBody(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); return Buffer.concat(chunks); } const send = (response, status, body, headers = {}) => { response.writeHead(status, { "content-type": "text/html; charset=utf-8", ...headers }); response.end(body); }; const sendJson = (response, status, body) => send(response, status, JSON.stringify(body), { "content-type": "application/json" }); const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); /** Zahlo errors become 400 (bad request) or 502 (Zahlo unavailable); anything else is a 500. */ function checkoutFailed(response, error) { if (!(error instanceof ZahloError)) throw error; console.error("checkout failed", error.code, error.requestId); sendJson(response, error.status >= 500 ? 502 : 400, { error: { code: error.code, message: error.message } }); } // ---- Routes ---- async function handle(request, response) { const url = new URL(request.url ?? "/", siteUrl); const route = `${request.method} ${url.pathname}`; if (route === "GET /" || route === "GET /index.html" || route === "GET /pay.html") { const file = url.pathname === "/pay.html" ? "pay.html" : "index.html"; return send(response, 200, await readFile(join(publicDir, file))); } // Hosted checkout: create the session on the server, then send the browser to session.url. if (route === "POST /checkout") { try { const form = new URLSearchParams((await readBody(request)).toString()); const session = await createOrderSession(form, "hosted"); return send(response, 303, "", { location: session.url }); } catch (error) { return checkoutFailed(response, error); } } // Embedded checkout: return only the session id and its short-lived client token to the browser. if (route === "POST /checkout-session") { try { const body = await readBody(request); const type = request.headers["content-type"] ?? ""; // The page posts FormData (multipart); a plain HTML form posts urlencoded. const form = type.startsWith("multipart/form-data") ? await new Response(body, { headers: { "content-type": type } }).formData() : new URLSearchParams(body.toString()); const session = await createOrderSession(form, "embedded"); return sendJson(response, 200, { apiUrl: apiBase, clientToken: session.clientToken, sessionId: session.id }); } catch (error) { return checkoutFailed(response, error); } } // Reads state and never fulfils: only the webhook marks an order paid, because a customer can open this URL without paying. if (route === "GET /success") { const sessionId = url.searchParams.get("session_id"); if (!sessionId) return send(response, 200, "

Missing session.

"); const session = await zahlo("GET", `/checkout-sessions/${encodeURIComponent(sessionId)}`); const order = db.prepare("SELECT * FROM orders WHERE session_id = ?").get(session.id); if (!order) return send(response, 200, "

Order not found.

"); return send( response, 200, order.status === "paid" ? `

Thank you! Order ${escapeHtml(order.id)} is paid.

` : // The webhook usually arrives within seconds; reload to check again. `

Payment processing. Order ${escapeHtml(order.id)} will be confirmed by email.

` ); } if (route === "POST /webhooks/zahlo") { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = (await readBody(request)).toString("utf8"); const event = verifyZahloEvent(rawBody, request.headers["zahlo-signature"] ?? null, webhookSecret); if (!event) return send(response, 400, "Invalid signature"); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. const id = encodeURIComponent(event.data.object.id); let apply = () => {}; if (event.type === "checkout.session.completed" || event.type === "checkout.session.expired") { const session = await zahlo("GET", `/checkout-sessions/${id}`); apply = () => { if (session.paymentStatus === "paid") { const paid = db .prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'") .run(session.paymentId, session.id); if (paid.changes === 1) { db.prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)").run( session.reference ); } } else if (session.status === "expired") { db.prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'").run(session.id); } }; } else if (event.type === "refund.succeeded") { const refund = await zahlo("GET", `/refunds/${id}`); apply = () => { if (refund.status === "succeeded") { db.prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?").run(refund.paymentId); } }; } else if (event.type === "dispute.created") { const dispute = await zahlo("GET", `/disputes/${id}`); apply = () => { // Hold the shipment and answer the dispute in the dashboard. db.prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'").run( dispute.paymentId ); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return send(response, 200, "ok"); } return send(response, 404, "Not found"); } createServer((request, response) => { handle(request, response).catch((error) => { console.error("request failed", error); if (!response.headersSent) send(response, 500, "Internal error"); }); }).listen(port, () => console.log(`Listening on ${siteUrl}`)); ``` The page loads the browser SDK and mounts the payment form. Its callbacks only change what the customer sees; the webhook fulfils the order. ```html public/pay.html Checkout

Checkout

```
```php webhook.php ,v1=` against the raw body. During a secret rotation the header * carries one v1 per secret, so any matching v1 is accepted. Rejects timestamps older than 5 minutes. */ function verify_zahlo_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } // 1. Verify the signature on the raw body before trusting anything in it. $rawBody = file_get_contents('php://input'); if (!verify_zahlo_signature($rawBody, $_SERVER['HTTP_ZAHLO_SIGNATURE'] ?? '', getenv('ZAHLO_WEBHOOK_SECRET') ?: '')) { http_response_code(400); echo 'Invalid signature'; exit; } $event = json_decode($rawBody, true); // 2. Re-fetch the object and act on its current state, so a late, older event cannot undo a newer one. $id = rawurlencode($event['data']['object']['id'] ?? ''); $apply = function (PDO $pdo): void {}; if ($event['type'] === 'checkout.session.completed' || $event['type'] === 'checkout.session.expired') { $session = zahlo('GET', "/checkout-sessions/$id"); $apply = function (PDO $pdo) use ($session): void { if ($session['paymentStatus'] === 'paid') { $paid = $pdo->prepare("UPDATE orders SET status = 'paid', payment_id = ? WHERE session_id = ? AND status = 'pending'"); $paid->execute([$session['paymentId'], $session['id']]); if ($paid->rowCount() === 1) { $pdo->prepare("INSERT OR IGNORE INTO jobs (kind, order_id) VALUES ('confirmation_email', ?)") ->execute([$session['reference']]); } } elseif ($session['status'] === 'expired') { $pdo->prepare("UPDATE orders SET status = 'expired' WHERE session_id = ? AND status = 'pending'") ->execute([$session['id']]); } }; } elseif ($event['type'] === 'refund.succeeded') { $refund = zahlo('GET', "/refunds/$id"); $apply = function (PDO $pdo) use ($refund): void { if ($refund['status'] === 'succeeded') { $pdo->prepare("UPDATE orders SET status = 'refunded' WHERE payment_id = ?")->execute([$refund['paymentId']]); } }; } elseif ($event['type'] === 'dispute.created') { $dispute = zahlo('GET', "/disputes/$id"); $apply = function (PDO $pdo) use ($dispute): void { // Hold the shipment and answer the dispute in the dashboard. $pdo->prepare("UPDATE orders SET status = 'disputed' WHERE payment_id = ? AND status = 'paid'") ->execute([$dispute['paymentId']]); }; } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. try { transaction(function (PDO $pdo) use ($event, $apply) { $fresh = $pdo->prepare('INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING'); $fresh->execute([$event['id']]); if ($fresh->rowCount() === 1) { $apply($pdo); } }); } catch (Throwable $error) { error_log('webhook failed: ' . $error->getMessage()); http_response_code(500); // not 2xx: Zahlo retries the event echo 'error'; exit; } echo 'ok'; ``` The handler is the `POST /webhooks/zahlo` route in `server.mjs` above. 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 server and note its `https://….trycloudflare.com` URL: ```bash cloudflared tunnel --url http://localhost:8000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/webhook.php", "enabledEvents": ["checkout.session.completed", "checkout.session.expired", "refund.succeeded", "dispute.created"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash set -a; source .env; set +a php -S localhost:8000 ``` ```bash node --env-file=.env server.mjs ``` Open `/pay.html`, 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: abandon a checkout (the order stays `pending` until `checkout.session.expired` marks it `expired`), and resend a delivery from **Developers → Webhooks** (nothing changes the second time). The handler already reacts to reversals: - **`refund.succeeded`**: the order is marked `refunded`. A refund is final only when `refund.succeeded` or `refund.failed` arrives. See [Refunds](/guides/refunds). - **`dispute.created`**: the order is marked `disputed`. Hold the shipment and respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Payment links (no code) Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-no-code A payment link is a URL to a Zahlo checkout page for a fixed product. Share it in an email, a social post or a "Buy" button on any website builder. Each visit starts its own checkout, so one link can be paid many times. **You need:** a Sandbox account at `https://merchant-demo.zahlo.eu`. In the dashboard, open **Products** and add a product with a one-time price in EUR, for example "T-shirt", €25.00. Open **Links**, create a payment link, pick the price and the quantity, and save. Copy the link's URL (`…/pay/`). To send customers back to your site after paying, set a redirect URL. Its host must be a registered checkout domain: add it under **Developers → Checkout domains** first (HTTPS only in live). Without a redirect URL, customers see Zahlo's confirmation page and no domain is needed. All prices on a link use one currency. A link can also apply a coupon or accept promotion codes. Open the link in a private window and pay with the Sandbox test card `4111 1111 1111 1111`, expiry `03/30`, CVC `737`. Check that the payment appears under **Payments** with the link's product. Each paid order appears under **Payments**, with the customer's email. Fulfil from there; reaching the redirect URL is not proof of payment, because anyone can open it. To fulfil automatically, add a webhook endpoint for `checkout.session.completed` and handle it as in [Hosted checkout with Next.js](/quickstarts/ecommerce-hosted-nextjs#handle-webhooks): verify the signature, re-fetch the session, and record the event and the order change in one transaction. Refund a payment from its page under **Payments**; it is final when the refund shows as succeeded. If a customer disputes a payment, respond in the dashboard before the deadline. See [Refunds](/guides/refunds) and [Disputes](/guides/disputes). Deactivate a link under **Links** when the offer ends. New checkouts stop at once, and a payment that still completes on an already open checkout is refunded automatically. Only steps 3, 4 (if you use a webhook), 5 (if you set a redirect URL) and 7 apply to links, plus recreating the product: 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](/go-live#recreate-in-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 `lookupKey`s 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. # WooCommerce Source: https://docs-demo.zahlo.eu/quickstarts/ecommerce-woocommerce The Zahlo plugin (`zahlo-pay`) adds a **Zahlo Checkout** payment method to WooCommerce. It works with Classic checkout and Checkout Blocks, in two modes: - **Hosted:** the customer is redirected to Zahlo's payment page and back to the order confirmation. - **Embedded:** the payment fields appear on your store's order-pay page. You write no code. The plugin creates one checkout session per order, moves orders to **Processing** only on a signed Zahlo event or a server-side check, and syncs refunds in both directions. **You need:** WordPress 6.0+, WooCommerce 8.0+ (9.5 tested), PHP 8.0+, and a Sandbox API key (`zk_test_…`) from the **Developers** page at `https://merchant-demo.zahlo.eu`. WooCommerce Subscriptions is not supported. To sell subscriptions next to a WooCommerce store, use [Subscriptions on a website or by link](/quickstarts/saas-subscriptions-website); see [Not supported](/unsupported#woocommerce-subscriptions). In WordPress, open **Plugins → Add New Plugin → Upload Plugin**, choose the `zahlo-pay` zip ([download](https://merchant.zahlo.eu/downloads/zahlo-pay.zip), also under **Integrations** in the dashboard), install it and click **Activate**. To install by hand, copy the `zahlo-pay` folder to `wp-content/plugins/` and activate it under **Plugins**. Open **WooCommerce → Settings → Payments → Zahlo Checkout** and: 1. Tick **Enable Zahlo Checkout**. 2. Paste your `zk_test_…` key into **API key**. 3. Choose **Hosted** or **Embedded** under **Checkout**. Hosted is the default. 4. Optionally change the title and description customers see at checkout. 5. Click **Save changes**. The settings page then shows **Sandbox mode (test key)** and the registered checkout domain. The key selects the environment: a `zk_test_` key uses the Sandbox at `https://merchant-demo.zahlo.eu`, a `zk_live_` key uses `https://merchant.zahlo.eu`. The method appears at checkout only when the key is valid and the store currency is one of CAD, CHF, DKK, EUR, GBP, JPY, PLN, RON or USD and enabled for your account. EUR is enabled by default; see [Limits](/limits). Saving the settings does this for you. The plugin registers: - the store's **checkout domain** (Zahlo only redirects back to, and embeds checkout on, registered domains), with Apple Pay requested in Embedded mode; - a **webhook endpoint** at `/?wc-api=zahlo_webhook` for checkout, payment, refund and dispute events. The plugin keeps its signing secret. Check both on the dashboard's **Developers** page. If the store's address changes, save the settings again. **Local store.** A `zk_test_` key also works on `http://localhost:`: the checkout domain registers, and orders move to Processing through the server-side check when the customer returns. Zahlo can't deliver webhooks to `localhost`, so saving reports that the webhook endpoint could not be registered, and dashboard refunds and disputes don't reach the order. For the full flow, expose the store through an HTTPS tunnel (for example `cloudflared tunnel --url http://localhost:8080`), set the public address in `wp-config.php` and save the settings again: ```php define('ZAHLO_PAY_STORE_PUBLIC_URL', 'https://your-tunnel.trycloudflare.com'); ``` Apple Pay needs a public HTTPS domain, so it isn't offered on `localhost`. Add a product to the cart, check out, choose **Zahlo Checkout** and pay with the Sandbox test card `4111 1111 1111 1111`, expiry `03/30`, CVC `737`. 1. You land on the order confirmation page. The browser return alone never marks an order paid. 2. The order moves to **Processing** once the signed `checkout.session.completed` event arrives (or the plugin's server-side check confirms the session). 3. The payment appears under **Payments** in the dashboard with your order number as its reference. Test both checkouts if your theme offers them: Classic checkout and Checkout Blocks use the same method. Delivered and failed events are listed per endpoint on the dashboard's **Developers** page. Refund from the WooCommerce order screen (**Refund → Refund via Zahlo Checkout**), in full or in part. If WooCommerce reports that the refund outcome is being confirmed, don't refund again: it appears on the order once Zahlo confirms it. Refunds made in the Zahlo dashboard appear on the order automatically. When a customer disputes a payment, the plugin adds an order note. Respond in the dashboard under **Disputes** before the deadline; see [Disputes](/guides/disputes). - **Zahlo Checkout is missing at checkout:** the key is missing or invalid, or the store currency is not enabled for your account. - **Order stays Pending payment:** a security plugin, a firewall or Cloudflare may block `POST /?wc-api=zahlo_webhook`. Allow it, then look at the endpoint's deliveries on the **Developers** page. - **"Could not register this store as a Zahlo checkout domain":** the message names the reason. A live key needs a public HTTPS hostname; `localhost` and IP addresses work only with a `zk_test_` key. - **"Payments are not available on this store":** a new account waits for Zahlo to approve its domain; the dashboard home shows the state. - **Apple Pay doesn't appear:** the settings page shows the Apple Pay state and any error next to the checkout domain. It needs Embedded mode, a public HTTPS domain and a supported device. Replace the `zk_test_` key with a `zk_live_` key and click **Save changes**. Saving registers the live webhook endpoint and the store's live checkout domain, so the plugin covers steps 1, 4 and 5 of the checklist below. Products stay in WooCommerce, so step 2 doesn't apply: 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](/go-live#recreate-in-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 `lookupKey`s 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. # Subscriptions with hosted checkout (Next.js) Source: https://docs-demo.zahlo.eu/quickstarts/saas-subscriptions-hosted-nextjs A signed-in user starts a 14-day free trial of your Pro plan (€19.00 a month) on Zahlo's hosted checkout. 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. 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. Create `.env.local` in the project root. Keep it out of version control; the key never reaches the browser. ```bash .env.local ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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: ```javascript scripts/setup-catalog.mjs // Creates the Pro plan, its monthly price with a 14-day trial, and a coupon, once per environment. // Run: node --env-file=.env.local scripts/setup-catalog.mjs // Safe to run again: it finds the price by lookupKey and the coupon by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PLAN = { lookupKey: "pro-monthly", name: "Pro", unitAmountMinor: 1900, trialDays: 14 }; const COUPON = { code: "WELCOME15", name: "15% off for 3 months", percentOff: 15, duration: "repeating", durationInMonths: 3 }; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); if (prices.some((price) => price.active && price.lookupKey === PLAN.lookupKey)) { console.log(`price ${PLAN.lookupKey}: exists`); } else { const product = await call("POST", "/products", { name: PLAN.name }, `setup-product-${PLAN.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: PLAN.lookupKey, productId: product.id, recurring: { interval: "month", trialDays: PLAN.trialDays }, type: "recurring", unitAmountMinor: PLAN.unitAmountMinor, }, `setup-price-${PLAN.lookupKey}` ); console.log(`price ${PLAN.lookupKey}: created`); } const coupons = await listAll("/coupons"); if (coupons.some((coupon) => coupon.code === COUPON.code)) { console.log(`coupon ${COUPON.code}: exists`); } else { await call("POST", "/coupons", COUPON, `setup-coupon-${COUPON.code}`); console.log(`coupon ${COUPON.code}: created`); } ``` ```bash node --env-file=.env.local scripts/setup-catalog.mjs ``` 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. ```typescript lib/zahlo.ts // Server-only Zahlo API client. Never import this file from a client component. const apiKey = process.env.ZAHLO_API_KEY ?? ""; /** zk_test_ keys use the sandbox, zk_live_ keys use live. */ export const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); export class ZahloError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null ) { super(message); } } const MAX_RETRIES = 2; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt: number, response?: Response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; /** Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. */ const retryable = (status: number, code: string) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ export async function zahlo( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, options: { body?: unknown; idempotencyKey?: string } = {} ): Promise { const idempotencyKey = method === "POST" ? (options.idempotencyKey ?? crypto.randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response: Response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) { return (response.status === 204 ? undefined : await response.json()) as T; } const payload = await response.json().catch(() => ({})); const code: string = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ export async function listAll( path: string ): Promise { const items: T[] = []; let startingAfter = ""; for (;;) { const page = await zahlo<{ data: T[]; hasMore: boolean }>( "GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}` ); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) { return items; } startingAfter = last.id; } } ``` The quickstart stores users in SQLite (`node:sqlite`, built into Node.js). Use your own database the same way: what matters is the transaction. ```typescript lib/db.ts // Any database with transactions works; SQLite keeps the quickstart self-contained. export const db = new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, subscription_id TEXT UNIQUE, subscription_status TEXT, -- trialing | active | past_due | paused | cancelled | incomplete past_due_since TEXT, current_period_end TEXT ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } export type User = { id: string; email: string; subscription_id: string | null; subscription_status: string | null; past_due_since: string | null; current_period_end: string | null; }; ``` The sign-in below is a stand-in so the quickstart runs on its own. Use your real auth instead. ```typescript lib/auth.ts // Stand-in for your auth library (Auth.js, Clerk, your own sessions). The quickstart signs in by email // and keeps the user id in a cookie; replace both functions with your real sign-in. export function userFromRequest(request: Request): User | null { const id = /(?:^|;\s*)uid=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1]; if (!id) return null; return (db.prepare("SELECT * FROM users WHERE id = ?").get(decodeURIComponent(id)) as User | undefined) ?? null; } export function signIn(email: string): User { db.prepare("INSERT INTO users (id, email) VALUES (?, ?) ON CONFLICT (email) DO NOTHING").run( crypto.randomUUID(), email.trim().toLowerCase() ); return db.prepare("SELECT * FROM users WHERE email = ?").get(email.trim().toLowerCase()) as User; } ``` ```typescript app/api/signin/route.ts // Demo sign-in: replace with your auth library. export async function POST(request: Request) { const user = signIn(String((await request.formData()).get("email") ?? "")); return new Response(null, { headers: { location: "/account", "set-cookie": `uid=${user.id}; Path=/; HttpOnly; SameSite=Lax` }, status: 303, }); } ``` ```typescript lib/subscribe.ts type Price = { id: string; lookupKey: string | null; active: boolean }; type Coupon = { id: string; code: string | null; active: boolean }; export type CheckoutSession = { id: string; type: "payment" | "subscription"; url: string | null; clientToken: string | null; status: "open" | "complete" | "expired" | "failed"; subscriptionId: string | null; reference: string | null; }; const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; /** Starts a subscription checkout for the signed-in user. The price is found by lookupKey. */ export async function createSubscriptionSession( user: User, options: { mode: "hosted" | "embedded"; promoCode?: string } ): Promise { const prices = await listAll("/prices"); const price = prices.find((p) => p.active && p.lookupKey === "pro-monthly"); if (!price) throw new Error("No price with lookupKey pro-monthly; run scripts/setup-catalog.mjs"); let couponId: string | undefined; if (options.promoCode) { const coupons = await listAll("/coupons"); couponId = coupons.find( (c) => c.active && c.code?.toUpperCase() === options.promoCode?.toUpperCase() )?.id; } return zahlo("POST", "/checkout-sessions", { // One key per attempt; a network retry inside zahlo() reuses it. idempotencyKey: `subscribe-${user.id}-${crypto.randomUUID()}`, body: { type: "subscription", mode: options.mode, lineItems: [{ priceId: price.id, quantity: 1 }], couponId, customerEmail: user.email, reference: user.id, successUrl: `${siteUrl}/account?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/account`, }, }); } ``` ```typescript app/api/subscribe/route.ts // Hosted checkout: create the session on the server, then send the browser to session.url. export async function POST(request: Request) { const user = userFromRequest(request); if (!user) return new Response("Sign in first", { status: 401 }); const promoCode = String((await request.formData()).get("promoCode") ?? "").trim() || undefined; try { const session = await createSubscriptionSession(user, { mode: "hosted", promoCode }); return Response.redirect(session.url ?? "", 303); } catch (error) { if (error instanceof ZahloError) { console.error("subscribe failed", error.code, error.requestId); return new Response(error.message, { status: error.status >= 500 ? 502 : 400 }); } throw error; } } ``` Access is decided from the stored subscription, never from the customer returning from checkout: ```typescript lib/access.ts // Your grace period setting (14 days by default). After it, Zahlo cancels the subscription. const GRACE_DAYS = 14; /** Access follows the subscription: trialing or active, or past_due while the grace period lasts. */ export function hasAccess(user: User, now = new Date()): boolean { if (user.subscription_status === "trialing" || user.subscription_status === "active") return true; if (user.subscription_status === "past_due" && user.past_due_since) { return now.getTime() - new Date(user.past_due_since).getTime() < GRACE_DAYS * 86_400_000; } return false; } ``` ```tsx app/account/page.tsx export const dynamic = "force-dynamic"; // Shows access from the stored subscription. Returning from checkout grants nothing by itself: // the webhook stores the subscription, usually within seconds. export default async function AccountPage() { const id = (await cookies()).get("uid")?.value; const user = id ? (db.prepare("SELECT * FROM users WHERE id = ?").get(id) as User | undefined) : undefined; if (!user) { return (
); } if (hasAccess(user)) { return (

Pro is active ({user.subscription_status}). Welcome, {user.email}.

); } return (

{user.subscription_id ? `Subscription ${user.subscription_status}.` : "Start your 14-day free trial."}

); } ```
```typescript lib/webhook-signature.ts export type ZahloEvent = { id: string; type: string; livemode: boolean; createdAt: string; data: { object: { id: string } & Record }; }; /** * Checks `Zahlo-Signature: t=,v1=` against the raw request body and returns the parsed event, * or null when the signature is missing, wrong or older than 5 minutes. During a secret rotation the * header carries one v1 per secret, so any matching v1 is accepted. */ export function verifyZahloEvent( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300 ): ZahloEvent | null { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return null; } const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex") ); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? (JSON.parse(rawBody) as ZahloEvent) : null; } ``` ```typescript app/api/webhooks/zahlo/route.ts type Subscription = { id: string; status: string; pastDueSince: string | null; currentPeriodEnd: string | null; }; const saveSubscription = (subscription: Subscription, userId?: string | null) => { const values = [subscription.status, subscription.pastDueSince, subscription.currentPeriodEnd]; if (userId) { // First sight of this subscription: link it to the user who started checkout. db.prepare( "UPDATE users SET subscription_id = ?, subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE id = ?" ).run(subscription.id, ...values, userId); } else { db.prepare( "UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE subscription_id = ?" ).run(...values, subscription.id); } }; export async function POST(request: Request) { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = await request.text(); const event = verifyZahloEvent( rawBody, request.headers.get("zahlo-signature"), process.env.ZAHLO_WEBHOOK_SECRET ?? "" ); if (!event) return new Response("Invalid signature", { status: 400 }); // 2. Re-fetch and store the subscription's current state; never apply the event payload, so events // arriving late or out of order cannot grant or revoke access wrongly. const id = encodeURIComponent(event.data.object.id); let apply: () => void = () => {}; if (event.type === "checkout.session.completed") { const session = await zahlo("GET", `/checkout-sessions/${id}`); if (session.type === "subscription" && session.status === "complete" && session.subscriptionId) { const subscription = await zahlo("GET", `/subscriptions/${encodeURIComponent(session.subscriptionId)}`); apply = () => saveSubscription(subscription, session.reference); } } else if (event.type.startsWith("subscription.")) { const subscription = await zahlo("GET", `/subscriptions/${id}`); apply = () => saveSubscription(subscription); } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return new Response("ok"); } ``` 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 dev server and note its `https://….trycloudflare.com` URL: ```bash cloudflared tunnel --url http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/api/webhooks/zahlo", "enabledEvents": ["checkout.session.completed", "subscription.created", "subscription.updated", "subscription.renewed", "subscription.past_due", "subscription.payment_failed", "subscription.cancelled", "subscription.trial_will_end"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash npm run dev ``` Open `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.completed` and `subscription.created`, and `/account` 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: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/test-helpers/subscriptions/$SUBSCRIPTION_ID/advance" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "renewal_failed"}' ``` | `to` | What it does | |---|---| | `trial_will_end` | moves the trial to 3 days before its end and sends `subscription.trial_will_end` | | `renewal_succeeded` | the next charge succeeds | | `renewal_failed` | the next charge is declined | | `renewal_requires_action` | the next charge needs the customer to authenticate | | `period_end` | ends the current period; a subscription set to cancel at period end is cancelled | After each step, reload `/account`: it shows the status your handler stored from the events. 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](/guides/customer-portal). ```typescript app/api/portal/route.ts const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; // "Manage billing": a single-use customer portal link (valid for one hour) for the signed-in user. // Create a fresh one on every click; never store it. export async function POST(request: Request) { const user = userFromRequest(request); if (!user?.subscription_id) return new Response("No subscription", { status: 404 }); const subscription = await zahlo<{ customerId: string }>( "GET", `/subscriptions/${encodeURIComponent(user.subscription_id)}` ); const portal = await zahlo<{ url: string }>("POST", "/customer-portal/sessions", { body: { customerId: subscription.customerId, returnUrl: `${siteUrl}/account` }, }); return Response.redirect(portal.url, 303); } ``` - **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](/guides/failed-payments) and [SaaS entitlements](/guides/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](/guides/refunds). - **Disputes**: respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Subscriptions with embedded checkout (Next.js) Source: https://docs-demo.zahlo.eu/quickstarts/saas-subscriptions-embedded-nextjs A signed-in user starts a 14-day free trial of your Pro plan (€19.00 a month) in a payment form that Zahlo renders inside 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. 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. Create `.env.local` in the project root. Keep it out of version control; the key never reaches the browser. ```bash .env.local ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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 work without it. 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: ```javascript scripts/setup-catalog.mjs // Creates the Pro plan, its monthly price with a 14-day trial, and a coupon, once per environment. // Run: node --env-file=.env.local scripts/setup-catalog.mjs // Safe to run again: it finds the price by lookupKey and the coupon by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PLAN = { lookupKey: "pro-monthly", name: "Pro", unitAmountMinor: 1900, trialDays: 14 }; const COUPON = { code: "WELCOME15", name: "15% off for 3 months", percentOff: 15, duration: "repeating", durationInMonths: 3 }; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); if (prices.some((price) => price.active && price.lookupKey === PLAN.lookupKey)) { console.log(`price ${PLAN.lookupKey}: exists`); } else { const product = await call("POST", "/products", { name: PLAN.name }, `setup-product-${PLAN.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: PLAN.lookupKey, productId: product.id, recurring: { interval: "month", trialDays: PLAN.trialDays }, type: "recurring", unitAmountMinor: PLAN.unitAmountMinor, }, `setup-price-${PLAN.lookupKey}` ); console.log(`price ${PLAN.lookupKey}: created`); } const coupons = await listAll("/coupons"); if (coupons.some((coupon) => coupon.code === COUPON.code)) { console.log(`coupon ${COUPON.code}: exists`); } else { await call("POST", "/coupons", COUPON, `setup-coupon-${COUPON.code}`); console.log(`coupon ${COUPON.code}: created`); } ``` ```bash node --env-file=.env.local scripts/setup-catalog.mjs ``` 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. ```typescript lib/zahlo.ts // Server-only Zahlo API client. Never import this file from a client component. const apiKey = process.env.ZAHLO_API_KEY ?? ""; /** zk_test_ keys use the sandbox, zk_live_ keys use live. */ export const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); export class ZahloError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null ) { super(message); } } const MAX_RETRIES = 2; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt: number, response?: Response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; /** Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. */ const retryable = (status: number, code: string) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ export async function zahlo( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, options: { body?: unknown; idempotencyKey?: string } = {} ): Promise { const idempotencyKey = method === "POST" ? (options.idempotencyKey ?? crypto.randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response: Response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) { return (response.status === 204 ? undefined : await response.json()) as T; } const payload = await response.json().catch(() => ({})); const code: string = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ export async function listAll( path: string ): Promise { const items: T[] = []; let startingAfter = ""; for (;;) { const page = await zahlo<{ data: T[]; hasMore: boolean }>( "GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}` ); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) { return items; } startingAfter = last.id; } } ``` The quickstart stores users in SQLite (`node:sqlite`, built into Node.js). Use your own database the same way: what matters is the transaction. ```typescript lib/db.ts // Any database with transactions works; SQLite keeps the quickstart self-contained. export const db = new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, subscription_id TEXT UNIQUE, subscription_status TEXT, -- trialing | active | past_due | paused | cancelled | incomplete past_due_since TEXT, current_period_end TEXT ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } export type User = { id: string; email: string; subscription_id: string | null; subscription_status: string | null; past_due_since: string | null; current_period_end: string | null; }; ``` The sign-in below is a stand-in so the quickstart runs on its own. Use your real auth instead. ```typescript lib/auth.ts // Stand-in for your auth library (Auth.js, Clerk, your own sessions). The quickstart signs in by email // and keeps the user id in a cookie; replace both functions with your real sign-in. export function userFromRequest(request: Request): User | null { const id = /(?:^|;\s*)uid=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1]; if (!id) return null; return (db.prepare("SELECT * FROM users WHERE id = ?").get(decodeURIComponent(id)) as User | undefined) ?? null; } export function signIn(email: string): User { db.prepare("INSERT INTO users (id, email) VALUES (?, ?) ON CONFLICT (email) DO NOTHING").run( crypto.randomUUID(), email.trim().toLowerCase() ); return db.prepare("SELECT * FROM users WHERE email = ?").get(email.trim().toLowerCase()) as User; } ``` ```typescript app/api/signin/route.ts // Demo sign-in: replace with your auth library. export async function POST(request: Request) { const user = signIn(String((await request.formData()).get("email") ?? "")); return new Response(null, { headers: { location: "/account", "set-cookie": `uid=${user.id}; Path=/; HttpOnly; SameSite=Lax` }, status: 303, }); } ``` ```typescript lib/subscribe.ts type Price = { id: string; lookupKey: string | null; active: boolean }; type Coupon = { id: string; code: string | null; active: boolean }; export type CheckoutSession = { id: string; type: "payment" | "subscription"; url: string | null; clientToken: string | null; status: "open" | "complete" | "expired" | "failed"; subscriptionId: string | null; reference: string | null; }; const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; /** Starts a subscription checkout for the signed-in user. The price is found by lookupKey. */ export async function createSubscriptionSession( user: User, options: { mode: "hosted" | "embedded"; promoCode?: string } ): Promise { const prices = await listAll("/prices"); const price = prices.find((p) => p.active && p.lookupKey === "pro-monthly"); if (!price) throw new Error("No price with lookupKey pro-monthly; run scripts/setup-catalog.mjs"); let couponId: string | undefined; if (options.promoCode) { const coupons = await listAll("/coupons"); couponId = coupons.find( (c) => c.active && c.code?.toUpperCase() === options.promoCode?.toUpperCase() )?.id; } return zahlo("POST", "/checkout-sessions", { // One key per attempt; a network retry inside zahlo() reuses it. idempotencyKey: `subscribe-${user.id}-${crypto.randomUUID()}`, body: { type: "subscription", mode: options.mode, lineItems: [{ priceId: price.id, quantity: 1 }], couponId, customerEmail: user.email, reference: user.id, successUrl: `${siteUrl}/account?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/account`, }, }); } ``` Your server creates the session and gives the browser only its ID and short-lived client token, never your API key: ```typescript app/api/subscribe-session/route.ts // Embedded checkout: return only the session id and its short-lived client token to the browser. export async function POST(request: Request) { const user = userFromRequest(request); if (!user) return Response.json({ error: { message: "Sign in first" } }, { status: 401 }); const promoCode = String((await request.formData()).get("promoCode") ?? "").trim() || undefined; try { const session = await createSubscriptionSession(user, { mode: "embedded", promoCode }); return Response.json({ apiUrl: apiBase, clientToken: session.clientToken, sessionId: session.id }); } catch (error) { if (error instanceof ZahloError) { console.error("subscribe failed", error.code, error.requestId); return Response.json( { error: { code: error.code, message: error.message } }, { status: error.status >= 500 ? 502 : 400 } ); } throw error; } } ``` 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 grants access. ```tsx app/subscribe/page.tsx "use client"; type Mounted = { mount(selector: string): Promise; destroy(): void }; declare global { interface Window { ZahloCheckout: new (config: { apiUrl: string; sessionId: string; clientToken: string; onSuccess?: (sessionId: string) => void; onError?: (error: Error) => void; }) => Mounted; } } const loadScript = (src: string) => new Promise((resolve, reject) => { if (window.ZahloCheckout) return resolve(); const script = Object.assign(document.createElement("script"), { src, async: true }); script.onload = () => resolve(); script.onerror = () => reject(new Error(`Could not load ${src}`)); document.head.append(script); }); // Embedded subscription checkout. Callbacks only change what the customer sees; access is granted // by the webhook. export default function SubscribePage() { const [error, setError] = useState(""); const checkout = useRef(null); async function start(form: FormData) { setError(""); const response = await fetch("/api/subscribe-session", { body: form, method: "POST" }); const body = await response.json(); if (!response.ok) return setError(body.error?.message ?? "Checkout is unavailable"); await loadScript(`${body.apiUrl}/sdk/v1/zahlo.js`); checkout.current?.destroy(); checkout.current = new window.ZahloCheckout({ apiUrl: body.apiUrl, sessionId: body.sessionId, clientToken: body.clientToken, onSuccess: () => window.location.assign("/account"), onError: (failure) => setError(failure.message), }); await checkout.current.mount("#zahlo-checkout"); } useEffect(() => () => checkout.current?.destroy(), []); return (

Subscribe to Pro

{error &&

{error}

}
); } ``` Access is decided from the stored subscription, never from the customer returning from checkout: ```typescript lib/access.ts // Your grace period setting (14 days by default). After it, Zahlo cancels the subscription. const GRACE_DAYS = 14; /** Access follows the subscription: trialing or active, or past_due while the grace period lasts. */ export function hasAccess(user: User, now = new Date()): boolean { if (user.subscription_status === "trialing" || user.subscription_status === "active") return true; if (user.subscription_status === "past_due" && user.past_due_since) { return now.getTime() - new Date(user.past_due_since).getTime() < GRACE_DAYS * 86_400_000; } return false; } ``` ```tsx app/account/page.tsx export const dynamic = "force-dynamic"; // Shows access from the stored subscription. Returning from checkout grants nothing by itself: // the webhook stores the subscription, usually within seconds. export default async function AccountPage() { const id = (await cookies()).get("uid")?.value; const user = id ? (db.prepare("SELECT * FROM users WHERE id = ?").get(id) as User | undefined) : undefined; if (!user) { return (
); } if (hasAccess(user)) { return (

Pro is active ({user.subscription_status}). Welcome, {user.email}.

); } return (

{user.subscription_id ? `Subscription ${user.subscription_status}. ` : "Start your 14-day free trial. "} Subscribe to Pro

); } ```
```typescript lib/webhook-signature.ts export type ZahloEvent = { id: string; type: string; livemode: boolean; createdAt: string; data: { object: { id: string } & Record }; }; /** * Checks `Zahlo-Signature: t=,v1=` against the raw request body and returns the parsed event, * or null when the signature is missing, wrong or older than 5 minutes. During a secret rotation the * header carries one v1 per secret, so any matching v1 is accepted. */ export function verifyZahloEvent( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300 ): ZahloEvent | null { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return null; } const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex") ); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? (JSON.parse(rawBody) as ZahloEvent) : null; } ``` ```typescript app/api/webhooks/zahlo/route.ts type Subscription = { id: string; status: string; pastDueSince: string | null; currentPeriodEnd: string | null; }; const saveSubscription = (subscription: Subscription, userId?: string | null) => { const values = [subscription.status, subscription.pastDueSince, subscription.currentPeriodEnd]; if (userId) { // First sight of this subscription: link it to the user who started checkout. db.prepare( "UPDATE users SET subscription_id = ?, subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE id = ?" ).run(subscription.id, ...values, userId); } else { db.prepare( "UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE subscription_id = ?" ).run(...values, subscription.id); } }; export async function POST(request: Request) { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = await request.text(); const event = verifyZahloEvent( rawBody, request.headers.get("zahlo-signature"), process.env.ZAHLO_WEBHOOK_SECRET ?? "" ); if (!event) return new Response("Invalid signature", { status: 400 }); // 2. Re-fetch and store the subscription's current state; never apply the event payload, so events // arriving late or out of order cannot grant or revoke access wrongly. const id = encodeURIComponent(event.data.object.id); let apply: () => void = () => {}; if (event.type === "checkout.session.completed") { const session = await zahlo("GET", `/checkout-sessions/${id}`); if (session.type === "subscription" && session.status === "complete" && session.subscriptionId) { const subscription = await zahlo("GET", `/subscriptions/${encodeURIComponent(session.subscriptionId)}`); apply = () => saveSubscription(subscription, session.reference); } } else if (event.type.startsWith("subscription.")) { const subscription = await zahlo("GET", `/subscriptions/${id}`); apply = () => saveSubscription(subscription); } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return new Response("ok"); } ``` 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 dev server and note its `https://….trycloudflare.com` URL: ```bash cloudflared tunnel --url http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/api/webhooks/zahlo", "enabledEvents": ["checkout.session.completed", "subscription.created", "subscription.updated", "subscription.renewed", "subscription.past_due", "subscription.payment_failed", "subscription.cancelled", "subscription.trial_will_end"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash npm run dev ``` Open `http://localhost:3000/account` and sign in with any email, then open `http://localhost:3000/subscribe`, enter `WELCOME15` and pay in the embedded form 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 `/account` 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: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/test-helpers/subscriptions/$SUBSCRIPTION_ID/advance" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "renewal_failed"}' ``` | `to` | What it does | |---|---| | `trial_will_end` | moves the trial to 3 days before its end and sends `subscription.trial_will_end` | | `renewal_succeeded` | the next charge succeeds | | `renewal_failed` | the next charge is declined | | `renewal_requires_action` | the next charge needs the customer to authenticate | | `period_end` | ends the current period; a subscription set to cancel at period end is cancelled | After each step, reload `/account`: it shows the status your handler stored from the events. 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](/guides/customer-portal). ```typescript app/api/portal/route.ts const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; // "Manage billing": a single-use customer portal link (valid for one hour) for the signed-in user. // Create a fresh one on every click; never store it. export async function POST(request: Request) { const user = userFromRequest(request); if (!user?.subscription_id) return new Response("No subscription", { status: 404 }); const subscription = await zahlo<{ customerId: string }>( "GET", `/subscriptions/${encodeURIComponent(user.subscription_id)}` ); const portal = await zahlo<{ url: string }>("POST", "/customer-portal/sessions", { body: { customerId: subscription.customerId, returnUrl: `${siteUrl}/account` }, }); return Response.redirect(portal.url, 303); } ``` - **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](/guides/failed-payments) and [SaaS entitlements](/guides/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](/guides/refunds). - **Disputes**: respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Subscriptions on a website Source: https://docs-demo.zahlo.eu/quickstarts/saas-subscriptions-website 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](/guides/customer-portal). To grant access in your app automatically, follow the steps below. 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: ```bash .env ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:8000 ``` 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`). 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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. ```php setup-catalog.php 'Pro'], 'setup-product-pro-monthly'); zahlo('POST', '/prices', [ 'currency' => 'EUR', 'lookupKey' => 'pro-monthly', 'productId' => $product['id'], 'recurring' => ['interval' => 'month', 'trialDays' => 14], 'type' => 'recurring', 'unitAmountMinor' => 1900, ], 'setup-price-pro-monthly'); echo "price pro-monthly: created\n"; } $exists = false; foreach (zahlo_list_all('/coupons') as $found) { if (($found['code'] ?? null) === 'WELCOME15') { $exists = true; } } if ($exists) { echo "coupon WELCOME15: exists\n"; } else { zahlo('POST', '/coupons', [ 'code' => 'WELCOME15', 'duration' => 'repeating', 'durationInMonths' => 3, 'name' => '15% off for 3 months', 'percentOff' => 15, ], 'setup-coupon-WELCOME15'); echo "coupon WELCOME15: created\n"; } ``` ```bash set -a; source .env; set +a php setup-catalog.php ``` ```javascript setup-catalog.mjs // Creates the Pro plan, its monthly price with a 14-day trial, and a coupon, once per environment. // Run: node --env-file=.env setup-catalog.mjs // Safe to run again: it finds the price by lookupKey and the coupon by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PLAN = { lookupKey: "pro-monthly", name: "Pro", unitAmountMinor: 1900, trialDays: 14 }; const COUPON = { code: "WELCOME15", name: "15% off for 3 months", percentOff: 15, duration: "repeating", durationInMonths: 3 }; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); if (prices.some((price) => price.active && price.lookupKey === PLAN.lookupKey)) { console.log(`price ${PLAN.lookupKey}: exists`); } else { const product = await call("POST", "/products", { name: PLAN.name }, `setup-product-${PLAN.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: PLAN.lookupKey, productId: product.id, recurring: { interval: "month", trialDays: PLAN.trialDays }, type: "recurring", unitAmountMinor: PLAN.unitAmountMinor, }, `setup-price-${PLAN.lookupKey}` ); console.log(`price ${PLAN.lookupKey}: created`); } const coupons = await listAll("/coupons"); if (coupons.some((coupon) => coupon.code === COUPON.code)) { console.log(`coupon ${COUPON.code}: exists`); } else { await call("POST", "/coupons", COUPON, `setup-coupon-${COUPON.code}`); console.log(`coupon ${COUPON.code}: created`); } ``` ```bash node --env-file=.env setup-catalog.mjs ``` 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. ```php zahlo.php = 500 || ($status === 409 && $code === 'request_in_progress'); } function zahlo_sleep(int $attempt, ?string $retryAfter): void { $seconds = $retryAfter !== null && is_numeric($retryAfter) ? (float) $retryAfter : 0; usleep((int) (($seconds > 0 ? $seconds : 0.5 * (2 ** $attempt)) * 1_000_000)); } /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ function zahlo(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): ?array { $headers = ['content-type: application/json', 'x-api-key: ' . (getenv('ZAHLO_API_KEY') ?: '')]; if ($method === 'POST') { $headers[] = 'idempotency-key: ' . ($idempotencyKey ?? bin2hex(random_bytes(16))); } for ($attempt = 0;; $attempt++) { $retryAfter = null; $ch = curl_init(zahlo_base() . '/api/v1' . $path); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) { if (stripos($line, 'retry-after:') === 0) { $retryAfter = trim(substr($line, 12)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $networkError = curl_error($ch); if ($raw === false) { if ($attempt < ZAHLO_MAX_RETRIES) { zahlo_sleep($attempt, null); continue; } throw new RuntimeException('Zahlo request failed: ' . $networkError); } $payload = json_decode($raw, true); if ($status >= 200 && $status < 300) { return $status === 204 ? null : $payload; } $code = $payload['error']['code'] ?? 'unknown'; if ($attempt < ZAHLO_MAX_RETRIES && zahlo_retryable($status, $code)) { zahlo_sleep($attempt, $retryAfter); continue; } throw new ZahloError($status, $code, $payload['error']['message'] ?? 'Request failed', $payload['requestId'] ?? null); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ function zahlo_list_all(string $path): array { $items = []; $after = ''; for (;;) { $page = zahlo('GET', $path . '?limit=100' . ($after !== '' ? '&startingAfter=' . rawurlencode($after) : '')); $items = array_merge($items, $page['data']); $last = end($page['data']); if (empty($page['hasMore']) || !$last) { return $items; } $after = $last['id']; } } ``` ```php db.php setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA busy_timeout = 5000'); $pdo->exec(" CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, subscription_id TEXT UNIQUE, subscription_status TEXT, -- trialing | active | past_due | paused | cancelled | incomplete past_due_since TEXT, current_period_end TEXT ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); "); } return $pdo; } /** Runs $fn in one transaction: all of its writes commit together, or none do. */ function transaction(callable $fn): mixed { $pdo = db(); $pdo->exec('BEGIN IMMEDIATE'); try { $result = $fn($pdo); $pdo->exec('COMMIT'); return $result; } catch (Throwable $error) { $pdo->exec('ROLLBACK'); throw $error; } } ``` The sign-in is a stand-in so the quickstart runs on its own. Use your real auth instead. ```php auth.php prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]); return $stmt->fetch() ?: null; } function sign_in(string $email): array { $email = strtolower(trim($email)); db()->prepare('INSERT INTO users (id, email) VALUES (?, ?) ON CONFLICT (email) DO NOTHING') ->execute([bin2hex(random_bytes(16)), $email]); $stmt = db()->prepare('SELECT * FROM users WHERE email = ?'); $stmt->execute([$email]); return $stmt->fetch(); } ``` ```php signin.php '/', 'httponly' => true, 'samesite' => 'Lax']); header('Location: /account.php', true, 303); ``` Access is decided from the stored subscription, never from the customer returning from checkout: ```php access.php

Pro is active (). Welcome, .

Pay without leaving the page

'subscription', 'mode' => $mode, 'lineItems' => [['priceId' => $price['id'], 'quantity' => 1]], 'customerEmail' => $user['email'], 'reference' => $user['id'], 'successUrl' => site_url() . '/account.php?session_id={CHECKOUT_SESSION_ID}', 'cancelUrl' => site_url() . '/account.php', ]; if ($couponId !== null) { $body['couponId'] = $couponId; } // One key per attempt; a network retry inside zahlo() reuses it. return zahlo('POST', '/checkout-sessions', $body, 'subscribe-' . $user['id'] . '-' . bin2hex(random_bytes(8))); } /** Runs $respond with the new session; Zahlo errors become a 400 (bad request) or 502 (Zahlo unavailable). */ function subscribe_or_fail(string $mode, callable $respond): void { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; } $user = current_user(); if ($user === null) { http_response_code(401); header('content-type: application/json'); echo json_encode(['error' => ['message' => 'Sign in first']]); exit; } try { $respond(create_subscription_session($user, $mode)); } catch (ZahloError $error) { error_log("subscribe failed {$error->errorCode} {$error->requestId}"); http_response_code($error->status >= 500 ? 502 : 400); header('content-type: application/json'); echo json_encode(['error' => ['code' => $error->errorCode, 'message' => $error->getMessage()]]); } } ``` **Hosted checkout:** the server creates the session and redirects to its `url`. ```php subscribe.php zahlo_base(), 'clientToken' => $session['clientToken'], 'sessionId' => $session['id']]); }); ``` ```html subscribe.html Subscribe to Pro

Subscribe to Pro

```
One file serves the pages and the API routes: sign-in (a stand-in for your real auth), the account page with access from the stored subscription, hosted checkout (`POST /subscribe`, a redirect to the session `url`), embedded checkout (`POST /subscribe-session`, only the session ID and client token) and the webhook. Its 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. ```javascript server.mjs // One-file Zahlo SaaS site: demo sign-in, hosted and embedded subscription checkout, account page and webhook. // Needs Node 22.5+ and no dependencies. Run: node --env-file=.env server.mjs // Env: ZAHLO_API_KEY, ZAHLO_API_BASE (optional), ZAHLO_WEBHOOK_SECRET, SITE_URL, DATABASE_PATH, PORT const apiKey = process.env.ZAHLO_API_KEY ?? ""; const webhookSecret = process.env.ZAHLO_WEBHOOK_SECRET ?? ""; const port = Number(process.env.PORT ?? 3000); const siteUrl = process.env.SITE_URL ?? `http://localhost:${port}`; // zk_test_ keys use the sandbox, zk_live_ keys use live. const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public"); // ---- Database (any database with transactions works; SQLite keeps this self-contained) ---- const db = new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, subscription_id TEXT UNIQUE, subscription_status TEXT, -- trialing | active | past_due | paused | cancelled | incomplete past_due_since TEXT, current_period_end TEXT ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ function transaction(fn) { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } // ---- Zahlo API client ---- class ZahloError extends Error { constructor(status, code, message, requestId) { super(message); this.status = status; this.code = code; this.requestId = requestId; } } const MAX_RETRIES = 2; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt, response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; // Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. const retryable = (status, code) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** Every POST carries one Idempotency-Key, reused on each retry, so a retry can never create a second object. */ async function zahlo(method, path, { body, idempotencyKey } = {}) { const key = method === "POST" ? (idempotencyKey ?? randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(key ? { "idempotency-key": key } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) return response.status === 204 ? undefined : await response.json(); const payload = await response.json().catch(() => ({})); const code = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ async function listAll(path) { const items = []; let startingAfter = ""; for (;;) { const page = await zahlo("GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}`); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) return items; startingAfter = last.id; } } // ---- Webhook signature ---- /** Checks `Zahlo-Signature: t=,v1=` on the raw body; any matching v1 passes (secret rotation). */ function verifyZahloEvent(rawBody, header, secret, toleranceSeconds = 300) { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return null; const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? JSON.parse(rawBody) : null; } // ---- Sign-in (demo) ---- // Stand-in for your auth library (Auth.js, Lucia, your own sessions). It signs in by email alone and keeps // the user id in a cookie, so anyone can claim any email. Replace both functions with real authentication. function userFromRequest(request) { const id = /(?:^|;\s*)uid=([^;]+)/.exec(request.headers.cookie ?? "")?.[1]; return id ? db.prepare("SELECT * FROM users WHERE id = ?").get(decodeURIComponent(id)) : undefined; } function signIn(email) { const clean = email.trim().toLowerCase(); db.prepare("INSERT INTO users (id, email) VALUES (?, ?) ON CONFLICT (email) DO NOTHING").run(randomUUID(), clean); return db.prepare("SELECT * FROM users WHERE email = ?").get(clean); } // ---- Access ---- // Your grace period setting (14 days by default). After it, Zahlo cancels the subscription. const GRACE_DAYS = 14; /** Access follows the subscription: trialing or active, or past_due while the grace period lasts. */ function hasAccess(user, now = new Date()) { if (user.subscription_status === "trialing" || user.subscription_status === "active") return true; if (user.subscription_status === "past_due" && user.past_due_since) { return now.getTime() - new Date(user.past_due_since).getTime() < GRACE_DAYS * 86_400_000; } return false; } // ---- Subscribe ---- /** Starts a subscription checkout for the signed-in user. The price is found by lookupKey. */ async function createSubscriptionSession(user, mode, promoCode) { const price = (await listAll("/prices")).find((p) => p.active && p.lookupKey === "pro-monthly"); if (!price) throw new Error("No price with lookupKey pro-monthly; run setup-catalog.mjs"); let couponId; if (promoCode) { couponId = (await listAll("/coupons")).find( (c) => c.active && c.code?.toUpperCase() === promoCode.toUpperCase() )?.id; } return zahlo("POST", "/checkout-sessions", { // One key per attempt; a network retry inside zahlo() reuses it. idempotencyKey: `subscribe-${user.id}-${randomUUID()}`, body: { type: "subscription", mode, lineItems: [{ priceId: price.id, quantity: 1 }], couponId, customerEmail: user.email, reference: user.id, successUrl: `${siteUrl}/account?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/account`, }, }); } async function readBody(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); return Buffer.concat(chunks); } /** Reads a urlencoded or multipart (FormData) body. */ async function readForm(request) { const body = await readBody(request); const type = request.headers["content-type"] ?? ""; return type.startsWith("multipart/form-data") ? await new Response(body, { headers: { "content-type": type } }).formData() : new URLSearchParams(body.toString()); } const send = (response, status, body, headers = {}) => { response.writeHead(status, { "content-type": "text/html; charset=utf-8", ...headers }); response.end(body); }; const sendJson = (response, status, body) => send(response, status, JSON.stringify(body), { "content-type": "application/json" }); const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); /** Zahlo errors become 400 (bad request) or 502 (Zahlo unavailable); anything else is a 500. */ function subscribeFailed(response, error) { if (!(error instanceof ZahloError)) throw error; console.error("subscribe failed", error.code, error.requestId); sendJson(response, error.status >= 500 ? 502 : 400, { error: { code: error.code, message: error.message } }); } const saveSubscription = (subscription, userId) => { const values = [subscription.status, subscription.pastDueSince, subscription.currentPeriodEnd]; if (userId) { // First sight of this subscription: link it to the user who started checkout. db.prepare( "UPDATE users SET subscription_id = ?, subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE id = ?" ).run(subscription.id, ...values, userId); } else { db.prepare( "UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE subscription_id = ?" ).run(...values, subscription.id); } }; // ---- Routes ---- async function handle(request, response) { const url = new URL(request.url ?? "/", siteUrl); const route = `${request.method} ${url.pathname}`; if (route === "POST /signin") { const user = signIn(String((await readForm(request)).get("email") ?? "")); return send(response, 303, "", { location: "/account", "set-cookie": `uid=${user.id}; Path=/; HttpOnly; SameSite=Lax` }); } // Shows access from the stored subscription. Returning from checkout grants nothing by itself: // the webhook stores the subscription, usually within seconds. if (route === "GET /account") { const user = userFromRequest(request); if (!user) { return send( response, 200, `
` ); } if (hasAccess(user)) { return send(response, 200, `

Pro is active (${escapeHtml(user.subscription_status)}). Welcome, ${escapeHtml(user.email)}.

`); } return send( response, 200, `

${ user.subscription_id ? `Subscription ${escapeHtml(user.subscription_status)}.` : "Start your 14-day free trial." }

` + `

Pay without leaving the page

` ); } if (route === "GET /subscribe.html") return send(response, 200, await readFile(join(publicDir, "subscribe.html"))); // Hosted checkout: create the session on the server, then send the browser to session.url. if (route === "POST /subscribe") { const user = userFromRequest(request); if (!user) return send(response, 401, "Sign in first"); try { const promoCode = String((await readForm(request)).get("promoCode") ?? "").trim() || undefined; const session = await createSubscriptionSession(user, "hosted", promoCode); return send(response, 303, "", { location: session.url }); } catch (error) { return subscribeFailed(response, error); } } // Embedded checkout: return only the session id and its short-lived client token to the browser. if (route === "POST /subscribe-session") { const user = userFromRequest(request); if (!user) return sendJson(response, 401, { error: { message: "Sign in first" } }); try { const promoCode = String((await readForm(request)).get("promoCode") ?? "").trim() || undefined; const session = await createSubscriptionSession(user, "embedded", promoCode); return sendJson(response, 200, { apiUrl: apiBase, clientToken: session.clientToken, sessionId: session.id }); } catch (error) { return subscribeFailed(response, error); } } if (route === "POST /webhooks/zahlo") { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = (await readBody(request)).toString("utf8"); const event = verifyZahloEvent(rawBody, request.headers["zahlo-signature"] ?? null, webhookSecret); if (!event) return send(response, 400, "Invalid signature"); // 2. Re-fetch and store the subscription's current state; never apply the event payload, so events // arriving late or out of order cannot grant or revoke access wrongly. const id = encodeURIComponent(event.data.object.id); let apply = () => {}; if (event.type === "checkout.session.completed") { const session = await zahlo("GET", `/checkout-sessions/${id}`); if (session.type === "subscription" && session.status === "complete" && session.subscriptionId) { const subscription = await zahlo("GET", `/subscriptions/${encodeURIComponent(session.subscriptionId)}`); apply = () => saveSubscription(subscription, session.reference); } } else if (event.type.startsWith("subscription.")) { const subscription = await zahlo("GET", `/subscriptions/${id}`); apply = () => saveSubscription(subscription); } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return send(response, 200, "ok"); } return send(response, 404, "Not found"); } createServer((request, response) => { handle(request, response).catch((error) => { console.error("request failed", error); if (!response.headersSent) send(response, 500, "Internal error"); }); }).listen(port, () => console.log(`Listening on ${siteUrl}`)); ``` The embedded page loads `/sdk/v1/zahlo.js` and mounts the form. Its callbacks only change what the customer sees. ```html public/subscribe.html Subscribe to Pro

Subscribe to Pro

```
```php webhook.php ,v1=` against the raw body. During a secret rotation the header * carries one v1 per secret, so any matching v1 is accepted. Rejects timestamps older than 5 minutes. */ function verify_zahlo_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } function save_subscription(PDO $pdo, array $subscription, ?string $userId = null): void { $values = [$subscription['status'], $subscription['pastDueSince'] ?? null, $subscription['currentPeriodEnd'] ?? null]; if ($userId !== null) { // First sight of this subscription: link it to the user who started checkout. $pdo->prepare('UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ?, subscription_id = ? WHERE id = ?') ->execute([...$values, $subscription['id'], $userId]); } else { $pdo->prepare('UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE subscription_id = ?') ->execute([...$values, $subscription['id']]); } } // 1. Verify the signature on the raw body before trusting anything in it. $rawBody = file_get_contents('php://input'); if (!verify_zahlo_signature($rawBody, $_SERVER['HTTP_ZAHLO_SIGNATURE'] ?? '', getenv('ZAHLO_WEBHOOK_SECRET') ?: '')) { http_response_code(400); echo 'Invalid signature'; exit; } $event = json_decode($rawBody, true); // 2. Re-fetch and store the subscription's current state; never apply the event payload, so events // arriving late or out of order cannot grant or revoke access wrongly. $id = rawurlencode($event['data']['object']['id'] ?? ''); $apply = function (PDO $pdo): void {}; if ($event['type'] === 'checkout.session.completed') { $session = zahlo('GET', "/checkout-sessions/$id"); if ($session['type'] === 'subscription' && $session['status'] === 'complete' && !empty($session['subscriptionId'])) { $subscription = zahlo('GET', '/subscriptions/' . rawurlencode($session['subscriptionId'])); $apply = fn (PDO $pdo) => save_subscription($pdo, $subscription, $session['reference']); } } elseif (str_starts_with($event['type'], 'subscription.')) { $subscription = zahlo('GET', "/subscriptions/$id"); $apply = fn (PDO $pdo) => save_subscription($pdo, $subscription); } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. try { transaction(function (PDO $pdo) use ($event, $apply) { $fresh = $pdo->prepare('INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING'); $fresh->execute([$event['id']]); if ($fresh->rowCount() === 1) { $apply($pdo); } }); } catch (Throwable $error) { error_log('webhook failed: ' . $error->getMessage()); http_response_code(500); // not 2xx: Zahlo retries the event echo 'error'; exit; } echo 'ok'; ``` The handler is the `POST /webhooks/zahlo` route in `server.mjs` above. 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: ```bash cloudflared tunnel --url http://localhost:8000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/webhook.php", "enabledEvents": ["checkout.session.completed", "subscription.created", "subscription.updated", "subscription.renewed", "subscription.past_due", "subscription.payment_failed", "subscription.cancelled", "subscription.trial_will_end"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash set -a; source .env; set +a php -S localhost:8000 ``` Open `http://localhost:8000/account.php`. ```bash node --env-file=.env server.mjs ``` Open `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.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: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/test-helpers/subscriptions/$SUBSCRIPTION_ID/advance" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "renewal_failed"}' ``` | `to` | What it does | |---|---| | `trial_will_end` | moves the trial to 3 days before its end and sends `subscription.trial_will_end` | | `renewal_succeeded` | the next charge succeeds | | `renewal_failed` | the next charge is declined | | `renewal_requires_action` | the next charge needs the customer to authenticate | | `period_end` | ends the current period; a subscription set to cancel at period end is cancelled | After each step, reload the account page: it shows the status your handler stored from the events. 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](/guides/customer-portal). - **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](/guides/failed-payments) and [SaaS entitlements](/guides/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](/guides/refunds). - **Disputes**: respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# SEPA Direct Debit subscriptions (Next.js) Source: https://docs-demo.zahlo.eu/quickstarts/saas-sepa A signed-in user starts a 14-day free trial of your Pro plan (€19.00 a month) and pays by SEPA Direct Debit. On Zahlo's hosted page they enter their IBAN and accept the mandate; your server never sees the IBAN. Zahlo debits the account when the trial ends and every month after, and notifies the customer before each debit. SEPA Direct Debit subscriptions are EUR only and use hosted checkout. They work in the Sandbox; in live, they are enabled per account on request (contact support). Use [card subscriptions](/quickstarts/saas-subscriptions-hosted-nextjs) if you need live payments without that step. This quickstart is the card quickstart with one file changed, `lib/subscribe.ts`. Turn on **SEPA Direct Debit** under **Payments → Payment methods** in the dashboard first; otherwise session create fails with `payment_method_unavailable`. Your webhook handler stores the subscription, and access follows its status. 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. Create `.env.local` in the project root. Keep it out of version control; the key never reaches the browser. ```bash .env.local ZAHLO_API_KEY=zk_test_your_sandbox_key ZAHLO_WEBHOOK_SECRET=set_in_step_6 SITE_URL=http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "shop.example.com"}' ``` 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. 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: ```javascript scripts/setup-catalog.mjs // Creates the Pro plan, its monthly price with a 14-day trial, and a coupon, once per environment. // Run: node --env-file=.env.local scripts/setup-catalog.mjs // Safe to run again: it finds the price by lookupKey and the coupon by code, and creates nothing twice. const apiKey = process.env.ZAHLO_API_KEY; if (!apiKey) throw new Error("Set ZAHLO_API_KEY"); const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); const PLAN = { lookupKey: "pro-monthly", name: "Pro", unitAmountMinor: 1900, trialDays: 14 }; const COUPON = { code: "WELCOME15", name: "15% off for 3 months", percentOff: 15, duration: "repeating", durationInMonths: 3 }; async function call(method, path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(`${apiBase}/api/v1${path}`, { body: body === undefined ? undefined : JSON.stringify(body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, }); if (response.ok) return response.json(); const error = (await response.json().catch(() => ({}))).error ?? {}; const retry = response.status === 429 || response.status >= 500 || error.code === "request_in_progress"; if (retry && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt)); continue; } throw new Error(`${method} ${path} -> ${response.status} ${error.code}: ${error.message}`); } } async function listAll(path) { const items = []; for (let after = ""; ; ) { const page = await call("GET", `${path}?limit=100${after ? `&startingAfter=${after}` : ""}`); items.push(...page.data); if (!page.hasMore || page.data.length === 0) return items; after = page.data.at(-1).id; } } const prices = await listAll("/prices"); if (prices.some((price) => price.active && price.lookupKey === PLAN.lookupKey)) { console.log(`price ${PLAN.lookupKey}: exists`); } else { const product = await call("POST", "/products", { name: PLAN.name }, `setup-product-${PLAN.lookupKey}`); await call( "POST", "/prices", { currency: "EUR", lookupKey: PLAN.lookupKey, productId: product.id, recurring: { interval: "month", trialDays: PLAN.trialDays }, type: "recurring", unitAmountMinor: PLAN.unitAmountMinor, }, `setup-price-${PLAN.lookupKey}` ); console.log(`price ${PLAN.lookupKey}: created`); } const coupons = await listAll("/coupons"); if (coupons.some((coupon) => coupon.code === COUPON.code)) { console.log(`coupon ${COUPON.code}: exists`); } else { await call("POST", "/coupons", COUPON, `setup-coupon-${COUPON.code}`); console.log(`coupon ${COUPON.code}: created`); } ``` ```bash node --env-file=.env.local scripts/setup-catalog.mjs ``` 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. ```typescript lib/zahlo.ts // Server-only Zahlo API client. Never import this file from a client component. const apiKey = process.env.ZAHLO_API_KEY ?? ""; /** zk_test_ keys use the sandbox, zk_live_ keys use live. */ export const apiBase = process.env.ZAHLO_API_BASE ?? (apiKey.startsWith("zk_live_") ? "https://merchant.zahlo.eu" : "https://merchant-demo.zahlo.eu"); export class ZahloError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null ) { super(message); } } const MAX_RETRIES = 2; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const backoff = (attempt: number, response?: Response) => Number(response?.headers.get("retry-after") ?? 0) * 1000 || 500 * 2 ** attempt; /** Retry only what can succeed on a second try: network errors, 409 request_in_progress, 429 and 5xx. */ const retryable = (status: number, code: string) => status === 429 || status >= 500 || (status === 409 && code === "request_in_progress"); /** * Calls the Zahlo API. Every POST carries one Idempotency-Key, reused on each retry, * so a retried request can never create a second object. */ export async function zahlo( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, options: { body?: unknown; idempotencyKey?: string } = {} ): Promise { const idempotencyKey = method === "POST" ? (options.idempotencyKey ?? crypto.randomUUID()) : undefined; for (let attempt = 0; ; attempt += 1) { let response: Response; try { response = await fetch(`${apiBase}/api/v1${path}`, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { "content-type": "application/json", "x-api-key": apiKey, ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}), }, method, signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < MAX_RETRIES) { await sleep(backoff(attempt)); continue; } throw error; } if (response.ok) { return (response.status === 204 ? undefined : await response.json()) as T; } const payload = await response.json().catch(() => ({})); const code: string = payload?.error?.code ?? "unknown"; if (attempt < MAX_RETRIES && retryable(response.status, code)) { await sleep(backoff(attempt, response)); continue; } throw new ZahloError( response.status, code, payload?.error?.message ?? response.statusText, payload?.requestId ?? response.headers.get("x-request-id") ); } } /** Lists return one page at a time; follow startingAfter until hasMore is false. */ export async function listAll( path: string ): Promise { const items: T[] = []; let startingAfter = ""; for (;;) { const page = await zahlo<{ data: T[]; hasMore: boolean }>( "GET", `${path}?limit=100${startingAfter ? `&startingAfter=${startingAfter}` : ""}` ); items.push(...page.data); const last = page.data.at(-1); if (!(page.hasMore && last)) { return items; } startingAfter = last.id; } } ``` The quickstart stores users in SQLite (`node:sqlite`, built into Node.js). Use your own database the same way: what matters is the transaction. ```typescript lib/db.ts // Any database with transactions works; SQLite keeps the quickstart self-contained. export const db = new DatabaseSync(process.env.DATABASE_PATH ?? "app.db"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, subscription_id TEXT UNIQUE, subscription_status TEXT, -- trialing | active | past_due | paused | cancelled | incomplete past_due_since TEXT, current_period_end TEXT ); CREATE TABLE IF NOT EXISTS processed_events ( event_id TEXT PRIMARY KEY, processed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); /** Runs fn in one transaction: all of its writes commit together, or none do. */ export function transaction(fn: () => T): T { db.exec("BEGIN IMMEDIATE"); try { const result = fn(); db.exec("COMMIT"); return result; } catch (error) { db.exec("ROLLBACK"); throw error; } } export type User = { id: string; email: string; subscription_id: string | null; subscription_status: string | null; past_due_since: string | null; current_period_end: string | null; }; ``` The sign-in below is a stand-in so the quickstart runs on its own. Use your real auth instead. ```typescript lib/auth.ts // Stand-in for your auth library (Auth.js, Clerk, your own sessions). The quickstart signs in by email // and keeps the user id in a cookie; replace both functions with your real sign-in. export function userFromRequest(request: Request): User | null { const id = /(?:^|;\s*)uid=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1]; if (!id) return null; return (db.prepare("SELECT * FROM users WHERE id = ?").get(decodeURIComponent(id)) as User | undefined) ?? null; } export function signIn(email: string): User { db.prepare("INSERT INTO users (id, email) VALUES (?, ?) ON CONFLICT (email) DO NOTHING").run( crypto.randomUUID(), email.trim().toLowerCase() ); return db.prepare("SELECT * FROM users WHERE email = ?").get(email.trim().toLowerCase()) as User; } ``` ```typescript app/api/signin/route.ts // Demo sign-in: replace with your auth library. export async function POST(request: Request) { const user = signIn(String((await request.formData()).get("email") ?? "")); return new Response(null, { headers: { location: "/account", "set-cookie": `uid=${user.id}; Path=/; HttpOnly; SameSite=Lax` }, status: 303, }); } ``` ```typescript lib/subscribe.ts type Price = { id: string; lookupKey: string | null; active: boolean }; type Coupon = { id: string; code: string | null; active: boolean }; export type CheckoutSession = { id: string; type: "payment" | "subscription"; url: string | null; clientToken: string | null; status: "open" | "complete" | "expired" | "failed"; subscriptionId: string | null; reference: string | null; }; const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; /** * Starts a SEPA Direct Debit subscription for the signed-in user. The customer enters their IBAN and * accepts the mandate on Zahlo's hosted page; your server never sees the IBAN. */ export async function createSubscriptionSession( user: User, options: { mode: "hosted"; promoCode?: string } ): Promise { const prices = await listAll("/prices"); const price = prices.find((p) => p.active && p.lookupKey === "pro-monthly"); if (!price) throw new Error("No price with lookupKey pro-monthly; run scripts/setup-catalog.mjs"); let couponId: string | undefined; if (options.promoCode) { const coupons = await listAll("/coupons"); couponId = coupons.find( (c) => c.active && c.code?.toUpperCase() === options.promoCode?.toUpperCase() )?.id; } return zahlo("POST", "/checkout-sessions", { // One key per attempt; a network retry inside zahlo() reuses it. idempotencyKey: `subscribe-${user.id}-${crypto.randomUUID()}`, body: { type: "subscription", mode: "hosted", // the SEPA mandate page is hosted only paymentMethodTypes: ["sepa_direct_debit"], lineItems: [{ priceId: price.id, quantity: 1 }], couponId, customerEmail: user.email, reference: user.id, successUrl: `${siteUrl}/account?session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${siteUrl}/account`, }, }); } ``` ```typescript app/api/subscribe/route.ts // Hosted checkout: create the session on the server, then send the browser to session.url. export async function POST(request: Request) { const user = userFromRequest(request); if (!user) return new Response("Sign in first", { status: 401 }); const promoCode = String((await request.formData()).get("promoCode") ?? "").trim() || undefined; try { const session = await createSubscriptionSession(user, { mode: "hosted", promoCode }); return Response.redirect(session.url ?? "", 303); } catch (error) { if (error instanceof ZahloError) { console.error("subscribe failed", error.code, error.requestId); return new Response(error.message, { status: error.status >= 500 ? 502 : 400 }); } throw error; } } ``` Access is decided from the stored subscription, never from the customer returning from checkout: ```typescript lib/access.ts // Your grace period setting (14 days by default). After it, Zahlo cancels the subscription. const GRACE_DAYS = 14; /** Access follows the subscription: trialing or active, or past_due while the grace period lasts. */ export function hasAccess(user: User, now = new Date()): boolean { if (user.subscription_status === "trialing" || user.subscription_status === "active") return true; if (user.subscription_status === "past_due" && user.past_due_since) { return now.getTime() - new Date(user.past_due_since).getTime() < GRACE_DAYS * 86_400_000; } return false; } ``` ```tsx app/account/page.tsx export const dynamic = "force-dynamic"; // Shows access from the stored subscription. Returning from checkout grants nothing by itself: // the webhook stores the subscription, usually within seconds. export default async function AccountPage() { const id = (await cookies()).get("uid")?.value; const user = id ? (db.prepare("SELECT * FROM users WHERE id = ?").get(id) as User | undefined) : undefined; if (!user) { return (
); } if (hasAccess(user)) { return (

Pro is active ({user.subscription_status}). Welcome, {user.email}.

); } return (

{user.subscription_id ? `Subscription ${user.subscription_status}.` : "Start your 14-day free trial."}

); } ```
```typescript lib/webhook-signature.ts export type ZahloEvent = { id: string; type: string; livemode: boolean; createdAt: string; data: { object: { id: string } & Record }; }; /** * Checks `Zahlo-Signature: t=,v1=` against the raw request body and returns the parsed event, * or null when the signature is missing, wrong or older than 5 minutes. During a secret rotation the * header carries one v1 per secret, so any matching v1 is accepted. */ export function verifyZahloEvent( rawBody: string, header: string | null, secret: string, toleranceSeconds = 300 ): ZahloEvent | null { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([key]) => key === "t")?.[1]); const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value ?? ""); if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) { return null; } const expected = Buffer.from( createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex") ); const valid = signatures.some((signature) => { const given = Buffer.from(signature); return given.length === expected.length && timingSafeEqual(given, expected); }); return valid ? (JSON.parse(rawBody) as ZahloEvent) : null; } ``` ```typescript app/api/webhooks/zahlo/route.ts type Subscription = { id: string; status: string; pastDueSince: string | null; currentPeriodEnd: string | null; }; const saveSubscription = (subscription: Subscription, userId?: string | null) => { const values = [subscription.status, subscription.pastDueSince, subscription.currentPeriodEnd]; if (userId) { // First sight of this subscription: link it to the user who started checkout. db.prepare( "UPDATE users SET subscription_id = ?, subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE id = ?" ).run(subscription.id, ...values, userId); } else { db.prepare( "UPDATE users SET subscription_status = ?, past_due_since = ?, current_period_end = ? WHERE subscription_id = ?" ).run(...values, subscription.id); } }; export async function POST(request: Request) { // 1. Verify the signature on the raw body before trusting anything in it. const rawBody = await request.text(); const event = verifyZahloEvent( rawBody, request.headers.get("zahlo-signature"), process.env.ZAHLO_WEBHOOK_SECRET ?? "" ); if (!event) return new Response("Invalid signature", { status: 400 }); // 2. Re-fetch and store the subscription's current state; never apply the event payload, so events // arriving late or out of order cannot grant or revoke access wrongly. const id = encodeURIComponent(event.data.object.id); let apply: () => void = () => {}; if (event.type === "checkout.session.completed") { const session = await zahlo("GET", `/checkout-sessions/${id}`); if (session.type === "subscription" && session.status === "complete" && session.subscriptionId) { const subscription = await zahlo("GET", `/subscriptions/${encodeURIComponent(session.subscriptionId)}`); apply = () => saveSubscription(subscription, session.reference); } } else if (event.type.startsWith("subscription.")) { const subscription = await zahlo("GET", `/subscriptions/${id}`); apply = () => saveSubscription(subscription); } // 3. Record the event and apply the change in ONE transaction, and answer 2xx only after it commits. // A crash before the commit rolls back both, so Zahlo's retry does the work again. transaction(() => { const fresh = db .prepare("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING") .run(event.id); if (fresh.changes === 1) apply(); }); return new Response("ok"); } ``` 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 dev server and note its `https://….trycloudflare.com` URL: ```bash cloudflared tunnel --url http://localhost:3000 ``` 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. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-tunnel.trycloudflare.com/api/webhooks/zahlo", "enabledEvents": ["checkout.session.completed", "subscription.created", "subscription.updated", "subscription.renewed", "subscription.past_due", "subscription.payment_failed", "subscription.cancelled", "subscription.trial_will_end"]}' ``` A quick tunnel's URL changes every time you start it; update the endpoint when it does. ```bash npm run dev ``` Open `http://localhost:3000/account`, sign in with any email, enter `WELCOME15` and subscribe. On the mandate page, enter any name and a valid test IBAN such as `DE89 3704 0044 0532 0130 00`, and accept the mandate. Nothing is debited during the trial. Check that: - your server logs `checkout.session.completed` and `subscription.created`, and `/account` 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 banking network. Find the subscription ID in the dashboard or with `GET /subscriptions`, then: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/test-helpers/subscriptions/$SUBSCRIPTION_ID/advance" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "renewal_failed"}' ``` | `to` | What it does | |---|---| | `trial_will_end` | moves the trial to 3 days before its end and sends `subscription.trial_will_end` | | `renewal_succeeded` | the next charge succeeds | | `renewal_failed` | the next charge is declined | | `renewal_requires_action` | the next charge needs the customer to authenticate | | `period_end` | ends the current period; a subscription set to cancel at period end is cancelled | | `sepa_returned` | the last paid debit comes back from the customer's bank (a return), as can happen days or weeks later | After each step, reload `/account`: it shows the status your handler stored from the events. 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](/guides/customer-portal). ```typescript app/api/portal/route.ts const siteUrl = process.env.SITE_URL ?? "http://localhost:3000"; // "Manage billing": a single-use customer portal link (valid for one hour) for the signed-in user. // Create a fresh one on every click; never store it. export async function POST(request: Request) { const user = userFromRequest(request); if (!user?.subscription_id) return new Response("No subscription", { status: 404 }); const subscription = await zahlo<{ customerId: string }>( "GET", `/subscriptions/${encodeURIComponent(user.subscription_id)}` ); const portal = await zahlo<{ url: string }>("POST", "/customer-portal/sessions", { body: { customerId: subscription.customerId, returnUrl: `${siteUrl}/account` }, }); return Response.redirect(portal.url, 303); } ``` - **Processing debits**: a SEPA debit is `processing` for several banking days. The subscription counts as active meanwhile, so `hasAccess` keeps access; don't wait for the money to arrive. - **Returns**: the customer's bank can return a debit after it succeeded. The period becomes unpaid and the subscription `past_due`, 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](/guides/failed-payments) and [SaaS entitlements](/guides/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](/guides/refunds). - **Disputes**: respond in the dashboard before the deadline. See [Disputes](/guides/disputes). 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](/go-live#recreate-in-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 `lookupKey`s 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.
# Payments Source: https://docs-demo.zahlo.eu/concepts/payments A payment represents a customer charge request. It includes an amount, currency, merchant reference, metadata, and a neutral status. ## Statuses | Status | Meaning | | --- | --- | | `created` | Request accepted but not yet presented to the customer | | `pending` | Customer action or confirmation is in progress | | `confirmed` | Payment has been authoritatively confirmed | | `partially_refunded` | Some, but not all, of the payment was refunded | | `refunded` | The full payment was refunded | | `cancelled` | Payment was cancelled before confirmation | | `expired` | The payment session passed its expiry time | Use integer minor units for new checkout requests, such as `4990` for EUR 49.90. Keep the currency with every amount and do not use floating-point arithmetic for accounting decisions. ## Payment methods Payment methods are merchant-scoped and can include card, Apple Pay, and Google Pay. Query enabled methods before rendering a choice, and validate the selected method again on the server. ## Fulfillment The payment status returned to a browser is useful for display, but fulfillment must use a verified server event. Make fulfillment idempotent and associate it with the merchant payment ID and order ID. # API conventions Source: https://docs-demo.zahlo.eu/concepts/api-conventions The merchant API is a JSON API under `/api/v1`. Choose the environment first, keep server credentials on your server, and treat every write as a state transition that may be retried or reconciled. ## Authentication Send `x-api-key` from trusted server code. Never put it in browser JavaScript, mobile bundles, URLs, logs, or metadata. Browser checkout calls use a short-lived bearer client token scoped to one session. ```bash curl "https://merchant-demo.zahlo.eu/api/v1/transactions/txn_demo_1001" \ -H "x-api-key: ${ZAHLO_API_KEY}" ``` ## Money and currency Use integer minor units for new requests. `4990` with `EUR` means EUR 49.90. Store the currency beside every amount, use decimal-safe arithmetic, and compare the authoritative API amount with your order total before fulfillment. ## Idempotency Send a stable `Idempotency-Key` on payment, checkout-session, subscription, payment-link, refund, and other create requests. Reuse the same key when the response is lost. Do not generate a new key until you have reconciled the original request. ## Pagination List endpoints accept `limit` (up to 100) and `startingAfter` (or `endingBefore`), and return `{ object: "list", data, hasMore }`. To get the next page, pass the last object's `id` as `startingAfter`; stop when `hasMore` is false. Do not use an object’s display name as a cursor. ## Errors Errors return `{ error: { type, code, message, docUrl, param? }, requestId }`; `requestId` sits next to `error`, not inside it. Fix `400` and `401` requests before retrying. Back off on `429` and `5xx`; retrieve by ID after an ambiguous response. A `409` means you must reconcile current state before applying another write. # Catalog and pricing Source: https://docs-demo.zahlo.eu/concepts/catalog Separate what you sell from how it is priced: - A product describes the good or service. - A price describes amount, currency, and optional recurrence. - A coupon describes a discount rule. Use stable product and price IDs in checkout and payment links. Store your own order and inventory identifiers in metadata. Never infer fulfillment from a product name or a price displayed in the browser. ## One-time catalog Use one-time prices for ordinary goods, event tickets, services, and donations. Include quantity and order metadata when creating a payment link or checkout session. ## Recurring catalog Use recurring prices for plans. A subscription references a customer and price, then bills each period. Use a new price for a materially different amount or billing interval instead of mutating a price already used by customers. ## Discounts, shipping, and tax Apply coupons explicitly. Zahlo does not calculate tax or shipping: show the customer the resulting total, send it tax-inclusive, preserve the inputs in metadata, and recompute totals server-side. See [Tax and shipping](/guides/shipping-and-tax). # Subscriptions Source: https://docs-demo.zahlo.eu/concepts/subscriptions A subscription connects a customer to one recurring price. It bills in **periods** of whole months: 1 to 36 months, fixed from the billing anchor. A period that starts on the 31st ends on the last day of shorter months. Zahlo collects each period's charge from the customer's saved card at the start of that period. ## Status The status is derived from the periods and from your actions. The first matching row wins: | Condition | Status | |---|---| | Cancelled, and the cancellation time has passed | `cancelled` | | Paused | `paused` | | No trial, and the first period is not paid yet | `incomplete` | | Inside the trial | `trialing` | | Any due period is unpaid | `past_due` | | Every due period is paid | `active` | `pastDueSince` is the time the **oldest** unpaid period became overdue. If a later period is paid while an older one is still open, the subscription stays `past_due`. ## Periods and late payments `currentPeriodStart` and `currentPeriodEnd` come from the period boundaries. They never move because a payment arrived late. A payment that settles an old period settles only that period. If two payments settle the same period, the second is **held** for you. It appears under *Collections to review* in the dashboard, and you decide whether to refund it. ## Events | Event | When | |---|---| | `subscription.created` | The subscription exists (after checkout or `POST /subscriptions`). | | `subscription.renewed` | A period was paid. | | `subscription.payment_failed` | A period's payment failed. | | `subscription.past_due` | The subscription became `past_due`. | | `subscription.trial_will_end` | Three days before the trial ends. | | `subscription.updated` | Status, cancellation, pause, price or payment method changed. | | `subscription.cancelled` | The subscription ended. | Each renewal is also a Payment, so you receive `payment.confirmed` or `payment.failed` with `subscriptionId` set. A trial checkout charges nothing and sends no `payment.*` event. ## Limits - Intervals are whole months (1–36). There are no daily or weekly intervals. - A single charge is 50 to 500,000 minor units (€5,000). - Price and quantity changes take effect at the next period. There is no proration. - Subscriptions are paid by card, or by SEPA Direct Debit (EUR, hosted checkout only; in live on request). See [SEPA Direct Debit subscriptions](/quickstarts/saas-sepa). # Webhooks Source: https://docs-demo.zahlo.eu/concepts/webhooks Webhooks notify your server when a payment, checkout session, refund, subscription, or customer action changes state. ## Event envelope Every event has a stable event ID, event type, creation time, and resource data. Store the event ID before applying business effects so duplicate delivery is harmless. ## Delivery rules 1. Read the raw request body. 2. Verify the signature with the endpoint secret. 3. Reject invalid signatures and timestamps more than 5 minutes old. 4. Deduplicate by event ID (`Zahlo-Event-Id`); delivery is at-least-once and unordered. 5. Queue work and return a success response quickly. 6. Retry transient processing failures without changing the financial state twice. See [Webhook operations](/operations/webhooks) for signature, retry, and observability guidance. # Accept a payment Source: https://docs-demo.zahlo.eu/guides/accept-a-payment Use a Checkout Session for every one-time payment. Choose [hosted checkout](/guides/hosted-checkout) to redirect the customer to a Zahlo-hosted page, or [in-page checkout](/guides/embedded-checkout) to embed secure fields in your own page. ## Create a checkout session Create it on your server after resolving the cart and customer. The API key never crosses this boundary. ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ mode: "hosted", amountMinor: order.totalMinor, currency: order.currency, method: "card", orderId: order.id, successUrl: "https://store.example/checkout/success", cancelUrl: "https://store.example/checkout/cancelled", metadata: { orderId: order.id }, }), }); const session = await response.json(); ``` Return only the session ID and the short-lived client token the browser needs. Submit the client token to the redirect endpoint (hosted) or the checkout SDK (in-page); never put it in a query string. ## Handle the result A session can stay pending while the customer completes additional action or the payment method settles asynchronously. Retrieve the session or wait for the verified event, and never treat a redirect return as confirmation. Use [Fulfill orders safely](/guides/fulfillment) for the confirmed-event boundary. Expire a session only when the order is genuinely abandoned. The verified `payment.confirmed` webhook remains the fulfillment authority when the browser flow ends before the server receives confirmation. # Build checkout Source: https://docs-demo.zahlo.eu/guides/checkout Zahlo checkout supports hosted redirects and in-page experiences. Both use the same server-created session, dynamic payment methods, short-lived client token, return URLs, webhook events, and secure payment-field boundary. ## Before you start - Choose Demo or production in [Environments](/environments). - Store the merchant API key and webhook secret in your server secret manager. - Register the exact in-page origins in merchant settings and use registered return URLs. - Create or resolve the order on the server and calculate its amount in minor units. The minimal lifecycle is: create an order → create one session with an idempotency key → render hosted or in-page checkout → keep the order pending → verify the payment event → fulfill once. ## Hosted checkout Create a checkout session from your server, then submit its client token to the hosted redirect endpoint. Hosted checkout is the fastest path and works well for email, invoices, and payment links. ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ mode: "hosted", amountMinor: order.totalMinor, currency: order.currency, method: "card", orderId: order.id, successUrl: "https://store.example/checkout/success", cancelUrl: "https://store.example/checkout/cancelled", metadata: { orderId: order.id }, }), }); const session = await response.json(); // Return session.id and session.clientToken to the browser handoff. ``` ## Embedded checkout Create a session with `mode: "embedded"`, load the Zahlo SDK from your website, and mount the session in a customer-owned container. Register the exact website origin before rendering it. See [Customize checkout](/guides/customize-checkout) for the full appearance contract. ```javascript const checkout = new ZahloCheckout({ apiUrl: "https://merchant-demo.zahlo.eu", sessionId, clientToken, locale: "en", onStatusChange: (status) => console.log(status), }); await checkout.mount("#zahlo-checkout"); ``` ## Request fields Use `amountMinor`, `currency`, `mode`, one canonical `method`, order metadata, registered return URLs, and appearance. Validate all values on the server. Do not send raw card number, security code, or expiry fields to your server. ## Completion Treat the checkout UI and browser callbacks as presentation signals. Your server should fulfill only after a verified `payment.confirmed` or equivalent terminal event. For a pending or asynchronous method, show a pending state and reconcile by session or payment ID rather than creating a replacement session. # Hosted checkout Source: https://docs-demo.zahlo.eu/guides/hosted-checkout Hosted checkout is the fastest integration. Your server creates a session and redirects the customer's browser to the session `url`. Zahlo owns payment-method interaction, sensitive card fields, 3-D Secure and the responsive payment UI. No browser SDK and no client token are involved. ## Before you start Register every origin your success, cancel and failure URLs use as a checkout domain, once: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-domains" \ -H "x-api-key: $ZAHLO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "store.example"}' ``` The domain works immediately. URLs must match a registered origin exactly (scheme, host and port). Live accepts HTTPS only. With a `zk_test_` key you can also register `localhost:` or `127.0.0.1:` and use plain `http://` return URLs while you develop. ## Server endpoint ```javascript export async function createCheckout(order) { const response = await fetch( "https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ mode: "hosted", amountMinor: order.totalMinor, currency: order.currency, successUrl: "https://store.example/checkout/success?session={CHECKOUT_SESSION_ID}", failureUrl: "https://store.example/checkout/failed", cancelUrl: "https://store.example/checkout/cancelled", }), } ); if (!response.ok) throw new Error(`checkout failed: ${response.status}`); const session = await response.json(); // Store session.id with the order, then send the browser to session.url (HTTP 303). return session; } ``` `{CHECKOUT_SESSION_ID}` in a return URL is replaced with the session ID; the ID is also appended as `?id=`. A session lasts 24 hours by default; set `expiresInSeconds` (1800 to 86400) to shorten it. The customer never sees a countdown, and an expired session shows a clear "this checkout expired" page. ## Return and confirmation After payment, the customer comes back through Zahlo to your success, failure or cancel URL. These pages are presentation only: a return URL never proves payment. Retrieve the session on your server with your secret key (`GET /api/v1/checkout-sessions/{id}` with `x-api-key`) and fulfil only when the payment is confirmed, ideally from the signed `checkout.session.completed` event. Show "processing" while a payment is still pending. ## Customize the page Use locale, payment methods and your checkout branding; see [Customize checkout](/guides/customize-checkout). Register a signed webhook endpoint before going live so delayed or asynchronous payment confirmation reaches your server. # In-page checkout Source: https://docs-demo.zahlo.eu/guides/embedded-checkout In-page checkout keeps your page layout and navigation while Zahlo owns sensitive payment fields and method-specific interaction. Create the session on your server, then pass the session ID and its client token to the browser. ## Server step Register the origin that will mount the component as a [checkout domain](/guides/hosted-checkout#before-you-start) (`POST /api/v1/checkout-domains`, add `"applePay": true` to offer Apple Pay on that site). Then create the session with `mode: "embedded"`; the amount and items come from your trusted order calculation. ```json { "mode": "embedded", "lineItems": [{ "priceId": "price_demo_1001", "quantity": 1 }], "paymentMethodTypes": ["card", "apple_pay", "google_pay"], "metadata": { "orderId": "order_1001" } } ``` The response contains `clientToken` and `embedUrl` (hosted sessions have a `url` instead and no token). The client token only lets the browser read a limited view of its own session: status, amount, currency, methods and branding, never customer data or your metadata. ## Browser step ```typescript const checkout = new ZahloCheckout({ apiUrl: "https://merchant-demo.zahlo.eu", sessionId, clientToken, appearance: { theme: "auto", locale: "en", merchantName: "Example Store", logoUrl: "https://store.example/assets/logo.svg", colors: { primary: "#087A60", background: "#FFFFFF", text: "#17211D", muted: "#5A625D", }, fontFamily: "Inter, system-ui, sans-serif", spacing: "comfortable", customCss: ".zahlo-checkout-card { border-radius: 12px; }", }, onStatusChange: (status) => renderCheckoutStatus(status), onSuccess: () => showPendingConfirmation(), onError: (error) => showRetry(error), }); await checkout.mount("#zahlo-checkout"); ``` Call `destroy()` when the page unmounts. Mount one instance per container, keep the container accessible, and do not copy sensitive field values into your DOM, state, analytics, or logs. Appearance values style the checkout shell; secure payment fields remain isolated and are styled only through the supported appearance options. ## Origin and event rules The browser may read only its session through the bearer client token. Verify the exact origin before mounting. A callback means the component observed a state change; a signed server event is the fulfillment authority. For asynchronous methods, leave the order pending until confirmation. # Customize checkout Source: https://docs-demo.zahlo.eu/guides/customize-checkout Checkout customization has two layers: merchant-owned page layout and a secure Zahlo checkout component. Configure the first with your site CSS and the second with the session appearance options. Sensitive payment fields remain inside the secure component in both hosted and in-page modes. The `custom CSS` option is scoped to the Zahlo-owned shell. It cannot read payment values, change the amount, disable validation, or style provider-owned sensitive iframe fields. It is capped at 4,000 characters and wrapped in `@scope (.zahlo-checkout-root)`. ## Supported appearance contract The SDK accepts the same neutral appearance object for hosted and in-page checkout: | Field | Supported values | Owned by | | --- | --- | --- | | `theme` | `light`, `dark`, `auto` | Zahlo shell | | `locale` | `en`, `de` | Zahlo labels and status | | `merchantName` | Up to 80 characters | Zahlo shell text | | `logoUrl`, `iconUrl` | Same-origin relative path or HTTPS asset URL | Zahlo shell asset | | `colors` | Hex `primary`, `background`, `text`, `muted` values | Zahlo shell | | `fontFamily` | Safe font-family text, up to 160 characters | Zahlo shell | | `spacing` | `compact`, `normal`, `comfortable` | Zahlo shell | | `customCss` | Shell-scoped CSS, up to 4,000 characters | Zahlo shell only | The server must validate these values before storing or sending them to the browser. The SDK keeps the existing top-level `theme` and `locale` fields as compatibility aliases; `appearance` is canonical. Secure payment fields can only be styled through the supported appearance options. ## Choose a mode | Mode | How the customer enters checkout | Best for | | --- | --- | --- | | Hosted | Submit the session client token to the redirect endpoint | Fast launch, email, invoices, and payment links | | In-page | Mount the session into your container | A checkout that stays inside your site | Create the session on your server. The server validates the cart, selects the allowed methods, supplies the appearance, and returns only the session ID and short-lived client token to browser code. ## Server configuration ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ mode: "embedded", amountMinor: order.totalMinor, currency: order.currency, method: "card", successUrl: "https://store.example/checkout/success", failureUrl: "https://store.example/checkout/failed", cancelUrl: "https://store.example/checkout/cancelled", appearance: { theme: "light", logoUrl: "https://store.example/assets/logo.svg", iconUrl: "https://store.example/assets/icon.svg", fontFamily: "Inter, system-ui, sans-serif", colors: { primary: "#087A60", background: "#FFFFFF", text: "#17211D" }, spacing: "comfortable", customCss: ".zahlo-checkout { border-radius: 12px; }", }, metadata: { orderId: order.id }, }), }); if (!response.ok) throw new Error(`checkout creation failed: ${response.status}`); const session = await response.json(); ``` Use HTTPS assets hosted on a controlled origin. Validate the URLs and appearance values on the server; never let arbitrary customer input become CSS or an asset URL. ## Hosted checkout Submit the client token to the session redirect endpoint from a server response or browser handoff. Keep the order pending at this point. The success, failure, or cancellation page should display the session result but must not fulfill the order. Fulfill only after a verified payment event. ## In-page checkout ```html
``` The SDK owns payment-field collection and method-specific interaction. Your callbacks update presentation only. Do not read, log, persist, or proxy payment field values. ## Allowed origins and return URLs Register each exact HTTPS origin that can mount in-page checkout. An origin is a scheme, host, and optional port; a path is not an origin. Keep return URLs on an allowlist and include only your own order reference. Never put an API key, webhook secret, or client token in a query string. ## Locale and accessibility Set `locale` explicitly for a multilingual storefront. Keep the checkout container visible, keyboard reachable, and announced with an `aria-live` status region. Test narrow screens, zoom, focus order, validation text, contrast, and reduced motion. Format money using the returned currency and minor-unit amount; do not derive totals from localized labels. ## Updating a session If the cart, customer, address, tax, shipping, or selected method changes, recalculate on the server and use the documented update path for the session. If the session can no longer represent the order, expire it and create one new session with a new idempotency key only after reconciling the original. # Payment methods Source: https://docs-demo.zahlo.eu/guides/payment-methods Payment methods are dynamic. The available set depends on the merchant, currency, amount, customer location, device, session mode, and method configuration. Ask for the set for the current checkout and render only what the API returns. ## 1. Discover methods on your server ```javascript const response = await fetch( "https://merchant-demo.zahlo.eu/api/v1/payment-methods?currency=EUR&amountMinor=4990&country=DE&mode=embedded", { headers: { "x-api-key": process.env.ZAHLO_API_KEY }, } ); if (!response.ok) throw new Error(`method discovery failed: ${response.status}`); const { methods } = await response.json(); ``` Use the returned `id` values to build the selection shown to the customer. Keep a server-side allowlist for methods your business accepts, then intersect it with the returned eligible set. Do not assume that a method available for one currency or device is available for another. ## 2. Request a checkout ```javascript const session = await fetch("https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ mode: "embedded", amountMinor: order.totalMinor, currency: order.currency, method: methods.find((method) => method.id === "card")?.id, orderId: order.id, successUrl: "https://store.example/checkout/complete", cancelUrl: "https://store.example/checkout/cancelled", metadata: { orderId: order.id }, }), }).then((result) => result.json()); ``` If the chosen method is no longer eligible, retrieve the session and ask the customer to select another returned method. Do not silently change the amount or create a second session. ## Method-specific behavior - **Cards:** the secure component may request additional customer authentication. Treat an intermediate status as pending. - **Wallets:** display a wallet only when the device and browser expose it and the API returns it as eligible. - **Wallets:** a customer may complete device or browser approval and return later. Preserve the order as pending until a verified event confirms it. - **Asynchronous methods:** the browser callback is a display signal, not proof of settlement. Reconcile by event and payment ID. ## Ordering and fallback Use the merchant checkout settings or the session’s method list to control order. Keep a clear fallback for an empty or changed list: explain that the selected method is unavailable and offer the methods currently returned. Record the method family, not sensitive payment details, in your own order metadata. # Card payments Source: https://docs-demo.zahlo.eu/guides/card-payments Use hosted or in-page checkout for card payments. Zahlo’s secure component collects card details and handles additional customer action; your server receives payment state, not raw card numbers or security codes. ## Configure card acceptance Request `method: "card"`, but still offer it only when current capability discovery returns `card`. A card can require additional action or remain processing after submission. ```javascript const session = await createCheckoutSession({ mode: "embedded", amountMinor: 4990, currency: "EUR", method: "card", metadata: { orderId: "order_1001" }, }); ``` ## Statuses Keep the order pending for `requires_action`, `processing`, or equivalent intermediate states. Display a recoverable error for a decline or validation failure. Confirm success from a verified `payment.confirmed` event and compare amount, currency, and order ID before fulfillment. ## Saved cards Never store card data yourself. Subscriptions charge the card saved at checkout; the API does not charge a saved card directly. ## Testing Exercise approval, decline, additional action, duplicate submission, session expiry, refund, and dispute paths in Demo. Keep test data separate from production and verify that no card field or token is written to application logs. # Wallet payments Source: https://docs-demo.zahlo.eu/guides/wallets Wallets are conditional payment methods. A wallet appears only when it is enabled for the merchant and supported by the shopper’s device, browser, currency, amount, and checkout mode. ## Discover eligible wallets Query methods for the current session and render only enabled results. Do not show a wallet button that the API or device cannot support. ```javascript const { methods } = await fetch( "https://merchant-demo.zahlo.eu/api/v1/payment-methods?currency=EUR&amountMinor=4990&mode=embedded", { headers: { "x-api-key": process.env.ZAHLO_API_KEY } }, ).then((response) => response.json()); const wallets = methods.filter((method) => ["apple_pay", "google_pay"].includes(method.type) && method.enabled, ); ``` The secure component owns wallet authorization. Your server owns the amount, order, idempotency key, event verification, and fulfillment decision. ## Fallback behavior If a wallet disappears after the page loads, keep the order and offer the other returned methods. If the wallet authorizes but confirmation is delayed, show pending and reconcile the same payment ID. Do not charge again because a wallet callback was interrupted. # API examples Source: https://docs-demo.zahlo.eu/guides/api-examples Every example keeps the merchant API key on the server. Replace the example origin with the environment selected in [Environments](/environments). ## Server authentication ```bash curl "https://merchant-demo.zahlo.eu/api/v1/payment-methods?currency=EUR&amountMinor=4990&mode=hosted" \ -H "x-api-key: $ZAHLO_API_KEY" ``` ```javascript const response = await fetch( "https://merchant-demo.zahlo.eu/api/v1/payment-methods?currency=EUR&amountMinor=4990&mode=hosted", { headers: { "x-api-key": process.env.ZAHLO_API_KEY }, } ); ``` ```python response = requests.get( "https://merchant-demo.zahlo.eu/api/v1/payment-methods?currency=EUR&amountMinor=4990&mode=hosted", headers={"x-api-key": os.environ["ZAHLO_API_KEY"]}, timeout=10, ) response.raise_for_status() ``` ## Create checkout ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `checkout-${order.id}`, }, body: JSON.stringify({ amountMinor: order.totalMinor, currency: "EUR", method: "card", orderId: order.id, successUrl: "https://store.example/checkout/success", cancelUrl: "https://store.example/checkout/cancel", metadata: { orderId: order.id }, }), }); ``` ```python checkout = requests.post( "https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", headers={ "x-api-key": os.environ["ZAHLO_API_KEY"], "Idempotency-Key": f"checkout-{order_id}", }, json={ "amountMinor": 4990, "currency": "EUR", "method": "card", "orderId": order_id, "successUrl": "https://store.example/checkout/success", "cancelUrl": "https://store.example/checkout/cancel", }, timeout=10, ) checkout.raise_for_status() ``` ## Subscription checkout ```bash curl -X POST https://merchant-demo.zahlo.eu/api/v1/subscriptions \ -H "x-api-key: $ZAHLO_API_KEY" \ -H 'Idempotency-Key: subscription-account-1001' \ -H 'Content-Type: application/json' \ -d '{"customerId":"cus_1001","priceId":"price_pro_monthly","trialDays":14}' ``` ## Payment link ```javascript const link = await fetch("https://merchant-demo.zahlo.eu/api/v1/payment-links", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": "campaign-summer-2026", }, body: JSON.stringify({ lineItems: [{ priceId: "price_basic", quantity: 1 }], metadata: { campaign: "summer-2026" }, }), }); ``` ## Webhook verification Verify the `Zahlo-Signature` header over the raw body before parsing it; never verify a re-serialized JSON object. The verification code in Node.js, PHP and Python is in [Webhook operations](/operations/webhooks). Store the event ID before applying a state transition. Return a 2xx response only after the event has passed authentication and schema checks. ## Refund ```bash curl -X POST https://merchant-demo.zahlo.eu/api/v1/refunds \ -H "x-api-key: $ZAHLO_API_KEY" \ -H 'Idempotency-Key: refund-order-1001' \ -H 'Content-Type: application/json' \ -d '{"paymentId":"pay_1001","amountMinor":4990,"reason":"requested_by_customer"}' ``` ## WooCommerce correlation Pass the WooCommerce order ID as the merchant API `orderId` and in `metadata`. Persist the returned payment or checkout-session ID in order metadata. Mark the order paid only after the signed event matches the order ID, amount, currency, and merchant account. # Fulfill orders safely Source: https://docs-demo.zahlo.eu/guides/fulfillment Keep an order pending after checkout creation. Store the checkout session ID with your order. ## The rule Fulfil when you receive `checkout.session.completed`, or when retrieving the session with your secret key shows `paymentStatus: "paid"`. Zahlo emits `checkout.session.completed` only once the payment has succeeded: - An **authorization is not a payment**. A card that is authorized but not yet captured leaves the session `open` and `unpaid`. - A **return to your success URL is not proof** of payment. The customer may arrive before the payment settles; show "processing" until the session is paid. - **Delayed methods** (for example SEPA Direct Debit) keep the session `open` while the bank processes the debit. It completes when the debit succeeds. ## Verify and fulfill ```javascript export async function handleEvent(event) { if (event.type !== "checkout.session.completed") return; const session = event.data.object; const order = await orders.findBySessionId(session.id); if (!order || order.amountMinor !== session.amountTotal || order.currency !== session.currency) { throw new Error("session does not match the order"); } await orders.fulfillOnce(order.id, event.id); } ``` When a signed event arrives: 1. Verify the `Zahlo-Signature` header and reject old timestamps. 2. Ignore an event ID you already processed. 3. Match the session ID, amount and currency with your order. 4. Fulfil once and record the event ID. ## Expired sessions and late payments A session expires after 24 hours by default (`expiresInSeconds` sets 30 minutes to 24 hours). Zahlo emits `checkout.session.expired`, and you can cancel the order then. If a payment still arrives after expiry, which is rare, the session stays expired, no `checkout.session.completed` is sent, and Zahlo refunds that payment automatically. So an order you cancelled on expiry is never charged. Do not fulfil from a success URL, an unverified client callback, or an amount supplied by the browser. Make the fulfilment handler safe to retry. # Failed payments Source: https://docs-demo.zahlo.eu/guides/failed-payments When a renewal fails: 1. The period stays unpaid. The subscription becomes `past_due`, and you receive `subscription.payment_failed`, `subscription.past_due` and `payment.failed`. 2. The customer gets an email with a link to the customer portal, where they can pay the outstanding amount with a card. A successful payment settles that period and becomes the subscription's card from then on. 3. Zahlo retries the saved card automatically on days 3, 5 and 7 after the first failure. 4. If anything is still unpaid when the grace period ends, the subscription is cancelled with `cancellationReason: "payment_failed"`. The grace period is 14 days by default and is set per merchant; it counts from the oldest unpaid period. A renewal that needs the customer present (for example a 3-D Secure challenge) fails with `authentication_required`. The portal payment completes it on-session. ## Find what is outstanding ```bash curl "https://merchant-demo.zahlo.eu/api/v1/subscriptions?status=past_due" \ -H "x-api-key: ${ZAHLO_API_KEY}" ``` Each subscription's `pastDueSince` and `latestPaymentId` show how long it has been overdue and which payment failed. ## Access during past due A common rule is to keep access while `past_due` and the grace period has not passed. `pastDueSince` tells you when it started; see [SaaS entitlements](/guides/saas-entitlements). # Refunds Source: https://docs-demo.zahlo.eu/guides/refunds Create refunds from trusted server code using the payment ID. For a partial refund, send an amount in minor units; otherwise refund the remaining amount. Use a unique key per intended refund and reuse it when the response is lost. ## Create a refund ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/refunds", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `refund-${order.id}-1`, }, body: JSON.stringify({ paymentId: order.paymentId, amountMinor: 4990, reason: "requested_by_customer", }), }); if (!response.ok) throw new Error(`refund request failed: ${response.status}`); ``` Record the refund ID in your order system and process refund events idempotently. A refund request being accepted is not the same as a completed refund, so update customer-facing state from the final refund event. For a partial refund, keep the remaining captured amount and fulfillment record explicit. Before refunding, verify the merchant payment, amount, currency, and order ownership. Do not accept a payment ID or amount from an untrusted browser without resolving it against your own order. # Transactions and reconciliation Source: https://docs-demo.zahlo.eu/guides/transactions Payments describe the customer-facing charge lifecycle. Transactions describe the resulting money movement. Keep both IDs and reconcile them to your order, currency, and amount. ## Retrieve a transaction ```javascript const response = await fetch( "https://merchant-demo.zahlo.eu/api/v1/transactions?limit=50&offset=0", { headers: { "x-api-key": process.env.ZAHLO_API_KEY } }, ); const page = await response.json(); for (const transaction of page.data) { await reconcileTransaction(transaction); } ``` Run reconciliation from a durable job, not from a browser return page. Store the last successful offset or checkpoint, but make the job safe to replay because new events can arrive while a page is being processed. ## Reconciliation checks Compare payment ID, transaction ID, order ID, amount, currency, and final status. Record unmatched, duplicated, or out-of-order records for manual review. Never “repair” a mismatch by creating a second charge. ## Refunds and balances A refund may remain pending after the request is accepted. Reconcile the final refund event and update the order once. Use the balance endpoint for operational reporting; do not use a displayed balance as proof that an individual customer payment was fulfilled. # Tax and shipping Source: https://docs-demo.zahlo.eu/guides/shipping-and-tax Zahlo does not calculate tax or shipping. Work out the final amount on your server, including any tax and delivery charges, and send that tax-inclusive total as `amountMinor` when you create a checkout session. ## Create a session with the final total ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-sessions" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{"mode":"hosted","amountMinor":5489,"currency":"EUR","metadata":{"orderId":"order_1001","taxMinor":"877","shippingMinor":"499"}}' ``` ## Keep the breakdown yourself - Recompute totals on the server whenever the cart, destination, or currency changes. - Store the tax and shipping breakdown with your order; keep it in `metadata` if you want it on the payment record. - Issue your own invoices and tax documents from your order data. # Payment disputes Source: https://docs-demo.zahlo.eu/guides/disputes A dispute (chargeback) is a customer's bank reversing a card payment. You have until `evidenceDueBy` to defend it with evidence or accept it. After the deadline it is lost. ## Lifecycle | `status` | Meaning | What to do | |---|---|---| | `needs_response` | Open, waiting for you. Also used when a lost or won dispute is reopened (a second chargeback or pre-arbitration). | Defend or accept before `evidenceDueBy`. | | `under_review` | You submitted evidence; the bank is deciding. | Wait for `dispute.closed`. | | `won` | Decided in your favour (or reversed after a loss). | Nothing. | | `lost` | Decided against you, or the deadline passed without a response. | Nothing; the amount is not returned. | | `accepted` | You accepted the dispute. | Nothing. | ## Timing Zahlo checks for new disputes every 15 minutes, so a new dispute can reach you up to 15 minutes after the bank opened it. Zahlo also emails the account owner when a dispute opens, and 3 days and 1 day before `evidenceDueBy` while it still needs a response. ## Events Subscribe a webhook endpoint to these events (see [Webhooks](/operations/webhooks)): - `dispute.created`: a new dispute. - `dispute.updated`: the status or deadline changed, for example a reopened dispute with a new `evidenceDueBy`. - `dispute.closed`: the dispute was won, lost or accepted. A loss that is later reversed sends a second `dispute.closed` with `status: "won"`. `data.object` is the [Dispute](/api-reference) object: `id`, `paymentId`, `amountMinor`, `currency`, `status`, `reason`, `evidenceDueBy`. Each event is sent once per change, however often Zahlo re-reads the dispute. ## List disputes ```javascript const response = await fetch("https://merchant-demo.zahlo.eu/api/v1/disputes", { headers: { "x-api-key": process.env.ZAHLO_API_KEY }, }); const { data } = await response.json(); ``` ## Respond with evidence Pick a defense reason, then upload the documents that reason requires (PDF up to 2 MB, JPEG or TIFF up to 10 MB, base64-encoded). Send an `Idempotency-Key`: a retry with the same key never submits twice. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/disputes/PSP_REFERENCE/respond" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: respond-PSP_REFERENCE" \ -H "Content-Type: application/json" \ -d '{"evidence":{"defenseReasonCode":"GoodsOrServicesProvided","documents":[{"defenseDocumentTypeCode":"DeliveryConfirmation","fileName":"delivery.pdf","contentType":"application/pdf","contentBase64":"JVBERi0..."}]}}' ``` Evidence tips: send what proves this specific order (delivery confirmation with address, customer messages, usage logs for digital goods). Don't send card data or unrelated customer data. A defended dispute is not guaranteed to be won. ## Accept a dispute Accepting is final: the dispute closes as `accepted` and can't be defended later. Accept in the dashboard: open the dispute under **Disputes** and choose **Accept dispute**. The API has no accept call; a dispute you don't defend before `evidenceDueBy` is lost. In the dashboard, open **Disputes** to see the deadline countdown, upload evidence or accept. # Fraud and risk rules Source: https://docs-demo.zahlo.eu/guides/fraud ## 3D Secure Card payments in the EEA need strong customer authentication. 3D Secure runs on the checkout page when the card issuer asks for it; you don't need to do anything. A declined payment's reason is on the Payment as `outcome.providerReason`. ## Risk rules Set rules in the dashboard (**Settings → Risk rules**) or with the API (`/api/v1/fraud-rules`): | `kind` | `value` | Blocks | |---|---|---| | `block_email` | an email address | checkouts for that customer email | | `block_email_domain` | a domain, e.g. `example.com` | checkouts for emails at that domain | | `max_amount` | minor units, with `currency` | checkouts above the amount | | `min_amount` | minor units, with `currency` | checkouts below the amount | | `velocity_email_hour` | a number | more than that many checkouts per customer email per hour | ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/fraud-rules" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: rule-max-500" \ -H "Content-Type: application/json" \ -d '{"kind":"max_amount","value":"50000","currency":"EUR"}' ``` **Email rules need an email.** They use the `customerEmail` (or the customer's email) you send when you create the checkout session. Without one they are skipped, not passed; amount rules still apply. Payment links always collect the email first. ## When a payment is blocked - **At checkout creation (guaranteed):** `POST /api/v1/checkout-sessions` answers `402` with `type: "payment_blocked"` and code `rule_`. No payment page is created. The response never reveals the rule's value. - **Right before payment (best effort):** The checkout asks Zahlo once more just before the customer pays. If a rule now matches, the customer sees "This payment is no longer available". The payment goes through if Zahlo doesn't answer in time or is unreachable, and a page that was already open can still be paid after the session expired. Such payments are recorded normally; refund them if needed. - **Too many attempts:** payment links allow 10 checkouts per hour from one network address, 300 per link and 1,000 per merchant; a checkout allows 5 payment attempts. Over the limit the customer is asked to try again later. Every block is listed under **Blocked attempts** in the dashboard. Zahlo stores a keyed hash of the email, never the email or the network address itself. ## What Zahlo does not do Zahlo doesn't score payments for risk, run device fingerprinting or machine-learning fraud models, or guarantee that a blocked customer can't pay another way. The payment processor applies its own checks independently. # Build subscriptions Source: https://docs-demo.zahlo.eu/guides/subscriptions ## 1. Create a recurring price ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/prices" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Content-Type: application/json" \ -d '{"productId":"prod_demo_1001","currency":"EUR","unitAmountMinor":1200,"type":"recurring","recurring":{"interval":"month","trialDays":14}}' ``` `interval` is `month`, `quarter` or `year`. Use `intervalMonths` (1–36) for anything else. ## 2. Start a subscription checkout A subscription checkout takes exactly one recurring price, card as its payment method, and the customer's email. ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/checkout-sessions" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: signup-account-1001" \ -H "Content-Type: application/json" \ -d '{"type":"subscription","lineItems":[{"priceId":"price_demo_1001","quantity":1}],"paymentMethodTypes":["card"],"customerEmail":"buyer@example.com","couponId":"coupon_demo_1001","successUrl":"https://shop.example/welcome","metadata":{"accountId":"account_1001"}}' ``` - **With a trial**, nothing is charged now. The customer saves a card, and the session completes with `paymentStatus: "no_payment_required"`. The first charge happens when the trial ends. - **Without a trial**, the first period is paid in the checkout. A payment link with one recurring price starts the same checkout. ## 3. Grant access from events Create a webhook endpoint for `subscription.*` events and decide access from the subscription in the event (or from `GET /subscriptions/:id`). Do not decide it from the browser return. See [SaaS entitlements](/guides/saas-entitlements). ## 4. Create subscriptions from the API When the customer already has a saved card (from an earlier subscription checkout), create further subscriptions server-side: ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/subscriptions" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: addon-account-1001" \ -H "Content-Type: application/json" \ -d '{"customerId":"cus_demo_1001","priceId":"price_demo_1002"}' ``` Without a trial, the first period is charged immediately. If that charge fails, the subscription stays `incomplete`. A customer without a saved card gets `400 payment_method_required`. ## Test it In the sandbox, `POST /test-helpers/subscriptions/:id/advance` moves a subscription to its next billing event. Pass `{"to": ...}` with one of `trial_will_end`, `renewal_succeeded`, `renewal_failed`, `renewal_requires_action`, `sepa_returned` or `period_end`. The real subscription events and customer emails follow. Nothing is charged. # Manage subscriptions Source: https://docs-demo.zahlo.eu/guides/subscription-management Every action is idempotent: send an `Idempotency-Key`, and a retry returns the same result. ## Change price or quantity ```javascript await fetch(`${API_URL}/api/v1/subscriptions/${subscription.id}`, { method: "PATCH", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "Idempotency-Key": `plan-change-${subscription.id}-${change.id}`, }, body: JSON.stringify({ priceId: "price_demo_1002", quantity: 3 }), }); ``` The change is scheduled for the end of the current period. The subscription shows it in `pendingUpdate` until then, and there is no proration. The new price must have the same interval and currency. Sending the same change again changes nothing. ## Cancel `POST /subscriptions/:id/cancel` cancels at the end of the current period by default. During a trial, it cancels at the trial end. The subscription keeps its status, with `cancelAtPeriodEnd: true`, until then. `subscription.cancelled` follows when it ends. Send `{"atPeriodEnd": false}` to end the subscription now. ## Resume `POST /subscriptions/:id/resume` does one of two things: - It withdraws a cancellation at period end. - It ends a pause. Paused time is never billed. Billing restarts with the period that contains the resume date. ## Pause `POST /subscriptions/:id/pause` stops collection. No new periods are billed while the subscription is paused, and its billing anchor does not move. # SaaS entitlements Source: https://docs-demo.zahlo.eu/guides/saas-entitlements Store the subscription from each `subscription.*` event, keyed by its `id`, and decide access from the stored copy: ```javascript const GRACE_DAYS = 14; // your merchant setting function hasAccess(subscription, now = new Date()) { if (subscription.status === "trialing" || subscription.status === "active") { return true; } if (subscription.status === "past_due" && subscription.pastDueSince) { const since = new Date(subscription.pastDueSince); return now - since < GRACE_DAYS * 86_400_000; } return false; } ``` ## Handle events idempotently - Events can arrive more than once and out of order. Upsert the subscription by `id`, and ignore an event whose `createdAt` is older than the copy you stored. - Grant access on `subscription.created` for a trial, and on `subscription.created` or `subscription.renewed` for a paid start. - Re-evaluate access on every `subscription.updated`, `subscription.past_due` and `subscription.cancelled`. - When in doubt, read `GET /subscriptions/:id`; it is always current. ## Which events to store | Event | Use it to | |---|---| | `subscription.created` | create the account's subscription record | | `subscription.renewed` | extend paid-through to `currentPeriodEnd` | | `subscription.past_due` | warn the customer, start your grace timer | | `subscription.trial_will_end` | remind the customer before the first charge | | `subscription.updated` | apply cancellation, pause or plan changes | | `subscription.cancelled` | revoke access at `cancelledAt` | # JavaScript SDK Source: https://docs-demo.zahlo.eu/integrations/javascript-sdk The Zahlo SDK provides browser-friendly hosted and embedded checkout helpers. Use the UMD build for a script tag or the ESM build for a modern bundler. The browser SDK is a presentation and secure-collection boundary; it is not a replacement for your server integration. Install the package from npm: ```bash npm install @zahlo/sdk ``` ## Server responsibilities Your server creates the payment or checkout session with `x-api-key`, validates the order, and returns only the session ID and short-lived client token needed by the browser. Do not return the API key, webhook secret, raw order total from an untrusted request, or any provider credential. ## Browser responsibilities The browser mounts checkout, displays neutral status, handles customer interaction, and navigates to your return page. It never receives the merchant API key or provider credentials. ```html
``` ## Appearance and secure payment fields Pass `appearance` to style the Zahlo-owned shell. Supported fields are `theme` (`light`, `dark`, `auto`), `locale` (`en`, `de`), `merchantName`, relative or HTTPS `logoUrl`/`iconUrl`, hex `colors` (`primary`, `background`, `text`, `muted`), `fontFamily`, `spacing` (`compact`, `normal`, `comfortable`), and shell-scoped `customCss` up to 4,000 characters. The existing top-level `theme` and `locale` options remain compatibility aliases. Custom CSS and branding apply to the checkout shell. They cannot read or style secure payment fields. Keep field styling within the supported `appearance` options and do not target internal selectors. For TypeScript, import the ESM package from `@zahlo/sdk`: ```typescript const checkout = new ZahloCheckout({ apiUrl: "https://merchant-demo.zahlo.eu", sessionId, clientToken, appearance: { locale: "en", theme: "auto", colors: { primary: "#087A60" } }, }); await checkout.mount("#zahlo-checkout"); ``` Use `destroy()` when the checkout page unmounts. Keep callbacks presentation-only and use signed webhooks for fulfillment. A `complete` callback means the customer interaction finished; it does not by itself prove the payment was confirmed. ## Status handling Render `pending` as in progress, `confirmed` as successful after your server confirms it, and `failed`, `cancelled`, or `expired` as recoverable or terminal according to the session. For asynchronous methods, keep the order pending after the customer returns and poll or process a verified event on the server. # Next.js integration Source: https://docs-demo.zahlo.eu/integrations/nextjs Create checkout sessions in a Route Handler or server action. Return a narrow DTO containing the session ID, client token, and expiry; never serialize the merchant API key into a client component. ## Route Handler ```typescript export async function POST(request: Request) { const order = await resolveOrder(await request.json()); const idempotencyKey = `checkout-${order.id}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch( "https://merchant-demo.zahlo.eu/api/v1/checkout-sessions", { signal: controller.signal, method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY!, "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ mode: "embedded", amountMinor: order.totalMinor, currency: order.currency, method: "card", orderId: order.id, }), } ); const body = await response.json(); return Response.json(body, { headers: { "Cache-Control": "no-store" }, status: response.status, }); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { return Response.json( { error: { code: "timeout", message: "Retry with the same Idempotency-Key.", retryable: true, }, }, { status: 504 } ); } throw error; } finally { clearTimeout(timeout); } } ``` ## Client component Load the SDK in a client component, mount after the server response, and destroy it on unmount. Set `Cache-Control: no-store` on token responses and do not put them in a permanent cache, URL, analytics event, or error message. ## Webhooks Handle signed events in a Route Handler that reads the raw body. Keep webhook fulfillment independent of the browser component and use a durable store for event IDs. ```typescript export async function POST(request: Request) { const rawBody = await request.text(); const header = request.headers.get("zahlo-signature") ?? ""; const parts = header.split(",").map((part) => part.trim().split("=")); const t = parts.find(([key]) => key === "t")?.[1] ?? ""; const expected = createHmac("sha256", process.env.ZAHLO_WEBHOOK_SECRET!) .update(`${t}.${rawBody}`) .digest(); const fresh = /^\d+$/.test(t) && Math.abs(Date.now() / 1000 - Number(t)) <= 300; const valid = fresh && parts.some(([key, value]) => { if (key !== "v1") return false; const received = Buffer.from(value ?? "", "hex"); return ( received.length === expected.length && timingSafeEqual(received, expected) ); }); if (!valid) return new Response("Unauthorized", { status: 401 }); const event = JSON.parse(rawBody); await recordOnceAndEnqueue(event.id, event); return Response.json({ received: true }); } ``` See [Webhook operations](/operations/webhooks) for the signature scheme, retries and local testing. The maintained snippet `snippets/merchant-nextjs-route.ts` in the repository keeps the API key server-side, returns `X-Request-Id` to the caller, and never treats a browser callback as fulfillment authority. # Python integration Source: https://docs-demo.zahlo.eu/integrations/python Use a single server-side client that sets the environment origin, API key, timeout, idempotency key, and structured error handling. Keep the client out of browser-rendered code. The maintained example uses only Python's standard library. The same request contract applies when an existing service uses `requests` or another HTTP client: keep the API key server-side and reuse the same `Idempotency-Key` when a create response is ambiguous. ## Requests Create hosted sessions from a server-side process. The maintained example sets the API origin, API key, timeout, idempotency key, and structured error handling in one place. ## Create a hosted session ```python API_URL = os.environ.get("ZAHLO_API_URL", "https://merchant-demo.zahlo.eu") API_KEY = os.environ["ZAHLO_API_KEY"] idempotency_key = "checkout-order-1001" request = urllib.request.Request( f"{API_URL}/api/v1/checkout-sessions", data=json.dumps({ "mode": "hosted", "amountMinor": 4990, "currency": "EUR", "method": "card", "orderId": "order-1001", "successUrl": "https://store.example/checkout/success", "cancelUrl": "https://store.example/checkout/cancelled", }).encode(), headers={"Content-Type": "application/json", "x-api-key": API_KEY, "Idempotency-Key": idempotency_key}, method="POST", ) try: with urllib.request.urlopen(request, timeout=10) as response: checkout = json.load(response) except (TimeoutError, urllib.error.URLError) as error: # Reuse idempotency_key to retrieve/reconcile; do not create a new payment. raise RuntimeError("retryable checkout error") from error except urllib.error.HTTPError as error: body = json.loads(error.read().decode() or "{}") retryable = error.code == 429 or error.code >= 500 raise RuntimeError(body.get("error", "merchant API error")) from error ``` Reuse the same idempotency key after a timeout, then retrieve the resulting session before deciding to retry. Verify webhook signatures over raw bytes before JSON parsing and return a fast success response after deduplication: See [Webhook operations](/operations/webhooks) for the `Zahlo-Signature` verification function and the timestamp check. The complete maintained snippets are `snippets/merchant-nextjs-route.ts` and `snippets/merchant-python-client.py` in the repository. A standard-library self-check covers idempotent retry behavior. # WooCommerce Source: https://docs-demo.zahlo.eu/integrations/woocommerce To set up a store step by step, follow the [WooCommerce quickstart](/quickstarts/ecommerce-woocommerce). This page is the reference. Download the plugin: [`zahlo-pay.zip`](https://merchant.zahlo.eu/downloads/zahlo-pay.zip) (version 2.x; also under **Integrations** in the dashboard). Install it under **Plugins → Add New Plugin → Upload Plugin**. ## Settings **WooCommerce → Settings → Payments → Zahlo Checkout**: | Setting | Meaning | |---|---| | Enable Zahlo Checkout | Shows the method at checkout. | | API key | `zk_test_…` (Sandbox, `https://merchant-demo.zahlo.eu`) or `zk_live_…` (live, `https://merchant.zahlo.eu`). The key selects the environment. | | Checkout | **Hosted** (redirect to Zahlo's payment page, default) or **Embedded** (payment fields on the order-pay page). | | Title, Description | What customers see at checkout. | Saving verifies the key, registers the webhook endpoint `/?wc-api=zahlo_webhook` and registers the store's origin as a checkout domain (with Apple Pay in Embedded mode on a public HTTPS domain). The plugin stores the endpoint's signing secret; you never copy it. `wp-config.php` constants for local or proxied stores: | Constant | Use | |---|---| | `ZAHLO_PAY_STORE_PUBLIC_URL` | The store's public address, for example an HTTPS tunnel in front of `localhost`. Used for the webhook URL, the checkout domain and return URLs. | | `ZAHLO_PAY_MERCHANT_API_URL` | Server-side API origin, when it differs from the one the key selects. | | `ZAHLO_PAY_MERCHANT_PUBLIC_API_URL` | Browser origin for `/sdk/v1/zahlo.js` in Embedded mode, when it differs. | ```php wp-config.php // A local store behind an HTTPS tunnel; save the plugin settings again afterwards. define('ZAHLO_PAY_STORE_PUBLIC_URL', 'https://your-tunnel.trycloudflare.com'); ``` ## Orders 1. At checkout the plugin creates one checkout session per order (`POST /api/v1/checkout-sessions`) with products, shipping and fees as separate lines, tax included, the order number as `reference` and the order ID in `metadata.wooOrderId`. A double submit reuses the same session; a changed cart gets a new one. 2. The order moves to **Processing** only on a signed `checkout.session.completed` event, or a server-side session check when the customer returns. The browser redirect alone never marks it paid. 3. Each event is applied once, even when delivered twice or out of order. A failed payment adds an order note and the customer can retry until the session expires; `checkout.session.expired` cancels an unpaid order. Subscribed events: `checkout.session.completed`, `checkout.session.expired`, `payment.confirmed`, `payment.failed`, `refund.succeeded`, `refund.failed` and the dispute events. ## Refunds and disputes Refunds from the order screen (full or partial) call `POST /api/v1/refunds`. Refunds made in the dashboard appear on the order through `refund.succeeded`. A dispute adds an order note; respond in the dashboard under **Disputes**. ## Upgrading from 1.x 2.0 replaces the four 1.x connection settings with one API key; the environment and origins come from the key prefix. It also removes the separate card and wallet gateways and product subscription plans. After upgrading, open the settings and click **Save changes** once: the plugin registers a new webhook endpoint and the checkout domain. ## Not supported WooCommerce Subscriptions. See [Not supported](/unsupported#woocommerce-subscriptions). ## Removal Deactivating the plugin keeps orders, settings and the processed-events table. Delete the store's webhook endpoint and checkout domain in the dashboard if you stop using Zahlo. # Environments Source: https://docs-demo.zahlo.eu/environments Zahlo has two separate environments. Each one has its own database, API keys, website origins, webhook endpoints and signing secrets, and its own payment account at our payment partner. Nothing carries over between them. | Environment | API origin | Use | | --- | --- | --- | | Sandbox | `https://merchant-demo.zahlo.eu` | Build and test your integration with test cards. No real money moves. | | Live | `https://merchant.zahlo.eu` | Real customers and real payments. | The documentation site automatically selects the matching API origin for its environment. Never mix a Sandbox API key with the live origin, or a live key with the Sandbox origin. ## Getting your Sandbox When Zahlo approves your business, we create your Sandbox account automatically and email you a personal link. Open it, set a password, and you land on a dashboard that is ready to use: no business verification is needed in the Sandbox. The "Get started" list on the home page walks you through creating an API key, adding your website origin and a webhook endpoint, and making a test payment (see [Testing](/testing)). The Sandbox gets new Zahlo features as they are released to it, so it can run slightly ahead of live. ## Environment checklist - Keep a separate API key for each environment. - Register website origins separately. - Use separate webhook endpoints and signing secrets. - Use test customer and order data in the Sandbox. - Confirm the selected origin before creating a payment or subscription. ## Going live IDs, keys and secrets created in the Sandbox do not exist in live. Follow [Go live](/go-live) to recreate what your integration uses. Never copy credentials or customer records between environments. # Authentication Source: https://docs-demo.zahlo.eu/authentication Keep the merchant API key in server-only configuration. Browser code receives only the short-lived client token returned by a trusted server endpoint. ## Merchant API keys Send the merchant API key in the `x-api-key` header from trusted server code: ```bash curl https://merchant-demo.zahlo.eu/api/v1/payment-methods \ -H "x-api-key: $ZAHLO_API_KEY" ``` Do not put merchant API keys in browser JavaScript, mobile bundles, source control, URLs, analytics events, or client-visible HTML. Rotate a key immediately if it may have been exposed. ## Client tokens Checkout session creation returns a short-lived client token for the browser. Send it as `Authorization: Bearer ` when reading or continuing that session. Client tokens are scoped to one session and expire automatically. ## Headers | Header | Used for | | -------------------------------- | -------------------------------------- | | `x-api-key` | Trusted merchant server requests | | `Authorization: Bearer ...` | Browser access to one checkout session | | `Idempotency-Key` | Safe retries of create requests | | `Content-Type: application/json` | JSON request bodies | ## Key lifecycle Create separate Demo and production keys, store them in a secret manager, restrict access to the server process, rotate them on a schedule, and remove old keys after all integrations have migrated. # Testing Source: https://docs-demo.zahlo.eu/testing ## Test the full lifecycle Every integration should exercise: 1. Session or payment creation. 2. Customer redirect or embedded checkout. 3. Successful confirmation. 4. Decline or failure. 5. Customer cancellation. 6. Session expiry. 7. Duplicate create request with the same idempotency key. 8. Duplicate and delayed webhook delivery. 9. Refund and subscription cancellation. ## What to assert - The expected amount, currency, order ID, and metadata are retained. - The API rejects invalid amounts, methods, origins, tokens, and webhook signatures. - A retry with an idempotency key does not create a second payment. - A browser return does not fulfill an order by itself. - Webhook processing is safe to repeat. - Fulfillment happens only after a verified confirmation event. ## Local development Use the repository’s local merchant and integration test commands from the repository root. Keep test credentials and database URLs outside committed files. Use a disposable database for integration tests. # Go live Source: https://docs-demo.zahlo.eu/go-live Your live account starts empty. Sandbox IDs, API keys and webhook signing secrets do not work in live. Recreate what your integration uses, switch its configuration, then take one small real payment. The **Go live** page in your live dashboard (`https://merchant.zahlo.eu/merchant/go-live`) shows each item's status. ## Recreate in live 1. **API key**: create a live key, then set `ZAHLO_API_KEY` to it and `ZAHLO_API_BASE` to `https://merchant.zahlo.eu`. 2. **Catalog**: create the same products, prices and coupons in live. Reference prices by `lookupKey` and coupons by your own ID or code, so your code needs no ID changes between environments. 3. **Payment links**: create them again. Live links have new URLs. 4. **Webhook endpoint**: add your live endpoint and set `ZAHLO_WEBHOOK_SECRET` to its new signing secret. The Sandbox secret is rejected in live. Confirm that one live event arrives. 5. **Website origins and domains**: register your website origin in live. Checkout domains and Apple Pay domains are registered again for your live payment account. 6. **Customers, subscriptions and saved payment methods** don't carry over: they are test data. Live ones are created by real checkouts. Return URLs (`successUrl`, `cancelUrl`) stay the same if your site is the same. ## Before launch - Your business is verified and payments are enabled for your live account. - Signature verification and event deduplication are deployed. - Fulfillment waits for authoritative payment confirmation. - Refund, cancellation and failed-payment paths are tested in the Sandbox. - Monitoring captures request IDs, event IDs and terminal failures without secrets. Keep the switch atomic so one integration never sends mixed-environment requests. ## First live payment Take a small live payment, check that `payment.confirmed` arrives on your live endpoint and that fulfillment runs, then refund it from the dashboard. Keep the Sandbox for integration testing and incident reproduction. # Webhook operations Source: https://docs-demo.zahlo.eu/operations/webhooks Zahlo sends each event as an HTTPS `POST` with a JSON body to every endpoint subscribed to it. Store the endpoint's signing secret (`whsec_…`) in a secret manager and never expose it to the browser. The secret is shown once, when you create the endpoint or rotate its secret. ## Request headers | Header | Meaning | | --- | --- | | `Zahlo-Signature` | `t=,v1=` | | `Zahlo-Event-Id` | The event ID. Identical on every retry of the same event; use it to deduplicate. | | `Zahlo-Event-Type` | The event type, for example `payment.confirmed`. | ## Verify the signature The signature is a hex HMAC-SHA256, keyed with the signing secret, over the string `t + "." + rawBody`, where `t` is the timestamp from the header and `rawBody` is the exact bytes received. 1. Split the header on `,` and read `t` and every `v1`. 2. Recompute the HMAC and compare it with each `v1` using a constant-time comparison. Accept if any one matches. Right after a secret rotation the header carries two `v1` values (new and previous secret) for 24 hours. 3. Reject the request when `|now − t|` is more than 300 seconds, so captured requests cannot be replayed. 4. Parse the JSON only after the checks pass. ### Node.js ```javascript export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) { const parts = (header ?? "").split(",").map((part) => part.trim().split("=")); const t = parts.find(([key]) => key === "t")?.[1]; const signatures = parts .filter(([key]) => key === "v1") .map(([, value]) => value); if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest(); return signatures.some((signature) => { const received = Buffer.from(signature, "hex"); return ( received.length === expected.length && timingSafeEqual(received, expected) ); }); } ``` ### PHP ```php function verify_webhook(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool { $t = null; $signatures = []; foreach (explode(',', $header ?? '') as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $t = $value; } if ($key === 'v1') { $signatures[] = $value; } } if ($t === null || !ctype_digit($t) || !$signatures) { return false; } if (abs(time() - (int) $t) > $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } // $rawBody = file_get_contents('php://input'); // $header = $_SERVER['HTTP_ZAHLO_SIGNATURE'] ?? null; ``` ### Python ```python def verify_webhook(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool: t, signatures = None, [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") if key == "t": t = value elif key == "v1": signatures.append(value) if t is None or not t.isdigit() or not signatures: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures) ``` ### Use the raw body Verification fails if the body was parsed and re-serialized. Read the raw bytes first: - **Next.js route handler:** `const rawBody = await request.text();` before any `JSON.parse`. - **Express:** mount `express.raw({ type: "application/json" })` on the webhook route, not `express.json()`. `req.body` is then a `Buffer`; pass `req.body.toString("utf8")`. - **PHP:** `file_get_contents('php://input')`. **Python (Flask):** `request.get_data()`. ## Delivery guarantees Delivery is at-least-once and unordered. - The same event can arrive more than once. Insert `Zahlo-Event-Id` into a unique store before applying effects, and acknowledge duplicates without repeating them. - Events can arrive out of order. When order matters, use the event as a trigger and fetch the object (`data.object.id`) to read its current state. - Return a `2xx` within 10 seconds. Anything else, a timeout or a redirect counts as a failure. Queue slow work and acknowledge first. ## Retries and disabling A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours and 24 hours (eight retries, about 2.8 days), then marked failed. An endpoint that has been failing for 3 days is disabled and we email you. Re-enable it in the dashboard (Developers, Webhooks) or with `PATCH /api/v1/webhook-endpoints/{id}` and `{"enabled":true}`. Deliveries that failed are not sent again automatically after you re-enable it: retry them from the dashboard or with `POST /api/v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry`. ## Test events Send a test event from the dashboard (Developers, Webhooks, Send test event) or with `POST /api/v1/webhook-endpoints/{id}/test`. A test event has an ID starting with `evt_test_`, `livemode: false` and sample data in the shape of the event type. It is sent once, never retried and not stored, so it does not appear in `GET /api/v1/events`. The response shows your receiver's HTTP status. ## Test webhooks locally Endpoints must be `https://` and reachable from the internet, so `localhost` does not work directly. Open a quick tunnel to your dev server: ```bash cloudflared tunnel --url http://localhost:3000 ``` Register the printed `https://….trycloudflare.com/your/webhook/path` URL as an endpoint, then send a test event from the dashboard. Delete the endpoint when you are done; the tunnel URL changes every run. ## Handler checklist - Read the raw body before parsing. - Verify the signature and the timestamp tolerance with the endpoint secret. - Reject malformed or unauthorized requests with `400` or `401`. - Insert `Zahlo-Event-Id` into a unique store before applying effects. - Queue long-running work and acknowledge quickly. - Alert on repeated failures: the Deliveries table shows the last response status and error. ## Reconciliation If an event was missed or your handler was down, list events with `GET /api/v1/events` (kept for 30 days) and reconcile the affected resource. Do not create a replacement payment just because delivery was delayed. # Configure webhook endpoints Source: https://docs-demo.zahlo.eu/operations/webhook-endpoints An endpoint is an `https://` URL on your server that receives signed events. Zahlo sends it every event that matches its `enabledEvents`. You can also manage endpoints in the dashboard under Developers, Webhooks. ## Create an endpoint ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: webhook-orders-v1" \ -H "Content-Type: application/json" \ -d '{"url":"https://store.example/api/zahlo/events","enabledEvents":["payment.confirmed","refund.succeeded","subscription.renewed"]}' ``` `enabledEvents` lists event types from the [event catalog](/operations/events), or `["*"]` for every event; an unknown name returns 400. The response includes `status` (`enabled` or `disabled`) and a `secret`. The secret appears only in this response and in the rotate-secret response: store it in a secret manager and never send it to the browser. ## Rotate the signing secret ```bash curl -X POST "https://merchant-demo.zahlo.eu/api/v1/webhook-endpoints/we_123/rotate-secret" \ -H "x-api-key: ${ZAHLO_API_KEY}" \ -H "Idempotency-Key: rotate-we-123-2026-10" ``` The response returns the new `secret`. For 24 hours Zahlo signs each request with both the new and the previous secret (two `v1` values), so you can deploy the new secret without dropping events. A second rotation inside those 24 hours ends the first overlap early. ## Test, change and disable - `POST /api/v1/webhook-endpoints/{id}/test` with `{"type":"payment.confirmed"}` sends a test event and returns the receiver's status. See [Webhook operations](/operations/webhooks#test-events). - `PATCH /api/v1/webhook-endpoints/{id}` changes `url`, `enabledEvents`, `description` or `enabled` (set `enabled` to false to disable the endpoint). When changing the URL, deploy the receiver first. - `DELETE /api/v1/webhook-endpoints/{id}` removes the endpoint. ## Deliveries and retry `GET /api/v1/webhook-endpoints/{id}/deliveries` lists attempts with `eventType`, `status` (`pending`, `sending`, `succeeded`, `failed`), `attempts`, `lastResponseStatus`, `lastError` and `nextAttemptAt`. `POST /api/v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry` queues one more attempt of the same event, whatever its status. It does not restart the retry schedule. A delivery being sent right now returns 409 `delivery_in_progress`, and a disabled endpoint returns 409 `endpoint_disabled`. ## Handler contract Verify the signature over the raw body, deduplicate on `Zahlo-Event-Id`, enqueue slow work and acknowledge with a `2xx`. See [Webhook operations](/operations/webhooks) for verification code, retries and reconciliation. # Event catalog Source: https://docs-demo.zahlo.eu/operations/events Subscribe to the smallest event set that supports your business flow. The table below is the complete catalog; no other event types exist. The [webhook event reference](/api-reference/webhooks) shows each payload. ## Event envelope ```json { "id": "evt_demo_1001", "object": "event", "type": "payment.confirmed", "livemode": false, "createdAt": "2026-09-02T12:00:00Z", "data": { "object": { "id": "pay_demo_1001", "object": "payment", "status": "succeeded" } } } ``` Treat `id` as the deduplication key (it is also sent as the `Zahlo-Event-Id` header). Treat `type` as a routing key and `data.object.id` as the resource to retrieve when your handler needs authoritative details. ## Fulfillment mapping | Event | `data.object` | When | | --- | --- | --- | | `checkout.session.completed` | checkout_session | The session's payment succeeded. Fulfil one-time orders here. | | `checkout.session.expired` | checkout_session | The session expired unpaid. | | `customer.created` | customer | A customer was created. | | `customer.updated` | customer | A customer's details changed. | | `dispute.closed` | dispute | A dispute was won, lost or accepted. | | `dispute.created` | dispute | The customer's bank opened a dispute on a payment. | | `dispute.updated` | dispute | A dispute changed status or evidence deadline. | | `payment.cancelled` | payment | A payment was cancelled before completion. | | `payment.confirmed` | payment | A payment succeeded, including subscription renewals (`subscriptionId` set). Not a fulfilment signal for one-time checkout: use `checkout.session.completed`. | | `payment.failed` | payment | A payment attempt failed; the session stays open for another attempt. | | `refund.created` | refund | A refund was requested; it is `pending` until it succeeds or fails. | | `refund.failed` | refund | A refund was rejected; the amount is available to refund again. | | `refund.succeeded` | refund | A refund reached the customer. | | `subscription.cancelled` | subscription | A subscription ended (requested or after failed payments). | | `subscription.created` | subscription | A subscription started (`trialing` or `active`). Grant access here. | | `subscription.past_due` | subscription | A renewal failed; the subscription is in its grace period. | | `subscription.payment_failed` | subscription | A renewal or first payment attempt failed. | | `subscription.renewed` | subscription | A period was paid. Extend access. | | `subscription.trial_will_end` | subscription | The trial ends in 3 days. | | `subscription.updated` | subscription | Status, price, quantity, payment method or cancel-at-period-end changed. Re-fetch the subscription. | Verify the signature over the raw body. Then, in **one** database transaction, insert the event ID into a table with a unique constraint and apply the business change; return `2xx` only after the commit. Never record the event ID, answer `200` and do the work afterwards: a crash in between loses the order. Work that cannot join the transaction (emails, calls to other systems) goes into a job row inside that transaction. Re-fetch the object before acting, so an older event arriving late cannot undo a newer state. ## List and retrieve events `GET /api/v1/events` lists your events, newest first, with the filters `type`, `createdGte` and `createdLte`. `GET /api/v1/events/{id}` returns one event. Events are kept for 30 days; use them to reconcile after downtime. Test events are not stored. # Errors and retries Source: https://docs-demo.zahlo.eu/operations/errors Errors include a machine-readable error code, a human-readable message, and when available a request ID and field details. Log the request ID and your own operation ID, never the API key, client token, webhook secret, or payment-field data. Redact response bodies before sending them to error tracking. ## Retry policy - Retry network failures and temporary server failures with bounded exponential backoff. - Reuse the same `Idempotency-Key` for a retried create request. - Do not retry validation or authentication errors without changing the request or credentials. - Treat an accepted request with an unknown response as pending until you reconcile it by ID. ## Common categories | Category | Action | | --- | --- | | `400` validation | Fix the request and do not retry unchanged | | `401` authentication | Check API key or client token scope | | `404` missing resource | Check the environment and resource ID | | `409` conflict | Retrieve current state and reconcile | | `429` rate limit | Back off and retry with the same idempotency key | | `5xx` temporary failure | Retry safely, then reconcile before creating again | ## Payment-specific outcomes An accepted checkout or payment request may remain `pending` while a customer completes a redirect or wallet approval. An invalid method or expired session requires a new customer action; an unknown network result requires retrieval by ID. Do not classify an ambiguous result as failed and charge again. ## Error codes Branch on `error.code`. Every error's `docUrl` links to its row below. | Code | Type | HTTP | Meaning | | --- | --- | --- | --- | | `amount_above_maximum` | `invalid_request_error` | 400 | The amount is above the method's or provider's maximum. | | `amount_below_minimum` | `invalid_request_error` | 400 | The amount is below the method's or provider's minimum. | | `amount_exceeds_subscription_limit` | `invalid_request_error` | 400 | A subscription charge must be between 0.50 and 5,000.00 in the currency. | | `api_key_expired` | `authentication_error` | 401 | The API key was rolled and its overlap has ended. | | `api_key_invalid` | `authentication_error` | 401 | The API key is unknown or revoked. | | `api_key_missing` | `authentication_error` | 401 | Send the key as `Authorization: Bearer ` or `x-api-key`. | | `attempt_limit` | `rate_limit_error` | 429 | Too many payment attempts; try again later. | | `body_invalid` | `invalid_request_error` | 400 | The request body is not valid JSON. | | `checkout_domain_not_registered` | `invalid_request_error` | 400 | Register the return URL's domain as a checkout domain first. | | `checkout_domain_pending` | `invalid_request_error` | 409 | Zahlo is still enabling checkout for this merchant. | | `checkout_domain_rejected` | `invalid_request_error` | 409 | Checkout isn't enabled for this merchant; contact support. | | `coupon_currency_mismatch` | `invalid_request_error` | 400 | The coupon's currency doesn't match the session currency. | | `coupon_duration_too_long` | `invalid_request_error` | 400 | The coupon's duration is longer than subscriptions support. | | `coupon_not_supported_for_subscriptions` | `invalid_request_error` | 400 | This coupon can't be used on a subscription. | | `coupons_unavailable` | `invalid_request_error` | 400 | Coupons aren't available yet. | | `delivery_in_progress` | `invalid_request_error` | 409 | The delivery is being sent right now. | | `domain_invalid` | `invalid_request_error` | 400 | The domain is not a valid HTTPS hostname. | | `endpoint_disabled` | `invalid_request_error` | 409 | The webhook endpoint is disabled; enable it first. | | `idempotency_key_invalid` | `invalid_request_error` | 400 | Idempotency-Key must be 1–255 printable ASCII characters. | | `idempotency_key_reused` | `idempotency_error` | 409 | The Idempotency-Key was already used with a different request. | | `internal_error` | `api_error` | 500 | Something went wrong on Zahlo's side. Retry with the same Idempotency-Key. | | `mandate_limit_exceeded` | `invalid_request_error` | 409 | The change is outside the accepted SEPA mandate terms. | | `no_payment_method_available` | `invalid_request_error` | 400 | No enabled payment method supports this currency and amount. | | `parameter_invalid` | `invalid_request_error` | 400 | A parameter is missing or invalid; `param` names it. | | `payment_method_required` | `invalid_request_error` | 400 | The customer has no saved payment method; collect one with Checkout first. | | `payment_method_unavailable` | `invalid_request_error` | 400 | The requested payment method isn't available for this merchant or currency. | | `permission_denied` | `permission_error` | 403 | The key can't access this resource. | | `provider_outcome_unknown` | `provider_error` | 502 | The payment provider didn't confirm the result. Retry with the same Idempotency-Key; Zahlo reconciles it. | | `provider_unavailable` | `provider_error` | 502 | The payment provider rejected or failed the request. | | `rate_limited` | `rate_limit_error` | 429 | Too many requests. Retry after the `Retry-After` header. | | `recurring_prices_unavailable` | `invalid_request_error` | 400 | Recurring prices aren't available yet. | | `request_in_progress` | `idempotency_error` | 409 | A request with this Idempotency-Key is still running. Retry after `Retry-After`. | | `resource_missing` | `invalid_request_error` | 404 | No such object for this merchant. | | `resource_not_available` | `invalid_request_error` | 404 | This resource isn't available yet. | | `route_not_found` | `invalid_request_error` | 404 | No such endpoint. | | `sepa_requires_first_debit` | `invalid_request_error` | 400 | SEPA subscriptions need a first debit; trials and free first periods aren't available on SEPA. | | `state_conflict` | `invalid_request_error` | 409 | The object's current state doesn't allow this action. | | `subscriptions_unavailable` | `invalid_request_error` | 400 | Subscription checkout isn't available yet. | | `trial_unavailable` | `invalid_request_error` | 400 | Trials aren't available for this payment method. | | `wrong_environment` | `authentication_error` | 401 | The key belongs to the other environment (sandbox vs live); the message names the right origin. | # Security and compliance Source: https://docs-demo.zahlo.eu/operations/security - Keep API keys, webhook secrets, and integration credentials in a managed secret store. - Use HTTPS for every production endpoint. - Allowlist website origins and return URLs. - Never log client tokens, API keys, webhook secrets, raw card numbers, security codes, or expiry data. - Keep payment fields on Zahlo-hosted or embedded secure surfaces. - Verify webhook signatures against the raw request body before parsing or processing. - Limit metadata to non-sensitive identifiers; do not put personal data or secrets in metadata. - Redact request and response bodies in error reporting where they may contain customer data. - Restrict dashboard roles and rotate credentials after personnel or system changes. ## Browser boundary Expose only a session ID and short-lived client token to the browser. The token must be scoped to the session and must not be placed in a query string, referrer, analytics event, or local-storage record. For in-page checkout, allowlist the exact origin and keep sensitive fields inside the secure component. ## Webhook boundary Verify the raw body and replay protection before parsing or enqueueing. Return a success response only after authenticity and schema checks pass. Keep the handler idempotent and use a durable queue for slow fulfillment, email, inventory, or entitlement work. # Reconciliation Source: https://docs-demo.zahlo.eu/operations/reconciliation Reconciliation is the recovery path when a browser return, API timeout, webhook delay, or out-of-order event leaves your local state uncertain. It must be safe to run repeatedly. ## Daily reconciliation 1. List payments, transactions, refunds, and disputes for the time window. 2. Match each record to your order or account using stored resource IDs and metadata. 3. Compare amount, currency, status, and timestamps. 4. Retrieve ambiguous resources by ID and record the request ID. 5. Queue unmatched or contradictory records for review. ```javascript const page = await listTransactions({ limit: 200, offset: checkpoint.offset }); for (const transaction of page.data) { await reconcileOnce(transaction.id, transaction.paymentId); } ``` ## Safe repairs Repair local projections from authoritative resources and verified events. Never create a replacement payment to repair an unknown result, never fulfill an amount that does not match your order, and never erase an unmatched record without an audit trail. # Limits Source: https://docs-demo.zahlo.eu/limits Limits apply equally in the Sandbox and in live, unless a row says otherwise. When a request breaks a limit, the API answers with the error code shown, so your code can branch on `error.code`. ## Currencies and payment methods | Payment method | Currencies | |---|---| | Cards (Visa, Mastercard and others) | EUR; CAD, CHF, DKK, GBP, JPY, PLN, RON and USD on request | | Apple Pay, Google Pay | EUR | | SEPA Direct Debit (subscriptions) | EUR only | Currencies marked "on request" are enabled per account; contact support. `GET /payment-methods` returns the methods enabled for **your** account; use it instead of hard-coding this table. SEK and NOK are not supported. ## Amounts | Limit | Value | Error code | |---|---|---| | Unit | integer `amountMinor` in the currency's minor unit (JPY has no minor unit) | `parameter_invalid` | | Minimum per payment | €1.00 (¥164); in other currencies, about the equivalent of €1.00 | `amount_below_minimum` | | Maximum per payment | €15,000.00; in other currencies, about the equivalent | `amount_above_maximum` | | Zero-total one-time checkout | not allowed (a 100% coupon on a one-time payment is rejected) | `amount_below_minimum` | | Line items per Checkout Session | 1-100 lines, quantity 1-999 | `parameter_invalid` | Discounts are spread across the line items, so the customer sees discounted line amounts, never a negative line. ## Subscriptions | Limit | Value | |---|---| | Billing interval | whole months, 1-36 (no daily or weekly intervals) | | Amount per charge | 0.50 to 5,000.00 in the major unit (`amount_exceeds_subscription_limit`) | | Failed card renewal | retried automatically up to 3 times, once a day | | Plan or quantity change | takes effect at the next period boundary; no proration | | `repeating` coupon | counts charged periods; a coupon below the payment minimum is rejected (`amount_below_minimum`) | | 100%-off `forever` coupon | rejected | | SEPA Direct Debit | EUR only; a debit is `processing` for several banking days and counts as active meanwhile; a later return starts the failed-payment process | ## Checkout Sessions | Limit | Value | |---|---| | Expiry (`expiresInSeconds`) | 1,800-86,400 seconds (30 minutes to 24 hours); default 24 hours | | What the customer sees | no countdown; an expired session shows "This checkout expired" with a link back to your site | | Fulfilment | only on `checkout.session.completed` | ## Checkout domains | Limit | Value | Error code | |---|---|---| | Return URLs | their host must be registered as a checkout domain | `checkout_domain_not_registered` | | `localhost` and `http://` | Sandbox only (`zk_test_` keys); live requires HTTPS | `domain_invalid` | | New accounts | checkout may be unavailable for a short time while Zahlo enables it for your account; registering a checkout domain takes effect immediately | `checkout_domain_pending` | | Apple Pay in embedded checkout | needs a real HTTPS host registered with `applePay: true`; a temporary tunnel host does not work | — | ## Webhooks | Limit | Value | |---|---| | Signature tolerance | reject when `t` is more than 300 seconds from your clock | | Response | any 2xx within 10 seconds counts as delivered | | Retries after a failure | 1 min, 5 min, 30 min, 2 h, 5 h, 10 h, 24 h, 24 h (about 2.8 days) | | Endpoint disabled | after 3 days with no successful delivery; you are emailed, and you can re-enable it and retry the failed deliveries | | Secret rotation | the previous secret keeps signing for 24 hours | | Event retention | 30 days (`GET /events`) | ## Requests | Limit | Value | Error code | |---|---|---| | API requests | 600 per minute per IP address | `rate_limit_error` (429) | | Payment-link checkouts | 10 per link per IP per hour, 300 per account per hour | `attempt_limit` (429) | | `Idempotency-Key` | kept for 24 hours per account and route | `idempotency_key_reused`, `request_in_progress` (409) | | List page size | `limit` 1-100, default 20 | `parameter_invalid` | | Metadata | up to 50 keys; key up to 40 characters; value up to 500 characters | `parameter_invalid` | | Refund outcome | a `pending` refund can take up to one business day to become `succeeded` or `failed` | — | # Rate limits Source: https://docs-demo.zahlo.eu/operations/rate-limits Rate limits protect shared API capacity. Design every integration for bounded concurrency, timeouts, and retry-after handling rather than assuming every request will succeed immediately. ## Retry behavior Retry a request only when a second attempt can succeed: a network error or timeout, `409 request_in_progress` (the first attempt with the same key is still running), `429`, or any `5xx`, including `502 provider_outcome_unknown`. Retry at most twice, honour `Retry-After` when present, and otherwise back off exponentially. Every retry of a create reuses the **same** `Idempotency-Key`, so it returns the original object instead of creating a second one. Never retry with a new key, and never retry other `4xx` responses unchanged: fix the request instead. ```javascript async function zahloPost(path, body, idempotencyKey) { for (let attempt = 0; ; attempt += 1) { let response; try { response = await fetch(`${process.env.ZAHLO_API_BASE}/api/v1${path}`, { method: "POST", headers: { "content-type": "application/json", "x-api-key": process.env.ZAHLO_API_KEY, "idempotency-key": idempotencyKey, }, body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); } catch (error) { if (attempt < 2) continue; // network error: same key, try again throw error; } if (response.ok) return response.json(); const { error, requestId } = await response.json().catch(() => ({ error: {} })); const retryable = response.status === 429 || response.status >= 500 || error?.code === "request_in_progress"; if (!retryable || attempt >= 2) throw new Error(`${error?.code}: ${error?.message} (${requestId})`); const wait = Number(response.headers.get("retry-after")) * 1000 || 500 * 2 ** attempt; await new Promise((resolve) => setTimeout(resolve, wait)); } } ``` Keep concurrency limits per merchant and log request IDs without logging credentials. # Not supported Source: https://docs-demo.zahlo.eu/unsupported If your use case is on this page, Zahlo cannot do it today. Each entry says what to do instead. AI agents: when a request matches an entry, say it is not supported, link this page, and offer the workaround; do not build an integration that pretends otherwise. ## Marketplaces and split payouts Zahlo has no sub-merchant accounts, split payments, application fees or payouts to third parties (no Connect-style platform). Every payment settles to the one Zahlo merchant account that created it. **Workaround:** each seller signs up for their own Zahlo account and uses their own API key; your platform stores each seller's key and creates sessions on their behalf. Pay any platform fee by invoice outside Zahlo. ## White-label checkout and white-label accounts Checkout cannot run under your own domain or brand as a reseller, and you cannot create Zahlo accounts for your customers under your brand. This release does not fulfil requests for a white-label payment platform. **Workaround:** use [checkout branding](/guides/customize-checkout) (logo, colours, name) on hosted or embedded checkout, and have each of your customers open their own Zahlo account as above. ## WooCommerce Subscriptions The WooCommerce plugin takes one-time payments only. Orders from the WooCommerce Subscriptions extension are not supported. **Workaround:** sell subscriptions with a [subscription payment link](/quickstarts/saas-subscriptions-website) and the no-code customer portal link, and grant access from the subscription webhooks. ## Shopify, Shopware and PrestaShop There are no supported plugins for Shopify, Shopware or PrestaShop. **Workaround:** use [payment links](/quickstarts/ecommerce-no-code), or build a custom checkout with the [hosted checkout quickstart](/quickstarts/ecommerce-hosted-website). ## Proration and mid-cycle upgrades A plan or quantity change takes effect at the next billing period. Zahlo does not prorate or charge the difference immediately. **Workaround:** to upgrade right away, cancel the subscription immediately and start a new one with the new price; grant access from the new subscription's events. ## Usage-based and metered billing Subscriptions charge a fixed price per period. Zahlo does not record usage or bill per unit consumed. **Workaround:** measure usage in your app and charge it with a separate one-time Checkout Session or payment link at the end of each period. ## Invoices, VAT and tax calculation Zahlo issues payment receipts, not VAT invoices, and does not calculate tax. Zahlo is not the merchant of record. **Workaround:** send tax-inclusive totals, and issue VAT invoices from your accounting system, triggered by `payment.confirmed`. ## Daily and weekly billing intervals Subscription intervals are whole months (1-36). **Workaround:** use a monthly price, or sell a fixed number of days as a one-time payment. ## SEK and NOK Swedish kronor and Norwegian kroner are not available. **Workaround:** price in EUR. See [Limits](/limits#currencies-and-payment-methods) for the currencies you can use. ## A webhook-forwarding CLI There is no CLI that forwards webhooks to your machine. **Workaround:** expose your local server with a temporary tunnel (for example `cloudflared tunnel --url http://localhost:3000`), register the tunnel's host as a checkout domain and its URL as a webhook endpoint in the Sandbox, and send a test event from the dashboard. ## In-person payments and terminals Zahlo has no card terminals or point-of-sale API. **Workaround:** for occasional in-person sales, show a payment link as a QR code. # Build with AI Source: https://docs-demo.zahlo.eu/ai Zahlo's documentation is written to be read by coding agents. Give your agent the docs and a Sandbox key, and it can build a working integration. ## Files for agents - [`/llms.txt`](https://docs-demo.zahlo.eu/llms.txt): an index of every page with a one-line description. Start here. - [`/llms-full.txt`](https://docs-demo.zahlo.eu/llms-full.txt): every page in one file, for agents that read everything up front. ## Copy-paste prompt Put your Sandbox secret key (it starts with `zk_test_`) in the environment variable `ZAHLO_API_KEY`, then give your agent this prompt, filling in the brackets: ```text Integrate Zahlo payments into this project. Read https://docs-demo.zahlo.eu/llms.txt first, then open "Choose your integration" and follow exactly the one quickstart that matches: business = [e-commerce | SaaS], checkout = [hosted | embedded | payment link], platform = [Next.js | plain website with PHP or Node | WooCommerce]. The Sandbox secret key is in the ZAHLO_API_KEY environment variable; never print it, commit it or send it to the browser. Follow every rule on https://docs.zahlo.eu/ai. If what I ask for is listed on https://docs.zahlo.eu/unsupported, tell me and offer the workaround instead of building it. When done, tell me how to make a test payment with a test card and what I should see. ``` ## Rules for generated integrations 1. **The secret key stays on the server.** Read it from `ZAHLO_API_KEY`. The browser only ever gets a session's `url` (hosted) or its `clientToken` (embedded). 2. **Verify every webhook** on the raw request body before parsing it, and reject a bad signature or a timestamp more than 300 seconds old with `400`. 3. **Dedupe and act in one transaction.** Insert the event id into a table with a unique constraint and apply the business change (mark the order paid, grant access) in the same database transaction; return `2xx` only after it commits. A duplicate event id returns `200` without work. Never record the event id, return `200`, and fulfil afterwards: a crash in between loses the order. 4. **Tolerate out-of-order events.** Re-fetch the object (`GET /checkout-sessions/:id`, `GET /subscriptions/:id`) and act on its current state instead of applying an older event's data. 5. **Fulfil one-time orders on `checkout.session.completed`**, never on `payment.confirmed` alone and never because the customer reached your success page. The success page shows "processing" until the webhook has run. 6. **Grant SaaS access from the subscription's status**: active while `trialing` or `active`, or `past_due` within the grace period. 7. **Handle reversals**: `refund.succeeded`, `dispute.created`, `subscription.payment_failed`, `subscription.past_due` and `subscription.cancelled`. 8. **Retry safely.** Send one `Idempotency-Key` per logical operation and reuse it on every retry. Retry only on a network error, `409 request_in_progress`, `429` or a `5xx` (including `provider_outcome_unknown`), at most twice, honouring `Retry-After`. Never retry other `4xx`, and never retry with a new key. 9. **Never hard-code object ids.** The Sandbox and live are separate accounts with different ids. Find prices with `GET /prices?lookupKey=…` and coupons by their code, and create customers at runtime. Treat every id as an opaque string. 10. **Register your checkout domains** (`POST /checkout-domains`) before testing; in the Sandbox this includes `localhost:`. 11. **Use `{CHECKOUT_SESSION_ID}`** in `successUrl` so your success page can retrieve the session with the secret key. 12. **Check [Not supported](/unsupported) and [Limits](/limits)** before promising a feature. # API Reference Source: https://docs-demo.zahlo.eu/api-reference/overview The API Reference tab contains the complete merchant API contract. Use the environment selector in the playground to send requests to Demo or production. The contract is organized around the same resources and lifecycle described in the guides. Before calling an endpoint: 1. Read [Authentication](/authentication) and keep merchant API keys on your server. 2. Choose the correct [environment](/environments). 3. Add an idempotency key to create requests that may be retried. 4. Treat [webhooks](/concepts/webhooks) as the source of truth for asynchronous state. Use the reference with the task guides: [checkout](/guides/checkout), [payment methods](/guides/payment-methods), [subscriptions](/guides/subscriptions), [refunds](/guides/refunds), and [webhook operations](/operations/webhooks). Each operation describes required fields, response status, retry behavior, and the next step in the lifecycle. # Request and response format Source: https://docs-demo.zahlo.eu/api-reference/request-format All merchant API requests use JSON under `/api/v1`. Resource IDs are opaque strings. Timestamps are ISO 8601 UTC values. Amounts are integer minor units paired with a three-letter currency code. ## Request headers ```http Content-Type: application/json x-api-key: $ZAHLO_API_KEY Idempotency-Key: order-1001-checkout ``` Use `x-api-key` only on the server. Use a bearer client token only for browser access to the session it belongs to. The API may return a `requestId`; retain it in safe operational logs. ## Response envelope Successful resource responses contain the resource object. List responses are `{ object: "list", data, hasMore }`; page with `limit` and `startingAfter`. Errors are `{ error: { type, code, message, docUrl, param? }, requestId }`. ## Null, omission, and updates An omitted field keeps its default or existing value. A field explicitly accepted as `null` clears it only where the operation documents that behavior. Patch requests should contain only fields the merchant intends to change. ## Versioning Keep the `/api/v1` path and use the documented API version header when the environment requires one. Treat unknown response fields as forward-compatible and do not break parsing when new fields appear. # Webhook event reference Source: https://docs-demo.zahlo.eu/api-reference/webhooks Webhook events contain an opaque event ID, event type, `livemode`, creation timestamp, and resource data. Each request carries the headers `Zahlo-Signature` (`t=…,v1=…`), `Zahlo-Event-Id` and `Zahlo-Event-Type`. The event ID is unique for deduplication; the resource ID is used to retrieve the latest authoritative state. Endpoint management is in [Configure webhook endpoints](/operations/webhook-endpoints) and event retention in the [event catalog](/operations/events). ## Payment events | Event | Resource | Merchant action | | --- | --- | --- | | `payment.confirmed` | Payment | Fulfill the matching order once | | `payment.failed` | Payment | Keep pending or show recovery | | `payment.cancelled` | Payment | Close the unpaid order | | `checkout.session.completed` | Checkout session | Fulfill the matching order once | | `checkout.session.expired` | Checkout session | Release reserved inventory | | `refund.created` | Refund | Record the pending refund | | `refund.succeeded` | Refund | Record the completed refund | | `refund.failed` | Refund | Restore the order state and notify support | | `dispute.created` | Dispute | Assign evidence work before the due date | | `dispute.updated` | Dispute | Track the evidence and review status | | `dispute.closed` | Dispute | Record the outcome | ## Billing and customer events | Event | Resource | Merchant action | | --- | --- | --- | | `subscription.created` | Subscription | Start the billing projection | | `subscription.updated` | Subscription | Apply plan or status changes | | `subscription.renewed` | Subscription | Grant or retain access for the next period | | `subscription.payment_failed` | Subscription | Start recovery or dunning | | `subscription.past_due` | Subscription | Keep the access policy explicit | | `subscription.trial_will_end` | Subscription | Remind the customer before the first charge | | `subscription.cancelled` | Subscription | Apply the stated cancellation policy | | `customer.created` | Customer | Create or link the local account | | `customer.updated` | Customer | Apply profile changes | Verify `Zahlo-Signature` over the raw body and reject timestamps more than 300 seconds old, as described in [Webhook operations](/operations/webhooks). Delivery is at-least-once and unordered: events can be duplicated, delayed, or delivered out of order, so deduplicate on `Zahlo-Event-Id` and retrieve the resource when order matters.