# Juke Audio > Juke is live participatory audio for Farcaster. Developers can embed live > Juke spaces on any website, let visitors listen anonymously, and optionally > upgrade listeners into authenticated Farcaster participants. Primary site: https://juke.audio API base URL: https://api.juke.audio Hosted embed base URL: https://juke.audio/embed/{spaceId} Developer dashboard: https://juke.audio/developers LLM implementation guide: https://juke.audio/SKILL.md Release feed (JSON, CORS-open): https://juke.audio/changelog.json ## What To Build Use Juke when a user asks for: - embedding a live Juke audio space - adding anonymous live listening to a webpage - adding "sign in to participate" for Farcaster users - adding reactions, replies, hand raise, or host-approved speaking to a space - creating custom Juke apps or server integrations with developer API keys - sharing a canonical Juke space URL Agent participation is a separate join path (paid via x402, or free for approved developer apps joining their own rooms — see "Agents" below). It's not part of the normal embed integration. ## Key Rule: When Keys Are Required Hosted iframe embeds do not need API keys. They are the default path for public websites, landing pages, blogs, community dashboards, and fast MVP embeds. Juke developer keys are only for custom SDK or server integrations that call protected Juke developer endpoints, such as creating apps, minting rooms from a server, or managing custom integration state. Never ask developers for Neynar API keys, LiveKit keys, or Juke backend credentials for ordinary embeds. Developers manage Juke apps and keys at: ```txt https://juke.audio/developers ``` Developer access states: - signed out: prompt Farcaster SIWF before loading developer apps - pending: no keys yet; hosted iframe still works without keys - approved: create apps, create keys, rotate keys, revoke keys - suspended: custom API key calls are disabled; hosted public embeds can remain available ## Fastest Integration: Hosted Iframe Use this when the developer wants spaces live quickly and accepts Juke-owned UI. ```html ``` Behavior: - no auth is required to render public metadata - visitors can listen anonymously - visitors sign in with Farcaster/Neynar only when they want to participate - speaking always requires host/co-host promotion inside the Juke permission model - "Powered by Juke" attribution must remain visible Third-party iframe integrations do not need a Neynar API key, LiveKit key, or Juke backend credentials. Juke hosts the auth, audio token issuance, and UI. ## Custom Integration: SDK Shape Use this when the developer wants custom UI. ```ts import { createJukeEmbedSdk } from "@juke/audio-sdk"; const juke = createJukeEmbedSdk(); const space = await juke.getSpace(spaceId); // 1. Anonymous listening — no sign-in required. const anonymousJoin = await juke.joinAnonymousListener(spaceId); await juke.connectAudio(anonymousJoin); // 2. When the visitor opts into participation, run Sign In With Farcaster. // The SDK fetches a single-use nonce from the backend, opens a SIWF // channel via the Farcaster auth relay, and returns a deeplink the // user opens in their Farcaster client. const { channelToken, url } = await juke.startSiwfFlow(); // 3. Render `url` (a `farcaster://connect?...` deeplink) as a QR code // on desktop or as a tap button on mobile. The user approves in // their Farcaster client, which displays the signing domain on the // approval screen. const approved = await juke.pollSiwfStatus(channelToken); // 4. Finish login and join the room as an authenticated participant. // The backend re-verifies the signature, the domain, and the nonce // before issuing a Juke JWT. await juke.completeSiwfLogin(approved); const authedJoin = await juke.joinAuthenticated(spaceId); await juke.connectAudio(authedJoin); await juke.raiseHand(spaceId, true); await juke.sendReaction("clap"); ``` SDK capabilities: - `getSpace(id)` - `joinAnonymousListener(id)` - `startSiwfFlow()` — returns `{channelToken, url}`; render `url` as a QR for desktop scan or as a tap button on mobile. - `pollSiwfStatus(channelToken, { signal? })` — resolves to `{message, signature, fid}` when the user signs; rejects on abort. - `completeSiwfLogin({message, signature})` — finalizes the Juke session. - `joinAuthenticated(id, token?)` - `leaveSpace(id)` - `refreshToken(id)` - `sendReaction(id, reaction)` - `raiseHand(id, raised)` - `connectAudio(joinResponse)` - `enableMicrophone()` only after host approval Do not open the SIWF `url` in a desktop popup — it is a Farcaster deeplink (`farcaster://connect?...`) and renders blank in a normal browser tab. Show a QR code so the user can scan it with their Farcaster app, plus a tap-to-deeplink button for visitors already on mobile. If building custom UI, keep visible Juke attribution and link to the canonical space page: `https://juke.audio/space/{spaceId}`. ## Custom Server Integration: Developer Keys Use Juke developer keys only from a trusted server environment. Do not place a developer key in browser JavaScript, mobile app bundles, iframe params, public environment variables, public repos, logs, analytics events, crash reports, or client-side error monitoring. End-to-end flow: 1. Open `https://juke.audio/developers`. 2. Sign in with Farcaster using SIWF. 3. Request developer access with the app/use-case details. 4. Wait for Juke admin approval. 5. Create an app with the production origins where the integration runs. 6. Create a key and copy the one-time secret immediately. 7. Store the secret in a private server environment variable such as `JUKE_API_KEY`. 8. Embed hosted spaces without a key when you only need public listening. 9. Call protected developer APIs from your backend only. Two distinct auth paths: - **`/v1/developer/spaces`** (room creation from a server) — **key only**. Send `X-Juke-Api-Key: `; do not send a bearer JWT. The room owner is derived from the key's owning developer app (`app.owner_fid`). This is the real machine credential. - **`/v1/developer/apps/*`** (dashboard routes: list apps, list keys, create app, etc.) — **JWT only**, scoped to the developer's signed-in session. Dangerous mutations (rotate, revoke, reveal, delete-app) additionally require a recent SIWF within the last 5 minutes; the server signals this with `401 "Recent sign-in required."` and a `WWW-Authenticate: ReAuth` header. The dashboard handles this for you — server integrations rarely need these endpoints. Example server-side call: ```ts const response = await fetch("https://api.juke.audio/v1/developer/spaces", { method: "POST", headers: { "X-Juke-Api-Key": process.env.JUKE_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ title: "Weekly builder room", scheduled_at: null, announce_cast: false, allow_agents: true, }), }); if (!response.ok) { throw new Error("Juke developer API request failed"); } ``` The room host is derived from the API key — specifically, the key's owning developer app's `owner_fid`. Do not send any host identifier or host override in developer API requests. If the `Origin` header is sent (browser-initiated requests) and the developer app has `allowed_origins` configured, the Origin must match one of the listed origins. Server-to-server requests with no `Origin` header are not subject to this check. Secret handling: - Juke shows a key secret only once at creation or rotation time - lost secrets cannot be revealed again; rotate the key instead - rotate keys from the dashboard before replacing production env vars - revoke keys immediately if they are exposed or no longer used - never expose Juke API keys in browser JS, mobile bundles, iframe URLs, `NEXT_PUBLIC_*`/`EXPO_PUBLIC_*` variables, logs, analytics, or screenshots Developer API wrapper contract: ```http GET /v1/developer/status POST /v1/developer/application GET /v1/developer/apps POST /v1/developer/apps GET /v1/developer/apps/{appId}/keys POST /v1/developer/apps/{appId}/keys POST /v1/developer/apps/{appId}/keys/{keyId}/reveal POST /v1/developer/apps/{appId}/keys/{keyId}/rotate POST /v1/developer/apps/{appId}/keys/{keyId}/revoke POST /v1/developer/spaces POST /v1/developer/spaces/{roomId}/end POST /v1/developer/partner-tokens POST /v1/developer/rooms/{roomId}/agent-join POST /v1/developer/webhooks GET /v1/developer/webhooks DELETE /v1/developer/webhooks/{webhookId} ``` ### Ending a room from your server Force-end an active room your app created. The room must have been minted via `POST /v1/developer/spaces` (cross-app and iOS-native rooms 404 — same response shape so partners can't enumerate other apps' UUIDs). ```http POST /v1/developer/spaces/{room_id}/end X-Juke-Api-Key: ``` 200 returns `{"status": "ended"}`. Outbound `room.finished` fires immediately on success — no waiting on LiveKit's 5-minute empty-room timeout. The webhook payload carries `"ended_via": "api"` so you can distinguish it from a human host pressing End Space (`"ended_via": "host"`). Idempotent: a second call against an already-ended room returns 200 without re-firing. ## Auth Ladder 1. Public metadata: no auth. 2. Anonymous listen: `POST /v1/rooms/{spaceId}/anonymous-join`. 3. Human participation on ordinary web: Sign In With Farcaster (SIWF). SDK calls `startSiwfFlow()` → renders the resulting `farcaster://connect` deeplink as a QR for desktop scan or a tap button on mobile → polls `pollSiwfStatus()` until signed → `completeSiwfLogin()`. The signing domain is bound into the SIWE message and displayed to the user at approval. 4. Farcaster miniapps: Quick Auth via `@farcaster/miniapp-sdk`. 5. Native Juke iOS app: on-device secp256k1 auth address registered via the developer-managed signed key API (separate from the web SDK). 6. Speaking: only after host/co-host promotion grants LiveKit publish permission. Promotion + mic publish works the same on web (SDK / hosted iframe via `livekit-client`) and native iOS — desktop speakers are first-class once promoted. Anonymous listener tokens are short-lived and subscribe-only: - `can_subscribe=true` - `can_publish=false` - `can_publish_data=false` ## Public API Reference Read space metadata: ```http GET https://api.juke.audio/v1/rooms/{spaceId} ``` Anonymous listen-only join: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/anonymous-join ``` Authenticated listener join: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/join Authorization: Bearer {jukeJwt} ``` Raise/lower hand: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/raise-hand Authorization: Bearer {jukeJwt} Content-Type: application/json {"raised": true} ``` Refresh LiveKit token: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/token Authorization: Bearer {jukeJwt} ``` ## Reading Participants `GET /v1/rooms/{spaceId}` returns the active participant list in the same response as room metadata — no separate endpoint needed. ```jsonc { "room": { "id": "…", "title": "Weekly builder room", "host_fid": 12345, "host": { "fid": 12345, "username": "host", "display_name": "Host", "pfp_url": "https://…" }, "status": "active", // "scheduled" | "active" | "ended" "started_at": "2026-05-23T16:00:00Z", "ended_at": null, "scheduled_at": null, "speaker_count": 3, "listener_count": 27, "recording": false, "allow_agents": true }, "participants": [ { "fid": 12345, "display_name": "…", "pfp_url": "https://…", "role": "host", "is_muted": false, "hand_raised": false }, { "fid": 23456, "display_name": "…", "pfp_url": "https://…", "role": "speaker", "is_muted": false, "hand_raised": false }, { "fid": 34567, "display_name": "…", "pfp_url": "https://…", "role": "listener", "is_muted": true, "hand_raised": true } ], "hand_queue": [34567] } ``` `role` is one of `host`, `co_host`, `speaker`, `listener`. Use this for "who's in the space" UIs (member badges, pfp stacks, counts). ## Recording Hosts toggle recording from the native app or via the developer API: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/recording/start POST https://api.juke.audio/v1/rooms/{spaceId}/recording/stop ``` After the space ends and the LiveKit egress completes, the recording is addressable two ways: - `GET /v1/recordings/{spaceId}` — returns `{ recording_url, started_at, ended_at, duration_seconds, cast_hash, title }`. `recording_url` is a short-lived presigned URL — re-fetch when expired. - Hosted web player: `https://juke.audio/r/{spaceId}` (own OG image, embeddable in casts and tweets). There is no clip / segment API in v1. ## Scheduled Spaces Pre-create a recurring space with `scheduled_at`: ```ts await fetch("https://api.juke.audio/v1/developer/spaces", { method: "POST", headers: { "X-Juke-Api-Key": process.env.JUKE_API_KEY!, "Content-Type": "application/json" }, body: JSON.stringify({ title: "Fractal Call", scheduled_at: "2026-05-25T22:00:00Z", // ISO 8601, UTC allow_agents: true, }), }); ``` Before `scheduled_at`, `GET /v1/rooms/{spaceId}` returns `status: "scheduled"` with `title`, `host`, `scheduled_at`, and `allow_agents`, but no LiveKit token or join URL. The hosted embed (`/embed/{spaceId}`) renders a "starts in …" countdown over the same metadata. When the host opens the space (or the scheduler auto-starts it), `status` transitions to `active` and join becomes available. ## Agents Agents are a separate join path — not part of the normal embed flow. Two ways in: pay per-join via x402 (any agent, any room with `allow_agents=true`), or use a developer API key for free joins scoped to rooms your own app created. Agents join as data-publishing participants (transcription, side-channel metadata) and do not publish audio in v1. The room must be created with `allow_agents: true`. To join an active room: ```http POST https://api.juke.audio/v1/rooms/{spaceId}/agent-join Content-Type: application/json { "agent_name": "ZOE", "agent_pfp_url": "https://…" } ``` The endpoint is gated by x402 payment on first call and returns a `session_token` you reuse via `X-Session-Token` for the duration of the room. Audio-publishing agents (speaker role) are on the v1.x roadmap. ### Partner agents (free, scoped to your own rooms) If you're an approved developer and want to run your own bot in rooms your app created, skip the x402 path and use the partner-scoped variant. Same request/response shape; auth swaps to your developer API key: ```http POST https://api.juke.audio/v1/developer/rooms/{spaceId}/agent-join X-Juke-Api-Key: Content-Type: application/json { "agent_name": "ZOE", "agent_pfp_url": "https://…" } ``` Constraints: - `room.created_by_app_id` must equal your app id; cross-app rooms and unknown rooms both 404 (same response, so the endpoint can't be used to enumerate which UUIDs belong to other developers) - `room.status == "active"` and `room.allow_agents == true` (same gates as the public path) - iOS-native rooms (no owning developer app) are not reachable here - Per-room agent cap: 5 concurrent. Hitting it returns 429. - Rate limit: 10/min and 100/day per key. Rejoin via `X-Session-Token` on the same endpoint works identically to the paid path. The returned `session_token` is interchangeable with the paid path's, so the same token-refresh / leave / rejoin flow applies. Sessions spawned this way are tagged `payer_address="partner:{app_id}"` for audit. ## Partner SSO Bridge When the embedding site has already authenticated the visitor (SIWN, SIWE, custom session), pre-mint a short-lived Juke JWT from your server and pass it on the iframe URL so the visitor doesn't repeat SIWF inside the embed. Server-side mint (key-only auth): ```http POST /v1/developer/partner-tokens X-Juke-Api-Key: Content-Type: application/json { "fid": 12345, "ttl_seconds": 300 } ``` `fid` is the Farcaster ID of the visitor you've already verified. `ttl_seconds` is `[60, 600]`, defaults to 300. Response: ```json { "token": "eyJ...", "fid": 12345, "expires_at": "2026-05-23T17:05:00Z", "partner_app_id": "" } ``` Pass the token on the iframe URL: ```html ``` The iframe adopts the session client-side, strips `?token=` from the URL via `history.replaceState` (so the JWT doesn't leak via Referer headers on outbound asset requests), and renders as already-authenticated — no QR. The backend re-verifies the JWT on every authed call (`/join`, `/raise-hand`, `/leave`, `/token`) so a forged or expired token still fails at use. Trust model + caveats: - Possession of an active Juke API key + the partner's assertion of `fid`. We trust the partner because they're an approved developer app. - Damage from a leaked API key is bounded by: the TTL cap (≤ 10 min), the `partner_app_id` + `source="partner"` claims baked into each JWT for audit attribution and downstream gating, per-key rate limiting on the mint endpoint (60/min + 5,000/day), and the first-party gate described below. - Partner-minted JWTs are deliberately limited to **room participation**: `/join`, `/leave`, `/raise-hand`, `/token`. Sensitive endpoints that act on the user's account — developer-dashboard ops (apps, keys, webhooks, application status), room creation, recording start/stop — reject any JWT carrying `source="partner"` with a 401 telling the caller to sign in directly on juke.audio. So a leaked partner token cannot be used to manage the user's developer apps or attribute new rooms/recordings to them. - No refresh token is issued. When the JWT expires, re-mint from your server or let the visitor sign in via SIWF inside the embed. ## Native App Deeplinks The Juke iOS app advertises two link forms: - Custom scheme: `juke://space/{spaceId}` (and `juke://recording/{spaceId}`) - Universal link: `https://juke.audio/space/{spaceId}` Use the **universal link** form for desktop CTAs ("Open in Juke") and shared links. iOS resolves it to the native app when installed; everywhere else falls back to the hosted web page. `{spaceId}` is a UUID — the handler rejects anything else. ## Lifecycle Webhooks Outbound webhooks fire only for rooms created via the developer API (rooms with `created_by_app_id` set). iOS-native rooms do not emit developer webhooks. Available events: - `room.started` — scheduled→active transition (and immediate room create) - `room.finished` — the room ended. Two trigger paths: - **Explicit end**: a host called End Space (iOS) or your server hit `POST /v1/developer/spaces/{id}/end`. Payload's `ended_via` is `"host"` or `"api"` respectively. Fires immediately. - **Empty-room timeout**: LiveKit reports the room emptied for 5 minutes. Payload omits `ended_via`. - `participant.joined` / `participant.left` — real humans + agents only (anonymous listeners and LiveKit virtual participants are filtered out) - `recording.ready` — egress completed; payload includes a presigned URL Register from your server: ```http POST /v1/developer/webhooks X-Juke-Api-Key: Content-Type: application/json { "url": "https://your-app.example/juke-webhooks", "events": ["room.finished", "recording.ready"] } ``` The 201 response includes a `secret` (prefixed `whsec_`) — store it; it is never re-revealable. Rotate by deleting + re-creating. Also available: `GET /v1/developer/webhooks` (list, with `last_delivery_at` + `last_status` + `last_error` for debugging) and `DELETE /v1/developer/webhooks/{id}`. Delivery format: ```http POST Content-Type: application/json User-Agent: JukeWebhooks/1 X-Juke-Signature: t=,v1= { "event_id": "uuid-v4", "event_type": "room.finished", "occurred_at": "2026-05-23T16:42:00.000+00:00", "data": { "room_id": "…", "host_fid": 12345, "started_at": "…", "ended_at": "…" } } ``` Verify by recomputing HMAC-SHA256 of `f"{timestamp}.{raw_body}"` with your stored secret and comparing in constant time. Reject events older than ~5 minutes. Use `event_id` for idempotency. Retries: 4 attempts at t=0, +10s, +60s, +300s for non-2xx or transport failures, then stop. The last status / error is exposed on `GET /v1/developer/webhooks` so you can diagnose silently failing endpoints. `last_error` is one of a fixed vocabulary — never a raw exception or response body — so the field cannot be used as a network probe: ``` "" | timeout | connection_failed http_3xx | http_4xx | http_5xx | http_other blocked_ip | dns_failure | invalid_url | no_attempts ``` After 10 consecutive failed deliveries we auto-disable the subscription (stamp `disabled_at` and stop dispatching). Re-enable by deleting and re-creating it. The webhook URL must resolve to a globally-routable public IP at delivery time. Loopback, link-local (including AWS/GCP IMDS at 169.254.169.254), private RFC1918, and other non-public ranges are rejected per-attempt with `last_error: blocked_ip`. Redirects are not followed. Max 5 subscriptions per app, and the same URL cannot be registered twice for the same app. ## Release Feed Structured changelog of shipped developer-facing changes, machine-readable and CORS-open for partner integration manifests: ```http GET https://juke.audio/changelog.json ``` Schema: ```jsonc { "version": 1, "generated_at": "2026-05-23T...", "canonical_spec": "https://juke.audio/llms.txt", "entries": [ { "id": "partner-agent-join", // globally unique, never reused "shipped_at": "2026-05-23", "category": "developer-api", // | "embed" | "webhooks" | "docs" "title": "…", "summary": "…", "endpoints": ["POST /v1/developer/rooms/{room_id}/agent-join"], "docs": "https://juke.audio/llms.txt", "docs_section": "Partner agents (free, scoped to your own rooms)", "resolves": ["agents"], // opaque partner slugs "breaking": false // optional } ] } ``` If you maintain a structured integration manifest (e.g. `/api/juke/status` listing your open asks against Juke), match each entry's `resolves[]` against your `open_asks[].id` to flip resolved items deterministically. Diff on `shipped_at` to incrementally update. ## What You Actually Need (SDK / iframe integration) The hosted Juke backend runs all the infrastructure for you. SDK and iframe integrators do **not** need to provision Neynar API keys, LiveKit keys, JWT secrets, databases, Redis, or anything else. - **Hosted iframe**: no env vars, no secrets, no setup. - **Server-side integrations** calling protected developer APIs: exactly one env var on your server — `JUKE_API_KEY`, the `secret_key` shown once when you create a key in the Juke developer dashboard. Store it somewhere private (cloud secret manager, vault, sealed env). It is never used in the browser. That's it. Skip the next section unless you are running your own Juke backend stack. ## Self-Hosting (rare — only if you're running the Juke backend yourself) You only need this if you are forking Juke and operating your own backend, e.g. for compliance, air-gapped deployment, or contributing to the project. SDK consumers integrating Juke into their own site do not need any of these. Backend env keys (the Juke server stack): - `DATABASE_URL` (Postgres 16) - `REDIS_URL` (Redis 7) - `JWT_SECRET`, `JWT_ALGORITHM`, `JWT_EXPIRY_HOURS`, `JWT_REFRESH_EXPIRY_DAYS` - `NEYNAR_API_KEY`, `NEYNAR_CLIENT_ID` - `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_WS_URL` - `FARCASTER_APP_FID`, `FARCASTER_APP_MNEMONIC` (used by the miniapp auth-address registration and snap signing — SIWF login does not require these; the user's own Farcaster custody signs) - `SIWF_DOMAIN` (the domain the backend will require in SIWF signed messages; e.g. `juke.audio`) - `JUKE_API_KEY_PEPPER` (≥32 bytes random; peppers stored API key hashes) - `JUKE_API_KEY_ENCRYPTION_KEY` (exactly 32 bytes base64; AES-256-GCM key for the once-only revealable secret blob) - `JUKE_API_AUDIT_SECRET` (≥32 bytes random; HMAC key for audit trail entries — optional but recommended) - `QUICKAUTH_ALLOWED_AUDIENCES`, `CORS_ORIGINS` - `ENVIRONMENT` (`development` or `production`; the backend enforces stricter pepper/key length and Secure-cookie/Domain checks when set to `production`) Pepper and encryption key rotation is destructive — rotating the pepper invalidates every stored key hash; rotating the encryption key makes every un-revealed secret undecryptable. Generate once per environment and back up in a secret manager. Landing env keys: - `NEXT_PUBLIC_API_BASE_URL` - `NEXT_PUBLIC_SITE_URL` Native app env keys: - `EXPO_PUBLIC_NEYNAR_CLIENT_ID` - `EXPO_PUBLIC_NEYNAR_API_KEY` - `EXPO_PUBLIC_API_BASE_URL` - `EXPO_PUBLIC_LIVEKIT_WS_URL` Local dev commands: ```bash cd backend && docker-compose up cd landing && npm run dev ``` ## Design Rules Juke embeds should feel like a small live listening bar: - dark navy base `#0f0f23` - terracotta live/action accent `#D85A30` - purple secondary/action accent `#855DCD` - warm, social copy - rounded, polished controls - visible "Powered by Juke" Never remove attribution, bypass auth for participation, bypass host permission for speaking, or hide the canonical Juke space/source identity.