Developer docs · v1 · stable
Lilium Studio as a headless CMS.
Lilium Studio exposes a small, opinionated REST API at /api/public/v1/* so any external site — Next.js, Astro, Remix, plain HTML — can plug in as a headless CMS. Your site stays where it is; Lilium becomes the data backend. Plug-and-unplug in minutes, no vendor lock-in, no proprietary SDK.
§ 01
Overview
The integration is intentionally loose-coupled. You expose two env vars on your hosting provider and one JSON handshake file on your domain. Lilium never reaches into your site — the site reaches out to Lilium.
- ›Reads — your site pulls services, form schemas, and storefront config from Lilium on every request (cache as needed).
- ›Writes — your site POSTs form submissions, payments, and bookings to Lilium. We fire the inbox notification, email, and push.
- ›Verification — Studio confirms the link by fetching
https://yourdomain.com/.well-known/lilium-handshake.jsonand matching the declaredorg_slug. - ›Security — writes are origin-locked to your verified domain (browser-enforced). Reads are public because the data is already public on your site.
§ 02
Quickstart
From zero to a connected site in about ten minutes.
Sign up + create a workspace
Go to studio.liliumlabs.co/studio/signup, create an account, and create a workspace. The workspace slug (e.g. my-coaching) is the identifier you'll use everywhere else.
Add your services + forms in Studio
Inside the workspace, go to /studio/services and /studio/forms to seed your data. Whatever you add here is what the API will return.
Wire env vars on your hosting provider
On the Vercel / Netlify / wherever project for your site:
NEXT_PUBLIC_CMS_URL=https://studio.liliumlabs.co NEXT_PUBLIC_CMS_ORG_SLUG=my-coaching
Expose the handshake file
Studio must be able to GET https://yourdomain.com/.well-known/lilium-handshake.json and read your org_slug. See handshake for the JSON shape and code samples.
Verify in Studio
Go to /studio/settings → Connect your site → enter your domain → click Verify. Status flips to Connected in under 7 seconds.
Ship
Your site can now read services + form fields from Lilium and submit form responses to Lilium. Submissions trigger Resend emails + web push notifications to the workspace's mobile PWA inbox at /m.
§ 03
Architecture
Lilium Studio doesn't render or own your front-end. We're a data backend you can detach from at any time.
yourdomain.com studio.liliumlabs.co
────────────── ────────────────────
your bespoke site Lilium workspace
hosted anywhere /api/public/v1/*
│ GET /api/public/v1/services/<slug>
│ GET /api/public/v1/forms/<slug>/<form>
│ GET /api/public/v1/storefront/<slug>
├──────────────────────────────────────────► pulls data
│
│ POST /api/public/v1/forms/<slug>/<form>/submit
├──────────────────────────────────────────► submissions
│ + Resend email
│ + web push
│
│ GET /.well-known/lilium-handshake.json
◄────────────────────────────────────────── Studio verifiesThe same workspace can power multiple sites (e.g. a marketing site + a customer portal), but each site sets its own env vars. Submissions from any verified-origin site land in the same workspace inbox.
§ 04
Environment variables
| Name | Required | Description |
|---|---|---|
NEXT_PUBLIC_CMS_URL | Yes | Base URL of Lilium Studio. Always https://studio.liliumlabs.co for production. |
NEXT_PUBLIC_CMS_ORG_SLUG | Yes | Your workspace slug. Used in all API paths and in the handshake. |
CMS_REVALIDATE_SECONDS | No | Cache revalidate window for SSR fetches (default 60). |
ALLOW_DEV_ORIGIN | Dev only | Set to "1" on Studio side to allow localhost POSTs (for testing). |
The NEXT_PUBLIC_* prefix is required for Next.js client-side reads. They are not secrets — they end up in your browser bundle. That's fine because all reads are public and writes are protected by Origin verification, not by the key.
§ 05
The handshake file
Studio fetches this file at https://<your-domain>/.well-known/lilium-handshake.json to verify you actually control the domain you registered. The verification is one-shot and on-demand (when you click Verify in Studio settings).
Required JSON shape
{
"org_slug": "my-coaching",
"cms_url": "https://studio.liliumlabs.co",
"generator": "lilium-starter",
"version": 1
}Studio only requires org_slug. Everything else is metadata. If org_slug matches the workspace, status flips to verified. Otherwise mismatch or unreachable.
Next.js (App Router)
// app/api/lilium-handshake/route.ts
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export function GET() {
return NextResponse.json({
org_slug: process.env.NEXT_PUBLIC_CMS_ORG_SLUG ?? null,
cms_url: process.env.NEXT_PUBLIC_CMS_URL ?? null,
generator: 'lilium-starter',
version: 1,
});
}// next.config.js — rewrite /.well-known/ → /api/
async rewrites() {
return [
{
source: '/.well-known/lilium-handshake.json',
destination: '/api/lilium-handshake',
},
];
}Static site (Astro, plain HTML, Jekyll)
Just drop a JSON file at public/.well-known/lilium-handshake.json with the JSON above. Hardcode the slug. Static is fine — Studio fetches fresh each time.
WordPress / non-Node hosts
Upload the JSON file directly to the document root under /.well-known/lilium-handshake.json. If your host blocks dotfile paths, add an Apache/nginx rewrite to point that path at a normal file.
§ 06
API reference
Base URL: https://studio.liliumlabs.co/api/public/v1
All endpoints return JSON. All errors return { error: string } with an appropriate HTTP status.
/storefront/:slugFull storefront config — theme + sections array. Useful for fully-headless customers who want to render the same sections their /store page would show.
Response
{
"org": {
"slug": "my-coaching",
"name": "My Coaching",
"brand_color": "#0A0A0A",
"logo_url": null,
"favicon_url": null
},
"published_at": "2026-05-22T14:33:00Z",
"config": {
"version": 1,
"theme": { ... },
"sections": [ ... ]
}
}/services/:slugActive services for the workspace, ordered by price ascending. Each service has slug, name, description, duration, price (in cents), currency.
Response
{
"org": { "slug": "my-coaching", "name": "My Coaching" },
"services": [
{
"slug": "foundation",
"name": "Foundation",
"description": "...",
"duration_minutes": 0,
"price_cents": 19900,
"currency": "USD"
}
]
}/forms/:slug/:formSlugSingle intake form schema. Use this to render the form on your site. Returns 404 if the form doesn't exist or is inactive.
Response
{
"org": { "slug": "my-coaching", "name": "My Coaching" },
"form": {
"slug": "application",
"title": "Coaching application",
"description": "...",
"fields": [
{
"name": "name",
"label": "Your name",
"kind": "text",
"required": true,
"placeholder": "First and last"
},
{
"name": "goal",
"label": "Primary goal",
"kind": "select",
"required": true,
"options": ["Lose weight", "Build strength", "..."]
}
]
}
}/forms/:slug/:formSlug/submitSubmit a form response. Origin header must match the workspace's verified custom_domain (or www. variant). Returns 403 if not verified. Returns 429 if rate-limited (10/min per IP).
Request body
{
"email": "client@example.com",
"responses": {
"name": "Jane Doe",
"goal": "Build strength"
},
"honeypot": ""
}Response
{ "ok": true }§ 07
Security model
Three layers, no API keys:
- ›Origin verification. The submit endpoint reads the
Originheader (browser-set, not forgeable client-side) and confirms it matches the workspace'scustom_domainor itswww.variant. Blocks random scripts on other domains, curl/Node calls without Origin, and slug-guessing attackers. - ›Handshake gate. Origin verification only activates after
handshake_status = 'verified'. Until then, the submit endpoint is closed. That binds slug ↔ domain in a way that can't be spoofed. - ›Rate limit + honeypot. 10 POSTs per IP per minute. The optional
honeypotfield silently 200s on bot submissions without storing them.
Why no API key?
A NEXT_PUBLIC_LILIUM_KEY would ship in your browser bundle since form submissions happen client-side. Anyone view-sources extracts it in 5 seconds — that's security theatre. Origin verification can't be spoofed by the client and is stronger than a public key would be. Server-only keys would require routing every submission through your own backend, doubling architecture for marginal security gain.
For higher-traffic deployments, Cloudflare Turnstile or hCaptcha is the next layer to add — invisible to real users, blocks headless Chrome / spoofed-header attacks. Not built yet — coming when traffic justifies it.
§ 08
Code examples
Next.js (App Router)
// lib/cms.ts
const BASE = `${process.env.NEXT_PUBLIC_CMS_URL}/api/public/v1`;
const SLUG = process.env.NEXT_PUBLIC_CMS_ORG_SLUG!;
const REVALIDATE = Number(process.env.CMS_REVALIDATE_SECONDS ?? 60);
export type Service = {
slug: string;
name: string;
description: string;
price_cents: number;
currency: string;
duration_minutes: number;
};
export async function fetchServices(): Promise<Service[] | null> {
try {
const res = await fetch(`${BASE}/services/${SLUG}`, {
next: { revalidate: REVALIDATE, tags: ['cms:services'] },
});
if (!res.ok) return null;
const data = await res.json();
return data.services ?? null;
} catch {
return null;
}
}
export async function submitForm(
formSlug: string,
payload: { email: string; responses: Record<string, unknown>; honeypot?: string }
) {
const res = await fetch(`${BASE}/forms/${SLUG}/${formSlug}/submit`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
return { ok: res.ok, error: data.error };
}// app/page.tsx — server component
import { fetchServices } from '@/lib/cms';
export const revalidate = 60;
export default async function Home() {
const services = (await fetchServices()) ?? DEFAULTS;
return <ServicesGrid services={services} />;
}Astro
---
// src/pages/index.astro
const base = `${import.meta.env.PUBLIC_CMS_URL}/api/public/v1`;
const slug = import.meta.env.PUBLIC_CMS_ORG_SLUG;
const services = await fetch(`${base}/services/${slug}`)
.then((r) => r.ok ? r.json() : null)
.catch(() => null);
---
<ul>
{(services?.services ?? []).map((s) => (
<li>{s.name} — ${s.price_cents / 100}</li>
))}
</ul>Plain HTML + JavaScript (WordPress, Webflow, anything)
<form id="apply">
<input name="email" type="email" required />
<textarea name="goal" required></textarea>
<button type="submit">Apply</button>
</form>
<script>
document.getElementById('apply').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch(
'https://studio.liliumlabs.co/api/public/v1/forms/your-slug/application/submit',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email: fd.get('email'),
responses: { goal: fd.get('goal') },
}),
}
);
if (res.ok) alert('Submitted!');
});
</script>Note: the submit endpoint is origin-locked. Your page must be served from the workspace's verified domain or the request returns 403.
§ 09
Error codes
| Status | Meaning | Common fix |
|---|---|---|
400 | Invalid JSON / missing required field | Check the request body shape. |
403 | Origin not authorized or handshake not verified | Verify your site in Studio settings; confirm Origin header. |
404 | Workspace, form, or service not found / inactive | Double-check the slug and that the resource is active. |
429 | Rate limited (10 POSTs / IP / min on submit) | Wait 60s. Add a honeypot field to silently drop bots. |
500 | Server error | Email hello@liliumlabs.co with the request ID. |
§ 10
Rate limits & SLAs
- ›Reads — no rate limit. Cache responses locally with whatever TTL works for you (we recommend 60 seconds).
- ›Writes — 10 POSTs per IP per minute on the submit endpoint. Honeypot-protected.
- ›Realtime — Lilium emits Supabase Realtime events for inbox + payments + orders. External sites don't subscribe directly; the Studio dashboard + the
/mmobile PWA do. - ›Uptime — hosted on Vercel + Supabase. Target 99.9%. Status: status.liliumlabs.co (when published).
§ 11
Versioning
The API is versioned in the path — /api/public/v1/*. Breaking changes get a new path (v2). Old versions are supported for 12 months after a new version ships.
Non-breaking additions (new optional response fields, new endpoints) can land in v1 without warning. Subscribe to the changelog at GitHub Releases to be notified.
§ 12
Support
Stuck on integration? Three ways to get help:
- ›Email — hello@liliumlabs.co. We reply within 24 hours.
- ›Strategy call — Book free via cal.com/liliumlabs/intro-call-30-min. We can pair-program the integration with you if needed.
- ›Studio dashboard — every workspace has a
/studio/settingspage with handshake status + the exact env vars you need.
Ready to integrate?
Spin up a workspace in 60 seconds.
No credit card. The Site + Studio plan is free for the first workspace during launch — you can wire integration before deciding if you want to upgrade.