{"openapi":"3.1.0","info":{"title":"PealCast API","version":"1.0.0","description":"The **PealCast** church-facing developer API — the follow-up engine for online church.\n\nPealCast turns your live stream's anonymous crowd into named people with a verified phone\nnumber, then hands your team a weekly **Shepherd's Report** of who to welcome (**New Faces**)\nand who to quietly check on (**Missed You**). This API lets you read that same data\nprogrammatically and manage your channels, audience, and go-live notifications.\n\n## Authentication\n\nEvery request is authenticated with an opaque **bearer token** and every token is pinned to\nexactly **ONE church** (organization). You create tokens in the app dashboard under\n**Developer / API tokens**:\n\n- `pc_live_…` — a **production** token (talks to `https://api.pealcast.io`).\n- `pc_test_…` — a **non-production** token (talks to `https://api.dev.pealcast.io`).\n\nAccess is set **per token**: a **read** token can only call `GET` endpoints, while a\n**read/write** token may also create and update. A read-only token that calls a write\nendpoint receives `403 READ_ONLY_TOKEN`.\n\n## Player endpoints are public\n\nThe **Player (public)** endpoints power the watch page and take **no authentication** — they\nreturn only what a viewer's browser is allowed to see.\n\n## AI assistants (MCP)\n\nAn **MCP server** is available at **`mcp.pealcast.io`** so AI assistants (Claude and others)\ncan read your Shepherd's Report and audience with the same church-scoped token.\n","contact":{"name":"PealCast Support","url":"https://pealcast.io"}},"servers":[{"url":"https://api.pealcast.io","description":"Production"},{"url":"https://api.dev.pealcast.io","description":"Development"}],"security":[{"BearerAuth":[]}],"tags":[{"name":"Audience","description":"Your **owned, verified audience** — the real people (watchers/viewers) who have watched a\nstream, deduped by verified mobile number. A \"watcher\" (or \"viewer\") is a person who watched\nat least one broadcast.\n"},{"name":"Shepherd's Report","description":"The weekly follow-up engine: **New Faces** (new watchers to welcome) and **Missed You**\n(drifting watchers to check on), each with a name and a verified phone number, plus the\ncontrols to send it on demand.\n"},{"name":"Channels","description":"A church's streaming channels — **broadcast** (one-to-many service) or **conference**\n(two-way prayer line) — and their encoder/ingest configuration.\n"},{"name":"Notifications","description":"Go-live notifications and the delivery integrations behind them (our SMS/email, Planning\nCenter, custom domain) — who is subscribed and what has been sent.\n"},{"name":"Account","description":"Your church's plan, trial/billing state, and Shepherd's Report scheduling preferences.\n"},{"name":"Player (public)","description":"Public, unauthenticated endpoints that power the watch page — live status, playback URL, and\nthe church's public channel list.\n"},{"name":"Auth","description":"**Internal.** Passwordless phone/email + OTP sign-in and church-admin signup for the app,\nadmin, and watcher surfaces. Not part of the developer token contract.\n"},{"name":"Billing","description":"**Internal.** The dashboard billing/checkout flow — Stripe card capture, plan subscribe,\npre-pay account credit, and the billing summary. Not part of the developer token contract.\n"},{"name":"Developer keys","description":"**Internal.** Session-only management of the church's REST developer keys and personal AI\n(MCP) connections. These endpoints require a human dashboard login — a `pc_` token can never\nmint or enumerate credentials.\n"},{"name":"Gate","description":"**Internal.** The customizable capture gate — the church-side editor and the public\nwatcher-side prayer/connect verification that identifies a viewer by verified mobile.\n"},{"name":"Planning Center","description":"**Internal.** The confidential Planning Center OAuth flow (authorize URL + server-side code\nexchange) and the inbound PCO webhook for two-way sync.\n"},{"name":"Webhooks","description":"**Internal.** Inbound webhooks from external providers — Cloudflare Stream (go-live\ndetection), SignalWire (SMS status + inbound), and Planning Center. Authenticated by HMAC\nsignature, never a bearer token.\n"},{"name":"Admin","description":"**Internal.** The platform-admin (Smart Talk staff) surface — `actor='admin'` only.\n"},{"name":"Marketing","description":"**Internal.** Public `/www/*` endpoints consumed by the marketing site — live Founding-100\nseat count, the pricing plan catalog, and the OpenAPI spec as JSON.\n"},{"name":"System","description":"**Internal.** Operational endpoints — the health check used by CI deploy verification and\nuptime monitoring.\n"},{"name":"MCP","description":"**Internal.** The Model Context Protocol JSON-RPC server (`mcp.pealcast.io`) that lets a\nchurch point its own AI assistant at its PealCast data with a church-scoped `pc_` token.\n"}],"paths":{"/app/channel-analytics":{"get":{"operationId":"getShepherdsReport","summary":"Get a channel's Shepherd's Report and analytics","description":"Returns the channel's rollup for the requested window: a summary (unique viewers, watch\nminutes, average QoE, broadcasts) plus the two Shepherd's Report queues —\n**`new_watchers`** (New Faces, people to welcome) and **`drifting_watchers`**\n(Missed You, regulars who have gone quiet).\n","tags":["Shepherd's Report"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"},{"$ref":"#/components/parameters/RangeParam"}],"responses":{"200":{"description":"The channel's analytics and Shepherd's Report queues.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Analytics"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/channel-viewers":{"get":{"operationId":"listViewers","summary":"List a channel's viewers","description":"Lists every known watcher for the channel — your owned, verified audience — with their\nfirst/last watch and total watch minutes.\n","tags":["Audience"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel's viewers.","content":{"application/json":{"schema":{"type":"object","properties":{"viewers":{"type":"array","description":"Every known watcher for the channel.","items":{"$ref":"#/components/schemas/ViewerRow"}}},"required":["viewers"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/viewer":{"get":{"operationId":"getViewer","summary":"Get one viewer's full profile","description":"Returns a single watcher's complete profile: their identity, lifetime totals, every watch\nsession, any prayer/connect submissions, and the go-live notifications they've received.\n","tags":["Audience"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"},{"name":"id","in":"query","required":true,"description":"The subscriber (viewer) id.","schema":{"type":"integer"}}],"responses":{"200":{"description":"The viewer's full profile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewerDetail"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"description":"No such channel or viewer (`NO_CHANNEL` / `NO_VIEWER`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Viewer not found","code":"NO_VIEWER"}}}}}}},"/app/channel-archive":{"get":{"operationId":"listArchive","summary":"List a channel's recorded broadcasts","description":"Lists the channel's recorded past broadcasts (VOD) within the plan's archive-retention\nwindow, with each recording's duration, playback URL, and watch stats.\n","tags":["Audience"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel's archived recordings.","content":{"application/json":{"schema":{"type":"object","properties":{"retention_days":{"type":"integer","description":"How many days of archive this church's plan retains.","example":90},"recordings":{"type":"array","items":{"$ref":"#/components/schemas/ArchiveRecording"}}},"required":["retention_days","recordings"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/channels":{"get":{"operationId":"listChannels","summary":"List the church's channels","description":"Returns the church's channels as a lightweight navigation list (slug, name, type, live\nstatus).\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The church's channels.","content":{"application/json":{"schema":{"type":"object","properties":{"channels":{"type":"array","items":{"$ref":"#/components/schemas/ChannelNav"}}},"required":["channels"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"}}},"post":{"operationId":"createChannel","summary":"Create a channel","description":"Creates a new channel for the church and provisions its Cloudflare Stream live input.\n**Requires a read/write token** (a read-only token receives `403 READ_ONLY_TOKEN`). Fails\nwith `402 CHANNEL_LIMIT` when the church's plan channel allotment is already used.\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Display name for the channel.","example":"Main Service"},"type":{"type":"string","enum":["broadcast","conference"],"default":"broadcast","description":"`broadcast` = one-to-many service stream; `conference` = two-way prayer line.\n"}},"required":["name"]}}}},"responses":{"200":{"description":"The newly created channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelApp"}}}},"400":{"description":"Invalid or missing name (`BAD_NAME`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Please provide a channel name","code":"BAD_NAME"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"402":{"description":"The plan's channel allotment is used up (`CHANNEL_LIMIT`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Your plan's channel limit has been reached","code":"CHANNEL_LIMIT"}}}},"403":{"$ref":"#/components/responses/ReadOnlyToken"}}}},"/app/channel":{"get":{"operationId":"getChannel","summary":"Get one channel","description":"Returns a single channel's full configuration, including its **stream_info** — the\nencoder/ingest settings (RTMPS/SRT URLs and keys, HLS playback URL) your church uses to go\nlive.\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelApp"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/channel-notifications":{"get":{"operationId":"getNotificationsOverview","summary":"Get a channel's notifications overview","description":"Returns who is subscribed to go-live notifications for the channel, broken down by delivery\nintegration, plus recently sent notifications and recent unsubscribes.\n","tags":["Notifications"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel's notifications overview.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationsOverview"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/channel-integrations":{"get":{"operationId":"getChannelIntegrations","summary":"Get a channel's connected integrations","description":"Returns the channel's connected systems — Planning Center status, custom-domain\nentitlement/hostname, and the list of notification-delivery integrations available to this\nchurch.\n","tags":["Notifications"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel's integration status.","content":{"application/json":{"schema":{"type":"object","properties":{"planning_center":{"type":"object","description":"Planning Center connection status for this church.","properties":{"connected":{"type":"boolean"},"status":{"type":"string","example":"connected"},"display_name":{"type":["string","null"],"example":"First Baptist (Planning Center)"},"last_synced_at":{"type":["string","null"],"format":"date-time"}}},"custom_domain":{"type":"object","description":"Custom watch/notification domain entitlement and DNS target.","properties":{"entitled":{"type":"boolean","description":"Whether the plan includes a custom domain."},"hostname":{"type":["string","null"],"example":"watch.firstbaptist.org"},"cname_target":{"type":["string","null"],"example":"player.pealcast.io"}}},"available_notification_integrations":{"type":"array","description":"The notification-delivery integrations selectable by this church.","items":{"type":"object","properties":{"key":{"type":"string","example":"native.sms"},"label":{"type":"string","example":"Text message"},"medium":{"type":"string","example":"sms"},"available":{"type":"boolean"}}}}},"required":["planning_center","custom_domain","available_notification_integrations"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/account":{"get":{"operationId":"getAccount","summary":"Get the church's account and plans","description":"Returns the church's billing/trial state, the catalog of plans, and the Stripe publishable\nkey used by the dashboard to collect a card.\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The account state, plan catalog, and Stripe publishable key.","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"$ref":"#/components/schemas/AccountState"},"plans":{"type":"array","items":{"$ref":"#/components/schemas/Plan"}},"publishable_key":{"type":"string","description":"The Stripe publishable key (safe for the browser).","example":"pk_live_51ABC…"}},"required":["account","plans","publishable_key"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"}}}},"/app/report-settings":{"get":{"operationId":"getReportSettings","summary":"Get Shepherd's Report schedule settings","description":"Returns when the church's weekly Shepherd's Report is delivered (day, hour, timezone) plus\nthe list of selectable timezones.\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The current report schedule and available timezones.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ReportSettings"},{"type":"object","properties":{"timezones":{"type":"array","description":"Selectable IANA timezone names.","items":{"type":"string"},"example":["America/New_York","America/Chicago"]}},"required":["timezones"]}]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"}}},"post":{"operationId":"updateReportSettings","summary":"Update Shepherd's Report schedule settings","description":"Updates when the weekly Shepherd's Report is delivered. Any omitted field is left unchanged.\n**Requires a read/write token** (a read-only token receives `403 READ_ONLY_TOKEN`).\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the weekly report is sent at all."},"wday":{"type":"integer","minimum":0,"maximum":6,"description":"Day of week (0 = Sunday … 6 = Saturday)."},"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of day (local to `timezone`)."},"timezone":{"type":"string","description":"IANA timezone name.","example":"America/New_York"}}}}}},"responses":{"200":{"description":"The updated report settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportSettings"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ReadOnlyToken"}}}},"/app/send-report":{"post":{"operationId":"sendShepherdsReport","summary":"Send the Shepherd's Report now","description":"Sends the church's Shepherd's Report immediately (out of cadence) to the online-ministry\nteam. **Requires a read/write token** (a read-only token receives `403 READ_ONLY_TOKEN`).\n","tags":["Shepherd's Report"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The report was queued/sent.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"message":{"type":"string","example":"Your Shepherd's Report is on its way."}},"required":["ok","message"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ReadOnlyToken"}}}},"/app/subscriber":{"post":{"operationId":"updateSubscriber","summary":"Update an audience member","description":"Updates an audience member's (subscriber's) editable details. The person's **verified\nmobile number cannot be changed** here — it is the dedupe key for your owned audience. Pass\nan empty string to clear a field. **Requires a read/write token** (a read-only token\nreceives `403 READ_ONLY_TOKEN`).\n","tags":["Audience"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer","description":"The subscriber (audience member) id."},"first_name":{"type":"string","description":"The member's first name. Empty string clears it."},"last_name":{"type":"string","description":"The member's last name. Empty string clears it."},"email":{"type":"string","description":"The member's email. Empty string clears it."}},"required":["id"]}}}},"responses":{"200":{"description":"The updated audience member.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"subscriber":{"$ref":"#/components/schemas/Subscriber"}},"required":["ok","subscriber"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ReadOnlyToken"},"404":{"description":"No such audience member for this church (`NO_SUBSCRIBER`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Audience member not found","code":"NO_SUBSCRIBER"}}}},"422":{"description":"Invalid field value (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"That email address doesn't look right","code":"INVALID"}}}}}}},"/app/channel-settings":{"post":{"operationId":"updateChannelSettings","summary":"Update a channel's settings","description":"Updates a channel's editable settings. The channel's **slug and type are structural and\nare not editable here**. Any omitted field is left unchanged. **Requires a read/write\ntoken** (a read-only token receives `403 READ_ONLY_TOKEN`).\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","description":"The channel slug (identifies which channel to update).","example":"main-service"},"name":{"type":"string","description":"Display name for the channel."},"description":{"type":"string","description":"A short public description of the channel."},"timezone":{"type":"string","description":"IANA timezone name.","example":"America/New_York"},"gate_enabled":{"type":"boolean","description":"Whether the capture gate is active for this channel."},"gate_free_watch_seconds":{"type":"integer","description":"Seconds a viewer may watch before the gate prompts."},"recording_retention_days":{"type":"integer","description":"Recording auto-delete window (days). Clamped to [30, the plan's archive cap]; the change is pushed to Cloudflare as deleteRecordingAfterDays.","minimum":30,"example":60}},"required":["slug"]}}}},"responses":{"200":{"description":"The updated channel.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"channel":{"$ref":"#/components/schemas/ChannelApp"}},"required":["ok","channel"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ReadOnlyToken"},"404":{"$ref":"#/components/responses/NoChannel"},"422":{"description":"Invalid field value (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"That isn't a valid timezone","code":"INVALID"}}}}}}},"/app/organization":{"post":{"operationId":"updateOrganization","summary":"Update the church's details","description":"Updates the church's (organization's) editable details. **Billing, status, plan, and slug\nare not editable here.** Any omitted field is left unchanged. **Requires a read/write\ntoken** (a read-only token receives `403 READ_ONLY_TOKEN`).\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"The church's display name.","example":"First Baptist"},"timezone":{"type":"string","description":"IANA timezone name.","example":"America/New_York"}}}}}},"responses":{"200":{"description":"The updated church.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"organization":{"$ref":"#/components/schemas/OrgApp"}},"required":["ok","organization"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ReadOnlyToken"},"422":{"description":"Invalid field value (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"That isn't a valid timezone","code":"INVALID"}}}}}}},"/player/channel-playback":{"get":{"operationId":"getPlayback","summary":"Get a channel's live playback state","description":"**Public (no auth).** Returns the current live state and playback URL for a channel, plus\nthe gate configuration and the notification methods a viewer can opt into. This is what the\nwatch page calls to render the player.\n","tags":["Player (public)"],"security":[],"parameters":[{"name":"c","in":"query","required":true,"description":"The channel slug or id.","schema":{"type":"string"}},{"name":"t","in":"query","required":false,"description":"A signed watch token that pre-identifies the viewer (from a notification link).","schema":{"type":"string"}},{"name":"host","in":"query","required":false,"description":"The watch host (used to resolve a custom domain).","schema":{"type":"string"}},{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The channel's playback state.","content":{"application/json":{"schema":{"type":"object","properties":{"is_live":{"type":"boolean"},"broadcast_id":{"type":["integer","null"]},"hls_url":{"type":["string","null"],"description":"The LL-HLS playback URL, or null when not live."},"gate_enabled":{"type":"boolean","description":"Whether the capture gate is active for this channel."},"gate_free_watch_seconds":{"type":"integer","description":"Seconds a viewer may watch before the gate prompts."},"notification_methods":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","example":"native.sms"},"label":{"type":"string","example":"Text message"},"medium":{"type":"string","example":"sms"}}}},"theme":{"type":"object","description":"Channel/org theming (brand colors, logo) for the player.","additionalProperties":true}},"required":["is_live","gate_enabled","gate_free_watch_seconds","notification_methods","theme"]}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/player/org":{"get":{"operationId":"getPlayerOrg","summary":"Get a church's public player profile","description":"**Public (no auth).** Resolves a church by custom-domain host or slug and returns its public\nprofile plus the list of channels a viewer can watch.\n","tags":["Player (public)"],"security":[],"parameters":[{"name":"host","in":"query","required":false,"description":"The watch host (used to resolve a custom domain).","schema":{"type":"string"}},{"name":"slug","in":"query","required":false,"description":"The church (organization) slug.","schema":{"type":"string"}}],"responses":{"200":{"description":"The church's public player profile and channels.","content":{"application/json":{"schema":{"type":"object","properties":{"org":{"type":"object","properties":{"name":{"type":"string"},"slug":{"type":"string"},"custom_domain":{"type":["string","null"]},"theme":{"type":"object","additionalProperties":true}},"required":["name","slug"]},"channels":{"type":"array","items":{"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["broadcast","conference"]},"is_live":{"type":"boolean"},"theme":{"type":"object","additionalProperties":true}},"required":["slug","name","type","is_live"]}}},"required":["org","channels"]}}}},"404":{"$ref":"#/components/responses/NoOrg"}}}},"/auth/request-otp":{"post":{"operationId":"authRequestOtp","summary":"Request a login OTP","x-internal":true,"description":"**Internal, no auth.** Sends a one-time code to a US/Canada mobile (SMS) or an email for\nthe given `actor` (`admin` | `app` | `watcher`). The single `identifier` field accepts a\ncell number OR an email; watchers must supply a real, textable mobile.\n","tags":["Auth"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identifier":{"type":"string","description":"A US/Canada mobile number or an email. (Alias, `phone`, also accepted.)"},"phone":{"type":"string","description":"Deprecated alias for `identifier`."},"actor":{"type":"string","enum":["admin","app","watcher"]},"channel":{"type":"string","description":"Optional channel slug/id (watcher opt-in context)."}},"required":["identifier","actor"]}}}},"responses":{"200":{"description":"A code was created and delivery attempted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"ttl_seconds":{"type":"integer"},"via":{"type":"string","enum":["mobile","email"]},"dev_code":{"type":"string","description":"DEV ONLY — the code, returned when delivery isn't wired up in dev."}},"required":["ok"]}}}},"400":{"description":"Bad actor, identifier, non-mobile watcher, or undeliverable email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many codes requested (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/verify-otp":{"post":{"operationId":"authVerifyOtp","summary":"Verify an OTP and mint a session","x-internal":true,"description":"**Internal, no auth.** Verifies the code for the `actor` and, on success, issues a bearer\nsession token (a JWT backed by a revocable `auth_sessions` row). A paid, active church gets\na long session; a trial/past-due church gets a short 15-minute one (the paywall).\n","tags":["Auth"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identifier":{"type":"string"},"phone":{"type":"string","description":"Deprecated alias for `identifier`."},"code":{"type":"string"},"actor":{"type":"string","enum":["admin","app","watcher"]}},"required":["identifier","code","actor"]}}}},"responses":{"200":{"description":"The session token and the signed-in profile.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"token":{"type":"string"},"actor":{"type":"string","enum":["admin","app","watcher"]},"expires_at":{"type":["string","null"],"format":"date-time"},"profile":{"type":"object","additionalProperties":true}},"required":["ok","token","actor"]}}}},"400":{"description":"Bad actor or identifier (`BAD_ACTOR` / `BAD_IDENTIFIER`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Invalid or expired code (`BAD_CODE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The identity isn't allowed for this actor (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"A watcher with no prior identity must join through the gate (`NEEDS_GATE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/me":{"get":{"operationId":"authMe","summary":"Validate the current session","x-internal":true,"description":"**Internal.** Returns the actor + profile for the caller's **session bearer token** (a\nsigned-in JWT, not a `pc_` developer token). The front-ends call this on load to validate a\nstored token.\n","tags":["Auth"],"responses":{"200":{"description":"The current actor and profile.","content":{"application/json":{"schema":{"type":"object","properties":{"actor":{"type":"string"},"profile":{"type":"object","additionalProperties":true}},"required":["actor"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"}}}},"/auth/logout":{"post":{"operationId":"authLogout","summary":"Revoke the current session","x-internal":true,"description":"**Internal.** Revokes the caller's `auth_sessions` row so the (long-lived) token stops\nworking immediately.\n","tags":["Auth"],"responses":{"200":{"description":"The session was revoked (idempotent).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true}},"required":["ok"]}}}}}}},"/auth/signup-start":{"post":{"operationId":"authSignupStart","summary":"Church-admin signup — step 1 (validate + text a code)","x-internal":true,"description":"**Internal, no auth.** Step 1 of the 3-step church-admin signup: validates name, a real\nmobile, and a deliverable email, then texts a phone OTP.\n","tags":["Auth"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"}},"required":["name","email","phone"]}}}},"responses":{"200":{"description":"The phone code was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"via":{"type":"string","const":"mobile"},"dev_code":{"type":"string","description":"DEV ONLY."}},"required":["ok"]}}}},"400":{"description":"Missing/invalid name, phone, or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/signup-verify-phone":{"post":{"operationId":"authSignupVerifyPhone","summary":"Church-admin signup — step 2 (verify phone, email a code)","x-internal":true,"description":"**Internal, no auth.** Step 2: verifies the phone OTP; on success emails an OTP and returns\na `phone_proof` to carry into step 3.\n","tags":["Auth"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"phone":{"type":"string"},"email":{"type":"string"},"code":{"type":"string"}},"required":["phone","email","code"]}}}},"responses":{"200":{"description":"Phone proven; email code sent.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"via":{"type":"string","const":"email"},"phone_proof":{"type":"string"},"dev_code":{"type":"string","description":"DEV ONLY."}},"required":["ok","phone_proof"]}}}},"400":{"description":"Invalid phone or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Wrong phone code (`BAD_CODE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/signup":{"post":{"operationId":"authSignup","summary":"Church-admin signup — step 3 (create the account)","x-internal":true,"description":"**Internal, no auth.** Step 3: verifies the `phone_proof` + email code, then creates (or\nreactivates) the user with a verified mobile + email, stands up their church, and makes them\nowner (a 14-day, card-free trial of the Large plan).\n","tags":["Auth"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"},"phone_proof":{"type":"string"},"code":{"type":"string","description":"The email OTP. (Alias `email_code` also accepted.)"},"email_code":{"type":"string"}},"required":["name","email","phone","phone_proof"]}}}},"responses":{"200":{"description":"An existing account was signed in.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupResult"}}}},"201":{"description":"A new account was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupResult"}}}},"400":{"description":"Missing signup details (`BAD_REQUEST`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Expired phone proof or wrong email code (`PHONE_UNVERIFIED` / `BAD_CODE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Could not create the account (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/billing/setup-intent":{"post":{"operationId":"createBillingSetupIntent","summary":"Start card entry (Stripe SetupIntent)","x-internal":true,"description":"**Internal.** Ensures a Stripe customer for the church and returns a SetupIntent\n`client_secret` so the dashboard can collect a card with Stripe.js. Owner/admin only.\n","tags":["Billing"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The SetupIntent client secret + publishable key.","content":{"application/json":{"schema":{"type":"object","properties":{"client_secret":{"type":"string"},"publishable_key":{"type":"string"}},"required":["client_secret","publishable_key"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Only owners/admins can manage billing (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Billing isn't configured or the SetupIntent failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/billing/subscribe":{"post":{"operationId":"subscribeToPlan","summary":"Set the card as default and start the plan","x-internal":true,"description":"**Internal.** Saves the chosen card as the customer's default, points the church at the\nchosen plan/interval, and runs the in-house billing system (invoice + off-session charge).\nA decline leaves the church `past_due`. Owner/admin only.\n","tags":["Billing"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"plan_code":{"type":"string","example":"medium"},"payment_method_id":{"type":"string","description":"The Stripe PaymentMethod id from the card element."},"interval":{"type":"string","enum":["monthly","annual"],"default":"monthly"}},"required":["plan_code","payment_method_id"]}}}},"responses":{"200":{"description":"Charged — the church is now active.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"account":{"$ref":"#/components/schemas/AccountState"}},"required":["ok","account"]}}}},"400":{"description":"No plan chosen or no card (`BAD_PLAN` / `NO_CARD`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"402":{"description":"The card was declined (`CARD_ERROR` / `CHARGE_FAILED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Only owners/admins can manage billing (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Billing isn't configured (`STRIPE_UNCONFIGURED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/billing/summary":{"get":{"operationId":"getBillingSummary","summary":"Get the billing page summary","x-internal":true,"description":"**Internal.** Powers the app billing page — plan/status/next charge, credit balance + bonus\noffer, whether a card is on file, and recent invoices. Church-admin or platform-admin.\n","tags":["Billing"],"responses":{"200":{"description":"The billing summary.","content":{"application/json":{"schema":{"type":"object","properties":{"organization":{"type":"object","additionalProperties":true},"plan":{"type":["object","null"],"additionalProperties":true},"credit_balance_cents":{"type":"integer"},"credit_bonus":{"type":"object","additionalProperties":true},"has_payment_method":{"type":"boolean"},"invoices":{"type":"array","items":{"type":"object","additionalProperties":true}}},"required":["organization","credit_balance_cents","has_payment_method","invoices"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"404":{"description":"No organization on the session (`NO_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/billing/credit":{"post":{"operationId":"addBillingCredit","summary":"Pre-pay account credit","x-internal":true,"description":"**Internal.** Charges the card on file and adds account credit (a +10% bonus on $500+).\nCredit is drawn down first when the monthly invoice is charged. Church-admin or\nplatform-admin.\n","tags":["Billing"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"amount_cents":{"type":"integer"}},"required":["amount_cents"]}}}},"responses":{"200":{"description":"The credit was added.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"charged_cents":{"type":"integer"},"bonus_cents":{"type":"integer"},"balance_cents":{"type":"integer"}},"required":["ok"]}}}},"400":{"description":"Bad amount or no card (`BAD_AMOUNT` / `NO_PAYMENT_METHOD`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"402":{"description":"The card was declined (`CHARGE_FAILED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No organization on the session (`NO_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/api-tokens":{"get":{"operationId":"listApiTokens","summary":"List the church's developer keys","x-internal":true,"description":"**Internal, session-only.** Lists the org's REST developer keys (secrets never returned).\nRequires a human dashboard login (a `pc_` token gets `403 SESSION_REQUIRED`); owner/admin\nonly.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The developer keys.","content":{"application/json":{"schema":{"type":"object","properties":{"tokens":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenInfo"}}},"required":["tokens"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/SessionOrRole"}}},"post":{"operationId":"createApiToken","summary":"Mint a developer key","x-internal":true,"description":"**Internal, session-only.** Mints an org REST developer key and returns the plaintext token\n**once** (unrecoverable after). Requires a human login; owner/admin only.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"access":{"type":"string","enum":["read","read_write"],"default":"read"},"expires_in_days":{"type":"integer","description":"Optional; omit or 0 = never expires."}},"required":["name"]}}}},"responses":{"200":{"description":"The newly minted key (plaintext token returned once).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"token":{"type":"string","description":"The plaintext token — shown only in this response."},"api_token":{"$ref":"#/components/schemas/ApiTokenInfo"}},"required":["ok","token","api_token"]}}}},"400":{"description":"Missing name (`BAD_NAME`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/SessionOrRole"},"422":{"description":"Could not create the key (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"revokeApiToken","summary":"Revoke a developer key","x-internal":true,"description":"**Internal, session-only.** Revokes an org developer key by id. Requires a human login;\nowner/admin only.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"name":"id","in":"query","required":true,"description":"The developer key id.","schema":{"type":"integer"}}],"responses":{"200":{"description":"The key was revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"api_token":{"$ref":"#/components/schemas/ApiTokenInfo"}},"required":["ok"]}}}},"400":{"description":"Missing id (`BAD_ID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/SessionOrRole"},"404":{"description":"No such key (`NO_TOKEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/mcp-connections":{"get":{"operationId":"listMcpConnections","summary":"List my AI (MCP) connections","x-internal":true,"description":"**Internal, session-only.** Lists the caller's OWN personal AI (MCP) connections in this\norg, plus the MCP endpoint URL and whether the caller's role can create a write-capable\nconnection. Requires a human login.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The caller's MCP connections.","content":{"application/json":{"schema":{"type":"object","properties":{"mcp_url":{"type":"string","example":"https://mcp.pealcast.io/mcp"},"can_write":{"type":"boolean"},"connections":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenInfo"}}},"required":["mcp_url","can_write","connections"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/SessionRequired"}}},"post":{"operationId":"createMcpConnection","summary":"Connect my AI assistant (mint a personal MCP token)","x-internal":true,"description":"**Internal, session-only.** Mints the caller's personal MCP token and returns it **once**.\nWrite access is capped by the caller's role (only owner/admin may create a write-capable\nconnection). Requires a human login.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","default":"My AI assistant"},"access":{"type":"string","enum":["read","read_write"],"default":"read"}}}}}},"responses":{"200":{"description":"The new connection (plaintext token returned once).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"token":{"type":"string","description":"The plaintext MCP token — shown only in this response."},"mcp_url":{"type":"string"},"connection":{"$ref":"#/components/schemas/ApiTokenInfo"}},"required":["ok","token","mcp_url","connection"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/SessionRequired"},"422":{"description":"Could not connect (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"revokeMcpConnection","summary":"Disconnect an AI connection","x-internal":true,"description":"**Internal, session-only.** Revokes a personal MCP connection by id. A user may revoke their\nown; an owner/admin may revoke anyone's (offboarding). Requires a human login.\n","tags":["Developer keys"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"name":"id","in":"query","required":true,"description":"The connection id.","schema":{"type":"integer"}}],"responses":{"200":{"description":"The connection was revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"connection":{"$ref":"#/components/schemas/ApiTokenInfo"}},"required":["ok"]}}}},"400":{"description":"Missing id (`BAD_ID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Not your connection, or not signed in (`FORBIDDEN` / `SESSION_REQUIRED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such connection (`NO_TOKEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/bootstrap":{"get":{"operationId":"getAppBootstrap","summary":"Bootstrap the church-admin app","x-internal":true,"description":"**Internal.** The single call the church-admin app makes on load — the signed-in user, all\nof their churches (the switcher), the resolved acting org + plan/paywall state, and that\norg's channels (which drive post-login routing).\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"Everything the app shell needs to route + render.","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"type":"object","additionalProperties":true},"orgs":{"type":"array","items":{"$ref":"#/components/schemas/OrgApp"}},"current_org":{"$ref":"#/components/schemas/OrgApp"},"account":{"$ref":"#/components/schemas/AccountState"},"channels":{"type":"array","items":{"$ref":"#/components/schemas/ChannelNav"}}},"required":["user","orgs","current_org","channels"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"No church is linked to this account yet (`NO_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/orgs":{"get":{"operationId":"listMyOrgs","summary":"List the churches I can act in","x-internal":true,"description":"**Internal.** The org-switcher list — every church the signed-in user has a role in, each\nflagged with whether it is the currently active one.\n","tags":["Account"],"responses":{"200":{"description":"The user's churches.","content":{"application/json":{"schema":{"type":"object","properties":{"orgs":{"type":"array","items":{"$ref":"#/components/schemas/OrgApp"}}},"required":["orgs"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"}}},"post":{"operationId":"createOrg","summary":"Add another church","x-internal":true,"description":"**Internal.** Create a NEW church for the signed-in user and make them its owner\n(a 14-day, card-free trial of the Large plan), then re-scope this session to it so\nthe app lands in the new church. This is how an existing user adds a second (third, …)\norg; the marketing signup flow routes a known mobile/email here via sign-in rather than\nduplicating the account. A `pc_` API token is pinned to one org and may NOT create tenants.\n","tags":["Account"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"The new church's name."}},"required":["name"]}}}},"responses":{"201":{"description":"The new church, now the session's active org.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"current_org":{"$ref":"#/components/schemas/OrgApp"}},"required":["ok","current_org"]}}}},"400":{"description":"Missing church name."},"403":{"description":"A read-only or org-pinned API token may not create a church."},"401":{"$ref":"#/components/responses/Unauthenticated"}}}},"/app/sessions":{"get":{"operationId":"listMySessions","summary":"List my active login sessions (devices)","x-internal":true,"description":"**Internal.** Every active (non-revoked, unexpired) login session for the signed-in user —\ndevice, browser, OS, readable IP, best-effort location, when it signed in and was last seen —\nwith the current session flagged. Backs the \"Where you're signed in\" security view.\n","tags":["Account"],"responses":{"200":{"description":"The user's active sessions.","content":{"application/json":{"schema":{"type":"object","properties":{"sessions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"actor":{"type":"string","enum":["admin","app","watcher"]},"device":{"type":["string","null"],"example":"Chrome on macOS"},"browser":{"type":["string","null"]},"os":{"type":["string","null"]},"device_type":{"type":["string","null"],"enum":["mobile","tablet","desktop","bot",null]},"ip":{"type":["string","null"]},"location":{"type":["string","null"],"example":"San Diego, California, US"},"last_seen_at":{"type":["string","null"],"format":"date-time"},"signed_in_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"current":{"type":"boolean"},"active":{"type":"boolean"}}}}},"required":["sessions"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"}}},"delete":{"operationId":"revokeMySession","summary":"Revoke one of my sessions (remote sign-out)","x-internal":true,"description":"**Internal.** Revokes a single session by id (must belong to the caller). Its token stops\nworking on the very next request — signing that device out everywhere.\n","tags":["Account"],"parameters":[{"name":"id","in":"query","required":true,"description":"The session id to revoke.","schema":{"type":"string"}}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"revoked":{"type":"integer"}},"required":["ok"]}}}},"400":{"description":"Missing id (`BAD_ID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"404":{"description":"Not found (`NO_SESSION`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/sessions/revoke-others":{"post":{"operationId":"revokeMyOtherSessions","summary":"Sign out all my other sessions","x-internal":true,"description":"**Internal.** Revokes every OTHER active session for the signed-in user, keeping the current\none. Returns how many were revoked.\n","tags":["Account"],"responses":{"200":{"description":"Other sessions revoked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"revoked_count":{"type":"integer"}},"required":["ok","revoked_count"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"}}}},"/app/stop-impersonation":{"post":{"operationId":"stopImpersonation","summary":"End the current admin impersonation","x-internal":true,"description":"**Internal.** The \"Exit\" action in the impersonation banner. Revokes the CURRENT session\nonly if it is an admin impersonation (its `impersonator_user_id` is set); the staff member's\nown admin session is untouched. The app then clears its token and returns to the admin panel.\n","tags":["Account"],"responses":{"200":{"description":"Impersonation ended.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true}},"required":["ok"]}}}},"400":{"description":"Not an impersonation session (`NOT_IMPERSONATION`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"}}}},"/app/switch-org":{"post":{"operationId":"switchOrg","summary":"Switch the active church","x-internal":true,"description":"**Internal.** Re-scopes the live session to another of the user's churches\n(`auth_sessions.organization_id`), so auth itself is org-bound.\n","tags":["Account"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"org":{"type":"string","description":"The target church id or slug."}},"required":["org"]}}}},"responses":{"200":{"description":"The session was re-scoped.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"current_org":{"$ref":"#/components/schemas/OrgApp"}},"required":["ok","current_org"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"No access to that church (`FORBIDDEN_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/users":{"get":{"operationId":"listOrgUsers","summary":"List the church's users","x-internal":true,"description":"**Internal.** Who can sign into this church's app and their role\n(owner|admin|manager|member), plus the caller's own role.\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"responses":{"200":{"description":"The church's users.","content":{"application/json":{"schema":{"type":"object","properties":{"my_role":{"type":["string","null"]},"users":{"type":"array","items":{"type":"object","additionalProperties":true}}},"required":["users"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"}}},"post":{"operationId":"inviteOrgUser","summary":"Invite a user to the church","x-internal":true,"description":"**Internal.** Creates (by mobile) or reactivates a user and grants them a role; they sign in\nwith phone+OTP (no password). Owner/admin only.\n","tags":["Account"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"phone":{"type":"string"},"role":{"type":"string","enum":["owner","admin","manager","member"],"default":"member"},"name":{"type":"string"}},"required":["phone"]}}}},"responses":{"200":{"description":"The user was added/updated.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"user_id":{"type":"integer"},"role":{"type":"string"},"mobile":{"type":"string"},"name":{"type":["string","null"]}},"required":["ok","user_id","role"]}}}},"400":{"description":"Invalid mobile (`BAD_PHONE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Only owners/admins can add users (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Could not add the user (`INVALID`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/theme":{"post":{"operationId":"updateOrgTheme","summary":"Set the church's brand","x-internal":true,"description":"**Internal.** Sets the org brand (logo/icon/primary/accent) used across the public player\nand landing surfaces. Colors are validated hex; URLs must be https. Owner/admin only.\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"logo_url":{"type":"string"},"icon_url":{"type":"string"},"primary_color":{"type":"string","example":"#12233b"},"accent_color":{"type":"string"}}}}}},"responses":{"200":{"description":"The updated org theme.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"theme":{"type":"object","additionalProperties":true}},"required":["ok","theme"]}}}},"400":{"description":"Bad color or URL (`BAD_COLOR` / `BAD_URL`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Only owners/admins can change branding (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/channel-theme":{"get":{"operationId":"getChannelTheme","summary":"Get a channel's theme and custom code","x-internal":true,"description":"**Internal.** Returns the channel's effective theme (org brand + channel overrides) plus the\nAdvanced custom CSS/HTML/JS for that channel's player page.\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The channel theme + custom code.","content":{"application/json":{"schema":{"type":"object","properties":{"org_theme":{"type":"object","additionalProperties":true},"channel_theme":{"type":"object","additionalProperties":true},"effective":{"type":"object","additionalProperties":true},"custom_css":{"type":["string","null"]},"custom_html":{"type":["string","null"]},"custom_js":{"type":["string","null"]}},"required":["effective"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}},"post":{"operationId":"updateChannelTheme","summary":"Set a channel's theme and custom code","x-internal":true,"description":"**Internal.** Sets a channel's theme overrides and the Advanced custom CSS/HTML/JS (stored\nverbatim, rendered only on that church's own channel page). Colors are validated hex; URLs\nmust be https. Owner/admin only.\n","tags":["Channels"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string"},"theme":{"type":"object","additionalProperties":true},"custom_css":{"type":"string"},"custom_html":{"type":"string"},"custom_js":{"type":"string"}},"required":["slug"]}}}},"responses":{"200":{"description":"The updated effective theme.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"effective":{"type":"object","additionalProperties":true}},"required":["ok","effective"]}}}},"400":{"description":"Bad color or URL (`BAD_COLOR` / `BAD_URL`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Only owners/admins can change branding (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/channel-gate":{"get":{"operationId":"getChannelGate","summary":"Get a channel's capture-gate config","x-internal":true,"description":"**Internal.** Returns the channel's effective gate (the public config the player renders),\nthe raw overrides, the enabled toggle, the free-watch seconds, and the defaults.\n","tags":["Gate"],"parameters":[{"$ref":"#/components/parameters/OrgParam"},{"$ref":"#/components/parameters/SlugRequiredParam"}],"responses":{"200":{"description":"The gate configuration.","content":{"application/json":{"schema":{"type":"object","properties":{"effective":{"type":"object","additionalProperties":true},"gate_config":{"type":"object","additionalProperties":true},"gate_enabled":{"type":"boolean"},"gate_free_watch_seconds":{"type":"integer"},"defaults":{"type":"object","additionalProperties":true}},"required":["effective","gate_enabled","gate_free_watch_seconds"]}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"$ref":"#/components/responses/ForbiddenOrg"},"404":{"$ref":"#/components/responses/NoChannel"}}},"post":{"operationId":"updateChannelGate","summary":"Set a channel's capture-gate config","x-internal":true,"description":"**Internal.** Updates the gate copy, extra fields, styling, and free-vs-hard behavior (the\ngate always collects a verified mobile — the moat). Owner/admin only.\n","tags":["Gate"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string"},"gate_config":{"type":"object","additionalProperties":true},"gate_enabled":{"type":"boolean"},"gate_free_watch_seconds":{"type":"integer","minimum":0,"maximum":86400}},"required":["slug"]}}}},"responses":{"200":{"description":"The updated effective gate config.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"effective":{"type":"object","additionalProperties":true}},"required":["ok","effective"]}}}},"400":{"description":"Bad config value (`BAD_COLOR` / `BAD_SECONDS`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"403":{"description":"Only owners/admins can change the gate (`FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/app/custom-domain":{"post":{"operationId":"setCustomDomain","summary":"Set the church's custom player domain","x-internal":true,"description":"**Internal.** Sets or clears the org's custom watch/notification hostname (CNAME target).\nRequires a plan that includes a dedicated domain (`402 NOT_ENTITLED` otherwise).\n","tags":["Notifications"],"parameters":[{"$ref":"#/components/parameters/OrgParam"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"hostname":{"type":"string","description":"The hostname (empty clears it).","example":"watch.firstbaptist.org"}}}}}},"responses":{"200":{"description":"The domain was set/cleared.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"hostname":{"type":["string","null"]},"cname_target":{"type":"string"}},"required":["ok"]}}}},"400":{"description":"Invalid hostname (`BAD_HOST`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthenticated"},"402":{"description":"The plan doesn't include a custom domain (`NOT_ENTITLED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"That domain is already in use (`DOMAIN_TAKEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/planning-center/authorize-url":{"get":{"operationId":"getPcoAuthorizeUrl","summary":"Get the Planning Center authorize URL","x-internal":true,"description":"**Internal.** Returns the PCO authorize URL (plus an opaque anti-CSRF `state`) for the app\nto redirect the browser to. Kept on the backend so client_id + redirect_uri come from one\nplace. Called by the signed-in app SPA; the API URL never enters the browser.\n","tags":["Planning Center"],"security":[],"parameters":[{"name":"state","in":"query","required":false,"description":"An opaque anti-CSRF value; generated if omitted.","schema":{"type":"string"}}],"responses":{"200":{"description":"The authorize URL + state.","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"},"state":{"type":"string"}},"required":["url","state"]}}}},"503":{"description":"Planning Center isn't configured (`PCO_UNCONFIGURED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/planning-center/exchange":{"post":{"operationId":"exchangePcoCode","summary":"Exchange a Planning Center OAuth code","x-internal":true,"description":"**Internal.** The app relays the single-use `code` it received on its `/pco` callback; we\nexchange it for tokens **server-side** (the client secret + tokens never reach the browser)\nand store them encrypted in the central `integrations` table.\n","tags":["Planning Center"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string"},"state":{"type":"string"}},"required":["code"]}}}},"responses":{"200":{"description":"The tokens were exchanged and stored.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"connected":{"type":"boolean"},"persisted":{"type":"boolean"},"scope":{"type":["string","null"]}},"required":["ok","connected"]}}}},"400":{"description":"Missing code (`BAD_REQUEST`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The Planning Center exchange failed (`PCO_EXCHANGE_FAILED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/planning-center/webhooks":{"post":{"operationId":"pcoWebhook","summary":"Planning Center inbound webhook","x-internal":true,"description":"**Internal webhook — HMAC auth, no bearer token.** The single inbound endpoint PCO POSTs to\nwhen a person/field/contact changes in a connected church. Authenticity is proven by the\nper-subscription signature (`X-PCO-Webhooks-Authenticity`) over the raw body; the matching\nintegration IS the org. A verified delivery is always 200-acked (even if unmapped).\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"403":{"$ref":"#/components/responses/BadSignature"}}}},"/cloudflare/stream-webhook":{"post":{"operationId":"cloudflareStreamWebhook","summary":"Cloudflare Stream inbound webhook","x-internal":true,"description":"**Internal webhook — HMAC auth, no bearer token.** PealCast's go-live detection: Cloudflare\nposts live-input state changes (connected/disconnected/errored) and recording readiness\nhere. We map `live_input → channel → org`, drive the broadcast lifecycle, and fan out\ngo-live notifications. A validly-signed event is always 200-acked; a bad signature is 401.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/sms/inbound":{"post":{"operationId":"signalwireSmsInbound","summary":"SignalWire inbound-SMS webhook","x-internal":true,"description":"**Internal webhook — HMAC auth, no bearer token.** SignalWire posts here when someone texts\none of our numbers. The Text model records the inbound message and acts on its keyword: a\nchannel's opt-in keyword (\"text SUNDAY to subscribe\") subscribes the texter to that channel's\ngo-live SMS; STOP/START opt them out / back in against our audience. Returns empty TwiML\n(SignalWire's Advanced Opt-Out owns the carrier compliance replies).\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Empty TwiML acknowledgement.","content":{"text/xml":{"schema":{"type":"string","example":"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cResponse\u003e\u003c/Response\u003e"}}}},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/sms/status":{"post":{"operationId":"signalwireSmsStatus","summary":"SignalWire SMS delivery-status webhook","x-internal":true,"description":"**Internal webhook — HMAC auth, no bearer token.** SignalWire posts delivery updates for\nevery outbound SMS; we find the message by `MessageSid` and advance its status\n(forward-only) with the carrier error code, if any.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/call/incoming":{"post":{"operationId":"supportCallIncoming","summary":"Support line incoming call → greeting + voicemail record (LaML)","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** Fired by SignalWire\nwhen someone calls the toll-free support line. Returns LaML that plays the recorded welcome\ngreeting and then records a voicemail (recording is on for all calls). Authenticity is\nproven by SignalWire's request signature, never a bearer token.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded voice webhook payload (`CallSid`, `From`, `To`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"LaML that greets the caller and records a voicemail.","content":{"text/xml":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/call/recording":{"post":{"operationId":"supportCallRecording","summary":"Voicemail recording ready → relay to Slack","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** Fired when a support\nvoicemail recording is ready. We relay the recording (URL + SID + duration) into the Slack\nsupport thread for that caller. Authenticity is proven by SignalWire's request signature.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded recording callback (`RecordingUrl`, `RecordingSid`, `RecordingDuration`, `CallSid`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/call/transcription":{"post":{"operationId":"supportCallTranscription","summary":"Voicemail transcription callback → post transcript to Slack","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** Fired when the\nvoicemail transcription is ready. We post the transcript text into the caller's Slack\nsupport thread. Authenticity is proven by SignalWire's request signature.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded transcription callback (`TranscriptionText`, `TranscriptionStatus`, `CallSid`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/call/status":{"post":{"operationId":"supportCallStatus","summary":"Support call status callback (lifecycle/duration)","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** Fired as a support\ncall moves through its lifecycle (ringing/answered/completed) with the final call duration.\nWe advance the logged `support_calls` row. Authenticity is proven by SignalWire's request\nsignature.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded status callback (`CallSid`, `CallStatus`, `CallDuration`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"$ref":"#/components/responses/WebhookAck"},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/call/fail":{"post":{"operationId":"supportCallFail","summary":"Voice fallback URL → apologetic LaML","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** The voice fallback URL\nSignalWire calls when the primary voice webhook errors. Returns an apologetic LaML message\nso the caller is never left with dead air. Authenticity is proven by SignalWire's request\nsignature.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded voice fallback payload (`CallSid`, `ErrorCode`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Apologetic LaML message.","content":{"text/xml":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/signalwire/sms/support":{"post":{"operationId":"supportSmsInbound","summary":"Inbound text/MMS to the support line → post to the Slack thread","x-internal":true,"description":"**Internal webhook — signature auth (SignalWire), no bearer token.** Fired when someone\ntexts (or MMS's) the toll-free support line. We post the message into the caller's Slack\nsupport thread (one thread per external number), relaying any media (images/PDF/text/HTML)\nas Slack file uploads and enriching with the sender's PealCast identity. Authenticity is\nproven by SignalWire's request signature.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"SignalWire form-encoded inbound SMS/MMS payload (`From`, `To`, `Body`, `NumMedia`, `MediaUrl{n}`, …).","content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Empty TwiML acknowledgement.","content":{"text/xml":{"schema":{"type":"string","example":"\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cResponse\u003e\u003c/Response\u003e"}}}},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/slack/events":{"post":{"operationId":"slackEvents","summary":"Slack Events API → staff reply in a support thread is texted back","x-internal":true,"description":"**Internal webhook — signature auth (Slack), no bearer token.** The Slack Events API\nendpoint. When a staff member replies in a support thread, we text that reply back to the\nperson on the support line (unicode emoji both ways). Also handles Slack's `url_verification`\nhandshake. Authenticity is proven by the Slack request signature\n(`X-Slack-Signature` / `X-Slack-Request-Timestamp`), never a bearer token.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"Slack Events API JSON payload (`url_verification` challenge or an `event_callback` with a `message` event).","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Acknowledged (or the `url_verification` challenge echoed back).","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}},"text/plain":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/BadSignature"}}}},"/stripe/webhook":{"post":{"operationId":"stripeWebhook","summary":"Stripe events → posted to the","x-internal":true,"description":"**Internal webhook — signature auth (Stripe), no bearer token.** Stripe POSTs account\nevents here (new customers, payments succeeded/failed, refunds, disputes, payouts); we\nverify the `Stripe-Signature` HMAC over the raw body against the stage's `whsec_…`\n(SSM `/pealcast/{env}/stripe/webhook_secret`) and relay a formatted line to the\n`#pealcast-{stage}-stripe` Slack channel. dev = Stripe test mode, prod = live.\n","tags":["Webhooks"],"security":[],"requestBody":{"required":true,"description":"A Stripe Event object (JSON).","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"Received."},"400":{"$ref":"#/components/responses/BadSignature"}}}},"/player/gate/verify":{"post":{"operationId":"verifyWatcherGate","summary":"Verify the watcher capture gate","x-internal":true,"description":"**Internal, no auth.** After an anonymous watcher exceeds a channel's free-watch allowance,\nthey submit their OTP code + optional prayer request here. We verify the code, upsert the\nsubscriber (the owned audience, deduped by verified mobile), record the prayer submission,\nand issue a watcher session so playback resumes. Identify once → pre-authed forever.\n","tags":["Gate"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string","description":"The channel slug or id."},"identifier":{"type":"string","description":"The verified mobile (or email). Alias `phone` accepted."},"phone":{"type":"string"},"code":{"type":"string"},"prayer_request":{"type":"string"}},"required":["channel","identifier","code"]}}}},"responses":{"200":{"description":"The watcher was identified; a session token is returned.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"token":{"type":"string"}},"required":["ok","token"]}}}},"400":{"description":"Invalid identifier or code (`BAD_IDENTIFIER`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Invalid or expired code (`BAD_CODE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/player/subscribe/info":{"get":{"operationId":"getSubscribeInfo","summary":"Get the notification sign-up widget config","x-internal":true,"description":"**Internal, no auth.** What the public opt-in widget needs to render — the channel + org\nnames, theme, and the notification methods available for the channel.\n","tags":["Notifications"],"security":[],"parameters":[{"name":"channel","in":"query","required":true,"description":"The channel slug or id.","schema":{"type":"string"}},{"$ref":"#/components/parameters/OrgParam"},{"name":"host","in":"query","required":false,"description":"The watch host (resolves a custom domain).","schema":{"type":"string"}}],"responses":{"200":{"description":"The widget config.","content":{"application/json":{"schema":{"type":"object","properties":{"channel_name":{"type":"string"},"org_name":{"type":["string","null"]},"theme":{"type":"object","additionalProperties":true},"methods":{"type":"array","items":{"type":"object","additionalProperties":true}}},"required":["channel_name","methods"]}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/player/subscribe/start":{"post":{"operationId":"startSubscribe","summary":"Start notification opt-in (send an OTP)","x-internal":true,"description":"**Internal, no auth.** Validates the chosen methods (a real, textable mobile is ALWAYS\nrequired; an email when an email method is chosen) and texts an OTP to confirm.\n","tags":["Notifications"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string"},"org":{"type":"string"},"host":{"type":"string"},"methods":{"type":"array","items":{"type":"string"},"example":["native.sms","native.email"]},"phone":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"}},"required":["channel","methods","phone"]}}}},"responses":{"200":{"description":"The OTP was sent.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"dev_code":{"type":"string","description":"DEV ONLY."}},"required":["ok"]}}}},"400":{"description":"No method, non-mobile, or missing/undeliverable email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"},"429":{"description":"Rate limited (`RATE_LIMITED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/player/subscribe/verify":{"post":{"operationId":"verifySubscribe","summary":"Confirm notification opt-in","x-internal":true,"description":"**Internal, no auth.** Verifies the OTP, upserts the subscriber (owned audience, deduped by\nverified mobile), and creates one subscription per chosen, channel-available method.\n","tags":["Notifications"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string"},"org":{"type":"string"},"host":{"type":"string"},"methods":{"type":"array","items":{"type":"string"}},"phone":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"code":{"type":"string"}},"required":["channel","methods","phone","code"]}}}},"responses":{"200":{"description":"The subscriptions were created.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"subscribed":{"type":"integer","description":"How many method subscriptions were created."}},"required":["ok","subscribed"]}}}},"400":{"description":"No method or bad phone (`NO_METHOD` / `BAD_PHONE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Wrong code (`BAD_CODE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/player/watch-sessions/start":{"post":{"operationId":"startWatchSession","summary":"Start a watch session","x-internal":true,"description":"**Internal, no auth.** Called once when the player opens a channel. Mints a unique Cloudflare\nStream signed playback token for this session, stores the `watch_sessions` row, and returns\nthe token, live HLS URL, and the channel's gate config.\n","tags":["Player (public)"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"session_uid":{"type":"string"},"channel":{"type":"string","description":"The channel slug/id. Alias `c` accepted."},"c":{"type":"string"},"org":{"type":"string"},"host":{"type":"string"},"broadcast_id":{"type":"integer"},"token":{"type":"string","description":"An optional signed watch link that pre-identifies the viewer."},"gate_state":{"type":"string"}},"required":["session_uid","channel"]}}}},"responses":{"200":{"description":"The session was started.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"is_live":{"type":"boolean"},"broadcast_id":{"type":["integer","null"]},"hls_url":{"type":["string","null"]},"cf_token":{"type":["string","null"]},"gate":{"type":"object","additionalProperties":true}},"required":["ok","is_live"]}}}},"400":{"description":"Missing session_uid (`BAD_REQUEST`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NoChannel"}}}},"/player/watch-sessions/heartbeat":{"post":{"operationId":"watchSessionHeartbeat","summary":"Send a watch-session heartbeat","x-internal":true,"description":"**Internal, no auth.** The player beats here every ~12s (and once via sendBeacon on unload).\nUpserts the `watch_sessions` row from the engagement + QoE metrics and returns the\nserver-recomputed QoE score so the client can reconcile its overlay.\n","tags":["Player (public)"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"session_uid":{"type":"string"},"token":{"type":"string"},"channel":{"type":"string"},"broadcast_id":{"type":"integer"},"platform":{"type":"string"},"device":{"type":"string"},"joined_at":{"type":"string"},"ended":{"type":"boolean"},"watched_seconds":{"type":"integer","description":"Cumulative seconds the video actually played (watch time)."},"delivered_seconds":{"type":"integer","description":"Cumulative seconds of media DELIVERED (downloaded) to this session — the billing basis. Cloudflare bills on minutes delivered, watched or not, and cannot split delivery per signed URL, so the player measures it here."},"buffering_seconds":{"type":"integer"},"rebuffer_count":{"type":"integer"},"startup_ms":{"type":"integer"}},"additionalProperties":true,"required":["session_uid"]}}}},"responses":{"200":{"description":"The heartbeat was recorded (or graded, when the DB isn't wired in dev).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"persisted":{"type":"boolean"},"session_uid":{"type":"string"},"qoe_score":{"type":["integer","null"]},"qoe_grade":{"type":["string","null"]},"watched_seconds":{"type":"integer"},"delivered_seconds":{"type":"integer","description":"Server-side cumulative delivered (downloaded) seconds for this session."},"ended":{"type":"boolean"}},"required":["ok","persisted"]}}}},"400":{"description":"Missing session_uid (`BAD_REQUEST`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/www/plans":{"get":{"operationId":"getWwwPlans","summary":"Get the public pricing plan catalog","x-internal":true,"description":"**Internal, no auth.** The single source the marketing pricing page reads — the displayable\nplans (in card order), prices in cents. During the Founding-100 window that's the founding\nrows + Custom.\n","tags":["Marketing"],"security":[],"responses":{"200":{"description":"The displayable plans.","content":{"application/json":{"schema":{"type":"object","properties":{"plans":{"type":"array","items":{"$ref":"#/components/schemas/Plan"}}},"required":["plans"]}}}}}}},"/www/founding":{"get":{"operationId":"getFoundingStatus","summary":"Get the live Founding-100 seat count","x-internal":true,"description":"**Internal, no auth.** The real remaining Founding-100 seat count for the marketing site\n(honest scarcity — never a fake/resetting countdown). `active` flips false once the 100\nseats are gone.\n","tags":["Marketing"],"security":[],"responses":{"200":{"description":"The founding-seat status.","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"integer","example":100},"taken":{"type":"integer"},"remaining":{"type":"integer"},"active":{"type":"boolean"}},"required":["total","taken","remaining","active"]}}}}}}},"/www/openapi":{"get":{"operationId":"getOpenApiSpec","summary":"Get the OpenAPI spec as JSON","x-internal":true,"description":"**Internal, no auth.** Serves this OpenAPI spec (the single source of truth) as JSON so the\nmarketing site's API-reference page renders it at build time.\n","tags":["Marketing"],"security":[],"responses":{"200":{"description":"The OpenAPI document as JSON.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"503":{"description":"The spec couldn't be read (`NO_SPEC`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/overview":{"get":{"operationId":"getAdminOverview","summary":"Get the platform-admin overview","x-internal":true,"description":"**Internal, staff only.** Top-line platform counts + the signed-in staff profile.\nAuthenticates as `actor='admin'` exclusively — an `app` or `watcher` token is rejected 403.\n","tags":["Admin"],"responses":{"200":{"description":"The overview counts and staff profile.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"counts":{"type":"object","properties":{"organizations":{"type":"integer"},"subscribers":{"type":"integer"}}},"me":{"type":"object","additionalProperties":true}},"required":["ok","counts"]}}}},"403":{"description":"Staff only (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/stats":{"get":{"operationId":"getAdminStats","summary":"Platform-wide usage stats","x-internal":true,"description":"**Internal, staff only.** Top-line totals across all churches (accounts, channels, subscribers, watch minutes, active sessions).","tags":["Admin"],"responses":{"200":{"description":"Platform stats.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"403":{"description":"Staff only (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/orgs":{"get":{"operationId":"listAdminOrgs","summary":"List / search all churches","x-internal":true,"description":"**Internal, staff only.** Every organization with summary counts (channels, subscribers, watch minutes, plan, status, owner). `q` filters by name / slug / domain.","tags":["Admin"],"parameters":[{"name":"q","in":"query","required":false,"description":"Search term (name / slug / custom domain).","schema":{"type":"string"}}],"responses":{"200":{"description":"The org list.","content":{"application/json":{"schema":{"type":"object","properties":{"orgs":{"type":"array","items":{"type":"object","additionalProperties":true}},"count":{"type":"integer"}},"required":["orgs"]}}}},"403":{"description":"Staff only (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/org":{"get":{"operationId":"getAdminOrg","summary":"One church — channels, members, usage, billing","x-internal":true,"description":"**Internal, staff only.** Full drill-down for a single organization: summary, billing/paywall state, usage, channels, members (with roles), and connected integrations.","tags":["Admin"],"parameters":[{"name":"id","in":"query","required":true,"description":"Organization id or slug.","schema":{"type":"string"}}],"responses":{"200":{"description":"The org detail.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"403":{"description":"Staff only (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Not found (`NO_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/admin/impersonate":{"post":{"operationId":"adminImpersonate","summary":"Open a church-app session AS one of its users","x-internal":true,"description":"**Internal, staff only.** Mints a normal `app` session for the church, stamped with the\nadmin's `impersonator_user_id` (the AuthSession model VALIDATES the impersonator is platform\nstaff). Short-lived (1h) + fully revocable. Returns a hand-off `app_url` carrying the token in\nthe URL FRAGMENT — the admin UI opens it and the app's /impersonate page enters the account.\n","tags":["Admin"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["organization_id"],"properties":{"organization_id":{"type":"string"},"user_id":{"type":"string","description":"Optional — defaults to the org owner."},"reason":{"type":"string"}}}}}},"responses":{"200":{"description":"Impersonation session issued.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"token":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"app_url":{"type":"string","description":"Hand-off URL; token in the fragment."},"acting_as":{"type":"object","properties":{"org":{"type":"string"},"user":{"type":"string"}}}},"required":["ok","token","app_url"]}}}},"403":{"description":"Staff only (`NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Org or target user not found (`NO_ORG` / `NO_TARGET`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/mcp":{"post":{"operationId":"mcpJsonRpc","summary":"MCP JSON-RPC endpoint","x-internal":true,"description":"**Internal.** The Model Context Protocol JSON-RPC 2.0 entry point (Streamable HTTP). A\nchurch points its own AI client at `mcp.pealcast.io` with a `pc_` token; each tool call runs\nagainst the same org-scoped model methods the dashboard uses. Implements `initialize`,\n`ping`, `tools/list`, `tools/call`. A read-only token is blocked from write tools.\n","tags":["MCP"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"description":"A single JSON-RPC 2.0 message or a batch array.","oneOf":[{"type":"object","additionalProperties":true},{"type":"array","items":{"type":"object","additionalProperties":true}}]}}}},"responses":{"200":{"description":"A JSON-RPC response (single object or batch array).","content":{"application/json":{"schema":{"oneOf":[{"type":"object","additionalProperties":true},{"type":"array","items":{"type":"object","additionalProperties":true}}]}}}},"202":{"description":"Accepted — the reply to notification-only / response-only posts (empty body)."},"401":{"description":"Missing/invalid MCP token (JSON-RPC error, `WWW-Authenticate` challenge)."}}},"get":{"operationId":"mcpGet","summary":"MCP GET (not allowed)","x-internal":true,"description":"**Internal.** No server-initiated SSE stream — the MCP spec allows 405 here. Use POST.\n","tags":["MCP"],"security":[],"responses":{"405":{"description":"Method not allowed (`METHOD_NOT_ALLOWED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/health":{"get":{"operationId":"healthCheck","summary":"Health check","x-internal":true,"description":"**Internal, no auth.** Used by CI deploy verification (asserts version == release) and\nuptime monitoring. Reports DB reachability without failing if the DB is down.\n","tags":["System"],"security":[],"responses":{"200":{"description":"The service is healthy.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"healthy"},"environment":{"type":"string"},"version":{"type":"string","description":"The deployed commit SHA."},"db":{"type":"boolean","description":"Whether the database is reachable."},"timestamp":{"type":"string","format":"date-time"}},"required":["status"]}}}}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"A PealCast API token (pc_live_… / pc_test_…). Create one in the app dashboard under Developer / API tokens. Every token is pinned to one church."}},"parameters":{"OrgParam":{"name":"org","in":"query","required":false,"description":"The church (organization) id or slug. Optional — the token is already pinned to one church; supply this only to disambiguate.","schema":{"type":"string"}},"SlugRequiredParam":{"name":"slug","in":"query","required":true,"description":"The channel slug.","schema":{"type":"string"}},"RangeParam":{"name":"range","in":"query","required":false,"description":"The report window, in days.","schema":{"type":"integer","enum":[7,30,90,365],"default":30}}},"responses":{"Unauthenticated":{"description":"Missing or invalid token (`UNAUTHENTICATED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Authentication required","code":"UNAUTHENTICATED"}}}},"ForbiddenOrg":{"description":"The token has no church, or is not permitted for this church (`NO_ORG` / `FORBIDDEN_ORG` / `FORBIDDEN`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"This token is not permitted for that church","code":"FORBIDDEN_ORG"}}}},"ReadOnlyToken":{"description":"A read-only token attempted a write (`READ_ONLY_TOKEN`); may also be `FORBIDDEN_ORG`/`FORBIDDEN`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"This token is read-only","code":"READ_ONLY_TOKEN"}}}},"NoChannel":{"description":"No such channel for this church (`NO_CHANNEL`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Channel not found","code":"NO_CHANNEL"}}}},"NoOrg":{"description":"No such church (`NO_ORG`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Church not found","code":"NO_ORG"}}}},"SessionRequired":{"description":"A `pc_` token attempted a credential-management action that requires a human dashboard login (`SESSION_REQUIRED`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Sign in to the dashboard to manage API access.","code":"SESSION_REQUIRED"}}}},"SessionOrRole":{"description":"Not signed in with a human session, or the caller lacks the required role (`SESSION_REQUIRED` / `FORBIDDEN`). Owner/admin only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"Sign in to the dashboard to manage API access.","code":"SESSION_REQUIRED"}}}},"WebhookAck":{"description":"The event was accepted (always acked for a verified/valid delivery).","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true}},"required":["ok"]}}}},"BadSignature":{"description":"The webhook signature could not be verified (`BAD_SIGNATURE`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"bad signature","code":"BAD_SIGNATURE"}}}}},"schemas":{"Error":{"type":"object","description":"The standard error envelope returned for every 4xx/402/500.","properties":{"error":{"type":"string","description":"A human-readable message.","example":"Channel not found"},"code":{"type":"string","description":"A stable machine code you can branch on.","example":"NO_CHANNEL"}},"required":["error","code"]},"AccountState":{"type":"object","description":"The church's current billing/trial state.","properties":{"state":{"type":"string","enum":["active","past_due","trialing","trial_expired","locked"],"description":"The high-level account state."},"status":{"type":"string","description":"A human-readable status label.","example":"Trial — 9 days left"},"trial_ends_at":{"type":["string","null"],"format":"date-time"},"trial_days_left":{"type":"integer","example":9},"has_payment_method":{"type":"boolean","description":"Whether a card is on file at Stripe."},"is_founding_member":{"type":"boolean","description":"Whether the church is on a Founding 100 plan (50% off the first year, then price-locked for life)."},"founding_intro_active":{"type":"boolean","description":"For a founding church, whether it is still inside its 50%-off first-year window."},"founding_discount_until":{"type":["string","null"],"format":"date","description":"When the founding 50%-off first-year window ends; after this the church pays the price-locked list rate."},"locked_price_cents":{"type":["integer","null"],"description":"The founding church's price-locked monthly list rate (cents), billed from month 13 on; null if not price-locked."},"plan":{"type":["object","null"],"description":"The church's current plan (null while unassigned).","properties":{"code":{"type":"string","example":"medium"},"name":{"type":"string","example":"Medium"}}},"next_bill_at":{"type":["string","null"],"format":"date-time"}},"required":["state","status","trial_days_left","has_payment_method","is_founding_member"]},"Plan":{"type":"object","description":"A purchasable plan row (the plans table is the single source of truth for pricing).","properties":{"code":{"type":"string","example":"medium"},"name":{"type":"string","example":"Medium"},"tagline":{"type":"string","example":"For a growing online congregation"},"founding":{"type":"boolean","description":"Whether this is a Founding 100 plan row."},"is_custom":{"type":"boolean","description":"Whether this is the Custom (contact-us) tier."},"price_cents":{"type":"integer","description":"Monthly price, in cents.","example":9900},"price_cents_annual":{"type":"integer","description":"Annual price, in cents (2 months free).","example":99000},"setup_fee_cents":{"type":"integer","description":"One-time setup/team-training fee, in cents.","example":4900},"trial_days":{"type":"integer","example":14},"watchers":{"type":"integer","description":"Included active watchers (the customer-facing value metric).","example":30},"watchers_label":{"type":"string","example":"~30 watchers"},"included_minutes":{"type":"integer","description":"Included streamed minutes per month, pooled org-wide.","example":8000},"channels_included":{"type":["integer","null"],"description":"Included channels. Null means unlimited.","example":2},"overage_block_minutes":{"type":"integer","description":"Minutes granted per flat $20 overage block (also usable for archive). Ascends by tier — 250 / 500 / 750 on Small / Medium / Large.","example":500},"overage_block_cents":{"type":"integer","description":"Price of one overage block, in cents.","example":2000},"minutes_note":{"type":["string","null"],"description":"DB-driven (i)-tooltip copy explaining the included-minutes math.","example":"One watcher ≈ 60 minutes a week × 4 weeks = about 240 minutes a month, pooled across your whole church."},"overage_note":{"type":["string","null"],"description":"DB-driven (i)-tooltip copy for the overage block.","example":"$20 per 500 extra minutes or archive"},"archive_retention_days":{"type":"integer","description":"Recording auto-delete window in days (30 / 60 / 90 on Small / Medium / Large), pushed to each channel's Cloudflare deleteRecordingAfterDays.","example":60},"archive_label":{"type":["string","null"],"description":"Human storage-window label for the pricing card.","example":"About 2 months"},"support_tier":{"type":"string","example":"email+phone"},"dedicated_sms":{"type":"boolean"},"dedicated_email":{"type":"boolean"},"dedicated_domain":{"type":"boolean"}},"required":["code","name","price_cents","watchers","included_minutes"]},"ChannelNav":{"type":"object","description":"A lightweight channel entry for navigation lists.","properties":{"slug":{"type":"string","example":"main-service"},"id":{"type":"integer"},"name":{"type":"string","example":"Main Service"},"type":{"type":"string","enum":["broadcast","conference"]},"is_live":{"type":"boolean"}},"required":["slug","id","name","type","is_live"]},"ChannelApp":{"type":"object","description":"A channel's full configuration, including its encoder/ingest stream info.","properties":{"slug":{"type":"string","example":"main-service"},"id":{"type":"integer"},"name":{"type":"string"},"type":{"type":"string","enum":["broadcast","conference"]},"description":{"type":["string","null"]},"timezone":{"type":"string","example":"America/New_York"},"is_live":{"type":"boolean"},"gate_enabled":{"type":"boolean"},"gate_free_watch_seconds":{"type":"integer"},"recording_retention_days":{"type":"integer","description":"The channel's recording auto-delete window (days), pushed to Cloudflare as deleteRecordingAfterDays. Editable within [retention_min_days, retention_max_days]; a change re-pushes to Cloudflare.","example":60},"retention_min_days":{"type":"integer","description":"Minimum retention Cloudflare allows (30).","example":30},"retention_max_days":{"type":"integer","description":"Maximum retention for the org's plan (the plan's archive window).","example":90},"subscriber_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"stream_info":{"$ref":"#/components/schemas/StreamInfo"}},"required":["slug","id","name","type","is_live","stream_info"]},"StreamInfo":{"type":"object","description":"The channel's encoder/ingest configuration — point your RTMP/SRT encoder (hardware, OBS, or a simulcast destination) at these to go live. PealCast hosts the feed and is the sole watch destination.","properties":{"cf_status":{"type":"string","description":"The Cloudflare Stream live-input status.","example":"ready"},"provisioned":{"type":"boolean","description":"Whether the live input has been provisioned."},"rtmps_url":{"type":"string","example":"rtmps://live.cloudflare.com:443/live/"},"rtmps_stream_key":{"type":"string"},"srt_url":{"type":"string","example":"srt://live.cloudflare.com:778"},"srt_stream_key":{"type":"string"},"hls_playback_url":{"type":"string","description":"The LL-HLS playback URL."},"sms_from_number":{"type":"string","description":"The number go-live SMS notifications are sent from.","example":"+15551234567"}},"required":["cf_status","provisioned"]},"ViewerRow":{"type":"object","description":"A single watcher (viewer) in your owned audience.","properties":{"subscriber_id":{"type":"integer"},"name":{"type":"string","example":"Jordan Rivera"},"mobile":{"type":"string","description":"The viewer's verified mobile number.","example":"+15551234567"},"first_watch_at":{"type":["string","null"],"format":"date-time"},"last_watch_at":{"type":["string","null"],"format":"date-time"},"watch_minutes":{"type":"integer","example":128}},"required":["subscriber_id","name","mobile"]},"Subscriber":{"type":"object","description":"An audience member (subscriber) — a real person in your owned, verified audience, deduped by verified mobile number.","properties":{"id":{"type":"integer"},"first_name":{"type":["string","null"]},"last_name":{"type":["string","null"]},"full_name":{"type":["string","null"]},"mobile":{"type":["string","null"],"description":"The member's verified mobile number (the dedupe key; not editable).","example":"+15551234567"},"mobile_verified":{"type":"boolean"},"email":{"type":["string","null"]},"email_verified":{"type":"boolean"},"source":{"type":["string","null"],"description":"How this member first entered your audience.","example":"watch_gate"},"created_at":{"type":["string","null"],"format":"date-time"}},"required":["id","mobile_verified","email_verified"]},"OrgApp":{"type":"object","description":"A church (organization) as seen by the church-facing app.","properties":{"id":{"type":"integer"},"name":{"type":"string"},"slug":{"type":"string"},"role":{"type":["string","null"],"description":"The caller's role in this church (owner/admin/manager), when known."},"status":{"type":"string"},"timezone":{"type":"string","example":"America/New_York"}},"required":["id","name","slug","status"]},"Analytics":{"type":"object","description":"A channel's analytics and Shepherd's Report for the window. `new_watchers` are **New Faces** (recent first-time watchers to welcome); `drifting_watchers` are **Missed You** (regulars who have gone quiet and could use a check-in).","properties":{"range_days":{"type":"integer","example":30},"summary":{"type":"object","properties":{"unique_viewers":{"type":"integer","description":"Distinct identified watchers in the window."},"anon_sessions":{"type":"integer","description":"Watch sessions from not-yet-identified visitors."},"watch_minutes":{"type":"integer"},"avg_qoe":{"type":["integer","null"],"description":"Average per-session quality-of-experience score (0–100)."},"broadcasts":{"type":"integer"},"subscriber_count":{"type":"integer"}},"required":["unique_viewers","anon_sessions","watch_minutes","broadcasts","subscriber_count"]},"new_watchers":{"type":"array","description":"New Faces — recent first-time watchers to welcome.","items":{"$ref":"#/components/schemas/ViewerRow"}},"drifting_watchers":{"type":"array","description":"Missed You — regulars who have gone quiet and could use a check-in.","items":{"$ref":"#/components/schemas/ViewerRow"}}},"required":["range_days","summary","new_watchers","drifting_watchers"]},"ViewerDetail":{"type":"object","description":"One watcher's complete profile, watch history, submissions, and notifications.","properties":{"subscriber":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"mobile":{"type":"string"},"email":{"type":["string","null"]},"first_seen_at":{"type":["string","null"],"format":"date-time"},"last_seen_at":{"type":["string","null"],"format":"date-time"}},"required":["id","name","mobile"]},"totals":{"type":"object","properties":{"sessions":{"type":"integer"},"watch_minutes":{"type":"integer"},"avg_qoe":{"type":["integer","null"]}},"required":["sessions","watch_minutes"]},"sessions":{"type":"array","description":"Every watch session for this viewer.","items":{"type":"object","properties":{"broadcast_id":{"type":"integer"},"date":{"type":"string","format":"date-time"},"watch_minutes":{"type":"number"},"completion":{"type":["number","null"],"description":"Fraction of the broadcast watched (0–1)."},"qoe":{"type":["integer","null"]},"platform":{"type":["string","null"],"example":"web"}}}},"submissions":{"type":"array","description":"Prayer/connect form submissions from this viewer.","items":{"type":"object","properties":{"type":{"type":"string","example":"prayer"},"fields":{"type":"object","additionalProperties":true},"at":{"type":"string","format":"date-time"}}}},"notifications":{"type":"array","description":"Go-live notifications sent to this viewer.","items":{"type":"object","properties":{"integration_key":{"type":"string","example":"native.sms"},"status":{"type":"string","example":"sent"},"provider":{"type":["string","null"],"example":"signalwire"},"sent_at":{"type":["string","null"],"format":"date-time"}}}}},"required":["subscriber","totals","sessions","submissions","notifications"]},"ArchiveRecording":{"type":"object","description":"A recorded past broadcast (VOD) within the plan's retention window.","properties":{"id":{"type":"integer"},"date":{"type":"string","format":"date-time","description":"When the broadcast started."},"ended_at":{"type":["string","null"],"format":"date-time"},"duration_minutes":{"type":"number","example":62.5},"recording_status":{"type":"string","example":"ready"},"cf_video_uid":{"type":["string","null"],"description":"The Cloudflare Stream video uid."},"playback_hls_url":{"type":["string","null"],"description":"The VOD HLS playback URL, or null if not yet available."},"watchers":{"type":"integer"},"watch_minutes":{"type":"integer"},"peak_concurrent":{"type":"integer","description":"Peak concurrent viewers during the broadcast."}},"required":["id","date","duration_minutes","recording_status"]},"NotificationsOverview":{"type":"object","description":"Who is subscribed to a channel's go-live notifications, and what has been sent.","properties":{"totals":{"type":"object","properties":{"subscribers":{"type":"integer"},"subscriptions":{"type":"integer"},"unsubscribes":{"type":"integer"}},"required":["subscribers","subscriptions","unsubscribes"]},"by_integration":{"type":"array","description":"Subscription counts per delivery integration.","items":{"type":"object","properties":{"key":{"type":"string","example":"native.sms"},"label":{"type":"string","example":"Text message"},"count":{"type":"integer"}}}},"people":{"type":"array","description":"The subscribed people and the integrations they've opted into.","items":{"type":"object","properties":{"subscriber_id":{"type":"integer"},"name":{"type":"string"},"mobile":{"type":"string"},"integrations":{"type":"array","items":{"type":"string"},"example":["native.sms","native.email"]}}}},"recent":{"type":"array","description":"Recently sent notifications.","items":{"type":"object","properties":{"integration_key":{"type":"string"},"status":{"type":"string"},"provider":{"type":["string","null"]},"subscriber_id":{"type":"integer"},"sent_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}}},"unsubscribes":{"type":"array","description":"Recent unsubscribes.","items":{"type":"object","properties":{"subscriber_id":{"type":"integer"},"name":{"type":"string"},"mobile":{"type":"string"},"integration_key":{"type":"string"},"unsubscribed_at":{"type":"string","format":"date-time"}}}}},"required":["totals","by_integration","people","recent","unsubscribes"]},"ReportSettings":{"type":"object","description":"When the church's weekly Shepherd's Report is delivered.","properties":{"enabled":{"type":"boolean"},"wday":{"type":"integer","minimum":0,"maximum":6,"description":"Day of week (0 = Sunday … 6 = Saturday)."},"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of day, local to `timezone`."},"timezone":{"type":"string","example":"America/New_York"},"last_sent_at":{"type":["string","null"],"format":"date-time"}},"required":["enabled","wday","hour","timezone"]},"ApiTokenInfo":{"type":"object","description":"Metadata for a developer key or personal MCP connection. The plaintext secret is NEVER returned here — only the recognizable prefix + attributes. (Internal.)","properties":{"id":{"type":"integer"},"name":{"type":["string","null"]},"token_type":{"type":"string","enum":["org","personal"]},"access":{"type":"string","enum":["read","read_write"]},"prefix":{"type":"string","description":"The recognizable, non-secret token prefix.","example":"pc_live_"},"last_used_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":["string","null"],"format":"date-time"}},"required":["id"]},"SignupResult":{"type":"object","description":"The result of a completed church-admin signup. (Internal.)","properties":{"ok":{"type":"boolean","const":true},"new_account":{"type":"boolean","description":"Whether a brand-new account (and church) was created."},"profile":{"type":"object","additionalProperties":true}},"required":["ok","new_account","profile"]}}}}