# 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 GET /v1/developer/apps/{appId}/bots POST /v1/developer/spaces GET /v1/developer/spaces/{roomId} POST /v1/developer/spaces/{roomId}/start POST /v1/developer/spaces/{roomId}/end POST /v1/developer/bots GET /v1/developer/bots/{botId} POST /v1/developer/bots/{botId}/signer DELETE /v1/developer/bots/{botId} POST /v1/developer/partner-tokens POST /v1/developer/rooms/{roomId}/agent-join POST /v1/developer/webhooks GET /v1/developer/webhooks GET /v1/developer/webhooks/{webhookId} DELETE /v1/developer/webhooks/{webhookId} ``` ### Reading a room you own `GET /v1/developer/spaces/{room_id}` (key-auth, app's own rooms only) returns the same shape as `GET /v1/rooms/{id}` — room metadata + live participant list + hand queue — so you can reconcile against the source of truth after a missed webhook delivery or answer "who's actually here right now" without polling LiveKit. Cross-app and iOS-native rooms 404 (no enumeration oracle). ### Reading a webhook subscription `GET /v1/developer/webhooks/{webhook_id}` returns a single subscription's current state — `url`, `events`, `last_delivery_at`, `last_status`, `last_error`, `disabled_at`, `consecutive_failures`. Use this to introspect a known-id subscription without paging the full list. ### Rate-limit response headers Every key-authed mutation response (success or 429) includes: ``` X-Juke-Rate-Limit-Limit: X-Juke-Rate-Limit-Remaining: X-Juke-Rate-Limit-Reset: ``` Use these to back off proactively rather than burning into 429s. Multi-tier limits (e.g. partner-tokens has both per-minute and daily buckets) expose headers for the per-minute tier; the daily cap surfaces only via 429 + `Retry-After` when hit. ### 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 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: ``` For Farcaster-native rooms where `host_mode == "user_fid"`, include a **fresh** `user_jfs_bearer` in the JSON body (the bearer FID must match the room's host FID → 403 mismatch). Bot-hosted and Juke-hosted rooms need no body. 200 returns `{"status": "ended"}`. Outbound `room.finished` fires immediately on success — no waiting on LiveKit's 5-minute empty-room timeout. The webhook payload's `ended_via` is `"api"` for server calls, `"host"` for a human pressing End Space, or `"upstream"` when the reconciler observed the room end on Farcaster's side. Idempotent: a second call against an already-ended room returns 200 without re-firing. ### Farcaster-native spaces from your server Create rooms that appear in the Farcaster spaces carousel as first-party Farcaster audio rooms. Two host models: - **Bot-hosted** — Juke generates and holds an encrypted Ed25519 app key for a bot FID you own. After a one-time Warpcast approval by the bot account, any API-key call can host rooms as that bot with no further user interaction. - **User-hosted** — your server passes a fresh JFS bearer signed by the end user's on-device Ed25519 app key (header `{fid, type:"app_key", key}`, payload audience `https://client.farcaster.xyz`, 5-minute expiry). Juke cross-checks the FID, forwards the bearer upstream once, and never stores it. #### Bot signer setup (one-time per bot FID) **Step 1.** Register the bot (Juke manages the key): ```bash curl -X POST https://api.juke.audio/v1/developer/bots \ -H "X-Juke-Api-Key: $JUKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fid": 123456, "label": "my-space-bot"}' ``` Response 201: ```json { "id": "bot_abc123", "fid": 123456, "label": "my-space-bot", "is_active": true, "auth_mode": "app_key", "signer_status": "pending_approval", "signer_public_key": "0xabc...", "approval_url": "https://client.farcaster.xyz/deeplinks/signed-key-request?...", "ready": false } ``` **Step 2.** Have the bot account open `approval_url` in Warpcast exactly **once**, from the bot's own Farcaster app. If the wrong account approves, `signer_status` becomes `fid_mismatch` — call `POST /v1/developer/bots/{bot_id}/signer` to re-register with a fresh keypair (also recovers an expired 24 h deadline or a revoked key). `approval_url` is null once the status reaches a terminal state. **Step 3.** Poll `GET /v1/developer/bots/{bot_id}` (≤ 1 per 30 s is safe; the server caches Neynar checks) until `ready: true`: ```bash curl https://api.juke.audio/v1/developer/bots/bot_abc123 \ -H "X-Juke-Api-Key: $JUKE_API_KEY" ``` `ready: true` is the single gate field. Cross-app requests return an opaque 404 (indistinguishable from unknown IDs). **Step 4.** Create a bot-hosted native room: ```bash curl -X POST https://api.juke.audio/v1/developer/spaces \ -H "X-Juke-Api-Key: $JUKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Friday build call", "host": "farcaster", "host_bot_fid": 123456, "scheduled_at": null, "allow_agents": true }' ``` Unapproved signer → 409 with actionable detail. Response room objects include `host_mode: "bot"` and `farcaster_host_fid`. #### User-hosted native room ```bash curl -X POST https://api.juke.audio/v1/developer/spaces \ -H "X-Juke-Api-Key: $JUKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "My space", "host": "farcaster", "user_jfs_bearer": "" }' ``` Supplying both or neither of `host_bot_fid` / `user_jfs_bearer` → 422. Response: `host_mode: "user_fid"`, `farcaster_host_fid` set to the bearer FID. #### Going live on a scheduled native room Use the new developer start endpoint to flip a scheduled room to active: ```bash curl -X POST https://api.juke.audio/v1/developer/spaces/{room_id}/start \ -H "X-Juke-Api-Key: $JUKE_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` For user-hosted rooms, supply a **fresh bearer** (fid must match the room host → 403 mismatch). Bot-hosted rooms need no body. Idempotent (already active → 200). Upstream 4xx → 400; upstream 5xx → 502 (room stays scheduled, safe to retry). #### Reading participants for native rooms `GET /v1/developer/spaces/{room_id}` proxies the upstream Farcaster participant list for native rooms. `is_muted` is always `false` for native rooms (not observable upstream). The response header `X-Juke-Participants-Source: upstream|unavailable|juke` distinguishes an empty room from a read failure (reads never 502). #### Legacy custody-env bot mode `POST /v1/developer/bots` still accepts `custody_address` + `custody_key_ref: "env:VAR"` for bots whose key lives in Juke's backend env. Response includes `auth_mode: "custody_env"`, `ready: true`, and null signer fields. Mixed/partial custody pair → 422. ## 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. For Farcaster-native spaces scheduled via the developer API, use `POST /v1/developer/spaces/{room_id}/start` to go live on demand (see "Farcaster-native spaces from your server" above). Juke-hosted scheduled rooms auto-start at `scheduled_at` without an explicit call. ## 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). Available events: - `room.started` — scheduled→active transition (and immediate room create). Native rooms include additional data fields: `is_farcaster_native: true`, `farcaster_room_id`, `host_mode` (`"bot"` | `"user_fid"`), and `farcaster_host_fid`. - `room.finished` — the room ended. Three trigger paths: - **Explicit end by host**: host called End Space in the Farcaster app. Payload's `ended_via` is `"host"`. Fires immediately. - **Explicit end via API**: your server called `POST /v1/developer/spaces/{id}/end`. Payload's `ended_via` is `"api"`. Fires immediately. - **Upstream observed end**: the native-rooms reconciler detected the room ended on Farcaster's side. Payload's `ended_via` is `"upstream"`. Same `is_farcaster_native` / `farcaster_room_id` / `host_mode` / `farcaster_host_fid` fields as `room.started`. - **Empty-room timeout** (Juke-hosted rooms only): 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). **Not emitted for native rooms** — poll `GET /v1/developer/spaces/{room_id}` instead. - `recording.ready` — egress completed; payload includes a presigned URL. **Not emitted for native rooms.** 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= X-Juke-Idempotency-Key: { "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. Dedupe on `X-Juke-Idempotency-Key` (= `event_id`); same value on every retry of the same event so a prior successful delivery that your side ack'd late is still recognizable. 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.