# Zooza Developer Docs — Full Text
> Zooza is ready-made backend infrastructure for businesses that run classes,
> courses and activities (kids' clubs, swimming, dance, music, sports, language
> schools, franchises). Instead of building booking and billing logic from
> scratch, developers build their own websites, forms and apps on top of the
> Zooza REST API and embeddable widgets.
Zooza already handles the parts that are hard to get right:
- Class booking and registration forms, including trial classes and waiting lists
- Make-up sessions: entitlement, capacity and expiry rules enforced automatically
- Installments, subscriptions and recurring payments, with Stripe and GoCardless integrations
- Integrations with accounting and local payment systems
- Class and course listings, schedules, venue and franchise maps
- Multi-company networks (franchises, multi-brand operators)
Getting access is self-serve: create a free Zooza account at https://signup.zooza.online,
then request a REST API key from Zooza support (support@zooza.online). Widgets only
need the widget API key shown in the Zooza admin under Team & Settings > Publish. The API runs in three regions
(Europe, UK, UAE) with stable, versioned endpoints described by an OpenAPI 3.0 spec.
Machine-readable API spec (OpenAPI 3.0): https://docs.zooza.online/zooza_api_v1.yaml
---
# Concepts & glossary
This page defines the key terms and concepts used across Zooza. Many API field names differ from the labels shown in the Zooza app — this glossary maps both.
## Core entities
### Programme (course)
A programme is the top-level entity — what a business sells (e.g. "Beginners Swimming", "Yoga for Adults").
- **API field:** `course` / `course_id`
- The API uses the term **course**, while the Zooza app UI shows **programme**.
### Class (group)
A class is a scheduled instance of a programme, with a specific location, instructor, and capacity.
- **API field:** `schedule` / `schedule_id`
- Example: "Monday 9:00 AM at Main Hall — max 10 participants"
### Session (lesson/event/term)
A session is an individual occurrence within a class — one specific meeting.
- **API field:** `event` / `event_id`
### Place (location/venue)
A physical address or facility where sessions take place.
- **API field:** `place_id`
### Room
An optional subdivision of a place (e.g. "Studio A" within a sports centre).
- **API field:** `room_id`
- In widget URLs, rooms are encoded as `place_id` + `_` + `room_id` (e.g. `123_0` means place 123, no specific room).
### Blocks (segments)
Blocks are subdivisions of sessions within a class. They are used for partial registration or split payments — for example, splitting a year-long course into semesters.
- **Blocks** and **segments** are the same thing.
- Related enum: `registration_display_mode`
---
## Registration and booking concepts
### Registration types
| Type | Description |
|------|-------------|
| **Single event** | One-off session booking |
| **Full term** (`full2`) | Registration for the entire class duration. Most common for ongoing courses. |
| **Open registration** | Ongoing flexible attendance, pay per session |
### Trials
Trials let clients experience a course before committing.
- Can be free or paid
- Can cover one or multiple sessions
- Available only for **full term** courses
### Make-up sessions
A make-up session is a replacement lesson — clients can attend a different session when they miss their regular one. Make-up sessions are managed through the [Profile widget](widgets/profile-widget.md).
### Payment schedules
| Type | API value | Description |
|------|-----------|-------------|
| One-time | `single_payment` | Single upfront payment |
| Periodic pre-paid | `in_advance` | Recurring payments billed in advance |
| By attendance | `by_attendance` | Pay per attended session |
| Pay as you go | `pay_as_you_go` | Per-session payment |
**Frequency values:** `monthly`, `quarterly`, `half_yearly`, `yearly`, `after_events`, `absolute`, `segments`
### Billing periods
Billing periods are organizational time periods for grouping courses (e.g. "Autumn 2024", "Summer Camps 2025"). They are visible to organizers only, not to customers.
---
## Platform concepts
### Company
A company is the business running Zooza, identified by `company_id`. A single user can belong to multiple companies with different roles.
### API keys
Zooza uses **two distinct types of API keys** that are not interchangeable:
| Key type | Where to find it | Used for |
|----------|-----------------|----------|
| **Widget API key** | **Team & Settings > Publish** — displayed for each widget | Embedding widgets on your website |
| **REST API key** | Provided by Zooza support | Programmatic access via the REST API |
:::warning
A widget API key **cannot** be used for REST API calls, and vice versa. They serve completely different purposes.
:::
### Widgets vs REST API
**Widgets** are pre-built embeddable components (registration forms, calendars, profiles, etc.) that you place on your website using a script tag. Widgets require no authentication — just your widget API key in the embed code.
**The REST API** gives you full programmatic access to Zooza data. Use it for:
- Admin and back-office applications
- Customer-facing websites that need custom UI (e.g. caching classes and courses, building custom customer journeys)
- Automated workflows and data synchronisation
The REST API requires authentication with three headers: `X-ZOOZA-API-KEY`, `X-ZOOZA-TOKEN`, `X-ZOOZA-COMPANY`. See the [authentication guide](api/authentication.md) for details.
### Widget group
A widget group is a set of widgets sharing the same widget API key. One group per domain is recommended.
---
## Terms in other languages
Zooza runs in many markets, and clients, staff and search queries use local words for the same concepts. Use this table to map a local term to the English name used in these docs and to the API name.
| English (docs) | API | Slovak | Czech | Polish | German | French |
|---|---|---|---|---|---|---|
| Programme | `course` | Program / Kurz | Kurz | Program | Programm / Kurs | Programme |
| Class | `schedule` | Skupina / Lekcia | Skupina / Lekce | Grupa | Gruppe / Kurs | Classe |
| Session | `event` | Termín / Hodina / Lekcia | Termín / Hodina / Lekce | Termin / Zajęcia | Termin / Stunde / Einheit | Séance |
| Booking | `registration` | Prihlásenie / Zápis / Registrácia | Přihlášení / Zápis / Registrace | Rezerwacja / Zapis | Buchung / Anmeldung | Réservation |
| Client | `user` | Klient / Zákazník | Klient / Zákazník | Klient | Kunde / Klient | Client |
| Instructor | `trainer` | Lektor | Lektor | Instruktor | Kursleiter | Instructeur |
| Venue | `place` | Miesto / Sála / Prevádzka | Místo / Sál / Provozovna | Miejsce | Standort / Ort / Halle | Lieu |
| Trial session | `trial` | Skúšobná hodina | Ukázková / Zkušební hodina | Lekcja próbna | Probestunde / Schnupperstunde | Séance d'essai |
| Make-up session | `replacement` | Náhradná hodina / Náhrada / Náhradka | Náhradní hodina / Náhrada | Termin zastępczy | Ersatzstunde / Nachholtermin | Séance de rattrapage |
| Waiting list | `waitlist` | Čakacia listina / Poradovník | Čekací listina / Pořadník | — | Warteliste | — |
| Block | `segment` | Blok | Blok | Blok | Block | Bloc |
| Payment plan | `payment_schedule` | Platobný plán | Platební plán | Plan płatności | Zahlungsplan | Plan de paiement |
| Aliquot (pro-rated price) | `aliquot` | Alikvóta / alikvótna suma | Alikvota | Płatność proporcjonalna | Anteilige Zahlung | — |
| Membership / subscription | — | Členstvo / Predplatné | Členství / Předplatné | Członkostwo | Mitgliedschaft / Abo | Abonnement |
:::note Make-up session ≠ free credits
A make-up session replaces a session the client missed. Free credits are extra sessions granted on top of a booking. They are different features in Zooza, even though some operators use the words loosely.
:::
---
## API regions
| Region | Base URL |
|--------|----------|
| Europe | `https://api.zooza.app` |
| UK | `https://uk.api.zooza.app` |
| UAE | `https://asia.api.zooza.app` |
---
# Quickstart
Zooza offers two integration approaches. Choose the one that fits your needs — or use both.
---
## Widgets quickstart
### Embed your first widget (5 min)
Widgets are pre-built Zooza components that you embed directly on your website. No authentication or server code required.
#### Prerequisites
- A Zooza account with at least one programme
- An HTTPS website to embed on
#### Step 1: Get your widget API key
1. Log into the [Zooza app](https://zooza.app).
2. Go to **Team & Settings > Publish**.
3. Click on your widget name to open the detail view.
4. Copy your **API key**.

#### Step 2: Add the embed code
Paste these two lines into your HTML page where you want the registration form to appear. Replace `YOUR_API_KEY` with your actual widget API key, and `ZOOZA_API_URL` with your [region's base URL](concepts.md#api-regions).
```html
```
For the Europe region, `ZOOZA_API_URL` is `https://api.zooza.app`.
:::tip Load faster from the head
You can move the loader `
Tasks:
1. Tell me where to place this snippet on my page. Both lines can stay together in the , or the loader
```
Replace `YOUR_API_KEY` with the widget key from **Team & Settings > Publish**, and use your region's API URL for the loader.
### What each setting does
| Setting | Why it's here |
|---|---|
| [`multi_step_form`](../widgets/registration-widget.md#multi_step_form) | Splits the form into steps, so each screen asks for one thing. |
| [`registration_display_mode`](../widgets/registration-widget.md#registration_display_mode) `trials_only` | Hides full-term and block registration. Trials must be enabled in the programme settings. |
| [`course_list_display`](../widgets/registration-widget.md#course-list-display) `grid` + [`course_list_columns`](../widgets/registration-widget.md#course-grid-columns) `2` | Shows programmes (age groups) as tiles instead of a dropdown. |
| [`course_list_collapse_on_select`](../widgets/registration-widget.md#course_list_collapse_on_select) / [`schedule_list_collapse_on_select`](../widgets/registration-widget.md#schedule_list_collapse_on_select) `false` | Keeps all tiles on screen after a selection. |
| [`translations`](../widgets/registration-widget.md#translations) | Renames individual labels. Turn on [`print_debug`](../widgets/registration-widget.md#print_debug) to find the keys. |
## Variations
- **Only some programmes on this page:** add [`course_ids`](../widgets/registration-widget.md#course_ids), for example `window.ZOOZA.course_ids = [101, 102];`.
- **One page per city:** add [`place_ids`](../widgets/registration-widget.md#place_ids), so each landing page shows only the venues in that city.
- **No custom script at all:** every setting above except `translations` also works as a `data-zooza-*` attribute on the placeholder. See [embed methods](../widgets/embed-methods.md#configuring-a-widget-on-the-placeholder).
{`I want a "Book a trial" page on my website for my kids' activity business, using the Zooza registration widget.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/multi-step-trial-booking/
Tasks:
1. Create the page with the registration widget embed (multi_step_form, registration_display_mode 'trials_only', course grid with 2 columns, tiles kept visible after selection).
2. Ask me for my widget key and my region (Europe / UK / UAE) before writing the final snippet.
3. Match the page's heading and intro text to my site's tone.`}
---
# Branded course and session tiles
## The problem
The booking form is often the first part of your site that looks "different". Parents notice when the fonts, colours and card style change halfway through booking. You want the form to look like the rest of the site while Zooza keeps handling capacity, pricing, trials and payment.
## How it looks

## Two levels of customisation
| Level | Effort | You control |
|---|---|---|
| **1. CSS on top of the default tiles** | Minutes | Colours, fonts, borders, spacing, grid |
| **2. Your own tile templates** | An hour | Everything inside each tile: images, copy, badges, buttons |
Both levels keep the widget's own logic: the tile wrapper, selection, collapse and the rest of the booking flow.
## Level 1: CSS only
Little Movers uses a plum and sunshine-yellow palette with rounded cards. Scope your rules to your own placeholder so they never leak into the rest of the page:
```html
```
```css
.lm-booking {
--lm-plum: #5b2a86;
--lm-plum-dark: #431f63;
--lm-sun: #ffc83d;
--lm-line: #e6dcef;
--lm-radius: 18px;
font-family: "Nunito", system-ui, sans-serif;
color: #2d2338;
}
/* Programme (age group) tiles */
.lm-booking .zooza_courses_course {
border: 1.5px solid var(--lm-line);
border-radius: var(--lm-radius);
padding: 1.25rem;
background: #fff;
transition: border-color .15s, box-shadow .15s;
}
.lm-booking .zooza_courses_course:hover {
border-color: var(--lm-plum);
box-shadow: 0 6px 18px rgba(91, 42, 134, .12);
}
.lm-booking .zooza_courses_course.selected {
border-color: var(--lm-plum);
background: #faf6fd;
}
/* Class tiles */
.lm-booking .zooza_schedules_schedule {
border: 1.5px solid var(--lm-line);
border-top: 4px solid var(--lm-sun);
border-radius: var(--lm-radius);
padding: 1rem 1.25rem;
}
/* Select / Choose buttons */
.lm-booking .zooza_courses_course_select,
.lm-booking .zooza_schedules_schedule_select {
border-radius: 999px;
border: 2px solid var(--lm-plum);
background: transparent;
color: var(--lm-plum);
font-weight: 700;
padding: .5rem 1.25rem;
}
.lm-booking .zooza_courses_course_select:hover,
.lm-booking .zooza_schedules_schedule_select:hover {
background: var(--lm-plum);
color: #fff;
}
```
The `selected` class is added to the chosen tile when [`course_list_collapse_on_select`](../widgets/registration-widget.md#course_list_collapse_on_select) is `false`. For other parts of the form, download the widget's default stylesheet and start from its selectors, as described in [Use CSS](../widgets/registration-widget.md#use-css).
### Without touching the website: custom CSS in the Zooza admin
If you can't edit the site's stylesheet, for example on a hosted website builder, paste the same CSS into **Publish > Widget > Custom CSS** in the Zooza admin. The widget loads it after its default styles, so plain overrides win.
- **One stylesheet serves both the registration and the profile widget**, so put the rules for both in the same file and scope them by each widget's classes.
- Prefix rules with `div.zooza` (the widget's root) instead of your own placeholder class, because the stylesheet doesn't know your markup.
- Define your palette once as CSS variables on `div.zooza` and reuse them.
- Leave `font-family` alone if the site's fonts should apply. The widget inherits the page font by default.
## Level 2: your own tile templates
To change what's *inside* a tile, register [`render_course_tile`](../widgets/registration-widget.md#render_course_tile) and [`render_schedule_tile`](../widgets/registration-widget.md#render_schedule_tile). Your function returns the tile's inner HTML. The widget keeps the wrapper, selection and booking flow.
Little Movers stores two pieces of public [metadata](../widgets/registration-widget.md#metadata_in) on each programme in Zooza: `thumbnail` (an image URL) and `duration` (minutes, as an `int`). The tiles use both:
```html
```
```css
.lm-course { display: flex; flex-direction: column; height: 100%; }
.lm-course__thumb { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 14px 14px 0 0; }
.lm-course__body { padding: 1rem 1.1rem 1.25rem; display: flex; flex-direction: column; gap: .4rem; flex: 1; }
.lm-course__title { margin: 0; font-size: 1.15rem; color: var(--lm-plum); }
.lm-course__duration { margin: 0; font-weight: 700; }
.lm-course__desc { margin: 0; color: #6a5f74; flex: 1; }
.lm-session__date { margin: 0; font-weight: 800; color: var(--lm-plum); }
.lm-session__meta, .lm-session__price { margin: .25rem 0; }
.lm-button { align-self: flex-start; margin-top: .5rem; }
```
### Why it's built this way
- **The buttons carry the widget's class hooks** (`zooza_courses_course_select`, `zooza_schedules_schedule_select`). The widget handles the click, so you write no event listeners. Without them, the widget appends its own default button.
- **Only stable fields are used**: `name`, `description`, `metadata` on the course and the `get_*_formatted()` getters on the schedule. Anything else is internal and may change.
- **Data is escaped** before it goes into HTML. Programme names and descriptions are written by staff in the Zooza admin, and the page should not trust them as markup.
- **Callbacks are registered on `window.ZOOZA` before the loader runs.** Merging into existing objects (`window.ZOOZA.callback || {}`) keeps other scripts' settings intact, for example a WordPress plugin's.
- Registering `render_course_tile` switches the course list to grid mode automatically.
## Variations
- **Badges from metadata:** add `age_from`/`age_to` or `level` metadata and render them as pills. Filter the same keys with [`metadata_in`](../widgets/registration-widget.md#metadata_in).
- **Separators between weeks or terms:** use [`schedule_list_rendered`](../widgets/registration-widget.md#schedule_list_rendered) to insert headings between groups of classes.
- **Trial-only page:** combine with the [multi-step trial booking](./multi-step-trial-booking.md) settings.
{`I want the Zooza registration widget on my site to match my design.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/branded-booking-tiles/
Tasks:
1. Inspect my site's design tokens (colours, fonts, radius, button style).
2. Level 1: write scoped CSS for the course and class tiles and their Select/Choose buttons.
3. If I want images or extra info on tiles, write render_course_tile / render_schedule_tile callbacks that read course metadata, escape all data, and use the zooza_*_select class hooks for buttons.
4. Ask me which metadata keys my programmes have before using them.`}
---
# Age-group tabs as the course selector
## The problem
Parents don't think in programme names. They think *"my daughter is six"*. A drama or sports school with one programme per age band wants the first thing on the form to be a simple row of ages, not a list of programme descriptions.
## What you'd have to build yourself
A custom selector that loads programmes, maps them to ages, loads the classes of the selected one, and then hands over to booking with the right ids. With Zooza you keep the widget's booking flow and only change how programmes are **displayed**.
## How it looks

A row of tabs labelled by age: **3–4 · 5–7 · 8–10 · 11–13 · 14–18 years**. Selecting a tab highlights it and shows that age group's classes below. Each class tile shows only its date range and a **Choose** button. The tabs stay visible, so switching age is one click.
## Set-up in Zooza
Give each programme a public [metadata](../widgets/registration-widget.md#metadata_in) entry `age` with the label you want on the tab, for example `5-7`. Order the programmes in the admin from youngest to oldest, or pass them in order with [`course_ids`](../widgets/registration-widget.md#course_ids).
## The code
```html
```
```css
/* Turn the course grid into a tab strip */
.lm-age .zooza_courses_course { padding: 0; border: 0; background: none; }
.lm-tab {
width: 100%; display: flex; flex-direction: column; align-items: center;
padding: .8rem .5rem; border: 2px solid #e6dcef; border-radius: 14px;
background: #fff; color: #5b2a86; font-weight: 800; cursor: pointer;
}
.lm-tab__age { font-size: 1.4rem; line-height: 1.1; }
.lm-tab__unit { font-size: .75rem; font-weight: 600; opacity: .7; }
.lm-age .zooza_courses_course.selected .lm-tab { background: #5b2a86; border-color: #5b2a86; color: #fff; }
.lm-dates { margin: 0; font-weight: 800; color: #5b2a86; }
.lm-cap { margin: .25rem 0 .6rem; color: #6a5f74; }
```
### Why it's built this way
- **The tab *is* the select control.** It carries `zooza_courses_course_select`, so the widget handles the click and loads that programme's classes. You write no click handlers.
- **[`course_list_collapse_on_select: false`](../widgets/registration-widget.md#course_list_collapse_on_select)** keeps all tabs on screen and adds `selected` to the active one, which is what a tab strip needs.
- **The age comes from metadata**, so staff change labels in Zooza without touching the website.
- Only public, documented members are used. There are no internal widget elements or DOM observers, so the example survives widget updates.
## Variations
- **Language versions:** run one page per language and filter its classes with [`labels_in`](../widgets/registration-widget.md#labels_in), for example `labels_in: ['english']`.
- **Variant under the age:** add a second metadata key (for example `track`: *stage* / *film*) and print it as a small line in the tab.
- **Multi-step:** add [`multi_step_form: true`](../widgets/registration-widget.md#multi_step_form) to put the tabs on step 1 on their own.
{`I want parents to pick their child's age first on my Zooza booking form.
Read: https://docs.zooza.online/examples/age-tabs/ and https://docs.zooza.online/widgets/registration-widget/
Tasks:
1. Ask me for my age bands and the metadata key holding them on each programme.
2. Configure course_list_collapse_on_select false and render_course_tile returning a tab with the zooza_courses_course_select class.
3. Add a simple render_schedule_tile with date range and capacity.
4. Write CSS that turns the course grid into a tab strip matching my site; escape all data in templates.`}
---
# Block packages with selection limits
## The problem
Some programmes aren't one class you join for a whole term. A dance school runs a season of short **blocks**: three technique blocks, two repertoire blocks, two choreography blocks. Students buy a **package** that defines the mix: *Starter* gets one of each, *Plus* gets two technique blocks, *Pro* gets two technique and two choreography blocks.
Parents need to see the blocks grouped so the choice makes sense, and the form has to stop them from choosing more than their package allows.
## What you'd have to build yourself
- Blocks with their own dates, price and capacity inside one class, plus pricing for any combination.
- Live availability per block, including blocks that free up later in the season.
- A booking form that handles several blocks in one order.
- The package rules on top.
Zooza already sells classes in [Blocks](../concepts.md#terms-in-other-languages) with per-block capacity and pricing. This example only adds the **grouping** and the **package limits**, using the registration widget's block callbacks.
## How it looks

Above the list sits a package switch: **Starter · Plus · Pro**. Below it, blocks appear under three headings. When the parent reaches the limit for a category, the remaining blocks in it are greyed out with a short note. Deselecting a block enables them again. Switching package clears the selection.
## The code
```html
```
```css
.lm-packages { display: flex; gap: .5rem; margin-bottom: 1rem; }
.lm-packages button { border: 2px solid #5b2a86; background: #fff; color: #5b2a86; border-radius: 999px; padding: .45rem 1.1rem; font-weight: 700; }
.lm-packages button[aria-pressed="true"] { background: #5b2a86; color: #fff; }
.lm-group { margin: 1.25rem 0 .5rem; color: #5b2a86; }
.zooza_segments_segment.lm-locked { opacity: .45; }
.zooza_segments_segment.lm-locked a.zooza_button { display: none; }
.zooza_segments_segment.lm-locked::after { content: "Package limit reached"; display: block; font-size: .85rem; margin-top: .4rem; }
```
### Why it's built this way
- **[`segment_list_rendered`](../widgets/registration-widget.md#segment_list_rendered)** tags each tile with its category from the block name, then regroups the tiles under headings. `segments[ i ]` matches the i-th tile, so tagging happens before anything moves.
- **[`on_segment_selected`](../widgets/registration-widget.md#on_segment_selected)** fires after every select and deselect, so the limits are re-evaluated after each click.
- **Locked blocks only hide the widget's `a.zooza_button`.** Nothing about the booking changes, so price and availability still come from Zooza.
- **Switching package clicks the widget's own buttons** to deselect, so the widget's price and form stay correct.
- **The handler is idempotent** and chains any `on_segment_selected` handler that was already registered.
:::caution Limits are a front-end rule
The package limits live in your page, not in Zooza. If a package must also be enforced on payment, for example a different price per package, model it in Zooza as well: use separate programmes or price rules, and use this page for the guided selection.
:::
## Variations
- **Category from metadata instead of names:** use [`render_segment_tile`](../widgets/registration-widget.md#render_segment_tile) to print a badge per block, and keep the category list in your CMS keyed by block id.
- **Show "frees up on …" for full blocks:** read `segment.earliest_available_date` in `render_segment_tile`.
- **One package per page:** drop the switch and set `state.pkg` from the URL.
{`My Zooza class is sold in blocks (registration_display_mode 'segments_only'). I want blocks grouped by category and package-based limits.
Read: https://docs.zooza.online/examples/block-packages/ and the block callbacks in https://docs.zooza.online/widgets/registration-widget/
Tasks:
1. Ask me for my block categories (and how to recognise them in block names) and my packages with per-category limits.
2. Implement segment_list_rendered (grouping + package switch, idempotent) and on_segment_selected (re-apply limits), chaining existing callbacks.
3. Only hide a.zooza_button on locked blocks; clear the selection by clicking the widget's own buttons.
4. Style it to match my site.`}
---
# Branch and franchise location map
## The problem
Once a business runs classes in more than a handful of venues, *"Is there a class near me?"* becomes the most common question. Parents want to type a postcode, see the nearest venues, and go straight to a class that suits their child. Franchise networks need the same thing across many operators.
## What you'd have to build yourself
- A venue database with addresses and coordinates, kept in sync with your timetable.
- Geocoding for postcodes and addresses, distance calculation and a radius filter.
- A map with pins, a venue detail panel, and the list of classes at each venue with live availability.
- Links from each class into booking, plus "navigate here" for the parent's maps app.
The Zooza map widget covers all of this from the venues and classes you already manage in Zooza.
## How it looks

Little Movers' "Find a class" page has a short intro, then a full-width map with a pin for each venue. A search field accepts a postcode or address, and a slider narrows the radius. Selecting a pin shows the venue's address and a **Navigate** button, and lists the classes running there. Each class links to the booking form.
## The code
```html
Locations
Find your nearest class
We run classes in twelve towns, with new venues every term. Search by postcode or browse the map.
Loading map…
```
```css
.lm-map-host {
position: relative;
min-height: 560px; /* reserve space: no layout shift while loading */
border-radius: 24px;
overflow: hidden;
background: #f3eef8;
}
.lm-map-skeleton {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #5b2a86;
font-weight: 700;
background: repeating-linear-gradient( 135deg, #f3eef8 0 24px, #ece4f4 24px 48px );
}
```
The map widget uses the **v2** loader (`/widgets/v2/loader.js`), while the registration widget uses v1. Little Movers operates in the UK, so the loader comes from `https://uk.api.zooza.app`. Use your own region's URL.
### Settings in the Zooza admin
The map's behaviour is configured in **Publish > Widget > Map**, not in code:
| Setting | Little Movers uses it for |
|---|---|
| [Search by ZIP code](../widgets/map-widget.md#search-by-zip-code) | Postcode search alongside addresses |
| [Show radius filter](../widgets/map-widget.md#show-radius-filter) | "Within 10 miles" slider |
| [Show use current location](../widgets/map-widget.md#show-use-current-location) | One-tap "near me" on phones |
| [Use age filter](../widgets/map-widget.md#use-age-filter) | Only classes for the child's age |
| [Show navigate button](../widgets/map-widget.md#show-navigate-button) | Opens the parent's maps app |
| [URL](../widgets/map-widget.md#url) | Where the map lives, so links come back to it |
Clicking a class takes the parent to the [registration widget](../widgets/registration-widget.md), so set its URL in the admin too.
### Why it's built this way
- **Reserved height and a skeleton** keep the page from jumping when the map appears. That helps both parents and Core Web Vitals (CLS).
- **The skeleton sits outside the placeholder**, so the widget never has to deal with your markup. The observer only watches for the widget's first render.
- **Venues come from Zooza**, so opening a new venue or closing one updates the map without touching the website.
## Variations
- **Venue landing pages for SEO:** create one page per town ("Kids' dance classes in Bristol") with local copy and a registration widget filtered by [`place_ids`](../widgets/registration-widget.md#place_ids). Link them from the map page.
- **Franchise networks:** each franchisee keeps their own Zooza company, and the network site shows all venues together.
- **Styling:** download the default map CSS as described in [Use CSS](../widgets/map-widget.md#use-css) and override it, scoped to `.lm-map-host`.
{`I want a "Find your nearest class" page with the Zooza map widget.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/location-map/
Tasks:
1. Build the page with an intro, a reserved-height map container with a skeleton, and the map widget embed (v2 loader).
2. Ask me for my widget key and region (Europe / UK / UAE).
3. List which map settings I should enable in Publish > Widget > Map for my use case.
4. Optionally scaffold one landing page per town with a registration widget filtered by place_ids.`}
---
# Weekly timetable with filters
## The problem
Parents and returning clients want to see the whole week at once: *"What's on Saturday morning in Riverton?"* A timetable that search engines can read and that answers this in one glance brings in more bookings than a form. It has to stay accurate as classes fill up, move or get added mid-term.
## What you'd have to build yourself
- A source of truth for classes, venues, times, instructors and capacity, plus a way to keep it in sync with your booking system.
- Availability that reflects real bookings, waiting lists and capacity changes.
- Filtering by town, programme and day that works without a page reload.
- A handover from "I like this class" into booking without asking the parent to pick it again.
With Zooza, classes and availability already live in one place. The website only reads and displays them.
## How it looks

Little Movers' timetable page has filter chips at the top: one row of **towns**, one of **programmes**, and a **Clear filters** link. Below is a seven-column week, Monday to Sunday. Each class card shows the term dates, programme, class name, time, venue and an availability line ("Spaces available" or "Waiting list"), with an **Enrol** button.
## Architecture
```
Browser ──GET /timetable──▶ Your server ──(cached 10 min)──▶ Zooza REST API
│ │ GET /v1/schedules
│◀── HTML + JSON ──────────┘ GET /v1/courses
│ GET /v1/companies/{id}/places
└─ Enrol ─▶ /book/?course_id=…&schedule_id=… (registration widget)
```
The REST API key and token stay on the server. The page is rendered server-side, so search engines see every class. Filtering happens in the browser.
## Step 1: fetch and cache on the server
```javascript
// timetable.js — Node 18+ (global fetch)
const API = 'https://api.zooza.app/v1';
const HEADERS = {
'X-ZOOZA-API-KEY': process.env.ZOOZA_API_KEY,
'X-ZOOZA-TOKEN': process.env.ZOOZA_TOKEN, // see Authentication
'X-ZOOZA-COMPANY': process.env.ZOOZA_COMPANY_ID,
};
async function get( path ) {
const response = await fetch( API + path, { headers: HEADERS } );
if ( ! response.ok ) throw new Error( `Zooza ${ path }: ${ response.status }` );
return response.json();
}
// /schedules is paginated: { total, page, page_size, data }
async function get_all_schedules( query ) {
const all = [];
for ( let page = 1; ; page++ ) {
const res = await get( `/schedules?${ query }&page=${ page }&page_size=100` );
all.push( ...( res.data || [] ) );
if ( all.length >= ( res.total || 0 ) || ! ( res.data || [] ).length ) return all;
}
}
let cache = { at: 0, data: null };
async function load_timetable() {
if ( cache.data && Date.now() - cache.at < 10 * 60 * 1000 ) return cache.data;
const [ courses, places, schedules ] = await Promise.all( [
get( '/courses' ),
get( `/companies/${ process.env.ZOOZA_COMPANY_ID }/places` ),
get_all_schedules( 'online_registration=true&ended=false' ),
] );
const course_by_id = new Map( courses.map( ( c ) => [ c.id, c ] ) );
const place_by_id = new Map( places.map( ( p ) => [ p.id, p ] ) );
const classes = schedules
.filter( ( s ) => s.public !== false )
.map( ( s ) => {
const course = course_by_id.get( s.course_id ) || {};
const place = place_by_id.get( s.place_id ) || {};
// Weekly classes repeat on the weekday of their first date
const weekday = new Date( s.start + 'T12:00:00' ).getDay(); // 0 = Sunday
const free = s.capacity ? s.capacity - ( s.__calc__registered || 0 ) : null;
return {
schedule_id: s.id,
course_id: s.course_id,
programme: course.name,
name: s.name,
weekday,
starts_at: minutes_to_time( s.time ),
ends_at: minutes_to_time( s.time + ( s.duration || 0 ) ),
term: { from: s.start, to: s.end },
place_id: s.place_id,
venue: place.name,
town: place.city,
status: free === null || free > 0 ? 'available' : 'waitlist',
};
} );
cache = { at: Date.now(), data: classes };
return classes;
}
function minutes_to_time( minutes ) {
const h = String( Math.floor( minutes / 60 ) ).padStart( 2, '0' );
const m = String( minutes % 60 ).padStart( 2, '0' );
return `${ h }:${ m }`;
}
module.exports = { load_timetable };
```
Check each field against the [schedules](/api/reference/get-list-of-schedules), [courses](/api/reference/get-list-of-courses) and [places](/api/reference/get-places-that-belong-to-a-company) reference before relying on it. In particular, the venue's `name`/`city` keys depend on how your places are set up. `time` is minutes from midnight, and `start`/`end` are the class's date range.
## Step 2: render with data attributes for filtering
Render each class server-side with its filter values on the element, using any template engine:
```html
```
## Step 3: filter in the browser
```html
```
## Step 4: the booking page
`/book/` holds a normal [registration widget](../widgets/registration-widget.md). The widget reads [`course_id`](../widgets/registration-widget.md#course_id) and [`schedule_id`](../widgets/registration-widget.md#schedule_id) from the URL, so the parent lands on the class they clicked:
```html
```
### Why it's built this way
- **Server-side fetch keeps credentials private** and lets search engines index the full timetable.
- **A short cache** (10 minutes) keeps the page fast and the API calls low, while availability stays fresh enough. The booking form always re-checks capacity live.
- **Filters use data attributes and `hidden`**, so they work without a framework and keep the page accessible.
- **Deep links into the widget** skip two steps of the booking form.
## Variations
- **No server at all:** the [calendar widget](../widgets/calendar-widget.md) shows a live schedule with built-in filters by location, programme, type and instructor.
- **Age filter:** keep the age range per programme in your CMS (keyed by `course_id`) and add it as another `data-` attribute.
- **Instructor names:** add `load_trainers` to the schedules request and render the instructor on the card.
{`I want a weekly class timetable on my site, built from the Zooza REST API.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/filtered-timetable/
Tasks:
1. Ask me for my stack (WordPress/PHP, Node, Next.js, …) and region.
2. Fetch /courses, /companies/{id}/places and /schedules server-side with cached results; keep the API key and token in environment variables.
3. Render a Monday–Sunday grid with filter chips (town, programme) and client-side filtering.
4. Link each class to my booking page with ?course_id=…&schedule_id=… for the registration widget.
5. Verify every field name against the OpenAPI spec (https://docs.zooza.online/zooza_api_v1.yaml) before using it.`}
---
# Fully custom course catalogue on the REST API
## The problem
A business with dozens of programmes in many towns usually outgrows a single booking form. It wants a catalogue that feels like part of the brand, with illustrations, age bands, SEO-friendly programme pages and filters, and still with live availability and proper booking. Marketing wants to edit the copy. Operations wants to manage classes in one system, not two.
## What you'd have to build yourself
- A class and venue database with capacity, waiting lists, terms and instructors, in sync with everything the office does every day.
- Availability that reacts to bookings, cancellations and transfers in real time.
- Booking, payments, installments, make-ups and client accounts behind the "Sign up" button.
With Zooza you build **only the presentation layer**. Everything behind it stays in Zooza.
## Who owns what
| Data | Owner | Why |
|---|---|---|
| Programme copy, illustrations, age band, SEO text | **Your CMS** | Marketing edits it freely and it doesn't affect operations |
| Programmes, classes, venues, times, instructors | **Zooza** | Staff manage them daily in Zooza |
| Capacity, bookings, waiting list | **Zooza** | Must be live and exact |
| Booking, payment, client account | **Zooza registration widget** | Already handles every rule |
The two are linked by the Zooza `course_id`, stored as a field on each programme in your CMS.
## How it looks

Little Movers' site has three levels:
1. **Catalogue (`/programmes/`)**: a filter sidebar (town, child's age, programme type) and a grid of illustrated programme cards, for example *Tiny Movers 1 (3–5 months)* or *Explorers (12–18 months)*.
2. **Programme page (`/programmes/tiny-movers-1/`)**: description, length and group size, plus a "Choose your town" dropdown with search.
3. **Class picker (`/book/tiny-movers-1/riverton/`)**: the classes for that programme in that town, grouped by venue. Each class shows its time, instructor and availability, with **Sign up**. Full classes say so and offer the waiting list.
**Sign up** opens the booking page with the registration widget, preselected to that class.
## Step 1: the catalogue (CMS only)
The catalogue and programme pages are ordinary CMS pages. Each programme entry has:
```json
{
"slug": "tiny-movers-1",
"title": "Tiny Movers 1",
"age_band": "3–5 months",
"type": "baby-classes",
"illustration": "/img/programmes/tiny-movers-1.svg",
"zooza_course_id": 101
}
```
Filters for age and type run on CMS data, with no API call. The town filter uses the town list from Step 2.
## Step 2: towns and classes from Zooza (server-side)
```php
[
'X-ZOOZA-API-KEY' => ZOOZA_API_KEY,
'X-ZOOZA-TOKEN' => zooza_server_token(), // see Authentication
'X-ZOOZA-COMPANY' => ZOOZA_COMPANY_ID,
],
'timeout' => 10,
] );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
return [];
}
$data = json_decode( wp_remote_retrieve_body( $response ), true ) ?: [];
set_transient( $cache_key, $data, 10 * MINUTE_IN_SECONDS );
return $data;
}
/** Classes of one programme that are open for online booking, grouped by venue. */
function little_movers_classes( int $course_id, string $town ): array {
$places = zooza_get( '/companies/' . ZOOZA_COMPANY_ID . '/places' );
$schedules = zooza_get( '/schedules?course_id=' . $course_id
. '&online_registration=true&ended=false&load_trainers=true&page_size=100' );
$venues = [];
foreach ( $places as $place ) {
if ( strcasecmp( $place['city'] ?? '', $town ) === 0 ) {
$venues[ $place['id'] ] = [ 'place' => $place, 'classes' => [] ];
}
}
foreach ( $schedules['data'] ?? [] as $s ) {
if ( ! isset( $venues[ $s['place_id'] ] ) ) {
continue;
}
$free = isset( $s['capacity'] ) ? $s['capacity'] - ( $s['__calc__registered'] ?? 0 ) : null;
$venues[ $s['place_id'] ]['classes'][] = [
'schedule_id' => $s['id'],
'name' => $s['name'],
'time' => sprintf( '%02d:%02d', intdiv( $s['time'], 60 ), $s['time'] % 60 ),
'duration' => $s['duration'],
'full' => $free !== null && $free <= 0,
];
}
return array_filter( $venues, fn( $v ) => count( $v['classes'] ) > 0 );
}
```
Verify the field names (`city`, `time`, `capacity`, `__calc__registered`, …) against the [schedules](/api/reference/get-list-of-schedules) and [places](/api/reference/get-places-that-belong-to-a-company) reference for your account. `time` is minutes from midnight.
## Step 3: the class picker template
```php
```
## Step 4: booking
`/book/` contains the [registration widget](../widgets/registration-widget.md). It picks up [`course_id`](../widgets/registration-widget.md#course_id) and [`schedule_id`](../widgets/registration-widget.md#schedule_id) from the URL. Whether a full class goes to the waiting list is decided by your programme settings in Zooza, not by the website.
```html
```
### Why it's built this way
- **Clear ownership:** marketing never touches classes, and staff never touch web copy. The only link between them is `course_id`.
- **Server-side reads with a cache** keep credentials private, keep pages fast and indexable, and avoid an API call per visitor.
- **Booking stays in the widget.** Prices, installments, trials, discounts, waiting lists and payment rules are enforced by Zooza, so the website can't sell something the business doesn't offer.
- **The availability shown is a hint.** The widget re-checks it live at booking time.
## Variations
- **Town landing pages:** `/riverton/` lists every programme with classes in that town. That's good for local SEO.
- **Trial-first funnel:** send "Sign up" to a page with [`registration_display_mode: 'trials_only'`](./multi-step-trial-booking.md).
- **Headless front-end:** the same pattern works in Next.js, Nuxt or Astro. Fetch on the server, render static pages and revalidate every few minutes.
{`I want a fully custom course catalogue on my website: my CMS holds programme marketing content, Zooza holds classes, availability and booking.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/custom-catalogue/
Tasks:
1. Ask me for my stack (WordPress, Next.js, …), region, and how my programmes are stored in the CMS.
2. Add a zooza_course_id field to each programme.
3. Build server-side helpers that read places and schedules from the Zooza REST API with caching; keep credentials in env/config.
4. Build the class picker page (grouped by venue, availability, waiting-list message) linking to /book/?course_id=…&schedule_id=… with the registration widget.
5. Verify every field against the OpenAPI spec: https://docs.zooza.online/zooza_api_v1.yaml`}
---
# Give your AI chatbot the logged-in client
## The problem
Many class businesses now run an AI chatbot or support widget on their website. Most questions it gets are personal: *"When is Emma's next class?"*, *"Did my payment go through?"*, *"Can I book a make-up for last Tuesday?"*. A bot that doesn't know who it's talking to can only answer *"please log in to your account"*.
The parent is often already logged in, through the Zooza [profile widget](../widgets/profile-widget.md) on the same site. This example passes that identity to the chatbot, so it can:
- greet the parent by name and skip "what's your email?",
- hand the conversation to staff with the right client attached,
- look up the parent's bookings through **your** backend, safely.
## How it looks

*Illustration with a fictional brand and data.*
## What you'd have to build yourself
Without Zooza you would need your own client accounts, login, session handling and booking data, and a way to keep all of it in sync with the chatbot. Here, Zooza already owns the client account and login. You only listen to two browser events and forward a few fields.
## Step 1: forward the identity to the chatbot
The profile widget fires [`zooza_user_logged_in`](../widgets/profile-widget.md#zooza_user_logged_in) and [`zooza_user_logged_out`](../widgets/profile-widget.md#zooza_user_logged_out) on `document`. The logged-in user is also kept in `localStorage` under `zooza_user`, so a parent who logged in on an earlier page is recognised too.
```html
```
Most chatbot vendors have a call for this. It is usually named "identify", "set user", "update" or "client context". Put it inside the adapter and leave the rest unchanged.
:::warning Forward identity, never credentials
Only pass the fields above. **Never forward a Zooza token or cookie to a third-party chatbot**, even if it appears in the browser. Anyone holding that token can act as the parent in Zooza, and chat vendors store conversation context.
:::
## Step 2: let the bot look up bookings through your backend
Everything in Step 1 runs in the browser, so treat it as a **hint** (good enough for a greeting) and not as proof of identity. When the bot needs real data, such as next classes, payments or make-up options, route the request through a small endpoint on your server:
```
Chatbot (tool call: "get_bookings")
│ conversation id + zooza_user_id
▼
Your backend ── verifies the person ── calls Zooza REST API with server credentials
│ GET /v1/registrations?user_id=…
▼
Short, filtered answer back to the bot (next session, status, balance)
```
```javascript
// Node.js (Express): a tool endpoint your chatbot calls
app.post( '/chatbot/tools/get-bookings', async ( req, res ) => {
const { conversation_id } = req.body;
// Your rule for trusting the identity: e.g. the chat vendor's signed
// identity verification, or a one-time code you emailed to the parent.
const user_id = await verified_zooza_user_id( conversation_id );
if ( ! user_id ) {
return res.json( { error: 'not_verified' } );
}
const response = await fetch(
`https://api.zooza.app/v1/registrations?user_id=${ encodeURIComponent( user_id ) }`,
{
headers: {
'X-ZOOZA-API-KEY': process.env.ZOOZA_API_KEY,
'X-ZOOZA-TOKEN': await zooza_server_token(),
'X-ZOOZA-COMPANY': process.env.ZOOZA_COMPANY_ID,
},
}
);
const { data = [] } = await response.json();
// Return only what the bot needs, never the raw payload.
// Pick the fields from the Registration schema in the API reference.
res.json( {
bookings: data.map( ( r ) => ( {
id: r.id,
// e.g. class name, next session, payment status
} ) ),
} );
} );
```
`verified_zooza_user_id()` and `zooza_server_token()` are yours to implement. See [Authentication](../api/authentication.md) for obtaining a server token (`client_secret`) and the required headers, and [`GET /registrations`](/api/reference/registrations) in the API reference for all filters and response fields.
### Why it's built this way
- **The browser only says who is probably there.** `localStorage` and DOM events can be edited by anyone, so they are fine for personalising a greeting but not for releasing data.
- **The API key and server token stay on your server.** The chatbot vendor never sees Zooza credentials.
- **The bot gets a filtered answer**, not the raw API response, so it can't leak fields the parent shouldn't see.
- **Logout clears the context**, so a shared family tablet doesn't keep the previous parent in the chat.
## Variations
- **Human handover:** pass `zooza_user_id` into your help desk ticket, so staff can open the client in Zooza straight away.
- **Proactive messages:** when a logged-in parent opens the timetable page, have the bot offer *"Book a make-up for a missed class?"* and link to the [profile widget](../widgets/profile-widget.md).
- **Analytics:** send a `logged_in` event with the hashed user id to your analytics tool to measure how many chats come from existing clients.
{`My website runs the Zooza profile widget (clients log in there) and an AI chatbot. I want the chatbot to know which client is logged in.
Read the Zooza docs first: https://docs.zooza.online/llms-full.txt
Use this example as the pattern: https://docs.zooza.online/examples/chatbot-logged-in-client/
Tasks:
1. Ask me which chatbot vendor I use, then write the adapter (identify/reset) for it.
2. Listen to zooza_user_logged_in / zooza_user_logged_out and read localStorage 'zooza_user' on page load.
3. Never forward Zooza tokens or cookies to the chatbot.
4. If I want the bot to answer booking questions, scaffold a backend tool endpoint that verifies the person and calls the Zooza REST API with server-side credentials.`}
---
# Package your widget customisation as a WordPress plugin
## The problem
Most activity businesses run WordPress, often with a page builder such as Elementor, Divi or WPBakery. You've written a nice customisation for the booking widget: custom tiles, brand styles, a few settings. Now you need to:
- put it on several pages with different settings (programme ids, columns, colours),
- let the site owner place it without editing code,
- survive page builders and content filters that **strip `\n",
wp_json_encode( array_filter( $config, fn( $v ) => $v !== [] ) )
);
// 2. Your callbacks (render_course_tile, render_schedule_tile, …)
printf( "\n", esc_url( plugins_url( 'assets/booking.js?ver=1.0.0', __FILE__ ) ) );
// 3. The Zooza loader last
printf( "\n", esc_url( LM_ZOOZA_API_URL . '/widgets/v1/loader.js' ) );
} );
```
`assets/booking.js` is any of the callback scripts from the other examples, for example [branded tiles](./branded-booking-tiles.md) or [age tabs](./age-tabs.md). Make it **merge** into `window.ZOOZA.callback` instead of replacing it:
```javascript
( function () {
window.ZOOZA = window.ZOOZA || {};
window.ZOOZA.callback = window.ZOOZA.callback || {};
if ( ! window.ZOOZA.callback.render_course_tile ) {
window.ZOOZA.callback.render_course_tile = function ( course ) { /* … */ };
}
} )();
```
### Why it's built this way
- **Page builders strip inline scripts** in content. Shortcode output that contains only a `
` is safe, and `wp_footer` output isn't filtered.
- **One placeholder plus one loader** follows the standard [embed](../widgets/embed-methods.md). Shortcode attributes become [initialisation options](../widgets/registration-widget.md#initialisation-options), so editors can change programmes or columns per page.
- **Settings are JSON-encoded with `wp_json_encode`** and attributes are escaped, so editor input can't break the page.
- **Assets are versioned files**, so browsers cache them and you update everything by releasing a new plugin version.
:::tip Already using the Zooza WordPress plugin?
The official Zooza plugin places the widget with `[zooza type="registration"]`. You can keep it for the embed and use your own plugin **only** for styling and callbacks. In that case, skip the placeholder in your shortcode and print just the settings and `booking.js` in the footer.
:::
## Variations
- **Settings page:** add an admin page under *Settings* that lists the shortcode attributes for editors.
- **Several widgets per site:** register one shortcode per use case (`[lm_trial_booking]`, `[lm_parent_zone]`) that share the same assets.
- **Multisite / franchise:** read the widget key from a site option, so each franchise site uses its own Zooza company.
{`I have custom JavaScript/CSS for the Zooza registration widget and I want to ship it as a WordPress plugin with a shortcode.
Read: https://docs.zooza.online/examples/wordpress-plugin/ and https://docs.zooza.online/widgets/registration-widget/
Tasks:
1. Ask me for my widget key, region, and which shortcode attributes editors need.
2. Create the plugin: shortcode outputs only the placeholder div; settings and scripts are printed in wp_footer (settings → my callbacks → Zooza loader).
3. Move my JS/CSS into versioned asset files; make the JS merge into window.ZOOZA.callback.
4. Escape all attributes and JSON-encode settings.`}
---
# API overview
The Zooza REST API gives you programmatic access to manage programmes, classes, registrations, payments, and more. All responses are JSON.
:::tip See it in practice
- [Weekly timetable with filters](/examples/filtered-timetable)
- [Custom course catalogue](/examples/custom-catalogue)
- [AI chatbot with the logged-in client](/examples/chatbot-logged-in-client)
:::
## When to use the REST API
Zooza offers two integration approaches: **widgets** (embeddable UI components) and the **REST API** (programmatic access). They are not mutually exclusive — many integrations use both.
```mermaid
flowchart TD
A["What do you need?"] --> B{"Pre-built booking forms,\ncalendars, profiles?"}
B -- Yes --> C["Use Widgets"]
B -- No --> D["Use the REST API"]
A --> E{"Custom UI, data caching,\nor back-office tools?"}
E -- Yes --> D
C --> F["No auth needed\nEmbed a script tag"]
D --> G["Server-side auth\n3 headers required"]
```
**Use widgets** when you want to embed ready-made Zooza components (registration forms, calendars, maps, profiles) on your website. No server code or authentication required — just a script tag. See the [widget docs](../widgets/index.md).
**Use the REST API** when you need to:
- Build admin or back-office applications
- Cache classes and courses on your website for custom customer journeys
- Build a fully custom customer-facing UI
- Automate workflows or synchronise data with external systems
:::note Different API keys
Widget API keys and REST API keys are **not interchangeable**. A widget key cannot be used for REST API calls. See [concepts](../concepts.md#api-keys) for details.
:::
{`Help me build an integration against the Zooza REST API.
Read the full Zooza API documentation first — it contains the complete endpoint reference, auth flow, base URLs, and error codes:
https://docs.zooza.online/llms-full.txt
Authentication uses three headers: X-ZOOZA-API-KEY, X-ZOOZA-TOKEN (from POST /v1/login), and X-ZOOZA-COMPANY (from GET /v1/user). Base URLs: Europe https://api.zooza.app, UK https://uk.api.zooza.app, UAE https://asia.api.zooza.app.
What I want to build:
[describe your use case — e.g. "list all programmes and export enrolled clients to CSV", "sync new bookings into my CRM", or "a back-office tool to manage payments"]
Tasks:
1. Set up authentication (login -> token -> company id) in [my language: Node.js / Python / PHP / cURL].
2. Implement the use case above using the correct endpoints from the docs — don't invent endpoints; cite the ones you use.
3. Handle pagination, errors, and token expiry (401) gracefully.
Ask me for my REST API key, login email, client secret, and region before writing code that runs.`}
---
## Base URLs
| Region | Base URL |
|--------|----------|
| Europe | `https://api.zooza.app` |
| UK | `https://uk.api.zooza.app` |
| UAE | `https://asia.api.zooza.app` |
All examples in this documentation use the Europe base URL. Replace it with your region's URL as needed.
---
## Authentication
The REST API requires three headers on every request:
| Header | Description |
|--------|-------------|
| `X-ZOOZA-API-KEY` | Your REST API key (from Zooza support) |
| `X-ZOOZA-TOKEN` | User token obtained via login |
| `X-ZOOZA-COMPANY` | Scopes requests to a specific company |
For full details on obtaining credentials and tokens, see the [authentication guide](authentication.md).
---
## Key concepts
Zooza uses specific terminology (e.g. "course" in the API means "programme" in the UI). See the [concepts and glossary](../concepts.md) page for definitions of all terms.
---
## Rate limiting
Rate limit details will be documented here.
---
## Versioning
The current API version is **v1**. All endpoints are prefixed with `/v1/`.
---
## Endpoints reference
See the full [API operations reference](reference) for all available endpoints.
---
# Authentication
This page covers authentication for the **REST API** only. Widgets do not require authentication — see the [widget docs](../widgets/index.md) for embedding instructions.
Zooza uses **passwordless login**. Instead of relying on a password, identity is verified via email verification, PIN code, or client secret.
---
## Required headers
Every REST API request requires these three headers:
| Header | Description |
|--------|-------------|
| `X-ZOOZA-API-KEY` | Your REST API key |
| `X-ZOOZA-TOKEN` | Identifies the current user |
| `X-ZOOZA-COMPANY` | Scopes requests to a specific company |
---
## Obtaining your REST API key
1. Create a free Zooza account at [signup.zooza.online](https://signup.zooza.online) if you don't have one yet.
2. Request a REST API key from [Zooza support](mailto:support@zooza.online), mentioning the company you created.
REST API keys are different from widget API keys. A widget API key **cannot** be used for REST API calls.
---
## Obtaining a user token
There are three ways to obtain a token, depending on your use case.
### Via email verification
The most common approach for customer-facing applications.
1. Send a POST request with the user's email.
2. Zooza sends a verification email containing a link with a `key` query parameter.
3. Extract the token from the URL and store it.
**Example verification link:**
```
https://yoursite.com?key=abc123token
```
```bash
curl -X POST https://api.zooza.app/v1/login \
-H "Content-Type: application/json" \
-H "X-ZOOZA-API-KEY: your_api_key" \
-d '{
"login": "customer@example.com",
"verification_method": "email"
}'
```
```javascript
const response = await fetch("https://api.zooza.app/v1/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-ZOOZA-API-KEY": "your_api_key",
},
body: JSON.stringify({
login: "customer@example.com",
verification_method: "email",
}),
});
const data = await response.json();
```
Store the token in a **session, cookie, or localStorage**, and include it in subsequent requests:
```
X-ZOOZA-TOKEN:
```
:::note
This is a long-term token, but it may be invalidated by Zooza at any time.
:::
---
### Via PIN code
Instead of clicking a verification link, users can enter a **PIN code** (valid for 5 minutes) from the same email.
```bash
curl -X POST https://api.zooza.app/v1/verify \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "X-ZOOZA-API-KEY: your_api_key" \
-d '{
"action": "validate_pin",
"email": "customer@example.com",
"pin": "123456"
}'
```
```javascript
const response = await fetch("https://api.zooza.app/v1/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-ZOOZA-API-KEY": "your_api_key",
},
body: JSON.stringify({
action: "validate_pin",
email: "customer@example.com",
pin: "123456",
}),
});
const data = await response.json();
// data.token contains the user token
```
**Response:**
```json
{
"message": "Code accepted",
"token": ""
}
```
---
### Via client secret (server-to-server)
For server-side or automated applications where no human interaction is possible. Use a **client secret** to authenticate directly.
```bash
curl -X POST https://api.zooza.app/v1/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-H "X-ZOOZA-API-KEY: your_api_key" \
-d '{
"login": "admin@example.com",
"verification_method": "client_secret",
"client_secret": "your_client_secret"
}'
```
```javascript
const response = await fetch("https://api.zooza.app/v1/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-ZOOZA-API-KEY": "your_api_key",
},
body: JSON.stringify({
login: "admin@example.com",
verification_method: "client_secret",
client_secret: "your_client_secret",
}),
});
const data = await response.json();
// data.token contains the user token
```
:::warning Important
- The email must belong to a **valid Zooza user** with a role in the target company.
- Use a user with the **Owner role** for full access.
- Responses are always scoped to the user's role (e.g. a Lecturer only sees classes where they are assigned).
:::
To obtain a client secret, contact [Zooza support](mailto:support@zooza.online).
---
## Getting the company ID
Once you have a token, retrieve the company ID from the `/v1/user` endpoint.
```bash
curl -X GET https://api.zooza.app/v1/user \
-H "X-ZOOZA-API-KEY: your_api_key" \
-H "X-ZOOZA-TOKEN: your_token"
```
```javascript
const response = await fetch("https://api.zooza.app/v1/user", {
headers: {
"X-ZOOZA-API-KEY": "your_api_key",
"X-ZOOZA-TOKEN": "your_token",
},
});
const data = await response.json();
// Find your company in data.user.companies
```
**Response (abbreviated):**
```json
{
"user_valid": true,
"user": {
"id": 1,
"first_name": "John",
"last_name": "Smith",
"email": "john@example.com",
"companies": [
{ "id": 65, "name": "Company A", "role": "member" },
{ "id": 76, "name": "Company B", "role": "owner" }
]
},
"app": {
"application_id": 1,
"company_id": 65,
"type": "application"
}
}
```
Use the `id` from the appropriate company in the `X-ZOOZA-COMPANY` header for subsequent requests.
---
## Token lifecycle
- Tokens are **long-lived** but may be invalidated by Zooza at any time.
- There is no explicit refresh mechanism — if a token becomes invalid, re-authenticate using one of the methods above.
- Store tokens securely and handle `401 Unauthorized` responses by re-initiating the login flow.
{`Help me implement authentication against the Zooza REST API.
Read the full Zooza API documentation first — it has the exact login methods, headers, and error codes:
https://docs.zooza.online/llms-full.txt
How Zooza auth works (passwordless):
1. Every request needs three headers: X-ZOOZA-API-KEY, X-ZOOZA-TOKEN, X-ZOOZA-COMPANY.
2. Obtain X-ZOOZA-TOKEN via POST /v1/login. For server-to-server use verification_method "client_secret".
3. Get the company id from GET /v1/user (user.companies[].id) and pass it as X-ZOOZA-COMPANY.
4. Tokens are long-lived but can be invalidated — handle 401 by re-authenticating.
Base URLs: Europe https://api.zooza.app, UK https://uk.api.zooza.app, UAE https://asia.api.zooza.app.
Tasks:
1. Write a reusable auth helper in [my language: Node.js / Python / PHP] that logs in, caches the token, and resolves my company id.
2. Make it automatically re-authenticate on a 401 response.
3. Read secrets from environment variables, never hardcode them.
Ask me for my REST API key, login email, client secret, and region before writing code that runs.`}
---
# Error handling
This page covers how the Zooza API communicates errors and how to troubleshoot common issues.
---
## Response format
When an error occurs, the API returns a JSON response with error details. Example:
```json
{
"error": "unauthorized",
"message": "Invalid or expired token"
}
```
---
## HTTP status codes
| Status code | Meaning | Description |
|-------------|---------|-------------|
| `200` | OK | Request succeeded |
| `400` | Bad request | Invalid request body or parameters |
| `401` | Unauthorized | Missing or invalid authentication headers |
| `403` | Forbidden | Valid credentials but insufficient permissions |
| `404` | Not found | Resource does not exist |
| `422` | Unprocessable entity | Request is well-formed but contains invalid data |
| `429` | Too many requests | Rate limit exceeded |
| `500` | Internal server error | Something went wrong on Zooza's side |
---
## Common error scenarios
### Authentication errors
| Symptom | Cause | Solution |
|---------|-------|----------|
| `401` on every request | Missing `X-ZOOZA-API-KEY` header | Add your API key header to all requests |
| `401` after working previously | Token expired or invalidated | Re-authenticate to obtain a new token |
| `401` on API endpoints | Using a widget API key instead of a REST API key | Widget keys cannot be used for REST API calls — contact support for a REST API key |
| `403` on specific endpoints | User role lacks permission | Check user permissions, use an Owner-role account for full access |
| `401` with valid credentials | Wrong API region | Verify your base URL matches your account's region (see [API regions](index.md#base-urls)) |
### Widget errors
Common widget integration issues:
- **Widget not loading:** Check that the API key is correct and the script tag is properly embedded.
- **CORS errors in browser console:** Ensure your domain is registered in the widget group settings.
- **Widget shows no data:** Verify that your Zooza account has active programmes and classes.
### Registration errors
Registration and booking errors may occur when:
- The class is at full capacity
- The registration period has closed
- Required customer fields are missing
- Payment validation fails
---
## Debugging tips
1. **Check browser DevTools** (for widgets) — look at the Network tab for failed requests and their response bodies.
2. **Log full response bodies** — error messages often contain specific details about what went wrong.
3. **Verify your API region** — make sure your base URL matches your account's region ([see regions](index.md#base-urls)).
4. **Check application type** — Widget keys cannot access admin endpoints. Verify your key type matches the endpoints you're calling.
5. **Test with cURL first** — isolate whether the issue is in your code or in the API request itself.
---
# Introduction to widgets
**Zooza widgets are the primary way your customers interact with your services online. Seamless integration ensures a smooth experience for both you and your clients. Widgets can be added easily using a WordPress plugin, a Wix plugin, or by pasting a snippet of JavaScript code into your website.**
However, this is just the beginning — Zooza offers unparalleled customization for both the appearance and behavior of each widget, giving your business a unique edge.
:::tip See it in practice
- [All integration examples](/examples)
:::
## Installation
You can integrate Zooza widgets using one of our official plugins for WordPress or Wix for quick and simple setup, or you can embed them manually. But before you install any widget on your website, consider how you want to use it. This mainly concerns the booking process. Other aspects of the integration are typically very straightforward.
### Minimum system requirements
- Your content management system needs to either be able to install our plugins (Wix, WordPress), or you need to be able to insert plain unfiltered JavaScript into your pages. Most modern systems allow this, but be sure to consult this with your system of choice.
- If you can't use JavaScript, you need to have admin rights to be able to install our plugins.
- Other than that, you're good to go as long as your website is served via HTTPS.
### Cookies & LocalStorage
Zooza is considered a necessary cookie and as such should be added to exceptions, so that it stays active even if a user declines all cookies within the cookie consent manager. Being unable to read and write cookies will break your website's functionality — people won't be able to access their customer profile or book sessions, so it is crucial to allow Zooza to run at all times.
Zooza uses these cookies:
- `token` - Customer's unique token
- `token-legacy` - Customer's unique token
Zooza additionally stores logged-in customer's data in localStorage so that it is available for website developers to display to the customer if required:
- `zooza_user` - Contains information about the logged-in user: `id`, `first_name`, `last_name`, `email`, `phone`, `avatar`
### Customer journey scenarios
How your customers navigate to a booking:
**Via map** — Your main focal point is a map, where customers search for an address first. From there, they select their class and proceed directly to booking.
**Via calendar of activities** — You have either one central calendar or more smaller calendars based on location or activity, and from there customers discover your programs and go to bookings.
**Via landing pages** — Your website is built around landing pages for each activity or location. You want to provide a booking form directly on these pages, pre-filtered so customers just fill in their information.
**Via single central booking form** — You have a centralized booking form that has a listing of all available classes. Customers already know where they want to book, and you just provide them with a single point where they can quickly navigate to a class of their choosing.
**All of the above** — Many websites combine these approaches to create multiple pathways to a booking based on customer preferences.
## Types of widgets
**[Registration/Booking Widget](registration-widget.md)** — Allows users to register or book services directly from your website.
**[Calendar Widget](calendar-widget.md)** — Displays available dates and times for your classes and sessions.
**[Map Widget](map-widget.md)** — Shows locations where your services are available and allows customers to search for a class based on the location.
**[Profile Widget](profile-widget.md)** — Lets users view and manage their personal information and bookings.
**[Video Widget](video-widget.md)** — Embeds video content related to your services.
**[Checkout Widget](checkout-widget.md)** — Sells your products, including entry passes with a payment plan, and takes the buyer through payment. Replaces the [legacy v2 checkout](checkout-widget-v2.md).
**[Contact Widget](contact-widget.md)** — Replaces your website's contact form and turns every enquiry into a contact in Zooza.
### Installation options
:::warning Important
Remember, you can install as many widgets as you want but there is one general rule: **Only one widget per page**.
:::
#### WordPress
Ideal for quick and hassle-free integration. Just search for keyword Zooza in official plugin directory.
Read more about setup and configuration options.
#### Wix
Wix doesn't allow for embedding custom JavaScript code and generally doesn't work well with embedded widgets. Therefore using this plugin is your only way of integrating Zooza if you're on this platform.
Read more.
#### Manual embedding
This option gives you most flexibility but is definitely targeted for more technically skilled persons although it does not necessarily require any coding.
## Customisation
### Appearance
Zooza widgets are embedded directly into your website, making them look and feel like a natural part of your site. You can further customize their appearance by overriding the default CSS directly on your website. This allows you to hide elements you don't need, change the appearance of buttons, or even adjust the order of certain elements.
The best way to override Zooza's default styles is to prefix your CSS selectors with anything that precedes Zooza's main `
` tag, which is prefixed with the class `.zooza`.
If you wish, you can even disable each widget's default CSS entirely.
**Note:** Disabling the default CSS will remove all built-in styles, so you must ensure that you also provide styles for essential utility classes such as `.hidden`. You can turn off the CSS in the Zooza app: `Publish > Widget > [Widget type] > Use CSS`.
### Behaviour
Many widgets can have their behaviour configured generally in two ways:
- Via app settings
- By changing the initialisation parameters before the widget loads
Both ways are described in more depth in the individual widget's pages.
## Advanced
### Importance of a URL
Each widget's main configuration property is its URL. This is because you need to let Zooza know where on your website the widget is installed. Zooza will often point your customers between individual widgets — for example, from the Map widget to a Registration widget, or from the Profile widget to the Checkout form. Therefore it is crucial for Zooza to be informed where the **main instance** of your widget resides.
Main instance is simply the most general location on your website where the registration widget resides.
**Good examples:**
- A page titled Registration (or Booking)
- A page without any title and without any other content on it apart from the form itself
- A page where user generally doesn't need to scroll to see the booking form
**Not recommended:**
- A page for a specific course or program
- A page where there is other unrelated content and form does not appear in prominent page
- A page where the widget's default display settings are overridden or customised
### Installation on multiple websites
Technically speaking, all widget types form a **group** which share **same API key**. You can place as many instances of a widget on a page and those instances can use the same API key over and over again. You may even place the widgets with the same API keys on different domains however this is not recommended as per point made above about the importance of a URL.
However, if you have multiple websites or your website is huge and has distinct sections, perhaps even with distinct branding, then it is appropriate to create a new group of widgets in `Publish > Create` and install each set of widgets on a different domain. This way you will be able to keep track of which registration came through which channel (domain), as it will be listed next to every registration.
:::info If you are a franchise
If you are a Franchise and your website includes a separate page for each individual Franchisee (and they don't have their own websites), then it is **NOT appropriate** to create a new widget group per franchisee. Each individual franchisee will have their own Zooza account thus will have their own separate widget group with a distinct API key.
Learn more about how to install Zooza on a franchise managed website
:::
---
# Choosing an embed method
**Every Zooza widget is embedded with two pieces: a placeholder element marking where the widget renders, and the Zooza loader script. The only choice is where the loader goes.**
## The embed
The placeholder names the widget type and carries your API key:
```html
```
The loader is a single script tag. Its URL carries your region host and the widget version:
```html
```
| Widget | `data-zooza-widget` | Loader version |
|---|---|---|
| Registration | `registration` | `v1` |
| Profile | `profile` | `v1` |
| Contact | `contact` | `v1` |
| Checkout | `checkout` | `v1` |
| Calendar | `calendar` | `v2` |
| Video | `video` | `v2` |
| Map | `map` | `v2` |
The widget mounts inside the placeholder.
## Where to put the loader
### Body only
Both pieces go in the ``, at the spot where the widget should appear:
```html
```
This is the simplest option — one copy-paste, no template changes.
### Head + body
The loader goes in the page ``, the placeholder in the ``:
```html
```
The loader is requested while the browser is still parsing the page, so the widget area fills sooner.
## Which one should I use?
| | Body only | Head + body |
|---|---|---|
| Where it goes | Placeholder and loader together in the body | Loader in ``, placeholder in `` |
| Starts loading | When the browser reaches the snippet | During page parse, as early as possible |
| Best for | Quickest setup, page builders that only allow body content | Pages where the widget is the main content |
If the widget sits below the fold and the page is light, the difference is not worth restructuring your template for. If the widget is the main content of the page, put the loader in the head.
## Legacy script
Before the placeholder embed, widgets were embedded with a single inline `
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the widget to appear:
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
:::note Legacy embed
This is the original embed snippet. It remains fully supported and existing embeds keep working, but it is no longer maintained or developed. New capabilities, such as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder), are only available with the placeholder embed.
:::
Place the following snippet directly into the `` of your page, where you want the widget to appear.
```html
```
{`I'm integrating the Zooza registration/booking widget into my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Tell me exactly where in my page to place this snippet. Both lines can stay together in the , or the loader
```
```plaintext
?course_list_display=grid
```
```html
```
Accepted values: `'select'`, `'grid'`. Anything else is ignored and the tenant setting is used.
:::
:::info Need a fully custom layout?
If the built-in grid doesn't match your design, register the [`render_course_tile`](#render_course_tile) callback to take over rendering of each tile. The widget falls back to the value of this setting whenever the callback isn't registered. Registering `render_course_tile` forces grid mode regardless of the tenant setting or any per-page override.
:::
### Course grid columns
Only applies when **Course list display** is set to **Grid**. Picks the number of columns the grid renders. Use 1 for a single-column list, 2–4 for a wider layout. The setting persists even when you switch the display back to Dropdown, so toggling between modes doesn't lose your column preference.
| Value | Description | Example Value |
|---|---|---|
| 1 (Default) | Single column — equivalent to a vertical list. | _Stacked tiles_ |
| 2 | Two columns. | _2 × N grid_ |
| 3 | Three columns. | _3 × N grid_ |
| 4 | Four columns. | _4 × N grid_ |
:::info Per-page override
The setting above is the tenant-wide default. Individual pages can override it via `window.ZOOZA` or URL query.
```javascript
```
```plaintext
?course_list_columns=3
```
```html
```
Accepted values: `1`, `2`, `3`, `4`. Values outside this range fall back to `1`.
:::
### Transfer the website visitor to the form
This is useful if the registration form is not on the top of the page and users will need to scroll to see it. By default this is turned off.
### Hide field for discount codes
This will hide the discount code form field in the booking form. By default this is turned on.
### Use CSS
This will load default Zooza styling. By default this is turned on. Typically you only want to override couple of styles but if you want, you can turn this off and create your own styling from scratch. However we recommend downloading the default styling and go from there, instead of building everything from scratch.
You can download the default css from this URL:
`API_URL/widgets/v1/css/?widget=YOUR_API_KEY&type=registration`
See [valid options](#embed-code) for `API_URL` and `YOUR_API_KEY` above.
## Initialisation options
In general, you can customise how the widget is initialized either via JavaScript or via URL query parameters. Due to legacy reasons, some of the parameters are defined into `document.zooza` and some into `window.ZOOZA`. It is always shown along each property which way to use. However the concept stays the same: insert the script tag before the embed code.
:::info Customising the course and schedule lists
The widget gives you three ways to control how courses and classes appear:
1. **Admin Settings (with per-page override)** — [Course list display](#course-list-display) and [Course grid columns](#course-grid-columns). Tenant-wide defaults, also overridable per page via `window.ZOOZA` / URL.
2. **[`course_list_collapse_on_select`](#course_list_collapse_on_select) / [`schedule_list_collapse_on_select`](#schedule_list_collapse_on_select)** — JS or URL-query flags for keeping all tiles visible after a selection.
3. **[`render_course_tile`](#render_course_tile) / [`render_schedule_tile`](#render_schedule_tile) callbacks** — return the inside of a single tile; the widget keeps owning the shell, click delegation, and collapse behaviour.
Callbacks always win over admin settings — registering `render_course_tile` forces grid mode even if **Course list display** is set to **Dropdown**.
:::
### `filter_courses`
_Type: Array, String_
This will allow you to limit which courses are shown in the booking form.
| Value | Description | Example Value |
|---|---|---|
| `YOUR_COURSE_ID` | Array of course ids. | `[ 123, 1234 ]` For WordPress see note in its tab. |
```javascript
```
Enter ids as a string delimited by pipe: `123|123`
```plaintext
[zooza type="registration" filter_courses="YOUR_COURSE_ID"]
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `course_ids`
_Type: Array, String_
This is the same as [`filter_courses`](#filter_courses) but instead it is defined like this:
```javascript
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `course_id`
_Type: Integer_
Use this in URL query to filter classes by course. Only single course ID is allowed. You can combine this with `schedule_id` and `place_id` to preselect concrete class.
| Value | Description | Example Value |
|---|---|---|
| `YOUR_COURSE_ID` | Course ID. | `123` |
```plaintext
https://sample-site.com/registration?course_id=YOUR_COURSE_ID
```
```html
```
### `filter_places`
_Type: Array, String_
This will limit the displayed classes based on given location(s). This filter uses place id thus doesn't allow for filtering to a classroom level.
| Value | Description | Example Value |
|---|---|---|
| `YOUR_PLACE_ID` | Array of place ids. | `[ 123, 1234 ]` For WordPress see note in its tab. |
```javascript
```
Enter ids as a string delimited by pipe: `123|123`
```plaintext
[zooza type="registration" filter_places="YOUR_PLACE_ID"]
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `place_ids`
_Type: Array, String_
Limit the widget to specific venues. This is the `window.ZOOZA` form of place filtering, and is the counterpart to [`filter_places`](#filter_places) in the same way [`course_ids`](#course_ids) is the counterpart to [`filter_courses`](#filter_courses).
| Value | Description | Example Value |
|---|---|---|
| `YOUR_PLACE_ID` | Array of place ids. | `3` or `[ 3, 8 ]` |
```javascript
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `place_id`
_Type: String_
Use this in URL query to filter classes by place. Only single place ID is allowed. You can combine this with `schedule_id` and `course_id` to preselect concrete class.
| Value | Description | Example Value |
|---|---|---|
| `YOUR_PID` | PID consists of `place_id` and a `room_id`. They are concatenated using underscore. If you don't want to specify room, just use zero (`123_0`) | `123_123` |
```plaintext
https://sample-site.com/registration?place_id=YOUR_PID
```
```html
```
### `schedule_id`
_Type: Integer_
Use this in URL query to filter classes by their ID. Only single class ID is allowed. You **NEED** to combine this with `place_id` and `course_id` to preselect concrete class. All three parameters need to be present if you want to select concrete class.
| Value | Description | Example Value |
|---|---|---|
| `YOUR_CLASS_ID` | Class id | `123` |
```plaintext
https://sample-site.com/registration?schedule_id=YOUR_SCHEDULE_ID
```
```html
```
### `labels_in`
_Type: Array of strings, or pipe-delimited string_
Show only courses tagged with at least one of the listed labels. Values are **label names** as shown in the admin UI — case-sensitive, including spaces and punctuation (e.g. `"Summer 2026"`, `"Kids — beginners"`). Label ids are not accepted here; the legacy id-based label filter is a separate, untouched mechanism.
Composes with [`labels_not_in`](#labels_not_in) (AND), and with [`course_ids`](#course_ids) / [`filter_places`](#filter_places) / [`schedule_id`](#schedule_id) (also AND). An empty array, empty string, or omitted value means no filter.
This option works alongside the [tile-grid course list](#course-list-display) and the [`render_course_tile`](#render_course_tile) callback — label filtering is applied first, then the surviving courses are rendered in whatever display mode you've chosen.
:::info Private labels are silently invisible
Private (admin-only) labels never match for embedders. There is no client-visible difference between "label doesn't exist" and "label exists but is private" — both produce zero matches. If a label is unpublished after the widget is embedded, the courses tagged with it will silently disappear from the list.
:::
```javascript
```
```plaintext
?labels_in=Summer 2026|Kids — beginners
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `labels_not_in`
_Type: Array of strings, or pipe-delimited string_
Hide courses tagged with any of the listed labels. Values are **label names** as shown in the admin UI — case-sensitive, including spaces and punctuation. Label ids are not accepted.
Composes with [`labels_in`](#labels_in) (AND), and with [`course_ids`](#course_ids) / [`filter_places`](#filter_places) / [`schedule_id`](#schedule_id). An empty array, empty string, or omitted value means no filter.
```javascript
```
```plaintext
?labels_not_in=Archived|Internal
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `metadata_in`
_Type: Object — per-key value is an array of strings or pipe-delimited string_
:::note JavaScript only
This option takes an object keyed by metadata name. It cannot be set as a `data-zooza-*` attribute on a head/body placeholder — HTML attributes are strings. Use a `
```
```plaintext
?metadata_in[color]=red|blue&metadata_in[age_band]=6-8
```
### `metadata_not_in`
_Type: Object — per-key value is an array of strings or pipe-delimited string_
:::note JavaScript only
This option takes an object keyed by metadata name. It cannot be set as a `data-zooza-*` attribute on a head/body placeholder — HTML attributes are strings. Use a `
```
```plaintext
?metadata_not_in[archived]=true
```
### `course_list_collapse_on_select`
_Type: Boolean_
By default, when a customer picks a course from the grid, the other tiles are hidden so the form can focus on the next step. The selected tile gets a "back to all courses" affordance the customer can use to change their mind. Set this to `false` to keep every tile visible at all times — useful when you want customers to compare courses side by side as they progress.
Only meaningful when [Course list display](#course-list-display) is set to **Grid**. In Dropdown mode the setting has no effect.
| Value | Description | Example Value |
|---|---|---|
| `true` (Default) | After a customer picks a course, other tiles are hidden until they pick "back to all courses". | _Focused single-tile view_ |
| `false` | All tiles stay visible after a selection; the chosen tile is highlighted with a `selected` class. | _Side-by-side comparison_ |
```javascript
```
```plaintext
?course_list_collapse_on_select=false
```
```html
```
### `schedule_list_collapse_on_select`
_Type: Boolean_
By default, after the customer picks a class from the schedule list, the other classes are hidden and the chosen one stays on screen with a "change" link. Set this to `false` to keep all classes visible — useful when customers should be able to scan the full list as they fill in the form, or when the form sits beside a fixed schedule overview.
| Value | Description | Example Value |
|---|---|---|
| `true` (Default) | After a class is picked, the other tiles collapse. The chosen tile shows a "change" link. | _Focused single-tile view_ |
| `false` | All tiles remain visible after a selection. | _Full schedule stays on screen_ |
```javascript
```
```plaintext
?schedule_list_collapse_on_select=false
```
```html
```
### `ps` (Payment schedule type)
_Type: String_
Use this setting either in combination with **f** or as a standalone preset to preselect first instance of available payment schedules by type.
Allowed values:
| Value | Label |
|---|---|
| `single_payment` | Single payment |
| `in_advance` | In advance |
| `by_attendance` | By attendance |
| `pay_as_you_go` | Pay as you go |
```javascript
```
```plaintext
https://sample-site.com/registration?ps=pay_as_you_go
```
```html
```
### `f` (Payment schedule frequency)
_Type: String_
Use this setting either in combination with **ps** or as a standalone preset to preselect first instance of available payment schedules by frequency.
Allowed values:
| Value | Label |
|---|---|
| `monthly` | Monthly |
| `quarterly` | Quarterly |
| `half_yearly` | Half yearly |
| `yearly` | Yearly |
| `after_events` | After events |
| `absolute` | Absolute |
| `segments` | Blocks |
```javascript
```
```plaintext
https://sample-site.com/registration?f=yearly
```
```html
```
### `multi_step_form`
_Type: Bool_
By default the registration form appears as a single step form, that reveals its parts as user navigates through a selection. You can turn this form into multiple steps:
- Course/Class selection
- Personal information
- Product options (optional)
- Payment options
```javascript
```
```plaintext
https://sample-site.com/registration?multi_step_form=true
```
```plaintext
[zooza type="registration" multistep="true"]
```
```html
```
### `preferred_currency`
_Type: String (Three letter ISO 4217 code)_
This settings will override default currency shown in the form which is based on your Zooza account's region. Even though the form will display prices for your currency, in order for this to work, your currency also needs to be set up on the course level for every course shown in the booking form. This setting can be done in: `Courses > COURSE > Settings > Price settings > Additional currencies`
:::warning When to use this
This enables you to sell your classes in different currency than defined by your region. If you want to collect money for all bookings in the same currency consider changing the region. This setting is intended for cases where you want to allow dual currency regime on a subset of your products.
:::
| Value | Description | Example Value |
|---|---|---|
| `CODE` | Three letter ISO 4217 currency code | `CZK` |
```javascript
```
```html
```
### `print_debug`
_Type: Bool_
This will print additional debug information to the browser's console. It is especially useful for tracking translations if you want to replace some of the default texts.
```javascript
```
```html
```
### `translations`
_Type: Object_
:::note JavaScript only
This option takes an object of custom strings. It cannot be set as a `data-zooza-*` attribute on a head/body placeholder — HTML attributes are strings. Use a `
```
:::note How to find KEY
Enable `print_debug` mode and open your browser's console. You will see printouts like this: `Text Key: notifications.title Translation: Notifications`. The `Text key` is the key used by application and `Translation` is its default translation. Notice that keys appear as you progress through the form. You can identify each key by searching for the Translation in the console.
:::
**Example**
```javascript
```
{`I want to customise the text labels shown in my Zooza registration widget using the translations option.
Read the Zooza widget documentation for the translations option and how to find translation keys:
https://docs.zooza.online/llms-full.txt
Tasks:
1. Give me a window.ZOOZA script block with a translations object.
2. Override the labels I list below with my preferred wording, using the correct translation keys.
3. Tell me where to place this script relative to the widget embed snippet.
Labels I want to change:
- [e.g. the "Register" button -> "Book your spot"]
- [add more here]
If you don't know a key, tell me to enable print_debug and read it from the browser console.`}
### `registration_display_mode`
_Type: [Enum](../enums.md#registration_display_mode)_
By default, if class supports more registration modes (e.g. Trial registrations or Segments), registration widget will present customer with all available options. If you want to limit this, you can do that using this property.
Example usages:
- Your class contains blocks and you only want your customers to select the blocks
- You offer trial lessons on a landing page for new customers. And you place a second instance of the widget in the members area where you'll offer more options
| Value | Description | Example Value |
|---|---|---|
| `REGISTRATION_DISPLAY_MODE` | [Enum](../enums.md#registration_display_mode) of your choice | `trials_only` |
```javascript
```
```html
```
### `lang`
_Type: String (BCP 47 locale code)_
Language of the widget is defined by your Zooza account. You can change this language in `Settings > General > Language`. However, you can override widget's language by setting its `lang` property to one of [supported languages](../enums.md#supported-languages).
| Value | Description | Example Value |
|---|---|---|
| `LANGUAGE_CODE` | [Language code](../enums.md#supported-languages) of your choice | `en-EN` |
```plaintext
https://sample-site.com/registration?lang=LANGUAGE_CODE
```
```html
```
```html
```
:::info How display language is determined
Although the widget's language is mainly determined by language set in your account, it is not the only determinant. The widget's language is determined in this order:
1. URL Query `lang` parameter
2. `lang` parameter read from browser's `LocalStorage()` _(this is set when user opts for a language using language selector at the bottom of the widget)_
3. `lang` attribute of `html` tag
4. language set in the Zooza
:::
:::info Multilingual websites
If you have multilingual website and you are using a content management system (such as WordPress) then you don't need to set up anything as these systems will typically set current page's language `html` attribute and the widget will automatically use this prior selecting the language defined in your account _(as described in box above)_.
:::
## Analytics
Registration widget will trigger following events for Google Tag Manager's DataLayer and Meta's Pixel:
| Event | Description | Event data |
|---|---|---|
| `zooza_event_form_loaded` | Triggered when the registration form is displayed to the user | `zooza_form_loaded`: true |
| `zooza_event_form_submit_start` | Triggered immediately after the submit button is clicked, before the request is sent | `zooza_total_price`: Total value of the booking (including future payments); `zooza_currency`: Currency; `zooza_pay_now`: Amount to pay now |
| `zooza_event_form_submit_done` | Triggered after the response is received from the server | `zooza_event_form_submit_done`: true |
| `zooza_event_form_error` | Triggered if the response contains any errors | |
| `zooza_event_form_submit_thank_you` | Triggered if registration is successful and no further steps are needed | `zooza_form_submit_thank_you`: true |
| `zooza_event_form_submit_go_to_payment` | Triggered if the user is taken to the payment step | `zooza_form_submit_go_to_payment`: true |
| `zooza_event_payment_response` | Triggered when the user returns from the payment gateway | `zooza_payment_response` (string) `ok` or `fail` |
| `zooza_event_confirm_registration` | Triggered when the user clicks the confirmation link in the email | `zooza_registration_id` (int) |
| `zooza_event_enroll_started` | Triggered when the user proceeds after a trial lesson | `zooza_enroll_started`: true |
| `zooza_event_reenroll_started` | Triggered when the user proceeds after a trial lesson | `zooza_reenroll_started`: true |
| `zooza_event_accept_waitlist` | Triggered when a user accepts a spot from a waitlist notification (for make-up lessons) | `zooza_accept_waitlist`: true |
| `zooza_event_turn_off_notifications` | Triggered when the user disables notifications in Full courses | `zooza_turn_off_notifications`: true |
| `zooza_event_turn_off_upcoming_events_notifications` | Triggered when the user disables reminders in Open courses | `zooza_turn_off_upcoming_events_notifications`: true |
| `zooza_event_cancel_event` | Triggered when the user cancels a session via the email link | `zooza_cancel_event`: true |
| `zooza_event_form_submit_error` | Triggered when form on submit returns an error (e.g. booking failed due to an error) | `zooza_event_form_submit_error` (bool) |
| `zooza_event_share_link_opened` | Triggered when Share link is used | `zooza_share_link_opened` (bool) |
Each event will also pass event data, which is an object with one or multiple properties. The one that will likely interest you most is the `zooza_event_form_submit_start` event, which also sends the value of the order to analytics.
### Guides
- [How to install Google Tag Manager](https://support.google.com/tagmanager/answer/6103696?hl=en)
- [How to install Meta Pixel](https://www.facebook.com/business/help/952192354843755)
### URL Hashtag-Based Tracking (For Registration Confirmation)
Besides event-based triggers, Zooza also supports conversion tracking via URL fragments (hashtags).
You'll see these appear after key steps in the registration flow. They can be used in Google Analytics, Meta Ads, or other platforms for measuring conversion milestones.
**First Phase – Submitted Form**
| Hashtag | Description |
|---|---|
| `#done` | Registration email sent. At this point, the user is recorded in Zooza and this counts as a conversion. |
| `#registration_failed` | Form submission failed. |
**Second Phase – Confirmed Email**
| Hashtag | Description |
|---|---|
| `#confirm_registration` | Triggered when the user confirms their registration via email. |
| `#confirm_registration_thank_you` | Triggered when the user reaches the final "Thank You" screen (registration complete). |
#### Notes on hashtag tracking
- **Dynamic URLs**: Zooza registration links often include query parameters like `?course_id=123&place_id=456`. Always match using `URL contains #YOUR_HASHTAG`.
- **Use Regex for Flexibility**: For more robust tracking across locations or courses, use regular expressions in your rules.
- **Tracking per Location**: Hashtags don't carry location data. If you need to track conversions per branch or subpage, combine hashtags with UTM parameters or URL path logic.
- **Thank You Page Tip**: `#confirm_registration_thank_you` only appears when the registration is fully completed. This helps distinguish between users who just clicked the email vs. those who reached the final step.
## Events and callbacks
:::note JavaScript only
Callbacks take functions, so none of them can be set as a `data-zooza-*` attribute on a head/body placeholder. Register them in a `
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the widget to appear:
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
:::note Legacy embed
This is the original embed snippet. It remains fully supported and existing embeds keep working, but it is no longer maintained or developed. New capabilities, such as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder), are only available with the placeholder embed.
:::
Place the following snippet directly into the `` of your page, where you want the widget to appear.
```html
```
{`I'm integrating the Zooza calendar widget into my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Tell me exactly where in my page to place this snippet. Both lines can stay together in the , or the loader
```
Enter ids as a string delimited by pipe: `123|123`
```plaintext
[zooza type="calendar" filter_courses="YOUR_COURSE_ID"]
```
```html
```
Multiple values are comma-separated, not pipe-delimited as in the URL form.
### `course_id`
_Type: String_
Pass any valid course_id, or array of course_ids delimited by pipe character `|`.
**In URL Query**
| Value | Description | Example Value |
|---|---|---|
| `YOUR_COURSE_ID` | Course ID or list of course IDs delimited by pipe. | `123` |
```plaintext
https://sample-site.com/calendar?course_id=YOUR_COURSE_ID
```
```html
```
### `place_id`
_Type: String_
Presets the active location in the location selection. You don't need to provide room_id, although place_id is required in order for room_id to work properly. You can also submit array of place_ids delimited by pipe character `|`.
**In URL Query**
| Value | Description | Example Value |
|---|---|---|
| `YOUR_PLACE_ID` | Place id or list of place ids delimited by pipe. | `123` |
```plaintext
https://sample-site.com/calendar?place_id=YOUR_PLACE_ID
```
```html
```
### `hide_filter_course`
_Type: Bool_
Overrides widget's setting and hides the calendar filter.
```javascript
```
```html
```
### `bps`
_Type: Integer_
Show only a specific billing period (term block). The value is a `billing_period_id`, which limits the calendar to sessions belonging to that term block.
| Value | Description | Example Value |
|---|---|---|
| `BILLING_PERIOD_ID` | Id of the billing period (term block). | `42` |
The `window.ZOOZA` form additionally requires `pbs` to be truthy — it is a legacy enable-flag for this form only, and is not needed for the URL Query or Data attribute forms.
```javascript
```
```plaintext
?bps=BILLING_PERIOD_ID
```
```html
```
## Events
### `event_tile_render`
:::note JavaScript only
This option takes a callback function. It cannot be set as a `data-zooza-*` attribute on a head/body placeholder — HTML attributes are strings. Use a `
```
## Import your own sessions
If you need to merge multiple data sources into the calendar, you can do that by importing the data at page load via JS property.
### Adding locations
| Parameter | Mandatory | Note |
|---|---|---|
| `place_id` | yes | Unique ID that cannot be the same as in Zooza |
| `room_id` | yes | Unique ID that cannot be the same as in Zooza. You can provide 0 if you don't require rooms. |
| `name` | yes | Name of the place |
| `region` | no | Name of the region/neighborhood. |
**Example**
```javascript
```
### Adding sessions
| Parameter | Mandatory | Note |
|---|---|---|
| `place_id` | yes | This is the place_id used to filter the tiles. Place id has to be valid and be either pulled from zooza, or imported in previous step. |
| `room_id` | no | Same rules apply as for place_id. |
| `schedule_id` | no | Schedule/Group ID in Zooza or your external registration system |
| `event_id` | no | Event/Lesson ID in Zooza or your external registration system |
| `course_id` | no | Course ID in Zooza or your external registration system |
| `name` | yes | Name of the course |
| `class_name` | no | CSS class name applied to event's tile |
| `url` | yes | URL to more information about the course (landing page). In case of courses managed in Zooza, this is pulled from Courses.url property |
| `registration_url` | no | URL to custom registration form |
| `time` | no | Time of the beginning of the lesson in HH:mm (H:i) |
| `date_start` | yes | Date of start of the course in YYYY-MM-DD format (Y-m-d) |
| `date_end` | no | Date of the end of the course in YYYY-MM-DD format (Y-m-d) |
| `trainer` | no | Name of lecturer |
| `capacity` | no | Total capacity of group |
| `capacity_left` | no | How many places are left to register |
| `event_count` | no | Count of the events in the group |
**Example**
```javascript
```
---
# Map widget
**This widget provides a map view of your locations. Upon selecting a location, its contact details are shown and available classes for this location are listed below the map. When a class is clicked, the customer is taken to the [Registration Form](./registration-widget.md).**
:::tip See it in practice
- [Branch and franchise location map](/examples/location-map)
:::
## Installation
### WordPress
When your WordPress plugin is installed, just head to `Settings > Zooza` and from dropdown of pages, select a page where you want the form to appear.
#### Shortcodes
You can also use shortcodes to place the form anywhere within the page. More configuration options for shortcodes is described below in their respective sections.
```plaintext
[zooza type="map"]
```
### Wix
In Wix editor, click on Zooza widget. In the `Settings` panel, enter the api key and as a widget choose `Map`.
### Embed code
Embed this widget with a placeholder element and the Zooza loader. The loader can sit in the `` right after the placeholder, or in the `` so the widget starts loading earlier. See [Choosing an embed method](./embed-methods.md) if you are not sure which one to use.
| Placeholder | Description | Example Value |
|---|---|---|
| `YOUR_API_KEY` | Replace with the API key found in the application under `Publish > Widget`. Appears once, on the placeholder (twice in the legacy script). | `abc123xyz` |
| `ZOOZA_API_URL` | Replace with the Zooza API URL for your region: Europe: `https://api.zooza.app`, UK: `https://uk.api.zooza.app`, UAE: `https://asia.api.zooza.app` | `https://api.zooza.app` |
Place the placeholder and the loader in the `` of your page, where you want the widget to appear.
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the widget to appear:
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
:::note Legacy embed
This is the original embed snippet. It remains fully supported and existing embeds keep working, but it is no longer maintained or developed. New capabilities, such as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder), are only available with the placeholder embed.
:::
Place the following snippet directly into the `` of your page, where you want the widget to appear.
```html
```
{`I'm integrating the Zooza map widget into my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Tell me exactly where in my page to place this snippet. Both lines can stay together in the , or the loader
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the widget to appear:
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
:::note Legacy embed
This is the original embed snippet. It remains fully supported and existing embeds keep working, but it is no longer maintained or developed. New capabilities, such as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder), are only available with the placeholder embed.
:::
Place the following snippet directly into the `` of your page, where you want the widget to appear.
```html
```
{`I'm integrating the Zooza profile widget (customer portal) into my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Tell me exactly where in my page to place this snippet (this is a logged-in members area). Both lines can stay together in the , or the loader
Zooza` and from dropdown of pages, select a page where you want the form to appear.
#### Shortcodes
You can also use shortcodes to place the form anywhere within the page. More configuration options for shortcodes is described below in their respective sections.
```plaintext
[zooza type="video"]
```
### Wix
In Wix editor, click on Zooza widget. In the `Settings` panel, enter the api key and as a widget choose `Video`.
### Embed code
Embed this widget with a placeholder element and the Zooza loader. The loader can sit in the `` right after the placeholder, or in the `` so the widget starts loading earlier. See [Choosing an embed method](./embed-methods.md) if you are not sure which one to use.
| Placeholder | Description | Example Value |
|---|---|---|
| `YOUR_API_KEY` | Replace with the API key found in the application under `Publish > Widget`. Appears once, on the placeholder (twice in the legacy script). | `abc123xyz` |
| `ZOOZA_API_URL` | Replace with the Zooza API URL for your region: Europe: `https://api.zooza.app`, UK: `https://uk.api.zooza.app`, UAE: `https://asia.api.zooza.app` | `https://api.zooza.app` |
Place the placeholder and the loader in the `` of your page, where you want the widget to appear.
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the widget to appear:
```html
```
Initialisation options can be set directly on the placeholder as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder).
:::note Legacy embed
This is the original embed snippet. It remains fully supported and existing embeds keep working, but it is no longer maintained or developed. New capabilities, such as [`data-zooza-*` attributes](./embed-methods.md#configuring-a-widget-on-the-placeholder), are only available with the placeholder embed.
:::
Place the following snippet directly into the `` of your page, where you want the widget to appear.
```html
```
{`I'm integrating the Zooza video widget into my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Tell me exactly where in my page to place this snippet. Both lines can stay together in the , or the loader
```
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the checkout to appear:
```html
```
The product to sell is not part of the embed code — it comes from the [page URL](#url-parameters). The placeholder accepts the [`lang`](#lang) and [`translations`](#translations) options as `data-zooza-*` attributes.
The loader works out the API host for your region from its own `src`. In the rare case you need a different host, set it with `data-zooza-api-url` on the placeholder.
:::warning One checkout per page
Place only one checkout placeholder on a page. If a page contains more than one, only the first one shows the checkout; the others stay empty.
:::
{`I'm adding the Zooza checkout widget to my website.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Help me create a dedicated checkout page and tell me exactly where to place this snippet on it. Both lines can stay together in the , or the loader
```
:::note Set by Zooza during payment
`r` and `payment_response` are added to the URL by the checkout itself — `r` identifies a payment in progress and `payment_response` carries the result back from the payment gateway. Do not add them to your links.
:::
## Migrating from the v2 checkout
To move a page from the [legacy v2 checkout](./checkout-widget-v2.md) to this widget:
1. Replace the embed code on the page with the [v1 embed code](#embed-code). If you already use the placeholder embed, it is enough to change `/widgets/v2/loader.js` to `/widgets/v1/loader.js`.
2. Keep your links — `?product=` and `?currency=` work the same way. You can now add [`return_url`](#return_url).
3. Rewrite your custom CSS. The v1 checkout uses [new class names](#class-names), so CSS written for the v2 checkout no longer applies.
4. On WordPress, replace the `[zooza type="checkout"]` shortcode with the embed code in a **Custom HTML** block, and clear the checkout page in the plugin settings so the plugin does not add the v2 checkout as well.
## Styling
The checkout widget renders directly into your page (no iframe), and its default styling is built to be overridden:
- Every default rule has zero specificity, so any rule in your stylesheet wins — no `!important` needed.
- By default the checkout inherits the font and text colour of the surrounding page.
- The checkout is at most `660px` wide and centred in the placeholder.
### Theme variables
The quickest way to match your brand is to set CSS custom properties on `.zooza-checkout-widget`, and style the buttons through their class:
```css
.zooza-checkout-widget {
--zooza-accent: #FA6900;
--zooza-border-color: #d0d0d0;
--zooza-radius: 5px;
--zooza-font-family: 'DM Sans', sans-serif;
}
.zooza-checkout-button__primary {
background: #FA6900;
color: #fff;
}
```
The checkout uses the same theme variables as the [contact widget](./contact-widget.md#theme-variables), for example `--zooza-font-family`, `--zooza-accent`, `--zooza-border-color`, `--zooza-radius`, `--zooza-gap` and `--zooza-max-width`.
### Class names
Every element carries two classes: a shared one that is the same in all Zooza widgets built this way, and one scoped to the checkout widget. Variants add a `__variant` suffix. Use the `zooza-checkout-*` classes to style only the checkout.
| Element | Classes |
|---|---|
| Widget root | `.zooza-widget` `.zooza-checkout-widget` |
| Section | `.zooza-section` `.zooza-checkout-section`, e.g. `.zooza-checkout-section__summary`, `__products`, `__buyer`, `__totals`, `__payment-methods`, `__payment` |
| Section heading | `.zooza-heading` `.zooza-checkout-heading`, e.g. `.zooza-checkout-heading__summary` |
| Order summary | `.zooza-summary` `.zooza-checkout-summary` |
| Price totals | `.zooza-totals` `.zooza-checkout-totals` |
| Buttons | `.zooza-button__primary` `.zooza-checkout-button__primary` |
| Field error | `.zooza-error` `.zooza-checkout-error` |
The product options and the payment method chooser still use the older `zooza_*` class names (for example `.zooza_service_option`). Scope any rules for them under `.zooza-checkout-widget`.
The widget root also has a `data-status` attribute — `loading`, `listing`, `data`, `payment`, `thank_you` or `error` — which you can use to style each step:
```css
.zooza-checkout-widget[data-status='thank_you'] {
padding: 2em;
background: #f4faf9;
}
```
{`I've embedded the Zooza checkout widget and now I want it to match my site's existing design.
Read the Zooza widget documentation for the checkout widget's theme variables and class names:
https://docs.zooza.online/llms-full.txt
Tasks:
1. Inspect my site's current design tokens — primary colour, fonts, border radius, spacing — and how my existing forms and buttons look.
2. Write CSS that sets the --zooza-* custom properties on .zooza-checkout-widget to match, and add rules on the zooza-checkout-* classes only where the variables are not enough.
3. Do not use !important.
4. Keep it accessible (visible focus, readable error colour) and responsive on mobile.
My brand: [describe your colours and fonts here, or point me at your stylesheet].`}
---
# Contact widget
**Replace the generic contact form on your website with a Zooza contact form. Every enquiry becomes a contact in Zooza, together with the page it was sent from and the campaign that brought the visitor, so your team can follow it up.**
What the form contains — its fields, consents and what happens after it is sent — is set up in the Zooza app, in a **contact form configuration**. The embed code only decides where the form appears and, optionally, which configuration it shows. Changes you make to a configuration show up on your website straight away, without touching the embed code.
## Installation
### WordPress
The `[zooza]` shortcode does not support the contact widget. Paste the [embed code](#embed-code) into a **Custom HTML** block on the page instead.
### Embed code
Embed this widget with a placeholder element and the Zooza loader. The loader can sit in the `` right after the placeholder, or in the `` so the widget starts loading earlier. See [Choosing an embed method](./embed-methods.md) if you are not sure which one to use.
| Placeholder | Description | Example Value |
|---|---|---|
| `YOUR_API_KEY` | Replace with the API key found in the application under `Publish > Widget`. | `abc123xyz` |
| `ZOOZA_API_URL` | Replace with the Zooza API URL for your region: Europe: `https://api.zooza.app`, UK: `https://uk.api.zooza.app`, UAE: `https://asia.api.zooza.app` | `https://api.zooza.app` |
Place the placeholder and the loader in the `` of your page, where you want the form to appear.
```html
```
Place the loader in the `` of your page:
```html
```
Then place the placeholder in the ``, where you want the form to appear:
```html
```
This embed shows the configuration selected for the widget in the app, or your company's default one. To show a specific configuration on a page, add [`data-zooza-config-id`](#config_id) to the placeholder. Initialisation options and [hidden field values](#hidden-fields) are also set directly on the placeholder as `data-zooza-*` attributes.
The loader works out the API host for your region from its own `src`. In the rare case you need a different host, set it with `data-zooza-api-url` on the placeholder.
:::note Placeholder embed only
The contact widget has no legacy script snippet. Use the placeholder and loader shown above.
:::
:::warning One contact form per page
Place only one contact placeholder on a page. If a page contains more than one, the first renders the form and every other one shows the notice _"Only one contact form can be shown per page."_ along with a warning in the browser console.
:::
{`I'm replacing the contact form on my website with the Zooza contact widget.
Read the full Zooza widget & API documentation first for context:
https://docs.zooza.online/llms-full.txt
Here is the embed snippet I need to install:
Tasks:
1. Find my current contact form and tell me exactly what to remove and where to place this snippet instead. Both lines can stay together in the , or the loader
```
```plaintext
https://sample-site.com/contact?config_id=CONFIG_ID
```
### `lang`
_Type: String (BCP 47 locale code)_
Language of the widget is defined by your Zooza account. You can change this language in `Settings > General > Language`. However, you can override widget's language by setting its `lang` property to one of [supported languages](../enums.md#supported-languages).
The language the form is displayed in is also sent with the enquiry, so the automatic reply to the visitor is sent in the same language.
| Value | Description | Example Value |
|---|---|---|
| `LANGUAGE_CODE` | [Language code](../enums.md#supported-languages) of your choice | `en-EN` |
```html
```
```plaintext
https://sample-site.com/contact?lang=LANGUAGE_CODE
```
```html
```
:::info How display language is determined
The widget's language is determined in this order:
1. URL Query `lang` parameter
2. `lang` option, set on the placeholder or in `window.ZOOZA`
3. `lang` attribute of `html` tag
4. language set in the Zooza
:::
### `translations`
_Type: Object_
:::note JavaScript only
This option takes an object of custom strings. It cannot be set as a `data-zooza-*` attribute on the placeholder — HTML attributes are strings. Use a `
```
:::note How to find a key
Enable [`print_debug`](#print_debug) and open your browser's console. Every text the widget displays is printed as `Text Key: contact.submit Translation: Send`.
:::
### `print_debug`
_Type: Bool_
Prints additional debug information to the browser's console. It is especially useful for finding translation keys.
```html
```
```javascript
```
## Events and callbacks
The contact widget has no JavaScript callbacks or custom DOM events. To react to the form being shown or sent, use the [analytics events](#analytics), which the widget pushes to `window.dataLayer` whenever the page has one.
## Styling
The contact widget renders directly into your page (no iframe), and its default styling is built to be overridden:
- Every default rule has zero specificity, so any rule in your stylesheet wins — no `!important` needed.
- By default the form inherits the font and text colour of the surrounding page.
- The form is at most `660px` wide and centred in the placeholder.
### Theme variables
The quickest way to match your brand is to set CSS custom properties on `.zooza-contact-widget`, and style the submit button through its class:
```css
.zooza-contact-widget {
--zooza-accent: #FA6900;
--zooza-border-color: #d0d0d0;
--zooza-radius: 5px;
--zooza-font-family: 'DM Sans', sans-serif;
--zooza-max-width: 100%;
}
.zooza-contact-button__primary {
background: #FA6900;
color: #fff;
}
```
| Variable | Default | Controls |
|---|---|---|
| `--zooza-font-family` | inherited | Font of the whole form |
| `--zooza-font-size` | `1em` | Base font size |
| `--zooza-line-height` | `1.4` | Line height |
| `--zooza-text-color` | `currentColor` | Text colour |
| `--zooza-accent` | `currentColor` | Submit button border, checkboxes and radio buttons, focus ring |
| `--zooza-border-color` | `currentColor` | Borders of inputs, buttons and messages |
| `--zooza-border-width` | `1px` | Input border width |
| `--zooza-radius` | `0.25em` | Corner radius of inputs and buttons |
| `--zooza-gap` | `1em` | Space between fields |
| `--zooza-field-gap` | `0.35em` | Space between a label and its input |
| `--zooza-control-padding` | `0.5em 0.75em` | Padding inside inputs and buttons |
| `--zooza-control-background` | `transparent` | Input background |
| `--zooza-muted-opacity` | `0.7` | Opacity of help texts, disabled controls and the loading state |
| `--zooza-error-color` | `currentColor` | Error messages |
| `--zooza-success-color` | `currentColor` | Success message |
| `--zooza-focus-ring` | `2px solid var(--zooza-accent)` | Focus outline |
| `--zooza-max-width` | `660px` | Maximum width of the form |
### Class names
Every element carries two classes: a shared one that is the same in all Zooza widgets built this way, and one scoped to the contact widget. Variants add a `__variant` suffix. Use the `zooza-contact-*` classes to style only the contact form.
| Element | Classes |
|---|---|
| Widget root | `.zooza-widget` `.zooza-contact-widget` |
| Form | `.zooza-form` `.zooza-contact-form` |
| Field wrapper | `.zooza-field` `.zooza-contact-field` |
| Label | `.zooza-label` `.zooza-contact-label` |
| Required marker | `.zooza-required` `.zooza-contact-required` |
| Text, number and date inputs | `.zooza-input` `.zooza-contact-input`, e.g. `.zooza-contact-input__date` |
| Message and long text | `.zooza-textarea` `.zooza-contact-textarea` |
| Drop-down | `.zooza-select` `.zooza-contact-select` |
| Checkbox list | `.zooza-fieldset__multiselect` `.zooza-contact-fieldset__multiselect` |
| Consents | `.zooza-agreements` `.zooza-contact-agreements`, each `.zooza-agreement` |
| Field error | `.zooza-error` `.zooza-contact-error` |
| Submit button | `.zooza-button__primary` `.zooza-contact-button__primary` |
| Messages | `.zooza-message__error`, `.zooza-message__success`, `.zooza-message__notice` |
The widget root also has a `data-status` attribute — `loading`, `ready`, `error` or `success` — which you can use to style each state:
```css
.zooza-contact-widget[data-status='success'] {
padding: 2em;
background: #f4faf9;
}
```
{`I've embedded the Zooza contact widget (contact form) and now I want it to match my site's existing design.
Read the Zooza widget documentation for the contact widget's theme variables and class names:
https://docs.zooza.online/llms-full.txt
Tasks:
1. Inspect my site's current design tokens — primary colour, fonts, border radius, spacing — and how my existing forms and buttons look.
2. Write CSS that sets the --zooza-* custom properties on .zooza-contact-widget to match, and add rules on the zooza-contact-* classes only where the variables are not enough.
3. Do not use !important, and do not make any hidden inputs inside the widget visible.
4. Keep it accessible (visible focus, readable error colour) and responsive on mobile.
My brand: [describe your colours and fonts here, or point me at your stylesheet].`}
## Analytics
The contact widget triggers the following events for Google Tag Manager's DataLayer, Google Analytics (`gtag`) and Meta's Pixel (as a custom event). Nothing needs to be set up in Zooza — the events are sent to whichever of these tools are already on the page.
| Event | Description | Event data |
|---|---|---|
| `zooza_event_contact_form_view` | Triggered once when the form is displayed to the visitor | `zooza_contact_form_view`: true; `zooza_contact_form_id`: id of the configuration shown |
| `zooza_event_contact_form_submit_start` | Triggered when the visitor submits a form that passed validation, before it is sent | `zooza_contact_form_submit_start`: true; `zooza_contact_form_id` |
| `zooza_event_contact_form_submitted` | Triggered when the enquiry is accepted, before the success message is shown or the visitor is redirected | `zooza_contact_form_submitted`: true; `zooza_contact_form_id` |
Event data never contains the visitor's name, email, phone or message.
Use `zooza_event_contact_form_submitted` as your lead conversion. In Google Tag Manager, create a **Custom Event** trigger with that event name and use it for your GA4 or Google Ads conversion tag. Use `zooza_contact_form_id` to tell apart forms on different pages.
If the configuration redirects the visitor after submitting, the page changes right after the event is pushed. Tags that must fire on the conversion should use that trigger, not a later page view.
{`I want to track enquiries sent through the Zooza contact widget on my website as lead conversions.
Read the Zooza widget documentation for the contact widget's analytics events:
https://docs.zooza.online/llms-full.txt
My setup: [Google Tag Manager / GA4 via gtag / Meta Pixel — describe what is on your site].
Tasks:
1. Tell me step by step how to use the zooza_event_contact_form_submitted event as a lead conversion in my setup (for GTM: a Custom Event trigger and the tags that use it).
2. Show me how to use zooza_contact_form_id to report enquiries per form.
3. If I use Meta Pixel, explain how to turn the zooza_event_contact_form_submitted custom event into a Lead conversion.`}
---
# Enums reference
## class_capacity
- **none**: Don't show availability
- **number**: Show numeric availability (e.g., 5/10)
- **text**: Status (e.g., "available")
## payment_method
- **card**: Pay by card
- **cash**: Pay in cash
- **transfer**: Bank transfer
## registration_display_mode
This will only be applied for courses where `registration_type=full2`.
- **default**: Customer will decide which option to choose.
- **full_course_only**: Only registration to a class will be enabled.
- **trials_only**: Only trial registration will be offered. Trials need to be enabled in course settings.
- **segments_only**: Only registration to blocks will be enabled. Blocks need to be set in the class settings.
- **trials_and_segments_only**: Customer can opt between trial or blocks.
## Supported languages
| Code | Language | Widgets | App |
|----|--------|-------|---|
| `en-EN` | English | Yes | Yes |
| `en-US` | English US | Yes | Yes |
| `fr-FR` | French | Yes | No |
| `sk-SK` | Slovak | Yes | Yes |
| `cz-CZ` | Czech | Yes | Yes |
| `de-DE` | German | Yes | Yes |
| `pl-PL` | Polish | Yes | No |
| `ro-RO` | Romanian | Yes | Partial |
| `it-IT` | Italian | Yes | No |
| `hu-HU` | Hungarian | Yes | No |
---