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.json and matching the declared org_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.

01

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.

02

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.

03

Wire env vars on your hosting provider

On the Vercel / Netlify / wherever project for your site:

bash
NEXT_PUBLIC_CMS_URL=https://studio.liliumlabs.co
NEXT_PUBLIC_CMS_ORG_SLUG=my-coaching
04

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.

05

Verify in Studio

Go to /studio/settingsConnect your site → enter your domain → click Verify. Status flips to Connected in under 7 seconds.

06

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 verifies

The 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

NameRequiredDescription
NEXT_PUBLIC_CMS_URLYesBase URL of Lilium Studio. Always https://studio.liliumlabs.co for production.
NEXT_PUBLIC_CMS_ORG_SLUGYesYour workspace slug. Used in all API paths and in the handshake.
CMS_REVALIDATE_SECONDSNoCache revalidate window for SSR fetches (default 60).
ALLOW_DEV_ORIGINDev onlySet 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

json
{
  "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)

ts
// 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,
  });
}
js
// 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.

GET/storefront/:slug

Full storefront config — theme + sections array. Useful for fully-headless customers who want to render the same sections their /store page would show.

AuthNone
CORS* (anywhere)

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": [ ... ]
  }
}
GET/services/:slug

Active services for the workspace, ordered by price ascending. Each service has slug, name, description, duration, price (in cents), currency.

AuthNone
CORS* (anywhere)

Response

{
  "org": { "slug": "my-coaching", "name": "My Coaching" },
  "services": [
    {
      "slug": "foundation",
      "name": "Foundation",
      "description": "...",
      "duration_minutes": 0,
      "price_cents": 19900,
      "currency": "USD"
    }
  ]
}
GET/forms/:slug/:formSlug

Single intake form schema. Use this to render the form on your site. Returns 404 if the form doesn't exist or is inactive.

AuthNone
CORS* (anywhere)

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", "..."]
      }
    ]
  }
}
POST/forms/:slug/:formSlug/submit

Submit 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).

AuthOrigin-locked
CORSVerified domain only

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:

  1. Origin verification. The submit endpoint reads the Origin header (browser-set, not forgeable client-side) and confirms it matches the workspace's custom_domain or its www. variant. Blocks random scripts on other domains, curl/Node calls without Origin, and slug-guessing attackers.
  2. 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.
  3. Rate limit + honeypot. 10 POSTs per IP per minute. The optional honeypot field 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)

ts
// 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 };
}
tsx
// 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

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)

html
<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

StatusMeaningCommon fix
400Invalid JSON / missing required fieldCheck the request body shape.
403Origin not authorized or handshake not verifiedVerify your site in Studio settings; confirm Origin header.
404Workspace, form, or service not found / inactiveDouble-check the slug and that the resource is active.
429Rate limited (10 POSTs / IP / min on submit)Wait 60s. Add a honeypot field to silently drop bots.
500Server errorEmail 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 /m mobile 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:

  • Emailhello@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/settings page 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.