Headless checkout
Build a custom storefront with cart, Zahls payment, and ticket issuance on the Public API
Use the Public API from your backend. Never put an organization API key in browser JavaScript. Cowtic CORS does not allow arbitrary partner origins for /api/v1.
This guide walks through a minimal custom checkout that:
- Shows ticket types on your site
- Keeps a cart in the browser
- Creates an order through your backend
- Redirects paid customers to Zahls
- Returns them to your success/failure/cancel pages
- Retrieves issued tickets after payment
For order status semantics (COMPLETED vs PENDING, free vs paid), see Checkout states.
Runnable example
The repository includes a Bun reference app:
It uses @cowtic/sdk on the server, signed HttpOnly cookies for order lookup, and custom Zahls return URLs.
Required scopes
| Step | Scopes |
|---|---|
| Load catalog | events:read, ticket_types:read |
| Create checkout | orders:write |
| Poll completion | orders:read, tickets:read |
| Optional check-in | tickets:check_in |
Paid totals also require zahls.ch connected for the organization.
Architecture
Your frontend (cart UI)
│
▼
Your backend (API key + @cowtic/sdk)
│
▼
Cowtic Public API ──► Zahls checkoutUrl
▲ │
│ webhook │ return URLs
└──────────────────────┘
Your success page polls GET /orders/{id}End-to-end sequence
Load ticket types
GET /events/{eventId} and GET /events/{eventId}/ticket-types from your backend. Render prices and availability on your site.
Keep the cart locally
There is no cart API. Store selections in the browser (or your own session). Inventory is reserved only when POST /orders succeeds.
Create the order
From your backend, call POST /orders with an Idempotency-Key:
import { Cowtic } from "@cowtic/sdk";
const cowtic = new Cowtic({ apiKey: process.env.COWTIC_API_KEY! });
const { data } = await cowtic.orders.create({
eventId: process.env.COWTIC_EVENT_ID!,
customer: { email: "buyer@example.com", name: "Buyer" },
items: [{ ticketId: "ticket_type_id", quantity: 1 }],
checkoutRedirectUrls: {
success: "https://shop.example.com/checkout/success",
failure: "https://shop.example.com/checkout/failure",
cancel: "https://shop.example.com/checkout/cancel",
},
}, {
idempotencyKey: "checkout_01J...",
});- Free / zero total:
order.statusisCOMPLETED,checkoutUrlisnull,issuedTicketsis populated. - Paid:
order.statusisPENDING,checkoutUrlis a Zahls URL,issuedTicketsis empty.
Redirect to Zahls
Send the customer to checkoutUrl. After payment (or cancel/failure), Zahls returns them to the matching URL from checkoutRedirectUrls.
Wait for issuance
Ticket issuance happens after Cowtic receives the Zahls webhook. On your success page:
- Poll
GET /orders/{orderId}untilstatusisCOMPLETED, or - Subscribe to webhooks for
order.completed/ticket.issued
Then call GET /tickets/{ticketId} for the full ticket (including code and downloadTicketUrl).
Check in
Scan the QR (ticket code) in the Cowtic check-in app, or call POST /tickets/check-in with { "code": "TCKT-…" }.
Custom return URLs
checkoutRedirectUrls is optional on POST /orders. When omitted, Zahls returns customers to Cowtic’s hosted confirmation pages. When set, all three fields are required:
| Field | When Zahls uses it |
|---|---|
success | Payment succeeded |
failure | Payment failed |
cancel | Customer cancelled |
URLs must use http or https. Use absolute URLs that your storefront can serve.
Security notes
- Keep the API key on the server only.
- Do not expose arbitrary order IDs to anonymous browsers without your own auth (session cookie, signed token, etc.).
- Prefer webhooks for production fulfillment; polling is fine for success-page UX.
Physical delivery and forms
This minimal headless path covers digital tickets. Physical delivery still requires shippingAddress on create (see Checkout states). Custom event form fields are not yet exposed on the Public API order create schema.