Backend API reference
Stateless token + room service for Petal. It exists for one reason: mint scoped LiveKit JWTs server-side so the LiveKit API secret never ships inside a client (the P0 blocker, internal/ISSUES.md). It also lets a client that already holds a room credential check that room’s live status across machines.
Minimal-backend design (product directive): the only infrastructure is
LiveKit + this lean Vercel function — no database. Rooms live entirely on
LiveKit; the human room name rides in LiveKit room metadata. The core
meeting service needs only the three LiveKit credentials; everything else in
the environment (listed under “Environment” below) enables an optional
integration. Favorites/recents and logs/stats stay local on the client.
Endpoints
Section titled “Endpoints”| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/token |
{ room, identity, displayName? } |
{ url, token, room, displayName? } |
| POST | /api/rooms |
{ name, open?, room? } |
{ room } (generates or stamps a credential) |
| POST | /api/rooms/status |
{ rooms: [{ room, accessCode? }] } (credentials you already hold) |
{ rooms: [{ id, name, open, occupancy }] } — proof-of-possession status, only for the rooms whose credential you sent |
| GET | /api/rooms |
— | 410 Gone — the public room directory was removed; use /api/rooms/status |
| POST | /api/gallery-token |
{ room, identity } + Authorization: Bearer <livekit jwt> |
a hidden, subscribe-only token for the in-webview gallery bridge |
| POST | /api/ai-token |
{ room, identity } + Authorization: Bearer <livekit jwt> |
{ token, expireTime, model } |
| POST | /api/admin |
{ action: "kick" | "close", room, identity? } |
{ ok, action, room } |
| GET | /api/version |
— | { commit } (PETAL_DEPLOY_COMMIT), read by the deploy-freshness gate |
| GET | /api/updater (alias /latest.json) |
— | the Tauri updater manifest, verbatim from Blob |
| GET | /api/download?platform=macos|windows |
— | 302 to the current signed installer’s Blob URL |
| GET | / |
— | 302 redirect to the marketing site (petal.live) |
room in the token request is the internal credential
room-<32 lowercase hex chars>, derived client-side from the user-facing
abc-defg-hjk access code. Bare human names like eng-sync, old public
<label>-<32hex> credentials, and display labels are rejected and do not mint
tokens. The returned room is the LiveKit room name (petal-room-<credential>),
and displayName is the human room name read from LiveKit metadata when it is
available.
There is no public room directory: /api/rooms/status only answers for a
credential the caller already holds, and never returns participant
identities — just whether the room is open and its live participant count.
The create response returns the short access code to the creator. Native clients
that already generated a credential pass it as room so the backend stamps that
LiveKit room’s metadata instead of creating a separate generated room. For those
native credential stamps, open is only an initial value; if server metadata
already exists, the backend preserves its open flag and refreshes only the
display label.
Public token requests always mint visible participant tokens with the fixed
Petal participant profile: publish, subscribe, data publish, and own-metadata
updates enabled; hidden is always false. Caller-supplied grant fields are
ignored for compatibility and must not be used for hidden gallery/bridge
participants. Hidden subscribe-only tokens require a trusted server-owned path
bound to an already-visible participant.
Token identity must be a generated participant id, not a human name. Accepted
forms are the native UUID participant id, the web harness web-<uuid> id, and
the legacy p-...-... generated fallback. Human-readable names belong in
displayName; the backend rejects values such as alice or Jane Doe as
LiveKit identities so callers cannot trivially mint a token whose authenticated
subject is a teammate’s name.
/api/admin is the server-side revocation primitive for operators and future
trusted app flows. It requires Authorization: Bearer <PETAL_ADMIN_TOKEN> and
never accepts a room credential alone as admin authority. action: "kick"
removes one LiveKit participant from the derived room; action: "close" deletes
the LiveKit room. This is not user authentication: invite credentials are still
bearer capabilities until Petal grows a real account/device-attestation layer.
AI chat: /api/ai-token
Section titled “AI chat: /api/ai-token”Mints a Gemini Live ephemeral token so a participant can hold an AI
conversation about a shared window. Petal’s repo is public, so the Gemini key
can only live here: the client connects its WebSocket straight to Google with
the minted token, media never proxies through this backend, and
GEMINI_API_KEY joins LIVEKIT_API_SECRET under the “never returns the
secret” rule in lib/livekit.ts.
Two auth layers, both required:
- Cryptographic. The caller sends its OWN LiveKit access token as
Authorization: Bearer <jwt>;TokenVerifierchecks the signature againstLIVEKIT_API_SECRET, and the token’ssub/video.room/video.roomJoinmust match the request. This is stronger than/api/gallery-token’s anchor, which only proves such an identity exists in the room — here the caller must be that identity, so nobody can mint against a teammate’s identity. - Liveness. That identity must be currently connected to the room
(
listParticipants), so a 24h join token outliving the meeting buys nothing.
Minted tokens are single-use, must open their session within 30s, and expire
12 minutes after minting. Rate limits: 6 per identity+room per hour and 60 per
client IP per hour, in their own buckets so an AI-chat burst can never lock a
caller out of /api/token. Both upstream calls run under a 4s timeout so the
route always answers inside vercel.json’s maxDuration: 10.
| Env var | Required | Purpose |
|---|---|---|
GEMINI_API_KEY |
to enable AI chat | Gemini Developer API key. Unset = global kill switch: every call returns 503 {"error":"AI chat is not configured"}. |
GEMINI_LIVE_MODEL |
no | Model id to mint against; defaults to models/gemini-3.1-flash-live-preview. |
GEMINI_API_VERSION |
no | Overrides the Gemini API version used for minting. |
The response’s model field is authoritative — clients must use it rather
than their own constant, so rotating a retired preview model is an env
change plus vercel --prod, not a client release.
Residual risk, stated deliberately: an ephemeral token constrains the model
and response modality but not prompt content, so a modified client can
spend its ~12-minute session on arbitrary audio chat against the key. That is
accepted, bounded by uses: 1, the expiry, the two rate buckets, and a
per-key quota plus billing alert on the Google Cloud project.
Browser-Origin And Burst Limits
Section titled “Browser-Origin And Burst Limits”The backend accepts native/server requests without an Origin header. Browser
callers must come from the configured allowlist:
- default:
https://app.petal.live,https://meet.petal.live - local development:
http://localhost:<port>andhttp://127.0.0.1:<port> - override: comma-separated
PETAL_ALLOWED_ORIGINS
Disallowed browser origins are rejected before endpoint handlers run. /api/token
and /api/rooms also have best-effort warm-instance token buckets keyed by
forwarded client IP. This is not a global abuse-prevention service across all
Vercel instances, but it closes the unbounded same-instance polling/minting path
and keeps room/identity data out of limiter logs.
⚠️ Lockstep contract
Section titled “⚠️ Lockstep contract”lib/slug.ts MUST stay byte-for-byte behaviorally identical to:
apps/desktop/src-tauri/src/rooms.rs—slugify/livekit_room_name_forshared/logic/meetingCode.ts—slugify/livekitRoomName
If they diverge, the same invite credential maps to different LiveKit rooms on
different clients and people never meet. test/local.ts asserts the exact values
the native Rust unit tests use. Change one, change all three.
Separately, normalizeAccessCode/credentialForAccessCode (the access-code ->
credential half of lib/slug.ts, used for parsing join links) are also
duplicated in web-harness/api/_lib/slug.ts — that project’s own join-link
interstitial (web-harness/api/j.ts, served at meet.petal.live) needs them but
can’t import across Vercel project roots. Keep those two functions in sync too.
Auto-update & distribution
Section titled “Auto-update & distribution”Distribution is private — no public GitHub releases repo. The release CI (built separately) signs + notarizes macOS, builds a Windows installer without Authenticode signing, and uploads artifacts to Vercel Blob at fixed pathnames (no random suffix, so this backend can always find the current download without a database):
| Blob pathname | What it is |
|---|---|
latest.json |
the combined Tauri updater manifest, produced by CI |
Petal_<version>_universal.dmg |
the macOS human-facing download |
Petal_<version>_universal.app.tar.gz |
the macOS updater artifact |
Petal_<version>_universal.app.tar.gz.sig |
macOS Tauri updater signature |
Petal_<version>_windows_x86_64-setup.exe |
the unsigned Windows x86-64 NSIS download/updater artifact |
Petal_<version>_windows_x86_64-setup.exe.sig |
Windows Tauri updater signature |
latest.json contains darwin-aarch64 and darwin-x86_64 entries, plus
windows-x86_64 whenever the publish included a Windows artifact (a
macOS-only publish omits that key). The Windows .sig is a Tauri updater
signature; it is not Authenticode signing and does not prevent SmartScreen
warnings.
This backend only reads from Blob (lib/blob.ts, via list) — CI does
all the writing, this service never uploads anything. Three endpoints serve
that content:
GET /api/updater(also reachable at/latest.jsonvia avercel.jsonrewrite) — fetcheslatest.jsonfrom Blob and returns it verbatim. This is whattauri.conf.json’splugins.updater.endpointsshould point at. Returns204 No Contentif no release has been published yet so the updater treats it as “no update available” instead of logging a launch-time endpoint error.GET /api/download— defaults to the latest macOSPetal_<version>_universal.dmgblob for backward compatibility. Pass?platform=macosexplicitly for the same result, or?platform=windowsfor the latestPetal_<version>_windows_x86_64-setup.exe. Both responses 302-redirect to the public Blob URL. Unknown platform values return400.GET /— this project is a pure API host (app.petal.live); it just 302-redirects to the marketing site athttps://petal.live/.
Counting downloads
Section titled “Counting downloads”/api/download recorded nothing until, so “how many people took the
Windows build?” could only be answered from the Vercel request log —
dashboard-gated, short retention, and carrying no resolved version. Every
download it actually serves now also writes one structured line to stdout:
petal.metric {"event":"download","platform":"windows","version":"0.9.15"}Count them by grepping the runtime logs (or a log drain) for the
petal.metric prefix. A 400 (bad platform) or 404 (no artifact
published) writes nothing — only a served redirect is a download.
Three fields, and no more: platform is macos | windows | unknown, and
version is parsed from the artifact’s own blob pathname. Nothing is read
off the request — no IP, no User-Agent, no referrer, no header, no query
string, no identity. lib/distributionMetrics.ts takes primitives rather
than a VercelRequest so that stays true, and test/privacy.ts fails if a
fourth field or an unsanitized value ever appears. This is a count, not a
profile.
/api/updater deliberately has no per-platform equivalent: the request
carries no platform (the client picks its own key out of the manifest) and
the only remaining signal is the User-Agent, which is off limits. Adding
{{target}} to the client’s configured updater endpoint is the clean route
if that count is ever needed, and only builds shipped after that change would
send it.
Environment
Section titled “Environment”| Variable | Needed for |
|---|---|
LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET |
Required. Token minting and every room operation. |
PETAL_ALLOWED_ORIGINS |
CORS allowlist for browser callers (comma-separated origins). |
PETAL_ADMIN_TOKEN |
/api/admin kick/close. Without it the endpoint answers 503. |
BLOB_READ_WRITE_TOKEN |
/api/updater and /api/download (release distribution; see below). |
SENTRY_DSN |
Backend error reporting. |
GEMINI_API_KEY, GEMINI_LIVE_MODEL, GEMINI_API_VERSION |
/api/ai-token (AI chat). |
PETAL_DEPLOY_COMMIT |
Reported by /api/version; set by the deploy script. |
Distribution env var: BLOB_READ_WRITE_TOKEN (alongside the 3 required
LIVEKIT_* ones). Despite the name, this backend
only ever calls list with it — Vercel Blob’s list API needs a token
even for read-only access; the actual blob URLs it returns are public CDN
URLs that don’t need the token to fetch. Get it from the Vercel project’s
Storage → Blob tab (same project, or a linked Blob store) and set it as a
Project env var like the LiveKit ones.
Optional admin env var: PETAL_ADMIN_TOKEN enables /api/admin. Without it,
admin-control requests return 503 and no participant kick or room close is
available through the backend.
Local dev + test
Section titled “Local dev + test”Needs a local SFU: livekit-server --dev (listens on :7880, key/secret
devkey/secret).
npm installnpm run typechecknpm test # offline suites: distribution, privacy, ai-token, rooms-resilience, hardeningnpm run test:local # runs against livekit-server :7880 (real create/list/delete)test/local.ts verifies slug lockstep, JWT grants, a real RoomServiceClient
round-trip against the running server, and rooms-directory idempotency.
Deploy (Vercel)
Section titled “Deploy (Vercel)”- New Vercel project, root directory =
backend/. - Set Project env vars (server-side, never exposed to clients):
LIVEKIT_URL,LIVEKIT_API_KEY,LIVEKIT_API_SECRET(from the LiveKit Cloud project) +BLOB_READ_WRITE_TOKEN(from the project’s Blob store — see “Auto-update & distribution” above). Still no database to provision. - The deployed base URL (
https://app.petal.live) is what the client bakes in as its token endpoint (internal/ISSUES.md) and its updater endpoint (/api/updater, alias/latest.jsonserved from Vercel Blob). The marketing site (https://petal.live) is a separate repo/project (petal-website); the browser SPA and join links live athttps://meet.petal.live(web-harness).
Client config
Section titled “Client config”Desktop clients read PETAL_BACKEND_URL and call this service for
POST /api/token and POST /api/rooms/status; browser harness builds can set
VITE_PETAL_BACKEND_URL to the same deployed base URL. LiveKit API secrets stay
in this backend’s environment only. Local probe binaries may still use
LIVEKIT_* directly for transport diagnostics, but the app join/gallery/rooms
paths do not.