# OmniLab Help Centre (/docs)
New to Studio? Start with your first campaign. Otherwise, pick a goal, fix a problem or browse by product.
## I want to… [#i-want-to]
## Fix a problem [#fix-a-problem]
Still stuck? Contact OmniLab support with the campaign's name and what you've tried.
## Browse by product [#browse-by-product]
## For admins and developers [#for-admins-and-developers]
# Admin & Access (/docs/admin-and-access)
For the people who set up OmniLab for everyone else.
# Developers (/docs/developers)
Connect your own systems to OmniLab with the API, webhooks and functions.
# Events (/docs/events)
Take bookings, check guests in and see who came.
# Games (/docs/games)
Build a game, launch it and read its results.
# Integrations (/docs/integrations)
Choose where the experience appears first, then connect sign-in, email and tags.
# Image & asset specs (/docs/platform/asset-specs)
Give this page to whoever makes your artwork. It lists the size, shape and format of every image a campaign uses, each with a live preview and a starter PNG to download.
## Shared visuals [#shared-visuals]
Most experiences reuse the same few visuals. The banner size also covers landing page headers and notification emails.
### Backgrounds & banners [#backgrounds--banners]
### Icons & thumbnails [#icons--thumbnails]
## Assets by experience [#assets-by-experience]
Each experience adds its own visuals. Its build page has the previews, sizes and steps.
| Experience | Visuals it needs | Build page |
| ----------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| **Wheel of Fortune** | Background, pointer (required), border and centre, segment icons, thumbnail | [Wheel of Fortune](/docs/games/build-by-game-type/luck-based/wheel-of-fortune) |
| **Scratch Game** | Scratchable front (required), branded back, winning art, background | [Scratch card](/docs/games/build-by-game-type/luck-based/scratch-card) |
| **Reveal Game** | Front cover (required), revealed art, background | [Reveal card](/docs/games/build-by-game-type/luck-based/reveal-card) |
| **Simple Game** (instant win) | Banner, thumbnail, reward icons | [Instant win](/docs/games/build-by-game-type/luck-based/simple-instant-win) |
| **3D Selection Game** | 3D model (required), background | [3D selection](/docs/games/build-by-game-type/luck-based/3d-selection-game) |
| **Memory Game** | Card fronts & backs in one shape, background | [Memory](/docs/games/build-by-game-type/skill-based/memory) |
| **Quiz** | Optional header, question images, background | [Quiz](/docs/games/build-by-game-type/skill-based/quiz) |
| **Catcher Game** | Catcher object, items to catch, traps (3D), background | [Catcher](/docs/games/build-by-game-type/skill-based/catcher) |
| **Form** | Optional header, background | [Form](/docs/games/build-by-game-type/skill-based/form) |
| **Advent calendar** | Door art (closed / open / disabled), background | [Advent calendar](/docs/games/build-by-game-type/advent-calendar/advent-calendar) |
| **Premium 3D** | 3D models, background | [Premium 3D](/docs/games/build-by-game-type/premium-3d/premium-3d-games) |
| **Offers Pass** | Banner, reward icons | [Offers Pass](/docs/games/build-by-game-type/access-based/offer-pass) |
| **Gated content** | Banner, background | [Gated content](/docs/games/build-by-game-type/access-based/gated-content) |
| **Events (Activity)** | Banner (required), location image | [Activity settings](/docs/events/build/general-activity-settings) |
| **Receipt game** | Banner, thumbnail, reward icons | [Receipt game](/docs/transactions/build/create-a-receipt-game) |
| **Treasure hunt** | Banner, map, 3D models, skybox | [Treasure hunt](/docs/treasure-hunts/build/create-a-treasure-hunt) |
| **Smart Link QR** | Custom QR icon | [QR code](/docs/smart-links/launch/download-qr-code) |
| **Landing page** | Header banner, instruction icons | [Header](/docs/platform/landing-page/configure-the-header) |
| **Badges** | Badge artwork (front / back) | [Design a 3D badge](/docs/platform/rewards/design-a-badge) |
## File formats [#file-formats]
* Images: JPEG, WEBP or PNG. Icons and the wheel pointer need a PNG with a transparent background.
* 3D objects (catcher, 3D selection, treasure hunts) come from your 3D library, not an image upload. They need a GLB file, plus USDZ for the best result on iPhone and iPad.
## Next steps [#next-steps]
* [Games by type](/docs/games/build-by-game-type)
* [About landing pages](/docs/platform/landing-page/about-landing-pages)
# Export a campaign as a template ZIP file (/docs/platform/export-campaign-template-zip-file)
A template file packs a whole campaign into one ZIP: touchpoints, rewards, theme, forms, notifications and every image they use. Another organisation imports it and builds its own campaign from it. Use it when you can't reach that organisation yourself. If you can, [sharing the template](/docs/platform/campaigns/share-a-template-across-organisations) is quicker. Here, Lindenhall exports its Winter Wonderland campaign.
## Create the ZIP [#create-the-zip]
1. In the campaign, select the arrow next to **Publish**.
2. Select **Save as template file**.
Studio confirms with **Template File Generated** and files the ZIP in your media library.
## Copy its link [#copy-its-link]
3. In the side menu, select **Assets Library**.
4. Select **Images and Files**.
5. On the file named after your campaign, ending in **-templateFile** and tagged **ZIP**, select **More Actions**.
6. Select **Copy Link**.
Opening the **Media Public Link** downloads the ZIP. Anyone with the link can download it, so send it only to the people setting up the other organisation.
## What travels in the ZIP [#what-travels-in-the-zip]
The campaign's structure and content travel, including its notification settings. Coupon codes don't, so a campaign made from the file starts with an empty coupon pool. The receiving organisation also sets its own dates, public link and add-ons.
## Next steps [#next-steps]
* [Import a campaign template ZIP file](/docs/platform/import-campaign-template-zip-file)
* [Templates: export, import, and reuse](/docs/platform/campaigns/templates-export-import-duplicate)
# Import a campaign template ZIP file (/docs/platform/import-campaign-template-zip-file)
A template ZIP is a whole campaign exported from another organisation. Importing it creates a template in your organisation, and you generate campaigns from that. Here, Lindenhall Riverside imports the Winter Wonderland file exported by Lindenhall. To make a file, see [Export a campaign as a template ZIP file](/docs/platform/export-campaign-template-zip-file).
## Create a template from the ZIP [#create-a-template-from-the-zip]
1. At the top of the side menu, open the organisation switcher and choose the organisation that receives the campaign.
2. In **Templates**, select **Create Template**.
3. Enter the **Template Name**.
**Banner Preview Image** is optional: it's the picture on the template's card.
4. Under **Template file selector**, select **Select attachment**.
5. Select **Add Image or File** and upload the ZIP. Download it first from the link you were sent.
6. Choose the ZIP: point at it, then select **Select File**.
7. Select **Save**.
8. Select **Create Template**.
Studio refuses a file that isn't a template ZIP.
## Generate a campaign from it [#generate-a-campaign-from-it]
9. Open the **Local** tab, where the new template appears.
10. On the new template, select **View Actions**.
11. Select **Create Campaign**.
12. Fill in the details, then select **Generate Campaign**.
Each field works as in [Create a campaign](/docs/platform/campaigns/create-a-campaign).
Before you publish, set what belongs to your centre: dates, public link, add-ons and reward stock. Coupon codes don't travel in the ZIP, so add your own. See [Validation and publishing](/docs/platform/campaigns/validation-and-publishing).
## Next steps [#next-steps]
* [Share a template across organisations](/docs/platform/campaigns/share-a-template-across-organisations)
* [Campaign dates](/docs/platform/campaigns/dates)
# Platform (/docs/platform)
Learn these once: they work the same in every app.
# Resources (/docs/resources)
Start with a plan for your goal, or check what changed.
# Smart Links (/docs/smart-links)
Share one link everywhere, and change where it goes later. Campaigns work with their own links without it.
# Transactions (/docs/transactions)
Reward shoppers for what they spend in your centre.
# Treasure Hunts (/docs/treasure-hunts)
Send visitors around the venue to find objects and win.
# How organisation scope works (/docs/admin-and-access/organisations/about-the-organisation-hierarchy)
Every campaign belongs to one organisation, and every organisation sits under Global.
| Organisation | What it's for |
| ------------ | ------------------------------------------------------------------- |
| **Global** | Creating organisations, managing users, shared defaults and scripts |
| Any other | Its own campaigns, settings, variables and subdomain |
Here, Global holds Lindenhall Shopping Centre, Lindenhall Riverside and a set of brand organisations.
## Choose the boundary [#choose-the-boundary]
Give a team its own organisation when it needs its own people, its own links and contact details, its own subdomain, or a different language or timezone.
With **one per venue**, each shopping centre runs its own campaigns.
With **one per country**, teams, campaigns and reporting follow geography.
A country team that oversees several venues works across them with one account: see [Multi-org access](/docs/admin-and-access/users-and-access/multi-org-access).
## What an organisation can take from its parent [#what-an-organisation-can-take-from-its-parent]
| Setting | Inherited? |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **General** (name, timezone, language, links) | Yes, with **Inherit from parent** |
| **Notifications** (email sender, header and footer) | Yes, with its own **Inherit from parent** |
| **Authentication** (participants' double opt-in) | Yes, when set to **Inherit**: see [Double opt-in email verification](/docs/integrations/email-triggers/double-opt-in-email-verification) |
| **Variables** | No: each organisation keeps its own |
| **Domain** | No: each organisation sets its own |
| **Scripts** | Set once, in Global, for every organisation |
Global's **General Settings** has more tabs than an organisation's **Organization Settings**: **Organizations**, **Users**, **Themes**, **Scripts**, **Functions** and **Webhooks** are only there.
When you move a template from one organisation to another, check the new organisation's variables, subdomain, language and timezone.
## Next steps [#next-steps]
* [Create an organisation](/docs/admin-and-access/organisations/create-a-child-organisation)
* [Organisation settings](/docs/admin-and-access/organisations/organisation-settings)
# Delete an organisation (/docs/admin-and-access/organisations/archive-or-delete-an-organisation)
Studio has no delete or archive action for an organisation. To remove one, ask OmniLab support, once nothing depends on it any more.
## Before you ask [#before-you-ask]
* **Campaigns**: end or move the ones that still run in it.
* **People**: remove the organisation from each user who has it: see [Modify or revoke access](/docs/admin-and-access/users-and-access/modify-or-revoke-access). Their access to it stops at once, which also works to retire an organisation gradually.
* **Templates and integrations**: update or retire the ones that use its unique key.
* **Variables**: update the emails and scripts that still read them.
* **The subdomain**: remove it yourself, as removing the organisation doesn't: see [Organisation domain settings](/docs/admin-and-access/organisations/domain-aliases#remove-it).
Once it's removed, nobody can choose it in the organisation switcher, and it can't be brought back.
## Next steps [#next-steps]
* [Modify or revoke access](/docs/admin-and-access/users-and-access/modify-or-revoke-access)
* [How organisation scope works](/docs/admin-and-access/organisations/about-the-organisation-hierarchy)
# Create an organisation (/docs/admin-and-access/organisations/create-a-child-organisation)
A new organisation gives a team its own space for campaigns and settings. Organisations are created from the Global organisation, which only admins can open. Here, an admin adds Lindenhall Northgate.
## Create it [#create-it]
1. Select the organisation switcher.
2. Select **Global**.
3. Select **General Settings**.
4. Select the **Organizations** tab.
5. Select **Create Organisation**.
6. Enter the **Organization Name**.
7. Enter the **Group Unique Key**.
Choose it with care: integrations and settings refer to the organisation by this key, so don't change it later. **Image**, **Show Consent Banner** and **Show Feedback Block** are optional.
8. Select **Create Group**.
The organisation appears on the **Organizations** tab.
## Then set it up [#then-set-it-up]
Switch to the new organisation, then:
* Set its timezone, language, address and links: see [Organisation settings](/docs/admin-and-access/organisations/organisation-settings).
* Check its email sender on the **Notifications** tab, or let it inherit.
* Add its [variables](/docs/admin-and-access/organisations/variables) and its [subdomain](/docs/admin-and-access/organisations/domain-aliases).
* Give people access: see [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations).
## Next steps [#next-steps]
* [How organisation scope works](/docs/admin-and-access/organisations/about-the-organisation-hierarchy)
* [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations)
# Organisation domain settings (/docs/admin-and-access/organisations/domain-aliases)
An organisation can have one subdomain of its own for its campaign pages. Here, an admin gives Lindenhall Shopping Centre the subdomain `lindenhall`.
## Add the subdomain [#add-the-subdomain]
In **Organization Settings**:
1. Select the **Domain** tab.
2. Select **Add Subdomain**.
3. Enter the subdomain.
4. Select **Submit**.
Campaign pages then use `https://lindenhall.topage.co/`, followed by each campaign's link.
## Remove it [#remove-it]
On the **Domain** tab, select **Remove** next to the subdomain.
## The rules [#the-rules]
* **One subdomain per organisation.** To change it, remove the current one first: there's no replace.
* **Unique across your account**, and at least 3 characters.
* **Each organisation sets its own.** Nothing here is inherited.
If your account uses a custom domain, the tab shows it read-only instead. Custom domains and their certificates are set up with your IT team and OmniLab: see [Subdomain & custom domain](/docs/platform/deployment/subdomain-and-custom-domain).
## Next steps [#next-steps]
* [Subdomain & custom domain](/docs/platform/deployment/subdomain-and-custom-domain)
* [Organisation settings](/docs/admin-and-access/organisations/organisation-settings)
# Organisations (/docs/admin-and-access/organisations)
Set up the organisations your teams work in.
# Organisation settings (/docs/admin-and-access/organisations/organisation-settings)
Each organisation has a name, a timezone, a language and the links its campaign pages show. Here, an admin checks Lindenhall Shopping Centre's settings.
## What the General tab holds [#what-the-general-tab-holds]
| Field | What it sets |
| ------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Enterprise Name** | The organisation's name |
| **Notification Email** | The organisation's default email address |
| **Enterprise Timezone**, **Enterprise Language** | The defaults for new campaigns |
| **Show Group Information in Campaigns**, **Show Group Top Header** | Whether campaign pages show the organisation's details and header |
| **Address**, the social links, **Group CTA Label** and **Group CTA Link** | What those campaign page blocks show |
For a value with no field here, such as a welcome desk phone number, use [Variables](/docs/admin-and-access/organisations/variables).
## Change a setting [#change-a-setting]
Choose the organisation in the switcher first. Then:
1. Select **Organization Settings**.
2. On the **General** tab, change the field, here **Enterprise Timezone**.
3. Select **Save**.
Open a campaign page that shows these details to check the result.
## Inherit from the parent [#inherit-from-the-parent]
**Inherit from parent**, at the top of the **General** tab, makes the organisation reuse its parent's values. The fields then can't be edited here until you untick it. Untick it for a venue or country that needs its own address, links or timezone.
The **Notifications** tab has its own **Inherit from parent**, for the sender, reply-to address, header and footer of the campaign emails.
## Timezone messages when publishing [#timezone-messages-when-publishing]
* **Campaign timezone (…) differs from organization timezone (…)**: a warning, not a blocker. Keep it if the campaign targets another region. Otherwise, change the campaign's timezone in **Build** > **General** > **Campaign Dates Settings**.
* **Campaign timezone '…' is not recognized**: choose a timezone from the list in **Build** > **General** > **Campaign Dates Settings**.
* **Organization timezone '…' is not recognized**: set a valid **Enterprise Timezone** here.
## Next steps [#next-steps]
* [Variables](/docs/admin-and-access/organisations/variables)
* [Organisation domain settings](/docs/admin-and-access/organisations/domain-aliases)
# Organisation scripts (/docs/admin-and-access/organisations/scripts)
Organisation scripts are bits of code that load with your campaign pages: a tag manager, an analytics tag, an advertising pixel or a cookie banner. They're added once, on the **Scripts** tab of the Global organisation's **General Settings**, and apply to every campaign.
| To find out | See |
| -------------------- | ---------------------------------------------------------------------------------------------------------- |
| What a script can do | [About tags and scripts](/docs/integrations/tags-and-scripts/about-tags-and-scripts) |
| When it runs | [Where and when a script runs](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs) |
| How to add one | [Add a script](/docs/integrations/tags-and-scripts/add-a-script) |
| Working examples | [Script examples](/docs/integrations/tags-and-scripts/script-examples) |
| What to avoid | [Common patterns and what to avoid](/docs/integrations/tags-and-scripts/common-patterns-and-what-to-avoid) |
A saved script reaches a live campaign only once that campaign is published again.
## Next steps [#next-steps]
* [Add a script](/docs/integrations/tags-and-scripts/add-a-script)
* [Variables](/docs/admin-and-access/organisations/variables)
# Organisation variables (/docs/admin-and-access/organisations/variables)
A variable is a named value, such as a phone number or a support link, that emails and campaign pages can reuse. It saves retyping when several organisations share the same campaigns but need their own details. Here, Lindenhall adds its welcome desk phone number.
Use the **General** tab for what has a field there, such as the address or social links: see [Organisation settings](/docs/admin-and-access/organisations/organisation-settings).
## Add a variable [#add-a-variable]
In **Organization Settings**:
1. Select the **Variables** tab.
2. Select **Add Item**.
3. Replace `newKey` with the key, here `welcome_desk_phone`.
4. Replace `New Value` with the value, here `0161 496 0100`.
5. Select **Save**.
Then check the email or page that uses it.
## Where variables are used [#where-variables-are-used]
* **Emails**, as a dynamic field: see [Personalise emails with dynamic fields](/docs/platform/notifications/liquid-templates-and-variable-bindings).
* **Campaign pages**, through the organisation's scripts: see [Script examples](/docs/integrations/tags-and-scripts/script-examples).
* **Add-ons** that route data per organisation.
## Good to know [#good-to-know]
* **Keys are used exactly as written.** Pick stable names, such as `support_url` or `welcome_desk_phone`, with one meaning each.
* **Renaming or deleting a key breaks what uses it.** Update every email, script and add-on first.
* **Variables aren't inherited.** Each organisation keeps its own list. When you reuse a template in another organisation, check that it has the same keys.
## Next steps [#next-steps]
* [Organisation settings](/docs/admin-and-access/organisations/organisation-settings)
* [Share a template across organisations](/docs/platform/campaigns/share-a-template-across-organisations)
# Security & Data (/docs/admin-and-access/security-and-data)
Check your environment, and connect your company sign-in.
# Company sign-in (SSO) (/docs/admin-and-access/security-and-data/sso-oidc-configuration)
With single sign-on (SSO), your team signs in to Studio with their company account, such as Microsoft. Your IT team prepares the connection, and your Customer Success Manager turns it on.
## Supported providers [#supported-providers]
| Provider | Best fit |
| ------------------------------------------- | --------------------------------------------- |
| Microsoft Entra ID (Azure Active Directory) | Companies that already use Microsoft accounts |
| Salesforce Marketing Cloud | Teams that already sign in to Marketing Cloud |
For any other provider, ask your Customer Success Manager before you plan a rollout.
Your IT team registers OmniLab in your identity system and collects the details to share. Send them [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider).
## Prepare the accounts in OmniLab [#prepare-the-accounts-in-omnilab]
Before you test sign-in, for each test user:
* Create their account, with the email address your identity system returns.
* Assign them to at least one organisation.
* Set their [sign-in method](/docs/admin-and-access/users-and-access/user-profiles-and-authentication#each-account-signs-in-one-way) to company single sign-on. Until then, they get a **Wrong sign-in method** message.
The **Authentication** tab in Organization Settings is for participants' double opt-in, not for your team's sign-in.
## Roll it out gradually [#roll-it-out-gradually]
Each account signs in one way. A user moved to single sign-on can no longer use an email link, even while email sign-in stays on for your environment. So move people in stages:
* Leave email sign-in on.
* Move a small pilot group, and check they land in the right organisation.
* Move everyone else in batches.
* Ask your Customer Success Manager to turn email sign-in off once everyone has moved.
If a pilot user gets stuck, an admin can put that account back on email sign-in.
## Next steps [#next-steps]
* [Users and authentication](/docs/admin-and-access/users-and-access/user-profiles-and-authentication)
* [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider)
# System requirements & supported environment (/docs/admin-and-access/security-and-data/system-requirements)
Studio is made for your team's computers. Campaign pages are made for participants' phones, and adapt to the screen. Both need a current browser and a steady connection.
## Supported web browsers [#supported-web-browsers]
Use an up-to-date version of one of these browsers.
| Browser | Supported on |
| -------------------- | --------------------------------- |
| **Google Chrome** | Windows, macOS, ChromeOS, Android |
| **Microsoft Edge** | Windows, macOS |
| **Mozilla Firefox** | Windows, macOS, Linux |
| **Apple Safari** | macOS, iOS, iPadOS |
| **Samsung Internet** | Android |
* **Keep JavaScript and cookies on.** OmniLab needs both to load and to keep you signed in.
* **Internet Explorer isn't supported**, nor are browsers that no longer get updates.
## Supported operating systems [#supported-operating-systems]
Any system that runs one of those browsers and still gets updates works. A system too old to update its browser may not.
| Where you work | Supported systems |
| ---------------------- | ---------------------------------------------------- |
| **Desktop and laptop** | Windows, macOS, ChromeOS, common Linux distributions |
| **Phone and tablet** | iOS / iPadOS, Android |
## Accepted file formats for uploads [#accepted-file-formats-for-uploads]
When you add artwork, models, or data in Studio, use one of the formats below. For the exact size and shape of each image per Touchpoint, see [Image & asset specs](/docs/platform/asset-specs).
| What you upload | Where it's used | Accepted formats |
| --------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Images** | Banners, backgrounds, icons, and game artwork | JPEG, PNG, GIF, WEBP, TIFF, BMP, ICO, HEIF, HEIC and AVIF, plus SVG for flat logos and icons |
| **3D models** | Treasure-hunt and augmented-reality objects | GLB, USDZ, and Reality |
| **Bulk data** | Importing reward codes, coupons, or participant lists | CSV |
Keep image files lightweight. Large files slow down how fast your pages load for participants, especially on mobile data.
## Internet connection [#internet-connection]
A steady connection matters more than a fast one.
* **Plan for at least 5 Mbps** for building and playing. 3D games, augmented reality and video work best at 10 Mbps or more.
* **Campaign pages work on 4G, 5G or Wi-Fi**, and adapt to slower connections.
* **Every page is served over HTTPS.**
- Connections use TLS 1.2 or higher: check your firewall allows it.
- If your network filters web traffic, allow your Studio address and the addresses your campaign pages and Smart Links use.
## Next steps [#next-steps]
* [Image & asset specs](/docs/platform/asset-specs)
* [Company sign-in (SSO)](/docs/admin-and-access/security-and-data/sso-oidc-configuration)
# Access request process (/docs/admin-and-access/users-and-access/access-request-process)
New people get into Studio through an admin, who creates their account. Settle these details first, so it's right on the first sign-in.
| Detail | Why |
| -------------------------------------- | ------------------------------------------------------------------------------------------ |
| The email address they'll sign in with | The account is tied to that exact address |
| **Admin** or **Contributor** | See [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions) |
| **Campaigns**, **Smartlinks**, or both | Which parts of Studio they'll see |
| Their organisations | Which ones they can switch between |
| Email link or company sign-in | Only if your environment offers both: the admin sets it as **Authentication Provider** |
## The admin creates the account [#the-admin-creates-the-account]
With those details, the admin adds the user on the **Users** tab of Global's **General Settings**: see [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations).
Then tell the new user how to sign in: their company sign-in button, or **Continue with Email**.
## Sign in with email [#sign-in-with-email]
1. On the sign-in page, enter your email address.
2. Select **Continue with Email**.
3. Open the sign-in link emailed to you, within 24 hours.
## Sign in with your company account [#sign-in-with-your-company-account]
On the same sign-in page, select your company's sign-in button, and sign in as you do for your other work tools.
Studio opens on your organisations, with your modules. If it says why you can't get in, see [If someone can't sign in](/docs/admin-and-access/users-and-access/user-profiles-and-authentication#if-someone-cant-sign-in).
## Next steps [#next-steps]
* [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations)
* [Users and authentication](/docs/admin-and-access/users-and-access/user-profiles-and-authentication)
# Assign users to organisations (/docs/admin-and-access/users-and-access/assign-users-to-organisations)
An admin gives each person an account with a role, the organisations they work in, and the parts of Studio they can open. Here, an admin adds Sam Taylor, Lindenhall's new marketing assistant.
In the organisation switcher, choose **Global**, and select **General Settings**. Then:
1. Select the **Users** tab.
2. Select **Create User**.
3. Enter the **Email Address** they'll sign in with.
4. Choose the **User Role**: **CONTRIBUTOR** or **ADMIN**.
5. Under **Assigned Organizations**, select **Organizations**.
6. Tick their organisations, here **Lindenhall Shopping Centre**.
7. Under **Module Access**, tick **Campaigns**, **Smartlinks**, or both.
8. Select **Create User**.
The new account appears on the **Users** tab, ready for its first sign-in: see [Access request process](/docs/admin-and-access/users-and-access/access-request-process).
## Good to know [#good-to-know]
* **One account per person**, however many organisations they work in. To add one later, edit their **Assigned Organizations**.
* **Give only what's needed today**, so their organisation switcher stays short.
## Next steps [#next-steps]
* [Modify or revoke access](/docs/admin-and-access/users-and-access/modify-or-revoke-access)
* [Multi-org access](/docs/admin-and-access/users-and-access/multi-org-access)
# Users & Access (/docs/admin-and-access/users-and-access)
Give people the right access to Studio.
# Modify or revoke access (/docs/admin-and-access/users-and-access/modify-or-revoke-access)
Change a user's role, organisations or modules when their job changes, and delete the account when they leave. Both happen on the **Users** tab of Global's **General Settings**: in the organisation switcher, choose **Global**, then select **General Settings**. Here, an admin takes Smart Links away from Aiden Ellis.
## Change someone's access [#change-someones-access]
1. Select the **Users** tab.
2. On the user's row, select the pencil.
3. Under **Module Access**, untick **Smartlinks**.
**User Role** and **Assigned Organizations** change the same way.
4. Select **Update User**.
## Delete the account [#delete-the-account]
When the person no longer needs OmniLab at all. It can't be undone: to give them access again, create a new account.
On the **Users** tab:
1. On the user's row, select the bin.
2. Select **Confirm**.
## Next steps [#next-steps]
* [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations)
* [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions)
# Multi-org access (/docs/admin-and-access/users-and-access/multi-org-access)
One account can work in several organisations. The switcher at the top of the Studio menu moves you between them, without signing out. Here, Alex Morgan moves from Lindenhall Shopping Centre to Lindenhall Riverside.
## Switch organisation [#switch-organisation]
1. Select the organisation switcher.
2. Type the organisation's name, here `Riverside`.
3. Select **Lindenhall Riverside**.
Studio reloads with that organisation's campaigns, templates and settings. Check the switcher's name before you create or publish anything.
Without a search, the list shows **Current Organization**, then **Global Organization** if you have it, then **Other Organizations**.
## What changes, and what doesn't [#what-changes-and-what-doesnt]
| Changes with the organisation | Stays with your account |
| -------------------------------------------------------------------------------- | ----------------------- |
| Campaigns, templates and settings | Your role |
| Variables and subdomain | Your **Module Access** |
| **General Settings** in Global, **Organization Settings** elsewhere (for admins) | How you sign in |
Smart Links appear only if your account has **Smartlinks** and the organisation has Smart Links turned on.
## If an organisation is missing [#if-an-organisation-is-missing]
You can only switch to organisations on your account. Ask an admin to add it to your **Assigned Organizations**: see [Modify or revoke access](/docs/admin-and-access/users-and-access/modify-or-revoke-access).
## Next steps [#next-steps]
* [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions)
* [How organisation scope works](/docs/admin-and-access/organisations/about-the-organisation-hierarchy)
# Roles and permissions (/docs/admin-and-access/users-and-access/roles-and-permissions)
What someone can do in Studio depends on three things set on their account: a role, module access and the organisations they're assigned to.
## The two roles [#the-two-roles]
| | **ADMIN** | **CONTRIBUTOR** |
| ----------------------------------------------------------- | ------------------------------------------------- | ------------------------------------- |
| For | People who manage the account, users and settings | People who build campaigns day to day |
| Switch between their organisations | Yes | Yes |
| **General Settings** (Global) and **Organization Settings** | Yes | No: the menu doesn't show them |
| Create, edit or delete users | Yes | No |
Choose **CONTRIBUTOR** unless the person manages the account. The role applies to every organisation on the account: it can't differ from one organisation to another.
## Module access [#module-access]
**Module Access** hides whole parts of Studio, whatever the role:
* **Campaigns**: campaigns, templates, rewards and files. The user also needs at least one organisation.
* **Smartlinks**: the Smart Links app. The organisation must also have Smart Links turned on.
## Organisations [#organisations]
Even an admin only sees the organisations on their account, one at a time: see [Multi-org access](/docs/admin-and-access/users-and-access/multi-org-access).
## Next steps [#next-steps]
* [Assign users to organisations](/docs/admin-and-access/users-and-access/assign-users-to-organisations)
* [Users and authentication](/docs/admin-and-access/users-and-access/user-profiles-and-authentication)
# Users and authentication (/docs/admin-and-access/users-and-access/user-profiles-and-authentication)
Each account has one email address, one way to sign in, a role, module access and its organisations. The sign-in page offers only the methods your OmniLab environment has turned on.
## How people sign in [#how-people-sign-in]
| Method | On the sign-in page | Good to know |
| ----------------------------------------- | ------------------------ | ------------------------------------------------------------------------- |
| Email | **Continue with Email** | OmniLab emails a one-time link to that address. It expires after 24 hours |
| Company single sign-on, such as Microsoft | A company sign-in button | Your company's own rules apply, such as multi-factor checks |
Whichever the method, Studio signs people out after 24 hours. To set up single sign-on, see [Company sign-in (SSO)](/docs/admin-and-access/security-and-data/sso-oidc-configuration).
## Each account signs in one way [#each-account-signs-in-one-way]
The method is part of the account, not a choice on the sign-in page. Someone set to single sign-on can't get in with an email link, and the reverse. That's what makes single sign-on enforce your company's rules: multi-factor checks apply to OmniLab too, and disabling someone in your company system ends their OmniLab access within 24 hours.
* When your environment offers several methods, the user form shows **Authentication Provider**, and the admin picks one. With one method, it's set for you.
* Only an admin can change it.
* Turning a method off for the environment blocks everyone set to it.
## If someone can't sign in [#if-someone-cant-sign-in]
The sign-in page says why:
| Message | What to do |
| ---------------------------- | ----------------------------------------------------------------------------------- |
| **Wrong sign-in method** | Use the method set on the account. Signing in the same way again fails the same way |
| **User not found** | Create the account for that exact address |
| **Email domain not allowed** | The domain isn't on your environment's allowed list: ask OmniLab |
| **No organisation assigned** | Add an organisation to the account |
| **Wrong organisation** | Their company account belongs to another OmniLab environment |
| **Sign-in unavailable** | Try again in a moment |
What they can do once in depends on their role, module access and organisations: see [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions).
## Next steps [#next-steps]
* [Access request process](/docs/admin-and-access/users-and-access/access-request-process)
* [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions)
# Changelog (/docs/developers/api-changelog/changelog)
A full public, reverse-chronological API changelog is not published in this help centre yet.
## What to rely on [#what-to-rely-on]
* Versioned endpoint paths and webhook event names
* Staging validation before every production rollout
* Notice from your Customer Success Manager about changes that affect your integration
* Your own monitoring for authentication failures, schema drift, and webhook delivery errors
## Recommended monitoring checklist [#recommended-monitoring-checklist]
* Alert on repeated `401` or `403` responses from approved API endpoints.
* Alert on webhook signature verification failures.
* Track the `4xx` and `5xx` answers your receiver gives to webhook deliveries.
* Keep sample staging payloads so you can detect schema changes quickly.
* Re-run end-to-end tests before large launches or major changes to your account's setup.
## Next steps [#next-steps]
* [Versioning strategy](/docs/developers/api-changelog/versioning-strategy)
* [Rate limits](/docs/developers/getting-started/rate-limits)
* [About webhooks](/docs/developers/webhooks/about-webhooks)
# API Changelog (/docs/developers/api-changelog)
See where versions appear, and how to catch API changes early.
# Versioning strategy (/docs/developers/api-changelog/versioning-strategy)
OmniLab exposes version markers directly in endpoint paths and event names, such as `/v1/oauth:token`, `/v1/interactions:fetchContactBookings`, and webhook event types ending in `.v1`.
## What is versioned [#what-is-versioned]
* REST endpoints under `/v1/...`
* Webhook event types such as `reward.won.v1` and `booking.created.v1`
* The payload schemas associated with those versions
## Practical compatibility rules [#practical-compatibility-rules]
* Treat new optional fields as additive rather than breaking.
* Treat path changes, required-field changes, or event type renames as breaking.
* Pin your receiver logic to the exact event names you subscribe to today.
* Keep contract tests in staging for token requests, booking flows, and webhook signatures.
* Validate downstream parsing before you promote a change to production.
## How to prepare for future change [#how-to-prepare-for-future-change]
1. Keep your OmniLab integration logic behind a small adapter layer.
2. Store sample request and response payloads from staging.
3. Verify booking flows and webhook signature verification whenever a change is announced.
4. Roll changes through staging before you promote them to production.
## Next steps [#next-steps]
* [Changelog](/docs/developers/api-changelog/changelog)
* [Authentication](/docs/developers/getting-started/authentication)
* [Webhook event reference](/docs/developers/webhooks/webhook-event-reference)
# Booking API (/docs/developers/api-reference/booking-api)
The Booking API lets your backend read and manage the bookings a contact has made on a campaign. For example, it can power an account page that shows a customer's upcoming bookings and lets them cancel.
## Before you begin [#before-you-begin]
* A token from the [client-credentials flow](/docs/developers/getting-started/authentication), requested on your own backend and sent as `Authorization: Bearer ` on every call.
* Your API host, from [Base URLs and environments](/docs/developers/getting-started/base-urls-and-environments). Every path below starts with `/v1/`.
* Confirmation from your Customer Success Manager that the Booking API is enabled for your account.
## Key concepts [#key-concepts]
Before the endpoint reference, three ideas explain how identifiers fit together.
### How a contact is identified [#how-a-contact-is-identified]
| Identifier | What it is | Who uses it |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| External ID (`external_id`) | The ID your own system already uses for this customer. You pass it to OmniLab when fetching bookings. | You — this is your handle for the contact. |
| Contact ID (`contacts/`) | The platform's own identifier for the same person, resolved from your external ID. | The platform. You normally never send it. |
| Public key (`public_key`) | The public-facing key of a campaign (interaction). It appears in experience URLs and lets you scope a fetch to a single campaign. | Identifies the campaign, not the person. |
In short: the **external ID identifies the person**, and the **public key identifies the campaign**. You only ever need to store the external ID you already have.
### Campaigns, touchpoints, and the activity ID [#campaigns-touchpoints-and-the-activity-id]
A campaign is an **interaction**, identified by its `public_key`. An interaction can contain one or more bookable **activities** — also called **touchpoints**. Each booking in the response carries an `activity` object with its own `id`. That `activity.id` is the touchpoint identifier, and it is the key to telling bookings apart when a campaign has more than one bookable activity.
`activity.id` is unique **within a campaign**, not across campaigns — the same `id` can appear under two different campaigns. To identify a touchpoint uniquely, use `activity.id` together with the campaign's `interaction.public_key`. The two use cases below show this in practice.
### The booking identifier and statuses [#the-booking-identifier-and-statuses]
Each booking is identified by the `booking` field — a unique identifier (a UUID). Pass that exact value back to cancel the booking; don't add a prefix or transform it.
| Booking status | Meaning |
| -------------- | ----------------------------------------------- |
| `CONFIRMED` | The booking is confirmed. |
| `WAITLIST` | The contact is on the waitlist for a full slot. |
| `CANCELLED` | The booking was cancelled. |
The API never returns `CHECKED_IN`, although the filter accepts it. Check-in is recorded on the attendee, not the booking, so a checked-in booking still reads `CONFIRMED`. A `CHECKED_IN` filter matches no bookings, and cancelling a checked-in booking returns a `400`.
## Endpoints [#endpoints]
### List a contact's bookings [#list-a-contacts-bookings]
```http
GET /v1/interactions:fetchContactBookings
```
Returns every booking for one contact, with pagination and optional filters.
**Query parameters**
| Parameter | Required | Description |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_id` | Yes | The contact's external ID in your system. |
| `public_keys` | No | Scope to a single campaign by its public key. Only one value is supported. Cannot be combined with `interactions`. |
| `interactions` | No | Scope to a single campaign by its resource name (`interactions/`). Only one value is supported. Cannot be combined with `public_keys`. |
| `slot_date_range.from` | No | Lower bound for the slot start time, RFC 3339 (for example `2024-12-10T12:30:00Z`). |
| `slot_date_range.to` | No | Upper bound for the slot start time, RFC 3339. |
| `booking_status` | No | Filter by status. Repeat the parameter to combine values (`CONFIRMED`, `WAITLIST`, `CANCELLED`). |
| `language` | No | Language code used to translate campaign text in the response. |
| `page_size` | No | Results per page. Default `100`. |
| `page_number` | No | Page to return, starting at `1`. Default `1`. |
Omit both `public_keys` and `interactions` to return the contact's bookings across all campaigns.
```bash title="Fetch confirmed bookings for one contact"
curl -X GET "https:///v1/interactions:fetchContactBookings?external_id=9b33d8c1-64bd-4eff-b0e4-d2fb29ecbe83&booking_status=CONFIRMED&page_size=50&page_number=1" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
**Response**
| Field | Type | Description |
| ------------- | ------ | ------------------------------------- |
| `bookings` | array | The contact's bookings for this page. |
| `page_number` | number | The page returned. |
| `page_size` | number | Results per page. |
| `total_count` | number | Total bookings matching the filters. |
When the contact has no bookings, or the external ID matches no contact, the answer is still `200` but has no `bookings` and no `total_count`. Treat a missing field as empty or zero. Each booking in `bookings` contains the fields below. See [Field formats and conventions](#field-formats-and-conventions) for the exact format of each.
| Field | Type | Description |
| -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `activity` | object | The booked touchpoint, with `id` (the touchpoint ID), `title` (localised name) and `location_hint` (localised location text). |
| `interaction` | object | The campaign, with `public_key` and `timezone` (IANA name, for example `Europe/London`). |
| `group` | object | The organisation the campaign belongs to, with `unique_key` and `display_name`. |
| `slot_starting_at` | string | Slot start time, RFC 3339 in UTC (ends in `Z`). |
| `slot_duration_in_minutes` | integer | Slot length in minutes. Omitted when the slot has no set duration — see below. |
| `booking_status` | string | `CONFIRMED`, `WAITLIST` or `CANCELLED`. |
| `booked_at` | string | Time of the booking's current status (confirmation, waitlist, or cancellation), RFC 3339 in UTC. |
| `ticket_type` | object | The ticket booked, with `display_name` and `has_limited_capacity`. |
| `host` | object | The specific host the contact booked with, when the activity offers a choice of hosts (for example a named station or staff member), with `display_name`. The location is in `activity.location_hint`. |
| `booking` | string | The booking's unique identifier (a UUID). Pass this exact value to the cancel endpoint. |
```json title="Example response (one booking)"
{
"bookings": [
{
"activity": {
"id": "b8b2a0e2-1f3c-4a7d-9c52-0c8f2d1a6e44",
"title": "Santa Photo Experience",
"location_hint": "Level 1, near the fountain"
},
"interaction": {
"public_key": "winter-festival",
"timezone": "Europe/London"
},
"group": {
"unique_key": "lindenhall-shopping-centre",
"display_name": "Lindenhall Shopping Centre"
},
"slot_starting_at": "2024-12-10T12:30:00Z",
"slot_duration_in_minutes": 30,
"booking_status": "CONFIRMED",
"booked_at": "2024-12-01T09:15:00Z",
"ticket_type": {
"display_name": "Standard entry",
"has_limited_capacity": true
},
"host": {
"display_name": "Photo Station 2"
},
"booking": "2394d604-afcc-4f7f-98c1-ef3676c20d6b"
}
],
"page_number": 1,
"page_size": 50,
"total_count": 1
}
```
### Field formats and conventions [#field-formats-and-conventions]
| Field | Format and behaviour |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `slot_starting_at` | RFC 3339 timestamp in UTC, ending in `Z` (for example `2024-12-10T12:30:00Z`). |
| `slot_duration_in_minutes` | Integer minutes, from the booked slot's configuration (the chosen host's slot length, or the activity's when there's no host choice). Omitted from the response when the slot has no set duration — never defaulted to 60, so type it as optional. |
| `booked_at` | RFC 3339 timestamp in UTC. Holds the time of the current status: confirmation for `CONFIRMED`, waitlist for `WAITLIST`, cancellation for `CANCELLED`. Check-in doesn't change it. |
| `interaction.timezone` | IANA timezone name (for example `Europe/London`), not an offset. Convert the UTC slot times into it to show the contact their local time. |
| `activity.title`, `activity.location_hint` | Localised plain strings in the campaign's resolved language. Edge case: when the chosen or default language has no value for that field, you may instead receive a raw language map serialised as a string, for example `{"fr":"…"}`. Parse defensively — if the value parses as a JSON object, take the entry for your language or the first available one. |
| `language` (query param) | Base language code such as `en` or `fr` (region subtags like `fr-CA` are reduced to `fr`). Omit it, or pass one the campaign doesn't offer, and the response uses the campaign's default language. |
Pass the `slot_date_range.from` and `slot_date_range.to` filters as RFC 3339 in UTC as well.
### Cancel a booking [#cancel-a-booking]
```http
POST /v1/interactions:cancelInteractionBooking
```
Cancels one booking and its tickets. Pass the `booking` value returned by the fetch call, exactly as received.
| Field | Required | Description |
| --------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `booking` | Yes | The booking identifier, exactly as returned in the `booking` field of `fetchContactBookings` (a UUID, with no prefix). |
```bash title="Cancel a booking"
curl -X POST "https:///v1/interactions:cancelInteractionBooking" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"booking": "2394d604-afcc-4f7f-98c1-ef3676c20d6b"
}'
```
A successful cancellation returns an empty JSON object (`{}`).
## Error responses [#error-responses]
Errors come back as JSON with a numeric `code` and a `message`:
```json title="Error body"
{
"code": 9,
"message": "Ticket is already canceled"
}
```
| Status | When | What to do |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `400` | The booking can't be cancelled: its ticket is already cancelled, or its attendee has checked in | Don't retry. Tell the customer the booking can no longer be cancelled. |
| `401` | The token is missing, expired or malformed, or belongs to another account | Request a new token, then retry once |
| `429` | Your IP address passed a [rate limit](/docs/developers/getting-started/rate-limits) | Back off, then retry |
| `500` with a message about your request | No `external_id`, more than one `public_keys` or `interactions` value, both together, an unknown public key, or an unknown booking ID | Fix the request. Retrying won't help. |
| Any other `5xx` | A temporary problem on the platform | Retry with backoff |
A call with no token at all also gets a `401`, but its body has `error` and `message` fields instead: `{"error": "Authentication required", ...}`.
## Use cases [#use-cases]
### Campaign with a single activity touchpoint [#campaign-with-a-single-activity-touchpoint]
When a campaign has only one bookable activity, every booking in the response shares the same `activity.id`. You can list the bookings directly without grouping — each row is one slot the contact booked for that activity.
### Campaign with multiple activity touchpoints [#campaign-with-multiple-activity-touchpoints]
When a campaign offers several bookable activities (for example a Santa photo and a craft workshop in the same winter campaign), each booking carries the `activity.id` of the touchpoint it belongs to. Use `activity.id` to group or label bookings by activity, so the customer sees "Santa Photo Experience" and "Craft Workshop" as separate lines rather than a flat list. The `activity.title` gives you a ready-made label for each group.
### Display bookings on an account page [#display-bookings-on-an-account-page]
A typical account-page flow:
1. On your backend, call `fetchContactBookings` with the customer's `external_id` and `booking_status=CONFIRMED`.
2. Page through the results if `total_count` is larger than your `page_size`.
3. Group bookings by campaign (`interaction.public_key`) and touchpoint (`activity.id`), since `activity.id` repeats across campaigns.
4. Show each slot with `slot_starting_at`, `slot_duration_in_minutes`, `ticket_type.display_name`, and `host.display_name`.
5. Display every time in `interaction.timezone`.
6. Offer a "Cancel" action that asks the customer to confirm.
7. Call `cancelInteractionBooking` with the `booking` value.
8. Refresh the list so the cancelled slot disappears.
## Postman collections [#postman-collections]
A Postman collection exists for each environment (Staging/UAT and Production) covering all of the endpoints above. Request the collection for your environment from your Customer Success Manager.
The collections ship with placeholder credentials only. Use the `client_id`, `client_secret`, and `audience` your Customer Success Manager provisions for you, store them in a secret manager, and keep them out of source control and shared links.
## Next steps [#next-steps]
* [Authentication](/docs/developers/getting-started/authentication)
* [Sync contacts to your systems](/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
* [Access bookings & tickets](/docs/events/operate/access-bookings-and-tickets)
# API Reference (/docs/developers/api-reference)
The Booking API is the published endpoint reference; read Authentication first.
# About functions (/docs/developers/functions/about-functions)
A function is a small piece of TypeScript that runs on OmniLab's own infrastructure. You write it in Studio and OmniLab compiles it. From then on the platform runs it for you: when something happens in a campaign, or at a decision point such as a contact signing up.
You need no server, no endpoint, and no deployment of your own.
Functions are configured under **Enterprise Settings**, which needs an **Admin** role with the **Global** organisation selected. Confirm you have that access before planning work: the tab is hidden without it.
## How it works [#how-it-works]
You store one TypeScript file with one exported handler. When you build it, OmniLab compiles that file to a WebAssembly module and keeps the compiled version. Every invocation from then on loads it into a fresh sandbox and calls your export.
```ts title="The shape of a platform event function"
import type { PlatformEventHandler } from "./omnilab";
export const onPlatformEvent: PlatformEventHandler = (event) => {
console.log("Something happened: " + event.type);
};
```
Your handler reaches the outside world through the calls OmniLab provides: read a config value, read a secret, make an HTTP request to a host you have allow-listed, read a contact, and write contact custom fields. The set is deliberately small, which is what makes your code safe to run on the platform. It is enough for most integration work.
Each invocation is independent. Nothing you set on one call carries over to the next. Keep what you need in contact custom fields, or in your own system over HTTP.
## Functions or webhooks? [#functions-or-webhooks]
Both let you react to what happens in OmniLab. The difference is where your code runs and whether it can change the outcome.
| | Function | [Webhook](/docs/developers/webhooks) |
| ---------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------ |
| Runs on | OmniLab's infrastructure | Yours |
| You need to host | Nothing | An HTTPS endpoint |
| Can change what OmniLab does | Yes, for one kind | No |
| Best for | Logic that must run inside an OmniLab operation, or small integrations you would rather not host | Feeding a system you already run |
A webhook tells your systems that something happened, after OmniLab has already decided it. A function runs inside OmniLab. One of the two kinds runs inside the decision itself, so it can reject a sign-up, normalise an email before it is stored, or send a verification message through your own provider.
## Choose a kind [#choose-a-kind]
Every function is one of two kinds, and the kind decides when it runs and what it can affect. **It is fixed when you create the function**, so make this choice before you start writing.
| | Platform event function | Hook function |
| -------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Runs | After something happened | During an operation, which waits for it |
| Can it change the outcome? | No | Yes — it continues or refuses |
| Your export | `onPlatformEvent` | `on` + the hook point, for example `onContactCreatePre` |
| Wired up by | A subscription to event types | A binding to a hook point |
| Your return value | Discarded | The decision OmniLab acts on |
| If it fails | The activity is unaffected | The operation is refused: see [what a hook guarantees](/docs/developers/functions/hook-functions#what-a-hook-guarantees) |
Start with a platform event function when either would work. It cannot break anything a customer is doing. Getting it wrong costs a missing side effect, not a failed sign-up.
Use a [platform event function](/docs/developers/functions/platform-event-functions) to react to activity: enrich a contact, push to your CRM, notify a channel. Use a [hook function](/docs/developers/functions/hook-functions) to vet a sign-up, normalise data before it is stored, or send verification mail.
The kind determines the export your code must provide, which tabs appear in the editor, and the payload the test runner sends. There is no way to convert one kind into another: you create a new function and delete the old one.
## From draft to running [#from-draft-to-running]
Saving and building are separate on purpose. Saving stores your code and type-checks it; it never changes what is running. Only a build produces a new compiled version.
The status in the function list is one of **Draft**, **Building**, **Ready**, **Build failed**, or **Deleted**. Only a **Ready** function runs.
The two paths out of **Building** are worth knowing. A function that has never built successfully goes to **Build failed**. A live function that fails a rebuild returns to **Ready** and keeps serving its previous version. A broken rebuild never takes a working function offline.
## What your code works within [#what-your-code-works-within]
Your code is one TypeScript file, `user.ts`. A function with an empty allow-list makes no outbound calls. Each invocation has a fixed time, memory and SDK call budget, listed under [Limits](/docs/developers/functions/function-sdk-reference#limits).
Two rules catch people out. **Secrets never go in your source** — you store them in a bundle, bind the bundle to the function, and read them by name at runtime. And **contact custom field keys beginning `system.` or `feature.` are reserved**, so writes to them are rejected.
## Next steps [#next-steps]
* [Create and build a function](/docs/developers/functions/create-and-build-a-function)
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Function SDK reference](/docs/developers/functions/function-sdk-reference)
# Configure runtime and secrets (/docs/developers/functions/configure-runtime-and-secrets)
Everything here belongs to one function, except secret bundles, which any function can bind. Here, Lindenhall's double opt-in function gets `api.brevo.com` and a `brevo` bundle.
## Before you begin [#before-you-begin]
* The function already exists. If not, start with [Create and build a function](/docs/developers/functions/create-and-build-a-function).
* You are an **Admin** working in the **Global** organisation.
* Have the exact hostnames of any API your handler calls. Wildcards are not accepted.
## Open the Settings tab [#open-the-settings-tab]
On the **Functions** tab:
1. On the function's row, select **Edit**.
2. Select the **Settings** tab.
Changes on this tab apply from the next invocation, with no rebuild: none of them change your compiled code.
## Runtime limits [#runtime-limits]
The **Runtime limits** card bounds a single invocation.
| Setting | Range | Default | What it controls |
| -------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **Timeout (ms)** | 50 – 2000 | 2000 | Wall-clock time your handler gets. A runaway loop is stopped when it runs out. |
| **Max memory pages** | 1 – 64 | 32 | Checked when you save, at 64 KiB per page, but not applied yet: every handler can currently use up to 4 MiB, whatever you set. |
Leave a field empty to use the default. The default timeout is already the ceiling, so you can only lower it.
One HTTP request can take up to 1500 ms before it fails with `TIMEOUT`, and your code can catch that. Running out of handler time can't be caught, and on a hook it [fails the operation](/docs/developers/functions/hook-functions#what-a-hook-guarantees). So keep **Timeout (ms)** above your provider's slowest responses plus your own work, never near its median. The default leaves 500 ms after a timed-out request.
The limits you can't change are in the [SDK reference](/docs/developers/functions/function-sdk-reference#limits).
## Allowed hosts [#allowed-hosts]
The **Allowed hosts** card is the outbound network allow-list. A missing host is the most common reason a working handler fails in production.
On the **Settings** tab:
1. Under **Allowed hosts**, enter the hostname, here `api.brevo.com`.
2. Select **Add**.
The host becomes a removable chip, and the counter goes up.
3. Select **Save settings**.
What the list accepts:
* A function with **no** allowed hosts is refused **every** outbound request. A new function starts this way.
* Entries are exact hostnames, matched case-insensitively, such as `api.example.com`. Wildcards and raw IP addresses are refused when you save.
* A host saved with a port or a path never matches a real request, because only the plain hostname is compared. Enter the hostname alone.
* Only `http` and `https` are allowed. How many hosts you can list is in the [SDK reference](/docs/developers/functions/function-sdk-reference#limits).
**Private and internal addresses are always refused**, even when listed: private ranges, loopback and link-local addresses. The check runs again after DNS resolution, so a public name that resolves to a private address is refused too. A blocked call raises `EGRESS_BLOCKED` in your handler.
## Plugin config [#plugin-config]
The **Plugin config** card holds key and value pairs your handler reads at runtime. Use it for values that differ between environments but aren't sensitive: a template id, a feature toggle, a base URL.
On the **Settings** tab:
1. Under **Plugin config**, select **Add row**.
2. Enter the key, here `brevo_template_id`.
3. Enter its value, here `12`.
4. Select **Save settings**.
```ts title="Reading a config key"
const templateId = OmniLab.Config.get("brevo_template_id");
```
Reading a key that isn't set returns `undefined`. Rows with an empty key are dropped when you save. The number of keys and their total size are capped: see [Limits](/docs/developers/functions/function-sdk-reference#limits).
Plugin config values are stored as plain configuration, readable by anyone who can open the function. API keys, tokens and passwords belong in a secret bundle.
## Secret bundles [#secret-bundles]
A secret bundle groups credentials under one key: for example a `brevo` bundle holding `API_KEY`. Every function can bind the same bundle. Values are write-only: once saved, nobody can read them back.
### Create a bundle [#create-a-bundle]
On the **Functions** tab:
1. Select **New secret**, next to **New function**.
2. Enter the **Key**, here `brevo`. It can't be changed later.
3. Enter the **Display name**, here `Brevo Production`.
4. (Optional) Enter a **Description**.
5. Select **Create bundle**.
The bundle opens, ready for its secrets.
6. Select **Add secret**.
7. In the new row, enter the secret's key, here `API_KEY`.
8. Enter its value: your Brevo API key.
9. Select **Save secrets**.
**Secret keys are case-sensitive**: `brevo.API_KEY` and `brevo.api_key` are different secrets. The characters and lengths allowed in bundle and secret keys, and how many bundles and secrets you can have, are under [Limits](/docs/developers/functions/function-sdk-reference#limits).
### Bind a bundle to a function [#bind-a-bundle-to-a-function]
On the function's **Settings** tab:
1. Under **Secret bundles**, select **Select bundles**.
2. Choose the bundle, here **brevo — Brevo Production**.
3. Select **Save settings**.
The card then lists a reference for each secret in the bundle, ready to copy. Read the value at runtime with that exact reference:
```ts title="Reading a secret"
const apiKey = OmniLab.Config.getSecret("brevo.API_KEY");
```
A function reads only the bundles bound to it. A missing key throws an error rather than returning an empty value, so a typo fails loudly: the codes are in [If something's blocked](#if-somethings-blocked).
### Change or remove a secret [#change-or-remove-a-secret]
On the **Functions** tab:
1. Under **Secrets**, select the bundle, here **brevo**.
2. In the secret's row, type the new value.
3. Select **Save secrets**.
A row you leave as `•••••• (unchanged)` keeps its value. To delete a secret, select its bin icon before **Save secrets**.
## If something's blocked [#if-somethings-blocked]
* **An HTTP call fails with EGRESS\_BLOCKED**: the hostname isn't in the allow-list, is spelled differently, uses a scheme other than HTTP or HTTPS, or resolves to a private address.
* **Reading a secret throws PERMISSION\_DENIED**: the bundle in the reference isn't bound to this function. Bind it on the **Settings** tab.
* **Reading a secret throws NOT\_FOUND**: the secret key doesn't match exactly, often a case difference such as `api_key` for `API_KEY`. It's also the error when the function has no bundle bound at all. Copy the reference from the **Secret bundles** card.
* **Deleting a bundle fails with Delete failed**: a function that isn't deleted still binds it, and the message says how many. Unbind the bundle on each function's **Settings** tab, then delete it.
* **Writing a contact custom field is rejected**: keys beginning `system.` or `feature.` are reserved. Choose a different key.
## Next steps [#next-steps]
* [Platform event functions](/docs/developers/functions/platform-event-functions)
* [Hook functions](/docs/developers/functions/hook-functions)
* [Test and monitor functions](/docs/developers/functions/test-and-monitor-functions)
# Create and build a function (/docs/developers/functions/create-and-build-a-function)
At the end, the function is **Ready** but not yet triggered by anything. You wire it up on the page for its kind: [platform event](/docs/developers/functions/platform-event-functions) or [hook](/docs/developers/functions/hook-functions).
## Before you begin [#before-you-begin]
* Your user role must be **Admin**. Contributors cannot see the Functions surface.
* You must be working in the **Global** organisation. The **Functions** tab only appears there.
* Decide the function's [kind](/docs/developers/functions/about-functions#choose-a-kind) first. It is permanent.
* If your handler will call an external API, have the hostnames ready. You will need to allow-list them before any outbound call works.
Unlike the rest of Studio, the Functions screens are not translated. Labels stay in English regardless of your interface language.
## Open the Functions tab [#open-the-functions-tab]
1. In the organisation switcher at the top of the sidebar, choose **Global**.
2. In the sidebar, select **General Settings**.
The **Enterprise Settings** page opens.
3. Select the **Functions** tab.
## Create the function [#create-the-function]
On the **Functions** tab:
1. Select **New function**.
2. Enter the **Display name**, such as `Loyalty award processor`. The identifier is generated for you.
3. Under **Kind**, choose **Platform event** or **Hook**. This choice is permanent.
4. (Hook only) Under **Hook point**, choose the hook point, here **contact.verify — Contact verification**.
**Hook point** starts on **contact.create.pre — Contact create (pre)**, and it's permanent too: the function can only ever bind there.
5. Select **Create**.
Each kind or hook point you choose drops a working starter handler into **Source (TypeScript)**, until you edit the code yourself. OmniLab type-checks the source and saves the function as **Draft**.
## Write your handler and save the draft [#write-your-handler-and-save-the-draft]
1. On the function's row, select **Edit**.
2. On the **Source** tab, write your handler.
3. Select **Save source**.
Your file must export the name that matches your kind, or saving fails with a type error. The starter handler already exports the right name and compiles as-is, so you can build it unchanged to check the setup.
The **contact.verify** starter reads the secret `brevo.api_key`, in lowercase. Secret keys are case-sensitive, so change it to match the key you [create in your bundle](/docs/developers/functions/configure-runtime-and-secrets#create-a-bundle), such as `brevo.API_KEY`.
Working examples for each kind, from a hello world up, are on [Platform event functions](/docs/developers/functions/platform-event-functions) and [Hook functions](/docs/developers/functions/hook-functions).
* The editor shows a live size counter against the **1 MB** source ceiling. The entry point is `user.ts`.
* **Save source** stays greyed out until you change something.
* Saving stores and type-checks your code. It doesn't deploy anything: a live function keeps serving its current version until you build.
* If the type check fails, a **Lint failed** panel lists each problem with its file, line and column. Select a row to jump to it. Nothing is saved while the check fails.
## Build it [#build-it]
In the function's editor:
1. Select the **Build** tab.
2. Select **Build**.
The status changes to **Building**, then **Ready**, usually within a minute: the version number goes up, and **WASM sha** and **WASM size** fill in. If the build fails, the tab shows the error and a truncated build log. Rebuilding unchanged source returns almost at once.
## Confirm it runs [#confirm-it-runs]
Before you wire it up, [run a test invocation](/docs/developers/functions/test-and-monitor-functions#run-a-test-invocation) from the function's **Test** tab.
## If something's blocked [#if-somethings-blocked]
* **The Functions tab is not there**: you are either not an Admin, or not in the **Global** organisation. Switch organisation with the picker at the top of the sidebar.
* **Saving reports a type error you don't recognise**: the most common cause is the export name. A hook function for `contact.create.pre` must export exactly `onContactCreatePre`; a platform event function must export `onPlatformEvent`. The name is derived from the kind and hook point, and a mismatch is reported as a missing or wrongly shaped export. A handler that doesn't match its declared type fails the same way: a wrong parameter, a wrong return value, or the wrong handler type imported for the kind. Compare your handler against the example on your kind's page.
* **The build has been Building for a long time**: a build that stalls is released after about fifteen minutes, and you can then start another. If it happens repeatedly, contact support with the build id from the **Build** tab.
* **Your handler cannot reach an external API**: a function with no allowed hosts is refused every outbound request. See [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets).
* **The function is Ready but nothing happens**: a built function is not triggered by anything until you add a subscription or a hook binding.
## Next steps [#next-steps]
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Platform event functions](/docs/developers/functions/platform-event-functions)
* [Hook functions](/docs/developers/functions/hook-functions)
# Function SDK reference (/docs/developers/functions/function-sdk-reference)
The complete contract between your TypeScript and the OmniLab runtime.
## Handler exports [#handler-exports]
Your file must export exactly one handler, named after the function's kind. Saving checks that export against the type for that kind, so with a missing or wrongly shaped handler, saving fails instead of the function failing in production.
| Kind | Export | Signature |
| ------------------------- | -------------------- | ------------------------------------------------------------------------------ |
| Platform event | `onPlatformEvent` | `(event: PlatformEvent) => void \| Promise` |
| Hook `contact.create.pre` | `onContactCreatePre` | `(input: ContactCreateInput) => HookOutput \| Promise<…>` |
| Hook `contact.verify` | `onContactVerify` | `(input: ContactVerifyInput) => HookOutput \| Promise<…>` |
The types come from the `./omnilab` module, available in every function:
```ts title="Importing the SDK and its types"
import { OmniLab, contactDisplayName } from "./omnilab";
import type { PlatformEventHandler, ContactCreatePreHandler } from "./omnilab";
```
`OmniLab` is the SDK object. `contactDisplayName` is a pure helper for resolving a contact's name: see [the two name shapes](#a-contact-carries-one-of-two-name-shapes).
Handlers may be `async`. Only TypeScript is supported, in a single file whose entry point is `user.ts`.
## Input shapes [#input-shapes]
### PlatformEvent [#platformevent]
```ts title="What a platform-event function receives"
type PlatformEvent = {
type: string; // e.g. "touchpoint.completed.v1"
id: string; // unique per event: deduplicate on this
time: string; // RFC 3339
source: string;
data: Json; // the event payload, exactly as OmniLab published it
};
```
### Event payloads [#event-payloads]
`data` holds the event's fields, and they differ by event type. The [event reference](/docs/developers/webhooks/webhook-event-reference) lists them for every type, and shows which types reach a function. Cast `data` to the shape you expect, and treat every field as possibly absent.
## How platform events are delivered [#how-platform-events-are-delivered]
These rules apply on top of whatever a subscription selects:
* **Only events with an identified contact trigger a function.** An event whose `data` carries no `contact_id` is skipped, so an anonymous visitor's activity never runs a function.
* **Page visits never trigger a function.** A subscription that names `touchpoint.page_visit.v1` is refused when you save it.
* **Delivery is at-least-once.** The same `id` can reach your handler more than once.
Setting a contact field to the same value twice is harmless. Two things are not: calling an API that charges money, sends a message or allocates a voucher, and **appending** to a field you just read, which can lose one of the two writes. Deduplicate on `event.id` before either.
## Hook contracts [#hook-contracts]
Every hook input carries the same `context` object: metadata about the invocation, whatever the hook point. It is always present, and a field OmniLab didn't fill is an empty string.
```ts title="HookContext, on every hook input"
interface HookContext {
idempotency_key: string; // a new key per verification send; empty on contact.create.pre
group_id: string; // the organisation the binding was resolved for
interaction_id: string; // the campaign behind the operation, e.g. "interactions/"
interaction_public_key: string; // the public key of that campaign's entry point
}
```
`group_id`, `interaction_id` and `interaction_public_key` tell you which organisation's binding routed the call and which campaign the operation came from. A sign-up through a campaign fills all three. A contact created from admin, import or the API carries no campaign, so the interaction fields stay empty.
Only `contact.verify` fills `idempotency_key`. OmniLab never retries a hook call, and each send, including a resend, gets a new key. Forward it to a provider that accepts one.
**`contact.create.pre` receives an empty key.** If your handler calls a provider that charges or changes something, supply your own dedupe value: the contact's email is the usual choice.
### What a hook handler returns [#what-a-hook-handler-returns]
```ts title="HookOutput, from ./omnilab"
type HookOutput =
| { decision: "continue"; result?: T }
| { decision: "deny"; code: string; message: string };
```
`OmniLab.Flow.continue(result?)` and `OmniLab.Flow.deny(code, message)` build these values, and a plain object of the same shape is accepted too. A deny with an empty `code` is reported as `DENIED`.
The contract is broken when the handler returns nothing, uses another `decision`, or returns a `result` field the hook point doesn't define or gives it the wrong type. A broken contract counts as a failure. What each outcome does is in [What a hook guarantees](/docs/developers/functions/hook-functions#what-a-hook-guarantees).
### A contact carries one of two name shapes [#a-contact-carries-one-of-two-name-shapes]
Both hook points hand you a contact whose name arrives in **one** of two shapes, fixed by the campaign that acquired it:
* `firstname` + `lastname`: what an acquisition form collects by default;
* `fullname`: a single string, for campaigns whose form collects [one **Full Name** field](/docs/platform/forms/acquisition-form/acquisition-form-reference#collecting-one-full-name-instead-of-first-and-last).
Never both. The shape the contact doesn't carry is empty, so reading `firstname` alone leaves a greeting blank for a Full Name campaign, and `firstname + " " + lastname` renders a lone space. Use the helper instead of concatenating:
```ts title="Resolving a display name"
import { contactDisplayName } from "./omnilab";
const name = contactDisplayName(input.contact);
```
It resolves `fullname`, then `firstname` + `lastname`, then `email`, and returns an empty string when the contact has no usable name. Render nothing or your own placeholder then, never `contact.id`, which is an opaque `contacts/`.
Names are read-only from a hook. Neither result type carries a name field, so a function can't write either shape back.
### contact.create.pre [#contactcreatepre]
Runs before a contact is stored, from sign-up, admin, import or API.
```ts title="ContactCreateInput"
interface ContactCreateInput {
contact: {
email: string;
phone_number: string;
firstname?: string;
lastname?: string;
fullname?: string; // set instead of firstname and lastname: see above
custom_fields?: Record;
optin?: boolean;
};
source: "signup" | "admin" | "import" | "api";
context: HookContext; // idempotency_key is empty for this hook point
}
```
Continuing accepts an optional `email`, `phone_number` and `custom_fields`. Only the fields you return are applied: omit one to leave it as submitted.
`optin` is the marketing consent the visitor submitted. It is always sent, so `false` means they didn't opt in, never "unknown". It is read-only: the result carries no `optin`, so a function can't rewrite consent.
`source` is typed with four values, but every call sends `"signup"`, whatever created the contact. Don't branch on it.
### contact.verify [#contactverify]
Runs when a double opt-in sign-up needs its verification email. Your handler delivers it.
```ts title="ContactVerifyInput"
interface ContactVerifyInput {
contact: {
email: string;
phone_number?: string;
firstname?: string;
lastname?: string;
fullname?: string; // set instead of firstname and lastname: see above
optin?: boolean;
custom_fields?: Record;
};
verification_url: string; // the link your email must point to
expires_at: string; // RFC 3339 time when the link stops working
context: HookContext; // all four fields filled for a campaign sign-up
}
```
`custom_fields` holds the acquisition form's extra answers as submitted, so you can personalise the message from them. `optin` may steer which template you send, but it is **not** permission to skip the link: delivering `verification_url` is still required.
Continuing accepts an optional `provider_message_id`, recorded in the execution log only, and an optional `already_verified`. Set `already_verified: true` only when your records hold a confirmed opt-in for the address: see [Skipping the email](/docs/developers/functions/hook-functions#skipping-the-email-when-the-contact-is-already-verified).
A `coupon.assign` hook point appears in the interface labelled "coming soon", and the SDK carries its types, but binding, invoking and testing it are all refused. Don't build against it.
## SDK surface [#sdk-surface]
These are the only calls that reach outside the sandbox. Everything else in your handler is pure computation.
### Config [#config]
| Call | Returns | Notes |
| ---------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `OmniLab.Config.get(key)` | The plugin config value, or `undefined` | Non-sensitive configuration only |
| `OmniLab.Config.getSecret("bundle.KEY")` | The secret value | Keys are case-sensitive. Throws `PERMISSION_DENIED` when the bundle isn't bound to the function, and `NOT_FOUND` when the key doesn't exist. |
### Http [#http]
| Call | Notes |
| ------------------------------------------------ | ---------------------------------------------------------- |
| `OmniLab.Http.fetch(url, options?)` | The general form |
| `OmniLab.Http.get / post / put / patch / delete` | Set the method for you. `get` and `delete` take no `body`. |
The options are a subset of the web `fetch` options:
| Option | Type | Notes |
| --------- | ------------------------ | -------------------------------------------------------- |
| `method` | `string` | `GET` when you leave it out |
| `headers` | `Record` | Sent as given. No `Content-Type` is added for you. |
| `body` | `string` | Text only: serialise JSON yourself with `JSON.stringify` |
A request that gets an answer resolves to a response, whatever its status:
| Field | Type | Notes |
| ------------ | ------------------------ | -------------------------------------------------------------------------------------- |
| `ok` | `boolean` | `true` for a `2xx` status |
| `status` | `number` | The HTTP status. A `4xx` or `5xx` resolves normally rather than throwing. |
| `statusText` | `string` | The reason phrase alone, such as `Not Found` |
| `headers` | `Record` | Names in lower case. A repeated header's values are joined with `, `. |
| `text()` | `Promise` | The body as text |
| `json()` | `Promise` | The body parsed as JSON. Throws as soon as it's called when the body isn't valid JSON. |
`json()` throws on the spot rather than returning a rejected promise, so `response.json().catch(...)` never sees a bad body. An empty body, such as a `201` or `204` with no content, isn't valid JSON either. Call it inside `try`/`catch`:
```ts title="user.ts: post JSON and read the answer"
import { OmniLab } from "./omnilab";
import type { PlatformEventHandler } from "./omnilab";
type WithContact = { contact?: { contact_id?: string } };
export const onPlatformEvent: PlatformEventHandler = async (event) => {
const contactId = (event.data as WithContact).contact?.contact_id;
const response = await OmniLab.Http.post("https://crm.lindenhall.example/api/visits", {
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + OmniLab.Config.getSecret("crm.API_TOKEN"),
},
body: JSON.stringify({ contactId, eventId: event.id }),
});
if (!response.ok) {
throw new Error("CRM answered " + response.status + " " + response.statusText);
}
if (response.status === 204) {
return; // accepted, with nothing to read
}
try {
const visit = await response.json<{ id: string }>();
console.log("CRM visit " + visit.id);
} catch {
console.warn("CRM answered " + response.status + " without a JSON body");
}
};
```
Every request is checked against the function's allowed-hosts list, and internal or private addresses are refused whether or not you listed them. Only `http` and `https` are allowed. A request that fails before any answer rejects with an [error code](#error-codes): `EGRESS_BLOCKED`, `TIMEOUT` or `UNAVAILABLE`. A response body over the [size limit](#limits) is truncated, and the response carries the header `x-omnilab-response-truncated: true`.
### Contact [#contact]
| Call | Returns |
| ------------------------------------------------ | --------------------------------------- |
| `OmniLab.Contact.get(id)` | `Promise` |
| `OmniLab.Contact.setCustomField(id, key, value)` | `Promise`: the updated contact |
| `OmniLab.Contact.setCustomFields(id, fields)` | `Promise`: all or nothing |
All three resolve to the same object, so a write hands back the contact it just changed and you rarely need a follow-up read:
```ts title="Contact"
interface Contact {
id: string; // "contacts/"
firstname: string; // one of two name shapes: see below
lastname: string;
fullname: string;
email: string;
phoneNumber: string;
externalId: string;
barcode: string;
customFields: Record; // the only writable part
emailVerified: boolean;
optin: boolean;
hasAcceptedTerms: boolean;
isBlacklisted: boolean;
blacklistReason: string;
originInteraction: string;
}
```
Every field except `customFields` is read-only from a function. Custom field values are always strings.
A contact read here carries the same two name shapes as a hook input: `firstname` + `lastname`, or `fullname`, never both. Resolve it with `contactDisplayName(contact)` rather than reading the parts.
Custom field keys beginning `system.` or `feature.` are reserved and rejected with `INVALID_ARGUMENT`. A key can be up to 128 characters and a value up to 4 KB. Prefer `setCustomFields` for several keys: it is one call against your budget rather than several.
### Logging [#logging]
`console.log`, `console.info`, `console.warn`, `console.error`, `console.debug` and `console.trace` are captured into the execution record and shown in the **Test** tab. Capture is limited to 16 KB total, 256 lines and 2 KB per line; beyond that the log is marked truncated.
### Designing within the SDK [#designing-within-the-sdk]
The calls above are the whole surface. When your logic needs more, reach your own systems over HTTP and do the work there. Three consequences to plan around:
**Keep your state outside the function.** Every invocation starts fresh, so a value you assign at module level is gone by the next one. Persist what you need to contact custom fields, or to your own service.
**Reach other OmniLab records through the API.** Functions read and write contacts. For transactions, rewards, smart links and everything else, call the [API](/docs/developers/api-reference) from your own systems instead.
**Functions run on triggers, not on a clock.** There is no way to schedule one. If you need work to happen at a set time, run it from your own scheduler.
## Error codes [#error-codes]
SDK failures throw an `OmniLabError` carrying a `code`, a `message` and a `retryable` flag. Import the type from `./omnilab` and read `(error as OmniLabError).code`.
| Code | Retryable | Typical cause |
| -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| `NOT_FOUND` | No | A secret key that doesn't exist (keys are case-sensitive), a function with no bundle bound, or a contact that doesn't exist |
| `PERMISSION_DENIED` | No | `getSecret` names a bundle that isn't bound to this function |
| `INVALID_ARGUMENT` | No | A reserved custom field key, a malformed value, or a secret reference not shaped `bundle.KEY` |
| `ALREADY_EXISTS` | No | A conflicting write |
| `EGRESS_BLOCKED` | No | The hostname isn't allow-listed, or resolves to a private address |
| `RESOURCE_EXHAUSTED` | No | The 21st SDK call in one invocation. The invocation is stopped, so catching it doesn't help. |
| `TIMEOUT` | Yes | An HTTP request exceeded its 1500 ms limit |
| `UNAVAILABLE` | Yes | A dependency was temporarily unreachable |
| `INTERNAL` | Yes | An unexpected platform error |
The SDK's `OmniLabErrorCode` type also lists `RATE_LIMITED` and `QUOTA_EXCEEDED`, but no SDK call raises them.
Error messages are meant for a human reading the execution log, and their wording isn't stable. Never parse one: branch on the `code`.
## Failure semantics [#failure-semantics]
* **Hooks**: what happens on a deny, an error, a timeout or a missing binding, for each hook point, is in [What a hook guarantees](/docs/developers/functions/hook-functions#what-a-hook-guarantees).
* **Platform events**: the activity behind the event is never affected. Retries are described in [Failure and retries](/docs/developers/functions/platform-event-functions#failure-and-retries).
## Limits [#limits]
### Per invocation [#per-invocation]
| Limit | Value |
| -------------------- | --------------------------------------------------------------------------------------------------------------- |
| Timeout | 50 – 2000 ms, default 2000 |
| Memory | 4 MiB for every handler. **Max memory pages** accepts 1 – 64 pages of 64 KiB, default 32, but isn't applied yet |
| SDK calls | 20 |
| HTTP request timeout | 1500 ms |
| HTTP response body | 4 MiB, truncated beyond |
The timeout covers your handler only. The time OmniLab spends preparing to run it isn't charged against your limit: the execution detail reports the two separately.
### Per function [#per-function]
| Limit | Value |
| -------------------- | ------------------- |
| Source size | 1 MB |
| Allowed hosts | 16 |
| Plugin config | 32 keys, 4 KB total |
| Secret bundles bound | 5 |
| Builds in flight | 1 |
### Across all your functions [#across-all-your-functions]
| Limit | Value |
| ----------------------------------- | -------------------------------------------------------------------------------------------- |
| Active subscriptions per event type | 5 |
| Active hook bindings per hook point | 1 global, plus 1 per group |
| Secret bundles | 5 |
| Secrets per bundle | 32 |
| Secret bundle key | Up to 32 characters: lowercase letters, digits and underscores, starting with a letter |
| Secret key | Up to 64 characters: letters, digits and underscores, starting with a letter. Case-sensitive |
### Retention and truncation [#retention-and-truncation]
| Item | Value |
| ------------------------- | --------------------------------------------------------------- |
| Execution records | 24 hours |
| Recorded input and output | 256 KB per side, truncated beyond, with original sizes reported |
| Captured logs | 16 KB total, 256 lines, 2 KB per line |
| Build log shown in Studio | The last 8 KB |
### Build [#build]
| Limit | Value |
| ----------------------------------------------------- | ---------- |
| Type check when you save | 10 seconds |
| Build | 3 minutes |
| A stalled build is released, so you can start another | 15 minutes |
There are no rate limits on invocation. Volume is bounded instead by the SDK call budget, the subscription cap, and the exclusion of anonymous page-visit events.
## Next steps [#next-steps]
* [About functions](/docs/developers/functions/about-functions)
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Test and monitor functions](/docs/developers/functions/test-and-monitor-functions)
# Hook functions (/docs/developers/functions/hook-functions)
A hook function runs *during* an operation, not after it. OmniLab pauses, calls your code, and does what your answer says: continue, optionally with values you supply, or refuse. That power is why hook functions need more care than [platform event functions](/docs/developers/functions/platform-event-functions).
## When to use it [#when-to-use-it]
Choose a hook function when an operation's outcome must depend on your logic. Examples: blocking sign-ups from disposable email domains, normalising data before it's stored, or sending verification mail through your own provider. To react to something that already happened, use a platform event function instead.
## The two hook points [#the-two-hook-points]
A hook function is created for one hook point and can only ever bind to that one.
| Hook point | Runs when | Your export | `continue` lets you |
| -------------------- | -------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------- |
| `contact.create.pre` | Before a contact is stored, from sign-up, admin, import or API | `onContactCreatePre` | Rewrite the email, phone number and custom fields |
| `contact.verify` | A double opt-in sign-up needs its verification email | `onContactVerify` | Report the provider's message id, or that the address is already verified |
## What a hook guarantees [#what-a-hook-guarantees]
| When | `contact.create.pre` | `contact.verify` |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| No active binding covers the organisation | The contact is stored as submitted | The sign-up fails, and campaigns there can't be published while double opt-in is on |
| Your handler returns `continue` | The fields you returned are applied, then the contact is stored | The visitor waits for the link you sent. With `already_verified: true`, the contact is created verified instead |
| Your handler returns `deny` | The contact is rejected with your `code`, or `DENIED` if the code is empty | The sign-up fails |
| Your handler throws, runs past a [limit](/docs/developers/functions/function-sdk-reference#limits) or breaks the contract, or the function was never built | The contact is rejected | The sign-up fails |
| Retries | None: the operation is waiting for your answer | None |
| To roll back a bad handler | Disable the binding: contacts are stored as submitted again | Save and build your last working source. Don't disable the binding while double opt-in is on |
A deny is a normal, successful run. The fourth row is a failure, and hooks fail closed: OmniLab refuses the operation rather than let it through unchecked.
Build your answer with `OmniLab.Flow.continue(result?)` or `OmniLab.Flow.deny(code, message)`. A plain object of the same shape works too: the [hook contract](/docs/developers/functions/function-sdk-reference#what-a-hook-handler-returns) lists what is accepted.
## contact.create.pre [#contactcreatepre]
Your file exports `onContactCreatePre`.
```ts title="user.ts — hello world"
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = (input) => {
console.log("Hello world! A contact is being created: " + input.contact.email);
return OmniLab.Flow.continue();
};
```
This lets every contact through unchanged, so it's the right first version. Bind it, watch the executions appear, and check sign-ups still work. Only then add logic that can say no.
### Rejecting and rewriting [#rejecting-and-rewriting]
`continue()` with no argument changes nothing. Pass an object and OmniLab applies only the fields you return: a field you leave out stays exactly as submitted.
```ts title="user.ts — normalise the email, block a domain"
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = (input) => {
const email = input.contact.email.trim().toLowerCase();
if (email.endsWith("@blocked.example")) {
return OmniLab.Flow.deny("blocked_domain", "Email domain is on the blocklist");
}
// Only `email` is returned, so the phone number and custom fields are stored as submitted.
return OmniLab.Flow.continue({ email });
};
```
Only the `code` you deny with goes back to the caller. The `message` stays in the execution log, so write it for the person reading the log, not the visitor.
Normalising here pays off because the hook runs before the contact is stored and before any connected CRM is updated. The email you return is the one OmniLab stores and forwards. Lowercasing it stops `Foo@example.com` and `foo@example.com` becoming two profiles.
### When your provider fails [#when-your-provider-fails]
If your handler calls a third party, decide what happens when that party is down. Hooks fail closed, so doing nothing means a vendor outage stops sign-ups for every organisation the binding covers.
Catch the failure and continue. A vendor being unreachable is not evidence that a shopper is a fraud:
```ts title="user.ts — refuse bad addresses, but survive an outage"
import { OmniLab } from "./omnilab";
import type { ContactCreatePreHandler } from "./omnilab";
export const onContactCreatePre: ContactCreatePreHandler = async (input) => {
const email = input.contact.email.trim().toLowerCase();
try {
const response = await OmniLab.Http.get(
"https://api.example-checker.com/v1/check?email=" + encodeURIComponent(email),
);
const verdict = await response.json<{ disposable: boolean }>();
if (verdict.disposable) {
return OmniLab.Flow.deny("disposable_email", "Checker flagged the address as disposable");
}
} catch (error) {
// The checker is unreachable. Let the sign-up through rather than block a
// real customer over someone else's outage.
console.warn("email check unavailable, allowing sign-up: " + String(error));
}
return OmniLab.Flow.continue({ email });
};
```
Deny only on a definite negative answer. Treat "I couldn't get an answer" as a reason to continue.
`try`/`catch` covers a failed HTTP request, not your handler running out of time. Keep the function's timeout above your provider's slowest responses: see [Runtime limits](/docs/developers/functions/configure-runtime-and-secrets#runtime-limits).
## contact.verify [#contactverify]
Your file exports `onContactVerify`. OmniLab builds the double opt-in link, and your handler delivers it through your email provider.
Start by proving the wiring without a provider. The link arrives in `input.verification_url`, so log it:
```ts title="user.ts — hello world, for setup only"
import { OmniLab } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";
export const onContactVerify: ContactVerifyHandler = (input) => {
console.log("Hello world! Verification link for " + input.contact.email);
console.log(input.verification_url);
return OmniLab.Flow.continue();
};
```
Sign up with a test contact, open the execution log, and copy the link from the **Logs** panel to complete verification by hand.
Continuing without sending anything tells OmniLab the email went out when it didn't, so real contacts never receive a link. Use this version only while you set the function up.
### Sending through your provider [#sending-through-your-provider]
OmniLab never retries a hook call. Each send, including a resend the contact asks for, carries a new `input.context.idempotency_key`. Pass it on if your provider accepts an idempotency key; otherwise you can ignore it.
The context also says where the sign-up came from. `context.interaction_id` and `context.interaction_public_key` identify the campaign, and `context.group_id` the organisation the binding was resolved for. One function can use them to vary its email per campaign, instead of you keeping a function per campaign.
Build the greeting with `contactDisplayName` rather than `input.contact.firstname`. A contact carries **either** `firstname` + `lastname` **or** a single `fullname`, depending on how the campaign's [acquisition form collects names](/docs/platform/forms/acquisition-form/acquisition-form-reference#collecting-one-full-name-instead-of-first-and-last). A handler that reads only `firstname` sends "Hello ," to everyone from a Full Name campaign.
```ts title="user.ts — send with Brevo"
import { OmniLab, contactDisplayName } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";
export const onContactVerify: ContactVerifyHandler = async (input) => {
// Reads fullname, or firstname + lastname, whichever the contact carries.
const name = contactDisplayName(input.contact);
// Plugin config values are strings, and Brevo expects a number.
const templateId = Number(OmniLab.Config.get("brevo_template_id"));
if (!Number.isInteger(templateId) || templateId < 1) {
throw new Error("Set brevo_template_id in Plugin config to a Brevo template id");
}
const response = await OmniLab.Http.post("https://api.brevo.com/v3/smtp/email", {
headers: {
"content-type": "application/json",
"api-key": OmniLab.Config.getSecret("brevo.API_KEY"),
},
body: JSON.stringify({
to: [{ email: input.contact.email, name }],
templateId,
params: { NAME: name, VERIFICATION_URL: input.verification_url },
}),
});
if (!response.ok) {
return OmniLab.Flow.deny("dispatch_failed", "Brevo refused the send: HTTP " + response.status);
}
const sent = await response.json<{ messageId?: string }>();
return OmniLab.Flow.continue({ provider_message_id: sent.messageId ?? "" });
};
```
This needs `api.brevo.com` in the allowed hosts, a `brevo_template_id` key in **Plugin config**, and a bound `brevo` bundle holding `API_KEY`. All three are set up in [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets). Without a valid template id the handler throws, so the sign-up fails and the execution log says why.
**Continuing means dispatched, not delivered.** `continue` tells OmniLab your provider accepted the message, not that it arrived. Deny only when the dispatch itself failed, because a deny fails the sign-up.
### Skipping the email when the contact is already verified [#skipping-the-email-when-the-contact-is-already-verified]
Your own records may already hold a confirmed opt-in for this address, from an earlier campaign say. Then return `already_verified: true` instead of sending: OmniLab creates the contact as verified on the strength of your proof. Set it only from a confirmed double opt-in, never from mere presence in an imported list: you are vouching for consent.
```ts title="user.ts — skip the email when your records prove consent"
import { OmniLab, contactDisplayName } from "./omnilab";
import type { ContactVerifyHandler } from "./omnilab";
// Asks your own system whether this address already completed double opt-in.
// Any failure counts as "not proven", so the email is sent as usual.
async function hasConfirmedOptIn(email: string): Promise {
try {
const response = await OmniLab.Http.get(
"https://crm.lindenhall.example/v1/optins?email=" + encodeURIComponent(email),
{ headers: { authorization: "Bearer " + OmniLab.Config.getSecret("crm.API_TOKEN") } },
);
if (!response.ok) {
return false;
}
const record = await response.json<{ confirmed?: boolean }>();
return record.confirmed === true;
} catch (error) {
console.warn("opt-in lookup failed, sending the email: " + String(error));
return false;
}
}
export const onContactVerify: ContactVerifyHandler = async (input) => {
if (await hasConfirmedOptIn(input.contact.email)) {
return OmniLab.Flow.continue({ already_verified: true });
}
const name = contactDisplayName(input.contact);
const templateId = Number(OmniLab.Config.get("brevo_template_id"));
if (!Number.isInteger(templateId) || templateId < 1) {
throw new Error("Set brevo_template_id in Plugin config to a Brevo template id");
}
const response = await OmniLab.Http.post("https://api.brevo.com/v3/smtp/email", {
headers: {
"content-type": "application/json",
"api-key": OmniLab.Config.getSecret("brevo.API_KEY"),
},
body: JSON.stringify({
to: [{ email: input.contact.email, name }],
templateId,
params: { NAME: name, VERIFICATION_URL: input.verification_url },
}),
});
if (!response.ok) {
return OmniLab.Flow.deny("dispatch_failed", "Brevo refused the send: HTTP " + response.status);
}
const sent = await response.json<{ messageId?: string }>();
return OmniLab.Flow.continue({ provider_message_id: sent.messageId ?? "" });
};
```
On top of the Brevo setup, this needs `crm.lindenhall.example` in the allowed hosts and a `crm` bundle holding `API_TOKEN`.
## Bind it to the hook point [#bind-it-to-the-hook-point]
Choose the scope first:
| Scope | Use it when | Set it with |
| ------------------- | ---------------------------------------------- | --------------------------------- |
| **Global** | The function should run for every organisation | **Global (all groups)** turned on |
| **Specific groups** | Only some organisations need it | **Target groups** |
There's no draft or log-only binding: covered operations start waiting for your function within seconds. Test it first, and bind one low-traffic organisation before you go global.
In the function's editor:
1. Select the **Hooks** tab.
2. Select **Add binding**.
The **Hook point** is fixed by the function and shown read-only.
3. Under **Target groups**, select **Lindenhall Shopping Centre**.
4. Select **Create**.
The binding starts **Active**. To cover every organisation, turn on **Global (all groups)** at step 3 instead.
The form enforces the scoping rules:
* At most **one active global binding** per hook point. If one exists, the toggle is disabled and says why.
* A group belongs to **at most one active binding** per hook point. Groups already covered are greyed out.
* A **group-specific binding wins over the global one** for its groups. Use a global binding for the default and group bindings for exceptions.
There's no edit action: to change a binding's groups, delete it and create a replacement. For `contact.verify`, bind before an admin turns on double opt-in: see [Double opt-in email verification](/docs/integrations/email-triggers/double-opt-in-email-verification).
### Take a binding out of the live path [#take-a-binding-out-of-the-live-path]
What happens next depends on the hook point: see the last row of [What a hook guarantees](#what-a-hook-guarantees). For `contact.verify`, disabling the binding makes every double opt-in sign-up fail.
In the function's editor:
1. Select the **Hooks** tab.
2. On the binding's row, select **Disable**.
**Enable** puts it back. **Delete** removes the binding and keeps the function. The function card shows **unbound** when a hook function has no active binding.
## If something's blocked [#if-somethings-blocked]
* **The Hooks tab is missing**: the tab only appears on hook functions. Check the kind badge at the top of the editor.
* **The global toggle is disabled**: another active global binding covers this hook point. Disable it, or target specific groups instead.
* **A group is greyed out in Target groups**: another active binding on this hook point already covers it.
* **Sign-ups fail since you created a binding**: roll back as [the guarantees](#what-a-hook-guarantees) say for your hook point, then read the execution log.
* **Runs look clean but operations are refused, or a test shows Contract violation**: your handler's answer breaks the contract. See [Reading a hook contract violation](/docs/developers/functions/test-and-monitor-functions#reading-a-hook-contract-violation).
* **The verification email never arrives**: open the execution detail. `EGRESS_BLOCKED` means the provider's host isn't allow-listed. `PERMISSION_DENIED` or `NOT_FOUND` from `getSecret` is a bundle or key problem: see [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets#if-somethings-blocked).
## Next steps [#next-steps]
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Test and monitor functions](/docs/developers/functions/test-and-monitor-functions)
* [Function SDK reference](/docs/developers/functions/function-sdk-reference)
# Functions (/docs/developers/functions)
Create a function, wire it to an event or a hook, then monitor it.
# Platform event functions (/docs/developers/functions/platform-event-functions)
A platform event function runs *after* the fact. Something happens in OmniLab — a touchpoint is completed, a reward is won, a contact is created — and your function is invoked with that event. It cannot change what already happened, which makes it the safe kind to start with.
## When to use it [#when-to-use-it]
Choose a platform event function when you want to react to activity: enrich a contact after they play, push a record to your CRM, notify an internal channel when a prize is claimed. If instead you need to *influence* an operation while it is happening — reject a sign-up, rewrite a submitted email — you need a [hook function](/docs/developers/functions/hook-functions).
## The handler [#the-handler]
Your file exports `onPlatformEvent`. It receives one event and returns nothing.
```ts title="user.ts — hello world"
import type { PlatformEventHandler } from "./omnilab";
export const onPlatformEvent: PlatformEventHandler = (event) => {
console.log("Hello world! Received " + event.type + " (" + event.id + ")");
};
```
Build this, subscribe it to an event type, and every matching event shows up in the execution log with your line in the **Logs** panel. That is the whole loop, and it is worth confirming before you write anything more involved.
The event you receive always has the same five fields:
```ts title="What your handler receives"
{
type: "touchpoint.completed.v1",
id: "evt-...", // unique per event
time: "2026-05-28T14:48:31Z",
source: "…", // where the event came from
data: { /* the event payload */ }
}
```
### Doing something useful [#doing-something-useful]
Because the return value is discarded, a platform event function has an effect only by calling out through the SDK. This one tags the contact with the last touchpoint they finished:
```ts title="user.ts — write a contact custom field"
import { OmniLab } from "./omnilab";
import type { PlatformEventHandler } from "./omnilab";
type TouchpointCompleted = {
contact?: { contact_id?: string };
touchpoint_title?: string;
};
export const onPlatformEvent: PlatformEventHandler = async (event) => {
const data = event.data as TouchpointCompleted;
const contactId = data.contact?.contact_id;
if (!contactId) {
console.log("No contact on " + event.id + ", nothing to do");
return;
}
await OmniLab.Contact.setCustomField(
contactId,
"last_touchpoint",
data.touchpoint_title ?? "unknown",
);
};
```
`event.data` is the raw event payload, so cast it to the shape you expect and read defensively — fields differ between event types. The [event reference](/docs/developers/webhooks/webhook-event-reference) lists what each type carries. For a complete sample of any type the picker lists, open the **Test** tab and use **Load example**.
The same event can reach your handler twice, so [deduplicate on `event.id`](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered) before any external side effect.
## Subscribe it to events [#subscribe-it-to-events]
A built function runs only once you subscribe it to event types, and only for events that [carry an identified contact](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered).
In the function's editor:
1. Select the **Subscriptions** tab.
2. Select **Add subscription**.
3. Enter the **Display name**, here `Loyalty award trigger`.
4. Under **Event types**, choose **Completed** in the **Touchpoint** group.
Leave **Event types** empty to match **every** event.
5. Select **Create**.
The subscription starts **Active** and appears in the list with its event types and filters. The picker groups events as **Touchpoint**, **Reward**, **Contact** and **Other**, and you can search it. Leave out **Expired** in the **Reward** group: the platform never sends it. The [event reference](/docs/developers/webhooks/webhook-event-reference) shows which types reach a function, listed or not.
A function can carry several subscriptions. The tab warns you when an event type reaches its [subscription cap](/docs/developers/functions/function-sdk-reference#limits).
### Add an event type the picker doesn't list [#add-an-event-type-the-picker-doesnt-list]
In the subscription form:
1. Enter the type in the custom event field, below **Event types**.
2. Select **Add custom**.
Booking, ticket, check-in and receipt submission events reach a function this way: copy their names from the [event reference](/docs/developers/webhooks/webhook-event-reference). Any other type saves too, even one the platform never sends, so a misspelt name never fires. Only `touchpoint.page_visit.v1` is refused, when you select **Create**.
### Narrow a subscription with filters [#narrow-a-subscription-with-filters]
Filters are combined with AND and must match exactly. With no filters, every event of the selected types triggers the function.
In the subscription form:
1. Select **Add filter**.
2. Choose the key, here **Interaction**. The others are **Tenant**, **Group** and **Contact**.
3. Enter the exact id to match, here the campaign's `interaction.interaction_id` from the payload.
4. Select **Create**, or **Save** when you edit a subscription.
The **Status** field appears when you edit a subscription, but saving a change to it has no effect, and the form says so. To stop a subscription, delete it, and create it again when you need it back.
## Failure and retries [#failure-and-retries]
Your function's failures do not affect the activity that produced the event — the operation has already completed by the time you are invoked.
| What happened | What OmniLab does |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Your handler throws | The failure is recorded as an execution with a non-zero exit code, and is **not** retried — the same code would throw again |
| A transient problem on OmniLab's side | Retried up to 5 times after the first attempt, waiting longer each time |
| A permanent problem, such as the function having been deleted | Stops being retried, because retrying cannot help |
## If something's blocked [#if-somethings-blocked]
* **The Subscriptions tab is missing**: the tab only appears on platform event functions. Check the kind badge at the top of the editor.
* **A subscription exists but the function never runs**: confirm the function status is **Ready** — a **Draft** function is skipped entirely — and that the subscription is **Active**. Then check that your filters are not excluding everything, and that the event [carries an identified contact](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered).
* **A custom event type never fires**: the platform doesn't send that type. A misspelt name saves without complaint, so check it against the [event reference](/docs/developers/webhooks/webhook-event-reference).
* **The subscription is refused when you select Create**: it names `touchpoint.page_visit.v1`, which functions can never subscribe to.
* **It ran, but nothing changed**: remember the return value is discarded. Check the **Logs** and the SDK call results in the execution detail — an `EGRESS_BLOCKED`, `PERMISSION_DENIED` or `NOT_FOUND` error there is the usual reason a handler runs cleanly but has no effect.
## Next steps [#next-steps]
* [Test and monitor functions](/docs/developers/functions/test-and-monitor-functions)
* [Hook functions](/docs/developers/functions/hook-functions)
* [Function SDK reference](/docs/developers/functions/function-sdk-reference)
# Test and monitor functions (/docs/developers/functions/test-and-monitor-functions)
Both tools are in the function editor: test a function before you wire it up, then read its log once it runs for real.
## Run a test invocation [#run-a-test-invocation]
Testing requires status **Ready**: a function that has not built successfully has nothing to run.
On the **Functions** tab:
1. On the function's row, select **Edit**.
2. Select the **Test** tab.
3. Under **Load example**, choose a sample input.
The input editor fills with a payload matching the function's kind: a full event envelope for any event type in the catalogue, or the typed input for the hook point.
4. (Optional) Edit the payload, then select **Beautify JSON** to reformat it.
The editor validates JSON as you type and marks the offending line if it can't parse.
5. Select **Run test**.
You can leave **Timeout override (ms)** empty: a test run always uses the function's own configured timeout.
What you see depends on the kind:
* **Platform event** functions show an exit code badge, the duration, the request id, and the output. Exit code `0` with no error means success. The output is shown for debugging only: in production it is discarded, so a function that needs an effect must act through the SDK.
* **Hook** shows the decision your handler returned — continue or deny — plus the code and message on a denial, and the result object it supplied.
Anything your handler logged with `console.log` and friends appears in a **Logs** panel below the result, alongside a couple of platform lines showing the runtime starting up and the handler being called.
Tests run in the real sandbox with the real SDK. An allow-listed HTTP request genuinely goes out, and a contact custom field write genuinely lands. Use test data, and be careful with hooks that call an external provider — a `contact.verify` test really sends an email.
### Reading a hook contract violation [#reading-a-hook-contract-violation]
When a hook run fails, the result shows a **Contract violation** panel instead of a decision. In production the same run [fails the operation](/docs/developers/functions/hook-functions#what-a-hook-guarantees).
It appears when your handler throws, returns nothing, uses a decision other than `continue` or `deny`, or returns a `result` field the hook point doesn't define. A plain `{ decision: "continue" }` object is fine, and a deny with an empty code is reported as `DENIED`.
In the execution log, a malformed answer still reads exit code `0`, because the run itself succeeded. When a hook blocks operations but its runs look clean, paste a recorded input here and run it.
## Read the execution log [#read-the-execution-log]
The **Executions** tab lists real invocations, newest first, and is the primary tool for answering "did it run, and what happened".
| Column | What it tells you |
| ---------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Started** | When the invocation began |
| **Source** | How it was triggered — `event`, `hook`, or `test` |
| **Exit code** | `0` for success; anything else, or a populated error, means the run failed |
| **Duration** | End-to-end time for the invocation, so it can read much higher than the handler time shown on a test result |
| **Error / Request id** | The failure message, or the request id to quote to support |
Records are held for **24 hours** and then expire, so capture anything you need for a post-mortem while it is still there. Keep each version of your handler in your own repository too — the platform tracks a version number and the compiled artifact, not your source history, so your repo is what lets you put the previous version back. The list loads 20 at a time with **Load more**, and **Refresh** pulls the newest.
Select a row to open its detail, which adds everything the platform recorded:
* The **input** and **output** as stored, capped at 256 KB per side. When either was longer, a banner reports the original sizes.
* The **WASM sha** — which compiled version actually ran. This is how you tell "my new code is broken" from "the old version is still serving".
* The full **logs**, or a truncated marker when your handler logged more than the capture allows.
* **Trace metrics** under a collapsible section: how many SDK calls the run made, memory used, and a breakdown of where the time went — waiting to start, getting ready to run, and running your code.
If a function feels slow, check the trace metrics before touching your code. The time OmniLab spends preparing the first run after a change is not something your handler can influence, and it does not count against your timeout. Only the time attributed to your own code is yours to improve.
## If something's wrong [#if-somethings-wrong]
**Check.** The function's status is **Ready**, and it has an active subscription or hook binding.
**Fix.** A **Draft** function never receives events. A **Ready** function with no wiring is never triggered. For an event function, also check the activity had an identified contact: [anonymous events trigger nothing](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered).
**Check.** The error message and the logs on the execution detail.
**Fix.** A non-zero exit code means your handler threw. Because the platform cannot tell a deliberate throw from a bug, add logging around the failure and re-run from the **Test** tab with the same input — the detail view shows you the input that failed, so you can paste it straight in.
**Check.** Whether the error is `EGRESS_BLOCKED` or `TIMEOUT`.
**Fix.** `EGRESS_BLOCKED` means the hostname is not allow-listed, is spelled differently, uses an unsupported scheme, or resolves to a private address. `TIMEOUT` means the request ran past the fixed per-request [limit](/docs/developers/functions/function-sdk-reference#limits), so the dependency has to answer faster or the call has to move out of the function.
**Check.** The error message, and the SDK call count in the trace metrics.
**Fix.** The run went past its **Timeout (ms)** or its SDK call budget: a call over budget fails with `RESOURCE_EXHAUSTED` and stops the invocation. Both values are under [Limits](/docs/developers/functions/function-sdk-reference#limits). Batch contact field writes with `setCustomFields` instead of writing one key at a time, and avoid loops that make a call per item.
**Check.** The **Executions** tab: rows with source `hook` are the production runs.
**Fix.** Roll back as [What a hook guarantees](/docs/developers/functions/hook-functions#what-a-hook-guarantees) says for your hook point: for `contact.verify`, disabling the binding makes things worse. Then paste a recorded input into the **Test** tab and run it: it shows a deliberate deny, an error, or a [contract violation](#reading-a-hook-contract-violation).
**Check.** Whether the two executions share the same event id on their detail views.
**Fix.** This is expected: an event [can be delivered more than once](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered). Deduplicate on the event id before any external side effect.
**Check.** The error on the execution detail.
**Fix.** Which failures are retried, and which never are, is in [Failure and retries](/docs/developers/functions/platform-event-functions#failure-and-retries).
**Check.** The **WASM sha** on a recent execution against the sha on the **Build** tab.
**Fix.** If they differ, your build did not replace what is serving. A failed rebuild deliberately keeps the previous working version live, and the function stays **Ready** — so check the **Build** tab for a build error rather than assuming the deploy succeeded.
## Still stuck? [#still-stuck]
Send support the **request id** from the failing execution and the **build id** from the **Build** tab. Execution records expire after 24 hours, so capture the detail view before then — including the input, the error, and the trace metrics.
## Next steps [#next-steps]
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Hook functions](/docs/developers/functions/hook-functions)
* [Function SDK reference](/docs/developers/functions/function-sdk-reference)
# API overview (/docs/developers/getting-started/api-overview)
Your backend calls the API and receives webhooks, while functions run your own code on the platform.
## What the developer surface is used for [#what-the-developer-surface-is-used-for]
* Server-to-server authentication with client credentials
* Booking self-service experiences, such as listing or cancelling a participant's bookings
* Event-driven integrations through webhooks
* Your own TypeScript, run by the platform after an event or inside a contact sign-up, with functions
* Integrations your Customer Success Manager enables for your account
## What is not published yet [#what-is-not-published-yet]
The full resource-by-resource REST reference is not published in this help centre yet. Do not assume that an endpoint you discover elsewhere is part of the supported external surface.
Use only the endpoints and flows confirmed for your account. If you need broader API coverage, agree it with your Customer Success Manager before development starts.
## Recommended starting path [#recommended-starting-path]
1. Read [Authentication](/docs/developers/getting-started/authentication).
2. Record your [staging and production API hosts](/docs/developers/getting-started/base-urls-and-environments) before you write code.
3. Build the first use case against staging.
4. Prefer webhooks over aggressive polling when another system needs timely updates.
5. Use a [function](/docs/developers/functions/about-functions) when the code should run on the platform, not your server.
6. Keep unsupported or not-yet-published areas behind a feature flag on your side.
## Next steps [#next-steps]
* [Authentication](/docs/developers/getting-started/authentication)
* [Base URLs and environments](/docs/developers/getting-started/base-urls-and-environments)
* [Sync contacts to your systems](/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
# Authentication (/docs/developers/getting-started/authentication)
Your backend exchanges its client credentials for a token, then sends that token with every call.
## Before you begin [#before-you-begin]
* A `client_id`, `client_secret` and `audience` for each environment. They aren't self-service: request them from your Customer Success Manager, and confirm which endpoints are enabled for you.
* Your API host for each environment, listed in [Base URLs and environments](/docs/developers/getting-started/base-urls-and-environments).
* A server you control for the token request, never browser code or a mobile app bundle.
## Request a token [#request-a-token]
1. Store the production and staging credentials separately, in a secret manager.
2. Call `POST https:///v1/oauth:token` with a JSON body:
```bash title="Request a token with curl"
curl -X POST "https:///v1/oauth:token" \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": "YOUR_AUDIENCE",
"grant_type": "client_credentials"
}'
```
3. Read the token from the response:
```json title="Token response"
{
"access_token": "YOUR_ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600
}
```
`expires_in` is the token's lifetime in seconds. Asking again while it's valid can return the same token, with the same `expires_in`. Cache the token, and count its expiry from the first response.
4. Send the token in the `Authorization` header on every API request:
```http title="Bearer token on an API request"
Authorization: Bearer YOUR_ACCESS_TOKEN
```
## When a token request or call fails [#when-a-token-request-or-call-fails]
| Response | Cause | What to do |
| -------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `400` on the token request | `client_id` or `client_secret` is missing | Send both, then retry |
| `500` on the token request | The credentials were refused, or no token could be issued | Check the credentials and `audience` before you retry |
| `401` on an API call | The token is missing, expired or malformed, or belongs to another account's host | Request a new token, then retry once |
A token only works on the API host of the account it was issued for.
## Security rules that matter [#security-rules-that-matter]
* Never expose the `client_secret` in browser JavaScript, a kiosk page or a mobile app bundle.
* Keep the token exchange on your own backend, then call the API server to server.
* Store separate credentials per environment.
* Ask your Customer Success Manager to rotate credentials that are compromised or no longer used.
* Log request IDs and timestamps on your side, but never full secrets or full access tokens.
## Next steps [#next-steps]
* [Base URLs and environments](/docs/developers/getting-started/base-urls-and-environments)
* [Booking API](/docs/developers/api-reference/booking-api)
* [Rate limits](/docs/developers/getting-started/rate-limits)
# Base URLs and environments (/docs/developers/getting-started/base-urls-and-environments)
Staging and production each have their own API host and their own credentials.
## API hosts [#api-hosts]
| Environment | API host | Example |
| ------------- | -------------------------------------------- | ------------------------------------------- |
| Staging (UAT) | `https://.api.uat.omnilab.studio` | `https://lindenhall.api.uat.omnilab.studio` |
| Production | `https://.api.omnilab.studio` | `https://lindenhall.api.omnilab.studio` |
`` is your account's subdomain, which your Customer Success Manager confirms with your credentials. The host decides which account a call reaches, so a token only works on its own account's host. Every endpoint path starts with `/v1/`.
## Keep three URL concepts separate [#keep-three-url-concepts-separate]
| URL type | What it is for | Example |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| API host | Token requests and server-to-server API calls | `https://lindenhall.api.omnilab.studio` |
| Experience domain | Landing pages, touchpoints and embeds. It can be your own [custom domain](/docs/integrations/custom-domain/about-custom-domains). | `https://offers.lindenhall.example` |
| Host page or wrapper URL | Your own site, kiosk shell or app container | `https://www.lindenhall.example/promo` |
The same integration often uses all three. Keeping them apart makes debugging easier when a token request works but an embed doesn't, or when staging webhooks point at production.
## Safe environment rules [#safe-environment-rules]
* Never reuse production secrets in staging.
* Register a separate webhook endpoint in each environment.
* Test booking flows, cancellations and webhook retries in staging first.
* Keep test campaigns, public links and downstream automations apart from production.
## Next steps [#next-steps]
* [Authentication](/docs/developers/getting-started/authentication)
* [About website integration](/docs/integrations/website-integration/about-website-integration)
* [About custom domains](/docs/integrations/custom-domain/about-custom-domains)
# Getting Started (/docs/developers/getting-started)
Set up credentials, tokens and retries before your first API call.
# Rate limits (/docs/developers/getting-started/rate-limits)
The limits count requests per client IP address, so workers that share one outgoing address share them.
## The limits [#the-limits]
| Limit, per client IP address | Value |
| ---------------------------- | ------------------------------- |
| Requests per second | 200, with short bursts above it |
| Requests per minute | 2,000 |
| Open connections at once | 400 |
Above a limit, the API answers `429 Too Many Requests`.
## Back off on 429 and 5xx [#back-off-on-429-and-5xx]
1. On a `429` or a temporary `5xx`, wait before you retry.
2. Double the wait each time, with jitter: about 1, 2, 4, then 8 seconds.
3. Cap concurrent workers, so one spike doesn't turn into a retry storm.
4. Retry reads automatically, but fetch a booking again before retrying its cancellation.
5. Alert an operator when throttling lasts.
A `500` whose message describes your request, such as a missing `external_id`, fails again on every retry. Fix the request instead: see [Booking API errors](/docs/developers/api-reference/booking-api#error-responses).
## Design patterns that reduce throttling [#design-patterns-that-reduce-throttling]
| Use case | Better pattern |
| ---------------------------------- | --------------------------------------------------------------------------- |
| Every API call | Reuse the access token until it expires, instead of requesting one per call |
| Show upcoming bookings in a portal | Cache booking results for a short session and refresh on demand |
| Keep a CRM updated | Use [webhooks](/docs/developers/webhooks/about-webhooks) instead of polling |
| Backfill historical data | Run scheduled batches outside your peak hours, and page through results |
| Cancel a booking | Fetch once, then cancel only after the participant confirms |
## Next steps [#next-steps]
* [Authentication](/docs/developers/getting-started/authentication)
* [Booking API](/docs/developers/api-reference/booking-api)
* [About webhooks](/docs/developers/webhooks/about-webhooks)
# About webhooks (/docs/developers/webhooks/about-webhooks)
Each event, such as a prize won or a booking made, reaches you as one signed HTTPS request. Your receiver checks it, ignores repeats and answers fast.
## Callbacks and subscriptions [#callbacks-and-subscriptions]
| Object | What it does | What you set |
| ------------ | ---------------------------------------------- | ------------------------------------------------------------------------------- |
| Callback | Says where requests go and how they are shaped | HTTPS URL, authentication mode, signing secret, optional request transformation |
| Subscription | Says which events reach a callback | Event types, optional filters, **Active** or **Paused** |
A callback can have several subscriptions. When more than one of them matches an event, the callback still receives a single request.
## What each request contains [#what-each-request-contains]
* `POST` with `Content-Type: application/json`, unless the callback's transformation changes them.
* The event as a [CloudEvents](https://cloudevents.io) JSON document, unless a body template replaces it. The [event reference](/docs/developers/webhooks/webhook-event-reference#a-delivered-request) shows a full request.
* These headers:
| Header | What it holds | How to use it |
| ------------------- | -------------------------------------------------------------------------------- | ---------------------------------- |
| `webhook-id` | `msg_`, then the event ID and the callback ID. It stays the same on every retry. | Store it and ignore repeats |
| `webhook-timestamp` | Unix time in seconds when this attempt was signed. Each attempt gets a new one. | Reject old requests |
| `webhook-signature` | One or two `v1,` signatures, separated by a space | Verify before you process anything |
| `User-Agent` | `OmniLab-Webhook/1.0` | Logs and allow-lists |
## Delivery guarantees [#delivery-guarantees]
| Situation | What the platform does |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Your endpoint answers `2xx` | Records the delivery as successful. |
| `5xx`, `429`, a redirect (`3xx`), a network error, or no answer within 30 seconds | Retries, up to 5 times after the first attempt. Redirects are never followed. |
| Time between attempts | Waits about 0.5, 1, 2, 4 and 8 seconds: 15.5 seconds in all, plus each attempt's own time. |
| A `Retry-After` header on your answer | Ignores it. Retries keep the schedule above. |
| Any other `4xx` | Stops. The failure is permanent and the event is not retried. |
| `410 Gone` | Stops, and switches the callback to **Failing**. It receives nothing more. See [Monitor delivery health](/docs/developers/webhooks/configure-webhooks#monitor-delivery-health). |
| `401` from an endpoint that uses OAuth2 | Fetches a new token and retries once, straight away. A second `401` is permanent. |
| Your OAuth2 token endpoint returns no token, or a template fails to render | Stops. The failure is permanent. |
| The last retry fails | Drops the event for that callback. It is not sent again. |
| Several events in quick succession | Sends them in no guaranteed order. Use each event's `time` to order them. |
| An event larger than 256 KB | Does not deliver it. |
| Connecting to your endpoint | Uses HTTPS with TLS 1.2 or later, and only to hosts that resolve to public IP addresses. |
## Verify the signature [#verify-the-signature]
The signature is an HMAC-SHA256 of this exact string, keyed with your signing secret decoded from base64:
```text title="Signed payload format"
webhook-id + "." + webhook-timestamp + "." + raw_request_body
```
The result is base64-encoded and prefixed with `v1,`. After you rotate the signing secret, every delivery carries two signatures, separated by a space: one made with the new secret and one with the previous secret. That lasts until the next rotation. Accept the request when any one of them matches.
Two things a naive verifier gets wrong:
* **Comparing signatures as strings.** String equality, or array membership, leaks timing information an attacker can use to guess the signature byte by byte. Use a constant-time comparison.
* **Skipping the timestamp.** A signature alone doesn't stop someone from replaying a captured request later. Check that `webhook-timestamp` is recent.
```js title="verify.js"
import crypto from "node:crypto";
const MAX_TIMESTAMP_SKEW_SECONDS = 300; // 5 minutes
export function isValidOmniLabSignature({
header,
webhookId,
webhookTimestamp,
rawBody,
signingSecret,
}) {
// Reject a stale or forward-dated timestamp so a captured-and-replayed
// callback can't be re-submitted later and still pass verification.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(webhookTimestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > MAX_TIMESTAMP_SKEW_SECONDS) {
return false;
}
const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", Buffer.from(signingSecret, "base64"))
.update(signedPayload)
.digest();
// The header holds two "v1," pairs after a secret rotation,
// so check each one instead of comparing the whole header at once.
return header.split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
let candidate;
try {
candidate = Buffer.from(signature, "base64");
} catch {
return false;
}
// timingSafeEqual throws if the two buffers aren't the same length, so
// rule that out before doing the constant-time comparison itself.
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}
```
[Receive your first event](/docs/developers/webhooks/configure-webhooks#receive-your-first-event) runs this verifier in a complete receiver.
## Next steps [#next-steps]
* [Configure webhooks](/docs/developers/webhooks/configure-webhooks)
* [Event reference](/docs/developers/webhooks/webhook-event-reference)
* [Sync contacts to your systems](/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
# Configure webhooks (/docs/developers/webhooks/configure-webhooks)
Here, Lindenhall sends prize wins to its CRM as they happen, so nobody has to poll for them. You do the whole setup yourself in **Enterprise Settings** > **Webhooks**.
## Before you begin [#before-you-begin]
* An admin account in Studio. The **Webhooks** tab only appears when **Global** is chosen in the organisation switcher.
* A public HTTPS endpoint. Plain `http`, and hosts that resolve to a private address, are refused.
* Somewhere safe to store the signing secret, which is shown once.
* Separate endpoints for staging and production, so test traffic never reaches live systems.
## Create the callback [#create-the-callback]
In the organisation switcher, choose **Global**, and select **General Settings**. Then:
1. Select the **Webhooks** tab.
2. Select **New webhook**.
3. Enter a **Name**, here `Lindenhall CRM`.
One callback per downstream system keeps failures isolated.
4. Enter the **Endpoint URL**, here `https://crm.lindenhall.example/omnilab/events`.
5. Keep **HTTP method** on `POST`, unless the receiving system needs another.
6. Select **Create**.
7. Copy the signing secret into your secret storage before you close the window.
If you closed the window without copying it, [rotate the signing secret](#rotate-the-signing-secret) to get a new one.
## Subscribe the callback to events [#subscribe-the-callback-to-events]
A new callback receives nothing until it has at least one subscription. On the **Webhooks** tab:
1. On the callback's row, select **Subscriptions**.
2. Select **Add subscription**.
3. Enter a **Name** for this slice of traffic, here `CRM sync — reward wins only`.
4. In **Event types**, select the events you want, here **Won** and **Redeemed**.
The picker groups events by family, such as **Touchpoint**, **Reward** and **Booking & Ticket**, and you can search it. Leave it empty only if you want every event type: an empty list subscribes to all of them.
5. (Optional) Enter a type the picker doesn't list in **Custom event type**, here `submission.created.v1`.
6. (Optional) Select **Add custom**.
The type joins **Event types** with a dashed outline. Copy names from the [event reference](/docs/developers/webhooks/webhook-event-reference).
7. (Optional) To narrow delivery to one organisation, select **Add filter**.
8. (Optional) In the new filter row, replace the key `tenant_id` with `group_id`.
9. (Optional) Enter the organisation's ID as the value, here `groups/6946c01f-8542-471c-9e2f-dac2661027c6`.
Filters combine with AND and match exactly. [How filters match](/docs/developers/webhooks/webhook-event-reference#how-filters-match) lists what each key matches today.
10. Select **Create**.
The subscription appears in the list marked **Active**, with its event types and filters.
Use **Pause** to stop delivery while keeping the configuration, and **Resume** to switch it back on.
## Receive your first event [#receive-your-first-event]
Studio has no test send and no delivery log, so your receiver's own log is where deliveries show up. This Node.js receiver checks each request, skips repeats and answers straight away.
It needs Node.js 18 or later, and the verifier from [Verify the signature](/docs/developers/webhooks/about-webhooks#verify-the-signature) saved as `verify.js` in the same folder. Set up the project:
```bash title="Terminal"
npm init -y
npm pkg set type=module
npm install express
```
```js title="server.js"
import express from "express";
import { isValidOmniLabSignature } from "./verify.js";
const signingSecret = process.env.OMNILAB_SIGNING_SECRET;
if (!signingSecret) {
throw new Error("Set OMNILAB_SIGNING_SECRET to the callback's signing secret");
}
// Use your own database in production: this set empties on every restart.
const handledIds = new Set();
const app = express();
// express.raw keeps the exact bytes the signature covers. Events reach 256 KB.
app.post(
"/omnilab/events",
express.raw({ type: "*/*", limit: "1mb" }),
(req, res) => {
const webhookId = req.get("webhook-id");
const webhookTimestamp = req.get("webhook-timestamp");
const header = req.get("webhook-signature");
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
if (!webhookId || !webhookTimestamp || !header) {
return res.status(400).send("Missing webhook headers");
}
const valid = isValidOmniLabSignature({
header,
webhookId,
webhookTimestamp,
rawBody,
signingSecret,
});
if (!valid) {
return res.status(400).send("Invalid signature");
}
// A retry carries the same webhook-id: acknowledge it, don't handle it twice.
if (handledIds.has(webhookId)) {
return res.sendStatus(204);
}
let event;
try {
event = JSON.parse(rawBody);
} catch {
return res.status(400).send("Body is not JSON");
}
handledIds.add(webhookId);
// Answer before doing any work: the platform waits 30 seconds at most.
res.sendStatus(204);
// Hand the event to your own queue here. This example only logs it.
console.log(`Received ${event.type} (${event.id})`, event.data);
},
);
app.listen(3000, () => {
console.log("Listening on http://localhost:3000/omnilab/events");
});
```
Start it with the signing secret you copied when you created the callback:
```bash title="Terminal"
OMNILAB_SIGNING_SECRET='' node server.js
```
The callback's **Endpoint URL** must reach this server over public HTTPS. During development, put a tunnelling service in front of port 3000. Then set its address, ending in `/omnilab/events`, as the **Endpoint URL** on the **General** tab.
To send a first event, play a published campaign on your staging account and win its prize. The win sends `reward.won.v1`, and the receiver logs its type and ID.
A `4xx` other than `429` is never retried, so a receiver that answers `400` loses that event. Return a `5xx` when you want a delivery back. Every case is in [Delivery guarantees](/docs/developers/webhooks/about-webhooks#delivery-guarantees).
## Authenticate to your endpoint [#authenticate-to-your-endpoint]
If your receiver needs credentials of its own, give the callback an authentication mode. Each mode asks for these fields:
| Mode | Good fit | What you enter |
| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **None** | The endpoint relies on signature verification alone | Nothing |
| **Basic** | Endpoints that expect a username and password | **Username**, then **Password** under **Secret credentials** |
| **Bearer token** | Services that accept a static API token | Optional **Custom header name**, then **Bearer token** under **Secret credentials**. The header value is always `Bearer `. |
| **OAuth2 (client credentials)** | Platforms that issue short-lived tokens | **Client ID**, **Token endpoint**, **Scopes**, **Client auth method**, optional **Audience**, then **Client secret** under **Secret credentials** |
For OAuth2, **Client auth method** is required and takes one of two values. `client_secret_basic` sends the client ID and secret in an `Authorization` header, and `client_secret_post` sends them in the token request body. Any other value is refused when you save.
Save the mode before its secret: a secret is only accepted for the mode already saved. On the **Webhooks** tab:
1. On the callback's row, select **Edit**, here on **Analytics export**.
2. Select the **Authentication** tab.
3. In **Mode**, choose the mode your endpoint expects, here **OAuth2 (client credentials)**.
4. Enter the **Client ID**, here `lindenhall-analytics`.
5. Enter the **Token endpoint**, here `https://idp.lindenhall.example/oauth2/token`.
6. Enter the **Scopes**, here `analytics.write`.
7. Enter the **Client auth method**, here `client_secret_post`.
8. Select **Save authentication**.
9. Under **Secret credentials**, enter the password, token or client secret.
10. Select **Save secrets**.
Secrets are never shown again. Leaving a secret field blank later keeps the stored value.
## Rotate the signing secret [#rotate-the-signing-secret]
After a rotation, every delivery is signed with the new secret and with the previous one, until the next rotation. A verifier still holding the old secret keeps passing, so you can switch without missing events.
After one rotation, the leaked secret still signs every delivery as the previous secret. Rotate twice to stop it signing anything, then give your verifier the newest secret.
On the **Webhooks** tab:
1. On the callback's row, select **Edit**.
2. Select the **Secrets** tab.
3. Select **Rotate signing secret**.
4. Confirm the rotation in the browser prompt.
The prompt says verifiers using the old secret start failing straight away. They keep passing until the next rotation.
5. Copy the secret from the **New signing secret** window into your secret storage.
6. Update your verifier with the new secret.
The **Secrets** tab also shows a **Secret reference**. It identifies the secret in support conversations, and can't sign or verify anything.
## Reshape the request (optional) [#reshape-the-request-optional]
Most receivers take the event as sent. When yours needs a different shape, use the **Transformation** tab:
* **HTTP method** and **Content type** for the outgoing request.
* **URL template (optional)**: overrides the endpoint on the **General** tab. Useful for routing event types to different paths.
* **Body template (optional)**: leave it blank to send the raw event JSON.
* **Custom headers**: sent on every delivery.
Templates reference event fields with `{{event.}}`, and `event.payload` holds the event's `data`:
```json title="Body template"
{
"eventId": "{{event.id}}",
"eventType": "{{event.type}}",
"rewardId": "{{event.payload.reward_id}}"
}
```
A custom header named `Authorization`, `Proxy-Authorization`, `webhook-id`, `webhook-timestamp` or `webhook-signature`, in any case, is refused when you select **Save transformation**. Set credentials on the **Authentication** tab instead. Leave out `User-Agent` too: the platform sets its own. The signature covers the body your template produces.
## Monitor delivery health [#monitor-delivery-health]
Each callback card carries a status badge:
* **Verified**: the normal state. A callback is created **Verified** and stays so while it accepts deliveries. It doesn't mean traffic has been tested.
* **Failing**: your endpoint answered `410 Gone`. Expand the card, or open the **General** tab, to read the reason recorded.
`410 Gone` tells the platform the endpoint is retired. The callback stops receiving events, and nothing in Studio switches it back on. Return `410` only when you mean it. If it happens by accident, create a replacement callback and add its subscriptions again.
## If something's blocked [#if-somethings-blocked]
* **The callback receives nothing**: check that it has a subscription, that the subscription is **Active**, and that each event type is one the [event reference](/docs/developers/webhooks/webhook-event-reference) lists as delivered. A misspelt custom event saves without complaint and never matches. An `interaction_id` filter matches no event today.
* **A subscription won't save**: every filter needs a key and a value, and the key must be `tenant_id`, `group_id` or `interaction_id`. The key field also suggests `contact_id`, which is refused.
* **Signature checks started failing**: the secret was rotated twice since your verifier last changed, or the body was parsed before verification. Use the newest secret and the raw body.
* **Deliveries stopped after an incident**: events that failed for good, or ran out of retries, are not sent again. If the endpoint answered `410 Gone`, the callback is now **Failing** and needs replacing.
* **The URL is refused when you save**: the endpoint must start with `https://` and resolve to a public address.
## Next steps [#next-steps]
* [About webhooks](/docs/developers/webhooks/about-webhooks)
* [Event reference](/docs/developers/webhooks/webhook-event-reference)
* [Sync contacts to your systems](/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
# Webhooks (/docs/developers/webhooks)
Receive OmniLab events on your own HTTPS endpoint, set up yourself in Studio.
# Event reference (/docs/developers/webhooks/webhook-event-reference)
Webhook subscriptions and platform event functions choose from the same event types. Copy names exactly as written here: a misspelt custom event saves without complaint and never fires.
## How to read the event tables [#how-to-read-the-event-tables]
Each family table has a **Webhooks** and a **Functions** column. Each cell says what that subscription picker offers and whether the event arrives:
| In the column | What it means |
| ----------------------------- | ---------------------------------------------------------------------- |
| A bold label, such as **Won** | The picker lists it under that label, and it is sent |
| Custom event | It is sent, but the picker doesn't list it: add it with **Add custom** |
| Never sent | The picker lists it, but the platform never sends it. Leave it out |
| Refused | A subscription that names it won't save |
| Never fires | It saves as a custom event, but never reaches a function |
Functions add their own trigger rules: see [How platform events are delivered](/docs/developers/functions/function-sdk-reference#how-platform-events-are-delivered).
## A delivered request [#a-delivered-request]
Here, Lindenhall's CRM receives a `reward.won.v1` event. The headers are explained in [About webhooks](/docs/developers/webhooks/about-webhooks#what-each-request-contains).
```http title="Request line and headers"
POST /omnilab/events HTTP/1.1
Host: crm.lindenhall.example
Content-Type: application/json
User-Agent: OmniLab-Webhook/1.0
webhook-id: msg_6abe2823c1e5b2a9d4f81c3e:callbacks/3bb6d5ca-a587-4a81-84e0-7e200394b9c5
webhook-timestamp: 1790847012
webhook-signature: v1,LXEWQrcmsEQBYnyp+6wy9chTD7GQPMTbAiWHF5IaSIE=
```
The body is a CloudEvents document: the event's identity at the top, its details in `data`. It is formatted here for reading. The real body is one line of compact JSON, and the signature covers those exact bytes.
```json title="Body"
{
"specversion": "1.0",
"id": "6abe2823c1e5b2a9d4f81c3e",
"type": "reward.won.v1",
"subject": "d96c390f-5753-441c-baa3-0689a8c83716",
"time": "2026-10-01T09:30:11.482Z",
"datacontenttype": "application/json",
"tenantid": "tenants/1e3f8137-9ea4-4d99-a7be-023d88c4f8c1",
"data": {
"contact": {
"contact_id": "contacts/922eb227-f65a-4998-b116-a53edaf0d325",
"unique_visitor_id": "e1bcce15-7972-4afc-bd6d-871ca666b7d0",
"external_contact_id": "LH-0042817"
},
"tenant_context": {
"subdomain": "lindenhall"
},
"group_context": {
"group_id": "groups/6946c01f-8542-471c-9e2f-dac2661027c6",
"unique_key": "lindenhall-riverside"
},
"interaction": {
"interaction_id": "interactions/6cc002c6-2be1-412c-ac0d-c5d16a8c7cc6",
"interaction_display_name": "Autumn Prize Draw",
"interaction_public_key": "autumn-prize-draw"
},
"web_context": {
"utm": { "source": "instagram", "medium": "social" },
"user_agent": {
"os": "iOS",
"device": "iPhone",
"is_mobile": true,
"is_desktop": false,
"is_tablet": false,
"is_bot": false
}
},
"reward_id": "d96c390f-5753-441c-baa3-0689a8c83716",
"internal_reward_id": "rewards/42101441-81cd-45c9-bca0-993039133359",
"reward_display_name": "£10 food court voucher",
"reward_winning_method": "FIRST_COME_FIRST_SERVE",
"coupon_code_id": "coupons/3bb6d5ca-a587-4a81-84e0-7e200394b9c5",
"coupon_code_value": "LH10-7QK2"
}
}
```
A few top-level attributes that only identify the sender are left out above. Empty fields are often omitted. Read every field as optional, and ignore the ones you don't use.
A function receives the same `type`, `id`, `time`, `source` and `data`, without the headers: see [PlatformEvent](/docs/developers/functions/function-sdk-reference#platformevent).
## Fields most events share [#fields-most-events-share]
Besides its own fields, `data` carries context blocks:
| Block | Fields | Carried by |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact` | `contact_id`, `unique_visitor_id`, `external_contact_id`, `custom_fields` | Every event. `contact_id` is empty for an anonymous visitor. Booking, ticket, check-in and submission events fill `contact_id` only. Smart Link events fill `unique_visitor_id` only. |
| `tenant_context` | `subdomain` | Every event |
| `group_context` | `group_id`, and `unique_key` on campaign events | Every event |
| `interaction` | `interaction_id`, `interaction_display_name`, `interaction_public_key`, `interaction_snapshot_id`, `session_id`, `visit_id` | Campaign events: the touchpoint, reward, contact and other families |
| `web_context` | `utm` (`campaign`, `source`, `medium`, `term`, `content`), `user_agent` (`os`, `device`, `raw`, `is_mobile`, `is_desktop`, `is_tablet`, `is_bot`), `referrer`, `query_params` | Campaign events. Smart Link events carry `utm`, `user_agent`, `referrer`, `url` and an `ip_address` whose last part is zeroed. |
No block carries the contact's email or name. A function reads them with `OmniLab.Contact.get(data.contact.contact_id)`.
## How filters match [#how-filters-match]
A webhook subscription filter compares one value in the event with the value you enter, exactly.
| Filter key | Matches when | Value format |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `group_id` | `data.group_context.group_id` equals the value. A parent organisation's ID doesn't match its children's events. | `groups/`, as in a delivery you've received |
| `tenant_id` | The top-level `tenantid` equals the value. Every event of your account carries the same one, so this filter narrows nothing. | `tenantid` from a delivery you've received |
| `interaction_id` | No event matches it today. Campaign events nest the campaign in `data.interaction`, where the filter doesn't look. Booking events don't carry it. | Leave this filter out |
To receive one campaign only, filter in your receiver on `data.interaction.interaction_public_key`. Function subscriptions have their own filter keys: see [Narrow a subscription with filters](/docs/developers/functions/platform-event-functions#narrow-a-subscription-with-filters).
## Touchpoint events [#touchpoint-events]
| Event type | When it fires | Webhooks | Functions |
| ----------------------------------------- | ---------------------------------------------- | ----------------------------- | ----------------------------- |
| `touchpoint.page_visit.v1` | A touchpoint page is viewed | **Page visit** | Refused |
| `touchpoint.started.v1` | A touchpoint session starts | **Started** | **Started** |
| `touchpoint.participated.v1` | A participant enters the experience | **Participated** | **Participated** |
| `touchpoint.participation_form_filled.v1` | A participation form is submitted | **Participation form filled** | **Participation form filled** |
| `touchpoint.completed.v1` | A touchpoint session completes | **Completed** | **Completed** |
| `touchpoint.terms_accepted.v1` | Terms or consent are accepted | **Terms accepted** | **Terms accepted** |
| `cta.clicked.v1` | A configured call-to-action button is selected | **CTA clicked** | **CTA clicked** |
| Event type | Adds to `data` |
| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `touchpoint.page_visit.v1`, `touchpoint.started.v1`, `touchpoint.participated.v1`, `touchpoint.completed.v1` | `touchpoint_id`, `touchpoint_title`, `touchpoint_type`, `interactive_totem_type`, `instant_game_type`, `event_id` |
| `touchpoint.participation_form_filled.v1` | The fields above, plus `form_level`, `form_frequency`, `form_schema_id`, and `submitted_data`: one `field_id`, `field_label`, `field_type` and `value` per answer |
| `touchpoint.terms_accepted.v1` | `term_and_condition_text`, `terms_and_conditions_accepted_at`, `terms_and_conditions_uri`, `opt_in_level`, `collectible_id`, `collectible_title` |
| `cta.clicked.v1` | `cta_label`, `cta_url` |
## Reward events [#reward-events]
| Event type | When it fires | Webhooks | Functions |
| ----------------------------- | ---------------------------------------------------------- | --------------------- | --------------------- |
| `reward.eligible.v1` | A participant becomes eligible for a reward | **Eligible** | **Eligible** |
| `reward.won.v1` | A participant wins a reward | **Won** | **Won** |
| `reward.lost.v1` | A participant doesn't win a reward | **Lost** | **Lost** |
| `reward.redeemed.v1` | A won reward is redeemed | **Redeemed** | **Redeemed** |
| `reward.temporary_won.v1` | A temporary win is recorded | **Temporary won** | **Temporary won** |
| `reward.temporary_lost.v1` | A temporary loss is recorded | **Temporary lost** | **Temporary lost** |
| `reward.temporary_blocked.v1` | A temporary reward state is blocked | **Temporary blocked** | **Temporary blocked** |
| `reward.expired.v1` | Never: the picker label **Expired** has no event behind it | Never sent | Never sent |
| Event type | Adds to `data` |
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
| Every reward event | `reward_id`, `internal_reward_id`, `reward_display_name`, `reward_winning_method` |
| `reward.won.v1`, `reward.temporary_won.v1` | The reward fields, plus `coupon_code_id`, `coupon_code_value` and `reward_time_id` |
| `reward.redeemed.v1` | The reward fields, plus `coupon_code_id` and `coupon_code_value` |
## Contact and other events [#contact-and-other-events]
Both pickers list these under **Contact** and **Other**.
| Event type | When it fires | Webhooks | Functions |
| -------------------------- | ------------------------------------------------- | ---------------------- | ---------------------- |
| `contact.created.v1` | A new contact is created | **Created** | **Created** |
| `contact.identified.v1` | An anonymous visitor is linked to a known contact | **Identified** | **Identified** |
| `contact.authenticated.v1` | A contact signs in | **Authenticated** | **Authenticated** |
| `notification.sent.v1` | A notification is sent to a participant | **Notification sent** | **Notification sent** |
| `feedback.submitted.v1` | Feedback or a satisfaction rating is submitted | **Feedback submitted** | **Feedback submitted** |
| `question.answered.v1` | A question or quiz answer is submitted | **Question answered** | **Question answered** |
| Event type | Adds to `data` |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `contact.created.v1` | `contact_id`, `touchpoint_id`, `touchpoint_title`, `touchpoint_type`, `event_id`, `game_id` |
| `contact.identified.v1`, `contact.authenticated.v1` | The `contact.created.v1` fields, plus `identification_type` (`SIGN_UP` or `LOGIN`) and `identification_timestamp` |
| `notification.sent.v1` | `notification_event_type`, `notification_by_type`, `touchpoint_id`, `touchpoint_title` |
| `feedback.submitted.v1` | `rating`, `comment` |
| `question.answered.v1` | `question`, `question_answer` |
## Booking, ticket and attendance events [#booking-ticket-and-attendance-events]
The webhook picker lists these under **Booking & Ticket** and **Participation**.
| Event type | When it fires | Webhooks | Functions |
| ----------------------------- | ------------------------------------------ | --------------------- | ------------ |
| `booking.created.v1` | A booking is created | **Booking created** | Custom event |
| `booking.deleted.v1` | A booking is deleted | **Booking deleted** | Custom event |
| `ticket.confirmed.v1` | A ticket is confirmed | **Ticket confirmed** | Custom event |
| `ticket.canceled.v1` | A confirmed ticket is cancelled | **Ticket canceled** | Custom event |
| `ticket.waitlisted.v1` | A participant joins the waitlist | **Ticket waitlisted** | Custom event |
| `ticket.deleted.v1` | A ticket is deleted | **Ticket deleted** | Custom event |
| `participation.checked_in.v1` | A participant is checked in to an activity | **Checked in** | Custom event |
| `participation.deleted.v1` | A participation record is deleted | Custom event | Custom event |
In these events, `event_id` and `event_name` name the bookable activity, not the platform event.
| Event type | Adds to `data` |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `booking.created.v1` | `booking_id`, `event_id`, `event_name`, `tickets_number`, `reserver_id`, `host_id`, `ticket_type_id`, `ticket_type_display_name`, `slot_start_date`, `slot_end_date`, `booked_at` |
| `ticket.confirmed.v1`, `ticket.canceled.v1`, `ticket.waitlisted.v1` | `ticket_id`, `booking_id`, `event_id`, `event_name`, `host_id`, `ticket_type_id`, `ticket_type_display_name`, `slot_start_date`, `slot_end_date`, then `ticket_confirmed_at`, `ticket_canceled_at` or `ticket_waitlisted_at` |
| `participation.checked_in.v1` | `participation_id`, `ticket_id`, `event_id`, `event_name`, `ticket_type_id`, `ticket_type_display_name`, `check_in_method` (`SELF` or `SCAN`), `slot_start_date`, `slot_end_date`, `check_in_time` |
| `booking.deleted.v1` | `booking_id`, `event_id`, `reserver_id`, `deleted_at`, `deletion_user` |
| `ticket.deleted.v1` | `ticket_id`, `booking_id`, `event_id`, `contact_id`, `deleted_at`, `deletion_user` |
| `participation.deleted.v1` | `participation_id`, `ticket_id`, `event_id`, `contact_id`, `deleted_at`, `deletion_user` |
```json title="booking.created.v1 data fields"
{
"booking_id": "2394d604-afcc-4f7f-98c1-ef3676c20d6b",
"event_id": "8f1c2a47-3b9e-4d6a-9c05-71e2b4d8a913",
"event_name": "VIP styling session",
"tickets_number": 2,
"reserver_id": "contacts/922eb227-f65a-4998-b116-a53edaf0d325",
"host_id": "host_K7m2Qx9",
"ticket_type_id": "ticket_5d0e9b3c-6f1a-4c2e-8a7d-2b9f4e6c1a08",
"ticket_type_display_name": "VIP",
"slot_start_date": "2026-05-03T14:00:00Z",
"slot_end_date": "2026-05-03T14:30:00Z",
"booked_at": "2026-04-26T09:15:00Z"
}
```
## Receipt submission events [#receipt-submission-events]
| Event type | When it fires | Webhooks | Functions |
| ------------------------------ | ------------------------------------------------ | ------------ | ------------ |
| `submission.created.v1` | A receipt submission is created | Custom event | Custom event |
| `submission.status_changed.v1` | A submission changes validation or review status | Custom event | Custom event |
| `submission.amended.v1` | A submission is amended | Custom event | Custom event |
| `submission.replaced.v1` | One submission replaces another | Custom event | Custom event |
| Event type | Adds to `data` |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every submission event | `submission_id`, `session_id`, `game_id`, `player_id`, `game_name` |
| `submission.created.v1` | `validation_mode`, `merchant_name`, `amount`, `currency`, `receipt_date`, `duplicate_reference_id`, `is_resubmission`, `original_submission_id`, `submitted_at` |
| `submission.status_changed.v1` | `previous_status`, `new_status`, `trigger_source` (`ocr`, `policy_auto` or `operator_action`), `reason`, `changed_at` |
| `submission.amended.v1` | `amended_fields`, `merchant_name`, `amount`, `currency`, `amended_at` |
| `submission.replaced.v1` | `new_submission_id`, `replaced_at` |
## Smart Link events [#smart-link-events]
| Event type | When it fires | Webhooks | Functions |
| ------------------------- | -------------------------------- | ------------ | ----------- |
| `smartlink.redirected.v1` | A Smart Link redirects a visitor | Custom event | Never fires |
It adds `smart_link_id`, `slug`, `title`, `destination_url`, `redirection_status`, `variant_id`, `variant_name` and `variant_variables` to `data`. It identifies the visitor, never a contact, so it can't trigger a function.
## Event group types [#event-group-types]
The webhook picker's **Event** group lists six types the platform never sends. A subscription to them saves, but nothing arrives, so leave them out:
* `event.created.v1`, `event.deleted.v1` and `event.type.updated.v1`
* `event.availability.updated.v1`, `event.slot.reached_capacity.v1` and `event.fully_booked.v1`
## Choosing events for analytics [#choosing-events-for-analytics]
Feeding a warehouse or a BI tool? Teams most often subscribe a webhook to these:
* `touchpoint.page_visit.v1`
* `touchpoint.participated.v1`
* `touchpoint.completed.v1`
* `reward.won.v1`
* `reward.redeemed.v1`
* `smartlink.redirected.v1`
Subscribe only to what the destination reads. A filtered subscription costs nothing, while an unwanted delivery still costs you a request.
## Next steps [#next-steps]
* [Configure webhooks](/docs/developers/webhooks/configure-webhooks)
* [Platform event functions](/docs/developers/functions/platform-event-functions)
* [Function SDK reference](/docs/developers/functions/function-sdk-reference)
# Add badges and rewards to an event (/docs/events/build/add-badges-and-rewards-to-an-event)
A **badge** proves someone took part. A **reward** gives them something of value, such as a voucher, a gift or a prize-draw entry.
## Choose the badge [#choose-the-badge]
Every Activity needs a badge before the campaign can publish.
1. In the Activity editor, select the **Gamification** tab.
2. Under **Badge Selection**, choose the **Badge Asset**, or select **Replace attachment** to change it.
3. Select **Save**.
A series of events can share one badge style, so regular visitors see their progress build up. See [Gamification badges](/docs/platform/touchpoints/gamification-badges).
## Add a reward [#add-a-reward]
An Activity has no reward field of its own. Rewards come from the campaign's **Rewards** tab:
* Create a **Lottery**, **Instant Win** or **First Come, First Serve** reward.
* In its **Reward Trigger**, use **Pick Touchpoints** to choose this Activity.
* Attendees qualify when they book or check in, depending on the trigger.
See [How rewards connect to touchpoints](/docs/platform/rewards/how-rewards-connect-to-touchpoints).
## If publishing is blocked [#if-publishing-is-blocked]
* **Badge missing**: choose a **Badge Asset** in the **Gamification** tab.
## Next steps [#next-steps]
* [Lottery rewards](/docs/platform/rewards/draw-at-end)
* [Instant Win rewards](/docs/platform/rewards/real-time-draw)
* [About rewards](/docs/platform/rewards/about-rewards-and-winning-methods)
# Configure booking notifications (/docs/events/build/configure-booking-notifications)
Booking emails apply to Activities set to **Booking and Check-In**. They go out through your email platform, so its add-on must be active first (see [If publishing is blocked](#if-publishing-is-blocked)).
| Email | Sent when | Needed to publish |
| --------------------------------- | ---------------------- | -------------------------- |
| **Booking Confirmation** | Someone books | Yes |
| **Booking Cancellation** | Someone cancels | Only if the reminder is on |
| **Booking Confirmation Reminder** | Before the booked slot | No |
## Set up the confirmation email [#set-up-the-confirmation-email]
1. In **Build** > **Notifications**, select the edit button on **Booking Confirmation**.
2. Keep the **Global** tab selected, so the email goes out for every Activity.
3. Turn on **Active**.
4. Enter the **Email Subject Line** and the **Email Preheader**.
5. Under **Email Banner**, upload the banner image. The confirmation can't publish without one.
6. In **Email Content**, write the message. Say what to bring and where to go.
7. Enter the **Email CTA Label** and the **Email CTA Link**, such as your campaign page.
8. Select **Save**.
Repeat these steps for **Booking Cancellation** and **Booking Confirmation Reminder**.
## Send one Activity its own email [#send-one-activity-its-own-email]
Use this when one event needs its own venue details or arrival instructions. Open the notification as in step 1, then:
1. Select the **by Touchpoint** tab.
2. Turn on the switch next to the Activity.
3. Select the Activity's name to open its email.
Fill in its fields as in steps 4 to 7, then select **Save**.
## If publishing is blocked [#if-publishing-is-blocked]
* **No booking confirmation**: turn on **Booking Confirmation**, globally or for this Activity.
* **Email banner missing**: upload an **Email Banner** in that notification.
* **Reminder on, cancellation off**: turn on **Booking Cancellation** too.
* **No email marketing add-on active**: turn on the Cheetah or Salesforce Marketing Cloud add-on in **Build** > **Settings** > **Advanced Settings** > **Addons**.
Studio also suggests adding the cancellation and reminder emails when they're off.
## Next steps [#next-steps]
* [Notification types reference](/docs/platform/notifications/notification-types-reference)
* [Configure notifications by touchpoint](/docs/platform/notifications/configure-notifications-by-touchpoint)
* [Test your event](/docs/events/launch/test-your-event)
# Configure booking restrictions (/docs/events/build/configure-booking-restrictions)
| To | Use | Example |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Stop one person booking too often | **Booking Restrictions** in the **Slots** tab | One pottery session per child per week |
| Cap the seats in one slot | [Ticket capacity](/docs/events/build/configure-tickets) | 12 places per session |
| Let only invited people book | **Validation Pools** in the **Restrictions** tab ([create the codes](/docs/platform/touchpoints/restrictions)) | A workshop for loyalty members |
## Limit bookings per person [#limit-bookings-per-person]
1. In the Activity's **Slots** tab, scroll to **Booking Restrictions**.
On a **Check-In Only** Activity, the same switches limit check-ins, such as **Limit check-ins per participant**.
2. Turn on **Limit bookings per participant**.
3. Enter the **Maximum bookings per participant**.
## Limit bookings per day or week [#limit-bookings-per-day-or-week]
Skip to step 8 if one overall limit is enough.
4. Turn on **Limit daily bookings per participant**.
5. Enter the **Daily booking limit**.
6. Turn on **Limit weekly bookings per participant**.
7. Enter the **Weekly booking limit**.
## Require a validation code [#require-a-validation-code]
Skip to step 13 if anyone can book.
8. Select the **Restrictions** tab.
9. Turn on **Validation Pools**.
10. Select **Choose existing pool**.
No pool yet? Select **Create a new pool**, give it a **Pool Name**, the **Number of codes to generate** and an **Expiry Date**, then **Generate Pool**. See [Restrictions](/docs/platform/touchpoints/restrictions).
11. Tick the pool.
12. Select **Continue**.
Bookers then need a valid code from that pool.
## Save [#save]
13. Select **Save**.
## How the limits work [#how-the-limits-work]
* Studio checks the limit when someone books. Over the limit, the booking is refused and they're told they've booked the maximum.
* Only confirmed bookings count. A cancellation gives the booking back, and a waitlisted booking counts once it's confirmed.
* Daily and weekly limits count bookings in that day or week. **Maximum bookings per participant** counts them across the whole Activity.
## If publishing is blocked [#if-publishing-is-blocked]
* **Booking limit is 0 or invalid**: enter a number above 0, or turn off **Limit bookings per participant**.
* **Validation Pools is on, but no pool is chosen**: choose a pool, or turn off **Validation Pools**.
## Next steps [#next-steps]
* [Configure tickets](/docs/events/build/configure-tickets)
* [Configure check-in](/docs/events/build/configure-check-in)
* [Event types](/docs/events/build/event-types)
# Configure check-in (/docs/events/build/configure-check-in)
Check-in settings sit in the Activity's **Slots** tab. Open the Activity from **Build** > **Touchpoints** > **Configure** first.
## Choose how attendees check in [#choose-how-attendees-check-in]
1. Scroll to the **Check-in** section.
2. In **Check-in Method**, choose **SELF** or **SCAN**.
* **SELF**: attendees check in on their own phone. They open the event page, sign in, then select **Start Check In** during the event's hours.
* **SCAN**: staff scan each attendee's ticket QR code with the [Event Host App](/docs/events/launch/set-up-the-event-host-app).
## Set when check-in opens and closes [#set-when-check-in-opens-and-closes]
3. Select **Advanced Check-in** to open the timing settings.
4. In **Early Check-in Window (minutes)**, choose how early attendees can check in before the start.
5. In **Check-in End Date Calculation Method**, choose when check-in closes.
* **Slot end date**: check-in closes when the slot ends.
* **personalized**: check-in closes a set time after the start.
6. If you chose **personalized**, set the **Late Check-in Window (minutes)**.
## Ask questions on arrival [#ask-questions-on-arrival]
7. (Optional) Turn on **Check-in Form**.
8. Choose the form, or select **Create new template**.
This form appears at check-in only. It's separate from the [booking form](/docs/events/build/configure-tickets) and the [participation form](/docs/events/build/set-the-participation-form-for-an-event).
## Write the confirmation messages [#write-the-confirmation-messages]
9. (Optional) Under **Messages**, write the **Booking Confirmation Message** and the **Check-in Confirmation Message**.
People see the first on the event page after they book, and the second once they're checked in. Use them for practical details, such as where to go.
10. Select **Save**.
## Which method to choose [#which-method-to-choose]
| Method | Best for | Plan for |
| -------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| **SELF** | Drop-in events with no hosts at the door | Clear signs, and a visible QR code that opens the event page |
| **SCAN** | Workshops, kids' activities, VIP sessions: anywhere staff confirm who arrives | Tablets or phones with the Event Host App, and a rehearsal before doors open |
A workshop might open check-in 15 minutes early and close at the slot end. A kids' activity might also accept latecomers for 15 minutes with **personalized**.
## If publishing is blocked [#if-publishing-is-blocked]
* **Negative early or late window**: set it to 0 or more.
* **Check-in form on, but no template chosen**: choose a form template, or turn off **Check-in Form**.
* **personalized end, but no late window**: set a **Late Check-in Window (minutes)** above 0.
Studio also warns when the early window is 60 minutes or more. Check the value is intended.
## Next steps [#next-steps]
* [Test your event](/docs/events/launch/test-your-event)
* [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app)
* [Check-in statuses](/docs/events/operate/check-in-statuses)
# Configure dates and slots (/docs/events/build/configure-slots-weekly-vs-specific-dates)
Dates sit in the Activity's **Slots** tab, above tickets and check-in. Set them first, since both depend on them.
## Choose weekly or specific dates [#choose-weekly-or-specific-dates]
1. In the **Slots** tab, under **Dates**, choose **Weekly** or **Specific Dates**.
| Choose | When | Example |
| ------------------ | ---------------------------------- | ---------------------------------------- |
| **Weekly** | The same weekdays and hours repeat | Yoga every Tuesday and Thursday at 08:00 |
| **Specific Dates** | Each session has its own date | Pottery on five Saturdays in October |
## Add the dates [#add-the-dates]
Follow the tab for the option you chose.
2. Select **+ Add Date**.
3. Enter the **Date**, **Start time** and **End time** of the session.
4. (Optional) To add many dates at once, select **Upload dates (CSV)**.
Uploaded rows are added to the dates already listed. The file needs these headers:
| `Activity Date` | `Start time` | `End time` |
| --------------- | ------------ | ---------- |
| `18/11/2026` | `10:00` | `12:00` |
Dates use `dd/mm/yyyy` and times use 24-hour `HH:mm`, in the timezone shown on the **Date** field.
2. Select **+ Add day of week**.
3. Choose the weekday.
4. Set the day's **Start time** and **End time**.
For a second range on the same day, select **+ Add Time Range**.
## Split the time into slots [#split-the-time-into-slots]
Keep each time range as one session, such as a kids' corner open 10:00 to 13:00? Skip to step 8. Split it for back-to-back bookings, such as 20-minute consultations.
5. Open **Slots Configuration**.
6. Turn on **Divide into Slots**.
7. Set the **Duration (minutes)** and the **Buffer between slots**.
The buffer gives staff time between groups.
8. Select **Save**.
## If publishing is blocked [#if-publishing-is-blocked]
* **No dates configured**: add at least one weekday or one specific date.
* **An end time is before its start time**: check each date and weekly range.
* **Two specific dates overlap**: make each session end before the next one starts.
* **Two ranges on the same weekday overlap**: adjust the times on that day.
* **A slot falls outside the campaign dates**: move it inside the [campaign dates](/docs/platform/campaigns/dates), or extend them.
* **A slot falls outside the activity dates**: move it inside the Activity's [own date range](/docs/platform/touchpoints/touchpoint-dates-override).
* **Negative duration or buffer**: set **Duration (minutes)** and **Buffer between slots** to 0 or more.
* **Slots divided, but no dates**: add a date, or turn off **Divide into Slots**.
## Next steps [#next-steps]
* [Configure tickets](/docs/events/build/configure-tickets)
* [Configure check-in](/docs/events/build/configure-check-in)
* [Overlapping slots](/docs/events/troubleshoot/overlapping-slots)
# Configure tickets (/docs/events/build/configure-tickets)
Tickets apply to Activities set to **Booking and Check-In** (see [event types](/docs/events/build/event-types)). Set up the [dates](/docs/events/build/configure-slots-weekly-vs-specific-dates) first.
## Add a ticket [#add-a-ticket]
1. In **Build** > **Touchpoints**, select **Configure** on your Activity.
2. Select the **Slots** tab.
3. Scroll to **Tickets**. Each ticket is one option attendees can book.
4. Select **+ Add ticket**.
5. In **Display Name**, enter the name attendees see, such as "Child place (ages 6 to 12)".
## Set capacity and sale dates [#set-capacity-and-sale-dates]
6. Turn on **Limited capacity**, then enter the **Total capacity** for each slot.
7. (Optional) Turn on **Maximum capacity per contact**, then enter how many tickets one person can book.
8. (Optional) Turn on **Limit Availability**, then set **Availability Start Date** and **Availability End Date**.
Outside these dates, nobody can book the ticket.
## Ask questions at booking [#ask-questions-at-booking]
9. (Optional) Turn on **Booking Form**, then choose a form in **Attendees form template**.
Attendees answer it while they book. It's separate from the [participation form](/docs/events/build/set-the-participation-form-for-an-event) and the [check-in form](/docs/events/build/configure-check-in).
10. Select **Save**.
## Good to know [#good-to-know]
* Capacity counts per slot. A ticket with 20 places across 5 slots can take 100 bookings.
* A simple event needs one ticket. Add more when audiences differ, such as "Child place" and "Accompanying adult".
* Make one test booking per ticket before you publish. See [Test your event](/docs/events/launch/test-your-event).
## If publishing is blocked [#if-publishing-is-blocked]
* **No ticket types**: add at least one ticket to every bookable Activity.
* **A ticket has no display name**: enter a **Display Name**.
* **Capacity is 0 or negative**: enter a positive **Total capacity**, or turn off **Limited capacity**.
* **Negative per-contact limit**: enter a positive number, or turn the limit off.
* **More tickets per person than the total capacity**: lower **Maximum capacity per contact** to the total or less.
* **Sale start or end date missing**: fill both dates under **Limit Availability**.
* **Sale start date on or after the end date**: make the start date earlier.
Studio also warns when one Activity mixes limited and unlimited tickets. It notes, without blocking, when an Activity has several ticket types or a ticket allows 5 or more per person.
## Next steps [#next-steps]
* [Configure check-in](/docs/events/build/configure-check-in)
* [Configure booking restrictions](/docs/events/build/configure-booking-restrictions)
* [Configure booking notifications](/docs/events/build/configure-booking-notifications)
# Create an Activity touchpoint (/docs/events/build/create-an-activity-touchpoint)
Each part below has its own page with every setting. Have the event's dates and a banner image ready.
## Add the Activity [#add-the-activity]
1. In **Build** > **Touchpoints**, select **Add New Touchpoint**.
2. Choose **Activity**.
3. Select **Create**. The Activity editor opens.
## Set it up [#set-it-up]
4. In the **Slots** tab, choose **Check-In Only** or **Booking and Check-In** under **Event Type**.
Choose **Booking and Check-In** when people reserve a place in advance. See [Event types](/docs/events/build/event-types).
5. In the **General** tab, upload the **Banner Image**.
6. Enter the **Title**.
Then add the description and location: [General activity settings](/docs/events/build/general-activity-settings).
7. In the **Slots** tab (not the **Dates** tab), set the event's dates under **Dates**.
See [Configure dates and slots](/docs/events/build/configure-slots-weekly-vs-specific-dates).
8. For a bookable event, add at least one ticket under **Tickets**.
See [Configure tickets](/docs/events/build/configure-tickets).
9. Under **Check-in**, choose the **Check-in Method**: **SELF** or **SCAN**.
See [Configure check-in](/docs/events/build/configure-check-in).
10. In the **Gamification** tab, choose a badge under **Badge Selection**. An Activity can't publish without one.
See [Add badges and rewards](/docs/events/build/add-badges-and-rewards-to-an-event).
11. Select **Save**.
## Publish [#publish]
A bookable event also needs an active **Booking Confirmation** email. Set it up first: [Configure booking notifications](/docs/events/build/configure-booking-notifications).
12. Select **Publish**.
13. When the window says **Campaign is ready to publish!**, select **Publish** again.
Only errors block publishing. Warnings and suggestions are advice.
## What attendees see [#what-attendees-see]
Attendees open the event, pick a time, accept the terms and book. They get a confirmation with a QR code, then check in on the day.
## Next steps [#next-steps]
* [Set opt-in for an event](/docs/events/build/set-opt-in-for-an-event)
* [Set the participation form](/docs/events/build/set-the-participation-form-for-an-event)
* [Test your event](/docs/events/launch/test-your-event)
# Event types (/docs/events/build/event-types)
Every Activity has one of two types. You choose it in the Activity's **Slots** tab, under **Event Type**.
## Which type to choose [#which-type-to-choose]
| | **Booking and Check-In** | **Check-In Only** |
| ---------- | --------------------------------------------------------------- | ------------------------------------------------ |
| Attendees | Pick a time, book, then check in on the day | Turn up and check in |
| You set up | Tickets, capacity per slot, sale dates, booking emails | Dates and check-in |
| You get | A guest list, confirmations, and booked-versus-attended reports | Attendance on the day |
| Good for | Workshops, classes, VIP sessions, guided visits | Photo booths, kids' corners, late-night openings |
On the attendee page, a bookable event shows **Book Now**. A check-in-only event shows **Check-In Now**.
## If publishing is blocked [#if-publishing-is-blocked]
**Check-In Only** runs on one ticket that attendees never see, and Studio doesn't show it. If a check-in-only Activity reports a missing ticket, or more than one, contact your Customer Success Manager. Send the campaign name, the Activity name and the exact message.
## Next steps [#next-steps]
* [Configure dates and slots](/docs/events/build/configure-slots-weekly-vs-specific-dates)
* [Configure tickets](/docs/events/build/configure-tickets)
* [Configure check-in](/docs/events/build/configure-check-in)
# General activity settings (/docs/events/build/general-activity-settings)
These fields sit in the Activity's **General** tab. Open it from **Build** > **Touchpoints** > **Configure**.
## Prepare the images [#prepare-the-images]
Each preview shows where the image lands on the attendee page.
For every image size in one place, see [Image and asset specs](/docs/platform/asset-specs).
## Describe the event [#describe-the-event]
1. In **Appearance**, under **Banner Image**, select **Select a File** and upload the banner.
Select **See Specs** to check the size first, or **Replace attachment** to change it later.
2. In **Title**, enter the event name attendees see as the main heading.
3. In **Subtitle**, add quick context, such as the length and age range.
4. In **Event Details** > **Description**, say what happens, who it's for, and what to bring.
Answer the usual questions here: how long it lasts, how check-in works, and who can come.
## Help attendees find it [#help-attendees-find-it]
5. In **Location**, enter the **Location Area**.
6. Upload a **Location Image** of the entrance or a landmark.
## Choose how times are shown [#choose-how-times-are-shown]
Skip to step 9 to keep the default display.
7. Open **Advanced UI Configuration**.
8. Set the two display options.
* **Hide Activity Schedule Dates Section**: removes the list of dates from the event page. Use it for drop-in events with no fixed times.
* **Display format for Activity Slot Times**: **Start Time Only**, or **Start Time and End Time** for fixed-length sessions.
9. Select **Save**.
Then [preview the event page](/docs/events/launch/test-your-event).
## If publishing is blocked [#if-publishing-is-blocked]
* **Banner image missing**: upload one under **Banner Image**.
* **Title missing**: enter a **Title**.
Studio also flags a missing **Description** or **Location Area**, and a **Location Image** with no area text. These don't block publishing, and neither does a missing **Subtitle**.
## Next steps [#next-steps]
* [Configure dates and slots](/docs/events/build/configure-slots-weekly-vs-specific-dates)
* [Configure tickets](/docs/events/build/configure-tickets)
* [Configure check-in](/docs/events/build/configure-check-in)
# Build (/docs/events/build)
Set up an Activity step by step.
# Set opt-in for an event (/docs/events/build/set-opt-in-for-an-event)
Attendees accept your terms before they book or check in. Forms on an event collect answers, but they never replace this consent step.
| Scope | Use it when | Example |
| -------------------- | ----------------------------------- | ---------------------------------------------- |
| **Campaign level** | One set of terms covers every event | An autumn programme run by the shopping centre |
| **Touchpoint level** | One Activity needs its own terms | A partner-run class inside the programme |
Most event campaigns use the campaign level. Attendees then see one checkbox, such as "I accept the Autumn Workshops terms and conditions", on every event.
To set it up, see [Enable opt-in and choose the scope](/docs/platform/opt-in/enable-opt-in-and-choose-scope).
## Next steps [#next-steps]
* [Configure opt-in at campaign level](/docs/platform/opt-in/configure-opt-in-at-campaign-level)
* [Configure opt-in at touchpoint level](/docs/platform/opt-in/configure-opt-in-at-touchpoint-level)
* [About opt-in](/docs/platform/opt-in/about-opt-in-in-omnilab)
# Choose the right form for an event (/docs/events/build/set-the-participation-form-for-an-event)
An event can ask questions at three moments. Choose the form by when you need the answer.
| Form | Appears | Set it in | Example question |
| ---------------------- | --------------------------------------------- | -------------------------------------- | --------------------------------- |
| **Booking Form** | While someone books a ticket | The ticket, in **Slots** > **Tickets** | Child's age, allergies |
| **Check-in Form** | When someone arrives | **Slots** > **Check-in** | How did you hear about us? |
| **Participation form** | When someone joins the campaign or touchpoint | Campaign or touchpoint settings | Experience level, favourite store |
For most bookable events, the booking form or the check-in form fits best. Use the participation form when the question isn't tied to a booking or an arrival.
## Set up each form [#set-up-each-form]
* [Booking form](/docs/events/build/configure-tickets#ask-questions-at-booking)
* [Check-in form](/docs/events/build/configure-check-in#ask-questions-on-arrival)
* [Participation form at campaign level](/docs/platform/forms/participation-form/configure-at-campaign-level) or [at touchpoint level](/docs/platform/forms/participation-form/configure-at-touchpoint-level)
## Next steps [#next-steps]
* [About participation forms](/docs/platform/forms/participation-form/about-participation-forms)
* [Set opt-in for an event](/docs/events/build/set-opt-in-for-an-event)
# Get Started (/docs/events/get-started)
Put your first event live.
# Your first event in 15 minutes (/docs/events/get-started/your-first-event-in-15-minutes)
You'll make a **Check-In Only** event, the quickest kind: no tickets or booking emails to set up.
## Create the campaign [#create-the-campaign]
1. In **Templates**, find an events template under **Featured**, **Shared** or **Local**, then select **View Actions**.
2. Select **Create Campaign**.
3. Enter the **Campaign Title**.
Studio fills in the **Campaign Public Key**, which becomes part of the event link. Change it if you like.
4. Choose the **Language**.
5. Choose the **Timezone**.
The timezone sets the times attendees and staff see.
6. Select **Generate Campaign**. The new campaign opens.
## Set up the event [#set-up-the-event]
7. In **Build** > **Touchpoints**, select **Configure** on the Activity.
No Activity in your template? [Add one](/docs/events/build/create-an-activity-touchpoint).
8. In the **General** tab, check the template's banner, **Title** and **Subtitle**.
9. In the **Slots** tab, choose **Check-In Only** under **Event Type**.
10. Under **Dates** in the **Slots** tab, set one date inside the campaign dates, with its **Start time** and **End time**.
Use the **Slots** tab, not the **Dates** tab, which limits when the whole Activity is open. To try it today, set today's date and a time range that covers now. Before the start time, the page says **Check-in will start** and nobody can check in yet. Change it to the real date once you've tested.
11. Under **Check-in**, set **Check-in Method** to **SELF**, so attendees check themselves in.
12. Select **Save**.
## Publish and try it [#publish-and-try-it]
13. Select **Publish**.
14. In the window, select **Publish** again.
15. In **Touchpoints**, select **Preview** on the Activity.
16. Scan the QR code with your phone.
On your phone, you'll see the event page. Tick the terms, select **Check-In Now**, and sign in with your email. During the event's hours, select **Start Check In**.
## Next steps [#next-steps]
* [Event types](/docs/events/build/event-types): switch to **Booking and Check-In** to take reservations.
* [Configure tickets](/docs/events/build/configure-tickets)
* [Test your event](/docs/events/launch/test-your-event)
# Download QR codes (/docs/events/launch/download-qr-codes)
| QR code | Who scans it | Where to find it |
| -------------------------- | ------------------------------ | -------------------------------------------------------------------------- |
| **Event QR code** | Attendees, to open the event | The steps below |
| **Ticket QR code** | Hosts, to check an attendee in | In each attendee's booking confirmation |
| **Event Host App QR code** | Staff, to open the Host App | [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app) |
## Download one event's QR code [#download-one-events-qr-code]
1. In **Build** > **Touchpoints**, select **Share** on the Activity.
2. Select **Download PNG** for screens, or **Download SVG** for print.
## Download every event's QR code [#download-every-events-qr-code]
1. In **Build** > **Touchpoints**, select the menu button next to **Touchpoints**.
2. Choose **Download All as PNG** or **Download All as SVG**.
## Test before you print [#test-before-you-print]
* Scan the final file with an iPhone and an Android phone.
* Make one test booking, then scan its ticket QR code in the Event Host App. See [Test your event](/docs/events/launch/test-your-event).
You rarely need a QR code per session. Hosts handle sessions with **Find by Slot** and **Slots Availability** in the Event Host App.
For colours, a logo or print sizes, see [QR codes](/docs/platform/deployment/qr-codes).
## Next steps [#next-steps]
* [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app)
* [Test your event](/docs/events/launch/test-your-event)
# Find the event URL (/docs/events/launch/find-the-event-url)
Links appear once the campaign is published. Each Activity has its own link, which opens it directly: a single event needs nothing else.
## Link to one event [#link-to-one-event]
1. In **Build** > **Touchpoints**, select **Share** on the Activity.
2. Copy the first link. It opens the event directly.
Use it in emails, on signs and in QR codes. The other links add tracking for Facebook, Instagram and TikTok: use each one only in that channel.
## If the campaign has a landing page [#if-the-campaign-has-a-landing-page]
With several events, a landing page lets attendees choose between them. Its link is the campaign's.
1. Select **Landing Page**.
2. Copy the address at the top: the domain, then your campaign's public link.
The QR button next to it gives the landing page's QR code. **Enable Landing Page** on the screen means it's off, so the campaign link opens nothing.
## Next steps [#next-steps]
* [Download QR codes](/docs/events/launch/download-qr-codes)
* [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app)
* [Touchpoint URLs and slugs](/docs/platform/deployment/touchpoint-urls-and-slugs)
# Launch (/docs/events/launch)
Work through these in order before you promote the event.
# Publish an event (/docs/events/launch/publish-an-event)
Publishing is campaign-wide: every Activity and touchpoint in the campaign goes live together.
## Publish the campaign [#publish-the-campaign]
1. At the top of the campaign, select **Publish**.
2. When the window says **Campaign is ready to publish!**, select **Publish**.
Errors block publishing. Warnings and suggestions don't. After any later change, publish again before attendees see it.
## If publishing is blocked [#if-publishing-is-blocked]
The window lists each problem and where to fix it. For the event-specific ones, see [Event validation errors](/docs/events/troubleshoot/event-validation-errors). For everything else, see [Validation and publishing](/docs/platform/campaigns/validation-and-publishing).
## Next steps [#next-steps]
* [Find the event URL](/docs/events/launch/find-the-event-url)
* [Download QR codes](/docs/events/launch/download-qr-codes)
# Set up the Event Host App (/docs/events/launch/set-up-the-event-host-app)
Hosts check attendees in from a browser on a tablet or phone, with nothing to install.
## Get the scanner link [#get-the-scanner-link]
1. In **Build** > **Touchpoints**, select **Share** on an Activity.
2. Select the **Event Host App** tab.
The same panel opens from **Participants** > **Bookings** > **Event Host App**.
3. Copy the scanner link, and send it only to your hosts.
For a QR code of the link, select **Download PNG** or **Download SVG** below it.
## Protect access [#protect-access]
1. Under **Password Protection**, select **Show password** to read it, or **Change password** to set a new one.
Changing it changes it for every Activity in the campaign, and for a Receipt Host App in the same campaign. Hosts already signed in must enter the new one, so change it between shifts.
## Brief your hosts [#brief-your-hosts]
1. Select **Generate PDF Instructions**.
2. Select **Download Event Host App Instructions PDF**.
The sheet has the steps, the scanner link as text and QR code, and the password. One sheet covers every Activity in the campaign.
## Test each device [#test-each-device]
Sign in on each tablet or phone and open the scanner: steps 1 to 5 of [Check guests in](/docs/events/operate/use-the-event-host-app-on-the-day#check-guests-in). Allow camera access when the device asks. Then:
1. Point the camera at a ticket, and check the picture shows.
Check it focuses quickly, and that the Wi-Fi or mobile signal is steady. The full rehearsal, with a booking and a scan, is in [Test your event](/docs/events/launch/test-your-event).
## Next steps [#next-steps]
* [Test your event](/docs/events/launch/test-your-event)
* [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day)
* [Event Host App access problems](/docs/events/troubleshoot/event-host-app-access)
# Test your event (/docs/events/launch/test-your-event)
The rehearsal runs on the published event, so do it before any poster or email goes out.
Before you begin: [publish the event](/docs/events/launch/publish-an-event), [copy its link](/docs/events/launch/find-the-event-url), and [set up the Event Host App](/docs/events/launch/set-up-the-event-host-app).
## Rehearse it [#rehearse-it]
| Check | How |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The way in | Open the event from the link or QR code you'll share: the Activity's own link, or the landing page if the campaign has one. See [Find the event URL](/docs/events/launch/find-the-event-url). |
| Booking | Book a place with your own email address, on the phone you'd expect guests to use. Fill in the booking form as a guest would. |
| Confirmation | Check the inbox and the spam folder for **Booking Confirmation**, and open the ticket's QR code. |
| Check-in | On the Event Host App, scan your ticket, or look it up with **Find by Email**. See [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day). |
| The result | Before the session, expect **Early Check-In Detected**: that's the window working. See [Check-in statuses](/docs/events/operate/check-in-statuses). |
| In Studio | Find your booking in **Participants** > **Bookings**. See [Access bookings & tickets](/docs/events/operate/access-bookings-and-tickets). |
| Clean up | Cancel your test booking, so it doesn't take a place: see [Cancel a booking](/docs/events/operate/cancel-a-booking). |
Don't select **Continue Check-In** on your test booking. A checked-in booking can't be cancelled, so it would keep its place.
Test on a tablet and a phone if your staff will use both.
## Next steps [#next-steps]
* [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day)
* [Access bookings & tickets](/docs/events/operate/access-bookings-and-tickets)
# Booking analytics (/docs/events/measure/booking-analytics)
The **Performance** tab in **Bookings** counts places per session, day, week or month, for each Activity. Here, Lindenhall checks how the Autumn Workshops are filling.
## Read the numbers [#read-the-numbers]
In **Participants** > **Bookings**:
1. Select the **Performance** tab.
2. Under **Group by**, choose **Slot**, **Daily**, **Weekly** or **Monthly**.
3. (Optional) Under **Event**, choose one Activity.
With one Activity chosen, a **Ticket Type** filter appears too.
## Download this view [#download-this-view]
With the grouping and filters you want on screen:
1. Select **Download CSV**.
## What each column means [#what-each-column-means]
| Column | Meaning |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Total Tickets** | The places the session offers, for every ticket type. By slot only. |
| **Confirmed** | Places booked |
| **Booking Rate** | **Confirmed** out of **Total Tickets**, all ticket types together: 100% means every type is sold out, so one type can be full below 100%. By slot only. |
| **Cancelled** | Places booked, then cancelled |
| **Check-ins** | Guests who were checked in |
| **Check-in Rate** | **Check-ins** out of **Confirmed**: see [Check-in rates & no-shows](/docs/events/measure/check-in-rates-and-no-shows) |
Only sessions with at least one booking are listed.
## Next steps [#next-steps]
* [Check-in rates & no-shows](/docs/events/measure/check-in-rates-and-no-shows)
* [Exports for events](/docs/events/measure/exports-for-events)
# Check-in rates & no-shows (/docs/events/measure/check-in-rates-and-no-shows)
The gap between places booked and guests checked in is your no-shows. Here, Lindenhall reads the first Saturday of its Kids' Pottery Workshop.
## Read the check-ins [#read-the-check-ins]
Open **Performance** in **Participants** > **Bookings**, grouped by **Slot**: see [Booking analytics](/docs/events/measure/booking-analytics).
## Work out the no-shows [#work-out-the-no-shows]
Studio shows **Check-ins** and **Check-in Rate**, the share of confirmed places that turned up. The no-shows are the rest:
| | 3 October, 10:00 | 3 October, 14:00 |
| -------------------------------------------- | ---------------- | ---------------- |
| **Confirmed** | 12 | 8 |
| **Check-ins** | 11 | 5 |
| **Check-in Rate** | 91.67% | 62.50% |
| No-shows (**Confirmed** minus **Check-ins**) | 1 | 3 |
Read cancellations apart, under **Cancelled**: they aren't no-shows. Read the numbers once the session is over.
## What to do with them [#what-to-do-with-them]
* **A low check-in rate on one session**: turn on the **Booking Confirmation Reminder**: see [Configure booking notifications](/docs/events/build/configure-booking-notifications).
* **The same session is always full and always shows up**: add a session at that time.
## Next steps [#next-steps]
* [Booking analytics](/docs/events/measure/booking-analytics)
* [Exports for events](/docs/events/measure/exports-for-events)
# Exports for events (/docs/events/measure/exports-for-events)
Each file comes from a different screen in Studio.
| You need | Export | Where |
| ------------------------------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| A door list, one row per ticket, with the booking form answers | Bookings CSV | **Participants** > **Bookings** > **Download**: see [Download the bookings CSV](/docs/events/operate/download-the-bookings-excel) |
| Places, cancellations and check-ins per session, day, week or month | Performance CSV | **Bookings** > **Performance** > **Download CSV**: see [Booking analytics](/docs/events/measure/booking-analytics) |
| Visits and sign-ups for the whole campaign, to share | Analytics PDF | **Analytics** > **Export PDF**: see [Exports & reports](/docs/platform/analytics-fundamentals/exports-and-reports#export-a-dashboard-snapshot) |
The Performance CSV follows the grouping and filters on screen.
## Next steps [#next-steps]
* [Download the bookings CSV](/docs/events/operate/download-the-bookings-excel)
* [Booking analytics](/docs/events/measure/booking-analytics)
# Measure (/docs/events/measure)
See how full each session was, and who turned up.
# Access bookings & tickets (/docs/events/operate/access-bookings-and-tickets)
Every booking lands in **Bookings**, with one row per ticket: who booked, which session, which ticket and its status. Here, Lindenhall looks up Daisy Reid's place at the Kids' Pottery Workshop.
## Find a booking [#find-a-booking]
1. In the campaign, select **Participants** > **Bookings**.
2. Type the guest's name or email in **Search**.
3. (Optional) To narrow the list, select a filter such as **Touchpoint**.
4. Tick the value to keep, here **Kids' Pottery Workshop**.
**Start Date**, **Status** and **Ticket Type** filter the same way.
## Read the booking form answers [#read-the-booking-form-answers]
Find the booking, then:
1. On the booking, select the **Attendees** icon.
**Attendee Information** shows what was entered for each ticket, here the child's name, age and needs.
## Download a ticket [#download-a-ticket]
Find the booking, then:
1. On the booking, select **Ticket**.
The ticket downloads as a PDF with its QR code, to send again to a guest who lost it. **Ticket** disappears once a booking is cancelled or checked in.
## Next steps [#next-steps]
* [Cancel a booking](/docs/events/operate/cancel-a-booking)
* [Download the bookings CSV](/docs/events/operate/download-the-bookings-excel)
# Cancel a booking (/docs/events/operate/cancel-a-booking)
Cancel a booking when a guest can't come and asks you to, or when a booking was made by mistake. The row stays in **Bookings** with the status **Cancelled**, so you keep a record.
## Cancel it [#cancel-it]
1. In the campaign, select **Participants** > **Bookings**.
2. Find the guest with **Search**.
If they hold several bookings, check the **Activity**, **Slot Start Date** and **Ticket Type**.
3. On the booking, select **Cancel Booking**.
4. In the **Cancel Booking** window, select **Confirm**.
It can't be undone: the guest books again if they change their mind.
## Good to know [#good-to-know]
* A booking that's already cancelled or checked in has no **Cancel Booking** button.
* Cancelled places count under **Cancelled** in [Booking analytics](/docs/events/measure/booking-analytics).
* If **Booking Cancellation** is on in the Activity's notifications, the guest gets that email: see [Configure booking notifications](/docs/events/build/configure-booking-notifications).
## Next steps [#next-steps]
* [Access bookings & tickets](/docs/events/operate/access-bookings-and-tickets)
# Check-in statuses (/docs/events/operate/check-in-statuses)
After a scan or a manual check-in, the Event Host App shows a coloured result. Read it before you let the guest in.
| Result | What it means | What to do |
| --------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Green, **Check-In Successful** | The booking is valid, and the guest is now checked in | Let them in |
| Orange, **Early Check-In Detected** | The guest is before the session's check-in window | Select **Continue Check-In** only if you let guests in early |
| Orange, **Late Check-In Detected** | The guest is after the window has closed | Ask a manager before you select **Continue Check-In** |
| Red, **Already Checked-In** on the booking | The ticket was used already | Don't check in again. Check their name, and call a manager if two people show the same ticket |
| Red, **Error**: "Unable to complete check-in" | The check-in didn't go through | Try once more. If it fails again, note the guest's name and let a manager decide: the booking stays **Confirmed** in Studio |
The window opens and closes around each session's start: see [Configure check-in](/docs/events/build/configure-check-in).
## Checked in [#checked-in]
## Early [#early]
## Already checked in [#already-checked-in]
## Error [#error]
## Next steps [#next-steps]
* [Manual check-in](/docs/events/operate/manual-check-in)
* [Early and late check-in problems](/docs/events/troubleshoot/early-late-window-issues)
# Download the bookings CSV (/docs/events/operate/download-the-bookings-excel)
The file has one row per ticket, with each guest's booking form answers. Here, Lindenhall downloads the Autumn Workshops list to print a door list.
## Download the file [#download-the-file]
1. In the campaign, select **Participants** > **Bookings**.
2. Select **Download**.
The file is named after the campaign's public link, such as `lindenhall-autumn-workshops.csv`.
## What's in the file [#whats-in-the-file]
| Columns | What they hold |
| ---------------------------------------------------------- | -------------------------------------------------------------- |
| **First Name**, **Last Name**, **Email**, phone and opt-in | Who booked |
| Booking time, **Slot Start Date**, **Activity** | When they booked, and for which session |
| **Ticket Type**, ticket number, **Status** | The ticket and whether it's confirmed, cancelled or checked in |
| **External ID** | The guest's ID in your own systems, if you send one |
| One column per booking form question | Each attendee's answers. Uploaded files aren't included. |
The file always holds every booking, whatever filters are on screen. For totals per session or per day, use **Performance** instead: see [Booking analytics](/docs/events/measure/booking-analytics).
## Next steps [#next-steps]
* [Exports for events](/docs/events/measure/exports-for-events)
# Operate (/docs/events/operate)
Check guests in, and manage their bookings.
# Manual check-in (/docs/events/operate/manual-check-in)
When a ticket won't scan, or a guest can't find it, check them in by hand. Here, a host at the Kids' Pottery Workshop finds a guest by email, then another by session.
## Find the guest by email [#find-the-guest-by-email]
On the Activity's menu in the Event Host App:
1. Select **Find by Email**.
2. Enter the guest's email address.
3. Select **Find Bookings**.
4. On the confirmed booking, select **Check-in**.
With several bookings, check the ticket type first.
5. Select **Confirm Check-In**.
6. If the app warns that the guest is early or late, select **Continue Check-In** only if your event allows it.
**Check-In Successful** then shows, and the booking reads **Already Checked-In**.
## Find the guest by slot [#find-the-guest-by-slot]
When the guest knows their slot, the session time, but not their email:
1. On the Activity's menu, select **Find by Slot**.
2. Open the day.
3. Choose the session.
4. Type the guest's name in the search box.
5. On their booking, select **Check-in**.
Then confirm as in steps 5 and 6 of [Find the guest by email](#find-the-guest-by-email). **Booking Status** (under **Filters** on a phone) can narrow the list to **Confirmed** bookings.
## Next steps [#next-steps]
* [Check-in statuses](/docs/events/operate/check-in-statuses)
* [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day)
# Use the Event Host App on the day (/docs/events/operate/use-the-event-host-app-on-the-day)
On the day, staff check guests in from a tablet or a phone with the Event Host App in the browser. Here, Lindenhall's workshop team checks in the Kids' Pottery Workshop.
Before you begin, set the app up and test it on each device: see [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app).
## Check guests in [#check-guests-in]
1. Open the scanner link on the device.
2. Enter the team's password.
3. Select **Submit**. It turns blue once you've typed the password.
4. Select the Activity.
5. Select **Scan QR Code**.
Allow the camera if the device asks. With several cameras, choose the back one.
6. Point the camera at the QR code on the guest's ticket.
7. Read the result before you let the guest in.
**Check-In Successful** means they're in. Any other result needs a decision: see [Check-in statuses](/docs/events/operate/check-in-statuses).
If a code won't scan, find the guest by email or by session instead: see [Manual check-in](/docs/events/operate/manual-check-in).
## See how full each session is [#see-how-full-each-session-is]
Open the Activity, then:
1. Select **Slots Availability**.
2. Choose the ticket type.
3. Select the day.
Each session shows its places taken. A full session shows in red.
## Next steps [#next-steps]
* [Manual check-in](/docs/events/operate/manual-check-in)
* [Check-in statuses](/docs/events/operate/check-in-statuses)
# Overview (/docs/events/overview)
Start here if you're new to bookable workshops and on-site check-in.
# What is Events (/docs/events/overview/what-is-events)
Events runs anything that happens at a set time and place, and records who came. In Studio, each event is an **Activity** touchpoint in a campaign.
## What you can do with it [#what-you-can-do-with-it]
| You want to | Use | Example |
| --------------------------------------- | ----------------------------- | -------------------------------------- |
| Run sessions on set dates or every week | Dates and slots | Pottery every Saturday in October |
| Cap how many people come | Ticket capacity per slot | 12 children per session |
| Take bookings in advance | **Booking and Check-In** | Families reserve a place |
| Offer different places | Several tickets | "Child place" and "Accompanying adult" |
| Check people in at the door | The Event Host App | Hosts scan tickets on a tablet |
| Reward attendance | A badge, or a campaign reward | A "Little Potter" badge after check-in |
## Two choices shape every event [#two-choices-shape-every-event]
**Does the event take bookings?** Choose **Booking and Check-In** when people reserve first, or **Check-In Only** when they just turn up.
**Who checks people in?** Choose **SELF** when attendees check in on their own phone, or **SCAN** when hosts scan their tickets.
| | **SELF** | **SCAN** |
| ------------------------ | ----------------------------------------------------------- | -------------------------------------------------------------- |
| **Check-In Only** | Walk-in photo booth: attendees check in from the event page | Staffed sampling stand: hosts scan attendees in |
| **Booking and Check-In** | Timed yoga class: attendees book, then check in themselves | Workshop or VIP session: attendees book, hosts scan the ticket |
A first event is quickest as **Check-In Only** with **SELF**: no tickets, booking emails or host devices to prepare.
## When Events fits [#when-events-fits]
Use Events when the experience happens at set times, when capacity matters, or when you need to know who attended. You then get reports on bookings, check-ins and no-shows.
If nobody needs a time, a place or a check-in, a game or a simple page is quicker to launch.
## Next steps [#next-steps]
* [Your first event in 15 minutes](/docs/events/get-started/your-first-event-in-15-minutes)
* [Create an Activity touchpoint](/docs/events/build/create-an-activity-touchpoint)
* [Event types](/docs/events/build/event-types)
# Early and late check-in problems (/docs/events/troubleshoot/early-late-window-issues)
Check-in opens **Early Check-in Window (minutes)** before a slot starts. It closes at the slot end with **Slot end date**, or a set time after the start with **personalized**. With **personalized**, a 14:00 slot with a 15-minute early and a 10-minute late window checks people in from 13:45 to 14:10.
## The Host App says "Early Check-In Detected" [#the-host-app-says-early-check-in-detected]
**Why this happens:** the attendee arrived before check-in opened.
**On the day:** if your team allows it, select **Continue Check-In** to let them in.
**For later sessions:**
1. Raise **Early Check-in Window (minutes)** under **Slots** > **Check-in** > **Advanced Check-in**.
2. Save, then publish the campaign again.
## The Host App says "Late Check-In Detected" [#the-host-app-says-late-check-in-detected]
**Why this happens:** the attendee arrived after check-in closed.
**On the day:** if your team allows it, select **Continue Check-In**.
**For later sessions:**
1. Raise **Late Check-in Window (minutes)**, or choose **Slot end date**.
2. Save, then publish the campaign again.
## Check-in opens at the wrong time [#check-in-opens-at-the-wrong-time]
**Why this happens:**
* Slot times follow the timezone shown on each **Date** field, such as **Date (Europe/London)**.
* The campaign's timezone differs from where the event runs.
**Fix it:**
1. In the Activity's **Slots** tab, check the timezone on the **Date** field.
2. If it's wrong, correct the campaign timezone in **Build** > **General**.
3. Check the slot's start time, then both windows.
## Publishing is blocked: "uses custom check-in end method but no late window is configured" [#publishing-is-blocked-uses-custom-check-in-end-method-but-no-late-window-is-configured]
**Fix it:** set **Late Check-in Window (minutes)** above 0, or choose **Slot end date**.
## Studio warns about "a very large early check-in window" [#studio-warns-about-a-very-large-early-check-in-window]
This appears when the early window is 60 minutes or more. It doesn't block publishing: check the value is intended.
## Still stuck? [#still-stuck]
Contact your Customer Success Manager. Send the campaign and Activity names, the slot time, when the attendee arrived, and the message shown.
## Next steps [#next-steps]
* [Configure check-in](/docs/events/build/configure-check-in)
* [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day)
# Event Host App access problems (/docs/events/troubleshoot/event-host-app-access)
Most problems come from an old scanner link, a changed password, a blocked camera or a weak connection. Check the device you'll use on the day: a recent tablet or phone with an up-to-date Chrome or Safari.
## The scanner link doesn't open, or opens the wrong event [#the-scanner-link-doesnt-open-or-opens-the-wrong-event]
**Fix it:**
1. In Studio, open **Share** on the Activity, then the **Event Host App** tab.
2. Copy the scanner link again and send it to the host.
## The password is refused [#the-password-is-refused]
**Why this happens:**
* The password was changed. One password covers the whole campaign.
* The host typed a personal password or an email instead.
**Fix it:**
1. Under **Password Protection**, select **Show password** and check it.
2. On the host's device, enter that password on the sign-in screen.
3. If the app keeps using an old password, select **Clear credentials and refresh**, then sign in again.
## The camera doesn't start [#the-camera-doesnt-start]
**Fix it:**
1. In the browser's site settings, allow the camera for the Event Host App page.
2. Reload the page, then select **Scan QR Code** again.
3. If the device has several cameras, choose the rear one.
## Scanning is slow or fails [#scanning-is-slow-or-fails]
* Scan the attendee's ticket QR code, not a poster or sign QR code.
* Clean the lens, raise the attendee's screen brightness, and hold the code steady in the frame.
* Move to steady Wi-Fi or better mobile coverage: each check-in needs a connection.
* If a code still won't scan, use **Find by Email**. See [Use the Event Host App on the day](/docs/events/operate/use-the-event-host-app-on-the-day).
## Still stuck? [#still-stuck]
If the app fails on more than one device, contact your Customer Success Manager. Send a screenshot, the device model and the browser version.
## Next steps [#next-steps]
* [Set up the Event Host App](/docs/events/launch/set-up-the-event-host-app)
* [Early and late check-in problems](/docs/events/troubleshoot/early-late-window-issues)
# Event validation errors (/docs/events/troubleshoot/event-validation-errors)
The **Publish** window lists each error with the Activity it belongs to. Each message names your Activity or ticket: search this page for the words after the name, fix it, then publish again.
## An old Activity won't publish [#an-old-activity-wont-publish]
The message starts: *Activity '\[name]' is a legacy activity and can no longer be published*.
**Why this happens:** a long-running campaign, or one duplicated from it, still has an Activity created long ago. Activities you create today aren't affected. Publishing blocks it so it can't fail when an attendee tries to book.
Bookings, check-ins and reports recorded so far stay with the old Activity. If the event is already taking bookings, talk to your Customer Success Manager before you change anything.
**Fix it:**
1. In **Build** > **Touchpoints**, select **Add New Touchpoint**, then **Activity**.
2. Set it up with the same details, schedule, tickets and check-in settings.
3. In **Share**, get the new link and QR codes.
4. Replace every link or QR code already printed, scheduled or published.
5. Remove the old Activity.
For the full setup, see [Create an Activity touchpoint](/docs/events/build/create-an-activity-touchpoint).
## Booking emails [#booking-emails]
* **"has booking enabled but no booking confirmation notification is configured"**: in **Build** > **Notifications**, turn on **Booking Confirmation**, globally or for this Activity.
* **"Email banner image is missing for … notification"**: upload an **Email Banner** in that notification.
* **"Booking confirmation reminder is enabled but booking cancellation notification is not active"**: turn on **Booking Cancellation** too.
* **"no email marketing addon (Cheetah or Salesforce Marketing Cloud) is active"**: turn one on in **Build** > **Settings** > **Advanced Settings** > **Addons**.
See [Configure booking notifications](/docs/events/build/configure-booking-notifications).
## Dates and slots [#dates-and-slots]
* **"has no dates configured"**: add at least one weekday or specific date.
* **"has end time (…) before start time (…)"**: check each date and weekly range.
* **"has overlapping specific dates"** or **"has overlapping time ranges"**: see [Overlapping slots](/docs/events/troubleshoot/overlapping-slots).
* **"falls outside the campaign dates"** or **"falls outside the activity dates"**: move the slot inside those dates, or extend them.
* **"has a negative slot duration"** or **"has a negative buffer time"**: set both to 0 or more.
* **"has slot division configured but no dates specified"**: add a date, or turn off **Divide into Slots**.
## Tickets and capacity [#tickets-and-capacity]
* **"has booking enabled but no ticket types configured"**: add a ticket in **Slots** > **Tickets**.
* **"has no display name"**: enter the ticket's **Display Name**.
* **"has capacity set to 0"** or **"has negative capacity"**: enter a positive **Total capacity**.
* **"has negative per contact limit"**: enter a positive number, or turn the limit off.
* **"tickets per person but total capacity is only"**: lower **Maximum capacity per contact**.
* **"has limited availability enabled but no sale start date"**, or no sale end date: fill both dates under **Limit Availability**.
* **"has sale start date (…) after or equal to sale end date"**: make the start date earlier than the end date.
## Check-in [#check-in]
* **"has a negative early check-in window"** or **"has a negative late check-in window"**: set it to 0 or more.
* **"requires a check-in form but no template is selected"**: choose a template, or turn off **Check-in Form**.
* **"uses custom check-in end method but no late window is configured"**: set a **Late Check-in Window (minutes)** above 0.
* **"has no technical ticket configured"** or **"technical tickets configured"**: contact your Customer Success Manager. Studio doesn't show this ticket.
## Restrictions [#restrictions]
* **"has booking limit enabled but booking limit per contact is 0"**: enter a number above 0, or turn off **Limit bookings per participant**.
* **"has booking validation codes restriction enabled but no validation codes pool configured"**: choose a pool, or turn it off.
## Messages that don't block publishing [#messages-that-dont-block-publishing]
Warnings and suggestions are advice. Studio flags, for example, a missing cancellation or reminder email, an early window of 60 minutes or more, or tickets mixing limited and unlimited capacity. Check each one is intended, then publish.
## Next steps [#next-steps]
* [Early and late check-in problems](/docs/events/troubleshoot/early-late-window-issues)
* [Validation and publishing](/docs/platform/campaigns/validation-and-publishing)
# Troubleshoot (/docs/events/troubleshoot)
Fix slot, check-in and publishing problems.
# Overlapping slots (/docs/events/troubleshoot/overlapping-slots)
An Activity can't run two sessions at once. Validation lists every overlap in one pass, so you can fix them all before publishing again.
## "has overlapping specific dates" [#has-overlapping-specific-dates]
The message names both dates, for example *Date #2 (03/10/2026 10:00-11:30) overlaps with Date #3*.
**Why this happens:** two rows under **Specific Dates** share some time. A duplicated row, or a session stretched without moving the next one, usually causes it.
**Fix it:**
1. In the Activity's **Slots** tab, open **Dates** > **Specific Dates**.
2. Find the two rows the message names.
3. Change the times so one ends before the next starts, or delete the duplicate.
4. Save, then publish again.
## "has overlapping time ranges on" a weekday [#has-overlapping-time-ranges-on-a-weekday]
**Why this happens:** two ranges on the same weekday share some time. Different days never conflict: Monday and Tuesday can share the same hours.
**Fix it:**
1. In **Dates** > **Weekly**, open the weekday the message names.
2. Change the ranges so one ends before the next starts, or remove one.
3. Save, then publish again.
After changing **Duration (minutes)** or **Buffer between slots**, review the schedule again before publishing.
## Still stuck? [#still-stuck]
Contact your Customer Success Manager with the campaign and Activity names and the full message.
## Next steps [#next-steps]
* [Configure dates and slots](/docs/events/build/configure-slots-weekly-vs-specific-dates)
* [Event validation errors](/docs/events/troubleshoot/event-validation-errors)
# Build by game type (/docs/games/build-by-game-type)
Step-by-step build guides, by game family.
# Configure appearance & branding (/docs/games/configure/configure-appearance-and-branding)
Every game's **Appearance** tab starts with the same few fields. Each game type then adds its own, such as the wheel builder or the memory cards.
## The fields most games share [#the-fields-most-games-share]
| Field | Where players see it | Tip |
| ---------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------- |
| **Background Image** | Behind the whole game | Keep key details away from the edges: phones crop the sides |
| **Title** | At the top of the game | Short enough for one or two lines on a phone |
| **Subtitle** | Under the title | One sentence: what to do, or what to win |
| The game's details, such as **Scratch Game Details** | Under **How to play?** | Say how often players can try, and where and when to collect the prize |
| **Thumbnail** or **Thumbnail Image** | On the landing page, as the game's tile | Required while the landing page is on |
The Simple Game and Gated Content use a **Banner Image** at the top instead of a background. The Form adds a **Header Image** above the questions.
## Prepare the images [#prepare-the-images]
Every size in one place: [Image & asset specs](/docs/platform/asset-specs).
## Keep the campaign consistent [#keep-the-campaign-consistent]
* Give the same prize, place and deadline in the subtitle, the details and the reward.
* Translate player-facing text into every language of the campaign.
* Open the game on a phone after changing a picture.
## Next steps [#next-steps]
* [Configure the header](/docs/platform/landing-page/configure-the-header)
* [Configure outcomes](/docs/games/configure/configure-outcomes)
# Configure notifications for a game (/docs/games/configure/configure-notifications-for-a-game)
A game doesn't send email itself: the campaign does, when something happens in the game. You switch each type on in **Build** > **Notifications**. Every type starts off.
## The emails a game can trigger [#the-emails-a-game-can-trigger]
| Email | Sent when | Can differ per |
| ----------------------- | -------------------------------------------------------- | -------------- |
| **Participate** | A player takes part in the campaign for the first time | Campaign |
| **Winner** | A player wins a reward | Reward |
| **Loser** | A player doesn't win | Reward |
| **Eligible** | A player is entered for a lottery or a leaderboard prize | Reward |
| **Reward Redeemed** | A player redeems a prize they won | Reward |
| **Touchpoint Unlocked** | A player collects a badge, or unlocks an offer | Touchpoint |
Each type sends one email for the whole campaign, or a different one per reward or touchpoint.
## Next steps [#next-steps]
* [About notifications](/docs/platform/notifications/about-notifications)
* [Configure notifications by touchpoint](/docs/platform/notifications/configure-notifications-by-touchpoint)
# Configure outcomes (/docs/games/configure/configure-outcomes)
On a luck-based game, the **Outcomes** tab decides the result of each play. You choose the mode under **How Winners Are Determined**.
## The three modes [#the-three-modes]
| Mode | What players get | Choose it when |
| --------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------- |
| **Random Chance** | A result straight away, from the odds you set | The game should feel spontaneous |
| **Scheduled Rewards** | A result straight away, but prizes can only be won at moments spread between two dates | Stock has to last, such as a few prizes a day |
| **Lottery** | An entry in a draw, with no result on the spot | You'd rather draw the winners after the game closes |
## Which games offer which mode [#which-games-offer-which-mode]
| Game | Random Chance | Scheduled Rewards | Lottery |
| ------------------------------------------------------------------------------ | ------------- | ----------------- | ------- |
| [Wheel of Fortune](/docs/games/build-by-game-type/luck-based/wheel-of-fortune) | Yes | Yes | No |
| [Scratch Card](/docs/games/build-by-game-type/luck-based/scratch-card) | Yes | Yes | No |
| [Reveal Card](/docs/games/build-by-game-type/luck-based/reveal-card) | Yes | Yes | No |
| [Simple Game](/docs/games/build-by-game-type/luck-based/simple-instant-win) | Yes | Yes | Yes |
| [3D Selection](/docs/games/build-by-game-type/luck-based/3d-selection-game) | Yes | Yes | Yes |
Skill-based games have no **Outcomes** tab: the best-ranked players win. See [Leaderboard rewards](/docs/platform/rewards/top-x).
## Simple or Advanced [#type]
**Random Chance** and **Scheduled Rewards** each have a **Type**, under **Distribution Mode** or **Rewards distribution**.
| Mode | **Simple** | **Advanced** |
| --------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| **Random Chance** | The odds apply for the whole campaign, until the reward runs out | The odds apply only inside attribution slots: time windows, each with its own quantity |
| **Scheduled Rewards** | Winning moments are spread at random between a **Start Date** and an **End Date** | Each winning option gets its own attribution slots |
Stock left over in one attribution slot carries into the next. See [Attribution slots](/docs/platform/rewards/attribution-slots).
## Winning and losing options [#winning-and-losing-options]
**Random Chance** and **Scheduled Rewards** decide each play from a list of options: winning options, each linked to a prize, and a losing outcome.
* **At least one winning option**, each with a **Luck Based Reward**.
* **One losing outcome at most.** With none, every player wins something.
* **Scheduled Rewards needs exactly one losing outcome**: players who play between two winning moments land on it.
* **Under Random Chance, the probabilities total 100%**, losing outcome included.
* **Under Scheduled Rewards, every linked reward has a set quantity**, not unlimited.
**Lottery** has no options. You write the message players see after entering, and link exactly one lottery reward made for this game: see [Lottery rewards](/docs/platform/rewards/draw-at-end).
## Next steps [#next-steps]
* [Instant Win rewards](/docs/platform/rewards/real-time-draw)
* [Attribution slots](/docs/platform/rewards/attribution-slots)
* [Game validation errors](/docs/games/troubleshoot/game-validation-errors)
# Configure (/docs/games/configure)
Set up what every game shares.
# Link rewards to a game (/docs/games/configure/link-rewards-to-a-game)
A reward you create in **Build** > **Rewards** reaches players only once a game hands it out. Each kind of game takes its own reward type, in its own place.
## Where each game takes its reward [#where-each-game-takes-its-reward]
| Game | Reward to create | Where you link it |
| ---------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------- |
| Wheel of Fortune, Scratch Card, Reveal Card, Simple Game, 3D Selection | **Game Reward** > **Luck Based Reward** | **Outcomes** tab, on each winning option |
| Simple Game or 3D Selection in **Lottery** mode | **Game Reward** > **Lottery** | **Select a game**, when you create the reward |
| Quiz, Memory, Catcher, Form | **Game Reward** > **Performance Based Reward** | **Performance Rewards**, at the bottom of **Appearance** |
| An offer, or an offer in an Offers Pass | **Offer Reward** | The offer's **Offer Reward** |
To reward the whole campaign rather than one game, use a **Global Reward**: see [Instant Win rewards](/docs/platform/rewards/real-time-draw).
## Luck-based games [#luck-based-games]
Each winning option has its own **Link with a Luck Based Game Reward**. Here, Bloom Reveal's winning option gives a spring bouquet.
Step by step: [Link rewards to touchpoints](/docs/platform/rewards/link-rewards-to-touchpoints).
## Skill-based games [#skill-based-games]
**Performance Rewards** lists the prizes for the best-ranked players. Here, Garden Pairs gives a picnic hamper.
Step by step: [Leaderboard rewards](/docs/platform/rewards/top-x).
## If publishing is blocked [#if-publishing-is-blocked]
* **A reward isn't linked to any game**: link it as above, or delete it in **Build** > **Rewards**.
* **A reward's quantity is 0**: set it above zero on the reward.
* **A lottery's game was deleted**: choose another game for it.
* **A Lottery game has no lottery, or more than one**: link exactly one, made for this game.
With no reward under **Performance Rewards**, a skill game still publishes, but nobody can win: Studio warns about it.
## Next steps [#next-steps]
* [Create a reward](/docs/platform/rewards/create-a-reward)
* [Configure outcomes](/docs/games/configure/configure-outcomes)
# Set opt-in for a game (/docs/games/configure/set-opt-in-for-a-game)
A game shows the campaign's consent checkbox and terms, unless the campaign is set to **Touchpoint Level**. Then each game has its own, on its **Terms & Conditions** tab.
Opt-in starts off: switch it on before a game collects anyone's details.
## Choose where it's set [#choose-where-its-set]
| Scope | Where you set it | Use it when |
| -------------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
| **Campaign Level** | Once, in **Build** > **General** > **Terms & Conditions** | Every game shares the same terms |
| **Touchpoint Level** | On each game's **Terms & Conditions** tab | One game needs its own terms, such as a prize draw's rules |
The scope applies to the whole campaign: you can't change it for one game only.
## Next steps [#next-steps]
* [Enable opt-in and choose its scope](/docs/platform/opt-in/enable-opt-in-and-choose-scope)
* [Configure opt-in at touchpoint level](/docs/platform/opt-in/configure-opt-in-at-touchpoint-level)
# Set restrictions for a game (/docs/games/configure/set-restrictions-for-a-game)
A game's **Restrictions** tab caps how often it can be played, to keep it fair and protect the prize budget. A new game allows each player one play in all. Here, Lindenhall gives Bloom Reveal one card a day instead.
## Allow one play a day [#allow-one-play-a-day]
In the game's settings:
1. Select the **Restrictions** tab.
2. Turn off **Per Participant Limit**.
It's on at 1 in a new game, which allows one play in all.
3. Turn on **Daily Limit**.
4. Set **Allowed number per participant per day**.
5. Select **Save**.
Say the same limit in the game's details, so players know what to expect: "one card a day".
## Which limits each game offers [#which-limits-each-game-offers]
| Game | Limits |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Wheel of Fortune, Scratch Card, Reveal Card, Simple Game, 3D Selection, Memory, Gated Content | All six: **Total Participations**, **Global Daily Restriction** and **Global Weekly Restriction** for everyone together; **Per Participant Limit**, **Daily Limit** and **Weekly Limit** for each player |
| Quiz, Catcher, Form | **Per Participant Limit** only |
What each limit counts, and the rules between them: see [Restrictions](/docs/platform/touchpoints/restrictions).
## Next steps [#next-steps]
* [Restrictions](/docs/platform/touchpoints/restrictions)
* [Touchpoint dates override](/docs/platform/touchpoints/touchpoint-dates-override)
# Set the participation form for a game (/docs/games/configure/set-the-participation-form-for-a-game)
A participation form asks extra questions when someone takes part, such as their favourite shop. A game uses the campaign's form, unless the campaign is set to **Touchpoint Level**. Then each game has its own, on its **Terms & Conditions** tab.
## Choose where it's set [#choose-where-its-set]
| Scope | Where you set it | Use it when |
| -------------------- | ----------------------------------------- | ---------------------------------- |
| Off | Nowhere: no extra questions | The sign-up details are enough |
| **Campaign Level** | Once, in **Build** > **General** | Every game asks the same questions |
| **Touchpoint Level** | On each game's **Terms & Conditions** tab | One game needs its own questions |
The scope applies to the whole campaign: you can't change it for one game only. To turn questions into the game itself, with a prize for the winners, use a [Form](/docs/games/build-by-game-type/skill-based/form) game instead.
## Next steps [#next-steps]
* [About participation forms](/docs/platform/forms/participation-form/about-participation-forms)
* [Configure at touchpoint level](/docs/platform/forms/participation-form/configure-at-touchpoint-level)
# Get Started (/docs/games/get-started)
Choose a game, then publish your first one.
# Pick a game type (/docs/games/get-started/pick-a-game-type)
Every game starts from the same window in **Build** > **Touchpoints** > **Add New Touchpoint**. It also lists types from other apps, such as **Activity** for events.
## Luck-based: players find out straight away [#luck-based-players-find-out-straight-away]
| Game | Players | Page |
| --------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
| **Wheel of Fortune** | Spin a wheel | [Wheel of Fortune](/docs/games/build-by-game-type/luck-based/wheel-of-fortune) |
| **Scratch Game** | Scratch a card | [Scratch Card](/docs/games/build-by-game-type/luck-based/scratch-card) |
| **Reveal Game** | Tap a card to turn it over | [Reveal Card](/docs/games/build-by-game-type/luck-based/reveal-card) |
| **Simple Game** | Tap one button | [Simple Game](/docs/games/build-by-game-type/luck-based/simple-instant-win) |
| **3D Selection Game** | Pick one 3D object out of several | [3D Selection](/docs/games/build-by-game-type/luck-based/3d-selection-game) |
Each result comes from odds you set, or from prize moments spread over time. The Simple Game and 3D Selection can also run as a lottery, with winners drawn later. See [Configure outcomes](/docs/games/configure/configure-outcomes).
## Skill-based: the best-ranked players win [#skill-based-the-best-ranked-players-win]
| Game | Players | Ranked by | Page |
| ---------------- | --------------------- | ---------------- | ------------------------------------------------------------- |
| **Quiz** | Answer questions | Score, then time | [Quiz](/docs/games/build-by-game-type/skill-based/quiz) |
| **Memory Game** | Match pairs of cards | Time | [Memory](/docs/games/build-by-game-type/skill-based/memory) |
| **Catcher Game** | Catch falling items | Score, then time | [Catcher](/docs/games/build-by-game-type/skill-based/catcher) |
| **Form** | Answer your questions | Time | [Form](/docs/games/build-by-game-type/skill-based/form) |
You declare the winners from the **Leaderboard** when the game closes: see [Leaderboard rewards](/docs/platform/rewards/top-x).
## Access-based: players unlock something [#access-based-players-unlock-something]
| Game | Players | Page |
| ----------------- | ------------------------------------------ | -------------------------------------------------------------------------- |
| **Offers Pass** | Unlock offers from shops | [Offers Pass](/docs/games/build-by-game-type/access-based/offer-pass) |
| **Gated Content** | Sign up to open a guide, a video or a page | [Gated Content](/docs/games/build-by-game-type/access-based/gated-content) |
For one game a day in December, put instant games behind the doors of an [Advent Calendar](/docs/platform/landing-page/advent-calendar-layout).
## Next steps [#next-steps]
* [Your first game in 15 minutes](/docs/games/get-started/your-first-game-in-15-minutes)
* [Link rewards to a game](/docs/games/configure/link-rewards-to-a-game)
# Your first game in 15 minutes (/docs/games/get-started/your-first-game-in-15-minutes)
This tutorial adds a Simple Game to the campaign from [Your first campaign](/docs/platform/getting-started/your-first-campaign): players tap once and find out straight away if they won. Here, Lindenhall adds the Frosty Gift Box to Winter Wonderland, with a gingerbread star to win.
## Before you begin [#before-you-begin]
* A campaign open in **Build**: see [Your first campaign](/docs/platform/getting-started/your-first-campaign).
* A banner for the game and an icon for the prize:
## Add the game [#add-the-game]
1. In **Build**, select the **Touchpoints** tab.
2. Select **Add New Touchpoint**.
3. Select **Simple Game**.
4. Select **Create**.
5. On the new game, named **New Interactive Totem**, select **Configure**.
The card reads **INVALID** until you finish the steps below.
## Set up its page [#set-up-its-page]
6. Select the pencil next to the name.
7. Enter the game's name, then press Enter.
8. Under **Banner Image**, select **Select attachment**.
9. Choose the picture: point at it, then select **Select File**.
10. Select **Save**.
11. Replace the **Title** with the game's name.
12. Replace the **Subtitle**: say what players can win.
13. Under **Description**, write the rules: how often players can try, and where and when to collect the prize.
14. Select **Save**.
## Create the prize [#create-the-prize]
15. Select the **Rewards** tab.
16. Select **Add New Reward**.
17. Select **Create a Game Reward**.
18. Select **Luck Based Reward**.
19. Select **Create Luck Based Reward**.
20. On the new reward, select **Configure**.
21. Select the pencil next to the reward's name.
22. Enter the prize's name, then press Enter.
23. Open **Appearance**.
24. Under **Icon**, select **Select attachment**.
25. Choose the icon: point at it, then select **Select File**.
26. Select **Save**.
27. Replace the **Title** players see.
28. Replace the **Subtitle** with a line about the prize.
29. Open **Reward Quantity**.
30. Under **Quantity**, enter how many there are.
31. Open **User Messages**.
32. Under **Messages for Winners**, enter the **Title**: where to collect the prize.
33. Under **Subtitle**, add opening hours and any limit.
34. Select **Save**.
## Decide who wins [#decide-who-wins]
35. Select the **Touchpoints** tab.
36. On the game, select **Configure**.
37. Select the **Outcomes** tab.
38. Select **Random Chance**.
39. On **Winning Option 1**, set the **Probability**.
**Total Probability** turns red until the options add up to 100%.
40. Write the **Winning Option Message**.
41. Under **Link with a Luck Based Game Reward**, choose the prize.
42. Set the **Losing Outcome**'s **Probability**, so the total reaches 100%.
43. Write the **Losing Message**.
## Limit the tries [#limit-the-tries]
A new game allows one try per player in all. For one try a day, as the description promises:
44. Select the **Restrictions** tab.
45. Turn off **Per Participant Limit**.
46. Turn on **Daily Limit**.
**Allowed number per participant per day** stays at 1.
47. Select **Save**.
The game's card now reads **VALID**.
## Publish and play [#publish-and-play]
48. Select **Publish**.
49. In the window that reads **Campaign is ready to publish!**, select **Publish**.
50. Select the **Touchpoints** tab.
51. On the game, select **Preview**.
52. Scan the QR code with your phone, and play.
To see both results before launch, test on a copy of the campaign with the winning odds at 99%, then 1%: see [Test your game](/docs/games/launch/test-your-game).
## If publishing is blocked [#if-publishing-is-blocked]
* **The game is INVALID**: open it and fill in what the card's **See more** lists.
* **The odds don't add up to 100%**: see [Probability weights don't sum to 100](/docs/games/troubleshoot/probability-weights-dont-sum-to-100).
* **The reward has no title, or isn't linked**: check steps 27 and 41.
## Next steps [#next-steps]
* [Simple Game](/docs/games/build-by-game-type/luck-based/simple-instant-win)
* [Pick a game type](/docs/games/get-started/pick-a-game-type)
# Download the QR code for a game (/docs/games/launch/download-the-qr-code-for-a-game)
A game's QR code opens the game straight away, with no landing page first.
## Download it [#download-it]
1. In **Build** > **Touchpoints**, select **Share** on the game.
2. Select **Download PNG** or **Download SVG**.
The SVG stays sharp at any print size.
## Every game at once [#every-game-at-once]
The menu button (⋮) at the top of **Touchpoints** downloads a QR code for each game, or creates dynamic QR codes for a place in the centre: see [QR codes](/docs/platform/deployment/qr-codes).
If your plan includes Smart Links and a printed code may need to point somewhere else later, print a Smart Link's code instead: the link can change, the print can't. See [Smart Links](/docs/smart-links).
## Next steps [#next-steps]
* [Find the game URL](/docs/games/launch/find-the-game-url)
* [Embed or distribute a game](/docs/games/launch/embed-or-distribute-a-game)
# Embed or distribute a game (/docs/games/launch/embed-or-distribute-a-game)
A game can be reached in three ways. Choose by where people will be when they play.
| Way in | Use it when | See |
| ------------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
| The game's own link or QR code | One email, ad or display should open this game | [Find the game URL](/docs/games/launch/find-the-game-url) |
| The campaign link | Visitors should choose between several games on the landing page | [About landing pages](/docs/platform/landing-page/about-landing-pages) |
| Inside your website or app | The game should appear within a page you already run | [Embedding](/docs/platform/deployment/embedding) |
Embedding uses the same link as the game on its own, so it plays the same way. Your web or app team sets it up: point them to [About website integration](/docs/integrations/website-integration/about-website-integration).
## Next steps [#next-steps]
* [Download the QR code for a game](/docs/games/launch/download-the-qr-code-for-a-game)
* [Embedding](/docs/platform/deployment/embedding)
# Find the game URL (/docs/games/launch/find-the-game-url)
Every game has its own link: the campaign's link followed by `?c=` and the game's code. Share it directly: a game needs no landing page. The campaign link on its own opens the landing page, if the campaign has one.
## Copy a game's link [#copy-a-games-link]
1. In **Build** > **Touchpoints**, select **Share** on the game.
2. Copy the first link.
The next three add tracking for Facebook, Instagram and TikTok. The last field is the game's ID.
## Which link to share [#which-link-to-share]
| Link | Opens | Share it on |
| ----------------- | -------------------------------------------------- | --------------------------------------------------------- |
| Campaign link | The landing page, if there is one, with every game | A poster or an email about a campaign with several games |
| Game link (`?c=`) | The game, straight away | A QR code next to one display, or an email about one game |
## Next steps [#next-steps]
* [Download the QR code for a game](/docs/games/launch/download-the-qr-code-for-a-game)
* [Touchpoint URLs & slugs](/docs/platform/deployment/touchpoint-urls-and-slugs)
# Launch (/docs/games/launch)
Go live, check it on a phone, then hand out its link and QR code.
# Publish a game (/docs/games/launch/publish-a-game)
You publish the campaign, not one game: every game in it goes live together. The same goes for changes: they reach players only when you publish again.
## Publish the campaign [#publish-the-campaign]
1. In the campaign, select **Publish**.
2. In the window that reads **Campaign is ready to publish!**, select **Publish**.
**Campaign is ready to publish!** means nothing blocks it. Warnings and suggestions don't block publishing. An error does: fix it first, see [Validation & publishing](/docs/platform/campaigns/validation-and-publishing).
## Once it's live [#once-its-live]
* Visitors open each game from its link or QR code, or from the landing page if the campaign has one: see [Find the game URL](/docs/games/launch/find-the-game-url).
* Until the campaign's start date, each game shows the **Text Displayed Before Start Date** instead of **Play Now**.
* A game you turn off with **Disable Touchpoint**, in its card's menu, leaves the campaign the next time you publish.
To take the whole campaign offline, use **Unpublish Campaign**: see [Campaign lifecycle](/docs/platform/campaigns/campaign-lifecycle).
## Next steps [#next-steps]
* [Test your game](/docs/games/launch/test-your-game)
* [Download the QR code for a game](/docs/games/launch/download-the-qr-code-for-a-game)
# Test your game (/docs/games/launch/test-your-game)
Test the published game on the kind of phone your visitors use. Before the campaign's start date, each game shows your before-start message instead of **Play Now**, so nobody can play it yet, you included.
## Open the game on your phone [#open-the-game-on-your-phone]
1. In **Build** > **Touchpoints**, select **Preview** on the game.
2. Scan the QR code with your phone.
It opens the game's public link, the same one visitors use.
## Test before the start date [#test-before-the-start-date]
Duplicate the campaign, give the copy dates that start today, then publish and play the copy: see [Duplicate a campaign](/docs/platform/campaigns/duplicate-a-campaign-within-an-organisation). Players and results stay with each campaign, so your tests don't count on the real one.
## What to check [#what-to-check]
| Check | How |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Each result | On a copy of the campaign, set the winning odds to 99% and play, then to 1% and play again: each option needs at least 1%. The message, the picture and the prize should match what you set. |
| The limits | Play again: after the limit, the game should say so. |
| Sign-up and consent | Sign up with a new email address, and read the consent text. |
| Emails | Check the inbox and the spam folder after a win. |
| The way in | Open the game from its own link, and from the landing page if the campaign has one. |
## Next steps [#next-steps]
* [Publish a game](/docs/games/launch/publish-a-game)
* [Why didn't my reward distribute?](/docs/games/troubleshoot/why-didnt-my-reward-distribute)
# Game participation metrics (/docs/games/measure/game-participation-metrics)
Games report at campaign level, in **Analytics**, **Participants** and **Leaderboard**, not in the game's own settings. Here, Lindenhall checks the first days of its Spring Garden Games.
## Read the dashboard [#read-the-dashboard]
1. In the campaign, select **Analytics**.
2. Set the **Start Date**.
3. Set the **End Date**.
4. Select **Run**.
5. Under **Engagement**, read **Active Participants by Touchpoint**: the players of each game.
## Which number answers which question [#which-number-answers-which-question]
| Question | Where to read it |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| How many people played each game? | **Active Participants by Touchpoint**, in **Analytics** |
| How many won? | **Total Winners**, in **Analytics** |
| How many are waiting for a draw or a leaderboard? | **Total Eligibles**, in **Analytics** |
| Who won which prize? | **Participants** > **All Participants**, filtered by reward: see [Winner lists & exports](/docs/games/measure/winner-lists-and-exports) |
| Who ranks best in a skill game? | **Participants** > **Leaderboard**, then the game |
The **Leaderboard** ranks a Form or a Memory game by completion time, and a Quiz or a Catcher game by score, then time. Select the refresh button (**Refresh Rankings**) for the latest plays.
## Next steps [#next-steps]
* [Winner lists & exports](/docs/games/measure/winner-lists-and-exports)
* [Reward attribution tracking](/docs/games/measure/reward-attribution-tracking)
# Measure (/docs/games/measure)
Read who played, who won, and run your draws.
# Reward attribution tracking (/docs/games/measure/reward-attribution-tracking)
Each prize has a status per player: won, lost, or eligible while a draw or a leaderboard is pending. You follow it in **Participants**. Here, the Spring bouquet has three winners so far.
## Where each answer is [#where-each-answer-is]
| Question | Where to read it |
| -------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Who won a prize, and when? | **Participants** > **All Participants**, with **Filter by rewards**: the **Reward Status** column |
| Who will a draw pick from? | The counts in the **New Draw** window: see [Run a lottery draw](/docs/games/measure/run-a-lottery-draw) |
| Who leads a skill game? | **Participants** > **Leaderboard**, before you declare the winners |
| Was a coupon prize used? | The reward's **Manage Coupons**: see [Coupons and redemption](/docs/platform/rewards/coupons-and-redemption) |
For how many won, lost or are still waiting, see [Game participation metrics](/docs/games/measure/game-participation-metrics).
## How each prize type shows up [#how-each-prize-type-shows-up]
| Prize | Status after the play | Final status |
| ----------- | ------------------------------------- | ----------------------------------------------------------------------------------------- |
| Instant Win | **Winner** straight away, if they win | The same |
| Lottery | **Eligible** | **Winner** after the draw. With **Declare Losers**, the others are counted as non-winners |
| Leaderboard | **Eligible**, with a rank | **Winner** once you select **Declare Winners** |
**Eligible** isn't a problem to fix: the player is waiting for a draw or a ranking.
## Next steps [#next-steps]
* [Winner lists & exports](/docs/games/measure/winner-lists-and-exports)
* [Why didn't my reward distribute?](/docs/games/troubleshoot/why-didnt-my-reward-distribute)
# Run a lottery draw (/docs/games/measure/run-a-lottery-draw)
A lottery doesn't pick its winners by itself: you run the draw from **Participants**, once or several times. Here, Lindenhall draws the winner of the Winter hamper among the Winter Games Arcade players.
Before you begin, you need a lottery reward with entries: see [Lottery rewards](/docs/platform/rewards/draw-at-end).
## Open the draw [#open-the-draw]
1. In the campaign, select **Participants** > **All Participants**.
2. Select **Declare Lottery Winners**.
## Set up the draw [#set-up-the-draw]
3. Under **Reward**, choose the lottery.
4. Enter the **Number of Winners**.
5. Under **Declare Losers**, choose what happens to the other players.
**Don't Declare Losers** keeps them in for a later draw, with no email. **Declare Losers** takes them out and sends them the **Loser** email.
6. Under **Winner Eligibility**, choose **All Participants** or **By Timeframe**.
**By Timeframe** only draws players who entered between two dates, for a weekly draw for example.
7. Under **Winner Restrictions**, choose **Include Everyone** or **Exclude Previous Winners**.
**Exclude Previous Winners** leaves out players who already won a different reward.
8. Under **Draw Method**, choose **Equal Chance** or **Weighted Chance**.
With **Weighted Chance**, players who unlocked more touchpoints have better odds.
## Draw the winners [#draw-the-winners]
9. Select **Declare Lottery Winners**.
**Final Eligible Participants** is how many players the draw can pick from, once your settings are applied.
## What players see [#what-players-see]
* Winners see the reward's winner message, and get the **Winner** email if it's on.
* With **Declare Losers**, the others see the loser message, and get the **Loser** email if it's on.
* With **Don't Declare Losers**, the others stay entered and see no result yet.
## If something's blocked [#if-somethings-blocked]
* **The reward isn't in the list**: only lottery rewards appear. See [Lottery rewards](/docs/platform/rewards/draw-at-end).
* **Too few eligible players**: blacklisted contacts are always left out. Lower **Number of Winners**, or wait for more entries.
* **More winners than prizes**: keep **Number of Winners** at or under **Remaining Rewards**.
## Next steps [#next-steps]
* [Winner lists & exports](/docs/games/measure/winner-lists-and-exports)
* [Lottery rewards and draw mechanics](/docs/games/troubleshoot/lottery-rewards-and-draw-mechanics)
# Winner lists & exports (/docs/games/measure/winner-lists-and-exports)
Winners are listed in **Participants**, ranked in **Leaderboard** and summed up in **Analytics**. Each one exports its own file. Here, Lindenhall gets the Spring bouquet winners and the Garden Wish List ranking.
## Export a prize's winners [#export-a-prizes-winners]
In **Participants** > **All Participants**:
1. Open **Filter by rewards**.
2. Choose the prize.
The list shows that prize's players, with a **Reward Status** column.
3. Select **Download**.
4. Choose **All participants data**, or **Participation forms data** for the answers to your participation form.
## Export a skill game's ranking [#export-a-skill-games-ranking]
1. Select **Participants** > **Leaderboard**.
2. Choose the game.
3. Select **Download**.
When the game has closed, **Declare Winners** gives the leaderboard prize to the top players. Nobody holds it until you do: see [Leaderboard rewards](/docs/platform/rewards/top-x).
## Share a snapshot [#share-a-snapshot]
1. In **Analytics**, select **Run**.
2. Select **Export PDF**.
It's the dashboard as a PDF, not a list of winners.
## Good to know [#good-to-know]
* **Participation forms data** is one file when the form is set for the whole campaign, and a folder with one file per touchpoint when each game has its own.
* In that file, a checkbox answer reads **Yes** or **No**, and an uploaded picture reads **Image uploaded**.
* Coupon codes stay with the reward: see [Coupons and redemption](/docs/platform/rewards/coupons-and-redemption).
## Next steps [#next-steps]
* [Run a lottery draw](/docs/games/measure/run-a-lottery-draw)
* [Reward attribution tracking](/docs/games/measure/reward-attribution-tracking)
# Overview (/docs/games/overview)
What Games is, and when another app fits better.
# What is Games (/docs/games/overview/what-is-games)
Games adds playable moments to a campaign: a wheel to spin, a card to scratch, a quiz to answer. You build each game in Studio, and players open it on their phone, from the game's own link or from a landing page that presents several games.
## How a game works [#how-a-game-works]
* **One campaign can hold several games.** Here, Spring Garden Games has seven, from a scratch card to a planting guide.
* **Players sign up once**, then play each game. Their details come from the campaign's sign-up, consent and participation form.
* **Prizes come from rewards**, won by chance, drawn later, or given to the best-ranked players: see [Pick a game type](/docs/games/get-started/pick-a-game-type).
* **Each game has its limits and dates**, such as one play a day: see [Set restrictions for a game](/docs/games/configure/set-restrictions-for-a-game).
## When another app fits better [#when-another-app-fits-better]
| App | Use it for |
| -------------------------------------- | -------------------------------------------------- |
| Games | Quick play, instant wins, quizzes and leaderboards |
| [Events](/docs/events) | Workshops and sessions people book and check in to |
| [Treasure Hunts](/docs/treasure-hunts) | Finding objects or places around the centre |
| [Transactions](/docs/transactions) | Entries based on a purchase receipt |
## Next steps [#next-steps]
* [Pick a game type](/docs/games/get-started/pick-a-game-type)
* [Your first game in 15 minutes](/docs/games/get-started/your-first-game-in-15-minutes)
# Game validation errors (/docs/games/troubleshoot/game-validation-errors)
When a game blocks publishing, the check names the game in its message. Find the message below. For a blocker that isn't about a game, see [Validation & publishing](/docs/platform/campaigns/validation-and-publishing).
## The odds don't add up to 100% [#odds]
> Probability-based game '…' has invalid total probability weights: … (must equal 100)
**Why this happens**
* Under **Random Chance**, the winning options and the losing outcome share 100%.
**Fix it**
1. On the game's **Outcomes** tab, read **Total Probability** at the top.
2. Adjust each option's **Probability** until the total is 100%.
More in [Probability weights don't sum to 100](/docs/games/troubleshoot/probability-weights-dont-sum-to-100).
## Two winning options give the same prize [#shared-reward]
> Luck-based game '…' has duplicate rewards: … winning options share the same reward '…'
**Why this happens**
* Each winning option needs its own reward, so its stock and messages stay separate.
**Fix it**
1. Create a second reward in **Build** > **Rewards**: see [Create a reward](/docs/platform/rewards/create-a-reward).
2. Link it to the other winning option, under **Link with a Luck Based Game Reward**.
## A reward is unlimited in Scheduled Rewards [#unlimited-scheduled-rewards]
> Game '…' uses reward '…' which has unlimited quantity, but Scheduled Rewards mode requires specific quantities
**Why this happens**
* **Scheduled Rewards** spreads a set number of prizes over time, so it needs a number.
**Fix it**
1. Open the reward in **Build** > **Rewards** and set its quantity.
2. Or, on the game's **Outcomes** tab, switch to **Random Chance**.
## The game has no winning option [#wheel-no-winner]
> Luck-based game '…' has no winning option
**Why this happens**
* Every luck-based game needs at least one prize to give: the Wheel of Fortune, Scratch Card, Reveal Card, Simple Game and 3D Selection.
**Fix it**
1. On the game's **Outcomes** tab, select **Add Winning Option**.
2. Link a reward to it, and set its message and picture.
## The game has more than one losing outcome [#multiple-losing]
> Luck-based game '…' has … losing options
**Why this happens**
* A game has one losing outcome, or none, where every player wins. The editor shows only the first one, so a copied game can hide a second.
**Fix it**
1. Open the game's **Outcomes** tab.
2. Delete the losing outcomes you don't need, and keep one.
## Scheduled Rewards has no losing outcome [#scheduled-losing-count]
> Time-based game '…' requires exactly one losing option
**Why this happens**
* Between two winning moments, players land on the losing outcome, so **Scheduled Rewards** needs exactly one.
**Fix it**
1. On the game's **Outcomes** tab, add a losing outcome, or delete the extra one.
## The wheel's losing slice was renamed [#losing-renamed]
> Losing option … must be named 'Losing Option' (current: '…')
**Why this happens**
* The wheel recognises its losing slice by the name **Losing Option**.
**Fix it**
1. Open the wheel builder and rename the slice **Losing Option**: see [Wheel of Fortune](/docs/games/build-by-game-type/luck-based/wheel-of-fortune).
## The scratch card has no cover [#scratch-no-cover]
> Scratch game '…' is missing a covered image
**Fix it**
1. Build the card again with a **Front Image (Scratchable Cover)**: see [Scratch Card](/docs/games/build-by-game-type/luck-based/scratch-card).
## The 3D game has no objects [#3d-no-objects]
> 3D selection game '…' has invalid number of objects: …
**Fix it**
1. On the **Appearance** tab, set **Number of Objects** to 1 or more: see [3D Selection](/docs/games/build-by-game-type/luck-based/3d-selection-game).
## The reveal game has no options [#reveal-no-options]
> Reveal game '…' has no options configured
**Fix it**
1. On the **Outcomes** tab, add a winning option, then a losing outcome: see [Reveal Card](/docs/games/build-by-game-type/luck-based/reveal-card).
## The quiz shows more questions than it has [#quiz-too-many]
> Quiz game '…' question count (…) exceeds available questions (…)
**Fix it**
1. Lower the number of questions shown, or add questions: see [Quiz](/docs/games/build-by-game-type/skill-based/quiz).
## The form has no questions [#form-no-fields]
> Form game '…' has no form fields configured
**Fix it**
1. Under **Form Fields**, add at least one question: see [Form](/docs/games/build-by-game-type/skill-based/form).
## A skill game has no prize [#a-skill-game-has-no-prize]
This one is a warning, not an error: the game publishes, but nobody can win it. Add a reward under **Performance Rewards**: see [Link rewards to a game](/docs/games/configure/link-rewards-to-a-game).
## Still blocked? [#still-blocked]
Contact support with the campaign's name, the game's name and the exact message from the publish check.
## Next steps [#next-steps]
* [Why didn't my reward distribute?](/docs/games/troubleshoot/why-didnt-my-reward-distribute)
* [Validation & publishing](/docs/platform/campaigns/validation-and-publishing)
# Troubleshoot (/docs/games/troubleshoot)
Fix publishing errors and prizes that never arrived.
# Lottery rewards and draw mechanics (/docs/games/troubleshoot/lottery-rewards-and-draw-mechanics)
A lottery game enters players in a draw instead of giving a prize on the spot. You pick the winners later, from **Participants**, with **Declare Lottery Winners**.
## No winners appeared when the campaign ended [#automatic]
The draw doesn't run by itself. Open **Participants** and run it: see [Run a lottery draw](/docs/games/measure/run-a-lottery-draw).
## Players see an entry message, not a prize [#before-draw]
That's expected. After playing, the game shows its **Lottery Participation Message**, and the reward itself shows its eligible message until the draw. Turn on the **Eligible** email to confirm each entry automatically: see [Notification types](/docs/platform/notifications/notification-types-reference).
## Who gets which email [#notifications]
| Email | Sent when |
| ------------ | --------------------------------------------------- |
| **Eligible** | A player enters the draw |
| **Winner** | You declare them a winner |
| **Loser** | You choose **Declare Losers** when you run the draw |
## A player who played five times has one entry [#entries]
The draw picks participants, not plays. To give players who unlocked more touchpoints better odds, choose **Weighted Chance** as the **Draw Method** when you run the draw.
## Run another draw later [#rerun]
Run **Declare Lottery Winners** again, as long as the reward has quantity left. Players who already won this lottery are left out automatically. To also leave out players who won a different reward, choose **Exclude Previous Winners** under **Winner Restrictions**.
## The draw won't run [#draw-fails]
**Why this happens**
* Blacklisted contacts are always left out. If too few eligible players remain for **Number of Winners**, the draw stops instead of picking fewer.
**Fix it**
1. Lower **Number of Winners**, or wait for more entries.
2. Run the draw again.
## If publishing is blocked [#if-publishing-is-blocked]
* **The lottery reward has no eligible message**: write it in the reward.
* **The game has no participation message**: write it on its **Outcomes** tab, under **Lottery**.
* **The game has no lottery, or more than one**: link exactly one, made for this game.
## Next steps [#next-steps]
* [Run a lottery draw](/docs/games/measure/run-a-lottery-draw)
* [Lottery rewards](/docs/platform/rewards/draw-at-end)
# Probability weights don't sum to 100 (/docs/games/troubleshoot/probability-weights-dont-sum-to-100)
Under **Random Chance**, every option a player can land on shares 100%: each winning option and the losing outcome. The **Total Probability** bar turns red while they don't add up, and publishing stops.
The publish check says:
> Probability-based game '…' has invalid total probability weights: … (must equal 100)
## Why this happens [#why-this-happens]
* An option was added or removed, and the others weren't changed to make up the difference.
* The losing outcome's share was left out of the sum.
* An option's **Probability** is empty, or outside 1 to 100.
## Fix it [#fix-it]
1. In **Build** > **Touchpoints**, select **Configure** on the game.
2. Select the **Outcomes** tab.
3. Change each option's **Probability** until **Total Probability** reads 100%.
4. Select **Save**, then publish again.
For example, with 20 + 30 + 50 and a new option at 10, take the 10 from the others: 20 + 25 + 45 + 10.
## Other odds messages [#other-odds-messages]
| Message | Fix |
| ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| "… option … is missing probability weight" | Fill in that option's **Probability**. |
| "Advanced probability-based game … has no attribution slots defined" | With the **Advanced** type, give each winning option a slot: see [Attribution slots](/docs/platform/rewards/attribution-slots). |
| "… uses reward … which has unlimited quantity, but probability-based attribution slots define specific quantities" | Set a quantity on the reward, or use the **Simple** type. |
## Still blocked? [#still-blocked]
Contact support with the campaign's name, the game's name and the message from the publish check.
## Next steps [#next-steps]
* [Game validation errors](/docs/games/troubleshoot/game-validation-errors)
* [Configure outcomes](/docs/games/configure/configure-outcomes)
# Why didn't my reward distribute? (/docs/games/troubleshoot/why-didnt-my-reward-distribute)
A player played, and no prize came through. Find what they saw below, then the fix.
## The player was told they're in the draw [#not-instant]
**Why this happens**
* **Lottery** and **Leaderboard** rewards aren't handed out during play. Lottery players are **Eligible** until you run the draw; leaderboard winners wait until you declare them.
**Fix it**
1. For a lottery, run the draw from **Participants**: see [Run a lottery draw](/docs/games/measure/run-a-lottery-draw).
2. For a leaderboard, declare the winners from the **Leaderboard**: see [Leaderboard rewards](/docs/platform/rewards/top-x).
## A winning option gives nothing [#not-linked]
**Why this happens**
* The reward isn't linked to that winning option, or it's linked to a game that was deleted or replaced.
**Fix it**
1. On the game's **Outcomes** tab, check **Link with a Luck Based Game Reward** on each winning option.
2. Link the right reward: see [Link rewards to a game](/docs/games/configure/link-rewards-to-a-game).
## The game or the reward is turned off [#disabled]
**Fix it**
1. In **Build** > **Touchpoints**, check the game isn't disabled.
2. In **Build** > **Rewards**, check the reward is active.
3. Publish again.
## The prize ran out [#quantity]
**Why this happens**
* A reward without **Unlimited Quantity** has a set quantity. Once it's used up, winning options with that reward stop paying out.
**Fix it**
1. In **Build** > **Rewards**, raise the reward's quantity, then publish again.
## Nobody can win at this time of day [#schedule]
**Why this happens**
* Under **Scheduled Rewards**, prizes can only be won inside the reward's dates and attribution slots. Outside them, every player lands on the losing outcome.
**Fix it**
1. Compare the campaign's dates, the game's dates and the reward's slots: see [Attribution slots](/docs/platform/rewards/attribution-slots).
2. Test again inside a slot.
## The odds are wrong [#probability]
Under **Random Chance**, the odds must total 100%: see [Probability weights don't sum to 100](/docs/games/troubleshoot/probability-weights-dont-sum-to-100).
## One reward is shared between games [#sharing]
**Why this happens**
* A reward shared by games with different modes, or by two skill games, can't hand out cleanly.
**Fix it**
1. Create one reward per game, and link each one to its own game.
## Still stuck? [#still-stuck]
Play the game yourself with a new email address and note the time. Then contact support with the campaign, the game, the reward, the player's email and that time.
## Next steps [#next-steps]
* [Game validation errors](/docs/games/troubleshoot/game-validation-errors)
* [Reward attribution tracking](/docs/games/measure/reward-attribution-tracking)
# About custom domains (/docs/integrations/custom-domain/about-custom-domains)
By default, experiences are served from an address such as `lindenhall.topage.co`. With a custom domain, they're served from a subdomain you own, such as `play.lindenhall.example`. Only the address in front of the campaign path changes: you build campaigns the same way.
## What changes [#what-changes]
| | Default address | Your own address |
| ----------------------- | -------------------------- | ----------------------------------------------------------------- |
| What a participant sees | `lindenhall.topage.co/...` | `play.lindenhall.example/...` |
| Time to launch | Immediate | After your DNS records and OmniLab's setup |
| Who to involve | Nobody | Whoever manages your DNS |
| Cookies and analytics | A separate site from yours | The same parent domain as your site, if you use a subdomain of it |
## When you need one [#when-you-need-one]
If you embed experiences in your own website, a custom domain is required. A browser treats a frame from another domain as third-party and blocks or isolates its cookies, so participants can't stay signed in. Serve the experience from a subdomain of the site it appears on, and the frame counts as part of that site. See [Website integration](/docs/integrations/website-integration).
## When it's worth it anyway [#when-its-worth-it-anyway]
* **Printed placements.** A QR code on a poster or a receipt outlives the campaign. An address you own can be re-pointed later; one you don't own can't.
* **Trust at the point of scan.** A shopper reads the address before typing anything into a form.
* **Analytics and consent.** A subdomain of your main site keeps the experience under the same parent domain. That affects how your analytics and consent tools treat it.
For a short internal pilot or a one-off test, skip it: the default address launches immediately and costs nothing.
## What happens to the default address [#what-happens-to-the-default-address]
Adding a custom domain doesn't retire the default one. `lindenhall.topage.co` keeps serving the same campaigns, so nothing you've already published breaks. Once the new address is live, the links Studio shows for your campaigns use it.
A printed QR code, though, keeps whichever address it had on the day it went to print, long after the campaign ends. Choose the domain before anything physical is produced.
## What it involves [#what-it-involves]
You choose a subdomain, and your Customer Success Manager emails the DNS records to add. Whoever manages your DNS adds them: see [Set up the DNS records](/docs/integrations/custom-domain/set-up-the-dns-record).
## Next steps [#next-steps]
* [Set up the DNS records](/docs/integrations/custom-domain/set-up-the-dns-record)
* [Custom domain checklist](/docs/integrations/custom-domain/custom-domain-checklist)
* [Subdomain & custom domain](/docs/platform/deployment/subdomain-and-custom-domain)
# Custom domain checklist (/docs/integrations/custom-domain/custom-domain-checklist)
Run through this before the new address appears anywhere a participant can reach it. A problem found after the posters are printed costs you the print run.
## Check the address [#check-the-address]
* OmniLab has confirmed the address is live.
* The records resolve in a public lookup tool, not just in your own browser.
* The certificate validation record is still in place.
* The padlock shows with no certificate warning, in a fresh browser session.
* A published campaign opens on the new address and runs through to a reward.
* The default address still opens the same campaigns, so nothing already published breaks.
## Update everything that uses it [#update-everything-that-uses-it]
* **Embedded pages**: change the address in every page that embeds an experience. See [Website integration](/docs/integrations/website-integration).
* **Apps**: a WebView address ships inside an app release, so plan the app update.
* **QR codes**: download again any code that hasn't been printed yet.
* **Email templates**: check any fixed links in your email platform.
* **Customer sign-in**: if participants sign in with your own customer accounts, register the new callback address with your identity system. Your Customer Success Manager gives you the address.
## Before you print anything [#before-you-print-anything]
* Scan a test QR code with a real phone on mobile data, not office wi-fi.
* Check your corporate network filter doesn't block the address. It catches out in-store tablets and staff devices.
## Where to send a problem [#where-to-send-a-problem]
| What's happening | Contact first |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| The address doesn't resolve | Your DNS or IT team |
| A certificate warning won't clear | Your DNS team, to check the validation record, then your Customer Success Manager |
| The address resolves but the campaign won't load | OmniLab support |
| Participants can't stay signed in on an embedded page | Your web team: the page still frames the default address, which the browser treats as third-party. It must use your own subdomain. |
## Next steps [#next-steps]
* [Set up the DNS records](/docs/integrations/custom-domain/set-up-the-dns-record)
* [QR codes](/docs/platform/deployment/qr-codes)
# Custom domain (/docs/integrations/custom-domain)
Your IT team adds the DNS records OmniLab sends. Test before anything is printed.
# Set up the DNS records (/docs/integrations/custom-domain/set-up-the-dns-record)
This is a job for whoever manages your DNS, often IT rather than marketing.
## Before you begin [#before-you-begin]
* Agree the exact subdomain with your Customer Success Manager, for example `play.lindenhall.example`.
* Find out who can edit DNS for the domain, and how long approval takes. Approval, not the change itself, is what delays launches.
## Add the records [#add-the-records]
Your Customer Success Manager emails the records to add. There are usually two:
| Record | Type | Name | Value |
| ---------------------- | ----- | ---------------------------------- | -------------------------------------------------------- |
| Certificate validation | CNAME | As sent | As sent |
| Website routing | CNAME | Your subdomain, for example `play` | Your default address, for example `lindenhall.topage.co` |
If your subdomain is already covered by OmniLab's certificate, the email only has the routing record.
1. If a record already exists at the routing name, lower its TTL to 300–600 seconds.
2. Wait for the old TTL to expire, so a rollback takes minutes rather than a day.
3. Create each record exactly as sent.
4. Tell your Customer Success Manager the records are in place.
Keep the certificate validation record after launch. It renews the certificate automatically, and deleting it breaks the address when the certificate expires.
Use a subdomain, not your root domain. Most DNS providers can't host a CNAME at the root, and you don't want your main website pointing at OmniLab.
## Wait for OmniLab to finish [#wait-for-omnilab-to-finish]
OmniLab checks the records, completes the configuration and runs a final validation, then confirms the address is live. Records can take from minutes to 48 hours to appear everywhere, depending on your provider and the previous TTL.
## Test the address [#test-the-address]
1. Open a published campaign on the new address, in a normal browser.
2. Check the padlock shows, with no certificate warning.
3. Play the full journey through to a reward.
## If something's blocked [#if-somethings-blocked]
* **OmniLab says it can't see the records**: check them with a public DNS lookup tool. Your own browser and network may show an old result.
* **A certificate warning on the new address**: the validation record is missing or mistyped. Compare it with the email, character by character.
* **The address works for you but not for a colleague**: their network still holds the old value. Wait out the original TTL.
* **Your provider won't accept a CNAME at that name**: some providers refuse a CNAME where other records already exist at the same name. Use another subdomain, or ask your provider for their equivalent record type.
## Next steps [#next-steps]
* [Custom domain checklist](/docs/integrations/custom-domain/custom-domain-checklist)
* [About custom domains](/docs/integrations/custom-domain/about-custom-domains)
# About customer accounts (/docs/integrations/customer-accounts/about-customer-accounts)
Customer accounts, sometimes called CIAM (customer identity and access management), connect the accounts your customers hold with you to OmniLab. They're for participants, not your staff. For your team's sign-in to Studio, see [SSO for your team](/docs/integrations/sso).
## What the participant experiences [#what-the-participant-experiences]
The participant leaves the experience, signs in with the account they already have with you, and comes straight back to where they were. Done well, it feels like one continuous visit.
## What it enables [#what-it-enables]
* Participants are recognised across sessions and campaigns.
* Reward history, registration data and personalised content are tied to a known account.
* Participants don't re-enter their details every time.
## Where it's used [#where-its-used]
The most common use is an in-store kiosk. A loyalty member scans their card, and the kiosk passes the card's identifier into the experience. With a recognition integration for the campaign, they play as a recognised member instead of filling in a form. See [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience).
If your customer identity system isn't connected, an experience that expects a known member doesn't fall back to an anonymous session: the visitor is blocked. Check the connection works before you test any card-based journey.
## How it's set up [#how-its-set-up]
OmniLab builds and configures the connection to your customer identity system: your developers don't write it. Amazon Cognito user pools are supported as standard. Any other system is a project with OmniLab, so raise it early.
Your identity team provides:
| Detail | Notes |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| An application registered for OmniLab | With its client ID and client secret. Share the secret securely with your Customer Success Manager, never by plain email |
| Your sign-in address | The hosted sign-in domain participants are sent to |
| The user pool ID and region | For Amazon Cognito |
| The callback address, registered | Your Customer Success Manager gives it to you. With a [custom domain](/docs/integrations/custom-domain), register its version too |
| The member identifier | The value a kiosk or app passes as `fci`, such as a loyalty card number |
To start, tell your Customer Success Manager:
* which participant details OmniLab should know;
* which campaigns will use it;
* which systems hold that data today.
## Next steps [#next-steps]
* [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience)
* [Customer accounts checklist](/docs/integrations/customer-accounts/customer-accounts-checklist)
* [About SSO for your team](/docs/integrations/sso/about-sso-for-your-team)
# Customer accounts checklist (/docs/integrations/customer-accounts/customer-accounts-checklist)
An identity problem doesn't degrade gracefully: it stops people at the door. Run these tests on a staging campaign first.
## Test signing in [#test-signing-in]
* A known customer signs in inside an experience and is recognised.
* They come back later and are still recognised, without signing in again.
* A brand-new person can still take part. Check what happens when they aren't in your system yet.
* Sign-in works on a phone, which is where most participants are.
## Test passing a customer in [#test-passing-a-customer-in]
If you use `fci` from a kiosk, an app or a personalised link:
* A valid identifier opens your customer-account sign-in, or, with a recognition integration, opens the experience as that person with no sign-in screen.
* An invalid identifier shows a clear message, not a dead screen. Test it on purpose: it's the case staff get asked about.
* The message makes sense where it appears. "Contact support" is useless on an in-store kiosk.
## Test the data coming back [#test-the-data-coming-back]
* A test participation reaches your CRM or CDP.
* It matches the existing contact instead of creating a duplicate.
* The opt-in the participant gave is what arrives, not a default.
* Replay the same event and check your receiver doesn't write it twice.
## Where to send a problem [#where-to-send-a-problem]
| What's happening | Contact first |
| ------------------------------------ | ------------------------------------------------------------------------- |
| A known customer isn't recognised | Your identity team: check the identifier matches |
| Everyone is blocked at sign-in | Your identity team, then OmniLab support |
| Contacts arrive duplicated | Your team: the receiver must match on a stable identifier |
| Contacts don't arrive at all | Your team: check the receiver is reachable and the subscription is active |
| Opt-in is wrong on arriving contacts | Your marketing team, in Studio |
## Next steps [#next-steps]
* [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts)
* [Sync contacts to your systems](/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
* [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience)
# Customer accounts (/docs/integrations/customer-accounts)
Recognise returning participants, and get their activity into your CRM.
# Pass a known customer into an experience (/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience)
Add one parameter to the experience address: `fci`, carrying the identifier your own system uses for that person. It's how a scanned loyalty card, or a tap inside your app, skips the anonymous start.
## When to use it [#when-to-use-it]
* **A kiosk in-store**: a member scans their loyalty card, and the kiosk adds the scanned value to the address.
* **Your mobile app**: the app knows who's signed in, and passes them through.
* **A personalised email or link**: a message to a known contact opens the experience for that contact.
When you don't know who the visitor is yet, let them sign in inside the experience instead: see [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts).
## The address [#the-address]
```text
https://play.lindenhall.example/?fci=
```
Use whatever your own system identifies that person by, such as a loyalty card number, a member ID or a CRM contact ID.
## What it does [#what-it-does]
| Setup | What the visitor sees |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Customer accounts connected | The experience skips the anonymous start and opens your customer-account sign-in |
| Customer accounts connected, plus a recognition integration for the campaign | The identifier signs the customer in on its own: no sign-in screen |
A recognition integration is set up by OmniLab for each campaign. Ask your Customer Success Manager before you plan a kiosk or app journey that depends on an instant start.
If the visitor comes back without being signed in, a dialog they can't close says their account couldn't be found.
A value that isn't recognised never falls back to an anonymous session. Pass a made-up identifier, or scan a card that shouldn't work, and check what a customer sees when their card is mistyped, expired or from another programme. On a kiosk, that's the difference between a clear message and a dead screen.
## For your developer [#for-your-developer]
* `fci` forces a signed-in session, whatever the visitor's previous session was.
* Once signed in, the visitor's participation is attributed to that contact.
* `fci` keeps applying as the visitor moves through the experience.
## Reword the account-not-found dialog [#reword-the-account-not-found-dialog]
The dialog's default title is "Wrong Identity". Its text says "We couldn't find your user. Please close the page and try again." A kiosk visitor can't close the page. Tell them to try the kiosk again, or to ask a member of staff.
In each campaign that receives `fci`, follow [Change the wording](/docs/platform/forms/acquisition-form/set-up-an-acquisition-form#change-the-wording). Open the **Global** group. Edit `fci.identity.error.title` and `fci.identity.error.description`.
## Next steps [#next-steps]
* [Kiosk URLs](/docs/integrations/kiosk-integration/url-structure)
* [Experience URLs for an app](/docs/integrations/mobile-app-integration/experience-urls-for-an-app)
* [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts)
# Sync contacts to your systems (/docs/integrations/customer-accounts/sync-contacts-to-your-systems)
A sign-up in a campaign can become a contact in your CRM without anyone exporting a spreadsheet.
## Choose how data leaves OmniLab [#choose-how-data-leaves-omnilab]
| Method | Good for | Timing |
| -------- | -------------------------------------------------------------------- | ------------- |
| Webhooks | Keeping another system continuously up to date | As it happens |
| Exports | One-off analysis, or a hand-off to a team that works in spreadsheets | On demand |
Most contact sync uses webhooks: OmniLab notifies your systems as participants act, and your side decides what to do with each event. See [About webhooks](/docs/developers/webhooks/about-webhooks).
## What you can send [#what-you-can-send]
Participation, rewards won and redeemed, bookings and, if your plan includes Smart Links, Smart Link activity all raise events you can subscribe to. The full list is in the [webhook event reference](/docs/developers/webhooks/webhook-event-reference).
Subscribe only to what the receiving system uses. A CRM that only needs new contacts doesn't need every page visit, and filtering at the subscription is cheaper than filtering after delivery.
## Match to an existing contact [#match-to-an-existing-contact]
To update someone who's already in your CRM, you need a value that identifies them in both places. If participants sign in with an account you manage, you already have it: it's the identifier you pass in as `fci`. See [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience).
Without it, matching falls back to the email address, which is unreliable for anyone with more than one.
A contact created from a campaign carries the opt-in the participant gave in that campaign. Make sure the receiving system stores it, rather than treating every arriving contact as marketable. See [About opt-in](/docs/platform/opt-in/about-opt-in-in-omnilab).
## For your developer [#for-your-developer]
* Keep the receiver separate from the CRM writes, so a slow CRM doesn't cause failed deliveries.
* Verify the signature before you process an event.
* Store each delivery ID and skip repeats. Deliveries are retried, so the same event can arrive twice, and preventing a duplicate write is up to you.
* Subscribe only to the event types the destination uses.
## Next steps [#next-steps]
* [Configure webhooks](/docs/developers/webhooks/configure-webhooks)
* [Customer accounts checklist](/docs/integrations/customer-accounts/customer-accounts-checklist)
* [Exports and reports](/docs/platform/analytics-fundamentals/exports-and-reports)
# About email triggers (/docs/integrations/email-triggers/about-email-triggers)
A triggered email goes out the moment a participant does something, such as signing up, winning a prize or booking a slot. How much setup you need depends on which kind of email it is.
## Two kinds of email [#two-kinds-of-email]
| | Campaign notifications | Emails through your own platform |
| ---------- | ------------------------------------------------- | ----------------------------------------------------------------- |
| Sent by | OmniLab, through the campaign's email add-on | Your own email platform |
| Looks like | A notification styled to your campaign | Everything else you send |
| Set up in | The campaign, in **Build** > **Notifications** | A connection plus a small piece of code |
| Use it for | Confirmations, reward messages, booking reminders | Emails that must match your others, or start your own automations |
For a confirmation or a reward message, campaign notifications are enough. They need Salesforce Marketing Cloud or Cheetah switched on in the campaign's add-ons: see [About notifications](/docs/platform/notifications/about-notifications#before-emails-can-go-out). The other Email triggers pages cover the second kind.
## When to use your own platform [#when-to-use-your-own-platform]
* **Deliverability matters.** Your platform already has a warmed-up sending domain and a good reputation.
* **The message must match your other emails.** Same template, same footer, same unsubscribe link.
* **The send should start your existing automations.** A sign-up should start a welcome series you already run.
* **The record should be where your team looks.** Your support staff check your platform, not Studio.
## How the two sides split the work [#how-the-two-sides-split-the-work]
OmniLab decides *when* an email should go out, and passes on the details. Your platform decides how it looks and delivers it. A small piece of code, a Function, joins the two by calling your platform's API.
Your own system can also send the email itself, from a webhook: see [Transactional emails](/docs/integrations/email-triggers/transactional-emails). Confirming an address with [double opt-in](/docs/integrations/email-triggers/double-opt-in-email-verification) always needs a Function.
Someone technical writes it once per email platform, and it then serves every campaign in the organisation. Marketing teams don't touch it again unless the platform changes. See [About Functions](/docs/developers/functions/about-functions).
## Next steps [#next-steps]
* [Connect your email platform](/docs/integrations/email-triggers/connect-your-email-platform)
* [Transactional emails](/docs/integrations/email-triggers/transactional-emails)
* [Double opt-in email verification](/docs/integrations/email-triggers/double-opt-in-email-verification)
# Connect your email platform (/docs/integrations/email-triggers/connect-your-email-platform)
Do this once for your account: the key lives in the Global organisation and serves every organisation. Triggered messages then arrive from your sending domain and show up in your existing reporting.
## Before you begin [#before-you-begin]
* You need **Admin** access to reach the settings where this is done.
* Choose the platform that sends: Brevo, Mailchimp, Salesforce Marketing Cloud, or another service with an API.
* Have someone technical available: the connection is a Function, which is code.
## Store the key as a secret [#store-the-key-as-a-secret]
In your email platform, create an API key that can only send transactional messages. A narrow key limits what a leak could do.
Then, in the organisation switcher, choose **Global**, and select **General Settings**:
1. Select the **Functions** tab.
2. Select **New secret**.
3. Enter a **Key**, here `brevo`. You can't change it later.
It must start with a letter, followed by lowercase letters, digits or underscores.
4. Enter a **Display name**, here `Brevo Production`.
5. Select **Create bundle**.
## Add the key to the bundle [#add-the-key-to-the-bundle]
The bundle opens once it's created.
1. Select **Add secret**.
2. Enter `API_KEY` as the key.
3. Enter your platform's API key as the value.
4. Select **Save secrets**.
Values are hidden once saved: a Function can use them, but nobody can read them back.
## Hand over to your developer [#hand-over-to-your-developer]
Your developer binds the bundle to a Function, on the Function's **Settings** tab, and writes the code that calls your platform's send API: see [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets#bind-a-bundle-to-a-function). What runs the Function depends on the email: an event such as a won reward for [transactional emails](/docs/integrations/email-triggers/transactional-emails), or the **contact.verify** hook for [double opt-in](/docs/integrations/email-triggers/double-opt-in-email-verification).
## Send yourself a test [#send-yourself-a-test]
1. Trigger the action in a test campaign.
2. Check the message arrives from the right sender, with the right template.
3. Check it appears in your platform's own sending log.
4. Check your spam folder. A new integration's first sends are often filtered.
## If something's blocked [#if-somethings-blocked]
* **The Function runs but nothing arrives**: a successful run means your platform accepted the request, not that it delivered the message. Check your platform's delivery log next.
* **Messages arrive from the wrong sender**: the sender is set in your platform or in the Function's payload, not in Studio.
* **It works in staging but not in production**: each environment has its own bundles. Check the bundle exists in production, with the same key.
## Next steps [#next-steps]
* [Transactional emails](/docs/integrations/email-triggers/transactional-emails)
* [Double opt-in email verification](/docs/integrations/email-triggers/double-opt-in-email-verification)
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
# Double opt-in email verification (/docs/integrations/email-triggers/double-opt-in-email-verification)
Double opt-in keeps your contact list clean and gives you a record of consent. OmniLab doesn't send the confirmation email itself. A Function you write sends it through your email service provider (ESP), such as Brevo.
## What the participant goes through [#what-the-participant-goes-through]
* **The link works once.** A second click says it's already been used and sends the participant back to the campaign. There, they sign in with a code sent to their email. Email security scanners and link previews that open the link first don't use it up.
* **An expired link** shows that it's no longer valid, and asks the participant to sign up again on the campaign page. That sends a new email.
* **Opening the email on another device is normal.** On the device they signed up on, **I've verified my email** on the waiting screen moves them on. On another device, they're asked for a code instead. Both end in the same place.
## Before you begin [#before-you-begin]
* You need **Admin** access to bind the Function and turn on the setting.
* The Function shows **Ready** on the **Functions** tab: your developer has built it. A binding to a Draft Function passes the publish check, but every sign-up then fails.
* Store your ESP's API key as a secret: see [Connect your email platform](/docs/integrations/email-triggers/connect-your-email-platform).
Once double opt-in is on for an organisation, publishing any campaign there is blocked until an active binding to **contact.verify** covers it. That includes campaigns built by colleagues who never touched the setting.
## Bind the Function to contact.verify [#bind-the-function-to-contactverify]
Your developer first creates a Hook function for **contact.verify**, here **Lindenhall double opt-in**: see [Hook functions](/docs/developers/functions/hook-functions). It sends the single-use link through your ESP, or reports the contact as already verified when your own records prove it.
Then, in the organisation switcher, choose **Global**, and select **General Settings**:
1. Select the **Functions** tab.
2. On the Function, select **Edit**.
3. Select the **Hooks** tab.
4. Select **Add binding**.
5. Under **Target groups**, select **Lindenhall Shopping Centre**.
6. Select **Create**.
The binding is active straight away. To cover every organisation, turn on **Global (all groups)** instead of choosing groups. An organisation that no binding covers stays blocked.
## Turn on double opt-in [#turn-on-double-opt-in]
Choose the organisation in the switcher, then select **Organization Settings**. For the default of every organisation, use Global's **General Settings** instead. Then:
1. Select the **Authentication** tab.
2. Set **Double opt-in for passwordless sign-up** to **Enabled**.
3. Select **Save**.
Then publish each campaign in the organisation again. Until then, it keeps its current behaviour.
An organisation set to **Inherit** follows the **General Settings** default. **Enabled** or **Disabled** on the organisation overrides it.
## Check a sign-up is held [#check-a-sign-up-is-held]
1. Sign up with a test contact.
2. In the campaign, open **Participants** > **All Participants**, and check the contact isn't listed.
3. Open the link, take part, and check the contact is listed now.
4. On the Function's **Executions** tab, check which of the two paths ran.
Until the link is opened, a held sign-up creates no contact, so the campaign doesn't count it as a participant.
## Change the waiting screen wording [#change-the-waiting-screen-wording]
The waiting screen is already translated into every language OmniLab supports. To reword it, follow [Change the wording](/docs/platform/forms/acquisition-form/set-up-an-acquisition-form#change-the-wording) and edit the `passwordless.verification…` keys.
The confirmation page opens in the campaign's default language, or in English if the campaign has none.
## If something's blocked [#if-somethings-blocked]
* **A campaign won't publish, or sign-ups fail straight away**: double opt-in is on for the organisation, but no active **contact.verify** binding covers it. Bind one, or set double opt-in to **Disabled** for that organisation. Both need Admin access, so a campaign builder has to ask an Admin. See [Validation & publishing](/docs/platform/campaigns/validation-and-publishing).
* **The validation error says the binding couldn't be checked**: the check didn't complete this time, which isn't the same as a missing binding. Run validation again, and contact support if it keeps happening.
* **A participant says the email never arrived**: open the Function's **Executions** tab within 24 hours of the sign-up, before its record expires. A successful run only means your ESP accepted the request. Check the ESP's delivery log next.
* **Sign-ups succeed at once for everyone, with no email sent**: your Function returns **already verified** on every run, not only when your records support it. Check its logic.
## Next steps [#next-steps]
* [Hook functions](/docs/developers/functions/hook-functions)
* [Configure runtime and secrets](/docs/developers/functions/configure-runtime-and-secrets)
* [Acquisition form reference](/docs/platform/forms/acquisition-form/acquisition-form-reference)
# Email triggers (/docs/integrations/email-triggers)
Confirm sign-ups, wins and bookings from the platform you already use.
# Transactional emails (/docs/integrations/email-triggers/transactional-emails)
A transactional email goes to one person at one moment, because of something they did. A marketing email goes to a whole list.
The campaign's own notifications already cover participation, winners and booking confirmations, reminders and cancellations, with no code: see [Two kinds of email](/docs/integrations/email-triggers/about-email-triggers#two-kinds-of-email). This page is for emails your own platform sends.
## What can trigger your own email [#what-can-trigger-your-own-email]
| Moment | Event | Connect it with |
| ------------------------------------ | -------------------------------------------------------------------- | -------------------------------------- |
| A participant signs up | `contact.created.v1` | A platform event Function or a webhook |
| A participant wins a reward | `reward.won.v1` | A platform event Function or a webhook |
| A participant completes a touchpoint | `touchpoint.completed.v1` | A platform event Function or a webhook |
| A booking is confirmed | `ticket.confirmed.v1` | A webhook |
| A reminder before a booking | No event: schedule it in your platform, from the booking it received | Your platform |
A Function sends the email from OmniLab, through your platform's API: see [Platform event functions](/docs/developers/functions/platform-event-functions). A webhook sends the event to your own system, which sends the email: see [Configure webhooks](/docs/developers/webhooks/configure-webhooks).
## Decide before your developer starts [#decide-before-your-developer-starts]
* Which moments trigger an email, from the table above.
* Which platform sends them: your email service provider, your CRM or a dedicated transactional tool.
* What each email shows: the participant's name, the event details, the reward description.
* Who designs and writes the email templates.
## Next steps [#next-steps]
* [Connect your email platform](/docs/integrations/email-triggers/connect-your-email-platform)
* [Platform event functions](/docs/developers/functions/platform-event-functions)
* [Configure webhooks](/docs/developers/webhooks/configure-webhooks)
# About kiosk integration (/docs/integrations/kiosk-integration/about-kiosk-integration)
Visitors scan a card or a voucher at an in-store kiosk to start playing. Three parties are involved: you, OmniLab and the company that supplies or builds the kiosk. Agree who does what before any build starts: an unclear split causes most of the delays on these projects.
## How a kiosk session works [#how-a-kiosk-session-works]
The kiosk stays in charge. It shows your welcome screen, reads the barcode, then opens the experience full screen, in a WebView or a frame. Everything inside it comes from OmniLab: the game, the form, the branding, the reward. Everything around it belongs to the kiosk.
## Two ways to start a session [#two-ways-to-start-a-session]
Your choice changes what the visitor does at the screen, and what you print on the barcode.
| Approach | The visitor scans | What happens next | Use it when |
| --------------- | ------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Known member | Their loyalty card | The member signs in with their customer account, or goes straight in with a recognition integration | You already run a loyalty programme and want little friction at the screen |
| New participant | A printed invitation or voucher | The visitor fills in the acquisition form on the kiosk, then plays | You're recruiting new contacts and want the visit to produce a sign-up |
The known-member route needs your customer identity system connected to OmniLab first: see [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts). Without a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does) for the campaign, members still see a sign-in screen. For the exact addresses, see [Kiosk URLs](/docs/integrations/kiosk-integration/url-structure).
## Who owns what [#who-owns-what]
Share this table with your kiosk provider at the first technical meeting.
| Responsibility | Owner |
| ----------------------------------------------------------------------- | --------------------------------------------------- |
| Welcome screen design and build | Kiosk provider |
| Barcode scanner and the values it reads | Kiosk provider |
| WebView or frame that loads the experience | Kiosk provider |
| Building the experience address and adding the scanned value | Kiosk provider |
| On-screen keyboard for form entry | Kiosk provider |
| The "Are you still there?" message and the return to the welcome screen | Kiosk provider |
| The idle script that notices a visitor has walked away | Kiosk provider writes it; your Studio admin adds it |
| The experience itself: game, form, branding, reward | OmniLab |
| Campaign configuration, rewards and prize stock | You, in Studio |
| Barcode stock: which cards or vouchers are printed and handed out | You |
| Staff supervision and prize handover in-store | You |
In short: OmniLab owns the experience, and your kiosk provider everything around it, plus the idle script. You own the campaign and the barcodes.
## Before you brief a provider [#before-you-brief-a-provider]
* The campaign is built and published in Studio, so there's a real address to test.
* You've chosen one of the two start routes, and decided the barcode values.
* If members should skip the sign-in, you've asked your Customer Success Manager for a recognition integration.
* Rewards and prize stock are set up, because they change what the visitor sees at the end.
* Your venue IT team knows the kiosk needs internet access: see the network points in [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements#network).
## Next steps [#next-steps]
* [Kiosk URLs](/docs/integrations/kiosk-integration/url-structure)
* [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements)
* [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell)
# Build the kiosk shell (/docs/integrations/kiosk-integration/build-the-kiosk-shell)
For the developer building the kiosk application. The experience is OmniLab's; the screen, the scanner and everything around the experience are yours.
## Choose how the kiosk loads the experience [#choose-how-the-kiosk-loads-the-experience]
| Setup | How it works | Use it when |
| --------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| A WebView | The kiosk app's WebView opens the experience address as its page | Your kiosk runs an app that controls the screen. It's the simpler setup, and the one to prefer. |
| A frame in a kiosk web page | Your page holds an iframe, written by hand or by the JavaScript tag | Your kiosk runs a browser on a page you host |
In a frame, sign-in only works when the experience is served from a subdomain of the kiosk page's domain. For a page at `kiosk.lindenhall.example`, serve the experience from `play.lindenhall.example`: see [Custom domain](/docs/integrations/custom-domain). From any other domain, the browser treats the frame as third-party and blocks its cookies. A scanned loyalty card then stops at the identity check, every time, so the card values look wrong rather than the setup. The JavaScript tag writes the same kind of frame, so the rule applies to it too.
In a frame, avoid touchpoints that ask the visitor to sign in on screen. Through the JavaScript tag, that sign-in replaces your whole kiosk page. In a frame you write yourself, it runs inside the frame, and some identity providers refuse to load there: check on a device.
## Kiosk URLs and parameters [#kiosk-urls-and-parameters]
Start from the touchpoint link in Studio. Add `l` with the kiosk's default language, then one parameter for each session:
| Session | Add | Example |
| ------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------ |
| A member scans a loyalty card | `l`, then `fci=` and the scanned value | `https://play.lindenhall.example/lindenhall-autumn-kiosk?c=a315&l=en&fci=6345789012` |
| A visitor starts without a card | `l`, then `login=0` | `https://play.lindenhall.example/lindenhall-autumn-kiosk?c=a315&l=en&login=0` |
`l` matters on a shared screen. The experience remembers a visitor's language choice on the device for 30 days, so without `l` the next visitor inherits it.
`login=0` signs out whoever used the kiosk before. Without it, the next visitor can carry on as the last one. A new `fci` scan always starts a fresh sign-in, so it needs no `login=0`.
Leave `embedded=1` out, in a WebView and in a frame fixed to the screen. It's meant for a page that grows the frame with the experience and scrolls to each panel. The JavaScript tag does both, and adds `embedded=1` itself. Add it anywhere else and panels open off screen once the visitor scrolls a long page.
To fix the variant, add `v`. The full list is in [Embed URLs and parameters](/docs/integrations/website-integration/embed-urls-and-parameters#parameters).
## Load different campaigns from one kiosk page [#load-different-campaigns-from-one-kiosk-page]
The JavaScript tag lets one kiosk page load whichever campaign its own address names. It adds `embedded=1`, and `login=0` by default, so leave both out of `id`. It also sizes the frame to the experience and scrolls your page to each panel. It gives its frame no camera or location access, so use it only for experiences that need neither.
```html title="Kiosk page with the JavaScript tag"
```
The kiosk page's own address then picks the campaign:
* `https://kiosk.lindenhall.example/?id=lindenhall-autumn-kiosk` opens the landing page, if the campaign has one.
* `https://kiosk.lindenhall.example/?id=lindenhall-autumn-kiosk%3Fc%3Da315%26l%3Den` opens one touchpoint, in English.
URL-encode the whole value of `id` when it holds more than one parameter. The Staging script address and every option are in the [JavaScript tag reference](/docs/integrations/website-integration/javascript-tag-reference).
### Start and end a session through the tag [#start-and-end-a-session-through-the-tag]
The tag reads `id` once, when the kiosk page loads. So start each session by loading the kiosk page with that session's `id`, and end it by loading the page without one:
```js title="Start and end sessions on the kiosk page"
const KIOSK_PAGE = "https://kiosk.lindenhall.example/";
const TOUCHPOINT = "lindenhall-autumn-kiosk?c=a315&l=en";
function startSession(scannedValue) {
const experience = scannedValue ? TOUCHPOINT + "&fci=" + scannedValue : TOUCHPOINT;
window.location.assign(KIOSK_PAGE + "?id=" + encodeURIComponent(experience));
}
function endSession() {
window.location.assign(KIOSK_PAGE);
}
```
Without `id`, the tag loads nothing, so the page shows your welcome screen. A session without a card gets the tag's `login=0`, so it starts signed out.
Scanned values made of letters, digits, hyphens and dots pass through `id` intact. A value holding `&`, `#`, `%` or `+` arrives cut short or changed: for those, write the frame yourself, as in the [kiosk page below](#receive-the-signal-in-a-kiosk-web-page).
The idle modal and `message` listener of the [kiosk page below](#receive-the-signal-in-a-kiosk-web-page) work here unchanged, with `endSession()` as written above. Set `inSession` to `true` when the page's address has an `id`.
## Start a session as a known member with `fci` [#start-a-session-as-a-known-member-with-fci]
Add the scanned value as `fci`, percent-encoded. What the visitor sees, and how to reword the dialog for an unknown card, is in [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience).
Two things to settle before kiosk testing:
* **The recognition integration.** Members skip the sign-in screen only with a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does), set up for each campaign. Ask your Customer Success Manager to confirm it works with your identity system, in the environment you test.
* **The addresses sign-in passes through.** Sign-in briefly leaves the experience domain. A WebView that only opens approved addresses must allow those too: see [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview#keep-sign-in-inside-the-webview).
For anonymous sessions, start with `login=0` and put an acquisition form on the touchpoint. The visitor completes it on the kiosk keyboard before playing.
## Layout messages [#layout-messages]
In a frame, the experience posts its height to your page. A frame fixed to the screen, like the sample below, can ignore it. Games fill the frame, a longer page scrolls inside it, and panels open on screen, as in a phone's browser.
A kiosk page that grows the frame with the experience, and scrolls, needs `embedded=1` and every message in [Messages between the page and the experience](/docs/integrations/website-integration/javascript-tag-reference#messages-between-the-page-and-the-experience). The JavaScript tag handles them for you. In a WebView, there's no page around the experience, so there's nothing to handle.
## Detect an abandoned session with `omnilab:idle` [#detect-an-abandoned-session-with-omnilabidle]
A kiosk left mid-game blocks the next visitor. A small script inside the experience notices that nobody has touched the screen, and tells the kiosk. The kiosk then offers to reset.
| Side | What it does | Who builds it |
| --------------------- | ------------------------------------------------------ | -------------------------------------------------- |
| Inside the experience | Watches for activity and sends `omnilab:idle` | You write the script. A Studio admin adds it once. |
| Around the experience | Receives the signal, shows the modal, ends the session | You, in the kiosk application |
Neither half is built in. Both live in your codebase, and you build, test and maintain them.
### Add the idle script [#add-the-idle-script]
```html title="Idle detection, scoped to kiosk campaigns"
```
Behaviour to know before you build the kiosk side:
* **The campaign list matches the path.** Each entry is a campaign's link after the domain, such as `/lindenhall-autumn-kiosk`. On any other campaign, the script returns at once.
* **The signal repeats.** An abandoned kiosk sends `omnilab:idle` every 10 seconds, not once.
* **Only interaction inside the experience counts.** A tap on your own modal doesn't reset this timer.
* **It runs wherever a listed campaign opens.** On a phone, nothing listens for the signal, so nothing happens.
Give the code to a Studio admin. They add it in the **Global Organization**'s **Scripts** tab, with **Script Context** `ALL` and **Trigger Event** `VISIT`: see [Add a script](/docs/integrations/tags-and-scripts/add-a-script). `ALL` runs it in a WebView as well as in a frame; the campaign list keeps it off every other campaign. Every campaign it covers must then be published again.
### Receive the signal in a kiosk web page [#receive-the-signal-in-a-kiosk-web-page]
This page is a complete kiosk shell for the frame setup. It shows a welcome screen, starts a session on a scan or a tap, and runs the modal. Its **Home** button ends the session at any moment. Replace the address with your touchpoint link.
```html title="Kiosk page with the idle modal"
Lindenhall kiosk
Scan your card, or touch the screen to start
Are you still there?
Returning home in seconds
```
What each part guards against:
* **The origin check.** A shell that acts on every message acts on messages from any page the frame loads.
* **The `modal.hidden` check.** Without it, each repeated signal restarts the countdown, and the modal never times out.
* **The grace period.** Without it, the modal comes back within 10 seconds of **Stay**.
* **`about:blank` on the way home.** The experience can't unload itself. If the kiosk doesn't act on the signal, nothing happens.
* **The Home button.** It has its own bar below the frame, so it never covers the experience. It ends the session the same way the countdown does.
### Receive the signal in a WebView [#receive-the-signal-in-a-webview]
In a WebView, the experience is the top-level page, so `window.parent` is the page itself. The signal lands on the experience's own window. Inject this script into every page the WebView loads, to pass the signal on to your app:
```js title="Injected into the experience page by the kiosk app"
(function () {
if (window.__omnilabIdleBridge) return;
window.__omnilabIdleBridge = true;
function forward(message) {
const json = JSON.stringify(message);
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(json); // React Native
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.omnilab) {
window.webkit.messageHandlers.omnilab.postMessage(json); // iOS WKWebView
} else if (window.OmnilabBridge) {
window.OmnilabBridge.postMessage(json); // Android WebView
}
}
window.addEventListener("message", (event) => {
if (event.source !== window || event.origin !== window.location.origin) return;
if (event.data && event.data.type === "omnilab:idle") forward(event.data);
});
})();
true;
```
| Platform | Inject the script with | Receive the signal with |
| ----------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| React Native | The `injectedJavaScript` prop | `onMessage`, in `event.nativeEvent.data` |
| iOS `WKWebView` | A `WKUserScript` at document end, main frame only | A `WKScriptMessageHandler` added under the name `omnilab` |
| Android `WebView` | `evaluateJavascript`, in `onPageFinished` | `addJavascriptInterface` under the name `OmnilabBridge`, with a `@JavascriptInterface` method `postMessage(String)` |
Build the modal and the **Home** button natively, with the same rules as the kiosk page above: one countdown at a time, a grace period after **Stay**, and **Return home**. To end the session, replace the experience with your welcome screen. Start the next one by loading the address again, with `l`, then `fci` or `login=0`.
## What else the kiosk application handles [#what-else-the-kiosk-application-handles]
* Device permissions for the camera and location, if the experience uses them.
* Scanner hardware and other peripherals.
* Logging and remote support diagnostics for the kiosk itself.
* Any server-to-server API access, from your own backend. Never put OmniLab client credentials in the kiosk page or the WebView bundle.
## Next steps [#next-steps]
* [Kiosk implementation checklist](/docs/integrations/kiosk-integration/kiosk-implementation-checklist)
* [Inactivity management](/docs/integrations/kiosk-integration/inactivity-management)
* [JavaScript tag reference](/docs/integrations/website-integration/javascript-tag-reference)
# Inactivity management (/docs/integrations/kiosk-integration/inactivity-management)
Without it, an abandoned kiosk stays on the last visitor's screen until a member of staff notices. The work comes in two halves. A script inside the experience notices that nobody is touching the screen, and says so. Your kiosk software decides what to do, because only the kiosk can close the session and go back to its welcome screen.
## How the reset happens [#how-the-reset-happens]
After 10 seconds with no touches, taps or scrolling, the script signals the kiosk. The kiosk then asks whether the visitor is still there, with a visible countdown. Touching **Stay** closes the message, and the visitor carries on where they left off. If nobody answers in time, the kiosk closes the experience and returns to its welcome screen.
With a 10-second countdown, an abandoned kiosk frees itself in about 20 seconds. Make the countdown long enough that someone reading the screen, or thinking about a quiz answer, isn't thrown out mid-game.
The message is yours to design; OmniLab has no control over how it looks. See it beside the welcome screen in [Screen controls the kiosk provides](/docs/integrations/kiosk-integration/kiosk-requirements#screen-controls-the-kiosk-provides).
## Turn it on for your kiosk campaigns [#turn-it-on-for-your-kiosk-campaigns]
There's no setting for it. Your kiosk developer adapts the script in [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell#add-the-idle-script) so it lists your kiosk campaigns. A Studio admin then adds it once.
1. List the address of every campaign that runs on the kiosk.
2. Send the list and [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell) to your kiosk developer.
3. Ask them for the idle script, the message, the countdown, **Stay** and **Return home**.
4. Ask a Studio admin to [add it](/docs/integrations/tags-and-scripts/add-a-script) with **Script Context** `ALL` and **Trigger Event** `VISIT`, then publish the campaigns again.
5. On the kiosk, start a session and stop touching the screen.
6. Watch the whole reset, then test **Stay**.
The signal does nothing on its own, so put the kiosk side in the scope you agree with your provider. The script only runs on the campaigns it names, so people playing your other campaigns on their phones never get an idle timer.
## Check before launch [#check-before-launch]
* The reset returns to your welcome screen, not to a blank frame or a reloaded game.
* A visitor who's reading rather than tapping isn't thrown out too early.
* After **Stay**, the message stays away long enough for the visitor to carry on.
* The message is readable from where people stand.
* Adding a kiosk campaign later means updating the script's list: put that on the next campaign's launch checklist.
## Next steps [#next-steps]
* [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell)
* [Add a script](/docs/integrations/tags-and-scripts/add-a-script)
* [Kiosk implementation checklist](/docs/integrations/kiosk-integration/kiosk-implementation-checklist)
# Kiosk integration (/docs/integrations/kiosk-integration)
Run a campaign on an in-store touchscreen, built with your kiosk provider.
# Kiosk implementation checklist (/docs/integrations/kiosk-integration/kiosk-implementation-checklist)
Work through the rollout in this order, so the expensive surprises come up early. Most kiosk projects fail in testing, for reasons a one-hour architecture call would have caught.
## Stage 1: agree the architecture [#stage-1-agree-the-architecture]
Get your kiosk provider, your OmniLab contact and whoever owns the venue network into the same meeting. Confirm:
* **Scanner output**: which barcode types the scanner reads, and the exact text it produces.
* **WebView or frame**: which one the kiosk uses, its browser engine and version, JavaScript, touch handling and full-screen mode. See [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements).
* **Address**: how the kiosk adds the scanned value to the experience address. See [Kiosk URLs](/docs/integrations/kiosk-integration/url-structure).
* **Recognition integration**: if members should skip the sign-in screen, ask your Customer Success Manager to set up a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does) for the campaign.
* **Keyboard**: whether new participants fill in a form, and which keyboard the kiosk provides.
* **Screen**: resolution and orientation, so the layout can be checked on the real panel.
* **Network**: firewall rules, and allowlisting for every address the experience and its sign-in use. See the [network points](/docs/integrations/kiosk-integration/kiosk-requirements#network).
* **Session end**: how the kiosk returns to its welcome screen when a visitor walks away. See [Inactivity management](/docs/integrations/kiosk-integration/inactivity-management), and confirm your provider writes the idle script and builds the message.
Leave the meeting with a written note of who confirmed what. It's what you hold people to in stage 2.
## Stage 2: test on the real device [#stage-2-test-on-the-real-device]
Test on the kiosk itself, not on a laptop. If you can, give your OmniLab contact remote access to a test unit: it turns a week of screenshots into an afternoon.
* Check the kiosk loads the right touchpoint, with `l`, then `fci` or `login=0`.
* Run the full journey for every start route you plan to use.
* Test the acquisition form with the kiosk keyboard, including a long email address.
* Scan a barcode that shouldn't work, and check the visitor sees a clear message, not a dead screen.
* Check the kiosk returns to its welcome screen when a visitor abandons a session, and that **Stay** puts them back where they were.
* Press **Home** mid-game, and check the kiosk returns to its welcome screen.
* Start two sessions in a row without a card, and check the second visitor doesn't inherit the first one's sign-up.
* Change the language in one session, and check the next one opens in the kiosk's default language.
* Scroll a long page, then open a panel, and check it opens on screen.
* Check camera or location access, if the experience asks for either.
* In a frame setup, log the raw messages from the experience, and check your listener tests `event.origin`.
* Check layout and touch target sizes at the kiosk's real resolution.
* Time the journey from scan to reward, and compare it with how long people will actually stand there.
* Pull the network cable and see what the visitor sees.
Record each issue with the stage it happened at and who owns the fix. After fixes, test the whole journey again, not just the part that changed.
## Stage 3: launch and run [#stage-3-launch-and-run]
Before you go live:
* If you tested on a Staging campaign or a duplicate, load the live campaign's touchpoint link on every unit instead. Put its host in the shell's origin check.
* With the JavaScript tag, check the kiosk page loads the script from `cdn.21-digital.com`, not the Staging `cdn-uat.21-digital.com`.
* Check the idle script lists the live campaign, then publish again every campaign it covers.
* Check rewards and prize stock are set up, and the campaign is published.
* Brief in-store staff on what the kiosk does, how to restart it and how to hand over prizes.
While the campaign runs, route problems by type:
| What's happening | Contact first |
| ----------------------------------------------------------------------- | ---------------------------------- |
| The game or form misbehaves, or the experience won't load | OmniLab support |
| A reward is wrong, missing or out of stock | Your marketing team, in Studio |
| The screen is frozen, the scanner won't read, or the keyboard is broken | Your kiosk provider |
| The kiosk has no internet connection | Your kiosk provider, then venue IT |
| A visitor complains about a prize or an outcome | Your in-store staff |
If you're a partner, raise each problem through the centre's Studio admin. Send the campaign link, the touchpoint ID, the device, the time it happened and a console log.
After the campaign, pull the participation and reward reports from Studio. Agree with your provider whether the units are taken down or set up for the next campaign.
## Next steps [#next-steps]
* [About kiosk integration](/docs/integrations/kiosk-integration/about-kiosk-integration)
* [Test your game](/docs/games/launch/test-your-game)
* [Publish a game](/docs/games/launch/publish-a-game)
# Kiosk requirements (/docs/integrations/kiosk-integration/kiosk-requirements)
Check these before you order hardware or sign off a provider's proposal. Send this page to your kiosk provider and ask them to confirm each point in writing.
## Browser, WebView or frame [#browser-webview-or-frame]
Whatever the experience opens in must behave like a modern browser, not a stripped-down viewer.
* **A current Chrome-based engine.** Older embedded browsers fail on animations, and the failure often looks like a blank screen rather than an error.
* **JavaScript enabled.** The experience doesn't load at all without it.
* **A way to run the kiosk's own code beside the experience.** The idle signal arrives as a browser message. A kiosk web page listens for it; a WebView needs an injected script and a bridge to the app.
* **Touches passed through to the experience.** If the kiosk swallows them, games look responsive but nothing registers.
* **Full screen.** An address bar or browser controls invite visitors to leave the experience.
## How the experience is loaded [#how-the-experience-is-loaded]
Ask your provider early: does the kiosk load the experience **as the page**, in a WebView, or in a frame inside its own page?
A WebView is simpler, and the one to prefer. A frame only works under a domain rule: see [Choose how the kiosk loads the experience](/docs/integrations/kiosk-integration/build-the-kiosk-shell#choose-how-the-kiosk-loads-the-experience). If the rule is missed, everything works until you test a real loyalty card on site. Settle it before the build, not during acceptance testing.
## Scanner [#scanner]
* **1D barcodes** cover loyalty cards and printed vouchers. This is the minimum.
* **QR codes** are optional. Ask for them if you might switch to QR invitations later.
* **Fast scan response.** A visitor who has to hold a card still for several seconds walks away.
Agree exactly what the scanner outputs. The experience receives the scanned value as text, so the kiosk must strip any prefix, checksum or leading zeros the scanner adds. Otherwise, the values you issue must include them.
## On-screen keyboard [#on-screen-keyboard]
This only matters if new participants fill in the acquisition form at the kiosk. When it does, it's the biggest single cause of drop-off: a cramped or laggy keyboard loses the sign-up. Ask your provider to confirm:
* large, touch-friendly keys, sized for people standing in a public space;
* full alphanumeric entry;
* easy access to the at sign, dot, hyphen and underscore, without hunting through symbol pages;
* the keyboard opens when the visitor taps a field, and closes when they finish;
* autocapitalisation and correction, which reduce typos in names and email addresses.
Keep the form short: every extra field is another keyboard interaction in public. See [Set up an acquisition form](/docs/platform/forms/acquisition-form/set-up-an-acquisition-form).
## Screen controls the kiosk provides [#screen-controls-the-kiosk-provides]
OmniLab doesn't draw any of these. They belong to the kiosk.
| Control | What it does |
| ------------------ | --------------------------------------------------------------------------------- |
| Welcome screen | Invites the visitor to scan, ideally with an animated pointer towards the scanner |
| Home button | Ends the session and returns to the welcome screen, from anywhere |
| On-screen keyboard | Lets the visitor complete the acquisition form |
## Network [#network]
The kiosk needs outbound internet access, and more than your experience domain. The experience also loads fonts, media and 3D viewers from other addresses. A loyalty card scan passes through sign-in addresses too. The first is `.api.topage.co`, or `.api.uat.topage.co` on Staging, with the subdomain of your default address, such as `lindenhall`. Then come your identity provider's pages.
On a restricted venue network, run a test session on an open connection first, and list every address it contacts. Give that list to venue IT early: allowlisting is slow, and a common launch-day blocker. If the kiosk only lets its browser open approved addresses, add the sign-in addresses there as well: see [Keep sign-in inside the WebView](/docs/integrations/mobile-app-integration/build-the-webview#keep-sign-in-inside-the-webview).
## Screen size [#screen-size]
Give OmniLab the kiosk's screen resolution and orientation before testing starts. Kiosk screens are often tall portrait panels, so check the layout on the real device rather than in a browser window.
## Next steps [#next-steps]
* [Kiosk implementation checklist](/docs/integrations/kiosk-integration/kiosk-implementation-checklist)
* [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell)
* [About mobile app integration](/docs/integrations/mobile-app-integration/about-mobile-app-integration)
# Kiosk URLs (/docs/integrations/kiosk-integration/url-structure)
A kiosk always opens the same touchpoint. At the start of each session, it adds who the visitor is, so the experience opens as that person.
## What the address controls [#what-the-address-controls]
* **Which experience loads**: a specific touchpoint, a space, or the campaign's landing page if it has one.
* **Language and variant**: `l` sets the kiosk's language for each session, so a visitor never inherits the last one's choice. You can also lock one variant.
* **Who the visitor is**: a scanned loyalty card adds the member's identifier. Without a card, the session starts signed out.
## The shape of a kiosk address [#the-shape-of-a-kiosk-address]
It's the touchpoint link from Studio, plus the kiosk's language and one value the kiosk adds for each session:
```text
https://play.lindenhall.example/lindenhall-autumn-kiosk?c=&l=en&fci=
```
Here `play.lindenhall.example` is the experience domain. It's your default address, such as `lindenhall.topage.co`, or your own custom domain. The rest comes from Studio and doesn't change. See [Touchpoint URLs & slugs](/docs/platform/deployment/touchpoint-urls-and-slugs).
| When the visitor | The kiosk adds |
| --------------------- | ------------------------------------------------------------------- |
| Scans a loyalty card | `l`, then `fci=` and the scanned value |
| Starts without a card | `l`, then `login=0`, so they don't carry on as the previous visitor |
Your developer has the exact rules in [Kiosk URLs and parameters](/docs/integrations/kiosk-integration/build-the-kiosk-shell#kiosk-urls-and-parameters).
## Pass a loyalty member in [#pass-a-loyalty-member-in]
The `fci` parameter carries the value printed on the member's loyalty card. With a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does) for the campaign, the member goes straight into the experience, with no form. Without it, they sign in with their customer account.
It works only when:
* your customer identity system is connected to OmniLab: see [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts);
* your Customer Success Manager has set up a recognition integration for this campaign, if members must skip the sign-in;
* the scanned value matches the identifier your system holds. If it doesn't, the visitor sees a message that the account couldn't be found, and can't continue.
An unrecognised value blocks the session; it never falls back to an anonymous one. During testing, scan a card that shouldn't work. You'll see what a visitor sees when a card is mistyped, expired or from the wrong programme.
## Let new visitors sign up instead [#let-new-visitors-sign-up-instead]
Use the address with `login=0` instead of `fci`, and put an acquisition form on the touchpoint. The visitor fills it in on the kiosk keyboard, then plays. See [Set up an acquisition form](/docs/platform/forms/acquisition-form/set-up-an-acquisition-form) and the keyboard points in [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements).
## What to give your technical team [#what-to-give-your-technical-team]
* The touchpoint link from Studio, or the campaign link if a landing page presents several touchpoints.
* The kiosk's default language.
* If you use loyalty cards, the format of the value the scanner produces.
* The address of every campaign that runs on the kiosk, for the idle script.
Your developer then follows [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell). Your kiosk provider checks the device against [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements).
## Next steps [#next-steps]
* [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell)
* [Kiosk requirements](/docs/integrations/kiosk-integration/kiosk-requirements)
* [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience)
# About mobile app integration (/docs/integrations/mobile-app-integration/about-mobile-app-integration)
The experience loads in a WebView, an in-app browser view your app controls. Participants stay in your branded app instead of being sent to a browser. Your navigation, tab bar and app chrome stay as they are, and the experience fills the space you give it.
## When to use a WebView [#when-to-use-a-webview]
* A loyalty or retail app should keep participants in the app while they take part in a campaign.
* The experience belongs in your app's navigation: as a tab, a screen, or a card that opens one.
* Your app already knows who the participant is, and they should be recognised. See [Customer accounts](/docs/integrations/customer-accounts).
If the experience only needs to be reachable from your app, a plain link that opens the phone's browser is simpler and needs no development. Choose a WebView when leaving the app would break the journey.
## Who owns what [#who-owns-what]
| Responsibility | Owner |
| ------------------------------------------------------- | --------------------------------- |
| The app screen the experience appears on | Your mobile team |
| The WebView and its settings | Your mobile team |
| Device permissions such as camera and location | Your mobile team, through the app |
| Back, close, and where the participant lands afterwards | Your mobile team |
| The experience itself: game, form, branding, reward | OmniLab |
| Campaign configuration and rewards | You, in Studio |
## What to give your mobile team [#what-to-give-your-mobile-team]
* The touchpoint link from Studio, or the campaign link if a landing page presents several touchpoints.
* What the experience asks of the phone: the camera for a QR scan, photos for a receipt upload, location for a location check.
* Whether participants should be recognised because they're signed in to your app.
A WebView screen ships with your app, so it follows your release cycle and app store review. Plan the campaign dates around that, not the other way round.
## Next steps [#next-steps]
* [WebView requirements](/docs/integrations/mobile-app-integration/webview-requirements)
* [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview)
* [Website integration](/docs/integrations/website-integration)
# Build the WebView (/docs/integrations/mobile-app-integration/build-the-webview)
For your mobile developer. Confirm the [WebView requirements](/docs/integrations/mobile-app-integration/webview-requirements) first, and test with the [Mobile integration checklist](/docs/integrations/mobile-app-integration/mobile-integration-checklist#test-on-real-devices) when you're done.
## Keep sign-in inside the WebView [#keep-sign-in-inside-the-webview]
Most apps send links that leave the experience to the phone's browser. Sign-in also leaves the experience domain, so a guard that only knows that domain breaks it. Keep these hosts in the WebView:
| Host | Example |
| --------------------------------------------------------------------------------------------------- | -------------------------- |
| Your experience domain | `play.lindenhall.example` |
| The sign-in step: your default subdomain, then `.api.topage.co`, or `.api.uat.topage.co` on Staging | `lindenhall.api.topage.co` |
| Your identity provider's sign-in pages | `id.lindenhall.example` |
During a test sign-in, log every address the guard sees. Add any other host the round trip passes through, then open everything else in the browser.
## React Native [#react-native]
```shell
npm install react-native-webview
```
```tsx title="OmniLab in a React Native WebView"
import React from "react";
import { Linking } from "react-native";
import { WebView } from "react-native-webview";
import type { ShouldStartLoadRequest } from "react-native-webview/lib/WebViewTypes";
const EXPERIENCE_URL = "https://play.lindenhall.example/lindenhall-autumn-workshops?c=a315";
// The experience, then every host its sign-in passes through.
const IN_APP_HOSTS = ["play.lindenhall.example", "lindenhall.api.topage.co", "id.lindenhall.example"];
type OmniLabScreenProps = {
language: string;
memberId?: string;
};
function hostOf(url: string): string {
const match = /^https?:\/\/([^/?#:]+)/i.exec(url);
return match ? match[1].toLowerCase() : "";
}
export default function OmniLabScreen({ language, memberId }: OmniLabScreenProps) {
const uri =
EXPERIENCE_URL +
`&l=${encodeURIComponent(language)}` +
(memberId ? `&fci=${encodeURIComponent(memberId)}` : "");
const keepInApp = (request: ShouldStartLoadRequest): boolean => {
// iOS reports frames inside the page, such as a video: let them load.
if (request.isTopFrame === false) return true;
// Files the page creates itself, such as a calendar file.
if (/^(blob|data|about):/i.test(request.url)) return true;
if (IN_APP_HOSTS.includes(hostOf(request.url))) return true;
// Anything else, such as a sponsor's site or the terms, opens in the browser.
Linking.openURL(request.url).catch(() => {});
return false;
};
return (
);
}
```
What the settings do:
* **`flex: 1`**: without it, the WebView collapses to zero height and shows nothing, which reads as a loading failure.
* **`geolocationEnabled`**: turns location on for Android, where it's off by default. Treasure hunts and location checks need it.
* **`allowsInlineMediaPlayback`**: lets the camera preview of a QR scan play inside the page on iOS.
* **`mediaCapturePermissionGrantType`**: on iOS 15 and later, the experience uses the camera without a second prompt from the page.
* **`onShouldStartLoadWithRequest`**: applies the host list above. External links open in the browser, where the participant has a way back.
The library opens the system file picker itself, for receipt uploads and photo fields.
## Native iOS [#native-ios]
Use `WKWebView`:
* Set `allowsInlineMediaPlayback` to `true` on its configuration, so a QR scan's camera preview plays inside the page.
* Implement `webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:)` to grant the camera to your experience domain. Otherwise WebKit adds its own prompt on top of the app's.
* In `webView(_:decidePolicyFor:decisionHandler:)`, allow frames that aren't the main frame, and the hosts above. Open anything else with `UIApplication.shared.open`, then cancel.
* Implement `webView(_:createWebViewWith:for:windowFeatures:)`. The experience opens its terms and some links in a new window, which `WKWebView` ignores without it.
## Native Android [#native-android]
Use the standard `WebView`:
* Turn on `javaScriptEnabled` and `domStorageEnabled` in its settings. Both are off by default.
* In your `WebChromeClient`, implement `onPermissionRequest`. Grant the camera after checking the app's own runtime permission: the page prompt and the Android permission are two separate gates.
* Implement `onShowFileChooser`, and return the chosen file. Without it, receipt uploads and photo fields do nothing.
* Implement `onGeolocationPermissionsShowPrompt`, and call `callback.invoke(origin, true, false)` once the app has location permission. Without it, location checks fail.
* In your `WebViewClient`, apply the host list in `shouldOverrideUrlLoading`. Open anything else with an `ACTION_VIEW` intent.
* Call `setMediaPlaybackRequiresUserGesture(false)` in its settings, so media the experience starts itself, such as the camera preview, can play.
## Declare the permissions [#declare-the-permissions]
| Feature | iOS `Info.plist` | Android manifest |
| -------------------------- | ------------------------------------- | ----------------------------------------- |
| Camera | `NSCameraUsageDescription` | `android.permission.CAMERA` |
| Photo library, for uploads | `NSPhotoLibraryUsageDescription` | None for the system picker |
| Location | `NSLocationWhenInUseUsageDescription` | `android.permission.ACCESS_FINE_LOCATION` |
A missing iOS usage description terminates the app the first time the permission is requested.
## Save downloads [#save-downloads]
A booking's calendar file or ticket, and a photo the participant saves, are files the page creates itself. The page offers each one as a download link, and releases that link straight after the tap. A WebView doesn't save such a file by itself, and a download handler that fetches the link afterwards gets nothing. Capture the file inside the page instead:
```js title="Injected into the experience page by your app"
(function () {
if (window.__omnilabDownloadBridge) return;
window.__omnilabDownloadBridge = true;
function forward(message) {
const json = JSON.stringify(message);
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(json); // React Native
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.omnilab) {
window.webkit.messageHandlers.omnilab.postMessage(json); // iOS WKWebView
} else if (window.OmnilabBridge) {
window.OmnilabBridge.postMessage(json); // Android WebView
}
}
// Keep each file the page creates until it releases the link, so a tap can still read it.
const files = new Map();
const createObjectURL = URL.createObjectURL.bind(URL);
const revokeObjectURL = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = function (object) {
const url = createObjectURL(object);
if (object instanceof Blob) files.set(url, object);
return url;
};
URL.revokeObjectURL = function (url) {
files.delete(url);
revokeObjectURL(url);
};
document.addEventListener("click", (event) => {
const link = event.target instanceof Element ? event.target.closest("a[download]") : null;
const file = link ? files.get(link.href) : null;
if (!file) return;
event.preventDefault();
const reader = new FileReader();
reader.onload = () =>
forward({ type: "omnilab:download", filename: link.download, mimeType: file.type, dataUrl: reader.result });
reader.readAsDataURL(file);
}, true);
})();
true;
```
| Platform | Inject the script and receive the file with | Then |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| React Native | The `injectedJavaScript` prop, and `onMessage`, in `event.nativeEvent.data` | Write `dataUrl` to a file named `filename` with your file-system library, then open the share sheet |
| iOS `WKWebView` | A `WKUserScript` at document end, main frame only, and a `WKScriptMessageHandler` named `omnilab` | Decode `dataUrl` into `Data`, write it to a temporary file named `filename`, then present a `UIActivityViewController` |
| Android `WebView` | `evaluateJavascript` in `onPageFinished`, and `addJavascriptInterface` named `OmnilabBridge`, with a `@JavascriptInterface` method `postMessage(String)` | Decode the Base64 part of `dataUrl`, then save it with `MediaStore` or share it with an `ACTION_SEND` intent |
## Pass your signed-in member through [#pass-your-signed-in-member-through]
If your app knows who the participant is, add `fci` to the address. Whether they then skip the sign-in screen depends on the campaign: see [What your app should add](/docs/integrations/mobile-app-integration/experience-urls-for-an-app#what-your-app-should-add).
If your app also needs server-to-server access to OmniLab, do it from your own backend. Anything shipped in an app bundle can be extracted from it.
## Next steps [#next-steps]
* [Mobile integration checklist](/docs/integrations/mobile-app-integration/mobile-integration-checklist)
* [Experience URLs for an app](/docs/integrations/mobile-app-integration/experience-urls-for-an-app)
* [WebView requirements](/docs/integrations/mobile-app-integration/webview-requirements)
# Experience URLs for an app (/docs/integrations/mobile-app-integration/experience-urls-for-an-app)
For your mobile developer. Every address starts from a link in Studio, then takes what your app knows about the participant.
## The address to load [#the-address-to-load]
| What you want | Address |
| ------------------------------------------ | -------------------------------------------------------------------------- |
| One touchpoint, opened directly | `https://play.lindenhall.example/?c=` |
| A specific space inside an experience | `https://play.lindenhall.example/?s=` |
| The campaign's landing page, if it has one | `https://play.lindenhall.example/` |
Here `play.lindenhall.example` stands for your experience domain. Every parameter is listed in [Embed URLs and parameters](/docs/integrations/website-integration/embed-urls-and-parameters#parameters).
Leave `embedded=1` out. It's meant for a web page that holds the experience in a frame and scrolls to each panel. In a WebView, panels would then open off screen once the participant scrolls a long page. So a script your organisation limits to embedded experiences, with **Script Context** `IFRAME_ONLY`, doesn't run in the app. Ask your Studio admin to set it to `ALL` if it should.
## What your app should add [#what-your-app-should-add]
* **`l`, the language.** Set it from your app's language setting. An app in French showing an experience in English is the most common complaint on this integration, and it's one parameter.
* **`fci`, the participant.** Use it when your app already knows who the participant is. With customer accounts connected, the experience asks them to sign in; with a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does) for the campaign, it opens as that person with no sign-in screen.
## Build the address in the app [#build-the-address-in-the-app]
Compose it in the app rather than hardcoding a finished string, so language and participant follow the app's state:
```text
https://play.lindenhall.example/
?c=
&l=
&fci=
```
Percent-encode every value you add. Otherwise a loyalty identifier holding `&`, `#` or `+` arrives cut short or changed. [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview#react-native) has a working example.
## Next steps [#next-steps]
* [WebView requirements](/docs/integrations/mobile-app-integration/webview-requirements)
* [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview)
* [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience)
# Mobile app integration (/docs/integrations/mobile-app-integration)
Keep participants in your app while they take part in a campaign.
# Mobile integration checklist (/docs/integrations/mobile-app-integration/mobile-integration-checklist)
Work through this before you submit a release. Fixing a WebView defect that reached the app store costs a review cycle, which is usually longer than the campaign.
## Before you build [#before-you-build]
* The campaign is published in Studio.
* Your release schedule leaves time for store review before the campaign starts.
* The permissions the experience needs are declared, with usage descriptions written.
* Your team has read [WebView requirements](/docs/integrations/mobile-app-integration/webview-requirements).
## Test on real devices [#test-on-real-devices]
Simulators grant permissions differently, and pass where a real phone fails.
* Log the address your app builds, and check the language and identifier in it are percent-encoded.
* Run the full journey to the reward on the smallest phone you support.
* Test every form field against the keyboard: it shouldn't cover the field, and the view should scroll.
* Scroll a long page, then open a panel, and check it opens on screen.
* Test the camera, an upload or location, including when the participant refuses permission.
* Save a booking's calendar file or ticket, if your campaigns offer them, and check where it lands.
* Put the app in the background mid-experience, return, and check the participant is where they left off.
* Turn off the network mid-journey and check they can recover.
* If your app has a language setting, switch it and check the experience follows.
* If your app passes `fci`, check the experience opens as that person. Without a [recognition integration](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience#what-it-does) for the campaign, they see your customer-account sign-in first.
* If participants sign in inside the experience, check the round trip stays in your app.
* Test your back control from several points in the journey.
## Before you release [#before-you-release]
* If you tested on a Staging campaign or a duplicate, point the app at the live campaign's touchpoint link.
* If you tested on Staging, update the [host list](/docs/integrations/mobile-app-integration/build-the-webview#keep-sign-in-inside-the-webview) too: the live experience domain, and `.api.topage.co` in place of `.api.uat.topage.co`.
* Check rewards and prize stock are set up.
* Check the campaign dates allow for your store review time.
## Where to send a problem [#where-to-send-a-problem]
| What's happening | Contact first |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| The experience won't load, or the game misbehaves | OmniLab support |
| A reward is wrong, missing or out of stock | Your marketing team, in Studio |
| The keyboard covers a field, or the view is cropped | Your mobile team |
| The camera prompt never appears, or an upload button does nothing | Your mobile team: the app grants the permission and opens the file picker |
| The experience opens in the wrong language, or as the wrong person | Your mobile team: both come from the address the app builds |
If you're a partner, raise each problem through the centre's Studio admin. Send the campaign link, the touchpoint ID, the device, the time it happened and a console log.
## Next steps [#next-steps]
* [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview)
* [Test your game](/docs/games/launch/test-your-game)
* [Experience URLs for an app](/docs/integrations/mobile-app-integration/experience-urls-for-an-app)
# WebView requirements (/docs/integrations/mobile-app-integration/webview-requirements)
Confirm these before your mobile team starts building. Each one fails in a way that looks like a broken experience, not a missing setting, which makes it expensive to find late.
## The WebView itself [#the-webview-itself]
* **JavaScript enabled.** Nothing loads without it. It's off by default in a plain Android `WebView`.
* **A current WebView component.** Use the platform's standard component or a maintained wrapper. Old embedded browsers fail on animations and modern layout, usually with a blank screen rather than an error.
* **Cookies and local storage allowed**, including for the experience's domain. The experience uses them to remember a participant between screens. Blocking them makes someone appear signed out mid-journey.
* **A full-screen or edge-to-edge container.** A WebView in a short, fixed-height box crops games.
## Permissions [#permissions]
Your app grants permissions, not OmniLab. The WebView must also pass the page's request on to the native prompt: declaring the permission in the app manifest isn't enough.
| The experience uses | Your app must provide |
| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| A QR code scan, such as in a treasure hunt | Camera access |
| A receipt upload, or a photo field in a form | A file picker, with the camera and the photo library |
| A location check, such as in a treasure hunt, an instant game or a self check-in | Location access |
| A booking's calendar file or ticket, or a photo to keep | A way to save a file the page creates itself: see [Save downloads](/docs/integrations/mobile-app-integration/build-the-webview#save-downloads) |
Test each one on a real device. Simulators grant permissions differently, and pass where a real phone fails.
## Why sign-in works here [#why-sign-in-works-here]
A WebView loads the experience as the page, not inside one, so the experience is first-party in that view. Cookies behave normally, and sessions persist. Passing a signed-in member through with `fci` needs nothing more than keeping the sign-in hosts in the view. It does need your customer identity system connected first: see [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience).
Sign-in briefly leaves the experience domain, through a sign-in address and your identity provider. Keep those pages inside the WebView. A guard that sends them to the phone's browser breaks sign-in: see [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview#keep-sign-in-inside-the-webview).
Don't build an HTML page of your own inside the WebView with the experience in a frame. That recreates the third-party problem a plain WebView avoids, and sign-in stops working. Point the WebView straight at the experience address.
## Keyboard and forms [#keyboard-and-forms]
If the experience has a form or a sign-in step, check the keyboard doesn't cover the field being typed in, and that the view scrolls to keep it visible. It's the most common WebView defect, and it costs you the sign-up.
## Session and navigation [#session-and-navigation]
* Decide what your back control does mid-experience. Leaving halfway should be deliberate, not accidental.
* Check the experience survives the app going into the background and coming back.
* Check a lost connection leaves the participant something they can recover from.
## Screen sizes [#screen-sizes]
Test the smallest phone you support and the largest tablet. Test both orientations if your app allows rotation.
## Next steps [#next-steps]
* [Build the WebView](/docs/integrations/mobile-app-integration/build-the-webview)
* [Mobile integration checklist](/docs/integrations/mobile-app-integration/mobile-integration-checklist)
* [About mobile app integration](/docs/integrations/mobile-app-integration/about-mobile-app-integration)
# About SSO for your team (/docs/integrations/sso/about-sso-for-your-team)
With single sign-on (SSO), your colleagues select one button on Studio's sign-in page, and there's no separate password to manage. Your IT team sets it up once with OmniLab, and it then applies to everyone in scope.
## Why use it [#why-use-it]
* No separate password for your team to manage or reset.
* Access follows your company identity system. Once someone's company account is disabled, they can't sign in again. An open session ends within 24 hours.
* The same sign-in your team expects from other business tools.
## How it's set up [#how-its-set-up]
Your IT team registers OmniLab as a trusted application in your company identity provider, such as Microsoft Entra ID or Salesforce. They send a few technical details to your Customer Success Manager, who turns on SSO for your environment.
1. Ask your IT team which provider you use: Microsoft Entra ID, Salesforce Marketing Cloud or another.
2. Ask your IT team to register OmniLab, following [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider).
3. Send the details they collect to your Customer Success Manager.
4. Check each colleague has a Studio account in at least one organisation.
5. Have an Admin set each colleague's **Authentication Provider** to your company provider.
6. Test with two or three colleagues before you move everyone.
Sign-in only works for accounts that already exist. Each account signs in [one way only](/docs/admin-and-access/users-and-access/user-profiles-and-authentication#each-account-signs-in-one-way). A colleague still set to email sign-in is refused with **Wrong sign-in method**, even once their company account signs them in.
When you test, check the sign-in button appears, the provider signs people in, and they land in the right organisation.
## Next steps [#next-steps]
* [What your identity team provides](/docs/integrations/sso/what-your-identity-team-provides)
* [Company sign-in (SSO)](/docs/admin-and-access/security-and-data/sso-oidc-configuration)
* [About customer accounts](/docs/integrations/customer-accounts/about-customer-accounts)
# Connect your identity provider (/docs/integrations/sso/connect-your-identity-provider)
For your IT team or identity administrator: what to register in your provider, and what to send back. The admin view is in [Company sign-in (SSO)](/docs/admin-and-access/security-and-data/sso-oidc-configuration).
You don't configure SSO yourself. Your Customer Success Manager gives you the exact callback URL to register in your provider. Once you've shared the details below, they enable the provider for your environment.
## How sign-in works [#how-sign-in-works]
Sign-in uses the OAuth 2.0 Authorization Code flow. Microsoft Entra ID uses OpenID Connect on top of it. Salesforce Marketing Cloud and other providers use plain OAuth 2.0, with a user info endpoint.
1. The user selects the provider button on the sign-in page.
2. OmniLab redirects them to the provider's authorization endpoint.
3. The provider authenticates the user and redirects back to OmniLab's callback URL.
4. OmniLab exchanges the authorization code for tokens at the token endpoint.
5. OmniLab reads the user's identity from the user info endpoint.
6. If a Studio account has that email and an organisation, sign-in completes.
## Microsoft Entra ID [#microsoft-entra-id]
### Register the application [#register-the-application]
1. In the Azure portal, register a new application under **App registrations**.
2. Under **Supported account types**, choose **Accounts in this organizational directory only**.
3. Under **Authentication**, add a **Redirect URI** (platform: Web) with the callback URL you were given.
4. Under **Certificates & secrets**, create a **Client secret**.
5. Copy the secret's value. It's shown only once.
6. Note the secret's expiry date.
7. On the **Overview** tab, note the **Application (client) ID** and **Directory (tenant) ID**.
A client secret lasts 24 months at most. Send your Customer Success Manager a new one before it expires: when it lapses, nobody can sign in with Entra ID.
### Details to share with OmniLab [#details-to-share-with-omnilab]
| Field | Where to find it | Notes |
| ----------------------- | -------------------------------------------------- | ----------------------------------------------------- |
| Application (client) ID | Azure portal > App registrations > Overview | Identifies your Entra app |
| Directory (tenant) ID | Azure portal > App registrations > Overview | Limits sign-in to your Microsoft tenant |
| Client secret value | Azure portal > Certificates & secrets (shown once) | Lets OmniLab complete the code exchange |
| Button display name | Your choice | What users see on the sign-in page |
| Callback URL registered | Confirm only | The OmniLab URL must appear in the Redirect URIs list |
**Scopes:** `openid`, `email`, `profile` (standard OIDC). Sign-in needs no other permissions.
OmniLab finds the Studio account by the email Entra ID returns: the user's mail address, which isn't always their sign-in name. A user without one is refused with **Email required**. Sign in with a test user to check the address comes through.
## Salesforce Marketing Cloud [#salesforce-marketing-cloud]
### Create the package [#create-the-package]
1. In Marketing Cloud, open **Setup** > **Apps** > **Installed Packages**.
2. Select **New**.
3. Name the package.
4. Select **Add Component**.
5. Choose **API Integration**.
6. Choose **Web App**.
7. Add the callback URL you were given as a **Redirect URI**.
8. Save the component.
9. Note the **Client ID** and **Client Secret**.
10. Note the package's **Authentication Base URI**, such as `https://.auth.marketingcloudapis.com/`.
### Details to share with OmniLab [#details-to-share-with-omnilab-1]
The three URLs add a path to your authentication base URI.
| Field | Notes |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Client ID | Identifies your package |
| Client secret | Lets OmniLab complete the code exchange, sent in the request body |
| Authorization URL | `https://.auth.marketingcloudapis.com/v2/authorize` |
| Token URL | `https://.auth.marketingcloudapis.com/v2/token` |
| User info URL | `https://.auth.marketingcloudapis.com/v2/userinfo`. Must return the user's email: that's how OmniLab finds their Studio account |
| Callback URL registered | Confirm only |
Marketing Cloud requests no OAuth scopes: access follows the package's own permissions.
## Another OAuth 2.0 provider [#another-oauth-20-provider]
Ask your Customer Success Manager before you plan this: one custom provider can be added per environment. Gather:
| Field | Notes |
| ----------------------- | ------------------------------------------------------------------------ |
| Button display name | What users see on the sign-in page |
| Client ID | Identifies your provider application |
| Client secret | Sent in the token request body (`client_secret_post`) |
| Authorization URL | Your provider's authorization endpoint |
| Token URL | Your provider's token endpoint |
| User info URL | Must return the user's email |
| Scope string | The scopes your provider needs to return the email |
| Callback URL registered | The OmniLab callback URL must be in the provider's allowed redirect list |
## Before you launch [#before-you-launch]
* The provider application is registered, with the OmniLab callback URL in its allowed redirect list.
* Secrets were shared securely with your Customer Success Manager, never in a plain-text email.
* At least one test user exists in OmniLab and is assigned to an organisation.
* The test user's email in OmniLab exactly matches the email the provider returns.
* You've confirmed which environment the provider is enabled for: Staging or Production.
## Next steps [#next-steps]
* [SSO launch checklist](/docs/integrations/sso/sso-launch-checklist)
* [About SSO for your team](/docs/integrations/sso/about-sso-for-your-team)
* [Authentication](/docs/developers/getting-started/authentication): server-to-server API access, a separate setup
# SSO for your team (/docs/integrations/sso)
No separate password to hand out, rotate or revoke.
# SSO launch checklist (/docs/integrations/sso/sso-launch-checklist)
Check these before company sign-in becomes the way your team reaches Studio. A failure here locks out the people who run your campaigns, so test the recovery path as carefully as the normal one.
## Test sign-in [#test-sign-in]
* A colleague in scope signs in with their company account and reaches Studio.
* They land with the access their role allows: no more, and not an empty screen.
* Someone who isn't in scope is refused with a message, not stuck in a loop.
* Sign-in works outside the office network, if your team works remotely.
* Sign-in works on a phone, if anyone reviews campaigns on one.
## Test access removal [#test-access-removal]
This part is usually assumed and rarely tested, and it's the one an audit asks about.
* Disable a test colleague's company account and check they lose Studio access.
* Check how fast that happens: at once, or when their current session expires.
## Test the recovery path [#test-the-recovery-path]
* Check at least one administrator is still on email sign-in, and that email sign-in is still on for your environment.
* Write down who that is and how, somewhere your team can reach without Studio.
* With Entra ID, note when the client secret expires, and plan to send a new one before that date. See [Register the application](/docs/integrations/sso/connect-your-identity-provider#register-the-application).
## Before you switch everyone [#before-you-switch-everyone]
* Tell your team what changes, and what the sign-in button looks like now.
* Agree who they contact if sign-in fails on the day.
## Where to send a problem [#where-to-send-a-problem]
| What's happening | Contact first |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sign-in fails for everyone | Your identity team: check the application registration first |
| Sign-in works but the person lands with no access | Your Studio administrator: it's a role assignment, not SSO |
| One person can't sign in, others can | Your Studio administrator first: a **Wrong sign-in method** or **User not found** message is fixed in their Studio account. Then your identity team |
| Someone who left still has access | Your identity team: Studio signs them out within 24 hours of being disabled there. Then your Studio administrator, to delete the account |
## Next steps [#next-steps]
* [What your identity team provides](/docs/integrations/sso/what-your-identity-team-provides)
* [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider)
* [Modify or revoke access](/docs/admin-and-access/users-and-access/modify-or-revoke-access)
# What your identity team provides (/docs/integrations/sso/what-your-identity-team-provides)
Collect these before your first configuration call. SSO projects rarely run late on the technical work. They wait on one value from a team nobody asked.
## Who to involve [#who-to-involve]
* Whoever administers your identity system (Microsoft Entra ID, Salesforce, Okta or similar). They need permission to register a new application, and they're often not the person who uses it.
* Your Customer Success Manager, who turns on SSO for your environment.
* Whoever decides which colleagues get which access in Studio.
## What to collect [#what-to-collect]
| What | Why it's needed | Who has it |
| ---------------------------------------------------- | -------------------------------------------------------- | --------------------------- |
| Your identity provider and how it's hosted | Decides which setup applies | Your IT team |
| The application registration in your identity system | What OmniLab authenticates against | Your identity administrator |
| The sign-in addresses people return to | Your identity system refuses any address not on its list | Agreed with OmniLab |
| Which user attributes are shared at sign-in | Decides what OmniLab knows about a person | Your identity administrator |
| The email domains in scope | Decides who SSO applies to | You |
The field names differ by provider. [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider) lists them for Microsoft Entra ID, Salesforce Marketing Cloud and another OAuth 2.0 provider.
## Decide before you configure [#decide-before-you-configure]
**Who's in scope.** All colleagues, or one department first? A staged rollout is easier to reverse.
**How existing accounts move over.** Colleagues who sign in by email today are moved one account at a time, with their **Authentication Provider**. The email on their Studio account must exactly match the one your identity system returns. If it doesn't, sign-in fails with **User not found**.
**Who can sign in if SSO breaks.** Keep at least one administrator on email sign-in: their **Authentication Provider** stays on email, and your Customer Success Manager keeps email sign-in on for your environment. Otherwise an outage at your provider locks everyone out of Studio too.
The main benefit is that disabling someone's company account removes their Studio access. Check with your identity team that this happens in your setup. Auditors ask about it, so test it rather than assume it.
## Next steps [#next-steps]
* [Connect your identity provider](/docs/integrations/sso/connect-your-identity-provider)
* [SSO launch checklist](/docs/integrations/sso/sso-launch-checklist)
* [Roles and permissions](/docs/admin-and-access/users-and-access/roles-and-permissions)
# About tags and scripts (/docs/integrations/tags-and-scripts/about-tags-and-scripts)
Without your tags, an experience is a blind spot: traffic arrives, people convert, and none of it reaches your reporting. A script here is the same code your web team already puts on your website. You add it once, centrally, and it loads with your campaign pages.
## What people use them for [#what-people-use-them-for]
| Use | What the script does |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| Tag manager | Loads your container, so your existing tags work here too |
| Analytics | Sends page views and conversions to your analytics platform |
| Advertising pixels | Lets Meta, Google or TikTok see the traffic, so you can build audiences from it and measure campaigns |
| Cookie banner | Loads your consent tool, so participants get the same choice as on your site |
With your pixel in place, an ad platform can build a lookalike audience from people who engaged with a campaign. It can also exclude people who already converted.
## Where scripts live [#where-scripts-live]
An admin adds scripts once, in the Global organisation. Every campaign takes them when it's published, whatever its organisation. A tag manager container belongs to your company, not to one campaign, so nobody should paste it into each new campaign by hand.
A script added for one centre loads on every centre's campaigns once they're republished. To keep it to one centre, check the organisation in the script: see [One organisation only](/docs/integrations/tags-and-scripts/script-examples#one-organisation-only).
Each script has two settings. One is where the experience runs: standalone, embedded in your site, or both. The other is when the script runs: on the page visit, or at registration. See [Where and when a script runs](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs).
Scripts run inside the experience your participants use. One that throws an error or blocks loading can stop people taking part. [Test it](/docs/integrations/tags-and-scripts/add-a-script#test-it) on one campaign first. Plan how you'd switch it off quickly.
## Consent is yours to handle [#consent-is-yours-to-handle]
OmniLab doesn't decide what your participants consented to. If you need a cookie banner, load your own consent tool as a script, as you do on your website. Then make your other scripts respect its answer instead of firing regardless. That matters most for advertising pixels, where consent rules bite hardest.
## Who does what [#who-does-what]
| Task | Who |
| ----------------------------------------------------------- | ---------------------------- |
| Deciding which tags belong in the experience | Your marketing team |
| Writing or supplying the script code | Your web or analytics team |
| Adding and switching it on in Studio | An admin |
| Publishing the affected campaigns again, so it takes effect | Whoever owns those campaigns |
## Next steps [#next-steps]
* [Where and when a script runs](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs)
* [Add a script](/docs/integrations/tags-and-scripts/add-a-script)
* [Script examples](/docs/integrations/tags-and-scripts/script-examples)
# Add a script (/docs/integrations/tags-and-scripts/add-a-script)
A saved script does nothing on a live campaign until that campaign is published again. That's the step people miss.
## Before you begin [#before-you-begin]
* You need **Admin** access to Global's **General Settings**.
* Have the script code from your web team, or from the tool's own setup screen. Remove any HTML comment lines around it, such as ``.
* Know which context and trigger it needs: see [Where and when a script runs](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs).
## Plan your test [#plan-your-test]
Test the script on one campaign before it reaches any live one.
* **With a staging environment**: follow this page there first, on a campaign nobody is using.
* **Without one**: add the script here, then publish only one unused campaign, and test that.
Once saved, the script loads in every campaign anyone publishes, in every organisation, not only your test one. Until your test passes, limit it to the test campaign: see [A script for selected campaigns only](/docs/integrations/tags-and-scripts/script-examples#a-script-for-selected-campaigns-only).
## Add the script [#add-the-script]
In the organisation switcher, choose **Global**, and select **General Settings**. Then:
1. Select the **Scripts** tab.
2. Select **Add Script**.
3. In **Name**, say what it does and who owns it, such as `GTM container (web team)`.
A clear name is findable in a year; "Script 3" isn't.
4. Choose the **Script Context**: where it runs. See [Where and when a script runs](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs).
5. Choose the **Trigger Event**: when it runs, such as **VISIT**.
6. Set **Is Active?** to **Yes**.
7. Paste the code into **Script Code**.
8. Select **Save Configuration**.
## Test it [#test-it]
Publish your test campaign, and only that one. Then, on it:
1. Run the full participant journey: navigation, forms, buttons and the reward step.
2. Open the browser console, and check for errors the script introduced.
3. Check the tag fired, in the destination tool rather than in Studio.
4. If consent is involved, refuse it, and check your tags stay silent.
Once it passes, remove any test-campaign limit from the code, and select **Save Configuration** again.
## Publish the campaigns again [#publish-the-campaigns-again]
In every campaign that should carry the script:
1. Select **Publish**.
If you save the script and stop there, live campaigns keep running without it, and the settings screen still looks correct.
## Switch a script off [#switch-a-script-off]
To stop a script quickly, set **Is Active?** to **No** instead of deleting it. Switch it back on once you understand what happened. Switching off also needs the campaigns published again, so build that into your rollback plan now, not during an incident.
## If something's blocked [#if-somethings-blocked]
* **The tag doesn't fire on a live campaign**: the campaign hasn't been published since the script was added.
* **It fires twice**: the experience is embedded in a page that already fires the same tag. Set **Script Context** to `NON_IFRAME_ONLY`, and check the embedded address carries `embedded=1`: the JavaScript tag adds it.
* **Save Configuration shows "Invalid script. Must be valid HTML with valid JavaScript inside ``, or hold both a `` blocks: the code starts with `
```
```html title="External script example"
```
For the patterns that work and the ones that break campaigns, see [Common patterns and what to avoid](/docs/integrations/tags-and-scripts/common-patterns-and-what-to-avoid).
## Hand it to your admin [#hand-it-to-your-admin]
Give the admin:
* the script name;
* the **Script Context**: `ALL`, `IFRAME_ONLY` or `NON_IFRAME_ONLY`;
* the **Trigger Event**: `VISIT` or `REGISTRATION`;
* whether it should start active or inactive;
* a rollback instruction, and the result of your test: see [Test it](/docs/integrations/tags-and-scripts/add-a-script#test-it).
## Complete example scripts [#complete-example-scripts]
### Google Tag Manager base loader [#google-tag-manager-base-loader]
Use this when your team wants GTM available before firing more events.
```html title="Google Tag Manager loader"
```
### Page-view event with organisation variables [#page-view-event-with-organisation-variables]
```html title="Page-view dataLayer push"
```
`country`, `region`, `language` and `locale` are organisation variables Lindenhall created, not built-in fields: see [What a script can read](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs#what-a-script-can-read).
### Registration event with dataLayer.push [#registration-event-with-datalayerpush]
Use this to send a conversion-style event after registration.
```html title="Registration dataLayer push"
```
At registration, `window.omnilab?.contact?.externalId` and `window.omnilab?.contact?.email` are available too, if your measurement design needs them.
### Meta Pixel registration trigger [#meta-pixel-registration-trigger]
Use this when the base Meta Pixel is already on the page, and you only need to fire an event from your campaign pages.
```html title="Meta Pixel registration event"
```
### One organisation only [#one-organisation-only]
Every organisation's campaigns load Global's scripts. Use this pattern for a pixel or tag that belongs to one centre only.
```html title="Run only for one organisation"
```
`uniqueKey` is the organisation's **Group Unique Key**, here `lindenhall`. On any other organisation's campaigns, the script stops at its first line.
### A script for selected campaigns only [#a-script-for-selected-campaigns-only]
Use this pattern when a script must run on some campaigns but not others. Each entry is a campaign's path: the part of its link after the domain, such as `/lindenhall-autumn-kiosk`.
```html title="Run only on the listed campaigns"
```
A path matches exactly, or as the start of a longer path, so `/lindenhall-autumn` alone matches nothing. It works on your default address and your custom domain alike. Give the admin the list along with the code: it grows every time a matching campaign launches.
### Advertising pixels [#advertising-pixels]
```html title="Meta Pixel base loader"
```
Google Ads and TikTok follow the same shape: paste the base tag the platform gives you, on the `VISIT` trigger. Fire conversions from a `REGISTRATION` script: see [When: the Trigger Event](/docs/integrations/tags-and-scripts/where-and-when-a-script-runs#when-the-trigger-event).
### Cookie banner [#cookie-banner]
Load your own consent tool as you do on your website, on the `VISIT` trigger:
```html title="Consent tool loader"
```
Then make your other scripts wait for its answer rather than firing regardless. Most consent tools expose a callback or a global you can check: follow that tool's documentation.
The accept path almost always works. The refuse path is where tags fire anyway, and it's the one that carries regulatory risk. Refuse consent and check your pixels stay silent.
## Next steps [#next-steps]
* [Add a script](/docs/integrations/tags-and-scripts/add-a-script)
* [Organisation variables](/docs/admin-and-access/organisations/variables)
* [Build the kiosk shell](/docs/integrations/kiosk-integration/build-the-kiosk-shell)
# Where and when a script runs (/docs/integrations/tags-and-scripts/where-and-when-a-script-runs)
Each script has two settings. Getting them wrong is the most common reason a tag reports the wrong numbers.
## Where: the Script Context [#where-the-script-context]
| **Script Context** | Runs when | Use it for |
| ------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `ALL` | Always | Most analytics and tag manager loads |
| `IFRAME_ONLY` | The experience's address carries `embedded=1` | Scripts that talk to a website page around the experience |
| `NON_IFRAME_ONLY` | The address has no `embedded=1` | A cookie banner you only need then, or a tag your own page already fires when it embeds the experience |
Only website embeds carry `embedded=1`: the JavaScript tag adds it, and you add `embedded=1` yourself to a hand-written website frame. Apps and kiosks leave it out, so their scripts use `ALL`, as the [kiosk idle signal](/docs/integrations/kiosk-integration/inactivity-management) does. See [Embed URLs and parameters](/docs/integrations/website-integration/embed-urls-and-parameters).
When an experience runs inside your website, your page's own analytics is usually counting the visit already. The same tag inside the frame counts it again.
## When: the Trigger Event [#when-the-trigger-event]
| **Trigger Event** | Fires |
| ----------------- | ------------------------- |
| `VISIT` | On the normal page visit |
| `REGISTRATION` | At the registration stage |
Put conversion events in a separate `REGISTRATION` script, rather than adding conditions to the page-view script. It's easier to follow, and easier to switch off if the conversion tag misfires. At registration, your other active scripts stay loaded too.
## What a script can read [#what-a-script-can-read]
Before your scripts run, the page makes these values available:
```js title="Values commonly available to scripts"
window.omnilab?.group?.uniqueKey
window.omnilab?.group?.configuration?.variables
window.omnilab?.contact?.externalId
window.omnilab?.contact?.email
window.location.pathname
```
Contact values only exist once the participant is known at that point in the flow. On a `VISIT` script for an anonymous visitor they're empty, which is expected.
`window.omnilab.group.uniqueKey` and `window.location.pathname` exist for every organisation. Everything under `configuration.variables` is a free-form store an admin created, and no keys are set up for you. Check which exist before you use one, and always give a fallback for a key that might be missing.
## Turn scripts off for one campaign [#turn-scripts-off-for-one-campaign]
Global's scripts run on every campaign, in every organisation. To keep them off one campaign, follow [Change the wording](/docs/platform/forms/acquisition-form/set-up-an-acquisition-form#change-the-wording), open the **Global** group, and set `global.scripts.ignore` to `true`. Then publish the campaign again. To keep only some scripts off, limit them to [one organisation](/docs/integrations/tags-and-scripts/script-examples#one-organisation-only) or to [selected campaigns](/docs/integrations/tags-and-scripts/script-examples#a-script-for-selected-campaigns-only) instead.
## Make one script serve several organisations [#make-one-script-serve-several-organisations]
If you run several organisations, such as regions, brands or centres, read organisation variables so one script adapts. It saves keeping several near-identical copies:
```html title="One page-view script for every organisation"
```
See [Organisation variables](/docs/admin-and-access/organisations/variables).
## Next steps [#next-steps]
* [Add a script](/docs/integrations/tags-and-scripts/add-a-script)
* [Script examples](/docs/integrations/tags-and-scripts/script-examples)
* [Organisation variables](/docs/admin-and-access/organisations/variables)
# About website integration (/docs/integrations/website-integration/about-website-integration)
Visitors stay on your domain, inside your navigation. Give the experience a dedicated page. It isn't a widget to slot between two paragraphs, but a screen that needs room. Its height changes as a participant moves from a form to a game to a reward.
Embedding only works properly when the experience is served from a subdomain of the site it appears on: `play.lindenhall.example` inside `www.lindenhall.example`. The default address is fine for links and QR codes. Inside your page, though, the browser treats it as a third-party frame and blocks its cookies. Participants then can't stay signed in.
Set up a [custom domain](/docs/integrations/custom-domain) before you build the page.
## Choose how the page loads the experience [#choose-how-the-page-loads-the-experience]
Both methods put the same experience on the page. They differ in who picks the experience, and in what it may use.
| Method | The page loads | Choose it when |
| --------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| JavaScript tag | Whichever experience the page's own address names | One template page shows different campaigns over time. Not for the camera or location: the tag can't give its frame either. |
| Your own iframe | One specific experience, fixed in the page's code | The experience uses the camera or location, such as a scan game or a treasure hunt |
The JavaScript tag is a small script that also handles the fiddly parts. It sizes the frame as the experience changes. It keeps panels in view and clear of a sticky header. It brings participants back to your page after sign-in.
Prefer the tag. With your own iframe, your code does all of that. [A parent page written by hand](/docs/integrations/website-integration/javascript-tag-reference#a-parent-page-written-by-hand) is a working example, sign-in included.
## Who owns what [#who-owns-what]
| Responsibility | Owner |
| ------------------------------------------------------------------- | --------------------------------------------------------- |
| The page the experience appears on | You |
| Loading the experience and sizing its frame | The JavaScript tag, or your own code with your own iframe |
| Allowing the tag and the experience in your content security policy | You |
| Browser permissions the experience needs, such as the camera | You, on your page |
| The experience itself: game, form, branding, reward | OmniLab |
| Campaign configuration and rewards | You, in Studio |
## What to give your web team [#what-to-give-your-web-team]
Both methods mean adding code to your website, so the person who maintains your site does the work. Give them:
* the touchpoint link from Studio, or the campaign link if a landing page presents several touchpoints;
* the branded address the experience is served from, a subdomain of the site you embed on: see [Custom domain](/docs/integrations/custom-domain);
* any permission the experience needs, such as camera access for a scan game.
## Next steps [#next-steps]
* [Embed URLs and parameters](/docs/integrations/website-integration/embed-urls-and-parameters)
* [What your page must handle](/docs/integrations/website-integration/what-your-page-must-handle)
* [JavaScript tag reference](/docs/integrations/website-integration/javascript-tag-reference)
# Embed URLs and parameters (/docs/integrations/website-integration/embed-urls-and-parameters)
For whoever writes the embed code. Every embed starts from an address in Studio, with parameters added. To change which experience a page shows, you edit this address.
## The address to load [#the-address-to-load]
Here the experience host is `play.lindenhall.example`, and the campaign link is `lindenhall-autumn-workshops`:
| What you want | Address |
| ------------------------------------------ | ------------------------------------------------------------------------------------------ |
| One touchpoint, opened directly | `https://play.lindenhall.example/lindenhall-autumn-workshops?c=&embedded=1` |
| A specific space inside an experience | `https://play.lindenhall.example/lindenhall-autumn-workshops?s=&embedded=1` |
| The campaign's landing page, if it has one | `https://play.lindenhall.example/lindenhall-autumn-workshops?embedded=1` |
Without a landing page, the campaign link has nothing to open, so use a touchpoint link.
Include `embedded=1` when you write the iframe yourself. It tells the experience it's inside your page, which changes how its height, panels and sign-in work: see [What your page must handle](/docs/integrations/website-integration/what-your-page-must-handle). Without it, the experience behaves as though it owns the whole window. The JavaScript tag adds `embedded=1` for you.
## Parameters [#parameters]
| Parameter | Controls | Notes |
| ------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `embedded` | Embedded mode | `1` when the page around the frame sizes it and scrolls to panels, as the JavaScript tag does. Leave it out in a WebView, or in a frame fixed to the screen. |
| `c` | Which touchpoint opens | Leave it out to open the landing page, if the campaign has one |
| `s` | Which space opens | For experiences that have spaces |
| `l` | Language | Opens the experience in this language, if the campaign offers it. Without `l`, the visitor's last choice, or else the campaign's default. With an `l` the campaign doesn't offer, the campaign's default. |
| `v` | Variant | Opens a specific variant of the experience |
| `fci` | Which customer is signing in | Only with customer accounts set up: see [Pass a known customer into an experience](/docs/integrations/customer-accounts/pass-a-known-customer-into-an-experience) |
| `login` | Sign-in state when the page loads | `0` starts the experience signed out, even for a participant who signed in before. `1` sends the visitor through sign-in before anything shows. Neither applies on the return from sign-in. The JavaScript tag sets it: see [Sign-in state](/docs/integrations/website-integration/javascript-tag-reference#sign-in-state). |
| `frameorigin` | Where sign-in returns | Your page's full address. Needs `embedded=1` and `frameparam`: see [A parent page written by hand](/docs/integrations/website-integration/javascript-tag-reference#a-parent-page-written-by-hand). |
| `frameparam` | Which parameter of your page names the experience | Set it to `id`: the return from sign-in always uses `id`. |
Set `l` when the page around the experience is already in one language, so the two don't disagree.
## Where the IDs come from [#where-the-ids-come-from]
The campaign link, touchpoint ID, space ID and variant ID all come from the campaign in Studio. See [Touchpoint URLs & slugs](/docs/platform/deployment/touchpoint-urls-and-slugs) for where each one appears.
Open the address in a normal browser tab before you put it in a page. If it doesn't work on its own, it won't work embedded, and you'd spend the debugging time in the wrong place.
## Next steps [#next-steps]
* [What your page must handle](/docs/integrations/website-integration/what-your-page-must-handle)
* [JavaScript tag reference](/docs/integrations/website-integration/javascript-tag-reference)
* [Custom domain](/docs/integrations/custom-domain)
# Website integration (/docs/integrations/website-integration)
Your header and footer stay; the experience fills the page between them.
# JavaScript tag reference (/docs/integrations/website-integration/javascript-tag-reference)
For your developer. With the tag, one template page can show any experience, picked by the page's own address.
## The one value you need first [#the-one-value-you-need-first]
The snippet needs the host your experiences are served from. For a site at `www.lindenhall.example`, that's a subdomain such as `play.lindenhall.example`. It must be a subdomain of your site, or participants can't stay signed in. See [Custom domain](/docs/integrations/custom-domain).
## Install the tag [#install-the-tag]
| Environment | Script address |
| ------------- | -------------------------------------------- |
| Production | `https://cdn.21-digital.com/omplayer.js` |
| Staging (UAT) | `https://cdn-uat.21-digital.com/omplayer.js` |
Use Production. Load the Staging address only to test against a Staging experience.
Put the container where the experience should appear, with the snippet after it:
```html title="Container + tag"
```
Your page's own address then decides which experience loads. The value of `id` is the path after the experience host:
* `https://www.lindenhall.example/promo?id=lindenhall-autumn-workshops` opens the campaign's landing page.
* `https://www.lindenhall.example/promo?id=lindenhall-autumn-workshops?c=a315` opens one touchpoint.
The tag reads `id` up to the next `&` or `#`, so one nested `?` works as written. To pass several nested parameters, URL-encode the whole value: `id=lindenhall-autumn-workshops%3Fc%3Da315%26l%3Den`.
Don't add `embedded=1` yourself. The tag adds it when it builds the frame.
## What the tag needs from your page [#what-the-tag-needs-from-your-page]
**Leave the container empty.** The tag adds its loading spinner and the frame after anything already in `#omnilab-container`. Render the element empty, and don't let your framework re-render its contents.
**Don't give the frame a height in CSS.** The tag sizes the frame with its `height` attribute, and any CSS `height` overrides that. Style the frame with the `iframeStyle` and `iframeClass` options instead.
**Allow both hosts in your content security policy.** If your site sends a policy, the embed is blocked until it allows the tag's host and the experience host:
```text title="Content-Security-Policy header"
Content-Security-Policy: script-src 'self' https://cdn.21-digital.com; frame-src https://play.lindenhall.example
```
Keep your own sources in each directive. The snippet is an inline script, so give it your page's nonce, or move it into a file on your own domain. If you also test against Staging, allow the Staging script host and your Staging experience host too.
The tag also adds a small inline `