{"openapi":"3.1.0","info":{"title":"Frequency Talk Control Plane","description":"Control plane for the Frequency Talk service.\n\n**Versioning.** Every route is served in two equivalent forms: under `/v1`\n(for example `/v1/platform/config`, how this spec writes routes) and\nunprefixed (`/platform/config`, what every first-party surface calls today). **Neither is\ndeprecated** — the prefix reserves room for a future breaking version; if a `/v2` ever\nexists, unprefixed will keep meaning `/v1`. Use whichever your tooling\nproduces. Only OAuth (`/oauth/*`), `/.well-known/*` and `/health` are exempt: they exist\nat root paths only, as infrastructure outside the versioned contract.\n\n**Errors.** Every failure returns `{ \"error\": { \"code\", \"message\", \"details\"? } }`. Branch\non `code`, never on `message` — messages are prose and get reworded. Codes are additive, so\ntreat an unrecognised code as a generic failure of its HTTP status. The full catalogue is\npublished as `ERROR_CODES` in `@frequency/shared`.\n\n**Capabilities.** `GET /health` reports `version` and a `capabilities` array. Feature-detect\nagainst it rather than assuming a deployment matches the build you developed against.","version":"0.124.2"},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Access token from `/auth/verify-code`, `/auth/refresh`, or `/auth/agent` (an agent account's key exchange)."}},"schemas":{"AccountNotificationDefaultsInput":{"type":"object","properties":{"ringTone":{"description":"Default ring for a contact who has not chosen one. null inherits the catalog default, \"none\" is silence.","$ref":"#/components/schemas/SoundChoiceInput"},"ringVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"callWaitingTone":{"description":"The pip that plays when a second call arrives while you are on one. Account-level only: it belongs to the call you are ALREADY on, not to whoever is calling.","$ref":"#/components/schemas/SoundChoiceInput"},"callWaitingVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"busyTone":{"description":"What plays when a call you placed is refused, or the other person is Busy.","$ref":"#/components/schemas/SoundChoiceInput"},"busyVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"voicemailEnabled":{"type":"boolean","description":"Whether the answering machine picks up. ON by default. With it on, a call this account does not answer within CALL_AUTOANSWER_SECONDS is answered by the machine, which plays the greeting, beeps and records; and a call arriving while this account reads `busy` goes straight there with no ring delay. With it OFF the call rings the full CALL_RING_SECONDS and is then over, and a caller who can already see this account as `busy` gets 409 CALL_BUSY exactly as they always have. ⚠️ The machine lives in the CLIENT, so it can only answer while an app is running: with every app closed the caller still gets CALL_UNAVAILABLE and no message can be left. That bound is accepted (owner, 2026-09-22) and is the price of the platform storing no message audio at any point."},"chatReadReceipts":{"default":true,"description":"Whether your contacts see \"Read\" under what they sent you once you have looked at it. ON by default. Off means `contact.read` is never published for you; it changes nothing about what YOU see of theirs. Never sent while you are invisible either way. Optional on the way in (defaults to on) so an app that predates it can still save the rest of this record.","type":"boolean"},"chatTypingIndicators":{"default":true,"description":"Whether your contacts see \"typing…\" while you write to them. ON by default; same rules as chatReadReceipts.","type":"boolean"},"acceptMessagesFromNonContacts":{"default":false,"description":"Whether somebody who is NOT a contact may send this account a direct message (capability `chat-non-contacts`). OFF by default. On, any account may write to you through POST /contacts/:userId/messages; blocks, Busy, invisibility, the per-account rate limit and the mute all still apply. Off, a non-contact's send is the same 404 a stranger always got, so the setting cannot be probed. Writing to a non-contact yourself lets THAT person reply for a day whatever this says, or a conversation you started could not be answered. Carried to everybody tuned with you as `acceptsMessages` on the roster row, so a client offers the option only where it would be accepted. Optional on the way in (defaults to off).","type":"boolean"},"voicemailMaxSeconds":{"type":"integer","minimum":15,"maximum":300,"description":"The longest message the answering machine will take before it hangs up, in seconds. It is also the machine's hangup deadline, so it is bounded at both ends rather than allowing 'unlimited': the machine holds a real call open, with a media container and the caller's own line, and a message that never ends is a call that never ends. A caller who stops talking is cut off well before this by the silence window."},"voicemailTone":{"description":"What plays when your own machine has taken a message. Account-level only, like busyTone beside it: it is a fact about your side rather than about whoever called.","$ref":"#/components/schemas/SoundChoiceInput"},"voicemailVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"messageTone":{"description":"A chat message arriving.","$ref":"#/components/schemas/SoundChoiceInput"},"messageVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"messageSentTone":{"description":"Your own message going out. \"none\" is a common and sane choice here.","$ref":"#/components/schemas/SoundChoiceInput"},"messageSentVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"waveTone":{"description":"A contact waving at you.","$ref":"#/components/schemas/SoundChoiceInput"},"waveVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"wavePopup":{"type":"boolean","description":"Show a desktop notification when a contact waves, and keep it up until it is dismissed. Account-wide. ⚠️ Ships ON, unlike `onlinePopup` beside it, and the difference is who asked for the interruption: a contact signing in is something that merely happened, while a wave is a person pressing a button whose only purpose is to reach you. It is also the one notification here with nothing to come back to — a wave is never buffered, so an unseen one is simply lost."},"logonTone":{"description":"A contact signing in. ⚠️ Ships as \"none\" — see DEFAULT_ACCOUNT_NOTIFICATION_DEFAULTS for why this is the one pair that may, and why that is not the silent-role trap the other eight guard against.","$ref":"#/components/schemas/SoundChoiceInput"},"logonVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"logoffTone":{"description":"A contact signing out. Ships as \"none\" for the same reason as logonTone.","$ref":"#/components/schemas/SoundChoiceInput"},"logoffVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"joinTone":{"description":"Default for a contact stepping onto a frequency you are tuned to, when they have not chosen one.","$ref":"#/components/schemas/SoundChoiceInput"},"joinVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"leaveTone":{"description":"Default for a contact stepping off one.","$ref":"#/components/schemas/SoundChoiceInput"},"leaveVolume":{"$ref":"#/components/schemas/SoundVolumeInput"},"onlinePopup":{"type":"boolean","description":"Show a desktop notification when a contact signs in or out. The account-wide switch; a contact's own `onlinePopup` overrides it, and null there means inherit this."}},"required":["ringTone","ringVolume","callWaitingTone","callWaitingVolume","busyTone","busyVolume","voicemailEnabled","voicemailMaxSeconds","voicemailTone","voicemailVolume","messageTone","messageVolume","messageSentTone","messageSentVolume","waveTone","waveVolume","wavePopup","logonTone","logonVolume","logoffTone","logoffVolume","joinTone","joinVolume","leaveTone","leaveVolume","onlinePopup"]},"AddOrgMemberBodyInput":{"type":"object","properties":{"handleOrEmail":{"type":"string","minLength":1},"role":{"default":"dj","type":"string","enum":["manager","dj","screener"]}},"required":["handleOrEmail"]},"AgentAuthBodyInput":{"type":"object","properties":{"key":{"type":"string","minLength":16,"maxLength":256,"description":"An agent account key, as minted by registration or `POST /v1/agents/me/keys`."}},"required":["key"]},"AgentKeyLabelInput":{"type":"string","minLength":1,"maxLength":100},"AvatarIdInput":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"Avatar id from GET /avatars. Opaque — draw the placeholder for one you do not recognise."},"BanMemberBodyInput":{"type":"object","properties":{"userId":{"type":"string","minLength":1},"reason":{"type":"string","maxLength":200}},"required":["userId"]},"BulkChannelMembersBodyInput":{"type":"object","properties":{"userIds":{"minItems":1,"maxItems":5000,"type":"array","items":{"type":"string"}}},"required":["userIds"]},"BulkGroupMembersBodyInput":{"type":"object","properties":{"userIds":{"minItems":1,"maxItems":5000,"type":"array","items":{"type":"string"}}},"required":["userIds"]},"BuyPhoneNumberBodyInput":{"type":"object","properties":{"e164":{"type":"string","pattern":"^\\+1[2-9]\\d{2}[2-9]\\d{6}$"},"name":{"type":"string","minLength":1,"maxLength":64}},"required":["e164"]},"CallsignGrammarInput":{"type":"string","pattern":"^[A-Z0-9][A-Z0-9-]{1,15}$"},"ChangeOrgRoleBodyInput":{"type":"object","properties":{"role":{"type":"string","enum":["manager","dj","screener"]}},"required":["role"]},"ChangeRoleBodyInput":{"type":"object","properties":{"role":{"type":"string","enum":["admin","user"]}},"required":["role"]},"ChatConversationRefInput":{"anyOf":[{"type":"object","properties":{"peerUserId":{"type":"string"}},"required":["peerUserId"],"additionalProperties":false},{"type":"object","properties":{"groupId":{"type":"string"}},"required":["groupId"],"additionalProperties":false}]},"ChatMessageKindInput":{"type":"string","enum":["text","reaction","edit","unsend","offer"]},"ChatReplyToInput":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64},"fromUserId":{"type":"string","minLength":1,"maxLength":64},"excerpt":{"type":"string","maxLength":200}},"required":["id","fromUserId","excerpt"]},"ChatThumbnailInput":{"type":"string","maxLength":60000,"pattern":"^data:image\\/(webp|jpeg|png);base64,[A-Za-z0-9+/]+={0,2}$"},"ChatTimeZoneInput":{"type":"string","maxLength":64,"pattern":"^[A-Za-z][A-Za-z0-9_+\\-]*(?:\\/[A-Za-z0-9_+\\-]+){0,2}$"},"ChatVoiceMetaInput":{"type":"object","properties":{"durationMs":{"type":"integer","minimum":0,"maximum":3600000},"peaks":{"maxItems":48,"type":"array","items":{"type":"integer","minimum":0,"maximum":255}}},"required":["durationMs","peaks"]},"CompanyContactInput":{"type":"object","properties":{"companyName":{"type":"string","minLength":1,"maxLength":128},"addressLine1":{"type":"string","minLength":1,"maxLength":200},"addressLine2":{"type":"string","maxLength":200},"city":{"type":"string","minLength":1,"maxLength":120},"region":{"type":"string","maxLength":120},"postalCode":{"type":"string","maxLength":32},"country":{"type":"string","minLength":2,"maxLength":2},"phone":{"type":"string","minLength":1,"maxLength":40},"contactEmail":{"type":"string","maxLength":200,"format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["companyName","addressLine1","city","country","phone","contactEmail"]},"CompletePaymentMethodBodyInput":{"type":"object","properties":{"setupIntentId":{"type":"string","minLength":1}},"required":["setupIntentId"]},"CompleteSubscriptionBodyInput":{"type":"object","properties":{"planKey":{"type":"string","enum":["operator","broadcast"]},"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"setupIntentId":{"type":"string","minLength":1},"ageConfirmed":{"type":"boolean"},"termsAccepted":{"type":"boolean"}},"required":["planKey","setupIntentId"]},"ContactPreferencesInput":{"type":"object","properties":{"alias":{"type":["string","null"],"maxLength":40,"description":"Owner-private nickname, drawn AFTER the callsign and never instead of it — the callsign is how everybody else refers to them, so a row that swapped one for the other leaves the user unable to quote the name that works."},"favorite":{"type":"boolean","description":"Owner-private pin. The other side is never told."},"labels":{"maxItems":12,"type":"array","items":{"type":"string","maxLength":32},"description":"Owner-private grouping. A label is a string several contacts SHARE, not an object anybody owns: no create step, no delete step — typing the same word on two people groups them, and taking it off the last person removes it."},"logonSound":{"description":"Sound when THIS contact signs in. A `logon` library id, \"none\" to mute just them, or null to inherit the account default. Replaced the old `onlineSound` boolean, which was an opt-IN gate: the account setting is the switch now, and this is the per-contact override that beats it.","$ref":"#/components/schemas/SoundChoiceInput"},"logoffSound":{"description":"Sound when they sign out. Chosen SEPARATELY from logonSound — arriving and leaving are two events (owner, 2026-09-20).","$ref":"#/components/schemas/SoundChoiceInput"},"onlinePopup":{"description":"Show a desktop notification when they sign in or out. null INHERITS the account default; true/false override it for this one person. Nullable for the same reason every sound here is: a plain boolean cannot say \"whatever I chose for everybody\", so an account-wide switch could never reach a contact row already written.","type":["boolean","null"]},"allowMessages":{"type":"boolean","description":"May they reach you with a chat or a wave. Enforced server-side and SILENTLY: a refused send answers exactly as a delivered one does, because an \"undeliverable\" answer would be a presence oracle."},"allowCalls":{"type":"boolean","description":"May they ring you. A refused call is placed and simply never delivered, so it rings out at CALL_RING_SECONDS — byte- and latency-identical to nobody answering."},"allowVoicemail":{"default":true,"description":"May they reach your answering machine (0.77.0). With it off they never do: while you are Busy they get CALL_BUSY exactly as if your machine were switched off, and otherwise the call rings the full time and ends. Declining their call hangs up rather than sending them to the machine. Absent on a PUT = true.","type":"boolean"},"ringTone":{"description":"Library id from the `ring` category, \"none\" for silence, or null to inherit.","$ref":"#/components/schemas/SoundChoiceInput"},"joinSound":{"description":"Sound when THIS contact steps onto a frequency you are tuned to. A `join` library id, \"none\", or null to inherit.","$ref":"#/components/schemas/SoundChoiceInput"},"leaveSound":{"description":"Sound when they step off one. A `leave` library id, \"none\", or null to inherit. Chosen SEPARATELY from joinSound: arriving and leaving are two events, not one event with a direction.","$ref":"#/components/schemas/SoundChoiceInput"},"callWaitingSound":{"description":"The pip when THIS contact reaches you while you are already on another call. A `callwaiting` library id, \"none\", or null to inherit the account default.","$ref":"#/components/schemas/SoundChoiceInput"},"messageSound":{"description":"Sound when a chat message arrives from THIS contact. A `message` library id, \"none\" to mute just them, or null to inherit. Silent either way while you are already looking at that conversation.","$ref":"#/components/schemas/SoundChoiceInput"},"waveSound":{"description":"Sound when THIS contact waves. A `wave` library id, \"none\" to mute just them, or null to inherit. ⚠️ Unlike a message this one is never suppressed by having their window open: a wave is not addressed to a conversation, it is somebody asking whether you are there.","$ref":"#/components/schemas/SoundChoiceInput"},"ringVolume":{"description":"How loud their ring is, 0-100, or null to inherit the account level.","$ref":"#/components/schemas/ContactVolumeInput"},"callWaitingVolume":{"description":"How loud their call-waiting pip is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"messageVolume":{"description":"How loud their message tone is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"waveVolume":{"description":"How loud their wave is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"logonVolume":{"description":"How loud their sign-in sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"logoffVolume":{"description":"How loud their sign-out sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"joinVolume":{"description":"How loud their arriving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"},"leaveVolume":{"description":"How loud their leaving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolumeInput"}},"required":["alias","favorite","labels","logonSound","logoffSound","onlinePopup","allowMessages","allowCalls","ringTone","joinSound","leaveSound","callWaitingSound","messageSound","waveSound","ringVolume","callWaitingVolume","messageVolume","waveVolume","logonVolume","logoffVolume","joinVolume","leaveVolume"]},"ContactVolumeInput":{"oneOf":[{"$ref":"#/components/schemas/SoundVolumeInput"},{"type":"null"}]},"CreateChannelBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"talkPermission":{"default":"open","type":"string","enum":["open","managed","broadcast_open"]},"memberUserIds":{"default":[],"type":"array","items":{"type":"string"}}},"required":["name"]},"CreateFrequencyBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"type":"string","maxLength":500},"vanityKhz":{"type":"integer","exclusiveMinimum":0},"publiclyListable":{"type":"boolean"},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"type":"string","enum":["push_to_talk","broadcast"]},"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"stationCallsign":{"$ref":"#/components/schemas/StationCallsignInput"},"companyContact":{"$ref":"#/components/schemas/CompanyContactInput"},"accessCode":{"type":"string","minLength":4,"maxLength":64},"ageConfirmed":{"type":"boolean"},"termsAccepted":{"type":"boolean"}},"required":["name"]},"CreateGroupBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"memberUserIds":{"default":[],"type":"array","items":{"type":"string"}}},"required":["name"]},"CreateInviteLinkBodyInput":{"type":"object","properties":{"role":{"default":"user","type":"string","enum":["admin","user"]},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"maxUses":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"channelId":{"type":"string"},"canTalk":{"default":false,"type":"boolean"}}},"CreateOrgBodyInput":{"type":"object","properties":{"companyContact":{"$ref":"#/components/schemas/CompanyContactInput"}},"required":["companyContact"]},"CreateOrgInviteLinkBodyInput":{"type":"object","properties":{"orgRole":{"default":"dj","type":"string","enum":["manager","dj","screener"]},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"maxUses":{"type":"integer","exclusiveMinimum":0,"maximum":10000}}},"CreatePortalSessionBodyInput":{"type":"object","properties":{"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}}},"CreateSetupIntentBodyInput":{"type":"object","properties":{"planKey":{"type":"string","enum":["operator","broadcast"]},"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},"required":["planKey"]},"DeleteAccountBodyInput":{"type":"object","properties":{"code":{"description":"The six-digit code emailed by POST /users/me/deletion-code. Mutually exclusive with `assertion`.","type":"string","minLength":6,"maxLength":6},"assertion":{"description":"The WebAuthn `AuthenticationResponseJSON` for the challenge from POST /users/me/deletion-challenge (capability `account-deletion-passkey`). Mutually exclusive with `code`. Survives a 409 blocker for resubmission, like `code`, for the 5-minute life of the challenge.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"DowngradeSubscriptionCompleteBodyInput":{"type":"object","properties":{"setupIntentId":{"type":"string","minLength":1},"khz":{"type":"integer","exclusiveMinimum":0},"ageConfirmed":{"type":"boolean"},"termsAccepted":{"type":"boolean"},"companyInfoReleaseAccepted":{"type":"boolean"}},"required":["setupIntentId"]},"FeedbackBodyInput":{"type":"object","properties":{"kind":{"type":"string","enum":["bug","feature_request"]},"category":{"type":"string","enum":["audio","connection","sign_in_account","calls","chat","video_screen_share","stations_broadcast","billing","interface","install_update","other"]},"summary":{"type":"string","minLength":5,"maxLength":120,"description":"One line. Already passed through the documented text cleaning."},"description":{"type":"string","minLength":10,"maxLength":4000,"description":"The report, in words. Text only: no attachments."},"context":{"$ref":"#/components/schemas/FeedbackContextInput"}},"required":["kind","category","summary","description","context"],"additionalProperties":false},"FeedbackContextInput":{"type":"object","properties":{"appVersion":{"type":["string","null"],"maxLength":40},"shellVersion":{"type":["string","null"],"maxLength":40},"platform":{"type":["string","null"],"maxLength":40},"userAgent":{"type":["string","null"],"maxLength":400}},"required":["appVersion","shellVersion","platform","userAgent"],"additionalProperties":false},"ForceMuteBodyInput":{"type":"object","properties":{"userId":{"type":"string"},"muted":{"type":"boolean"}},"required":["userId","muted"]},"HandleInput":{"$ref":"#/components/schemas/CallsignGrammarInput"},"InviteMemberBodyInput":{"type":"object","properties":{"handleOrEmail":{"type":"string","minLength":1}},"required":["handleOrEmail"]},"MemberForceMuteBodyInput":{"type":"object","properties":{"muted":{"type":"boolean"}},"required":["muted"]},"MintAgentKeyBodyInput":{"type":"object","properties":{"label":{"$ref":"#/components/schemas/AgentKeyLabelInput"}},"required":["label"]},"OpaquePassthroughInput":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"PublicDisplayNameInput":{"type":"string","minLength":1,"maxLength":64},"RedeemInviteBodyInput":{"type":"object","properties":{"code":{"type":"string","minLength":4,"maxLength":64}},"required":["code"]},"RefreshTokenBodyInput":{"type":"object","properties":{"refreshToken":{"type":"string","minLength":1},"deviceId":{"type":"string","minLength":1}},"required":["refreshToken","deviceId"]},"RegisterAgentBodyInput":{"type":"object","properties":{"handle":{"type":"string","pattern":"^[A-Z0-9][A-Z0-9-]{1,15}$","description":"The agent's callsign, shown to everyone beside its badge. Same grammar and profanity screen as a person's.","$ref":"#/components/schemas/HandleInput"},"displayName":{"description":"Optional. Defaults to the Station ID the server issues.","$ref":"#/components/schemas/PublicDisplayNameInput"},"label":{"description":"A label for the first key, for example the machine or deployment that will hold it.","$ref":"#/components/schemas/AgentKeyLabelInput"},"ageConfirmed":{"type":"boolean","description":"Must be true. The OPERATOR running this agent attests that they are at least 18 / the age of majority in their jurisdiction. Recorded durably against the account, like a person's attestation on their first frequency."},"termsAccepted":{"type":"boolean","description":"Must be true. The operator accepts the Terms of Service on the agent's behalf; recorded durably."}},"required":["handle","label","ageConfirmed","termsAccepted"]},"RenameCallsignBodyInput":{"type":"object","properties":{"handle":{"$ref":"#/components/schemas/CallsignGrammarInput"}},"required":["handle"]},"RequestCodeBodyInput":{"type":"object","properties":{"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["email"]},"SetAccessCodeBodyInput":{"type":"object","properties":{"accessCode":{"type":["string","null"],"minLength":4,"maxLength":64}},"required":["accessCode"]},"SetFavoriteBodyInput":{"type":"object","properties":{"frequencyId":{"type":"string","minLength":1}},"required":["frequencyId"]},"SetMemberTuningBodyInput":{"type":"object","properties":{"listen":{"description":"Channels the member listens to. Group channels of this frequency only.","$ref":"#/components/schemas/TuningDeltaInput"},"talk":{"description":"Channels the member talks on. `add` takes group channels only; `remove` may also name the member's own personal channel.","$ref":"#/components/schemas/TuningDeltaInput"},"grantAccess":{"type":"boolean","description":"What happens when the member lacks access to a channel being added. false: the request is refused whole with TUNING_ACCESS_REQUIRED. true: they are given the roster row first (talk for a talk add, listen-only for a listen add)."}},"required":["listen","talk","grantAccess"]},"SetOffAirNoticeBodyInput":{"type":"object","properties":{"email":{"type":["string","null"],"maxLength":254,"format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["email"]},"SetStationReactionBodyInput":{"type":"object","properties":{"reaction":{"type":["string","null"],"enum":["cheer","hush",null]}},"required":["reaction"]},"SetTalkPermissionBodyInput":{"type":"object","properties":{"canTalk":{"type":"boolean"}},"required":["canTalk"]},"SoundChoiceInput":{"type":["string","null"],"minLength":1,"maxLength":64,"pattern":"^[a-z0-9][a-z0-9-]*$"},"SoundVolumeInput":{"type":"integer","minimum":0,"maximum":100},"StationCallsignInput":{"$ref":"#/components/schemas/CallsignGrammarInput"},"TopupBodyInput":{"type":"object","properties":{"amountCents":{"type":"integer","minimum":50,"maximum":50000,"description":"US cents to add to the balance."}},"required":["amountCents"]},"TuneBodyInput":{"type":"object","properties":{"transmitIntent":{"default":false,"type":"boolean"},"resume":{"type":"boolean"},"takeOver":{"type":"boolean"},"shareIntent":{"type":"boolean"}}},"TuneByNumberBodyInput":{"type":"object","properties":{"khz":{"type":"integer","exclusiveMinimum":0},"accessCode":{"type":"string"}},"required":["khz"]},"TuningDeltaInput":{"type":"object","properties":{"add":{"maxItems":200,"type":"array","items":{"type":"string"}},"remove":{"maxItems":200,"type":"array","items":{"type":"string"}},"removeAll":{"type":"boolean"}},"required":["add","remove","removeAll"]},"UpdateChannelBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"talkPermission":{"type":"string","enum":["open","managed","broadcast_open"]}}},"UpdateChannelNowPlayingBodyInput":{"type":"object","properties":{"nowPlayingTag":{"type":"string","maxLength":120}},"required":["nowPlayingTag"]},"UpdateFrequencyBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"type":["string","null"],"maxLength":500},"khz":{"type":"integer","exclusiveMinimum":0},"publiclyListable":{"type":"boolean"},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"type":"string","enum":["push_to_talk","broadcast"]},"organizationId":{"type":["string","null"],"format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"stationCallsign":{"$ref":"#/components/schemas/StationCallsignInput"},"companyContact":{"$ref":"#/components/schemas/CompanyContactInput"}}},"UpdateGroupBodyInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64}},"required":["name"]},"UpdateNowPlayingBodyInput":{"type":"object","properties":{"text":{"type":["string","null"],"maxLength":200},"trackTitle":{"type":["string","null"],"maxLength":200},"trackArtist":{"type":["string","null"],"maxLength":200}}},"UpdateOrgBodyInput":{"type":"object","properties":{"companyName":{"type":"string","minLength":1,"maxLength":128},"addressLine1":{"type":["string","null"],"minLength":1,"maxLength":200},"addressLine2":{"type":["string","null"],"maxLength":200},"city":{"type":["string","null"],"minLength":1,"maxLength":120},"region":{"type":["string","null"],"maxLength":120},"postalCode":{"type":["string","null"],"maxLength":32},"country":{"type":["string","null"],"minLength":2,"maxLength":2},"phone":{"type":["string","null"],"minLength":1,"maxLength":40},"contactEmail":{"type":["string","null"],"maxLength":200,"format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}}},"UpdateProfileBodyInput":{"type":"object","properties":{"displayName":{"type":"string","minLength":1,"maxLength":64},"avatarColor":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"avatar":{"oneOf":[{"$ref":"#/components/schemas/AvatarIdInput"},{"type":"null"}]}}},"UpdateStationBodyInput":{"type":"object","properties":{"stationCallsign":{"$ref":"#/components/schemas/StationCallsignInput"},"brandColor":{"type":["string","null"],"pattern":"^#[0-9a-fA-F]{6}$"},"tagline":{"type":["string","null"],"maxLength":140},"websiteUrl":{"type":["string","null"],"maxLength":300,"format":"uri"},"format":{"type":["string","null"],"enum":["talk","music","mixed",null]},"genre":{"type":["string","null"],"maxLength":60},"links":{"maxItems":5,"type":["array","null"],"items":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":24},"url":{"type":"string","maxLength":300,"format":"uri"}},"required":["label","url"]}}}},"UpdateTuningStateBodyInput":{"type":"object","properties":{"active":{"type":"boolean"},"selectedChannelIds":{"type":"array","items":{"type":"string"}},"selectedTransmitChannelIds":{"type":"array","items":{"type":"string"}}}},"UpdateWebAuthnCredentialBodyInput":{"type":"object","properties":{"nickname":{"type":["string","null"],"minLength":1,"maxLength":64}},"required":["nickname"]},"UpgradeSubscriptionCompleteBodyInput":{"type":"object","properties":{"setupIntentId":{"type":"string","minLength":1},"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"stationName":{"type":"string","minLength":1,"maxLength":64},"stationCallsign":{"$ref":"#/components/schemas/StationCallsignInput"},"ageConfirmed":{"type":"boolean"},"termsAccepted":{"type":"boolean"}},"required":["setupIntentId","organizationId","stationCallsign"]},"UpgradeSubscriptionStartBodyInput":{"type":"object","properties":{"stationName":{"type":"string","minLength":1,"maxLength":64},"stationCallsign":{"$ref":"#/components/schemas/StationCallsignInput"},"organizationId":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"companyContact":{"$ref":"#/components/schemas/CompanyContactInput"}},"required":["stationCallsign"]},"UploadStationLogoBodyInput":{"type":"object","properties":{"dataBase64":{"type":"string","minLength":1},"mime":{"type":"string","enum":["image/png","image/jpeg","image/webp"]}},"required":["dataBase64","mime"]},"UserStatusInput":{"type":"string","enum":["online","away","busy","invisible"]},"VerifyCodeBodyInput":{"type":"object","properties":{"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"code":{"type":"string","minLength":6,"maxLength":6},"deviceId":{"type":"string","minLength":1},"clientId":{"type":"string","minLength":1,"maxLength":64},"handle":{"$ref":"#/components/schemas/HandleInput"},"displayName":{"type":"string","minLength":1,"maxLength":64},"signOutOtherSessions":{"type":"boolean"}},"required":["email","code","deviceId"]},"AccessCode":{"type":"object","properties":{"accessCode":{"description":"Null when the frequency is open. Owner/admin only.","type":["string","null"]}},"required":["accessCode"],"additionalProperties":false},"AccessCodeUpdated":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"hasAccessCode":{"type":"boolean"}},"required":["ok","hasAccessCode"],"additionalProperties":false},"AccountDeletionSchedule":{"type":"object","properties":{"requestedAt":{"type":"string","description":"When the deletion was accepted (ISO 8601)."},"purgeAfter":{"type":"string","description":"Erasure happens at the first sweep after this instant (ISO 8601)."},"graceDays":{"type":"integer","description":"The grace window in days; signing in before it ends cancels the deletion."}},"required":["requestedAt","purgeAfter","graceDays"],"additionalProperties":false},"AccountNotificationDefaults":{"type":"object","properties":{"ringTone":{"description":"Default ring for a contact who has not chosen one. null inherits the catalog default, \"none\" is silence.","$ref":"#/components/schemas/SoundChoice"},"ringVolume":{"$ref":"#/components/schemas/SoundVolume"},"callWaitingTone":{"description":"The pip that plays when a second call arrives while you are on one. Account-level only: it belongs to the call you are ALREADY on, not to whoever is calling.","$ref":"#/components/schemas/SoundChoice"},"callWaitingVolume":{"$ref":"#/components/schemas/SoundVolume"},"busyTone":{"description":"What plays when a call you placed is refused, or the other person is Busy.","$ref":"#/components/schemas/SoundChoice"},"busyVolume":{"$ref":"#/components/schemas/SoundVolume"},"voicemailEnabled":{"type":"boolean","description":"Whether the answering machine picks up. ON by default. With it on, a call this account does not answer within CALL_AUTOANSWER_SECONDS is answered by the machine, which plays the greeting, beeps and records; and a call arriving while this account reads `busy` goes straight there with no ring delay. With it OFF the call rings the full CALL_RING_SECONDS and is then over, and a caller who can already see this account as `busy` gets 409 CALL_BUSY exactly as they always have. ⚠️ The machine lives in the CLIENT, so it can only answer while an app is running: with every app closed the caller still gets CALL_UNAVAILABLE and no message can be left. That bound is accepted (owner, 2026-09-22) and is the price of the platform storing no message audio at any point."},"chatReadReceipts":{"default":true,"description":"Whether your contacts see \"Read\" under what they sent you once you have looked at it. ON by default. Off means `contact.read` is never published for you; it changes nothing about what YOU see of theirs. Never sent while you are invisible either way. Optional on the way in (defaults to on) so an app that predates it can still save the rest of this record.","type":"boolean"},"chatTypingIndicators":{"default":true,"description":"Whether your contacts see \"typing…\" while you write to them. ON by default; same rules as chatReadReceipts.","type":"boolean"},"acceptMessagesFromNonContacts":{"default":false,"description":"Whether somebody who is NOT a contact may send this account a direct message (capability `chat-non-contacts`). OFF by default. On, any account may write to you through POST /contacts/:userId/messages; blocks, Busy, invisibility, the per-account rate limit and the mute all still apply. Off, a non-contact's send is the same 404 a stranger always got, so the setting cannot be probed. Writing to a non-contact yourself lets THAT person reply for a day whatever this says, or a conversation you started could not be answered. Carried to everybody tuned with you as `acceptsMessages` on the roster row, so a client offers the option only where it would be accepted. Optional on the way in (defaults to off).","type":"boolean"},"voicemailMaxSeconds":{"type":"integer","minimum":15,"maximum":300,"description":"The longest message the answering machine will take before it hangs up, in seconds. It is also the machine's hangup deadline, so it is bounded at both ends rather than allowing 'unlimited': the machine holds a real call open, with a media container and the caller's own line, and a message that never ends is a call that never ends. A caller who stops talking is cut off well before this by the silence window."},"voicemailTone":{"description":"What plays when your own machine has taken a message. Account-level only, like busyTone beside it: it is a fact about your side rather than about whoever called.","$ref":"#/components/schemas/SoundChoice"},"voicemailVolume":{"$ref":"#/components/schemas/SoundVolume"},"messageTone":{"description":"A chat message arriving.","$ref":"#/components/schemas/SoundChoice"},"messageVolume":{"$ref":"#/components/schemas/SoundVolume"},"messageSentTone":{"description":"Your own message going out. \"none\" is a common and sane choice here.","$ref":"#/components/schemas/SoundChoice"},"messageSentVolume":{"$ref":"#/components/schemas/SoundVolume"},"waveTone":{"description":"A contact waving at you.","$ref":"#/components/schemas/SoundChoice"},"waveVolume":{"$ref":"#/components/schemas/SoundVolume"},"wavePopup":{"type":"boolean","description":"Show a desktop notification when a contact waves, and keep it up until it is dismissed. Account-wide. ⚠️ Ships ON, unlike `onlinePopup` beside it, and the difference is who asked for the interruption: a contact signing in is something that merely happened, while a wave is a person pressing a button whose only purpose is to reach you. It is also the one notification here with nothing to come back to — a wave is never buffered, so an unseen one is simply lost."},"logonTone":{"description":"A contact signing in. ⚠️ Ships as \"none\" — see DEFAULT_ACCOUNT_NOTIFICATION_DEFAULTS for why this is the one pair that may, and why that is not the silent-role trap the other eight guard against.","$ref":"#/components/schemas/SoundChoice"},"logonVolume":{"$ref":"#/components/schemas/SoundVolume"},"logoffTone":{"description":"A contact signing out. Ships as \"none\" for the same reason as logonTone.","$ref":"#/components/schemas/SoundChoice"},"logoffVolume":{"$ref":"#/components/schemas/SoundVolume"},"joinTone":{"description":"Default for a contact stepping onto a frequency you are tuned to, when they have not chosen one.","$ref":"#/components/schemas/SoundChoice"},"joinVolume":{"$ref":"#/components/schemas/SoundVolume"},"leaveTone":{"description":"Default for a contact stepping off one.","$ref":"#/components/schemas/SoundChoice"},"leaveVolume":{"$ref":"#/components/schemas/SoundVolume"},"onlinePopup":{"type":"boolean","description":"Show a desktop notification when a contact signs in or out. The account-wide switch; a contact's own `onlinePopup` overrides it, and null there means inherit this."}},"required":["ringTone","ringVolume","callWaitingTone","callWaitingVolume","busyTone","busyVolume","voicemailEnabled","chatReadReceipts","chatTypingIndicators","acceptMessagesFromNonContacts","voicemailMaxSeconds","voicemailTone","voicemailVolume","messageTone","messageVolume","messageSentTone","messageSentVolume","waveTone","waveVolume","wavePopup","logonTone","logonVolume","logoffTone","logoffVolume","joinTone","joinVolume","leaveTone","leaveVolume","onlinePopup"],"additionalProperties":false},"AgentKeyList":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/AgentKeySummary"},"description":"Newest first, revoked and expired ones included so a deletion can be seen."},"liveKeysMax":{"type":"integer","description":"How many live (unrevoked, unexpired) keys the account may hold at once."}},"required":["keys","liveKeysMax"],"additionalProperties":false},"AgentKeySummary":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/Scope"},"description":"The scopes a token minted from this key carries. Every user scope today."},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"A year after minting. Mint a replacement before this and delete the old one; minting never revokes."},"lastUsedAt":{"type":["string","null"],"format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"The last exchange, to the minute. Null until the key has been used."},"revokedAt":{"type":["string","null"],"format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["id","label","scopes","createdAt","expiresAt","lastUsedAt","revokedAt"],"additionalProperties":false},"AgentRegistration":{"type":"object","properties":{"accessToken":{"type":"string","description":"Short-lived bearer token. Send as `Authorization: Bearer <token>`."},"tokenType":{"type":"string","const":"Bearer"},"expiresIn":{"type":"integer","description":"Seconds until the token expires. Exchange the key again before then; there is no refresh token."},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"user":{"$ref":"#/components/schemas/UserSummary"},"key":{"$ref":"#/components/schemas/AgentKeySummary"},"secret":{"type":"string","description":"The key itself, prefixed `ftag_`. Shown exactly ONCE: the server keeps only a hash. Store it outside any repository and never log it."}},"required":["accessToken","tokenType","expiresIn","expiresAt","user","key","secret"],"additionalProperties":false},"AgentSession":{"type":"object","properties":{"accessToken":{"type":"string","description":"Short-lived bearer token. Send as `Authorization: Bearer <token>`."},"tokenType":{"type":"string","const":"Bearer"},"expiresIn":{"type":"integer","description":"Seconds until the token expires. Exchange the key again before then; there is no refresh token."},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"user":{"$ref":"#/components/schemas/UserSummary"}},"required":["accessToken","tokenType","expiresIn","expiresAt","user"],"additionalProperties":false},"AiringKind":{"type":"string","enum":["track","show"]},"ApiError":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","description":"One of: VALIDATION_ERROR, INVALID_SIGNATURE, UNAUTHENTICATED, INVALID_CODE, INVALID_REFRESH, WEBAUTHN_FAILED, NO_PASSKEY_ENROLLED, SIGNUP_REQUIRED, CALLSIGN_REQUIRED, PROFANE_CALLSIGN, CALLSIGN_RENAME_COOLDOWN, SESSION_CONFLICT, BROADCAST_ELSEWHERE, FREQUENCY_OPENED_ELSEWHERE, TOO_MANY_NOTICES, FORBIDDEN, NOT_MEMBER, NOT_ORG_MEMBER, ACCESS_CODE_REQUIRED, ACCESS_CODE_INVALID, FREQUENCY_SUSPENDED, BANNED, FEEDBACK_BLOCKED, INSUFFICIENT_SCOPE, INVALID_CLIENT, HUMAN_ONLY, AGENT_ONLY, AGENT_REGISTRATION_CLOSED, AGENT_ACCOUNT_KEY_INVALID, AGENT_KEY_LIMIT, AGENT_LIMIT, SPONSORED_AGENT, SPONSORS_AGENTS, CREDIT_FUNDED_SUBSCRIPTION, MACHINE_PAYMENTS_NOT_CONFIGURED, NOT_FOUND, USER_NOT_FOUND, INVITE_INVALID, NO_BILLING_ACCOUNT, NOT_LINKED, INVITE_LINK_LIMIT, FREQUENCY_TAKEN, STATION_NAME_TAKEN, STATION_CALLSIGN_TAKEN, ALREADY_MEMBER, ALREADY_INVITED, AMBIGUOUS_HANDLE, ALREADY_CONTACTS, CONTACT_REQUEST_PENDING, CONTACT_LIMIT, CALL_BUSY, CALL_UNAVAILABLE, CONTACT_BUSY, CONTACT_AWAY, CHAT_CHANGED, CHAT_GROUP_FULL, ALREADY_IN_CHAT, CONTACT_BLOCKED, CANNOT_BAN_OWNER, INVALID_FREQUENCY, HAM_NUMBER_REQUIRED_FOR_PTT, STATION_REQUIRED_FOR_BROADCAST, NOT_A_STATION, NOT_LISTENING, NOTHING_PLAYING, ORGANIZATION_REQUIRED, UNSUPPORTED_COUNTRY, AGE_ATTESTATION_REQUIRED, TERMS_ACCEPTANCE_REQUIRED, DEFAULT_CHANNEL_IMMUTABLE, MOVE_GRANT_REQUIRED, TUNING_ACCESS_REQUIRED, TUNING_CHANNEL_INVALID, TUNING_PROGRAM_CHANNEL, PERSONAL_CHANNEL_IMMUTABLE, PHONE_CHANNEL_IMMUTABLE, PHONE_NOT_CONFIGURED, PHONE_NUMBER_UNAVAILABLE, PHONE_SUBSCRIPTION_REQUIRED, CARRIER_ERROR, PROTECTED_MEMBER, ORG_MANAGED_GROUP, ORG_STAFF_MEMBER, ORG_HAS_FREQUENCIES, CANNOT_REMOVE_OWNER, CANNOT_CHANGE_OWNER, OWNER_CANNOT_LEAVE, OWNS_FREQUENCIES, ACTIVE_SUBSCRIPTION, FILE_TOO_LARGE, TRANSFER_NOT_ALLOWED, RETRY_LIMIT, TOO_MANY_TRANSFERS, LOGO_TOO_LARGE, LOGO_UNREADABLE, UNKNOWN_AVATAR, AVATAR_ID_TAKEN, AVATAR_IMAGE_TOO_LARGE, AVATAR_IMAGE_UNREADABLE, AVATAR_HOLDER_COUNT_STALE, SOUND_CHOOSER_COUNT_STALE, SUBSCRIPTION_REQUIRED, SETUP_NOT_COMPLETED, TAX_LOCATION_REQUIRED, SUBSCRIPTION_NOT_RESUMABLE, PLAN_CHANGE_NOT_ALLOWED, PAYMENT_FAILED, COMPANY_RELEASE_REQUIRED, AGENT_KEY_REQUIRED, AGENT_KEY_INVALID, RATE_LIMITED, INTERNAL_ERROR, EMAIL_DELIVERY_FAILED, SMTP_SEND_FAILED, BILLING_NOT_CONFIGURED, OAUTH_NOT_CONFIGURED, UNKNOWN_MEDIA_NODE, NATIVE_TRANSPORT_DISABLED, GENERATION_NOT_CLEARED, MEDIA_NODE_NOT_RUNNING, SHARE_NOT_ALLOWED, SHARE_NODE_NOT_RUNNING, OFF_AIR, TICKET_EXPIRED, STREAM_CONFLICT, LOAD_TEST_MINT_FAILED, LOAD_TEST_PROVISION_FAILED, LOAD_TEST_DISABLED. Unknown codes may be added at any time."},"message":{"type":"string"},"details":{}},"required":["code","message"],"additionalProperties":false}},"required":["error"],"additionalProperties":false},"AuthServerMetadata":{"type":"object","properties":{"issuer":{"type":"string"},"authorization_endpoint":{"type":"string"},"token_endpoint":{"type":"string"},"jwks_uri":{"type":"string"},"response_types_supported":{"type":"array","items":{"type":"string"}},"grant_types_supported":{"type":"array","items":{"type":"string"}},"code_challenge_methods_supported":{"type":"array","items":{"type":"string"},"description":"Always [\"S256\"] - plain is not supported."},"scopes_supported":{"type":"array","items":{"type":"string"}},"token_endpoint_auth_methods_supported":{"type":"array","items":{"type":"string"}}},"required":["issuer","authorization_endpoint","token_endpoint","jwks_uri","response_types_supported","grant_types_supported","code_challenge_methods_supported","scopes_supported","token_endpoint_auth_methods_supported"],"additionalProperties":false},"AuthSession":{"type":"object","properties":{"accessToken":{"type":"string","description":"Short-lived bearer token. Send as `Authorization: Bearer <token>`."},"refreshToken":{"type":"string","description":"Single-use. Rotated on every refresh: the old value stops working as soon as a new pair is issued, so persist the new one before using it."},"user":{"$ref":"#/components/schemas/UserSummary"},"deletionCancelled":{"type":"boolean"}},"required":["accessToken","refreshToken","user"],"additionalProperties":false},"AvailablePhoneNumber":{"type":"object","properties":{"e164":{"type":"string"},"display":{"type":"string"},"locality":{"description":"City, when the carrier reports one.","type":["string","null"]},"region":{"description":"State, two letters, when the carrier reports one.","type":["string","null"]}},"required":["e164","display","locality","region"],"additionalProperties":false},"AvatarAsset":{"type":"object","properties":{"id":{"type":"string","description":"The opaque token stored on a user's `avatar`."},"label":{"type":"string","description":"Human name for the picker, e.g. \"Microphone\"."},"category":{"type":"string","description":"Grouping for the picker, e.g. \"Equipment\"."},"sortOrder":{"type":"integer","description":"Ascending within a category."},"version":{"type":"integer","description":"Image cache key. Pass as ?v= for an immutable response."}},"required":["id","label","category","sortOrder","version"],"additionalProperties":false},"AvatarCatalog":{"type":"object","properties":{"avatars":{"type":"array","items":{"$ref":"#/components/schemas/AvatarAsset"}}},"required":["avatars"],"additionalProperties":false},"BanOrigin":{"type":"string","enum":["admin","code_attempts"]},"BandPlan":{"type":"object","properties":{"mode":{"type":"string","enum":["push_to_talk","broadcast"]},"minKhz":{"type":"integer","description":"Lowest tunable kHz."},"maxKhz":{"type":"integer","description":"Highest tunable kHz, inclusive."},"gridStepKhz":{"type":["integer","null"],"description":"Channel grid; null means a continuous dial with no snap grid."},"dialResolutionKhz":{"type":"integer","description":"Finest addressable increment — always 1, since kHz is the canonical unit."},"allocationStrategy":{"type":"string","enum":["sequential","bisecting"],"description":"How the server picks the next free number. `sequential` walks allocationStartKhz upward in allocationStepKhz increments. `bisecting` repeatedly halves the open interval (allocationWindowMinKhz, allocationWindowMaxKhz), keeping a sparse dial spread out. Informational only: a number cannot be predicted client-side, since allocation resolves against everything in use across both modes."},"allocationStartKhz":{"type":["integer","null"],"description":"Sequential only; null when bisecting."},"allocationStepKhz":{"type":["integer","null"],"description":"Sequential only; null when bisecting."},"allocationWindowMinKhz":{"type":["integer","null"],"description":"Bisecting only, exclusive; null when sequential."},"allocationWindowMaxKhz":{"type":["integer","null"],"description":"Bisecting only, exclusive; null when sequential."},"bandSpanKhz":{"type":["integer","null"],"description":"Width of the primary band; numbering continues past maxKhz in multiples of this. Null for a continuous dial."},"channelsPerBand":{"type":["integer","null"],"description":"Channels in the primary band; null for a continuous dial."},"labelDecimals":{"type":"integer","description":"Decimal places in the canonical label."},"labelTrimsTrailingZeros":{"type":"boolean","description":"Whether the label trims trailing zeros (\"97.9\") or keeps them (\"118.000\")."},"circular":{"type":"boolean","description":"True when a dial UI should wrap from maxKhz back to minKhz."}},"required":["mode","minKhz","maxKhz","gridStepKhz","dialResolutionKhz","allocationStrategy","allocationStartKhz","allocationStepKhz","allocationWindowMinKhz","allocationWindowMaxKhz","bandSpanKhz","channelsPerBand","labelDecimals","labelTrimsTrailingZeros","circular"],"additionalProperties":false},"BandPlans":{"type":"object","properties":{"push_to_talk":{"$ref":"#/components/schemas/BandPlan"},"broadcast":{"$ref":"#/components/schemas/BandPlan"}},"required":["push_to_talk","broadcast"],"additionalProperties":false},"BillingInvoice":{"type":"object","properties":{"id":{"type":"string"},"number":{"description":"Stripe's human invoice number, once finalised.","type":["string","null"]},"status":{"type":"string","description":"Stripe invoice status: \"draft\", \"open\", \"paid\", \"void\", \"uncollectible\"."},"amountDue":{"type":"number","description":"In the smallest currency unit (cents)."},"amountPaid":{"type":"number","description":"In the smallest currency unit (cents)."},"currency":{"type":"string","description":"Lowercase ISO code, e.g. \"usd\"."},"created":{"type":"string","description":"ISO 8601."},"hostedInvoiceUrl":{"description":"Stripe-hosted invoice page. Open in the system browser.","type":["string","null"]},"invoicePdf":{"description":"Direct PDF download link.","type":["string","null"]}},"required":["id","number","status","amountDue","amountPaid","currency","created","hostedInvoiceUrl","invoicePdf"],"additionalProperties":false},"BillingInvoices":{"type":"object","properties":{"invoices":{"type":"array","items":{"$ref":"#/components/schemas/BillingInvoice"}}},"required":["invoices"],"additionalProperties":false},"BillingPaymentMethod":{"type":"object","properties":{"paymentMethod":{"type":["object","null"],"properties":{"kind":{"type":"string","description":"Stripe PaymentMethod type, e.g. \"card\". Card fields are null for other kinds."},"brand":{"description":"Card network, e.g. \"visa\".","type":["string","null"]},"last4":{"type":["string","null"]},"expMonth":{"type":["number","null"]},"expYear":{"type":["number","null"]}},"required":["kind","brand","last4","expMonth","expYear"],"additionalProperties":false,"description":"The payment method charged for this subscription, or null if none is on file yet."}},"required":["paymentMethod"],"additionalProperties":false},"BillingPublicConfig":{"type":"object","properties":{"configured":{"type":"boolean","description":"Whether this deployment has billing configured (Stripe keys + prices present)."},"publishableKey":{"description":"Stripe publishable key for Payment Element surfaces, or null when not set server-side.","type":["string","null"]},"plans":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","enum":["operator","broadcast"]},"name":{"type":"string"},"priceUsdCents":{"type":"integer"},"per":{"type":"string","const":"frequency"}},"required":["key","name","priceUsdCents","per"],"additionalProperties":false}},"trialDays":{"type":"integer","description":"Free-trial length for first-time customers; returning customers are charged immediately."}},"required":["configured","publishableKey","plans","trialDays"],"additionalProperties":false},"BillingStatus":{"type":"object","properties":{"configured":{"type":"boolean","description":"Whether this deployment has billing configured at all."},"enforced":{"type":"boolean","description":"Whether frequency creation is actually gated on a subscription."},"subscriptions":{"type":"array","items":{"$ref":"#/components/schemas/BillingSubscription"}}},"required":["configured","enforced","subscriptions"],"additionalProperties":false},"BillingSubscription":{"type":"object","properties":{"id":{"type":"string"},"planKey":{"type":"string","enum":["operator","broadcast"]},"status":{"type":"string"},"frequencyId":{"description":"The frequency occupying this slot, or null for an unclaimed (paid but unused) slot.","type":["string","null"]},"currentPeriodEnd":{"type":["string","null"]},"cancelAtPeriodEnd":{"type":"boolean"},"lastPaymentFailureAt":{"description":"When the most recent payment attempt failed, or null while the card is healthy. Cleared on recovery.","type":["string","null"]},"nextPaymentAttemptAt":{"description":"When Stripe will next attempt the card. NULL while `lastPaymentFailureAt` is set means no retry is coming — the card must be replaced. Branch on that pair, not on status alone.","type":["string","null"]},"fundedBy":{"type":"string","enum":["stripe","credit"],"description":"What pays for this row: a card subscription at Stripe, or the account's prepaid credit (0.123.3). A credit-funded row has no Stripe subscription, so the card routes answer 409 CREDIT_FUNDED_SUBSCRIPTION."},"owner":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","const":"user"}},"required":["type"],"additionalProperties":false},{"type":"object","properties":{"type":{"type":"string","const":"organization"},"organizationId":{"type":"string"},"name":{"type":"string"}},"required":["type","organizationId","name"],"additionalProperties":false}]}},"required":["id","planKey","status","frequencyId","currentPeriodEnd","cancelAtPeriodEnd","lastPaymentFailureAt","nextPaymentAttemptAt","fundedBy","owner"],"additionalProperties":false},"BillingTrialEligibility":{"type":"object","properties":{"trialEligible":{"type":"boolean","description":"Whether this caller's next subscription would start with the free trial. False once they own any frequency, or have ever had a subscription (any status, canceled included) on a billing account they can bill against: their own, or a company they hold owner or manager on."},"trialDays":{"type":"integer","description":"Trial length this caller's next subscription would get: the deployment's trial length when eligible, 0 when not."}},"required":["trialEligible","trialDays"],"additionalProperties":false},"BlockedUserList":{"type":"object","properties":{"blocked":{"type":"array","items":{"$ref":"#/components/schemas/BlockedUser"}}},"required":["blocked"],"additionalProperties":false},"BlockedUser":{"type":"object","properties":{"id":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Callsign. NOT unique — stationId is the stable identifier. Null for listener-only accounts (email-only signup); render stationId instead.","type":["string","null"]},"stationId":{"type":"string","description":"Globally unique, human-quotable id (FT-XXXX). The fallback label wherever handle is null."},"avatarColor":{"type":"string","description":"Hex colour, #rrggbb."},"avatar":{"description":"Chosen avatar id from GET /avatars, or null for \"not set\" — a normal state, not an error. Resolve it against the catalog for the image URL; draw the avatarColor placeholder for null OR for an id you do not recognise. Capability `user-avatar`.","type":["string","null"]},"blockedAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["id","kind","handle","stationId","avatarColor","avatar","blockedAt"],"additionalProperties":false},"BoardChannel":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"kind":{"$ref":"#/components/schemas/ChannelKind"},"name":{"type":"string"},"ownerUserId":{"description":"Set for personal channels.","type":["string","null"]},"talkPermission":{"$ref":"#/components/schemas/TalkPermission"},"colorIndex":{"type":"integer"},"isDefault":{"type":"boolean"},"nowPlayingTag":{"type":["string","null"]},"phone":{"description":"The line behind a `phone` channel (capability `phone-numbers`). Absent on every other kind.","$ref":"#/components/schemas/ChannelPhoneSummary"},"createdAt":{"type":"string"},"memberCount":{"type":"integer","description":"Effective members: the direct roster unioned with every linked group. 1 for a personal channel."},"listenerCount":{"type":"integer","description":"Live, from the media node's roster snapshot. Zero when nothing is running."},"talkerCallsigns":{"type":"array","items":{"type":"string"},"description":"Currently transmitting, for roster display."},"viewer":{"$ref":"#/components/schemas/BoardChannelViewer"}},"required":["id","frequencyId","kind","name","ownerUserId","talkPermission","colorIndex","isDefault","nowPlayingTag","createdAt","memberCount","listenerCount","talkerCallsigns","viewer"],"additionalProperties":false},"BoardChannelViewer":{"type":"object","properties":{"member":{"type":"boolean","description":"The caller is an effective member (directly or through a linked group). For a personal channel: the caller owns it."},"directMember":{"type":"boolean","description":"The caller holds a direct roster row, i.e. what a self-leave would remove."},"mayTalk":{"type":"boolean","description":"The caller may transmit here: the shared `canTransmit` rule, evaluated server-side, role override included."},"forceMuted":{"type":"boolean","description":"The caller is under the admin mute on this channel (capability `member-force-mute`): their talk switch may stay on, but the media node refuses their audio here until it is lifted. The caller may lift it themselves."}},"required":["member","directMember","mayTalk","forceMuted"],"additionalProperties":false},"BoardMember":{"type":"object","properties":{"member":{"$ref":"#/components/schemas/FrequencyMember"},"personalChannel":{"oneOf":[{"$ref":"#/components/schemas/BoardChannel"},{"type":"null"}]}},"required":["member","personalChannel"],"additionalProperties":false},"BulkMembersAdded":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"added":{"type":"integer","description":"How many of the requested users were actually added — non-members of the frequency are skipped, not rejected."}},"required":["ok","added"],"additionalProperties":false},"CallAccepted":{"type":"object","properties":{"callId":{"type":"string"},"frequencyId":{"type":"string","description":"The ephemeral call frequency. It appears on no dial surface and is destroyed when the call ends."},"channelId":{"type":"string","description":"The single call channel. Every participant TX and RX exactly this."},"audioMode":{"type":"string","const":"open_mic","description":"Full duplex. Not push-to-talk, and not gated by the console's talk mode."}},"required":["callId","frequencyId","channelId","audioMode"],"additionalProperties":false},"CallEndReason":{"type":"string","enum":["hangup","declined","cancelled","timeout","failed","blocked","moved","merged","removed"]},"CallLogEntry":{"type":"object","properties":{"id":{"type":"string"},"callId":{"type":"string","description":"The call it describes. The room itself is long gone — a call frequency is destroyed when the call ends."},"direction":{"type":"string","enum":["outgoing","incoming"]},"outcome":{"type":"string","enum":["answered","missed","declined","no_answer"],"description":"From the READER's side. See the schema note."},"peers":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"},"description":"Everybody else who was on it, as far as they still exist."},"startedAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When the call was placed. The same instant for everybody on it."},"answeredAt":{"type":["string","null"],"format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When the READER joined, or null if they never did."},"endedAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When the reader's own involvement ended."}},"required":["id","callId","direction","outcome","peers","startedAt","answeredAt","endedAt"],"additionalProperties":false},"CallMoveDestination":{"type":"object","properties":{"frequencyId":{"type":"string"},"frequencyName":{"type":"string"},"frequencyLabel":{"type":"string","description":"Mode-aware display label. Render verbatim, same rule as FrequencySchema.label."},"channelId":{"type":"string"},"channelName":{"type":"string"},"talkPermission":{"$ref":"#/components/schemas/TalkPermission"},"isDefault":{"type":"boolean","description":"The auto-created \"Public\" channel. Worth surfacing: arrivals granted access land there able to LISTEN and not to talk, because its roster follows frequency membership and cannot be edited directly."},"access":{"type":"string","enum":["all_present","grant_required"],"description":"`all_present` — everybody on the call can already reach this channel, and the move grants nothing. `grant_required` — some cannot, and moving here will make them permanent members of the frequency. Say which, before the button is pressed."},"grantUserIds":{"type":"array","items":{"type":"string"},"description":"Who a move here would grant access to. Empty when `access` is `all_present`."},"skippedUserIds":{"type":"array","items":{"type":"string"},"description":"Participants who cannot be brought and will stay behind. A banned participant lands here rather than refusing the whole move, because refusing on their behalf would leak their ban to the host by elimination."}},"required":["frequencyId","frequencyName","frequencyLabel","channelId","channelName","talkPermission","isDefault","access","grantUserIds","skippedUserIds"],"additionalProperties":false},"CallMoveDestinations":{"type":"object","properties":{"participantUserIds":{"type":"array","items":{"type":"string"},"description":"The participants eligibility was computed over: everybody who has JOINED and is not masked. Somebody still ringing is excluded, because they never answered."},"destinations":{"type":"array","items":{"$ref":"#/components/schemas/CallMoveDestination"}}},"required":["participantUserIds","destinations"],"additionalProperties":false},"CallParticipantKind":{"type":"string","enum":["user","external"]},"CallParticipant":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserSummary"},"kind":{"$ref":"#/components/schemas/CallParticipantKind"},"state":{"$ref":"#/components/schemas/CallParticipantState"},"isHost":{"type":"boolean","description":"The participant who placed the call. Only the host may move the call to a frequency."}},"required":["user","kind","state","isHost"],"additionalProperties":false},"CallParticipantState":{"type":"string","enum":["ringing","active","held","left"]},"CallPlaced":{"type":"object","properties":{"callId":{"type":"string"},"frequencyId":{"type":"string","description":"The ephemeral call frequency. Tune it now — do not wait for an answer."},"channelId":{"type":"string","description":"The single call channel. Every participant TX and RX exactly this."},"audioMode":{"type":"string","const":"open_mic","description":"Full duplex. Not push-to-talk, and not gated by the console's talk mode."},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When an unanswered ring times out. The only latency an unanswered call ever shows."},"participants":{"type":"array","items":{"$ref":"#/components/schemas/CallParticipant"},"description":"Everybody invited, the caller included. All invitees read `ringing` whether or not their ring was actually delivered."},"ringback":{"type":"string","enum":["normal","waiting"],"description":"The tone to play while dialling. `waiting` means every invitee who was actually rung is already on another call. Never a statement about a masked invitee."}},"required":["callId","frequencyId","channelId","audioMode","expiresAt","participants","ringback"],"additionalProperties":false},"CallState":{"type":"object","properties":{"callId":{"type":"string"},"state":{"type":"string","enum":["ringing","active","ended"]},"participants":{"type":"array","items":{"$ref":"#/components/schemas/CallParticipant"},"description":"Everybody who has been invited, including the reader and anybody who has since left."},"role":{"type":"string","enum":["host","guest"],"description":"The reader's own role. `caller`/`callee` are gone: in a call of five they name nothing."},"frequencyId":{"description":"The ephemeral call frequency, carried in every state to every participant. Null only on a call that has ended.","type":["string","null"]},"channelId":{"type":["string","null"]},"expiresAt":{"type":["string","null"],"format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When the reader's own unanswered ring times out. Null once they have answered."},"endReason":{"oneOf":[{"$ref":"#/components/schemas/CallEndReason"},{"type":"null"}]}},"required":["callId","state","participants","role","frequencyId","channelId","expiresAt","endReason"],"additionalProperties":false},"ChannelKind":{"type":"string","enum":["personal","group","phone"]},"ChannelPhoneSummary":{"type":"object","properties":{"phoneNumberId":{"type":"string"},"e164":{"type":"string"},"display":{"type":"string"},"status":{"$ref":"#/components/schemas/PhoneNumberStatus"}},"required":["phoneNumberId","e164","display","status"],"additionalProperties":false},"Channel":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"kind":{"$ref":"#/components/schemas/ChannelKind"},"name":{"type":"string"},"ownerUserId":{"description":"Set for personal channels.","type":["string","null"]},"talkPermission":{"$ref":"#/components/schemas/TalkPermission"},"colorIndex":{"type":"integer","description":"Index into the fixed channel-colour palette."},"isDefault":{"type":"boolean","description":"The auto-created \"Public\" channel; its roster follows frequency membership and cannot be edited directly."},"memberUserIds":{"type":"array","items":{"type":"string"},"description":"Directly-added roster. Does NOT include members who are only in via a linked group."},"memberTalkPermissions":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"boolean"},"description":"userId -> canTalk, for direct members only."},"groupIds":{"type":"array","items":{"type":"string"}},"groupTalkPermissions":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"boolean"},"description":"groupId -> canTalk, applying to every member of that group."},"effectiveMemberUserIds":{"type":"array","items":{"type":"string"},"description":"Direct members unioned with every linked group's membership. Recomputed on each read."},"talkEligibleUserIds":{"type":"array","items":{"type":"string"},"description":"The subset of effective members who may transmit here."},"forceMutedUserIds":{"type":"array","items":{"type":"string"},"description":"Members under the admin mute on this channel (capability `member-force-mute`): the media node refuses their audio here until an owner or admin lifts it, or they lift it themselves. Draw Unmute in place of Mute for these."},"nowPlayingTag":{"description":"DJ-set now-playing tag on a broadcast channel; null when unset.","type":["string","null"]},"phone":{"description":"The line behind a `phone` channel (capability `phone-numbers`). Absent on every other kind.","$ref":"#/components/schemas/ChannelPhoneSummary"},"createdAt":{"type":"string"},"listenerCount":{"type":"integer","description":"Live, from the media node's roster snapshot. Zero when nothing is running."},"talkerCallsigns":{"type":"array","items":{"type":"string"},"description":"Currently transmitting, for roster display."}},"required":["id","frequencyId","kind","name","ownerUserId","talkPermission","colorIndex","isDefault","memberUserIds","memberTalkPermissions","groupIds","groupTalkPermissions","effectiveMemberUserIds","talkEligibleUserIds","forceMutedUserIds","nowPlayingTag","createdAt","listenerCount","talkerCallsigns"],"additionalProperties":false},"ChatConversationRef":{"anyOf":[{"type":"object","properties":{"peerUserId":{"type":"string"}},"required":["peerUserId"],"additionalProperties":false},{"type":"object","properties":{"groupId":{"type":"string"}},"required":["groupId"],"additionalProperties":false}]},"ChatFileOffer":{"type":"object","properties":{"offerId":{"type":"string","minLength":1,"maxLength":64},"name":{"type":"string","minLength":1,"maxLength":180},"size":{"type":"integer","minimum":1},"mime":{"type":"string","maxLength":127},"voice":{"oneOf":[{"$ref":"#/components/schemas/ChatVoiceMeta"},{"type":"null"}]},"thumbnail":{"default":null,"oneOf":[{"$ref":"#/components/schemas/ChatThumbnail"},{"type":"null"}]},"width":{"type":["integer","null"],"minimum":1,"maximum":100000,"default":null},"height":{"type":["integer","null"],"minimum":1,"maximum":100000,"default":null},"group":{"default":false,"type":"boolean"}},"required":["offerId","name","size","mime","voice","thumbnail","width","height","group"],"additionalProperties":false},"ChatGroupChangeResult":{"type":"object","properties":{"conversation":{"description":"Where the chat went: a group, the 1:1 the remaining two already share, or null when fewer than two remain.","oneOf":[{"$ref":"#/components/schemas/ChatConversationRef"},{"type":"null"}]},"group":{"description":"The group it went to, when `conversation` is a group.","oneOf":[{"$ref":"#/components/schemas/ChatGroup"},{"type":"null"}]}},"required":["conversation","group"],"additionalProperties":false},"ChatGroupFileOfferResult":{"type":"object","properties":{"id":{"type":"string"},"seq":{"type":"integer"},"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"skipped":{"type":"array","items":{"type":"string"},"description":"Members this message did NOT reach because their status is Busy. Nothing is held for them."},"offerId":{"type":"string","description":"The group offer. Each recipient's transfer is `groupOfferLegId(offerId, theirUserId)`."},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"transfers":{"type":"array","items":{"type":"object","properties":{"offerId":{"type":"string"},"toUserId":{"type":"string"}},"required":["offerId","toUserId"],"additionalProperties":false},"description":"One transfer per member the offer was made to: everybody but you and the members in `skipped`."}},"required":["id","seq","sentAt","skipped","offerId","expiresAt","transfers"],"additionalProperties":false},"ChatGroupList":{"type":"object","properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/ChatGroup"},"description":"Every active group the caller is in. The reconcile after a socket gap, since a missed `chat.membership` has no next edge."}},"required":["groups"],"additionalProperties":false},"ChatGroup":{"type":"object","properties":{"groupId":{"type":"string","description":"Derived from the member set: the same people always make the same id, so a client's history for it continues."},"members":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"},"description":"Everybody in the group, the caller included. Labels only (handle, stationId), never a name."}},"required":["groupId","members"],"additionalProperties":false},"ChatGroupSendResult":{"type":"object","properties":{"id":{"type":"string"},"seq":{"type":"integer"},"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"skipped":{"type":"array","items":{"type":"string"},"description":"Members this message did NOT reach because their status is Busy. Nothing is held for them."}},"required":["id","seq","sentAt","skipped"],"additionalProperties":false},"ChatMessageKind":{"type":"string","enum":["text","reaction","edit","unsend","offer"]},"ChatMessageList":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/ChatMessage"},"description":"Ascending by seq. Only what is still buffered — this is a relay, not a history."}},"required":["messages"],"additionalProperties":false},"ChatMessage":{"type":"object","properties":{"id":{"type":"string"},"seq":{"type":"integer","description":"Monotonic within the conversation, across both directions. The ack cursor."},"fromUserId":{"type":"string"},"body":{"type":"string"},"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"clientMessageId":{"description":"Echoed back so a sender can match this to its optimistic row; null on messages from the other party.","type":["string","null"]},"kind":{"description":"How to fold this in (contract §15). `text` for anything relayed before kinds existed.","$ref":"#/components/schemas/ChatMessageKind"},"refId":{"description":"The message a reaction, edit or unsend points at; null for text.","type":["string","null"]},"reaction":{"description":"The emoji of a reaction; null otherwise.","type":["string","null"]},"reactionRemove":{"type":"boolean","description":"A reaction taking the sender's earlier one back."},"replyTo":{"description":"The message a text replies to, quoted whole; null when not a reply.","oneOf":[{"$ref":"#/components/schemas/ChatReplyTo"},{"type":"null"}]},"tz":{"description":"The sender's IANA time zone, when they share it; null otherwise. Lets a reader show a written time in their own zone.","oneOf":[{"$ref":"#/components/schemas/ChatTimeZone"},{"type":"null"}]},"offer":{"description":"For kind `offer`: the file or voice message offered (contract §20); null otherwise.","oneOf":[{"$ref":"#/components/schemas/ChatFileOffer"},{"type":"null"}]},"sender":{"description":"Who sent it, when the two of you are NOT contacts (capability `chat-non-contacts`), so the thread can be named; null between contacts, whose label comes from the contact list.","oneOf":[{"$ref":"#/components/schemas/ChatSender"},{"type":"null"}]}},"required":["id","seq","fromUserId","body","sentAt","clientMessageId","kind","refId","reaction","reactionRemove","replyTo","tz","offer","sender"],"additionalProperties":false},"ChatReplyTo":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64},"fromUserId":{"type":"string","minLength":1,"maxLength":64},"excerpt":{"type":"string","maxLength":200}},"required":["id","fromUserId","excerpt"],"additionalProperties":false},"ChatSendResult":{"type":"object","properties":{"id":{"type":"string"},"seq":{"type":"integer"},"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["id","seq","sentAt"],"additionalProperties":false},"ChatSender":{"type":"object","properties":{"kind":{"description":"person or agent (contract §25): draw the agent badge on the thread. Defaulted for an older server.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Callsign; null for a listener-only account, so render stationId instead.","type":["string","null"]},"stationId":{"type":"string","description":"FT-XXXX. Stable, and what POST /contacts/requests or a block takes."},"avatar":{"type":["string","null"]},"avatarColor":{"type":"string"}},"required":["kind","handle","stationId","avatar","avatarColor"],"additionalProperties":false},"ChatThumbnail":{"type":"string","maxLength":60000,"pattern":"^data:image\\/(webp|jpeg|png);base64,[A-Za-z0-9+/]+={0,2}$"},"ChatTimeZone":{"type":"string","maxLength":64,"pattern":"^[A-Za-z][A-Za-z0-9_+\\-]*(?:\\/[A-Za-z0-9_+\\-]+){0,2}$"},"ChatTransferLimits":{"type":"object","properties":{"maxFileBytes":{"type":"integer","minimum":1024,"maximum":107374182400},"offerTtlHours":{"type":"integer","minimum":1,"maximum":720},"voiceMaxSeconds":{"type":"integer","minimum":5,"maximum":3600},"maxActivePerUser":{"type":"integer","minimum":1,"maximum":50},"maxGroupFileBytes":{"type":"integer","minimum":1024,"maximum":107374182400}},"required":["maxFileBytes","offerTtlHours","voiceMaxSeconds","maxActivePerUser","maxGroupFileBytes"],"additionalProperties":false},"ChatVoiceMeta":{"type":"object","properties":{"durationMs":{"type":"integer","minimum":0,"maximum":3600000},"peaks":{"maxItems":48,"type":"array","items":{"type":"integer","minimum":0,"maximum":255}}},"required":["durationMs","peaks"],"additionalProperties":false},"CompanyStation":{"type":"object","properties":{"frequencyId":{"type":"string"},"name":{"type":"string"},"stationCallsign":{"type":"string"},"khz":{"type":"integer"},"label":{"type":"string","description":"Mode-aware display label. Render verbatim."}},"required":["frequencyId","name","stationCallsign","khz","label"],"additionalProperties":false},"ConflictingSession":{"type":"object","properties":{"id":{"type":"string","description":"Refresh-token family id of the session that would be closed."},"clientName":{"type":"string","description":"App this session belongs to, e.g. \"Frequency Talk Service Manager\"."},"platform":{"description":"Coarse OS read off the user agent, e.g. \"macOS\". Null when unknown.","type":["string","null"]},"ip":{"description":"Address the session signed in from. Null for sessions predating this field.","type":["string","null"]},"signedInAt":{"type":"string","description":"When this session signed in."},"lastActiveAt":{"description":"Last token rotation; null if it has never refreshed.","type":["string","null"]},"transmitting":{"type":["object","null"],"properties":{"broadcast":{"type":"boolean","description":"True when the live producer is program audio on a broadcast station, not voice."},"stationCallsign":{"type":["string","null"]},"frequencyLabel":{"type":["string","null"]}},"required":["broadcast","stationCallsign","frequencyLabel"],"additionalProperties":false,"description":"Non-null when this session is ON AIR right now — signing it out interrupts the transmission."}},"required":["id","clientName","platform","ip","signedInAt","lastActiveAt","transmitting"],"additionalProperties":false},"ContactList":{"type":"object","properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"maxContacts":{"type":"integer","description":"The server's ceiling, served rather than hardcoded so a client renders the real limit."}},"required":["contacts","maxContacts"],"additionalProperties":false},"ContactPreferences":{"type":"object","properties":{"alias":{"type":["string","null"],"maxLength":40,"description":"Owner-private nickname, drawn AFTER the callsign and never instead of it — the callsign is how everybody else refers to them, so a row that swapped one for the other leaves the user unable to quote the name that works."},"favorite":{"type":"boolean","description":"Owner-private pin. The other side is never told."},"labels":{"maxItems":12,"type":"array","items":{"type":"string","maxLength":32},"description":"Owner-private grouping. A label is a string several contacts SHARE, not an object anybody owns: no create step, no delete step — typing the same word on two people groups them, and taking it off the last person removes it."},"logonSound":{"description":"Sound when THIS contact signs in. A `logon` library id, \"none\" to mute just them, or null to inherit the account default. Replaced the old `onlineSound` boolean, which was an opt-IN gate: the account setting is the switch now, and this is the per-contact override that beats it.","$ref":"#/components/schemas/SoundChoice"},"logoffSound":{"description":"Sound when they sign out. Chosen SEPARATELY from logonSound — arriving and leaving are two events (owner, 2026-09-20).","$ref":"#/components/schemas/SoundChoice"},"onlinePopup":{"description":"Show a desktop notification when they sign in or out. null INHERITS the account default; true/false override it for this one person. Nullable for the same reason every sound here is: a plain boolean cannot say \"whatever I chose for everybody\", so an account-wide switch could never reach a contact row already written.","type":["boolean","null"]},"allowMessages":{"type":"boolean","description":"May they reach you with a chat or a wave. Enforced server-side and SILENTLY: a refused send answers exactly as a delivered one does, because an \"undeliverable\" answer would be a presence oracle."},"allowCalls":{"type":"boolean","description":"May they ring you. A refused call is placed and simply never delivered, so it rings out at CALL_RING_SECONDS — byte- and latency-identical to nobody answering."},"allowVoicemail":{"default":true,"description":"May they reach your answering machine (0.77.0). With it off they never do: while you are Busy they get CALL_BUSY exactly as if your machine were switched off, and otherwise the call rings the full time and ends. Declining their call hangs up rather than sending them to the machine. Absent on a PUT = true.","type":"boolean"},"ringTone":{"description":"Library id from the `ring` category, \"none\" for silence, or null to inherit.","$ref":"#/components/schemas/SoundChoice"},"joinSound":{"description":"Sound when THIS contact steps onto a frequency you are tuned to. A `join` library id, \"none\", or null to inherit.","$ref":"#/components/schemas/SoundChoice"},"leaveSound":{"description":"Sound when they step off one. A `leave` library id, \"none\", or null to inherit. Chosen SEPARATELY from joinSound: arriving and leaving are two events, not one event with a direction.","$ref":"#/components/schemas/SoundChoice"},"callWaitingSound":{"description":"The pip when THIS contact reaches you while you are already on another call. A `callwaiting` library id, \"none\", or null to inherit the account default.","$ref":"#/components/schemas/SoundChoice"},"messageSound":{"description":"Sound when a chat message arrives from THIS contact. A `message` library id, \"none\" to mute just them, or null to inherit. Silent either way while you are already looking at that conversation.","$ref":"#/components/schemas/SoundChoice"},"waveSound":{"description":"Sound when THIS contact waves. A `wave` library id, \"none\" to mute just them, or null to inherit. ⚠️ Unlike a message this one is never suppressed by having their window open: a wave is not addressed to a conversation, it is somebody asking whether you are there.","$ref":"#/components/schemas/SoundChoice"},"ringVolume":{"description":"How loud their ring is, 0-100, or null to inherit the account level.","$ref":"#/components/schemas/ContactVolume"},"callWaitingVolume":{"description":"How loud their call-waiting pip is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"messageVolume":{"description":"How loud their message tone is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"waveVolume":{"description":"How loud their wave is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"logonVolume":{"description":"How loud their sign-in sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"logoffVolume":{"description":"How loud their sign-out sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"joinVolume":{"description":"How loud their arriving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"leaveVolume":{"description":"How loud their leaving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"}},"required":["alias","favorite","labels","logonSound","logoffSound","onlinePopup","allowMessages","allowCalls","allowVoicemail","ringTone","joinSound","leaveSound","callWaitingSound","messageSound","waveSound","ringVolume","callWaitingVolume","messageVolume","waveVolume","logonVolume","logoffVolume","joinVolume","leaveVolume"],"additionalProperties":false},"ContactRequestList":{"type":"object","properties":{"incoming":{"type":"array","items":{"$ref":"#/components/schemas/ContactRequest"}},"outgoing":{"type":"array","items":{"$ref":"#/components/schemas/ContactRequest"}}},"required":["incoming","outgoing"],"additionalProperties":false},"ContactRequest":{"type":"object","properties":{"id":{"type":"string"},"direction":{"type":"string","enum":["incoming","outgoing"]},"user":{"description":"The other party.","$ref":"#/components/schemas/UserSummary"},"status":{"$ref":"#/components/schemas/ContactRequestStatus"},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["id","direction","user","status","createdAt"],"additionalProperties":false},"ContactRequestStatus":{"type":"string","enum":["pending","accepted","declined","cancelled"]},"Contact":{"type":"object","properties":{"id":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Callsign. NOT unique — stationId is the stable identifier. Null for listener-only accounts (email-only signup); render stationId instead.","type":["string","null"]},"stationId":{"type":"string","description":"Globally unique, human-quotable id (FT-XXXX). The fallback label wherever handle is null."},"avatarColor":{"type":"string","description":"Hex colour, #rrggbb."},"avatar":{"description":"Chosen avatar id from GET /avatars, or null for \"not set\" — a normal state, not an error. Resolve it against the catalog for the image URL; draw the avatarColor placeholder for null OR for an id you do not recognise. Capability `user-avatar`.","type":["string","null"]},"status":{"description":"What THIS caller may observe. An invisible contact always reads `offline` — never infer liveness from anything else in this DTO.","$ref":"#/components/schemas/VisibleStatus"},"since":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$","description":"When the request was accepted."},"statusAt":{"type":"integer","description":"When `status` was true, in ms since the epoch — the same clock as the `at` on a `contact.presence` event. Hold the newest value per contact and ignore anything not newer, in EITHER direction: this read and the event stream are two sources of one truth, and an edge arriving while this request is in flight would otherwise lose to the older answer permanently. Sampled BEFORE the statuses are read, so an edge published mid-read is always newer and therefore always wins."},"waveCooldownSeconds":{"type":["integer","null"],"description":"Seconds until YOU may wave at this contact again, or null when you may wave now. Render the Wave control as unavailable with the time remaining rather than letting the press bounce: the refusal is otherwise a 429 the operator has to be looking elsewhere on screen to read. A DURATION and not a deadline, so a client with a skewed clock still counts down correctly — convert it to a local deadline on receipt and re-derive from the `Retry-After` on a 429, which is the same number measured later. This is YOUR spend against them and says nothing whatever about them: a muted, hidden, offline and delivered wave all leave it identical, which is deliberate and is what makes serving it safe."},"alias":{"type":["string","null"],"maxLength":40,"description":"Owner-private nickname, drawn AFTER the callsign and never instead of it — the callsign is how everybody else refers to them, so a row that swapped one for the other leaves the user unable to quote the name that works."},"favorite":{"type":"boolean","description":"Owner-private pin. The other side is never told."},"labels":{"maxItems":12,"type":"array","items":{"type":"string","maxLength":32},"description":"Owner-private grouping. A label is a string several contacts SHARE, not an object anybody owns: no create step, no delete step — typing the same word on two people groups them, and taking it off the last person removes it."},"logonSound":{"description":"Sound when THIS contact signs in. A `logon` library id, \"none\" to mute just them, or null to inherit the account default. Replaced the old `onlineSound` boolean, which was an opt-IN gate: the account setting is the switch now, and this is the per-contact override that beats it.","$ref":"#/components/schemas/SoundChoice"},"logoffSound":{"description":"Sound when they sign out. Chosen SEPARATELY from logonSound — arriving and leaving are two events (owner, 2026-09-20).","$ref":"#/components/schemas/SoundChoice"},"onlinePopup":{"description":"Show a desktop notification when they sign in or out. null INHERITS the account default; true/false override it for this one person. Nullable for the same reason every sound here is: a plain boolean cannot say \"whatever I chose for everybody\", so an account-wide switch could never reach a contact row already written.","type":["boolean","null"]},"allowMessages":{"type":"boolean","description":"May they reach you with a chat or a wave. Enforced server-side and SILENTLY: a refused send answers exactly as a delivered one does, because an \"undeliverable\" answer would be a presence oracle."},"allowCalls":{"type":"boolean","description":"May they ring you. A refused call is placed and simply never delivered, so it rings out at CALL_RING_SECONDS — byte- and latency-identical to nobody answering."},"allowVoicemail":{"default":true,"description":"May they reach your answering machine (0.77.0). With it off they never do: while you are Busy they get CALL_BUSY exactly as if your machine were switched off, and otherwise the call rings the full time and ends. Declining their call hangs up rather than sending them to the machine. Absent on a PUT = true.","type":"boolean"},"ringTone":{"description":"Library id from the `ring` category, \"none\" for silence, or null to inherit.","$ref":"#/components/schemas/SoundChoice"},"joinSound":{"description":"Sound when THIS contact steps onto a frequency you are tuned to. A `join` library id, \"none\", or null to inherit.","$ref":"#/components/schemas/SoundChoice"},"leaveSound":{"description":"Sound when they step off one. A `leave` library id, \"none\", or null to inherit. Chosen SEPARATELY from joinSound: arriving and leaving are two events, not one event with a direction.","$ref":"#/components/schemas/SoundChoice"},"callWaitingSound":{"description":"The pip when THIS contact reaches you while you are already on another call. A `callwaiting` library id, \"none\", or null to inherit the account default.","$ref":"#/components/schemas/SoundChoice"},"messageSound":{"description":"Sound when a chat message arrives from THIS contact. A `message` library id, \"none\" to mute just them, or null to inherit. Silent either way while you are already looking at that conversation.","$ref":"#/components/schemas/SoundChoice"},"waveSound":{"description":"Sound when THIS contact waves. A `wave` library id, \"none\" to mute just them, or null to inherit. ⚠️ Unlike a message this one is never suppressed by having their window open: a wave is not addressed to a conversation, it is somebody asking whether you are there.","$ref":"#/components/schemas/SoundChoice"},"ringVolume":{"description":"How loud their ring is, 0-100, or null to inherit the account level.","$ref":"#/components/schemas/ContactVolume"},"callWaitingVolume":{"description":"How loud their call-waiting pip is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"messageVolume":{"description":"How loud their message tone is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"waveVolume":{"description":"How loud their wave is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"logonVolume":{"description":"How loud their sign-in sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"logoffVolume":{"description":"How loud their sign-out sound is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"joinVolume":{"description":"How loud their arriving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"},"leaveVolume":{"description":"How loud their leaving footstep is, or null to inherit.","$ref":"#/components/schemas/ContactVolume"}},"required":["id","kind","handle","stationId","avatarColor","avatar","status","since","statusAt","waveCooldownSeconds","alias","favorite","labels","logonSound","logoffSound","onlinePopup","allowMessages","allowCalls","allowVoicemail","ringTone","joinSound","leaveSound","callWaitingSound","messageSound","waveSound","ringVolume","callWaitingVolume","messageVolume","waveVolume","logonVolume","logoffVolume","joinVolume","leaveVolume"],"additionalProperties":false},"ContactVolume":{"oneOf":[{"$ref":"#/components/schemas/SoundVolume"},{"type":"null"}]},"CreditBalance":{"type":"object","properties":{"balanceCents":{"type":"integer","description":"The account's prepaid credit, in US cents. Can be negative only after an operator refund or adjustment."},"currency":{"type":"string","const":"usd"},"dailyDebitCents":{"type":"object","properties":{"operator":{"type":"integer"},"broadcast":{"type":"integer"}},"required":["operator","broadcast"],"additionalProperties":false,"description":"What one day of each plan costs, in cents, as the sweeper debits it. Thirty-one days sum to the monthly price."},"minTopupCents":{"type":"integer"},"maxTopupCents":{"type":"integer"},"rails":{"type":"array","items":{"type":"string"},"description":"The payment methods this server offers in a top-up challenge, by mppx method name (`stripe`, `tempo`, `evm`; `fake` on a development server). Empty when it takes no machine payments."},"ledger":{"type":"array","items":{"$ref":"#/components/schemas/CreditLedgerEntry"},"description":"The newest movements, newest first."}},"required":["balanceCents","currency","dailyDebitCents","minTopupCents","maxTopupCents","rails","ledger"],"additionalProperties":false},"CreditLedgerEntry":{"type":"object","properties":{"id":{"type":"string"},"kind":{"$ref":"#/components/schemas/CreditLedgerKind"},"amountCents":{"type":"integer","description":"Signed: positive credits the account, negative debits it."},"externalRef":{"description":"The rail's reference for a top-up, `frequency:<id>:day:<yyyy-mm-dd>` for a daily debit; null for an operator row.","type":["string","null"]},"rail":{"type":["string","null"]},"subscriptionId":{"type":["string","null"]},"frequencyId":{"type":["string","null"]},"planKey":{"type":["string","null"]},"memo":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["id","kind","amountCents","externalRef","rail","subscriptionId","frequencyId","planKey","memo","createdAt"],"additionalProperties":false},"CreditLedgerKind":{"type":"string","enum":["topup_mpp","topup_x402","debit_frequency_day","refund","adjustment","promo"]},"EmojiLibraryEntry":{"type":"object","properties":{"id":{"type":"string","description":"Code points in lowercase hex joined by `-`, VS16 removed (`1f600`, `1f62e-200d-1f4a8`). The image route's key."},"emoji":{"type":"string","description":"The character exactly as it goes on the wire, including any VS16 and ZWJ."},"name":{"type":"string"},"shortcode":{"type":"string","description":"Without colons: `grinning_face_with_smiling_eyes`."},"keywords":{"type":"array","items":{"type":"string"}},"category":{"type":"string","description":"A `categories[].id`."},"sortOrder":{"type":"integer","description":"Picker order within the whole library, ascending."},"version":{"type":"string","description":"Changes when the artwork does. Pass as `?v=` on the image route."}},"required":["id","emoji","name","shortcode","keywords","category","sortOrder","version"],"additionalProperties":false,"description":"One emoji the platform has artwork for."},"EmojiLibrary":{"type":"object","properties":{"categories":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"additionalProperties":false},"description":"Picker tabs, in order."},"emoji":{"type":"array","items":{"$ref":"#/components/schemas/EmojiLibraryEntry"}},"licence":{"type":"string","description":"Terms for the artwork, which clients cache on disk."}},"required":["categories","emoji","licence"],"additionalProperties":false,"description":"The platform emoji library (capability `emoji-library`)."},"Favorite":{"type":"object","properties":{"slot":{"type":"integer"},"frequency":{"$ref":"#/components/schemas/Frequency"},"updatedAt":{"type":"string"}},"required":["slot","frequency","updatedAt"],"additionalProperties":false},"FrequencyBan":{"type":"object","properties":{"userId":{"type":"string"},"handle":{"description":"Null for listener-only accounts; render stationId.","type":["string","null"]},"stationId":{"type":"string"},"avatarColor":{"type":"string"},"avatar":{"description":"Chosen avatar id, or null. See UserSummarySchema.","type":["string","null"]},"origin":{"description":"`admin` = banned by a person; `code_attempts` = automatic, after repeated wrong access codes.","$ref":"#/components/schemas/BanOrigin"},"reason":{"type":["string","null"]},"bannedBy":{"type":["object","null"],"properties":{"handle":{"type":["string","null"]},"stationId":{"type":"string"}},"required":["handle","stationId"],"additionalProperties":false,"description":"Null for automatic (code_attempts) bans. On-air identity only — see UserSummarySchema."},"expiresAt":{"description":"Null = until lifted. code_attempts bans expire on their own.","type":["string","null"]},"createdAt":{"type":"string"}},"required":["userId","handle","stationId","avatarColor","avatar","origin","reason","bannedBy","expiresAt","createdAt"],"additionalProperties":false},"FrequencyBoard":{"type":"object","properties":{"channels":{"type":"array","items":{"$ref":"#/components/schemas/BoardChannel"},"description":"Every channel the caller may see, oldest first. Managed channels they are not on are omitted, exactly as GET /frequencies/:id/channels omits them."}},"required":["channels"],"additionalProperties":false},"FrequencyDetail":{"type":"object","properties":{"id":{"type":"string"},"khz":{"type":"integer","description":"The canonical number. Integer kHz, globally unique across both modes."},"label":{"type":"string","description":"Mode-aware display label — broadcast reads as a dial position (\"97.9\"), push-to-talk keeps aviation notation (\"118.000\"). Render verbatim; do not re-derive it from khz."},"name":{"type":"string"},"description":{"type":["string","null"]},"ownerId":{"type":"string"},"publiclyListable":{"type":"boolean","description":"Whether non-members can discover this frequency. EFFECTIVE, not a raw setting: for broadcast it is derived (listed unless it has an access code — being on the dial IS the listing); for push-to-talk it is the owner's stored flag."},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"$ref":"#/components/schemas/FrequencyMode"},"hasAccessCode":{"type":"boolean","description":"Whether a first-time joiner must supply a code. The code itself is never exposed here."},"organizationId":{"description":"Always set for broadcast (org-only); null for personal push-to-talk.","type":["string","null"]},"station":{"description":"Embedded station identity when org-owned.","oneOf":[{"$ref":"#/components/schemas/FrequencyStation"},{"type":"null"}]},"suspension":{"description":"Set when the frequency is off the air by platform or billing action; null (the normal case) when it is live. Nothing is deleted by a suspension — keep rendering the station's identity, but refuse to tune it. Capability: frequency-suspension.","oneOf":[{"$ref":"#/components/schemas/FrequencySuspension"},{"type":"null"}]},"createdAt":{"type":"string"},"myRole":{"description":"Null when the caller is not a member (only possible for publicly-listable frequencies).","oneOf":[{"$ref":"#/components/schemas/FrequencyRole"},{"type":"null"}]},"mayBroadcast":{"type":"boolean","description":"Whether the caller may send the station's PROGRAM (the broadcast capture profile) on this frequency: its owner or an admin, or staff of the company that owns it with the on-air capability. Everybody else given talk on Public transmits an ordinary VOICE there, which the listen stream mixes with the program; a broadcast-profile produce from them is refused. Only meaningful on a broadcast frequency, since only a station has a program; it is decided without reading the mode so that switching a frequency to broadcast does not strand its owner."},"mayManageChannels":{"type":"boolean","description":"Whether the caller may use the channel controls here: create, rename and delete group channels, and manage who is on them, which includes turning a member's talk on or off (on Public, that puts them on the air). True for the frequency's owner and admins, and, on a frequency a company owns, for its staff at screener or above (company capability `station.manageChannels`, 0.79.0). It does NOT widen what the caller can see: a managed channel stays invisible to staff who are not on its roster. Group links, a channel's own talk permission and force-mute stay admin-only."},"mediaNodeStatus":{"$ref":"#/components/schemas/MediaNodeStatus"}},"required":["id","khz","label","name","description","ownerId","publiclyListable","autoApprovePublicJoin","mode","hasAccessCode","organizationId","station","suspension","createdAt","myRole","mayBroadcast","mayManageChannels","mediaNodeStatus"],"additionalProperties":false},"FrequencyMember":{"type":"object","properties":{"userId":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Null for listener-only accounts; render stationId.","type":["string","null"]},"stationId":{"type":"string"},"avatarColor":{"type":"string"},"avatar":{"description":"Chosen avatar id, or null. See UserSummarySchema.","type":["string","null"]},"role":{"$ref":"#/components/schemas/FrequencyRole"},"staffRole":{"description":"The member's role at the company that owns this station (owner, manager, dj, screener), or null when they are not its staff or the frequency has no company. Display this in place of `role` when present: company staff are synced onto a station as ordinary `user` members, so `role` alone reads `user` for a hired DJ. `role` still decides frequency-admin actions.","oneOf":[{"$ref":"#/components/schemas/OrgRole"},{"type":"null"}]},"joinedAt":{"type":"string"},"online":{"type":"boolean","description":"Live presence at the time of the request. Not a stored field — do not cache it."}},"required":["userId","kind","handle","stationId","avatarColor","avatar","role","staffRole","joinedAt","online"],"additionalProperties":false},"FrequencyMode":{"type":"string","enum":["push_to_talk","broadcast"]},"FrequencyRole":{"type":"string","enum":["owner","admin","user"]},"FrequencyRoster":{"type":"object","properties":{"frequencyId":{"type":"string"},"connectedCount":{"type":"integer"},"talkingCount":{"type":"integer","description":"Members with a non-empty talkingChannelIds set."},"members":{"type":"array","items":{"$ref":"#/components/schemas/TunedRosterMember"},"description":"Everyone tuned, including members subscribed to zero channels and including the caller's own row(s). Maintain with frequency.rosterMember / frequency.rosterLeave deltas; re-fetch after a realtime reconnect."}},"required":["frequencyId","connectedCount","talkingCount","members"],"additionalProperties":false},"Frequency":{"type":"object","properties":{"id":{"type":"string"},"khz":{"type":"integer","description":"The canonical number. Integer kHz, globally unique across both modes."},"label":{"type":"string","description":"Mode-aware display label — broadcast reads as a dial position (\"97.9\"), push-to-talk keeps aviation notation (\"118.000\"). Render verbatim; do not re-derive it from khz."},"name":{"type":"string"},"description":{"type":["string","null"]},"ownerId":{"type":"string"},"publiclyListable":{"type":"boolean","description":"Whether non-members can discover this frequency. EFFECTIVE, not a raw setting: for broadcast it is derived (listed unless it has an access code — being on the dial IS the listing); for push-to-talk it is the owner's stored flag."},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"$ref":"#/components/schemas/FrequencyMode"},"hasAccessCode":{"type":"boolean","description":"Whether a first-time joiner must supply a code. The code itself is never exposed here."},"organizationId":{"description":"Always set for broadcast (org-only); null for personal push-to-talk.","type":["string","null"]},"station":{"description":"Embedded station identity when org-owned.","oneOf":[{"$ref":"#/components/schemas/FrequencyStation"},{"type":"null"}]},"suspension":{"description":"Set when the frequency is off the air by platform or billing action; null (the normal case) when it is live. Nothing is deleted by a suspension — keep rendering the station's identity, but refuse to tune it. Capability: frequency-suspension.","oneOf":[{"$ref":"#/components/schemas/FrequencySuspension"},{"type":"null"}]},"createdAt":{"type":"string"}},"required":["id","khz","label","name","description","ownerId","publiclyListable","autoApprovePublicJoin","mode","hasAccessCode","organizationId","station","suspension","createdAt"],"additionalProperties":false},"FrequencyStation":{"type":"object","properties":{"id":{"type":"string","description":"The broadcast frequency's id. Was the organization's id before 2026-08-02."},"name":{"type":"string","description":"Station name — the frequency's name. Globally unique across stations."},"stationCallsign":{"type":"string","description":"On-air callsign. Globally unique."},"brandColor":{"description":"Hex, #rrggbb. May be illegible on a dark UI — clients should adjust luminance rather than use it raw.","type":["string","null"]},"tagline":{"type":["string","null"]},"logoVersion":{"type":"integer","description":"Increments on every logo change; use it to bust caches of GET /frequencies/{id}/logo."},"hasLogo":{"type":"boolean","description":"Whether GET /frequencies/{id}/logo will return an image."},"format":{"oneOf":[{"$ref":"#/components/schemas/StationFormat"},{"type":"null"}]},"genre":{"type":["string","null"]},"nowPlaying":{"description":"Null when the station has published nothing — branch on presence, not on four nulls.","oneOf":[{"$ref":"#/components/schemas/NowPlaying"},{"type":"null"}]}},"required":["id","name","stationCallsign","brandColor","tagline","logoVersion","hasLogo","format","genre","nowPlaying"],"additionalProperties":false},"FrequencySuspension":{"type":"object","properties":{"class":{"$ref":"#/components/schemas/SuspensionClass"},"since":{"type":"string","description":"When it went off the air."},"reinstatable":{"type":"boolean","description":"Whether the OWNER can lift this themselves (POST /frequencies/{id}/reinstate). Computed server-side; do NOT re-derive it from `class`. True for non_payment and voluntary. The route additionally requires a live subscription, so true here is necessary but not sufficient."},"deletionScheduledAt":{"description":"When the frequency will be DELETED rather than merely left off the air, or null. Non-null only for a `voluntary` suspension raised by cancelling during the free trial, where the station is reclaimed at the end of the trial the customer already had; every other suspension is indefinite. Reinstating at any point before this clears it. Surface the date — a deadline the customer cannot see is one they will miss.","type":["string","null"]}},"required":["class","since","reinstatable","deletionScheduledAt"],"additionalProperties":false},"Group":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"name":{"type":"string"},"memberUserIds":{"type":"array","items":{"type":"string"}},"organizationId":{"description":"Set when the group is derived from an organization's staff; its membership is then read-only.","type":["string","null"]},"createdAt":{"type":"string"}},"required":["id","frequencyId","name","memberUserIds","organizationId","createdAt"],"additionalProperties":false},"IceServer":{"type":"object","properties":{"urls":{"type":"string"},"username":{"type":"string"},"credential":{"type":"string"}},"required":["urls"],"additionalProperties":false},"InvitationAccepted":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"frequencyId":{"type":"string","description":"The frequency just joined, so the caller can tune straight in."}},"required":["ok","frequencyId"],"additionalProperties":false},"Invitation":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"invitedUserId":{"type":"string"},"invitedByUserId":{"type":"string"},"status":{"$ref":"#/components/schemas/InvitationStatus"},"createdAt":{"type":"string"},"respondedAt":{"type":["string","null"]}},"required":["id","frequencyId","invitedUserId","invitedByUserId","status","createdAt","respondedAt"],"additionalProperties":false},"InvitationStatus":{"type":"string","enum":["pending","accepted","declined","revoked"]},"InviteLinkPreview":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"frequency"},"name":{"type":"string"},"khz":{"type":"integer"},"label":{"type":"string","description":"Mode-aware display label. Render verbatim."},"channelId":{"description":"Channel-targeted links: the target channel's id — the identity to match on. Null = plain membership (default Public via ordinary join).","type":["string","null"]},"channelName":{"description":"Channel-targeted links: where the redeemer lands. Null = plain membership. Display only — never match on this.","type":["string","null"]},"canTalk":{"type":"boolean","description":"Whether redeeming grants transmit on that channel."}},"required":["kind","name","khz","label","channelId","channelName","canTalk"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"organization"},"name":{"type":"string"}},"required":["kind","name"],"additionalProperties":false}]},"InviteLink":{"type":"object","properties":{"id":{"type":"string"},"code":{"type":"string","description":"The shareable code. Case-insensitive on redeem."},"frequencyId":{"type":["string","null"]},"organizationId":{"type":["string","null"]},"role":{"description":"Granted on redeem of a FREQUENCY link. Present but meaningless on an organization link.","$ref":"#/components/schemas/FrequencyRole"},"orgRole":{"description":"Granted on redeem of an ORGANIZATION link; null on a frequency link.","oneOf":[{"$ref":"#/components/schemas/OrgRole"},{"type":"null"}]},"expiresAt":{"type":["string","null"]},"maxUses":{"type":["integer","null"],"description":"Null = unlimited."},"useCount":{"type":"integer"},"revoked":{"type":"boolean"},"createdAt":{"type":"string"},"channelId":{"description":"Channel-targeted links: redeem places the redeemer on this channel. Null = plain membership (default Public via ordinary join).","type":["string","null"]},"channelName":{"description":"The target channel's CURRENT name — renames track live; the id is the identity.","type":["string","null"]},"canTalk":{"type":"boolean","description":"Whether redeem grants transmit on the target channel. Always false on untargeted and organization links."}},"required":["id","code","frequencyId","organizationId","role","orgRole","expiresAt","maxUses","useCount","revoked","createdAt","channelId","channelName","canTalk"],"additionalProperties":false},"InviteRedeemResult":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"frequency"},"frequencyId":{"type":"string"},"channelId":{"description":"Channel-targeted links: the channel this redeem placed the redeemer on. Null = plain membership, no channel grant.","type":["string","null"]}},"required":["kind","frequencyId","channelId"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"organization"},"organizationId":{"type":"string"}},"required":["kind","organizationId"],"additionalProperties":false}]},"JoinRequestResult":{"oneOf":[{"type":"object","properties":{"status":{"type":"string","const":"joined"}},"required":["status"],"additionalProperties":false,"description":"Auto-approve was on; the caller is now a member."},{"type":"object","properties":{"status":{"type":"string","const":"pending_approval"},"invitationId":{"type":"string"}},"required":["status","invitationId"],"additionalProperties":false,"description":"An admin must approve; poll or await the realtime event."}]},"Jwk":{"type":"object","properties":{"kty":{"type":"string"},"crv":{"type":"string"},"x":{"type":"string"},"kid":{"type":"string"},"alg":{"type":"string"},"use":{"type":"string"}},"required":["kty","kid","alg","use"],"additionalProperties":false,"description":"Public JSON Web Key. Ed25519 (OKP/EdDSA) today; treat the set as the contract, not the algorithm."},"Jwks":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/Jwk"}}},"required":["keys"],"additionalProperties":false},"LinkPreviewResponse":{"type":"object","properties":{"preview":{"description":"Null when the page has no card, cannot be fetched safely, or previews are switched off.","oneOf":[{"$ref":"#/components/schemas/LinkPreview"},{"type":"null"}]},"enabled":{"type":"boolean","description":"False when an operator has switched link previews off platform-wide."}},"required":["preview","enabled"],"additionalProperties":false},"LinkPreview":{"type":"object","properties":{"url":{"type":"string","description":"The URL previewed, fragment removed."},"host":{"type":"string","description":"The host the page was finally served from, after redirects."},"title":{"type":["string","null"]},"description":{"type":["string","null"]},"siteName":{"type":["string","null"]},"image":{"description":"data:image/webp;base64,... re-encoded server-side, or null.","type":["string","null"]}},"required":["url","host","title","description","siteName","image"],"additionalProperties":false},"ListenTicketResponse":{"type":"object","properties":{"ticket":{"type":"string","description":"Opaque, bound to this frequency/account/install/session. Active playback keeps it alive; a stopped player's ticket expires on its own. Treat it as a credential — never log it."},"playlistUrl":{"type":"string","pattern":"^\\/(?!\\/)","description":"A PATH on this control plane's origin, ticket included — guaranteed to start with a single `/`. Hand it to the player verbatim; do not append anything, and resolve it against the origin you already trust."},"expiresInSeconds":{"type":"integer","description":"Idle lifetime. Every playlist poll slides the expiry, so this only runs out once playback stops. On 401 TICKET_EXPIRED, mint a new ticket rather than refreshing the access token first — only mint failures concern the token."},"streamHealth":{"description":"Capability `stream-health`. Whether the station's egress pipeline can serve what its producer is sending: `live` = on air and servable; `stalled` = transmitting but the encoder cannot serve, so playback will answer OFF_AIR despite the station being on the air — show a technical-difficulties state rather than plain static. ABSENT means no signal: station off air, or a deployment/node predating the capability — behave exactly as before this field existed.","type":"string","enum":["live","stalled"]},"liveState":{"description":"Capability `hls-mint-offair`. The station stage the mint preflight read — never \"off_air\" (that answers 503 OFF_AIR with no ticket minted). ABSENT on push-to-talk frequencies and deployments predating the capability. dead_air and interrupted still mint: the stream is expected back, so hold the ticket and poll.","$ref":"#/components/schemas/StationLiveStage"}},"required":["ticket","playlistUrl","expiresInSeconds"],"additionalProperties":false},"ListenerPlaces":{"type":"object","properties":{"from":{"type":"string","description":"First UTC day of the period, YYYY-MM-DD (inclusive)."},"to":{"type":"string","description":"The UTC day the period ends BEFORE, YYYY-MM-DD (exclusive)."},"floor":{"type":"integer","description":"A place with fewer distinct listeners than this in the period is not named. Its listeners are still counted in the level above: a small state in its country, a small country in `elsewhere`."},"listeners":{"type":"integer","description":"Distinct listeners in the period, placed or not. The denominator for a percentage."},"unknown":{"type":"integer","description":"Listeners with at least one listen that carries no place: listens before 0.115.0, a private or unlisted address, or a listen opened with no location database loaded. A listener can count here and in a country."},"elsewhere":{"type":"integer","description":"Listeners in countries too small to name, added together."},"countries":{"type":"array","items":{"type":"object","properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code, upper case. The app supplies the name."},"listeners":{"type":"integer"},"areas":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"A stable id, for example \"us-west-coast\"."},"name":{"type":"string","description":"English display name, for example \"West Coast\"."},"listeners":{"type":"integer"}},"required":["id","name","listeners"],"additionalProperties":false},"description":"Broad areas of the country, largest first. United States only today; empty for every other country. An area is a group of states, so it can be named where none of its states is large enough to be."},"regions":{"type":"array","items":{"type":"object","properties":{"region":{"type":"string","description":"State or province, by its English name."},"listeners":{"type":"integer"}},"required":["region","listeners"],"additionalProperties":false},"description":"States or provinces at or above the floor, largest first. Somebody who listened from two counts in both, so these can sum to more than the country."}},"required":["country","listeners","areas","regions"],"additionalProperties":false},"description":"Countries at or above the floor, largest first."},"attribution":{"type":["object","null"],"properties":{"text":{"type":"string"},"url":{"type":"string"}},"required":["text","url"],"additionalProperties":false,"description":"The credit the location database's licence requires. SHOW IT, as a link, wherever these totals are drawn. Null when the deployment has no database loaded."}},"required":["from","to","floor","listeners","unknown","elsewhere","countries","attribution"],"additionalProperties":false},"ListenerSessionList":{"type":"object","properties":{"sessions":{"type":"array","items":{"$ref":"#/components/schemas/ListenerSession"},"description":"Newest first."},"liveCount":{"type":"integer","description":"Open sessions (endedAt null) on the frequency right now."}},"required":["sessions","liveCount"],"additionalProperties":false},"ListenerSession":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"userId":{"description":"Null for anonymous listeners and for erased accounts (identity detached, row retained).","type":["string","null"]},"handle":{"description":"The listener's callsign at read time, when the account still exists. Also null for a listener-only account, which has none — fall back to stationId.","type":["string","null"]},"stationId":{"description":"The listener's Station ID at read time, when the account still exists. Replaced `displayName` on 2026-08-29: most rows here come from listen-only apps whose accounts carry NO callsign, so without it a signed-in listener would be unidentifiable — and this is licensing evidence. On-air identity only; no real name.","type":["string","null"]},"transport":{"type":"string","enum":["hls","webrtc","native"],"description":"Which audio path carried the listen — hls covers the Tuner and any player; webrtc the Service Manager; native a Switchboard engine."},"clientId":{"description":"Which APP the listen came from — an OAuth client_id (\"tuner-windows\", \"tuner-apple\", \"frequency-web\", …); §2 maps id to name, and the id is sent because renaming a shipped client moves only its display name. NOT A DEVICE CLASS and must not be rendered as one: `tuner-apple` is deliberately one client_id for macOS AND iOS (§4), so a phone and a laptop are indistinguishable here by design, and no smart-speaker or car client exists. Null on rows predating 2026-09-08 (no backfill is possible) and whenever the listening install has no surviving session to resolve.","type":["string","null"]},"startedAt":{"type":"string"},"lastSeenAt":{"type":"string","description":"Last proven moment of listening — the liveness clock."},"endedAt":{"description":"Null while the listen is live.","type":["string","null"]},"endReason":{"description":"How it ended: \"disconnected\", \"stopped\", \"expired\", \"timeout\", \"revoked\", \"replaced\".","type":["string","null"]}},"required":["id","frequencyId","userId","handle","stationId","transport","clientId","startedAt","lastSeenAt","endedAt","endReason"],"additionalProperties":false},"MediaNodeStatus":{"type":"string","enum":["starting","running","stopping","stopped","error"],"description":"\"stopped\" is an ordinary state, not a failure — provisioning is transmit-gated, so a frequency nobody is transmitting on has no container."},"MemberForceMuteResult":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"channelIds":{"type":"array","items":{"type":"string"},"description":"Every channel of the frequency the member could talk on, all now in the requested state (muted or not)."}},"required":["ok","channelIds"],"additionalProperties":false},"MemberTuningResult":{"type":"object","properties":{"frequencyId":{"type":"string"},"active":{"type":"boolean","description":"Whether the user currently has this frequency tuned in."},"selectedChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels the user is listening to."},"selectedTransmitChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels the user's transmit toggle addresses."},"applied":{"type":"string","enum":["live","stored"],"description":"live: the member is tuned to this frequency right now and their client was sent the change. stored: they are not (or are appearing offline), so it applies when they next tune."}},"required":["frequencyId","active","selectedChannelIds","selectedTransmitChannelIds","applied"],"additionalProperties":false},"MemberTuningStates":{"type":"object","properties":{"states":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"selectedChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels this member has saved as listening."},"selectedTransmitChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels this member has saved as talking, their own personal channel included."}},"required":["userId","selectedChannelIds","selectedTransmitChannelIds"],"additionalProperties":false},"description":"One row per member who has a saved selection. A member with none is absent, which reads as nothing selected."},"forceMuted":{"type":"array","items":{"type":"object","properties":{"userId":{"type":"string"},"channelIds":{"type":"array","items":{"type":"string"},"description":"The channels of this frequency this member is under the admin mute on."}},"required":["userId","channelIds"],"additionalProperties":false},"description":"Who is under the admin mute, and where (capability `member-force-mute`). Independent of the saved selections: a member with nothing saved can still appear here. Draw Unmute for a member listed here."}},"required":["states","forceMuted"],"additionalProperties":false},"MintedAgentKey":{"type":"object","properties":{"key":{"$ref":"#/components/schemas/AgentKeySummary"},"secret":{"type":"string","description":"The key itself, prefixed `ftag_`. Shown exactly ONCE: the server keeps only a hash. Store it outside any repository and never log it."}},"required":["key","secret"],"additionalProperties":false},"MyFrequency":{"type":"object","properties":{"id":{"type":"string"},"khz":{"type":"integer","description":"The canonical number. Integer kHz, globally unique across both modes."},"label":{"type":"string","description":"Mode-aware display label — broadcast reads as a dial position (\"97.9\"), push-to-talk keeps aviation notation (\"118.000\"). Render verbatim; do not re-derive it from khz."},"name":{"type":"string"},"description":{"type":["string","null"]},"ownerId":{"type":"string"},"publiclyListable":{"type":"boolean","description":"Whether non-members can discover this frequency. EFFECTIVE, not a raw setting: for broadcast it is derived (listed unless it has an access code — being on the dial IS the listing); for push-to-talk it is the owner's stored flag."},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"$ref":"#/components/schemas/FrequencyMode"},"hasAccessCode":{"type":"boolean","description":"Whether a first-time joiner must supply a code. The code itself is never exposed here."},"organizationId":{"description":"Always set for broadcast (org-only); null for personal push-to-talk.","type":["string","null"]},"station":{"description":"Embedded station identity when org-owned.","oneOf":[{"$ref":"#/components/schemas/FrequencyStation"},{"type":"null"}]},"suspension":{"description":"Set when the frequency is off the air by platform or billing action; null (the normal case) when it is live. Nothing is deleted by a suspension — keep rendering the station's identity, but refuse to tune it. Capability: frequency-suspension.","oneOf":[{"$ref":"#/components/schemas/FrequencySuspension"},{"type":"null"}]},"createdAt":{"type":"string"},"myRole":{"$ref":"#/components/schemas/FrequencyRole"},"mayBroadcast":{"type":"boolean","description":"Whether the caller may send the station's PROGRAM (the broadcast capture profile) on this frequency: its owner or an admin, or staff of the company that owns it with the on-air capability. Everybody else given talk on Public transmits an ordinary VOICE there, which the listen stream mixes with the program; a broadcast-profile produce from them is refused. Only meaningful on a broadcast frequency, since only a station has a program; it is decided without reading the mode so that switching a frequency to broadcast does not strand its owner."},"mayManageChannels":{"type":"boolean","description":"Whether the caller may use the channel controls here: create, rename and delete group channels, and manage who is on them, which includes turning a member's talk on or off (on Public, that puts them on the air). True for the frequency's owner and admins, and, on a frequency a company owns, for its staff at screener or above (company capability `station.manageChannels`, 0.79.0). It does NOT widen what the caller can see: a managed channel stays invisible to staff who are not on its roster. Group links, a channel's own talk permission and force-mute stay admin-only."},"mediaNodeStatus":{"description":"Current media-node status for this frequency. One list call seeds every band's status at directory build and after a realtime reconnect — a lost `mediaNode.status` edge is unrecoverable by waiting, so re-seed from here rather than polling per frequency.","$ref":"#/components/schemas/MediaNodeStatus"},"tunedCounts":{"description":"Live tuned-presence counts (capability: frequency-roster). Absent when unknown (Redis unavailable). Like mediaNodeStatus, this list is the bulk seed: `frequency.tunedCounts` edges that fire while a client is disconnected are lost, not replayed — re-seed from here after a reconnect.","$ref":"#/components/schemas/TunedCounts"}},"required":["id","khz","label","name","description","ownerId","publiclyListable","autoApprovePublicJoin","mode","hasAccessCode","organizationId","station","suspension","createdAt","myRole","mayBroadcast","mayManageChannels","mediaNodeStatus"],"additionalProperties":false},"MyStationReaction":{"type":"object","properties":{"since":{"description":"The airing this answer is about: `nowPlaying.since`. Null when nothing can be cheered or hushed right now.","type":["string","null"]},"reaction":{"description":"The caller's own Cheer or Hush on that airing. Null when they have sent neither, or took it back.","oneOf":[{"$ref":"#/components/schemas/StationReaction"},{"type":"null"}]}},"required":["since","reaction"],"additionalProperties":false},"MyStationStats":{"type":"object","properties":{"stations":{"type":"array","items":{"$ref":"#/components/schemas/StationLiveState"},"description":"Live state for every broadcast frequency the caller is a member of, listed or code-gated, ordered by dial number."}},"required":["stations"],"additionalProperties":false},"MyStatus":{"type":"object","properties":{"chosen":{"$ref":"#/components/schemas/UserStatus"},"effective":{"$ref":"#/components/schemas/UserStatus"}},"required":["chosen","effective"],"additionalProperties":false},"NativeKeysResult":{"type":"object","properties":{"keysetId":{"type":"string","description":"Identity of this key material; matches the keysetId in the notice that prompted the fetch."},"bootId":{"type":"string","description":"The node generation these keys are bound to. A mismatch against your session's generation means re-tune, not install."},"epoch":{"type":"integer","minimum":0,"maximum":255,"description":"Current key epoch (uint8, wraps mod 256)."},"keys":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The channel's uint32 id as it appears in the packet header's channelId field."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM channel key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false},"description":"Channel UUID -> { id, key }."},"shareKey":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The share key's uint32 wire id: the packet header's channelId on every share packet. Take it from here rather than hard-coding it."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM share key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false,"description":"The frequency-wide screen-share key, held by every member. Seal a share ONCE under it: no carrying set, no home copy."}},"required":["keysetId","bootId","epoch","keys","shareKey"],"additionalProperties":false},"NowPlaying":{"type":"object","properties":{"text":{"description":"Free-text programme line, e.g. \"The Morning Show\".","type":["string","null"]},"trackTitle":{"type":["string","null"]},"trackArtist":{"type":["string","null"]},"updatedAt":{"description":"ISO timestamp of the last update, so a client can age out stale metadata.","type":["string","null"]},"since":{"description":"When the thing now playing began (ISO 8601): the airing a Cheer or Hush is counted against (capability `cheer-hush`). It moves only when the song or show itself changes. Null when there is nothing to cheer or hush right now.","type":["string","null"]}},"required":["text","trackTitle","trackArtist","updatedAt","since"],"additionalProperties":false},"OAuthAuthorizeError":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_request","invalid_client","invalid_grant","unauthorized_client","unsupported_grant_type","invalid_scope","signup_required","callsign_required","session_conflict"]},"error_description":{"type":"string"},"sessions":{"description":"Present on `session_conflict`: the sessions that would be closed. Resubmit with `signOutOtherSessions: true` to proceed.","type":"array","items":{"$ref":"#/components/schemas/ConflictingSession"}}},"required":["error"],"additionalProperties":false},"OAuthTokenError":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_request","invalid_client","invalid_grant","unauthorized_client","unsupported_grant_type","invalid_scope"]},"error_description":{"type":"string"}},"required":["error"],"additionalProperties":false},"OAuthTokenResponse":{"type":"object","properties":{"access_token":{"type":"string"},"token_type":{"type":"string","const":"Bearer"},"expires_in":{"type":"integer","description":"Access-token lifetime in seconds."},"refresh_token":{"description":"Absent on client_credentials grants. Single-use and rotating, same semantics as /auth/refresh.","type":"string"},"scope":{"type":"string","description":"Space-delimited scopes actually granted (may be narrower than requested)."},"installation_id":{"description":"Server-issued identity for this app install, minted on the authorization_code grant. Persist it: it names this session in GET /auth/sessions and keys media-node device slots.","type":"string"}},"required":["access_token","token_type","expires_in","scope"],"additionalProperties":false},"OffAirNotice":{"type":"object","properties":{"email":{"description":"The address notices go to. Null means none is set and nothing is sent.","type":["string","null"]},"updatedAt":{"description":"When the address was last set (ISO 8601). Null when none is set.","type":["string","null"]},"holdSeconds":{"type":"integer","description":"How long a station must have been off the air unexpectedly before the message is sent. A drop that recovers sooner sends nothing."},"minGapSeconds":{"type":"integer","description":"The least time between two off-the-air messages for one station. A station that keeps dropping gets one message, which says how many times."}},"required":["email","updatedAt","holdSeconds","minGapSeconds"],"additionalProperties":false},"Ok":{"type":"object","properties":{"ok":{"type":"boolean","const":true}},"required":["ok"],"additionalProperties":false},"OpaquePassthrough":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"OrgRole":{"type":"string","enum":["owner","manager","dj","screener"],"description":"A company role, highest first. screener (0.79.0) is station staff who is not on the air: it may use the channel controls on the company's stations but is not a broadcaster."},"OrganizationDetail":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":"The company's name. Not unique, and never a lookup key."},"stationCount":{"description":"Broadcast frequencies this company owns. Present on GET /orgs/mine, which is the only supported way to reach an existing company.","type":"integer"},"addressLine1":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"description":"ISO 3166-1 alpha-2.","type":["string","null"]},"phone":{"type":["string","null"]},"contactEmail":{"type":["string","null"]},"createdAt":{"type":"string"},"myRole":{"description":"The caller's role. Present on listings and on reads by a member.","$ref":"#/components/schemas/OrgRole"},"members":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationMember"},"description":"On-air staff. Present on a single-company read, absent from listings."},"stations":{"type":"array","items":{"$ref":"#/components/schemas/CompanyStation"},"description":"Broadcast frequencies this company owns. Staff are hired once at the company and reach every one of them."}},"required":["id","name","addressLine1","addressLine2","city","region","postalCode","country","phone","contactEmail","createdAt","members","stations"],"additionalProperties":false},"OrganizationMember":{"type":"object","properties":{"userId":{"type":"string"},"handle":{"description":"Null for listener-only accounts; render stationId.","type":["string","null"]},"stationId":{"type":"string"},"avatarColor":{"type":"string"},"avatar":{"description":"Chosen avatar id, or null. See UserSummarySchema.","type":["string","null"]},"role":{"$ref":"#/components/schemas/OrgRole"},"createdAt":{"type":"string"}},"required":["userId","handle","stationId","avatarColor","avatar","role","createdAt"],"additionalProperties":false},"Organization":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string","description":"The company's name. Not unique, and never a lookup key."},"stationCount":{"description":"Broadcast frequencies this company owns. Present on GET /orgs/mine, which is the only supported way to reach an existing company.","type":"integer"},"addressLine1":{"type":["string","null"]},"addressLine2":{"type":["string","null"]},"city":{"type":["string","null"]},"region":{"type":["string","null"]},"postalCode":{"type":["string","null"]},"country":{"description":"ISO 3166-1 alpha-2.","type":["string","null"]},"phone":{"type":["string","null"]},"contactEmail":{"type":["string","null"]},"createdAt":{"type":"string"},"myRole":{"description":"The caller's role. Present on listings and on reads by a member.","$ref":"#/components/schemas/OrgRole"}},"required":["id","name","addressLine1","addressLine2","city","region","postalCode","country","phone","contactEmail","createdAt"],"additionalProperties":false},"PhoneCarrier":{"type":"string","enum":["telnyx","fake"]},"PhoneNumber":{"type":"object","properties":{"id":{"type":"string"},"e164":{"type":"string","description":"The number in E.164 form, e.g. +18035550100."},"display":{"type":"string","description":"The number formatted for a person: (803) 555-0100."},"frequencyId":{"description":"Null once the frequency it lived on is gone.","type":["string","null"]},"channelId":{"description":"The `phone` channel carrying this line; null after release.","type":["string","null"]},"lineUserId":{"description":"The synthetic user the line appears as in rosters; null once released.","type":["string","null"]},"status":{"$ref":"#/components/schemas/PhoneNumberStatus"},"carrier":{"$ref":"#/components/schemas/PhoneCarrier"},"purchasedByUserId":{"type":["string","null"]},"createdAt":{"type":"string"},"activatedAt":{"type":["string","null"]},"releasedAt":{"type":["string","null"]}},"required":["id","e164","display","frequencyId","channelId","lineUserId","status","carrier","purchasedByUserId","createdAt","activatedAt","releasedAt"],"additionalProperties":false},"PhoneNumberStatus":{"type":"string","enum":["pending","active","releasing","released"]},"PlanChangePreview":{"type":"object","properties":{"targetPlanKey":{"type":"string","enum":["operator","broadcast"]},"dueNowCents":{"type":"integer","description":"First charge after credits, in cents (0 while a carried trial runs)."},"creditCents":{"type":"integer","description":"Credit applied: the fixed operator month (upgrade) or unused broadcast time (downgrade)."},"trialCarried":{"type":"boolean","description":"Whether the old subscription's remaining trial days carry to the new one."},"trialEnd":{"description":"ISO 8601 end of the carried trial, when trialCarried.","type":["string","null"]},"khzRequired":{"description":"Downgrade only: the frequency must move to a HAM-band number in the same change.","type":"boolean"}},"required":["targetPlanKey","dueNowCents","creditCents","trialCarried","trialEnd"],"additionalProperties":false},"PlanChangeResult":{"type":"object","properties":{"newSubscriptionId":{"type":"string","description":"Our id for the replacement subscription (as in /billing/status)."},"status":{"type":"string","description":"Stripe's status of the new subscription, verbatim."},"oldSubscriptionStatus":{"type":"string","description":"Stripe's status of the replaced subscription after cancellation."}},"required":["newSubscriptionId","status","oldSubscriptionStatus"],"additionalProperties":false},"PlanChangeStart":{"type":"object","properties":{"clientSecret":{"type":"string","description":"Confirm this with the Stripe Payment Element, then call the matching /complete."},"customerSessionClientSecret":{"description":"CustomerSession secret so the Payment Element can offer saved cards.","type":"string"},"organizationId":{"description":"Upgrade only: the station minted (or adopted) for the broadcast side.","type":"string"},"organizationMinted":{"description":"True when /start created the org — delete it via DELETE /orgs/:id on abandon.","type":"boolean"},"preview":{"$ref":"#/components/schemas/PlanChangePreview"}},"required":["clientSecret","preview"],"additionalProperties":false},"PlatformConfig":{"type":"object","properties":{"supportedCountries":{"type":"array","items":{"type":"string","minLength":2,"maxLength":2},"description":"ISO 3166-1 alpha-2 codes this deployment operates in. Company contacts outside this list are rejected."},"bands":{"description":"The authoritative dial description, keyed by frequency mode.","$ref":"#/components/schemas/BandPlans"}},"required":["supportedCountries","bands"],"additionalProperties":false},"PortalSession":{"type":"object","properties":{"url":{"type":"string","description":"Stripe-hosted Customer Portal URL. Open in the system browser, not in-app."}},"required":["url"],"additionalProperties":false},"PublicFrequency":{"type":"object","properties":{"id":{"type":"string"},"khz":{"type":"integer","description":"The canonical number. Integer kHz, globally unique across both modes."},"label":{"type":"string","description":"Mode-aware display label — broadcast reads as a dial position (\"97.9\"), push-to-talk keeps aviation notation (\"118.000\"). Render verbatim; do not re-derive it from khz."},"name":{"type":"string"},"description":{"type":["string","null"]},"ownerId":{"type":"string"},"publiclyListable":{"type":"boolean","description":"Whether non-members can discover this frequency. EFFECTIVE, not a raw setting: for broadcast it is derived (listed unless it has an access code — being on the dial IS the listing); for push-to-talk it is the owner's stored flag."},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"$ref":"#/components/schemas/FrequencyMode"},"hasAccessCode":{"type":"boolean","description":"Whether a first-time joiner must supply a code. The code itself is never exposed here."},"organizationId":{"description":"Always set for broadcast (org-only); null for personal push-to-talk.","type":["string","null"]},"station":{"description":"Embedded station identity when org-owned.","oneOf":[{"$ref":"#/components/schemas/FrequencyStation"},{"type":"null"}]},"suspension":{"description":"Set when the frequency is off the air by platform or billing action; null (the normal case) when it is live. Nothing is deleted by a suspension — keep rendering the station's identity, but refuse to tune it. Capability: frequency-suspension.","oneOf":[{"$ref":"#/components/schemas/FrequencySuspension"},{"type":"null"}]},"createdAt":{"type":"string"},"isMember":{"type":"boolean"}},"required":["id","khz","label","name","description","ownerId","publiclyListable","autoApprovePublicJoin","mode","hasAccessCode","organizationId","station","suspension","createdAt","isMember"],"additionalProperties":false},"PublicStationDirectory":{"type":"object","properties":{"stations":{"type":"array","items":{"$ref":"#/components/schemas/PublicStation"},"description":"Publicly-listable broadcast frequencies not off_air (live, dead_air, and interrupted stations all hold the dial), ordered by dial number. Pass ?includeOffline=true for the rest."}},"required":["stations"],"additionalProperties":false},"PublicStation":{"type":"object","properties":{"id":{"type":"string"},"khz":{"type":"integer","description":"The canonical number. Integer kHz, globally unique across both modes."},"label":{"type":"string","description":"Mode-aware display label — broadcast reads as a dial position (\"97.9\"), push-to-talk keeps aviation notation (\"118.000\"). Render verbatim; do not re-derive it from khz."},"name":{"type":"string"},"description":{"type":["string","null"]},"ownerId":{"type":"string"},"publiclyListable":{"type":"boolean","description":"Whether non-members can discover this frequency. EFFECTIVE, not a raw setting: for broadcast it is derived (listed unless it has an access code — being on the dial IS the listing); for push-to-talk it is the owner's stored flag."},"autoApprovePublicJoin":{"type":"boolean"},"mode":{"$ref":"#/components/schemas/FrequencyMode"},"hasAccessCode":{"type":"boolean","description":"Whether a first-time joiner must supply a code. The code itself is never exposed here."},"organizationId":{"description":"Always set for broadcast (org-only); null for personal push-to-talk.","type":["string","null"]},"station":{"description":"Embedded station identity when org-owned.","oneOf":[{"$ref":"#/components/schemas/FrequencyStation"},{"type":"null"}]},"suspension":{"description":"Set when the frequency is off the air by platform or billing action; null (the normal case) when it is live. Nothing is deleted by a suspension — keep rendering the station's identity, but refuse to tune it. Capability: frequency-suspension.","oneOf":[{"$ref":"#/components/schemas/FrequencySuspension"},{"type":"null"}]},"createdAt":{"type":"string"},"liveState":{"$ref":"#/components/schemas/StationLiveStage"},"listenerCount":{"type":"integer","description":"WebRTC room listeners only — HLS listeners never join the room and are not counted.","minimum":0}},"required":["id","khz","label","name","description","ownerId","publiclyListable","autoApprovePublicJoin","mode","hasAccessCode","organizationId","station","suspension","createdAt","liveState","listenerCount"],"additionalProperties":false},"PublicStationStats":{"type":"object","properties":{"stations":{"type":"array","items":{"$ref":"#/components/schemas/StationLiveState"},"description":"Publicly-listed broadcast frequencies, ordered by dial number — the same listing rule /public/stations applies, without its extra not-off_air filter. Code-gated stations are NOT here at any stage (0.24.0); a member reads their own from /stations/stats/mine."}},"required":["stations"],"additionalProperties":false},"ReceivedInvitation":{"type":"object","properties":{"id":{"type":"string"},"frequencyId":{"type":"string"},"frequencyName":{"type":"string"},"frequencyKhz":{"type":"integer"},"frequencyLabel":{"type":"string","description":"Mode-aware display label. Render verbatim."},"invitedBy":{"type":"object","properties":{"handle":{"type":["string","null"]},"stationId":{"type":"string"}},"required":["handle","stationId"],"additionalProperties":false,"description":"The inviter's on-air identity. No real name — see UserSummarySchema."},"createdAt":{"type":"string"}},"required":["id","frequencyId","frequencyName","frequencyKhz","frequencyLabel","invitedBy","createdAt"],"additionalProperties":false},"Scope":{"type":"string","enum":["stations:read","stream:listen","channels:talk","frequencies:manage","org:manage","billing:manage","profile","internal:media-node"]},"SetupIntentCreated":{"type":"object","properties":{"clientSecret":{"description":"Confirm this with the Stripe Payment Element, then call /billing/subscribe.","type":["string","null"]},"customerSessionClientSecret":{"description":"Stripe CustomerSession secret so the Payment Element can offer this customer's saved cards.","type":["string","null"]}},"required":["clientSecret","customerSessionClientSecret"],"additionalProperties":false},"ShareKeyResult":{"type":"object","properties":{"keysetId":{"type":"string","description":"Identity of this key material; matches the keysetId in the notice that prompted the fetch."},"bootId":{"type":"string","description":"The share container's generation these keys are bound to. A mismatch against your share session's generation means re-tune, not install."},"epoch":{"type":"integer","minimum":0,"maximum":255,"description":"Current share key epoch (uint8, wraps mod 256)."},"shareKey":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The share key's uint32 wire id: the packet header's channelId on every share packet. Take it from here rather than hard-coding it."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM share key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false,"description":"The frequency-wide screen-share key, held by every member. Seal a share ONCE under it: no carrying set, no home copy."}},"required":["keysetId","bootId","epoch","shareKey"],"additionalProperties":false},"SoundAsset":{"type":"object","properties":{"id":{"type":"string","description":"What a contact's `ringTone` / `joinSound` / `leaveSound`, or an account default, stores."},"label":{"type":"string","description":"What the picker says, e.g. \"Marimba\"."},"category":{"$ref":"#/components/schemas/SoundCategory"},"sortOrder":{"type":"integer","description":"Ascending within a category."},"version":{"type":"integer","description":"Audio cache key. Pass as ?v= for an immutable response."},"isDefault":{"type":"boolean","description":"True for the one sound that plays when a ring has never been chosen. **Served rather than hard-coded**: a client that assumed a name is wrong the day the set changes, and a client that offers its own extra \"Standard\" entry beside the named one puts the same sound on screen twice under two names. ⚠️ Exactly one member of EACH of the ten categories carries it, so an unfiltered `find(s => s.isDefault)` returns whichever category sorts first - a footstep where a ring belongs. Filter by category. It was true for exactly one row in the whole catalog until 2026-09-20 and was therefore right by accident."}},"required":["id","label","category","sortOrder","version","isDefault"],"additionalProperties":false},"SoundCatalog":{"type":"object","properties":{"sounds":{"type":"array","items":{"$ref":"#/components/schemas/SoundAsset"}}},"required":["sounds"],"additionalProperties":false},"SoundCategory":{"type":"string","enum":["ring","callwaiting","busy","voicemail","message","sent","wave","logon","logoff","join","leave","startup","greeting"]},"SoundChoice":{"type":["string","null"],"minLength":1,"maxLength":64,"pattern":"^[a-z0-9][a-z0-9-]*$"},"SoundVolume":{"type":"integer","minimum":0,"maximum":100},"SponsoredAgentCreated":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserSummary"},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"key":{"$ref":"#/components/schemas/AgentKeySummary"},"secret":{"type":"string","description":"The key itself, prefixed `ftag_`. Shown exactly ONCE: the server keeps only a hash. Store it outside any repository and never log it."}},"required":["user","createdAt","key","secret"],"additionalProperties":false},"SponsoredAgentList":{"type":"object","properties":{"agents":{"type":"array","items":{"$ref":"#/components/schemas/SponsoredAgent"}},"max":{"type":"integer","description":"How many agents one person may sponsor."}},"required":["agents","max"],"additionalProperties":false},"SponsoredAgent":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserSummary"},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"keys":{"type":"array","items":{"$ref":"#/components/schemas/AgentKeySummary"},"description":"Every key the agent has held, newest first; the secret is in none of them."}},"required":["user","createdAt","keys"],"additionalProperties":false},"StationDetail":{"type":"object","properties":{"id":{"type":"string","description":"The broadcast frequency's id. Was the organization's id before 2026-08-02."},"name":{"type":"string","description":"Station name — the frequency's name. Globally unique across stations."},"stationCallsign":{"type":"string","description":"On-air callsign. Globally unique."},"brandColor":{"description":"Hex, #rrggbb. May be illegible on a dark UI — clients should adjust luminance rather than use it raw.","type":["string","null"]},"tagline":{"type":["string","null"]},"logoVersion":{"type":"integer","description":"Increments on every logo change; use it to bust caches of GET /frequencies/{id}/logo."},"hasLogo":{"type":"boolean","description":"Whether GET /frequencies/{id}/logo will return an image."},"format":{"oneOf":[{"$ref":"#/components/schemas/StationFormat"},{"type":"null"}]},"genre":{"type":["string","null"]},"nowPlaying":{"description":"Null when the station has published nothing — branch on presence, not on four nulls.","oneOf":[{"$ref":"#/components/schemas/NowPlaying"},{"type":"null"}]},"websiteUrl":{"type":["string","null"]},"links":{"type":["array","null"],"items":{"$ref":"#/components/schemas/StationLink"}},"organizationId":{"type":"string","description":"The company that owns this station."},"myRole":{"type":"string","enum":["owner","manager","dj","screener"],"description":"The caller's role at that company. Editing needs manager or above."}},"required":["id","name","stationCallsign","brandColor","tagline","logoVersion","hasLogo","format","genre","nowPlaying","websiteUrl","links","organizationId","myRole"],"additionalProperties":false},"StationFormat":{"type":"string","enum":["talk","music","mixed"]},"StationLink":{"type":"object","properties":{"label":{"type":"string"},"url":{"type":"string"}},"required":["label","url"],"additionalProperties":false},"StationLiveStage":{"type":"string","enum":["live","dead_air","interrupted","off_air"],"description":"\"live\" = armed and audible; \"dead_air\" = armed but silent (hard mute or genuine silence) — holds indefinitely, keep the station on the dial; \"interrupted\" = involuntary drop while armed, listed for a short grace with the stream down; \"off_air\" = not armed."},"StationLiveState":{"type":"object","properties":{"frequencyId":{"type":"string"},"khz":{"type":"integer"},"liveState":{"$ref":"#/components/schemas/StationLiveStage"},"listenerCount":{"type":"integer","description":"WebRTC room listeners only — HLS listeners never join the room and are not counted.","minimum":0}},"required":["frequencyId","khz","liveState","listenerCount"],"additionalProperties":false},"StationPopular":{"type":"object","properties":{"from":{"type":"string","description":"Start of the period (ISO 8601, inclusive)."},"to":{"type":"string","description":"End of the period (ISO 8601, exclusive)."},"items":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"The service's own identity for this song or show. Opaque: compare it, never build it."},"kind":{"$ref":"#/components/schemas/AiringKind"},"title":{"type":"string","description":"As most recently published."},"artist":{"type":["string","null"]},"plays":{"type":"integer","description":"How many airings it had in the period. Two cheers in forty plays and two in two are opposite facts."},"cheers":{"type":"integer","description":"Cheers across those airings. One listener counts once per airing."},"hushes":{"type":"integer"},"lastPlayedAt":{"type":"string"}},"required":["key","kind","title","artist","plays","cheers","hushes","lastPlayedAt"],"additionalProperties":false}}},"required":["from","to","items"],"additionalProperties":false},"StationReaction":{"type":"string","enum":["cheer","hush"]},"StationReactionTally":{"type":"object","properties":{"airing":{"type":["object","null"],"properties":{"since":{"type":"string","description":"When this airing began: `nowPlaying.since`."},"kind":{"$ref":"#/components/schemas/AiringKind"},"title":{"type":"string","description":"The song's title, or the show's line of text."},"artist":{"description":"The song's artist. Null for a show, and for a song published without one.","type":["string","null"]}},"required":["since","kind","title","artist"],"additionalProperties":false},"cheers":{"type":"integer","description":"How many listeners are cheering this airing. People, not presses: one listener counts once."},"hushes":{"type":"integer","description":"How many listeners are hushing this airing."}},"required":["airing","cheers","hushes"],"additionalProperties":false},"StreamContribution":{"type":"object","properties":{"transport":{"type":"string","enum":["webrtc","native"],"description":"Which datapath the program audio arrived over."},"encoding":{"description":"Null when a native sender declared nothing — that transport is codec-opaque by design, so the server says \"not stated\" rather than guessing. WebRTC senders always negotiate, so this is always present for them.","oneOf":[{"$ref":"#/components/schemas/StreamEncoding"},{"type":"null"}]}},"required":["transport","encoding"],"additionalProperties":false},"StreamDelivery":{"type":"object","properties":{"codec":{"type":"string"},"bitrateKbps":{"type":"number","description":"The ENCODER setting. A player reading BANDWIDTH off the master playlist sees ~10-15% more, because that is measured on the muxed segments and includes MPEG-TS overhead. Different quantities, not a disagreement."},"sampleRate":{"type":"number"},"channels":{"type":"number"},"segmentSeconds":{"type":"number"},"windowSeconds":{"type":"number","description":"Audio held in the rolling playlist — segment length x window size."}},"required":["codec","bitrateKbps","sampleRate","channels","segmentSeconds","windowSeconds"],"additionalProperties":false},"StreamEncoding":{"type":"object","properties":{"codec":{"type":"string","description":"Lowercase encoder codec name, e.g. \"opus\"."},"bitrateKbps":{"description":"Target/ceiling bitrate the sender encoded at; null when it declared none.","type":["number","null"]},"sampleRate":{"type":"number"},"channels":{"type":"number","description":"1 = mono (voice), 2 = stereo (program audio)."},"fec":{"type":"boolean","description":"In-band forward error correction."},"dtx":{"type":"boolean","description":"Discontinuous transmission. Off for program audio — it punches holes in music."},"frameMs":{"description":"Encoder frame size where the sender states it; null on the WebRTC path, which does not negotiate it.","type":["number","null"]}},"required":["codec","bitrateKbps","sampleRate","channels","fec","dtx","frameMs"],"additionalProperties":false},"StreamInfo":{"type":"object","properties":{"live":{"type":"boolean","description":"Program audio is flowing right now — the same question as the directory's `liveState === \"live\"`. Independent of `contribution`: a station can be live while the platform cannot describe its encoding. Never infer one from the other."},"contribution":{"description":"What reached the platform. Null when nothing is on the air, or when the media node is too old to report it.","oneOf":[{"$ref":"#/components/schemas/StreamContribution"},{"type":"null"}]},"delivery":{"description":"What the HLS listen tier serves. Deployment config — the same for every station here.","$ref":"#/components/schemas/StreamDelivery"},"bottleneck":{"type":["string","null"],"enum":["contribution","delivery",null],"description":"Which leg constrains the result, or null when nothing is on the air. Deliberately not a percentage: lossy-to-lossy chains do not compose linearly, so any single fidelity score would be fiction."},"streamHealth":{"description":"Capability `air-monitor`. The same fact the listen-ticket mint carries, readable here without minting a ticket: `live` = on air and servable; `stalled` = transmitting but the encoder cannot serve, so every listener is getting OFF_AIR while the broadcaster's own meters are green. ABSENT means no signal (off the air, an old node): never read absence as healthy. This one field changes while a station is on the air, so a TRANSMITTING client may poll the route for it, no faster than once every 15 seconds.","type":"string","enum":["live","stalled"]}},"required":["live","contribution","delivery","bottleneck"],"additionalProperties":false},"SubscribeResult":{"type":"object","properties":{"status":{"type":"string","description":"Stripe's subscription status, verbatim."},"subscriptionActive":{"type":"boolean","description":"Whether the subscription entitles frequency creation right now. Branch on this, not on status."}},"required":["status","subscriptionActive"],"additionalProperties":false},"SubscriptionMutation":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","description":"Stripe's subscription status, verbatim."},"cancelAtPeriodEnd":{"type":"boolean"},"currentPeriodEnd":{"type":["string","null"]}},"required":["id","status","cancelAtPeriodEnd","currentPeriodEnd"],"additionalProperties":false},"SuggestedNumber":{"type":"object","properties":{"khz":{"type":"integer","description":"Suggested integer kHz — valid for the requested mode and unused at suggestion time. NOT reserved."},"label":{"type":"string","description":"Display form of the number, e.g. \"121.500\"."}},"required":["khz","label"],"additionalProperties":false},"SuspensionClass":{"type":"string","enum":["non_payment","voluntary","conduct","legal"],"description":"Why the frequency is off the air. Ours, not Stripe's, so unlike Subscription.status this is a closed set. The class is what decides who may lift the suspension — read `reinstatable` rather than encoding that policy client-side. `voluntary` means the customer cancelled; if they cancelled during the free trial the station is also deleted when that trial would have ended, which is the one class that is not indefinite."},"TalkPermission":{"type":"string","enum":["open","managed","broadcast_open"]},"TokenPair":{"type":"object","properties":{"accessToken":{"type":"string","description":"Short-lived bearer token. Send as `Authorization: Bearer <token>`."},"refreshToken":{"type":"string","description":"Single-use. Rotated on every refresh: the old value stops working as soon as a new pair is issued, so persist the new one before using it."}},"required":["accessToken","refreshToken"],"additionalProperties":false},"TopupReceipt":{"type":"object","properties":{"balanceCents":{"type":"integer","description":"The balance after this top-up."},"creditedCents":{"type":"integer"},"replayed":{"type":"boolean","description":"True when this receipt had already been credited and nothing moved."},"receipt":{"type":"object","properties":{"method":{"type":"string"},"reference":{"type":"string","description":"The card PaymentIntent id or the chain transaction hash."}},"required":["method","reference"],"additionalProperties":false},"entryId":{"type":"string","description":"The ledger row."}},"required":["balanceCents","creditedCents","replayed","receipt","entryId"],"additionalProperties":false},"TransferConfig":{"type":"object","properties":{"limits":{"$ref":"#/components/schemas/ChatTransferLimits"},"maxRetries":{"type":"integer","description":"How many times a recipient may retry one failed transfer before `RETRY_LIMIT` (capability `transfer-retry`)."},"iceServers":{"type":"array","items":{"type":"object","properties":{"urls":{"type":"string"},"username":{"type":"string"},"credential":{"type":"string"}},"required":["urls"],"additionalProperties":false},"description":"TURN relay for the data channel. Use iceTransportPolicy \"relay\": the server strips every non-relay candidate anyway."}},"required":["limits","maxRetries","iceServers"],"additionalProperties":false},"TransferRecord":{"type":"object","properties":{"offerId":{"type":"string"},"fromUserId":{"type":"string"},"toUserId":{"type":"string"},"name":{"type":"string"},"size":{"type":"integer"},"mime":{"type":"string"},"voice":{"type":"boolean"},"state":{"$ref":"#/components/schemas/TransferState"},"attempt":{"type":"integer","description":"Which connection attempt this is: 1 from the offer, one more after each `retry`. Signalling and `fail` name it, and anything naming an older attempt is dropped."},"groupId":{"description":"The group chat this transfer was offered in (one per recipient of a group offer), or null for a 1:1.","type":["string","null"]},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"expiresAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"}},"required":["offerId","fromUserId","toUserId","name","size","mime","voice","state","attempt","groupId","createdAt","expiresAt"],"additionalProperties":false},"TransferState":{"type":"string","enum":["offered","accepted","declined","cancelled","complete","failed","expired"]},"TuneResult":{"type":"object","properties":{"token":{"type":"string","description":"Short-lived media-join token. Present it to the signaling endpoint below."},"mediaNodeAddress":{"type":"string","description":"A PATH, not a URL. The media node is not directly reachable; join it against this control plane's own origin, which proxies the signaling traffic."},"mediaNodeStatus":{"$ref":"#/components/schemas/MediaNodeStatus"},"iceServers":{"type":"array","items":{"$ref":"#/components/schemas/IceServer"}},"expiresAt":{"type":"string","description":"ISO timestamp after which `token` is refused."},"nativeTransport":{"type":"object","properties":{"udpEndpoint":{"type":"string","description":"host:port the native datapath dials directly (rtcMinPort+1 — not proxied, unlike signaling)."},"bootId":{"type":"string","description":"The node generation these keys are bound to; must match authOk.native.bootId or re-tune."},"channelKeys":{"type":"object","properties":{"keysetId":{"type":"string","description":"Identity of THIS key material, new on every rotation and every mint. Compare this — not the epoch — to decide whether a `nativeKeysRotated` notice concerns keys you already hold: a control-plane restart re-mints at epoch 0 with different bytes, so the epoch number can collide while the material differs."},"epoch":{"type":"integer","minimum":0,"maximum":255,"description":"Current key epoch (uint8, wraps mod 256). Receivers keep their own previous epoch for the tolerance window."},"keys":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The channel's uint32 id as it appears in the packet header's channelId field."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM channel key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false},"description":"Channel UUID -> { id, key }."},"shareKey":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The share key's uint32 wire id: the packet header's channelId on every share packet. Take it from here rather than hard-coding it."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM share key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false,"description":"The frequency-wide screen-share key, held by every member. Seal a share ONCE under it: no carrying set, no home copy."}},"required":["keysetId","epoch","keys","shareKey"],"additionalProperties":false}},"required":["udpEndpoint","bootId","channelKeys"],"additionalProperties":false},"share":{"$ref":"#/components/schemas/TuneShare"}},"required":["token","mediaNodeAddress","mediaNodeStatus","iceServers","expiresAt"],"additionalProperties":false},"TuneShare":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/MediaNodeStatus"},"connect":{"type":"object","properties":{"signalPath":{"type":"string","description":"A PATH on this control plane's origin, as mediaNodeAddress is: the share container's proxied signaling socket. Send `auth` with `token` below and `nativeTransport: true`."},"token":{"type":"string","description":"A media-join token for the SHARE container. It opens only that container: the voice container refuses it, and the share container refuses the voice token."},"udpEndpoint":{"type":"string","description":"host:port the native datapath dials for shares, on the share container's host. Not the voice udpEndpoint."},"bootId":{"type":"string","description":"The share container's generation. Must match that container's authOk.native.bootId, or re-tune."},"keysetId":{"type":"string","description":"Identity of the share key below; compare it, not the epoch, against a `nativeKeysRotated` notice from the share container."},"epoch":{"type":"integer","minimum":0,"maximum":255,"description":"The share key's epoch (uint8, wraps mod 256)."},"shareKey":{"type":"object","properties":{"id":{"type":"integer","exclusiveMinimum":0,"description":"The share key's uint32 wire id: the packet header's channelId on every share packet. Take it from here rather than hard-coding it."},"key":{"type":"string","description":"32-byte base64 AES-256-GCM share key, CURRENT epoch only."}},"required":["id","key"],"additionalProperties":false,"description":"The frequency-wide screen-share key, held by every member. Seal a share ONCE under it: no carrying set, no home copy."}},"required":["signalPath","token","udpEndpoint","bootId","keysetId","epoch","shareKey"],"additionalProperties":false}},"required":["status"],"additionalProperties":false,"description":"The frequency's share container. Absent when it has none, in which case shares (if any) run in the voice container under nativeTransport.channelKeys.shareKey."},"TunedCounts":{"type":"object","properties":{"connected":{"type":"integer","description":"Devices currently declaring themselves tuned to this frequency. Soft state with a ~90s horizon."},"talking":{"type":"integer","description":"Of those, devices with a non-empty talking set — could talk right now, not necessarily making sound."}},"required":["connected","talking"],"additionalProperties":false},"TunedRosterMember":{"type":"object","properties":{"userId":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"deviceId":{"type":"string","description":"Server-issued installation id. Two devices of one account are two rows; a client finds its own row by its access token's installation_id claim."},"callsign":{"type":"string","description":"The person's own callsign, always — station-callsign substitution is a per-producer media-socket fact and never appears here."},"stationId":{"type":"string","description":"The person's Station ID (FT-XXXX), the stable identifier a callsign is not. Send THIS to `POST /contacts/requests` to add somebody from a roster row: a callsign can be ambiguous, a Station ID never is. Same value UserSummary carries."},"avatar":{"description":"Chosen avatar id, exactly as UserSummary carries it (null = not set). Capability `user-avatar`.","type":["string","null"]},"avatarColor":{"type":"string","description":"Hex colour, #rrggbb: the placeholder behind a null or unrecognised avatar."},"acceptsMessages":{"type":"boolean","description":"Whether this person accepts direct messages from non-contacts (their `acceptMessagesFromNonContacts`, capability `chat-non-contacts`). Offer a Message action on this row only when it is true or they are already a contact. Discloses a preference, never presence."},"listeningChannelIds":{"type":"array","items":{"type":"string"}},"talkingChannelIds":{"type":"array","items":{"type":"string"},"description":"Intent, not activity — channels this device could talk on right now (routing contract's roster rule), as declared by the client."}},"required":["userId","kind","deviceId","callsign","stationId","avatar","avatarColor","acceptsMessages","listeningChannelIds","talkingChannelIds"],"additionalProperties":false},"TuningState":{"type":"object","properties":{"frequencyId":{"type":"string"},"active":{"type":"boolean","description":"Whether the user currently has this frequency tuned in."},"selectedChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels the user is listening to."},"selectedTransmitChannelIds":{"type":"array","items":{"type":"string"},"description":"Channels the user's transmit toggle addresses."}},"required":["frequencyId","active","selectedChannelIds","selectedTransmitChannelIds"],"additionalProperties":false},"UserKind":{"type":"string","enum":["person","line","agent"]},"UserProfile":{"type":"object","properties":{"id":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Callsign. NOT unique — stationId is the stable identifier. Null for listener-only accounts (email-only signup); render stationId instead.","type":["string","null"]},"stationId":{"type":"string","description":"Globally unique, human-quotable id (FT-XXXX). The fallback label wherever handle is null."},"avatarColor":{"type":"string","description":"Hex colour, #rrggbb."},"avatar":{"description":"Chosen avatar id from GET /avatars, or null for \"not set\" — a normal state, not an error. Resolve it against the catalog for the image URL; draw the avatarColor placeholder for null OR for an id you do not recognise. Capability `user-avatar`.","type":["string","null"]},"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$","description":"Only ever returned for the authenticated user themselves."},"displayName":{"type":"string","description":"The account holder's name, used for BILLING RECORDS ONLY — it is what Stripe puts on the invoice, the receipt and the card statement. Never shown to other users, who see handle + stationId. Only ever returned for the authenticated user themselves."},"createdAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"notificationDefaults":{"$ref":"#/components/schemas/AccountNotificationDefaults"},"maySendFeedback":{"type":"boolean"},"sponsorUserId":{"type":["string","null"]}},"required":["id","kind","handle","stationId","avatarColor","avatar","email","displayName","createdAt","notificationDefaults","maySendFeedback","sponsorUserId"],"additionalProperties":false},"UserSummary":{"type":"object","properties":{"id":{"type":"string"},"kind":{"description":"person: a human. agent: an automated account acting under an operator (capability `agents`); draw a badge beside its callsign and never hide it. line: the synthetic user behind a phone number.","$ref":"#/components/schemas/UserKind"},"handle":{"description":"Callsign. NOT unique — stationId is the stable identifier. Null for listener-only accounts (email-only signup); render stationId instead.","type":["string","null"]},"stationId":{"type":"string","description":"Globally unique, human-quotable id (FT-XXXX). The fallback label wherever handle is null."},"avatarColor":{"type":"string","description":"Hex colour, #rrggbb."},"avatar":{"description":"Chosen avatar id from GET /avatars, or null for \"not set\" — a normal state, not an error. Resolve it against the catalog for the image URL; draw the avatarColor placeholder for null OR for an id you do not recognise. Capability `user-avatar`.","type":["string","null"]}},"required":["id","kind","handle","stationId","avatarColor","avatar"],"additionalProperties":false},"UserSession":{"type":"object","properties":{"id":{"type":"string","description":"Refresh-token family id. Pass to DELETE /auth/sessions/{id} to revoke."},"clientId":{"type":"string","description":"OAuth client, e.g. \"frequency-web\", \"tuner-windows\"."},"clientName":{"type":"string","description":"Human-readable app name for display."},"scope":{"type":"string","description":"Space-delimited scopes this session's tokens carry."},"createdAt":{"type":"string","description":"When this install first signed in."},"lastRefreshedAt":{"description":"Last successful token rotation; null if never rotated.","type":["string","null"]},"current":{"type":"boolean","description":"True for the session that made this request."}},"required":["id","clientId","clientName","scope","createdAt","lastRefreshedAt","current"],"additionalProperties":false},"UserStatus":{"type":"string","enum":["online","away","busy","invisible"]},"VisibleStatus":{"type":"string","enum":["online","away","busy","offline"]},"WebAuthnCredentialList":{"type":"object","properties":{"credentials":{"type":"array","items":{"$ref":"#/components/schemas/WebAuthnCredentialSummary"}}},"required":["credentials"],"additionalProperties":false},"WebAuthnCredentialSummary":{"type":"object","properties":{"id":{"type":"string"},"nickname":{"type":["string","null"]},"deviceType":{"type":"string","description":"`\"singleDevice\"` or `\"multiDevice\"`, as reported at enrollment."},"backedUp":{"type":"boolean","description":"Whether the authenticator reported the credential as synced/backed up."},"createdAt":{"type":"string"},"lastUsedAt":{"description":"Last successful passkey login with this credential; null if never used.","type":["string","null"]}},"required":["id","nickname","deviceType","backedUp","createdAt","lastUsedAt"],"additionalProperties":false},"WebAuthnDiscoverableOptions":{"type":"object","properties":{"ceremonyId":{"type":"string"},"options":{"$ref":"#/components/schemas/OpaquePassthrough"}},"required":["ceremonyId","options"],"additionalProperties":false},"WebAuthnLoginOptions":{"type":"object","properties":{"userId":{"type":"string"},"options":{"$ref":"#/components/schemas/OpaquePassthrough"}},"required":["userId","options"],"additionalProperties":false},"WebAuthnVerified":{"type":"object","properties":{"verified":{"type":"boolean"}},"required":["verified"],"additionalProperties":false}}},"paths":{"/v1/auth/request-code":{"post":{"summary":"Email a six-digit login code","tags":["auth"],"description":"Rate limited per email address. Always start here; the code is then exchanged at `/auth/verify-code`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestCodeBodyInput"}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`EMAIL_DELIVERY_FAILED` — The login-code email could not be handed to the mail provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/verify-code":{"post":{"summary":"Exchange a login code for tokens","tags":["auth"],"description":"On a first-ever login for this email, a TALK app gets `422 SIGNUP_REQUIRED`; resubmit the SAME code with `handle` and `displayName`. A listen-only app (capability `email-only-listeners`) creates the account from the email alone — no handle, displayName defaulting to the Station ID. A talk app signing in to such an account gets `422 CALLSIGN_REQUIRED`; resubmit the SAME code with `handle` to set one. No answerable rejection consumes the code, so every retry succeeds. An account may hold up to five live sessions per app (capability `session-seats`); at the limit, signing in here closes the least recently used one automatically. A broadcast operator with a session ON THE AIR is asked first instead (since 0.82.0; off the air they are treated like everyone else): they get `409 SESSION_CONFLICT` describing the session that would close (including whether it is transmitting), and resubmit the SAME code with `signOutOtherSessions: true` to take over. Declining means simply not retrying: nothing is issued and nothing is closed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCodeBodyInput"}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthSession"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`INVALID_CODE` — The emailed six-digit login code is wrong, expired, or out of attempts.\n\n`INVALID_CLIENT` — Unknown, disabled, or wrongly-authenticated OAuth client_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`SESSION_CONFLICT` — This account is already signed in to another install of the same app, and the caller runs a broadcast station, so the older session is NOT closed automatically — interrupting a live show has to be a decision. `details.sessions` describes the other session(s), including whether one is transmitting right now. Resubmit the SAME login code with `signOutOtherSessions: true` to take over, or abandon the login. Like SIGNUP_REQUIRED, the code is not consumed by this rejection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`SIGNUP_REQUIRED` — First-ever login for this email: resubmit verify-code with `handle`. `displayName` is OPTIONAL and defaults to the account's Station ID — it was required until 2026-08-25, so a client that still sends both is correct and unchanged. Not a failure — it is the second step of signup, and the login code stays valid for the retry. Only talk apps see this (capability `email-only-listeners`): a listen-only app's first login creates the account from the email alone.\n\n`CALLSIGN_REQUIRED` — This app requires a callsign and the account has none (it was created by a listen-only app from an email alone). From `/auth/verify-code`: resubmit the SAME login code with `handle` (and optionally `displayName`) to set it — like SIGNUP_REQUIRED, the code is not consumed by this rejection. From `/auth/webauthn/login-verify` (capability `passkey-callsign-gate`) the retry needs a FRESH assertion instead: a login code survives an answerable rejection, a WebAuthn challenge does not, so run the ceremony again with `handle` in the body. Also sent as 403 by transmit gates when a callsign-less token attempts to arm transmit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/refresh":{"post":{"summary":"Rotate a refresh token for a new token pair","tags":["auth"],"description":"Single-use and rotating: the presented token is invalidated. Persist the returned pair BEFORE using it — a crash between the response and the write is recoverable only inside the short reuse-grace window. `deviceId` must match the one the token was issued to.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenBodyInput"}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenPair"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`INVALID_REFRESH` — The refresh token is unknown, already used, or revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/logout":{"post":{"summary":"Revoke a refresh token","tags":["auth"],"description":"Idempotent, and deliberately returns `ok` even for an unknown token so it cannot be used to probe which tokens exist.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenBodyInput"}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/register-options":{"post":{"summary":"Begin passkey registration","tags":["auth"],"description":"Returns WebAuthn `PublicKeyCredentialCreationOptions` to hand to `navigator.credentials.create()`.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpaquePassthrough"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/register-verify":{"post":{"summary":"Complete passkey registration","tags":["auth"],"description":"Body is the `RegistrationResponseJSON` produced by the browser.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpaquePassthroughInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnVerified"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.\n\n`WEBAUTHN_FAILED` — WebAuthn assertion or attestation could not be verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/add-existing-options":{"post":{"summary":"Begin adding a passkey this device already holds to this app","tags":["auth"],"description":"Returns `PublicKeyCredentialRequestOptions` listing the account's passkeys that are not yet this app's (capability `passkey-add-existing`). `404 NOT_FOUND` when every passkey on the account is already listed here.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpaquePassthrough"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/add-existing-verify":{"post":{"summary":"Complete adding a passkey this device already holds to this app","tags":["auth"],"description":"Body is the `AuthenticationResponseJSON` for the options above. On success the passkey is listed in this app.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpaquePassthroughInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnVerified"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.\n\n`WEBAUTHN_FAILED` — WebAuthn assertion or attestation could not be verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/login-options":{"post":{"summary":"Begin passkey login","tags":["auth"],"description":"Always answers 200 for a syntactically valid email — a response deliberately does NOT confirm an account exists or holds passkeys. When it can't complete (no such account, or no passkey enrolled), the browser's `navigator.credentials.get()` simply finds no matching credential; fall back to the emailed-code flow.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["email"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnLoginOptions"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/discoverable-options":{"post":{"summary":"Begin a discoverable (email-less) passkey login","tags":["auth"],"description":"Mints a ceremony keyed to ITSELF, for the ambient passkey offer: Apple's `performAutoFillAssistedRequests()` and a browser's `mediation: \"conditional\"` both have to start before anybody has typed an address, so they cannot use the email-keyed ceremony. `options.allowCredentials` is empty, so the authenticator offers whatever passkeys it holds for this relying party. Takes no input and identifies nobody: spend `ceremonyId` on `POST /auth/webauthn/login-verify` in place of `userId` (the suite sign-in standard; capability `passkey-discoverable-login-verify`), or on `POST /oauth/token` with the webauthn grant in place of `user_id` (being retired for suite apps). The account is resolved from the credential the authenticator returns.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnDiscoverableOptions"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/login-verify":{"post":{"summary":"Complete passkey login","tags":["auth"],"description":"On success returns the same token pair as `/auth/verify-code`. Identify the ceremony with EXACTLY ONE of `userId` (the email-keyed ceremony from `/auth/webauthn/login-options`) or `ceremonyId` (the discoverable, email-less one from `/auth/webauthn/discoverable-options`, whose account is resolved from the credential the authenticator chose; capability `passkey-discoverable-login-verify`). Both or neither is `400 VALIDATION_ERROR`; every verification failure on either path is the same `401 WEBAUTHN_FAILED`. A talk app signing in to an account that holds no callsign gets `422 CALLSIGN_REQUIRED` — the same gate `/auth/verify-code` applies — and must retry with `handle`. Unlike that route, the retry needs a FRESH assertion: a login code survives an answerable rejection, a WebAuthn challenge does not. A broadcast operator at their app's session limit gets `409 SESSION_CONFLICT` carrying `details.sessions`; answering yes is ANOTHER ceremony sent with `signOutOtherSessions: true`, for the same reason.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"description":"The `userId` that `/auth/webauthn/login-options` returned. Exactly one of `userId` or `ceremonyId`.","type":"string"},"ceremonyId":{"description":"DISCOVERABLE flavour: the `ceremonyId` that `/auth/webauthn/discoverable-options` returned. The account is resolved from the credential the authenticator chose, so no user is named. Exactly one of `userId` or `ceremonyId`.","type":"string","minLength":1},"deviceId":{"type":"string","minLength":1},"clientId":{"description":"Registered app id; defaults to \"frequency-web\".","type":"string","minLength":1,"maxLength":64},"response":{"$ref":"#/components/schemas/OpaquePassthroughInput"},"handle":{"$ref":"#/components/schemas/HandleInput"},"displayName":{"$ref":"#/components/schemas/PublicDisplayNameInput"},"signOutOtherSessions":{"type":"boolean"}},"required":["deviceId","response"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthSession"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`WEBAUTHN_FAILED` — WebAuthn assertion or attestation could not be verified.\n\n`INVALID_CLIENT` — Unknown, disabled, or wrongly-authenticated OAuth client_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`SESSION_CONFLICT` — This account is already signed in to another install of the same app, and the caller runs a broadcast station, so the older session is NOT closed automatically — interrupting a live show has to be a decision. `details.sessions` describes the other session(s), including whether one is transmitting right now. Resubmit the SAME login code with `signOutOtherSessions: true` to take over, or abandon the login. Like SIGNUP_REQUIRED, the code is not consumed by this rejection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CALLSIGN_REQUIRED` — This app requires a callsign and the account has none (it was created by a listen-only app from an email alone). From `/auth/verify-code`: resubmit the SAME login code with `handle` (and optionally `displayName`) to set it — like SIGNUP_REQUIRED, the code is not consumed by this rejection. From `/auth/webauthn/login-verify` (capability `passkey-callsign-gate`) the retry needs a FRESH assertion instead: a login code survives an answerable rejection, a WebAuthn challenge does not, so run the ceremony again with `handle` in the body. Also sent as 403 by transmit gates when a callsign-less token attempts to arm transmit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/credentials":{"get":{"summary":"List my enrolled passkeys for this app","tags":["auth"],"description":"Only the calling app's passkeys: the one created in this app, or that has signed in with it (capability `passkeys-per-app`).","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/webauthn/credentials/{id}":{"patch":{"summary":"Rename one of my passkeys","tags":["auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebAuthnCredentialBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnCredentialSummary"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove one of my passkeys","tags":["auth"],"description":"Removing the last passkey is allowed — the emailed code remains the primary login, so this can never lock an account out.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agent/keys/request-code":{"post":{"summary":"Email a code authorizing a dev-agent key mint (platform staff only)","tags":["agent"],"description":"Always answers 200: whether the address belongs to platform staff is deliberately not observable here. Internal/operator surface.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"platform":{"type":"string","enum":["mac","windows"]},"machineLabel":{"type":"string","minLength":1,"maxLength":100}},"required":["email","platform","machineLabel"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`EMAIL_DELIVERY_FAILED` — The login-code email could not be handed to the mail provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agent/keys/mint":{"post":{"summary":"Exchange an emailed code for a dev-agent API key","tags":["agent"],"description":"The secret is returned exactly once. Minting revokes the previous live key for the same (staff member, machine) — re-running the login client IS the rotation procedure. Internal/operator surface.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"platform":{"type":"string","enum":["mac","windows"]},"machineLabel":{"type":"string","minLength":1,"maxLength":100},"code":{"type":"string","minLength":6,"maxLength":6}},"required":["email","platform","machineLabel","code"]}}}},"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"keyId":{"type":"string"},"secret":{"type":"string"},"expiresAt":{"type":"string"}},"required":["keyId","secret","expiresAt"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`INVALID_CODE` — The emailed six-digit login code is wrong, expired, or out of attempts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agent/keys/current":{"delete":{"summary":"Revoke the presented agent key (machine decommission)","tags":["agent"],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`AGENT_KEY_REQUIRED` — The request carried no x-agent-key header. Agent keys are minted through a human login (npm run agent:login); the agent must ask its operator to run it. Internal/operator surface only — end-user clients never see this.\n\n`AGENT_KEY_INVALID` — The presented agent key is unknown, expired, or revoked. Not retryable with the same key: ask the operator to re-run the login client to mint a fresh one. Internal/operator surface only — end-user clients never see this.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/agent":{"post":{"summary":"Exchange an agent account key for an access token","tags":["agents"],"description":"The agent account's sign-in (capability `agents`). Presents a key minted by `POST /agents/register` or `POST /agents/me/keys` and receives the ordinary short-lived access token under the `agent` client, with every scope the key carries. There is NO refresh token: keep the token until `expiresAt` and exchange the key again. Exchanging on every request is a mistake and is answered `429 RATE_LIMITED` per key. Every failure is one `401 AGENT_ACCOUNT_KEY_INVALID`, whether the key is unknown, revoked, expired or not an agent key: which of those it was is not the presenter's to learn. Rate limited per source address like every sign-in route.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentAuthBodyInput"}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentSession"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`AGENT_ACCOUNT_KEY_INVALID` — The agent account key presented to `POST /v1/auth/agent` is unknown, revoked, expired, or not an agent account key at all. One answer for every case, deliberately. Not retryable with the same key: mint a new one from a session that still works, or ask the sponsor to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agents/register":{"post":{"summary":"Register an agent account (self-serve)","tags":["agents"],"description":"Creates an agent account (`kind: \"agent\"`) with no mailbox and no passkey, mints its first key, and signs it in, all in one call. The `secret` in the response is the key and is shown exactly once: store it outside any repository, never log it, and present it to `POST /auth/agent` for every later token. The OPERATOR running the agent attests age and accepts the Terms (`ageConfirmed`, `termsAccepted`, both must be true); the acceptance is recorded durably against the account. An agent accepts direct messages from anybody by default (`acceptMessagesFromNonContacts`), since being reachable is the point; it may switch that off through `PATCH /users/me` like anyone. Bounded per source address per hour and per deployment per day (`429 RATE_LIMITED`, see `Retry-After`), and switched off entirely by the operator with `403 AGENT_REGISTRATION_CLOSED`; a person can always create a sponsored agent from their own account instead.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentBodyInput"}}}},"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRegistration"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`AGENT_REGISTRATION_CLOSED` — Self-serve agent registration (`POST /v1/agents/register`) is switched off on this deployment. A person can still create a sponsored agent from their own account. Not retryable until an operator reopens it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agents/me/keys":{"get":{"summary":"The agent's own keys","tags":["agents"],"description":"Every key the account has held, newest first, revoked and expired ones included. The secret is in none of them.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentKeyList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`AGENT_ONLY` — The caller is a person and this route is an agent account's own surface (`/v1/agents/me/*`, contract §25). A person manages the agents they sponsor through `/v1/users/me/agents` instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Mint another key for the agent","tags":["agents"],"description":"Rotation is mint-then-delete: mint the new key, move it into place, then `DELETE` the old one, so there is no moment without a working credential. Minting never revokes. At the live-key limit (`liveKeysMax`) the answer is `409 AGENT_KEY_LIMIT`: delete one first. The secret is shown once.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintAgentKeyBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintedAgentKey"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`AGENT_ONLY` — The caller is a person and this route is an agent account's own surface (`/v1/agents/me/*`, contract §25). A person manages the agents they sponsor through `/v1/users/me/agents` instead.\n\n`SPONSORED_AGENT` — The caller is a SPONSORED agent (created by a person from their account, contract §25) and this is something its sponsor does: minting its keys and deleting it are `/v1/users/me/agents/*` from the sponsor's account. Not retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AGENT_KEY_LIMIT` — The agent account already holds its maximum number of live keys (`liveKeysMax` on the key list). Delete one, then mint again. Rotation is mint-then-delete, so one slot is always free for the next key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agents/me/keys/{keyId}":{"delete":{"summary":"Delete one of the agent's keys","tags":["agents"],"description":"Revokes the key: every token it ever minted stops working at once and any socket it holds closes 4409. Deleting the key behind the token making this request is allowed and ends that session too. Idempotent: a key already deleted answers 204 again. A key that is not this agent's is `404 NOT_FOUND`.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"keyId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`AGENT_ONLY` — The caller is a person and this route is an agent account's own surface (`/v1/agents/me/*`, contract §25). A person manages the agents they sponsor through `/v1/users/me/agents` instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/agents/me":{"delete":{"summary":"Delete the agent account, now","tags":["agents"],"description":"No grace window and no confirmation code: an agent has no mailbox to receive one and no second session to change its mind from. The account and its personal data are erased in this call, every key is revoked first, and the same records a person's deletion retains are retained (docs/user-data-deletion.md). Blocked exactly as a person's deletion is: `409 OWNS_FREQUENCIES` (transfer or delete each first) and `409 ACTIVE_SUBSCRIPTION`.","security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`AGENT_ONLY` — The caller is a person and this route is an agent account's own surface (`/v1/agents/me/*`, contract §25). A person manages the agents they sponsor through `/v1/users/me/agents` instead.\n\n`SPONSORED_AGENT` — The caller is a SPONSORED agent (created by a person from their account, contract §25) and this is something its sponsor does: minting its keys and deleting it are `/v1/users/me/agents/*` from the sponsor's account. Not retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`OWNS_FREQUENCIES` — Account deletion is blocked while the account still owns frequencies: deleting would take other people's frequencies with it. details.frequencies lists each one (id, khz, name, mode, memberCount) — transfer ownership (POST /frequencies/:id/transfer-ownership) or delete each, then retry.\n\n`ACTIVE_SUBSCRIPTION` — Account deletion is blocked by a live subscription (owner policy: the deletion flow never cancels billing as a side effect). details.subscriptions lists them. Cancel through the normal billing path first; a subscription set to cancel at period end still blocks until it has fully ended. Retry deletion after.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/agents":{"get":{"summary":"The agents this person sponsors","tags":["agents"],"description":"Each with every key it has held (the secret in none of them). `max` is how many one person may sponsor.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SponsoredAgentList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Create a sponsored agent","tags":["agents"],"description":"Creates an agent account under this person: the sponsor attests age and accepts the Terms on its behalf (recorded against the sponsor with the agent as subject), the two become contacts, and the first key is minted. `secret` is the key, shown once: hand it to the program that will run the agent and never log it. The agent signs in with it on `POST /auth/agent`. Everything personal the agent buys bills the sponsor; the sponsor cannot delete their own account while they sponsor anyone (`SPONSORS_AGENTS`). Never gated by the self-serve switch. `409 AGENT_LIMIT` at `max`; `409 CONTACT_LIMIT` when either contact list is full.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SponsoredAgentCreated"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AGENT_LIMIT` — This person already sponsors the maximum number of agents (`max` on `GET /v1/users/me/agents`). Delete one before creating another.\n\n`CONTACT_LIMIT` — One of the two contact lists is full.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/agents/{agentId}/keys":{"post":{"summary":"Mint a key for a sponsored agent","tags":["agents"],"description":"The sponsor's rotation: mint, hand over, then delete the old one. `409 AGENT_KEY_LIMIT` at the agent's live-key limit. The secret is shown once.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintAgentKeyBodyInput"}}}},"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"agentId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintedAgentKey"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AGENT_KEY_LIMIT` — The agent account already holds its maximum number of live keys (`liveKeysMax` on the key list). Delete one, then mint again. Rotation is mint-then-delete, so one slot is always free for the next key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/agents/{agentId}/keys/{keyId}":{"delete":{"summary":"Delete a sponsored agent's key","tags":["agents"],"description":"Every token the key minted stops working now and its sockets close 4409. Idempotent.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"agentId","required":true},{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"keyId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/agents/{agentId}":{"delete":{"summary":"Delete a sponsored agent, now","tags":["agents"],"description":"Erases the agent account immediately: keys revoked first, then the same erasure a person's deletion runs, with the same blockers (`409 OWNS_FREQUENCIES`: transfer or delete each of the agent's frequencies first).","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"agentId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`OWNS_FREQUENCIES` — Account deletion is blocked while the account still owns frequencies: deleting would take other people's frequencies with it. details.frequencies lists each one (id, khz, name, mode, memberCount) — transfer ownership (POST /frequencies/:id/transfer-ownership) or delete each, then retry.\n\n`ACTIVE_SUBSCRIPTION` — Account deletion is blocked by a live subscription (owner policy: the deletion flow never cancels billing as a side effect). details.subscriptions lists them. Cancel through the normal billing path first; a subscription set to cancel at period end still blocks until it has fully ended. Retry deletion after.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/oauth/providers":{"get":{"responses":{"200":{"description":"Default Response"}}}},"/v1/auth/oauth/google/start":{"post":{"responses":{"200":{"description":"Default Response"}}}},"/v1/auth/oauth/google/callback":{"get":{"responses":{"200":{"description":"Default Response"}}}},"/v1/auth/oauth/session/{state}":{"get":{"parameters":[{"schema":{"type":"string"},"in":"path","name":"state","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/.well-known/oauth-authorization-server":{"get":{"summary":"Authorization-server metadata (RFC 8414)","tags":["oauth"],"description":"Anonymous. Discover endpoints and capabilities from here instead of hardcoding paths.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthServerMetadata"}}}}}}},"/.well-known/jwks.json":{"get":{"summary":"Token verification keys (JWKS)","tags":["oauth"],"description":"Anonymous. Access tokens are EdDSA-signed; any service can verify them against this set without holding a secret. Cache by kid — a rotation publishes the new key here before tokens signed with it appear.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Jwks"}}}}}}},"/oauth/authorize":{"get":{"summary":"Begin the PKCE authorization-code flow (renders a sign-in page)","tags":["oauth"],"description":"Open this in the SYSTEM browser (RFC 8252), not an embedded webview. On success the browser is redirected to `redirect_uri?code=...&state=...`; exchange the code at `/oauth/token` with the `code_verifier`. S256 only.","parameters":[{"schema":{"type":"string","enum":["code"]},"in":"query","name":"response_type","required":true},{"schema":{"type":"string"},"in":"query","name":"client_id","required":true},{"schema":{"type":"string","format":"uri"},"in":"query","name":"redirect_uri","required":true},{"schema":{"type":"string"},"in":"query","name":"scope","required":false,"description":"Space-delimited; omitted = everything the client is registered for."},{"schema":{"type":"string","minLength":1,"maxLength":512},"in":"query","name":"state","required":true},{"schema":{"type":"string","minLength":43,"maxLength":128},"in":"query","name":"code_challenge","required":true},{"schema":{"type":"string","enum":["S256"]},"in":"query","name":"code_challenge_method","required":true},{"schema":{"type":"string"},"in":"query","name":"login_hint","required":false,"description":"OpenID Connect Core 3.1.2.1. The email address the app already holds, pre-filled into the sign-in page's email field so a returning user types nothing. A HINT and nothing else: the field stays editable, no step is skipped, and it is never treated as proof of anything. Longer than 320 characters (RFC 5321's maximum address length) or blank after trimming: ignored, not refused."}],"responses":{"200":{"description":"The sign-in page (text/html).","content":{"application/json":{"schema":{"type":"string","description":"The sign-in page (text/html)."}}}},"400":{"description":"An HTML error page — unknown client or unregistered redirect_uri, which RFC 6749 §4.1.2.1 forbids answering with a redirect.","content":{"application/json":{"schema":{"type":"string","description":"An HTML error page — unknown client or unregistered redirect_uri, which RFC 6749 §4.1.2.1 forbids answering with a redirect."}}}}}}},"/oauth/authorize/request-code":{"post":{"summary":"Email a login code for an in-flight authorization request","tags":["oauth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["requestId","email"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"],"additionalProperties":false}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"429":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"502":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}}}}},"/oauth/authorize/complete":{"post":{"summary":"Complete an authorization request with an emailed login code","tags":["oauth"],"description":"On a first-ever login for the email, responds 422 with `error=signup_required`; resubmit the SAME code with `handle` and `displayName` (mirrors /auth/verify-code).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"code":{"type":"string","minLength":6,"maxLength":6},"handle":{"$ref":"#/components/schemas/HandleInput"},"displayName":{"$ref":"#/components/schemas/PublicDisplayNameInput"},"signOutOtherSessions":{"type":"boolean"}},"required":["requestId","email","code"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"redirect":{"type":"string","description":"Navigate the browser here; it lands on the app's redirect_uri."}},"required":["redirect"],"additionalProperties":false}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"401":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"409":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"422":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}}}}},"/oauth/authorize/passkey-options":{"post":{"summary":"Begin a passkey ceremony for an in-flight authorization request","tags":["oauth"],"description":"Always answers 200 for a syntactically valid email — the response does NOT confirm an account exists or holds a passkey (§4a decoys). Run `navigator.credentials.get()` with the returned options and post the assertion to `/oauth/authorize/passkey-complete`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"email":{"type":"string","format":"email","pattern":"^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"}},"required":["requestId","email"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnLoginOptions"}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"429":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}}}}},"/oauth/authorize/discoverable-passkey-options":{"post":{"summary":"Begin a discoverable (email-less) passkey ceremony for an in-flight authorization request","tags":["oauth"],"description":"For `navigator.credentials.get({ mediation: 'conditional' })` on the hosted sign-in page. `options.allowCredentials` is `[]` and the body carries NO user-identifying field: the account is resolved from the credential the authenticator chooses. Post the assertion to `/oauth/authorize/passkey-complete` with `ceremonyId` in place of `userId`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"}},"required":["requestId"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebAuthnDiscoverableOptions"}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"429":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}}}}},"/oauth/authorize/passkey-complete":{"post":{"summary":"Complete an authorization request with a passkey assertion","tags":["oauth"],"description":"EVERY verification failure is the same `401 invalid_grant` — expired challenge, unknown credential, tampered assertion, decoy flow, expired discoverable ceremony — and the error never says which. A talk app signing in to a callsign-less account gets `422 callsign_required`, and the retry needs a FRESH assertion (a WebAuthn challenge does not survive a rejection the way a login code does). A broadcast operator at seat capacity gets `409 session_conflict` pointing at the emailed-code flow, which can prompt; nothing is issued and nothing is closed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"userId":{"description":"The `userId` that `/oauth/authorize/passkey-options` returned. Exactly one of `userId` or `ceremonyId`.","type":"string"},"ceremonyId":{"description":"DISCOVERABLE flavour: the `ceremonyId` that `/oauth/authorize/discoverable-passkey-options` returned. The account is resolved from the credential the authenticator chose, so no user is named. Exactly one of `userId` or `ceremonyId`.","type":"string"},"response":{"$ref":"#/components/schemas/OpaquePassthroughInput"},"handle":{"$ref":"#/components/schemas/HandleInput"},"displayName":{"$ref":"#/components/schemas/PublicDisplayNameInput"}},"required":["requestId","response"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"redirect":{"type":"string","description":"Navigate the browser here; it lands on the app's redirect_uri."}},"required":["redirect"],"additionalProperties":false}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"401":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"409":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"422":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"429":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}}}}},"/oauth/token":{"post":{"summary":"Token endpoint (RFC 6749): authorization_code, refresh_token, client_credentials","tags":["oauth"],"description":"Accepts `application/x-www-form-urlencoded` (standard) or JSON. Errors use the RFC 6749 shape, not the platform envelope. Public clients authenticate by PKCE (`code_verifier`); confidential clients by `client_secret`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token","client_credentials","urn:frequencytalk:params:oauth:grant-type:webauthn"]},"client_id":{"type":"string"},"client_secret":{"type":"string"},"code":{"type":"string"},"redirect_uri":{"type":"string"},"code_verifier":{"type":"string"},"refresh_token":{"type":"string"},"scope":{"type":"string"},"user_id":{"description":"Passkey grant: the `userId` that login-options returned. Exactly one of `user_id` or `ceremony_id`.","type":"string"},"ceremony_id":{"description":"Passkey grant, DISCOVERABLE flavour: the `ceremonyId` that `/auth/webauthn/discoverable-options` returned. The account is resolved from the credential the authenticator chose, so no user is named. Exactly one of `user_id` or `ceremony_id`.","type":"string"},"assertion":{"description":"Passkey grant: the `AuthenticationResponseJSON` the authenticator produced.","anyOf":[{"type":"string"},{"$ref":"#/components/schemas/OpaquePassthroughInput"}]},"device_id":{"description":"Passkey grant: optional client-chosen device id, stored as the legacy binding.","type":"string"}},"required":["grant_type","client_id"]}}}},"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponse"}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenError"}}}},"401":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenError"}}}},"409":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"422":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeError"}}}},"429":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenError"}}}}}}},"/v1/auth/sessions":{"get":{"summary":"List this account's signed-in app sessions","tags":["auth"],"description":"One entry per app install (refresh-token family). `current` marks the session making this request.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserSession"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/auth/sessions/{id}":{"delete":{"summary":"Sign one app session out","tags":["auth"],"description":"Revokes every refresh token in the family; the app's access token dies at its ≤15-minute expiry. Revoking the CURRENT session is allowed (it is just a logout).","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me":{"get":{"summary":"The signed-in user's profile","tags":["users"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Update the signed-in user's profile","tags":["users"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Delete the signed-in user's account and personal data","tags":["users"],"description":"Requires EXACTLY ONE confirmation factor: `code` (the six-digit code from POST /users/me/deletion-code) or `assertion` (the passkey assertion for a challenge from POST /users/me/deletion-challenge, capability `account-deletion-passkey`). Sending both, or neither, is 400 VALIDATION_ERROR. The two are equivalent proofs of freshness, and a 409 blocker preserves EITHER for resubmission: the blockers are checked before either factor is spent, so resolving the blocker and resending the same `code` or the same `assertion` works. They differ only in how long that stays true — a code lasts 10 minutes, a challenge 5 — after which the passkey path needs a fresh challenge and one more tap. On 202 the deletion is SCHEDULED: every session across every app is revoked immediately (live sockets close 4409; refresh answers 401 INVALID_REFRESH — terminal, do not retry), and the account is erased after `purgeAfter`. Signing in again before then cancels it (the sign-in response carries `deletionCancelled: true`). Blocked cases answer 409 with a specific code, and — like SIGNUP_REQUIRED — a rejection does not consume the code, so resolving the blocker and resubmitting the SAME code works: OWNS_FREQUENCIES (details.frequencies — transfer or delete each first; any frequency mode blocks) and ACTIVE_SUBSCRIPTION (details.subscriptions — this flow never cancels billing; unsubscribe through the normal billing path and retry once the subscription has fully ended). What is retained afterwards and why: docs/user-data-deletion.md (in short: legal acceptances, this deletion's receipt, billing/tax records, moderation history where any exists, and de-identified audit entries rendered as \"Deleted user\").","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAccountBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"202":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountDeletionSchedule"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.\n\n`INVALID_CODE` — The emailed six-digit login code is wrong, expired, or out of attempts.\n\n`WEBAUTHN_FAILED` — WebAuthn assertion or attestation could not be verified.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`OWNS_FREQUENCIES` — Account deletion is blocked while the account still owns frequencies: deleting would take other people's frequencies with it. details.frequencies lists each one (id, khz, name, mode, memberCount) — transfer ownership (POST /frequencies/:id/transfer-ownership) or delete each, then retry.\n\n`ACTIVE_SUBSCRIPTION` — Account deletion is blocked by a live subscription (owner policy: the deletion flow never cancels billing as a side effect). details.subscriptions lists them. Cancel through the normal billing path first; a subscription set to cancel at period end still blocks until it has fully ended. Retry deletion after.\n\n`SPONSORS_AGENTS` — The account cannot be deleted while it sponsors agents: `details.agents` is `[{id, handle, stationId}]`. Delete each sponsored agent (`DELETE /v1/users/me/agents/{id}`), then retry; like the other deletion blockers it consumes neither confirmation factor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/notification-defaults":{"put":{"summary":"Replace the account's notification defaults","tags":["users"],"description":"What a contact's own settings fall back to. Sent WHOLE: an absent field is a reset to its default, never \"leave it alone\". For the two sound fields, `null` means INHERIT (the catalog's isDefault member for that category) and `\"none\"` means chosen silence - they are different, and collapsing them would make \"turn this off for everybody\" and \"use whatever ships\" the same request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountNotificationDefaultsInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/status":{"get":{"summary":"The caller's own presence status","tags":["users"],"description":"`chosen` is what they picked; `effective` folds in auto-away. Neither is ever `offline` — offline is the absence of a socket, not a choice, and \"appear offline while connected\" IS `invisible`.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyStatus"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"summary":"Set the caller's presence status","tags":["users"],"description":"Account-level, not per-device: picking Away on a phone shows Away everywhere, on the `favorites.updated` precedent — the point is that the session which did NOT make the change finds out. Going invisible while connected publishes the OFFLINE edges, so contacts and frequency members see the user leave.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/UserStatusInput"}},"required":["status"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyStatus"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/callsign":{"post":{"summary":"Change the signed-in user's callsign","tags":["users"],"description":"Changes `handle`. Feature-detect on the `callsign-rename` capability rather than probing this path. The body carries GRAMMAR only; the profanity screen is the SAME predicate the signup handle uses and answers 400 PROFANE_CALLSIGN so a client can tell it from a charset or length refusal. Rate limited per account, and limited to one change per CALLSIGN_RENAME_COOLDOWN_DAYS (default 30) — 409 CALLSIGN_RENAME_COOLDOWN carries `details.unlockAt`, `details.retryAfterSeconds` and `details.cooldownDays` so the refusal can name the date. Setting a callsign for the FIRST time (a listener account created from an email alone) is never refused by the cooldown. Submitting the callsign the account already has is a no-op that does NOT spend the window. Case is preserved verbatim: a callsign is self-expression, and no surface may re-case one. On success a `profile.updated` event is published on the user's realtime lane; a client receiving it MUST refresh its access token, because the socket labels presence and rosters from the token's `handle` claim and the rename is invisible to everyone else until that token is replaced.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenameCallsignBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.\n\n`PROFANE_CALLSIGN` — The requested callsign failed the profanity screen — the SAME predicate the signup handle has always used, surfaced here under its own code so a client can tell it from a charset or length refusal. `message` is presentable prose.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CALLSIGN_RENAME_COOLDOWN` — A callsign may only be changed once per cooldown window (30 days by default). `details` carries `unlockAt` (ISO 8601), `retryAfterSeconds` and `cooldownDays`, so the client can name the date instead of saying \"not yet\".","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/deletion-code":{"post":{"summary":"Email a six-digit account-deletion confirmation code","tags":["users"],"description":"Sent to the ACCOUNT's address (no body): fresh proof of mailbox control, because a resident app signed in for weeks must not let anyone at an unlocked machine erase the account in two clicks. The code is purpose-bound — a login code is not accepted by DELETE /users/me and this code cannot sign anyone in. Rate limited per address, independently of login codes.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`EMAIL_DELIVERY_FAILED` — The login-code email could not be handed to the mail provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/deletion-challenge":{"post":{"summary":"Begin confirming account deletion with a passkey","tags":["users"],"description":"The passkey alternative to POST /users/me/deletion-code (capability `account-deletion-passkey`; feature-detect on it rather than probing this path). Returns WebAuthn `PublicKeyCredentialRequestOptions` to hand to `navigator.credentials.get()`; send the resulting assertion as DELETE /users/me's `assertion`. The challenge is PURPOSE-BOUND: an assertion obtained by signing in is not accepted here and this one cannot sign anyone in, exactly as the emailed deletion code is not a login code. `userVerification` is `required` (login uses `preferred`) — the point is that a human authorised an irreversible action, which an unlocked laptop alone does not establish. Expires in 5 minutes and is single-use, so a refused attempt needs a fresh one. 400 NO_PASSKEY_ENROLLED when the account has no passkey: fall back to the emailed code.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpaquePassthrough"}}}},"400":{"description":"`NO_PASSKEY_ENROLLED` — This account has no passkey, so it cannot confirm with one. Defensive: a client should offer the passkey factor only after listing the account's credentials. Use the emailed code instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/tuning-states":{"get":{"summary":"Saved per-frequency tuning state","tags":["users"],"description":"Returns every saved row, not only the active ones: an inactive row carries the channel selections to restore when the user re-tunes that frequency.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TuningState"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/search":{"get":{"summary":"Search users by callsign, Station ID or email","tags":["users"],"description":"Needs at least 2 characters; a shorter query returns an empty list rather than an error. Capped at 10 results. Identify a user by `stationId` — `handle` is not unique.","parameters":[{"schema":{"type":"string"},"in":"query","name":"q","required":false,"description":"At least 2 characters, otherwise the result is empty."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserSummary"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/suggest-number":{"get":{"summary":"Suggest the next open frequency number","tags":["frequencies"],"description":"What auto-assignment would pick right now for the given mode — push_to_talk: the lowest open HAM-band slot; broadcast: the FM-dial halving strategy. A suggestion only, NOT a reservation: the number is only yours once a create or re-number lands it (the khz unique constraint remains the backstop, so a raced suggestion 409s there).","parameters":[{"schema":{"default":"push_to_talk","type":"string","enum":["push_to_talk","broadcast"]},"in":"query","name":"mode","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuggestedNumber"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies":{"post":{"summary":"Create a frequency","tags":["frequencies"],"description":"A broadcast frequency IS a station: it needs `stationCallsign`, plus the company that owns it as EXACTLY ONE of `organizationId` (one of your own, from GET /orgs/mine — manager+ required) or `companyContact` (creates a new company — unless its name+contactEmail match a company you already manage, which is then reused rather than duplicated). There is no attach-by-company-name. Omit `vanityKhz` to be auto-allocated the next free number for the mode.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFrequencyBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Frequency"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`SUBSCRIPTION_REQUIRED` — This action needs an active subscription on the owning organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`FREQUENCY_TAKEN` — That frequency number is already allocated.\n\n`STATION_NAME_TAKEN` — Another station already uses that name.\n\n`STATION_CALLSIGN_TAKEN` — Another station already uses that callsign (callsigns are globally unique).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.\n\n`STATION_REQUIRED_FOR_BROADCAST` — A broadcast frequency needs an on-air callsign and a company to own it.\n\n`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.\n\n`UNSUPPORTED_COUNTRY` — The supplied country is not in the platform's supported-country allowlist.\n\n`INVALID_FREQUENCY` — The frequency number is outside the band plan or off its channel grid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Frequencies the caller is a member of","tags":["frequencies"],"description":"Each row carries `mediaNodeStatus`, so one call seeds every band's status — use this at directory build and after a realtime reconnect (a `mediaNode.status` edge that fired while disconnected is lost, not replayed) instead of per-frequency GETs.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MyFrequency"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/public":{"get":{"summary":"The broadcast dial the caller may see, decorated with their membership","tags":["frequencies"],"description":"Requires a token. Publicly-listed broadcast frequencies, PLUS any code-gated ones the caller is a member of — a station its owner gated with an access code is not disclosed to non-members (0.24.0). For an anonymous view of broadcast stations use `/public/stations`.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicFrequency"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/stations/stats/mine":{"get":{"summary":"Live state for the caller's own broadcast frequencies","tags":["stations"],"description":"Requires a token, per-caller, never cached. Live state for every broadcast frequency the caller is a member of, code-gated ones included — the companion to `/public/stations/stats`, which withholds them. Rows are the same shape, so a client folds both into one dial. Capability `private-station-stats`; a client with no code-gated memberships need not call it at all.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyStationStats"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/public/stations":{"get":{"summary":"Anonymous station directory","tags":["stations"],"description":"No authentication. Publicly-listable BROADCAST frequencies with branding, live programme metadata and live dial state, ordered by dial number — enough to render a dial before anyone signs in. Returns only stations that are currently TRANSMITTING; pass `?includeOffline=true` for the full listed dial. A hard-muted station is still transmitting (`onAir: false`) and stays listed. Short-cached (15s), so live state can lag by that plus any CDN TTL.","parameters":[{"schema":{"type":"string","enum":["true","false"]},"in":"query","name":"includeOffline","required":false,"description":"\"true\" also returns listed stations that are not transmitting. Default: transmitting stations only."}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicStationDirectory"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/public/stations/stats":{"get":{"summary":"Live per-station transmit/on-air flags and listener counts for the dial","tags":["stations"],"description":"Anonymous, cacheable. Live state for publicly-listed broadcast frequencies at every stage — the same set `/public/stations` lists, without its extra not-off_air filter. Code-gated stations are NOT included at any stage (0.24.0); a member reads their own from `/stations/stats/mine`. Identical for every caller, short TTL.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicStationStats"}}}}}}},"/v1/frequencies/{id}":{"get":{"summary":"One frequency, with the caller's role and the media-node status","tags":["frequencies"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FrequencyDetail"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Update a frequency","tags":["frequencies"],"description":"Admin or owner. Changing `mode` to push_to_talk requires the number to be a HAM-band one; send `khz` alongside to re-number in the same call.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFrequencyBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Frequency"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`FREQUENCY_TAKEN` — That frequency number is already allocated.\n\n`STATION_NAME_TAKEN` — Another station already uses that name.\n\n`STATION_CALLSIGN_TAKEN` — Another station already uses that callsign (callsigns are globally unique).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`INVALID_FREQUENCY` — The frequency number is outside the band plan or off its channel grid.\n\n`HAM_NUMBER_REQUIRED_FOR_PTT` — A push-to-talk frequency must be allocated a HAM-band number.\n\n`UNSUPPORTED_COUNTRY` — The supplied country is not in the platform's supported-country allowlist.\n\n`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Delete a frequency","tags":["frequencies"],"description":"Owner only. Stops any running container and clears the frequency's Redis state. Returns 204.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/transfer-ownership":{"post":{"summary":"Transfer ownership to another member","tags":["frequencies"],"description":"The new owner must already be a member. The previous owner is demoted to admin.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"newOwnerUserId":{"type":"string"}},"required":["newOwnerUserId"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/access-code":{"get":{"summary":"Reveal the frequency's access code","tags":["frequencies"],"description":"Owner/admin only. Storage is reversible encryption precisely so this route can exist.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessCode"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"summary":"Set or clear the access code","tags":["frequencies"],"description":"Owner/admin only. `null` clears it, making the frequency open. Every change is audit-logged (never the code itself).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetAccessCodeBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessCodeUpdated"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/tune":{"post":{"summary":"Tune in, and get a media-join token","tags":["frequencies"],"description":"Provisioning is transmit-gated. Listening (`transmitIntent` false/absent) never starts a container and reports whatever status already exists — including `stopped`, which is an ordinary state a client renders as static, not an error. Only `transmitIntent: true` provisions. Requires `stream:listen`; `transmitIntent: true` additionally requires `channels:talk`. `transmitIntent` is a statement that this caller is about to produce audio — it is NOT a keepalive flag, and it costs a container. Send it when the user arms or holds transmit, not on every poll: containers are reaped after MEDIA_NODE_TTL_MINUTES of TRANSMIT idleness (nobody producing), so a client that sends `true` on a repeating poll while nobody talks makes the server build a container, reap it, and build it again on the next poll, indefinitely. One location per frequency (capability `one-location-per-frequency`, console apps only): a successful tune WITHOUT `resume` takes the frequency for this install and pushes `frequency.displaced` to every other console install of the account. An automatic reconnect must send `resume: true`, which never displaces anybody and answers 409 `FREQUENCY_OPENED_ELSEWHERE` when the frequency has since moved to another install. On a broadcast station where this account is live from another install, a tune without `resume` answers 409 `BROADCAST_ELSEWHERE` and changes nothing; resend with `takeOver: true` to proceed. Screen share (capability `share-container`): `shareIntent: true` builds the frequency's SHARE container, a separate container from the voice one, and needs `channels:talk`, a callsign and talk permission on at least one channel (403 `SHARE_NOT_ALLOWED`). Every member's result then carries `share`, and `share.connect` once that container is running.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TuneBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TuneResult"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`CALLSIGN_REQUIRED` — This app requires a callsign and the account has none (it was created by a listen-only app from an email alone). From `/auth/verify-code`: resubmit the SAME login code with `handle` (and optionally `displayName`) to set it — like SIGNUP_REQUIRED, the code is not consumed by this rejection. From `/auth/webauthn/login-verify` (capability `passkey-callsign-gate`) the retry needs a FRESH assertion instead: a login code survives an answerable rejection, a WebAuthn challenge does not, so run the ceremony again with `handle` in the body. Also sent as 403 by transmit gates when a callsign-less token attempts to arm transmit.\n\n`SHARE_NOT_ALLOWED` — A tune with `shareIntent` from a member who holds talk permission on no channel of the frequency, so the share container would refuse every share. Nothing is built. Not a sign-out and not retryable as-is: the member needs talk somewhere first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NATIVE_TRANSPORT_DISABLED` — The native transport is not enabled on this deployment, so no channel-key material exists to serve. Internal surface only (the media node's bridge key poll) — clients never see this.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`BROADCAST_ELSEWHERE` — One location per frequency (capability `one-location-per-frequency`): this account has a LIVE broadcast on this station from another install, and tuning in here would take that install off the frequency and the station off the air. Nothing was changed and nothing was consumed. `message` is presentable prose naming the station; `details.station` carries the name alone. Ask the user, then resend the same tune with `takeOver: true`, or abandon it.\n\n`FREQUENCY_OPENED_ELSEWHERE` — One location per frequency: a `resume: true` tune (an automatic reconnect) from an install the frequency has since moved away from. The account tuned in to it from another install, which now holds it. Drop the frequency locally exactly as on a `frequency.displaced` push, and do not retry. A deliberate tune (no `resume`) takes it back.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/share-key":{"get":{"summary":"The share key again, without re-tuning","tags":["frequencies"],"description":"The response to a `nativeKeysRotated` notice from the frequency's SHARE container (capability `share-container`). Returns the same key `POST /tune` carries in `share.connect`, under the same authorization, with none of tune's side effects. JITTER this call, as for `/native-keys`: a rotation is frequency-wide. Compare `keysetId`, not `epoch`. A `bootId` that differs from your share session's means re-tune.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResult"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`SHARE_NODE_NOT_RUNNING` — The share key was requested for a frequency whose share container is not running with a cleared generation. Ordinary, not a fault: nobody is sharing, or the container is still booting. Wait for `shareNode.status` `running` and re-tune.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/native-keys":{"get":{"summary":"Fresh native channel keys, without re-tuning","tags":["frequencies"],"description":"The response to a `nativeKeysRotated` signaling message. Returns the same key material `POST /tune` carries, under the same authorization, but with none of tune's side effects — no new connectionId, no new senderMac key, no new stream ids, no provisioning. That is the point: a talker installs the new keys and keeps talking, where a re-tune would interrupt its audio and rebuild its session on an event that fires whenever anybody leaves the frequency. JITTER this call (multiply an exponential step by a random factor in [0.5, 1.0)): the rotation that prompts it is frequency-wide, so every native client is told at the same instant. Compare `keysetId`, not `epoch` — a control-plane restart re-mints at epoch 0 with different material.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NativeKeysResult"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NATIVE_TRANSPORT_DISABLED` — The native transport is not enabled on this deployment, so no channel-key material exists to serve. Internal surface only (the media node's bridge key poll) — clients never see this.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`MEDIA_NODE_NOT_RUNNING` — Native channel keys were requested for a frequency whose media node is not running, so there is no cleared generation to bind them to. Ordinary, not a fault: the container is idle-swept or still booting. Re-tune when it returns — that is when a client needs a fresh native identity anyway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/tune-by-number":{"post":{"summary":"Join a frequency by its number","tags":["frequencies"],"description":"The number itself is the invite, as on a real radio. Works for any frequency, listed or not; a first-time joiner of a code-protected one must also send `accessCode`. Idempotent for existing members. The 403 split is deliberate: ACCESS_CODE_REQUIRED = no code sent (a private station EXISTS at that number — intended design, and the cue to prompt for its code); ACCESS_CODE_INVALID = the supplied code is wrong. Codes are passwords: wrong ones are rate-limited per user (429), and repeated wrong codes against one frequency earn a temporary automatic ban (403 BANNED). Discovery scanning belongs on the public directory, never on this route.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TuneByNumberBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Frequency"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`ACCESS_CODE_REQUIRED` — This frequency is access-code protected and the request carried no code. The station EXISTS at that number — intended design (owner decision 2026-08-06): sweeping the band can learn where private stations are, never what they are or who runs them.\n\n`ACCESS_CODE_INVALID` — An access code was supplied and it is wrong. Split from ACCESS_CODE_REQUIRED (additive, §8) so a client can react to a bad guess distinctly from a missing code. Wrong codes are rate-limited per user, and repeated wrong codes against one frequency earn a temporary automatic ban.\n\n`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.\n\n`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/request-join":{"post":{"summary":"Ask to join a publicly-listable frequency","tags":["frequencies"],"description":"Joins outright when the frequency auto-approves, otherwise raises a pending invitation for an admin.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JoinRequestResult"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`ALREADY_MEMBER` — The user is already a member of this frequency/organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/reinstate":{"post":{"summary":"Put a suspended frequency back on the air","tags":["frequencies"],"description":"Self-service lift of a NON-PAYMENT suspension, by the frequency's owner (push-to-talk) or a manager+ of the owning company (broadcast). Requires an entitling subscription, whose slot this claims for the frequency. Conduct and legal suspensions answer 403 — read `suspension.reinstatable` before offering this. Idempotent: a frequency that is already live answers 200 with the current DTO. Capability: frequency-suspension.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Frequency"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`SUBSCRIPTION_REQUIRED` — This action needs an active subscription on the owning organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FREQUENCY_SUSPENDED` — The FREQUENCY is off the air — a platform or billing action against the station itself, not against the caller (contrast BANNED, which is about one person). `details.suspensionClass` is non_payment | voluntary | conduct | legal and `details.reinstatable` says whether the owner can lift it from the app; for non_payment and voluntary that means holding a live subscription again and calling POST /frequencies/{id}/reinstate. Nothing has been deleted: every station field survives a suspension, so keep showing the station and refuse only to tune it. ONE exception, and it is the only class that is not indefinite — a `voluntary` suspension raised by cancelling during the free trial also DELETES the station when that trial would have ended, because the customer is reclaiming a station they never paid for; reinstating at any point before then cancels the deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/tuning-state":{"put":{"summary":"Save the caller's tuning state for this frequency","tags":["frequencies"],"description":"Partial: each of `active`, `selectedChannelIds` and `selectedTransmitChannelIds` is applied only when present, so toggling one never clobbers the others.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTuningStateBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TuningState"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/stream-info":{"get":{"summary":"The station's audio chain: what was sent, and what is being served","tags":["stations"],"description":"What reached the platform (`contribution`) and what the listen tier serves (`delivery`), so a client can state how far the audio it is playing sits from what the broadcaster put on the air. `delivery` is deployment configuration and is always present; `contribution` is null when nothing is on the air, and its `encoding` is null when a native sender declared none — that transport is codec-opaque, so the honest answer is \"not stated\", never a guess. `bottleneck` names the narrower leg; it is deliberately not a percentage, because lossy-to-lossy chains do not compose linearly and any single fidelity score would be fiction. Changes only when someone goes on or off the air — poll it on tune and on a `mediaNode.status` event, not on a timer. The one exception is `streamHealth` (capability `air-monitor`): a client that is TRANSMITTING on the station may poll for it, no faster than once every 15 seconds.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StreamInfo"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/roster":{"get":{"summary":"Everyone tuned to this frequency right now (capability: frequency-roster)","tags":["frequencies"],"description":"The tuned roster: every device currently declaring itself connected to this frequency over the realtime lane, with the channels it listens and could talk on — including members subscribed to zero channels, and including the caller's own row(s). Works with no media node running; the roster lives on the control plane, not in a container. Soft state with a ~90s horizon. Fetch on tune and after a realtime reconnect, then maintain from `frequency.rosterMember` / `frequency.rosterLeave` events — deltas are not replayed, so a lost edge is only recoverable here.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FrequencyRoster"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/listener-sessions":{"get":{"summary":"The listener ledger: who listened, over what, from when to when","tags":["frequencies"],"description":"Audience evidence for licensed-music reporting (capability `listener-ledger`), recorded by the audio paths themselves — the HLS ticket lifecycle and the media node's presence heartbeat — never by a client's say-so. One row per continuous listen; `endedAt` null means live right now. Identity fields are null for anonymous listeners and for erased accounts (the row survives erasure de-identified). Admin/owner only: listener identities are personal data. `from`/`to` bound `startedAt`; newest first; at most `limit` rows (default 200, max 1000).","parameters":[{"schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"in":"query","name":"from","required":false,"description":"Only sessions started at/after this instant (ISO 8601)."},{"schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"in":"query","name":"to","required":false,"description":"Only sessions started before this instant (ISO 8601)."},{"schema":{"default":200,"type":"integer","minimum":1,"maximum":1000},"in":"query","name":"limit","required":false},{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListenerSessionList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/listener-places":{"get":{"summary":"Where the audience was: listeners by country, state and broad area","tags":["frequencies"],"description":"Totals only (capability `listener-places`, contract §14). How many DISTINCT listeners the frequency had in a period, by country, by state or province, and for the United States by broad area such as West Coast or Southeast. Each listen's place is resolved from the address it arrived on and the address is then dropped: nothing here, or anywhere else, serves an address or a place for one listener. A place with fewer listeners than `floor` is not named; its listeners stay inside the level above. There is no city and no live view, by design. The period is whole UTC days: `from` inclusive, `to` exclusive, default the last 30 days, at most 366. Places are approximate: a listener on a VPN or a mobile network reads as wherever that network exits. Show `attribution` wherever these totals are drawn. Admin/owner only, as the ledger is.","parameters":[{"schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"},"in":"query","name":"from","required":false,"description":"First UTC day of the period, YYYY-MM-DD (inclusive). Default: 30 days before `to`."},{"schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"},"in":"query","name":"to","required":false,"description":"The UTC day the period ends before, YYYY-MM-DD (exclusive). Default: tomorrow, so today is included."},{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Frequency id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListenerPlaces"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/station":{"get":{"summary":"Read a station's full identity","tags":["stations"],"description":"Any staff member of the owning company (dj included — knowing what the station looks like on air is the job). Carries the settings-only fields the compact `station` embed on a frequency omits.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationDetail"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Update a station's on-air identity","tags":["stations"],"description":"Callsign, branding, content classification. Manager or above at the owning company. The station's NAME is the frequency's name — change it with PATCH /frequencies/{id}.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateStationBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationDetail"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`STATION_CALLSIGN_TAKEN` — Another station already uses that callsign (callsigns are globally unique).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/now-playing":{"patch":{"summary":"Publish what is on air","tags":["stations"],"description":"dj or above — the lowest role, since this is routine on-air work. Emits a `station.nowPlaying` realtime event on THIS frequency only, which reaches its subscribed MEMBERS; a listener in a Tuner reads the new value by polling the station (contract §1a).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNowPlayingBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"nowPlaying":{"oneOf":[{"$ref":"#/components/schemas/NowPlaying"},{"type":"null"}]}},"required":["nowPlaying"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/logo":{"put":{"summary":"Upload the station logo","tags":["stations"],"description":"Manager or above. Base64 PNG, JPEG or WebP. Bumps `logoVersion`, which is the cache key for the serve route.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadStationLogoBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationDetail"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.\n\n`LOGO_TOO_LARGE` — The uploaded logo exceeds the maximum byte size.\n\n`LOGO_UNREADABLE` — The uploaded logo could not be decoded as PNG, JPEG or WebP.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove the station logo","tags":["stations"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationDetail"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Fetch the station logo (anonymous)","tags":["stations"],"description":"No authentication — logos load via `<img src>`, which cannot send a bearer header, and station branding is public by nature. Negotiate a rendition with `?format=png|webp|original` or an `Accept` header; PNG is what a native client should ask for. There is no WebP rendition: `webp` answers WebP only when the station uploaded WebP, and otherwise the stored original, labelled with its own type. Pass `?v=<logoVersion>` for an immutable cache key. The ETag varies by rendition, so a cached PNG is not offered to a client asking for WebP.","parameters":[{"schema":{"type":"string","enum":["png","webp","original"]},"in":"query","name":"format","required":false},{"schema":{"type":"string"},"in":"query","name":"v","required":false,"description":"The station's logoVersion. Only a cache key — not validated."},{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"responses":{"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/now-playing/reaction":{"put":{"summary":"Cheer or Hush what the station is playing","tags":["stations"],"description":"Capability `cheer-hush` (contract §1a). Any signed-in LISTENER: the caller must have an open listen on the station, on any transport (`NOT_LISTENING` otherwise; on WebRTC and native a listen is recorded within ten seconds of audio starting). `cheer`, `hush`, or null to take it back. One standing Cheer or Hush per listener per AIRING: sending the other one replaces it, sending the same one again changes nothing. It names no song. The service counts it against whatever is playing when it arrives and answers with that airing's `since`, which is `nowPlaying.since`; when the song or show changes the press no longer stands and the listener may press again. `NOTHING_PLAYING` when the station has published nothing or is off the air. Answers the caller's own press and never a count: only the station's staff see a tally.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetStationReactionBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyStationReaction"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_LISTENING` — A Cheer or Hush comes from somebody listening: the caller has no open listen on that station, on any transport.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`NOTHING_PLAYING` — There is nothing to cheer or hush: the station has published nothing as now playing, or it is off the air. `nowPlaying.since` is null whenever this would be the answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Read your own Cheer or Hush","tags":["stations"],"description":"Capability `cheer-hush`. For an app that just started, or a second device: the caller's own standing Cheer or Hush on this station. Compare `since` with `nowPlaying.since`: equal means it stands for what is playing now. Both fields are null when the caller has no standing press, and the answer is the same for a station that does not exist.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyStationReaction"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/reactions":{"get":{"summary":"Read the live Cheer and Hush tally","tags":["stations"],"description":"Capability `cheer-hush`. Station staff, dj or above: the role that publishes what is playing. How many listeners are cheering and hushing the current airing, counted as people. The same numbers arrive live as the `station.reactions` realtime event on each member of staff's own connection, as each press lands and at most four times a second in a rush; read this once when a console opens and after a reconnect. `airing` is null when nothing is playing that can be cheered.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationReactionTally"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/reactions/popular":{"get":{"summary":"Read which songs and shows the audience liked","tags":["stations"],"description":"Capability `cheer-hush`. Station staff, dj or above. Every song and show that began in the last `days` days, most liked first (cheers less hushes), with how many times it played. A song is the same song when its artist and title match after the SERVICE's own folding of case and spacing; `key` is that identity and is opaque. The airing still on counts as a play, and its cheers are added when it ends. Counting starts the day this capability shipped: the service kept no record of what was played before it.","parameters":[{"schema":{"default":30,"type":"integer","minimum":1,"maximum":400},"in":"query","name":"days","required":false,"description":"How far back to read. The log is kept 400 days."},{"schema":{"default":50,"type":"integer","minimum":1,"maximum":200},"in":"query","name":"limit","required":false},{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationPopular"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/off-air-notice":{"get":{"summary":"Read where the station is told that it dropped off the air","tags":["stations"],"description":"Capability `off-air-notice` (contract §1a). Manager or above at the owning company: the address may be a person's. `email` null means none is set and nothing is sent.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OffAirNotice"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"put":{"summary":"Set or clear where the station is told that it dropped off the air","tags":["stations"],"description":"Capability `off-air-notice` (contract §1a). Manager or above. One address, or null to stop. When the station goes off the air and nobody switched it off (the same line the `interrupted` live stage draws), and stays off for `holdSeconds`, the service emails this address, and emails it once more when the station is back. At most one off-the-air message per station per `minGapSeconds`. Setting a NEW address sends it one message saying so; saving the same address again sends nothing. Email only: the address can be a paging service's inbound mailbox. This is the one notice the service sends, because it is the one an app cannot (a computer that lost power cannot report it); an app's own notices are its own.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetOffAirNoticeBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Broadcast frequency (station) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OffAirNotice"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`NOT_A_STATION` — That frequency is not a broadcast station, so it has no on-air identity to read or change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/members":{"get":{"summary":"Members of a frequency","tags":["frequencies"],"description":"Any member may read the roster. `online` is live presence, resolved per request.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FrequencyMember"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/board/members/{userId}":{"get":{"summary":"One member, with their personal channel","tags":["frequencies"],"description":"Capability `frequency-board`. A board's read for a `membership.updated` about somebody it does not hold and that did not carry the member. The same row `GET /frequencies/:id/members` serves, plus the personal channel a board draws them under.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardMember"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/invitations":{"post":{"summary":"Invite a user to a frequency","tags":["frequencies"],"description":"Identify the user by callsign, Station ID or email. Prefer Station ID — callsigns are not unique, and an ambiguous one is rejected rather than guessed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteMemberBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`USER_NOT_FOUND` — No user matches the supplied handle, Station ID or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AMBIGUOUS_HANDLE` — Several users share that callsign — address them by Station ID or email instead.\n\n`ALREADY_MEMBER` — The user is already a member of this frequency/organization.\n\n`ALREADY_INVITED` — An unaccepted invite for this user already exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/members/{userId}/role":{"patch":{"summary":"Change a member's role","tags":["frequencies"],"description":"The owner's role cannot be changed here — use transfer-ownership.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRoleBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CANNOT_CHANGE_OWNER` — The owner's role cannot be changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/tuning-states":{"get":{"summary":"Every member's saved listen and talk channels, and who is under the admin mute (capability: member-tuning, member-force-mute)","tags":["frequencies"],"description":"Owner and admins only. What each member has SAVED on this frequency, which is what their next tune restores and what `PUT .../members/:userId/tuning-state` edits. Members with nothing saved are absent. What somebody is doing LIVE is the tuned roster (`GET /frequencies/:id/roster`), not this: draw tuned members from the roster and everybody else from here.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberTuningStates"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/members/{userId}/force-mute":{"post":{"summary":"The admin mute, frequency-wide: mute or unmute a member on every channel (capability: member-force-mute)","tags":["frequencies"],"description":"The same mute as `POST /channels/:channelId/force-mute`, applied to every channel of this frequency the member could talk on (its group channels and their own personal channel) in one call. Owner and admins (`channel.forceMute`) set and lift it; the member may LIFT it on themselves (`muted: false`, their own userId) and never set it. It never touches the member's talk channels: a mute is not an unjack. One `channel.forceMute` with `byUserId` is published per channel whose state changed. Not a member: 404 NOT_MEMBER. A channel created after the mute is not covered; read `GET .../tuning-states` and mute again.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberForceMuteBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberForceMuteResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/members/{userId}/tuning-state":{"put":{"summary":"Set another member's listen and talk channels (capability: member-tuning)","tags":["frequencies"],"description":"Owner and admins only (`member.setTuning`); company staff do not hold it. The body is a DELTA per side, applied removeAll, then remove, then add. The result is stored in the member's own tuning state and the same delta is pushed to them as `frequency.tuningSet`: a tuned client applies it at once in any mic mode, and one that is not tuned restores it at its next tune. All or nothing. Channel ids must be group channels of this frequency, except that `talk.remove` may name the member's own personal channel (TUNING_CHANNEL_INVALID). Where the member lacks access to an added channel, `grantAccess: false` refuses with TUNING_ACCESS_REQUIRED and `grantAccess: true` gives them the roster row first, published as an ordinary `channel.updated`; a force-mute is never lifted either way. Talk can never be ADDED on a broadcast station's default channel (TUNING_PROGRAM_CHANNEL). This is a switch the member can change back, not a lock: to keep somebody off a channel use its access controls or force-mute.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetMemberTuningBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberTuningResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`TUNING_ACCESS_REQUIRED` — PUT /frequencies/:id/members/:userId/tuning-state would put the member on a channel they may not use, and nothing was changed. details.channelIds lists the channels they lack access to: re-send with grantAccess: true to give it. details.forceMutedChannelIds lists talk channels they are force-muted on, which grantAccess never lifts: clear the force-mute first.\n\n`TUNING_CHANNEL_INVALID` — A channel id in a member tuning change is not one it may name: not a group channel of this frequency (talk.remove may also name the member's own personal channel). details.channelIds lists them. Nothing was changed.\n\n`TUNING_PROGRAM_CHANNEL` — A member tuning change tried to ADD talk on a broadcast station's default channel. A station is never armed remotely; whoever goes on the air switches it on themselves. Removing talk there is allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/members/{userId}":{"delete":{"summary":"Remove a member","tags":["frequencies"],"description":"Organization staff cannot be removed here: the staff sync would re-add them on the next org mutation, so the removal would silently un-happen. Remove them from the organization instead.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CANNOT_REMOVE_OWNER` — The owner cannot be removed from their own frequency/organization.\n\n`ORG_STAFF_MEMBER` — This member is organization staff — remove them from the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/bans":{"get":{"summary":"The frequency's ban list","tags":["frequencies"],"description":"Requires the ban capability (admin+). Includes automatic bans (origin `code_attempts`, minted after repeated wrong access codes, self-expiring) alongside admin bans — both liftable via DELETE.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FrequencyBan"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Ban a user from a frequency","tags":["frequencies"],"description":"Removes their membership (if any) and blocks every path back in — tune-by-number with any code, join requests, and invitations — until lifted. The target need not be a current member. The owner cannot be banned, and organization staff must be removed from the organization instead (the staff sync would re-add them). Re-banning updates the row in place; banning over an automatic ban upgrades it to an admin ban.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BanMemberBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FrequencyBan"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`USER_NOT_FOUND` — No user matches the supplied handle, Station ID or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CANNOT_BAN_OWNER` — The frequency's owner cannot be banned from it.\n\n`ORG_STAFF_MEMBER` — This member is organization staff — remove them from the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/bans/{userId}":{"delete":{"summary":"Lift a ban","tags":["frequencies"],"description":"Works on admin and automatic bans alike. Lifting does not restore membership — the user may simply join again.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/leave":{"post":{"summary":"Leave a frequency","tags":["frequencies"],"description":"The owner must transfer ownership first, and organization staff must leave the organization instead — see the note on remove.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`OWNER_CANNOT_LEAVE` — The owner cannot leave; transfer ownership or delete instead.\n\n`ORG_STAFF_MEMBER` — This member is organization staff — remove them from the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/users/me/invitations":{"get":{"summary":"Pending invitations addressed to the caller","tags":["frequencies"],"description":"Excludes the caller's own join requests, which appear to admins rather than to the requester.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReceivedInvitation"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invitations/{invitationId}/accept":{"post":{"summary":"Accept an invitation","tags":["frequencies"],"description":"404 covers unknown, already-answered, and not-addressed-to-you alike — the three are not distinguished, so an id cannot be probed.","parameters":[{"schema":{"type":"string"},"in":"path","name":"invitationId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationAccepted"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invitations/{invitationId}/decline":{"post":{"summary":"Decline an invitation","tags":["frequencies"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"invitationId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invitations/{invitationId}/approve":{"post":{"summary":"Approve a pending join request","tags":["frequencies"],"description":"For a publicly-listable frequency that does not auto-approve. Requires the invite capability on that frequency.","parameters":[{"schema":{"type":"string"},"in":"path","name":"invitationId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/favorites":{"get":{"summary":"The caller's preset slots","tags":["favorites"],"description":"Sorted by slot. Sparse — only set slots appear. Each row embeds the full frequency DTO so a preset button renders without a second call. Rows are returned even for frequencies the caller has since left or been banned from (the favorite is a pointer; membership still gates the audio, so tuning such a row re-asks for its access code).","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Favorite"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/favorites/{slot}":{"put":{"summary":"Set a preset slot","tags":["favorites"],"description":"Upsert: an occupied slot is overwritten. Requires current membership of the frequency — join it first (tune-by-number). Pushes `favorites.updated` to the account's other connections.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetFavoriteBodyInput"}}}},"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":24},"in":"path","name":"slot","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Favorite"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Clear a preset slot","tags":["favorites"],"description":"Idempotent — clearing an empty slot is a 204 too (no event is pushed for it). Pushes `favorites.updated` when a row was actually removed.","parameters":[{"schema":{"type":"integer","minimum":1,"maximum":24},"in":"path","name":"slot","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts":{"get":{"summary":"The caller's contacts","tags":["contacts"],"description":"Mutual contacts only. `status` is what THIS caller may observe — an invisible contact always reads `offline`. `maxContacts` is served rather than hardcoded so a client renders the real ceiling.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/requests":{"get":{"summary":"Outstanding contact requests, both directions","tags":["contacts"],"description":"Only `pending` rows. Terminal rows are retained server-side (a declined row is the decline cooldown) but are never listed.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactRequestList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Ask someone to be a contact","tags":["contacts"],"description":"Identify them by callsign, Station ID or email — prefer Station ID, since callsigns are not unique and an ambiguous one is rejected rather than guessed. Nothing is shared until they accept. A block in EITHER direction answers 403 CONTACT_BLOCKED without saying which way it runs.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identifier":{"type":"string","minLength":1,"maxLength":320,"description":"Callsign, Station ID or email — resolved exactly as an invite is (users/resolve.ts)."}},"required":["identifier"]}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`USER_NOT_FOUND` — No user matches the supplied handle, Station ID or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AMBIGUOUS_HANDLE` — Several users share that callsign — address them by Station ID or email instead.\n\n`ALREADY_CONTACTS` — You are already contacts with that person.\n\n`CONTACT_REQUEST_PENDING` — A request between you and that person is already outstanding — accept theirs, or wait for yours to be answered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/requests/{id}/accept":{"post":{"summary":"Accept a contact request","tags":["contacts"],"description":"Creates the mutual edge. Refused with CONTACT_LIMIT if EITHER list is full — the requester's list grows without their consent, so checking only the accepter would not be a cap.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CONTACT_LIMIT` — One of the two contact lists is full.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/requests/{id}/decline":{"post":{"summary":"Decline a contact request","tags":["contacts"],"description":"The requester is deliberately NOT notified — a decline notice is a second message from someone who has just said no. Their outgoing list stops showing it on the next read.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/requests/{id}":{"delete":{"summary":"Withdraw a contact request you sent","tags":["contacts"],"description":"Unlike a decline, the recipient IS notified: a withdrawn request must leave their inbox, or they act on something no longer offered.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}":{"delete":{"summary":"Remove a contact","tags":["contacts"],"description":"Mutual — you disappear from their list as they disappear from yours. A one-way remove would leave them receiving your presence while you no longer receive theirs.","parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/blocks":{"get":{"summary":"Accounts the caller has blocked","tags":["contacts"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlockedUserList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Block an account","tags":["contacts"],"description":"Destructive and transactional: removes the contact edge both ways and cancels any pending request in either direction. A block that left either standing would keep a door open that the block was meant to close.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identifier":{"type":"string","minLength":1,"maxLength":320,"description":"Callsign, Station ID or email — resolved exactly as an invite is (users/resolve.ts)."}},"required":["identifier"]}}}},"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`USER_NOT_FOUND` — No user matches the supplied handle, Station ID or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AMBIGUOUS_HANDLE` — Several users share that callsign — address them by Station ID or email instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/blocks/{userId}":{"delete":{"summary":"Lift a block","tags":["contacts"],"description":"Restores nothing. The contact edge was destroyed by the block; getting back in touch means asking again.","parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/preferences":{"put":{"summary":"Replace everything you have chosen about one contact","tags":["contacts"],"description":"Owner-private in full — **not one field here is ever returned to the person it describes**, which is what makes `allowMessages: false` a mute rather than an announcement. **A PUT, not a PATCH: the body carries the WHOLE block and an absent field is a reset to its default**, so two clients at different versions cannot merge two halves of a preference set. `ringTone` and `footsteps` take a library id from GET /sounds, the reserved `\"none\"`, or null — and null is not `\"none\"`: for a ring it means the default, for footsteps it means off. A `file:` path is refused; a sound on your own disk is machine-local and belongs in client state, because a Windows path arriving on a Mac resolves to nothing and falls back to the wrong sound with no error anywhere.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactPreferencesInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactPreferences"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/wave":{"post":{"summary":"Wave at a contact","tags":["contacts"],"description":"A greeting, not a message. Answers 204 whether or not it could be DELIVERED — a 'they are offline' response would be a presence oracle — and is NEVER buffered, because a wave arriving nine minutes late is worse than one that never arrives. An INVISIBLE recipient therefore receives nothing at all, exactly as an offline one does, and the 204 is unchanged. It is however REFUSED, 409, when the recipient is visibly Busy or Away: a wave needs somebody there to see it, and nothing is queued for later. That refusal only ever fires for a recipient the sender can already see, so it discloses nothing an offline or invisible recipient was hiding. One per pair per hour, and a refusal does not consume it; the 429 carries Retry-After.","parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CONTACT_BUSY` — The recipient's status is Busy, which refuses chats and waves outright — nothing is queued, so re-send later rather than expecting it to arrive. Like CALL_BUSY, returned ONLY when they are already visible to the sender: an invisible recipient reads as offline and their chat is accepted normally, because a refusal that only Busy could produce would be a presence oracle.\n\n`CONTACT_AWAY` — The recipient is Away and a wave needs somebody there to receive it — chats to an Away recipient are accepted and sit unread. Masked exactly like CONTACT_BUSY: only returned when they are already visible to the sender.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/messages":{"post":{"summary":"Send a 1:1 message","tags":["contacts"],"description":"A RELAY, not a mailbox: nothing is stored in the database, and what is buffered for an offline recipient is discarded after ten minutes. Both parties must be contacts. Refused with 409 CONTACT_BUSY when the recipient is visibly Busy — nothing is queued, so re-send later rather than expecting it to arrive; an Away recipient is fine and the message simply sits unread. An INVISIBLE recipient is treated exactly as an offline one: the 201 is identical, the message is buffered as normal and can be read whenever they look, but no live frame is pushed at them while they are hidden. `clientMessageId` makes the send idempotent for two minutes — send one, because a retry after a timeout would otherwise double-post and there is no history to correct it with.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string","minLength":1},"clientMessageId":{"type":["string","null"],"maxLength":64},"kind":{"description":"Default `text`. See contract §15, \"Message kinds\".","oneOf":[{"$ref":"#/components/schemas/ChatMessageKindInput"},{"type":"null"}]},"refId":{"type":["string","null"],"minLength":1,"maxLength":64,"description":"Required for reaction, edit and unsend: the message they point at."},"reaction":{"type":["string","null"],"minLength":1,"maxLength":16,"description":"Required for a reaction: the emoji."},"reactionRemove":{"description":"A reaction taking the sender's earlier one back.","type":["boolean","null"]},"replyTo":{"description":"Text only: the message being replied to, quoted.","oneOf":[{"$ref":"#/components/schemas/ChatReplyToInput"},{"type":"null"}]},"tz":{"description":"Text only: the sender's IANA time zone, when they choose to share it.","oneOf":[{"$ref":"#/components/schemas/ChatTimeZoneInput"},{"type":"null"}]}},"required":["body"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatSendResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CONTACT_BUSY` — The recipient's status is Busy, which refuses chats and waves outright — nothing is queued, so re-send later rather than expecting it to arrive. Like CALL_BUSY, returned ONLY when they are already visible to the sender: an invisible recipient reads as offline and their chat is accepted normally, because a refusal that only Busy could produce would be a presence oracle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Re-read a conversation's buffer","tags":["contacts"],"description":"The reconnect path — realtime frames are never replayed, so re-read after every socket gap with the highest `seq` you hold. Returns ONLY what is still buffered; this is not a history and there is none to page through.","parameters":[{"schema":{"default":0,"type":"integer","minimum":0},"in":"query","name":"since","required":false},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatMessageList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Forget a conversation","tags":["contacts"],"description":"What closing a chat window means: your copy of the buffer is deleted immediately rather than waiting out its TTL. One-sided on purpose — the other person's window is theirs.","parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/messages/ack":{"post":{"summary":"Acknowledge delivery up to a sequence","tags":["contacts"],"description":"Drains your buffer AND produces the sender's delivered receipt — one call, both jobs. Suppressed for an invisible recipient, whose messages simply read as still buffered to the sender.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"upTo":{"type":"integer","minimum":0}},"required":["upTo"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/typing":{"post":{"summary":"Tell a contact you are typing to them (or stopped)","tags":["contacts"],"description":"Live only: published as `contact.typing`, never buffered or stored, and the recipient expires it on its own after CHAT_TYPING_EXPIRES_SECONDS without a refresh, so send `typing: true` at most every CHAT_TYPING_MIN_INTERVAL_SECONDS while typing and `typing: false` on send or when the draft empties. ALWAYS 204: nothing is published while you are invisible, have typing indicators off, are muted by them, or they are Busy, and the answer does not say which.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"typing":{"type":"boolean"}},"required":["typing"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/messages/read":{"post":{"summary":"Tell a contact you have read their messages up to a position","tags":["contacts"],"description":"The \"Read\" receipt, separate from the ack (which only says your app HAS the message). Live only: published as `contact.read` and never stored, because the platform keeps no read state (owner rule). Send it when their messages have actually been on screen. ALWAYS 204: nothing is published while you are invisible or have read receipts off.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"upTo":{"type":"object","properties":{"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"seq":{"type":"integer","minimum":0}},"required":["sentAt","seq"]}},"required":["upTo"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/transfers/config":{"get":{"summary":"Limits and relay servers for chat file transfers","tags":["contacts"],"description":"The admin-set limits and the TURN relay the data channel must use (iceTransportPolicy \"relay\"). Signed in only: it carries a relay credential.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferConfig"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/files":{"post":{"summary":"Offer a file or voice message to a contact","tags":["contacts"],"description":"Creates a transfer RECORD and relays a message of kind `offer`. No bytes are sent: they go device to device after the recipient accepts, over a data channel forced through the TURN relay. Refused exactly as a chat message is (stranger 404, CONTACT_BLOCKED, CONTACT_BUSY), and silently not delivered to a recipient who muted messages from you.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":180},"size":{"type":"integer","minimum":1},"mime":{"default":"application/octet-stream","type":"string","maxLength":127},"voice":{"oneOf":[{"$ref":"#/components/schemas/ChatVoiceMetaInput"},{"type":"null"}]},"thumbnail":{"description":"An image's preview, made by the sender. Re-encoded by the server before relaying; dropped if it will not decode.","oneOf":[{"$ref":"#/components/schemas/ChatThumbnailInput"},{"type":"null"}]},"width":{"type":["integer","null"],"minimum":1,"maximum":100000},"height":{"type":["integer","null"],"minimum":1,"maximum":100000},"clientMessageId":{"type":["string","null"],"maxLength":64}},"required":["name","size"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"offerId":{"type":"string"},"id":{"type":"string"},"seq":{"type":"integer"},"sentAt":{"type":"string"},"expiresAt":{"type":"string"}},"required":["offerId","id","seq","sentAt","expiresAt"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CONTACT_BUSY` — The recipient's status is Busy, which refuses chats and waves outright — nothing is queued, so re-send later rather than expecting it to arrive. Like CALL_BUSY, returned ONLY when they are already visible to the sender: an invisible recipient reads as offline and their chat is accepted normally, because a refusal that only Busy could produce would be a presence oracle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`FILE_TOO_LARGE` — The offered file is larger than this service allows (GET /contacts/transfers/config names the limit), or a voice message is longer than allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Live transfers with a contact","tags":["contacts"],"description":"The reconcile after a socket gap or restart: every transfer between you and them that has not expired.","parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"transfers":{"type":"array","items":{"$ref":"#/components/schemas/TransferRecord"}}},"required":["transfers"],"additionalProperties":false}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/files/{offerId}/{action}":{"post":{"summary":"Accept, decline, cancel, retry, or report the end of a transfer","tags":["contacts"],"description":"Only the recipient accepts, declines or reports `complete` (it alone has checked the file's hash); either side may cancel or report `fail`. Both parties receive `contact.transfer`. A transfer that expired, never existed or is not yours is 404.\n\n`retry` (capability `transfer-retry`) is the recipient's, from `failed`: the transfer is `accepted` again as the NEXT `attempt`, and the sender's device holding the file starts again on that frame. Open your session before calling it, as for `accept`. Refused with `RETRY_LIMIT` once the transfer has used its retries.\n\n`fail` should name the `attempt` it is about in the body (absent means 1). A `fail` naming an attempt that is not the current one answers 200 with the record as it stands and changes nothing, so a late report from a finished attempt cannot end the retry that replaced it.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":["object","null"],"properties":{"attempt":{"type":"integer","minimum":1,"maximum":1000}}}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true},{"schema":{"type":"string","maxLength":64},"in":"path","name":"offerId","required":true},{"schema":{"type":"string","enum":["accept","decline","cancel","complete","fail","retry"]},"in":"path","name":"action","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TransferRecord"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`TRANSFER_NOT_ALLOWED` — That transfer cannot do that from its current state, or you are not the side that may (only the recipient accepts, declines or reports complete).\n\n`TOO_MANY_TRANSFERS` — You already have as many transfers running as this service allows at once. Wait for one to finish.\n\n`RETRY_LIMIT` — This transfer has been retried as many times as the service allows (GET /contacts/transfers/config names the number). The sender has to offer the file again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/contacts/{userId}/files/{offerId}/signal":{"post":{"summary":"Relay WebRTC signalling for an accepted transfer","tags":["contacts"],"description":"Forwarded to the other party as `contact.transferSignal`, with every ICE candidate that is not of type `relay` REMOVED and relay candidates' related address zeroed, from the SDP and from trickled candidates alike. That is how the IP mask is enforced rather than requested: an address the server never forwards cannot be learned. Always 204.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":["object","null"],"properties":{"type":{"type":"string","enum":["offer","answer"]},"sdp":{"type":"string","maxLength":20000}},"required":["type","sdp"]},"candidate":{"type":["object","null"],"properties":{"candidate":{"type":"string","maxLength":1000},"sdpMid":{"type":["string","null"],"maxLength":64},"sdpMLineIndex":{"type":["integer","null"]}},"required":["candidate","sdpMid","sdpMLineIndex"]},"attempt":{"description":"The attempt this signal belongs to (absent means 1). One naming any attempt but the current one is dropped.","type":"integer","minimum":1,"maximum":1000}}}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"userId","required":true},{"schema":{"type":"string","maxLength":64},"in":"path","name":"offerId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups":{"get":{"summary":"Your group chats","tags":["contacts"],"description":"Every active group you are in, with its members' labels. Re-read after a socket gap: a missed `chat.membership` has no next edge.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroupList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Add somebody to a chat","tags":["contacts"],"description":"Adds one of YOUR contacts to a chat you are in, either a 1:1 (`from: {peerUserId}`) or a group (`from: {groupId}`). The new member need not be anybody else's contact. The chat MOVES: its identity is its member set, so the result names a group derived from the new set, and a set that has talked before gets back its own id (and so its history). Every member, old and new, is sent `chat.membership`. A 1:1 between contacts is never removed by this. Refused with CONTACT_BLOCKED when the new member and anybody already there have blocked each other, and with CHAT_CHANGED when somebody else changed the group first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"from":{"$ref":"#/components/schemas/ChatConversationRefInput"},"addUserId":{"type":"string"}},"required":["from","addUserId"]}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroupChangeResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CHAT_CHANGED` — That group chat's members changed while you were acting on it (somebody else added a member or left first), so the group you named no longer exists. A `chat.membership` frame names where it went; act on that one.\n\n`CHAT_GROUP_FULL` — A group chat holds at most CHAT_GROUP_MAX_MEMBERS people.\n\n`ALREADY_IN_CHAT` — That person is already in this chat.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}":{"get":{"summary":"One group chat","tags":["contacts"],"description":"Its members' labels (handle and Station ID, never a name). 404 for a group you are not in, the same as for one that does not exist.","parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroup"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/leave":{"post":{"summary":"Leave a group chat","tags":["contacts"],"description":"Nobody can remove anybody else from a group chat (owner rule): leaving is the only way out, and only for yourself. The chat moves to the smaller set: the 1:1 when the two left are contacts, a group of the remaining people otherwise, or nothing when fewer than two remain. Everybody, the person leaving included, is sent `chat.membership` with `change: \"left\"`.","parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroupChangeResult"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CHAT_CHANGED` — That group chat's members changed while you were acting on it (somebody else added a member or left first), so the group you named no longer exists. A `chat.membership` frame names where it went; act on that one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/messages":{"post":{"summary":"Send a message to a group chat","tags":["contacts"],"description":"The 1:1 relay's rules, applied to each member: nothing is stored, a member who is visibly Busy is SKIPPED (listed in `skipped`, nothing held for them), an invisible member is buffered like an offline one with no live push, and a member who muted you, or with whom you have a block, silently does not receive it. Text, reactions, edits, unsends and replies. A file or voice message is offered through `POST /chats/groups/:groupId/files`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string","minLength":1},"clientMessageId":{"type":["string","null"],"maxLength":64},"kind":{"description":"Default `text`. See contract §15, \"Message kinds\".","oneOf":[{"$ref":"#/components/schemas/ChatMessageKindInput"},{"type":"null"}]},"refId":{"type":["string","null"],"minLength":1,"maxLength":64,"description":"Required for reaction, edit and unsend: the message they point at."},"reaction":{"type":["string","null"],"minLength":1,"maxLength":16,"description":"Required for a reaction: the emoji."},"reactionRemove":{"description":"A reaction taking the sender's earlier one back.","type":["boolean","null"]},"replyTo":{"description":"Text only: the message being replied to, quoted.","oneOf":[{"$ref":"#/components/schemas/ChatReplyToInput"},{"type":"null"}]},"tz":{"description":"Text only: the sender's IANA time zone, when they choose to share it.","oneOf":[{"$ref":"#/components/schemas/ChatTimeZoneInput"},{"type":"null"}]}},"required":["body"]}}}},"parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroupSendResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Re-read a group chat's buffer","tags":["contacts"],"description":"The reconnect path, as for a 1:1: only what is still buffered, never a history.","parameters":[{"schema":{"default":0,"type":"integer","minimum":0},"in":"query","name":"since","required":false},{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatMessageList"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Forget a group chat's buffer","tags":["contacts"],"description":"Deletes your copy of the buffer now. One-sided, as for a 1:1. Does not leave the group.","parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/files":{"post":{"summary":"Offer a file or voice message to a group chat","tags":["contacts"],"description":"One message of kind `offer` (its `offer.group` is true), and one transfer PER MEMBER, because the bytes go device to device between two people. Each member's transfer is `groupOfferLegId(offerId, theirUserId)` and is then run exactly as a 1:1 transfer is, through `/contacts/:userId/files/:offerId/...` (those routes answer the two parties to a transfer whether or not they are contacts). Delivery follows the group message rules: a visibly Busy member is SKIPPED (named in `skipped`, no transfer made for them), an invisible member is buffered with no push, and a member who muted you or with whom you have a block silently does not receive it. 409 CONTACT_BUSY when every other member is visibly Busy, since nobody could accept it. 422 FILE_TOO_LARGE above the GROUP limit (`maxGroupFileBytes` in `GET /contacts/transfers/config`, lower than the 1:1 one because the sender uploads a copy per member).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":180},"size":{"type":"integer","minimum":1},"mime":{"default":"application/octet-stream","type":"string","maxLength":127},"voice":{"oneOf":[{"$ref":"#/components/schemas/ChatVoiceMetaInput"},{"type":"null"}]},"thumbnail":{"description":"An image's preview, made by the sender. Re-encoded by the server before relaying; dropped if it will not decode.","oneOf":[{"$ref":"#/components/schemas/ChatThumbnailInput"},{"type":"null"}]},"width":{"type":["integer","null"],"minimum":1,"maximum":100000},"height":{"type":["integer","null"],"minimum":1,"maximum":100000},"clientMessageId":{"type":["string","null"],"maxLength":64}},"required":["name","size"]}}}},"parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChatGroupFileOfferResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CONTACT_BUSY` — The recipient's status is Busy, which refuses chats and waves outright — nothing is queued, so re-send later rather than expecting it to arrive. Like CALL_BUSY, returned ONLY when they are already visible to the sender: an invisible recipient reads as offline and their chat is accepted normally, because a refusal that only Busy could produce would be a presence oracle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`FILE_TOO_LARGE` — The offered file is larger than this service allows (GET /contacts/transfers/config names the limit), or a voice message is longer than allowed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Live transfers in a group chat","tags":["contacts"],"description":"The reconcile after a socket gap or restart: every transfer offered in this group that you send or receive and that has not expired. A transfer you have not been offered (its message never reached you) is not listed.","parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"transfers":{"type":"array","items":{"$ref":"#/components/schemas/TransferRecord"}}},"required":["transfers"],"additionalProperties":false}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/messages/ack":{"post":{"summary":"Acknowledge delivery in a group chat","tags":["contacts"],"description":"Drains your buffer and tells the other members (`chat.groupAck`). Suppressed while you are invisible.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"upTo":{"type":"integer","minimum":0}},"required":["upTo"]}}}},"parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/typing":{"post":{"summary":"Tell a group chat you are typing (or stopped)","tags":["contacts"],"description":"As the 1:1 typing signal: live only, ALWAYS 204, nothing published while you are invisible or have typing indicators off, and never to a member who muted you or is Busy.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"typing":{"type":"boolean"}},"required":["typing"]}}}},"parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/chats/groups/{groupId}/messages/read":{"post":{"summary":"Tell a group chat you have read up to a position","tags":["contacts"],"description":"As the 1:1 read receipt: live only, never stored, ALWAYS 204, nothing published while you are invisible or have read receipts off.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"upTo":{"type":"object","properties":{"sentAt":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"},"seq":{"type":"integer","minimum":0}},"required":["sentAt","seq"]}},"required":["upTo"]}}}},"parameters":[{"schema":{"type":"string","maxLength":64},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls":{"post":{"summary":"Ring one or more contacts","tags":["calls"],"description":"Returns the callId, the ephemeral frequency and its single channel, the ring deadline, the full participant roster and the tone the caller should play. Tune immediately: the caller joins and starts transmitting while it rings, so the container is already up when somebody answers. There is still no ringing acknowledgement — every invitee reads `ringing` whether or not their ring was delivered, because 'it is ringing' would reveal that they hold a live socket. An INVISIBLE invitee is never rung: their invitation is created in full, reaches nobody and times out, which is what a call to somebody offline does. Refused with `CALL_UNAVAILABLE` only when NO invitee is reachable, so a group call survives an offline member and an unreachable one still cannot be identified within a roster. The frequency is minted before anything about the invitees is known, so it says nothing about them.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"toUserIds":{"minItems":1,"maxItems":15,"type":"array","items":{"type":"string"},"description":"Everybody to ring. One id places an ordinary 1:1 call."}},"required":["toUserIds"]}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallPlaced"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CALL_BUSY` — That person's ring queue is full, or their status is Busy. Returned ONLY when they are already visible to the caller. Distinct from CALL_UNAVAILABLE, which answers for a callee who reads `offline`: the two are never both applicable, because `busy` is by definition not `offline`, so a caller can tell 'they are here and cannot take it' from 'they are not here' — and can already tell both from their own contact list.\n\n`CALL_UNAVAILABLE` — Nobody the call was addressed to is reachable — every invitee reads `offline` to this caller. Answered immediately so the caller hears a busy signal instead of a 45-second ring-out at a closed app. This is NOT a presence oracle: it is derived from the same `visibleStatuses` read that already answers the caller's contact list, so an invisible callee produces this code at this speed exactly as a genuinely offline one does, and a blocked-or-muted callee who is offline is indistinguishable from either. A call with even one reachable invitee is placed as normal, and the unreachable ones are masked exactly as before.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/participants":{"post":{"summary":"Ring somebody into a call already in progress","tags":["calls"],"description":"Needs no new container and no renegotiation for anybody already in the room. Any participant who has ANSWERED may invite — not only the host — and the invitee's `call.ringing` names whoever actually invited them. An invitee blocked by any current participant is screened exactly as they would be on a fresh call. `CALL_UNAVAILABLE` when none of the people named in THIS request is reachable; the call itself carries on regardless.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"toUserIds":{"minItems":1,"maxItems":15,"type":"array","items":{"type":"string"},"description":"Everybody to ring. One id places an ordinary 1:1 call."}},"required":["toUserIds"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallState"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`CONTACT_BLOCKED` — A block exists between the two accounts, in one direction or the other. Deliberately does not say WHICH — telling a blocked person they were blocked is an invitation to route around it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`CALL_BUSY` — That person's ring queue is full, or their status is Busy. Returned ONLY when they are already visible to the caller. Distinct from CALL_UNAVAILABLE, which answers for a callee who reads `offline`: the two are never both applicable, because `busy` is by definition not `offline`, so a caller can tell 'they are here and cannot take it' from 'they are not here' — and can already tell both from their own contact list.\n\n`CALL_UNAVAILABLE` — Nobody the call was addressed to is reachable — every invitee reads `offline` to this caller. Answered immediately so the caller hears a busy signal instead of a 45-second ring-out at a closed app. This is NOT a presence oracle: it is derived from the same `visibleStatuses` read that already answers the caller's contact list, so an invisible callee produces this code at this speed exactly as a genuinely offline one does, and a blocked-or-muted callee who is offline is indistinguishable from either. A call with even one reachable invitee is placed as normal, and the unreachable ones are masked exactly as before.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/accept":{"post":{"summary":"Answer a call","tags":["calls"],"description":"Returns the ephemeral frequency and its single channel. `audioMode` is always open_mic — a call is full duplex, and is not gated by the console's talk mode.","parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallAccepted"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/decline":{"post":{"summary":"Decline a call","tags":["calls"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}":{"delete":{"summary":"Leave a call","tags":["calls"],"description":"Means 'I am out' — cancels while ringing, hangs up while active. One route; the state decides. Any participant may call it. The CALL ends when fewer than two people are left in it, so in a group the others carry on.","parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"The current state of a call","tags":["calls"],"description":"The reseed primitive — realtime frames are never replayed, so re-read this after a socket gap rather than assuming a call is still up.","parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallState"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/participants/{userId}":{"delete":{"summary":"Drop somebody from a call","tags":["calls"],"description":"Host only, and only while THREE people are present. On a two-person call 'drop them' is hanging up, which is `DELETE /calls/:callId`; refusing here keeps `removed` from ever being the reason the call ends for the people left behind. The removed client receives `call.ended` with reason `removed` and nobody else does: everybody remaining gets the ordinary `call.participant` frame saying that person left. A masked participant is never told, here as everywhere.","parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallState"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/hold":{"post":{"summary":"Put yourself on hold, or come back","tags":["calls"],"description":"Hold is not a separate audio path: pause your producer and drop your listen set on the call frequency, which your client already knows how to do. This records it so the other participants can be shown why you went quiet, and it is what lets you answer a second call without dropping the first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"held":{"type":"boolean"}},"required":["held"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallState"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/destinations":{"get":{"summary":"Where this call can be handed off to","tags":["calls"],"description":"Host only. A flat list of CHANNELS, each named with its frequency, because the operator is choosing a channel. Only destinations that actually work appear: either everybody on the call can already reach it (`all_present`), or the caller can grant the ones who cannot (`grant_required`, with `grantUserIds` naming who would be let in permanently). Anything else is absent rather than listed with a reason. This is a picker's data and never an authorization — `POST /calls/:callId/move` re-derives every one of these decisions, because this answer can be seconds stale by the time the button is pressed.","parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallMoveDestinations"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/move":{"post":{"summary":"End the call and land everybody on a frequency","tags":["calls"],"description":"Host only. `grantAccess` decides what happens to participants who cannot reach the target: false and the move is REFUSED with `MOVE_GRANT_REQUIRED` rather than silently leaving them behind; true and they are made permanent members of the frequency, and given a roster row on the channel unless it is the default Public one. The move is also a grant of TALK, on every move whether or not anybody needed access: on any destination other than the default Public channel or a `broadcast_open` one, every moved participant arrives with `canTalk` on that channel. A mover holding `channel.manageMembers` there (a frequency admin, or station staff at screener and up) also raises an existing listen-only roster row; without it, missing rows are created only on an `open` channel (what each person could do by joining it themselves) and existing rows are left as they are. Use `GET /calls/:callId/destinations` to offer only the places that work. A banned participant is skipped in silence either way, and the response never names who was left out. `call.movedToFrequency` is published AFTER every grant commits and BEFORE the call is torn down, so an arrival's client already has its membership when it acts on the frame.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"frequencyId":{"type":"string"},"channelId":{"type":"string"},"grantAccess":{"type":"boolean","description":"Required, with no default: 'hand out permanent memberships' is not something to infer from an absent field. False is the safe read of an old client's intent, but an old client would then get a silent partial move, which is what this replaces."}},"required":["frequencyId","channelId","grantAccess"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`MOVE_GRANT_REQUIRED` — POST /calls/:callId/move would leave somebody behind, and the request did not ask to grant them access. Re-send with grantAccess: true if the caller may grant it, or pick a destination everybody can already reach — GET /calls/:callId/destinations lists only the ones that work. details carries wouldGrantCount. It deliberately does NOT name who is missing: a banned participant is skipped silently, and naming the gap would leak their ban by elimination.\n\n`FREQUENCY_SUSPENDED` — The FREQUENCY is off the air — a platform or billing action against the station itself, not against the caller (contrast BANNED, which is about one person). `details.suspensionClass` is non_payment | voluntary | conduct | legal and `details.reinstatable` says whether the owner can lift it from the app; for non_payment and voluntary that means holding a live subscription again and calling POST /frequencies/{id}/reinstate. Nothing has been deleted: every station field survives a suspension, so keep showing the station and refuse only to tune it. ONE exception, and it is the only class that is not indefinite — a `voluntary` suspension raised by cancelling during the free trial also DELETES the station when that trial would have ended, because the customer is reclaiming a station they never paid for; reinstating at any point before then cancels the deletion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/{callId}/merge":{"post":{"summary":"Merge another call into this one","tags":["calls"],"description":"The phone's Merge. `:callId` SURVIVES and `fromCallId` is absorbed: everybody who had answered the absorbed call joins this one's frequency and channel, and the absorbed call ends with reason `merged`. You must have answered BOTH — only somebody in both rooms may join them — and both must be real conversations, because merging a call that is still dialing would cancel its ring as a side effect. Somebody still ringing in the absorbed call does NOT come across; invite them into the survivor if you still want them. `call.merged` is published to the absorbed call's participants BEFORE it is torn down.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"fromCallId":{"type":"string","description":"The call to absorb. It ends; :callId survives."}},"required":["fromCallId"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"callId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CallState"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/history":{"get":{"summary":"The phone's Recents","tags":["calls"],"description":"One row per CALL PER PERSON, newest first, and `outcome` describes the READER rather than the call: the same conversation is `no_answer` to whoever placed it and `missed` to whoever never picked up. A call the reader was never told about has no row, which is the ring-acknowledgement rule applied after the fact. Entries are kept for a bounded window and then swept.","parameters":[{"schema":{"default":50,"type":"integer","minimum":1,"maximum":200},"in":"query","name":"limit","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CallLogEntry"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/calls/current":{"get":{"summary":"Every call this user is on or ringing","tags":["calls"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CallState"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/phone-numbers/available":{"get":{"summary":"Numbers for sale in an area code","tags":["phone-numbers"],"description":"Capability `phone-numbers`. US local numbers the carrier offers in `areaCode`, voice-enabled. A listing is not a hold: buy one with `POST /frequencies/:id/phone-numbers` and expect `409 PHONE_NUMBER_UNAVAILABLE` if somebody else got there first.","parameters":[{"schema":{"type":"string","pattern":"^[2-9]\\d{2}$"},"in":"query","name":"areaCode","required":true},{"schema":{"default":10,"type":"integer","minimum":1,"maximum":25},"in":"query","name":"limit","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AvailablePhoneNumber"}}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`CARRIER_ERROR` — The carrier refused or did not answer. Nothing was changed on our side unless the message says otherwise; retry later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`PHONE_NOT_CONFIGURED` — This deployment has no carrier configured, so no phone number can be searched, bought or released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/phone-numbers":{"get":{"summary":"The numbers on a frequency","tags":["phone-numbers"],"description":"Capability `phone-numbers`. Owner and admins. Released numbers are not listed; a number mid-release (`releasing`) is.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PhoneNumber"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Buy a number and put it on the frequency as a phone channel","tags":["phone-numbers"],"description":"Capability `phone-numbers`. Owner only. Creates the `phone` channel (roster managed; the buyer is on it with talk), the line's synthetic user, and the number, then orders it at the carrier. Claims a paid phone-line slot when billing is enforced (`402 PHONE_SUBSCRIPTION_REQUIRED` otherwise). A carrier refusal leaves nothing behind: `409 PHONE_NUMBER_UNAVAILABLE` means search again, `502 CARRIER_ERROR` means retry later.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BuyPhoneNumberBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumber"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`PHONE_SUBSCRIPTION_REQUIRED` — Buying a number needs a paid, unclaimed phone line subscription (capability phone-numbers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`FREQUENCY_SUSPENDED` — The FREQUENCY is off the air — a platform or billing action against the station itself, not against the caller (contrast BANNED, which is about one person). `details.suspensionClass` is non_payment | voluntary | conduct | legal and `details.reinstatable` says whether the owner can lift it from the app; for non_payment and voluntary that means holding a live subscription again and calling POST /frequencies/{id}/reinstate. Nothing has been deleted: every station field survives a suspension, so keep showing the station and refuse only to tune it. ONE exception, and it is the only class that is not indefinite — a `voluntary` suspension raised by cancelling during the free trial also DELETES the station when that trial would have ended, because the customer is reclaiming a station they never paid for; reinstating at any point before then cancels the deletion.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`PHONE_NUMBER_UNAVAILABLE` — The number was taken between the search and the purchase, or is already ours. Search again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`CARRIER_ERROR` — The carrier refused or did not answer. Nothing was changed on our side unless the message says otherwise; retry later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`PHONE_NOT_CONFIGURED` — This deployment has no carrier configured, so no phone number can be searched, bought or released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/phone-numbers/{phoneNumberId}":{"delete":{"summary":"Release a number","tags":["phone-numbers"],"description":"Capability `phone-numbers`. Owner only. Gives the number back to the carrier, deletes its channel and its line user, and frees the phone-line slot. Idempotent: releasing a number already mid-release retries the carrier. `502 CARRIER_ERROR` leaves the number `releasing`; call again later.","parameters":[{"schema":{"type":"string"},"in":"path","name":"phoneNumberId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"502":{"description":"`CARRIER_ERROR` — The carrier refused or did not answer. Nothing was changed on our side unless the message says otherwise; retry later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`PHONE_NOT_CONFIGURED` — This deployment has no carrier configured, so no phone number can be searched, bought or released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs":{"post":{"summary":"Create a company","tags":["organizations"],"description":"The caller becomes its owner. The company's name is `companyContact.companyName`, and the country must be on the platform allowlist. Stations are created separately, as broadcast frequencies that name this company as their owner.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`UNSUPPORTED_COUNTRY` — The supplied country is not in the platform's supported-country allowlist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/mine":{"get":{"summary":"Companies the caller is staff of","tags":["organizations"],"description":"The only way to reach an existing company — company names are never a lookup key. Contact fields are populated only where the caller is manager or above; a dj sees the same keys as null.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Organization"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}":{"get":{"summary":"One company, with its staff and stations","tags":["organizations"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationDetail"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Update the company's name or contact details","tags":["organizations"],"description":"Manager or above. Contact changes are mirrored to the billing customer record. Station branding lives on the station — see PATCH /frequencies/{id}/station.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`UNSUPPORTED_COUNTRY` — The supplied country is not in the platform's supported-country allowlist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Delete a company","tags":["organizations"],"description":"Owner only, and only once it owns no stations.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_HAS_FREQUENCIES` — The organization still owns frequencies; delete or reassign them first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}/logo":{"get":{"summary":"Fetch a station logo by company id (compatibility alias)","tags":["organizations"],"description":"Serves the logo of this company's ONE station, for URLs minted before station identity moved onto the frequency (2026-08-02). 404s when the company owns no station or more than one — pick a station and use GET /frequencies/{id}/logo, which is the canonical route.","parameters":[{"schema":{"type":"string","enum":["png","webp","original"]},"in":"query","name":"format","required":false},{"schema":{"type":"string"},"in":"query","name":"v","required":false,"description":"The station's logoVersion. Only a cache key — not validated."},{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."}],"responses":{"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}/members":{"post":{"summary":"Add on-air staff","tags":["organizations"],"description":"Manager or above. Identify the user by callsign, Station ID or email — prefer Station ID, since callsigns are not unique.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddOrgMemberBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string"},"role":{"$ref":"#/components/schemas/OrgRole"}},"required":["userId","role"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`USER_NOT_FOUND` — No user matches the supplied handle, Station ID or email.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`AMBIGUOUS_HANDLE` — Several users share that callsign — address them by Station ID or email instead.\n\n`ALREADY_MEMBER` — The user is already a member of this frequency/organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}/members/{userId}/role":{"patch":{"summary":"Change a staff member's role","tags":["organizations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeOrgRoleBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CANNOT_CHANGE_OWNER` — The owner's role cannot be changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}/members/{userId}":{"delete":{"summary":"Remove staff, or leave the station","tags":["organizations"],"description":"Manager or above to remove somebody else; anyone may remove themselves.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true,"description":"Organization (company) id."},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`CANNOT_REMOVE_OWNER` — The owner cannot be removed from their own frequency/organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/invite-links":{"post":{"summary":"Mint an invite link for a frequency","tags":["frequencies"],"description":"Requires the invite capability. A link granting `admin` is a standing credential for co-running the frequency, so only the owner may mint one. Channel targeting (capability `invite-link-channel-targeting`): `channelId` must name a group channel of this frequency; `canTalk: true` additionally requires an `expiresAt` no more than 7 days out — a talk link is a forwardable transmit grant and the platform enforces its bound. At most 50 live links per frequency (409 `INVITE_LINK_LIMIT`); reuse a standing live link before minting.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteLinkBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteLink"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`INVITE_LINK_LIMIT` — The frequency (or organization) already carries the maximum number of live invite links — revoke one or let one expire.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Invite links on a frequency","tags":["frequencies"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InviteLink"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/orgs/{id}/invite-links":{"post":{"summary":"Mint an invite link for a station's staff","tags":["organizations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgInviteLinkBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteLink"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`INVITE_LINK_LIMIT` — The frequency (or organization) already carries the maximum number of live invite links — revoke one or let one expire.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"get":{"summary":"Invite links on a station","tags":["organizations"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InviteLink"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invite-links/{linkId}":{"delete":{"summary":"Revoke an invite link","tags":["frequencies"],"description":"Authorised against whichever side the link belongs to: frequency invite capability, or org manager.","parameters":[{"schema":{"type":"string"},"in":"path","name":"linkId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invite-links/preview":{"get":{"summary":"Preview an invite before signing up (anonymous)","tags":["frequencies"],"description":"The signup onramp: a brand-new invitee opens a link before they have an account, sees what they would be joining, signs up, and the client redeems the stashed code after auth. Discloses only the display identity of the target. Unknown, expired, revoked and exhausted codes are indistinguishable (`INVITE_INVALID`). The code rides the QUERY STRING, not the path: it is a bearer credential, and both edge and app log redaction key on query parameter names (2026-08-19).","parameters":[{"schema":{"type":"string"},"in":"query","name":"code","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteLinkPreview"}}}},"404":{"description":"`INVITE_INVALID` — The invite or invite link is unknown, expired, revoked, or fully used.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/invite-links/redeem":{"post":{"summary":"Redeem an invite code","tags":["frequencies"],"description":"Idempotent per user: redeeming a link already redeemed succeeds without consuming another use. A frequency ban blocks redemption (403 `BANNED`, contract §1) — including re-redemption of a link redeemed before the ban.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedeemInviteBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteRedeemResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`BANNED` — This user is banned from the target frequency — by its admins, or automatically after too many wrong access-code attempts (those bans expire on their own). Joining, tuning by number and invitations are all refused until the ban is lifted or lapses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`INVITE_INVALID` — The invite or invite link is unknown, expired, revoked, or fully used.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/channels":{"get":{"summary":"Channels on a frequency","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Channel"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Create a group channel","tags":["channels"],"description":"Only frequency members can be seeded onto the roster; the creator is always included even if left out of the request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateChannelBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Channel"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/board":{"get":{"summary":"The caller's board: channels without member lists","tags":["channels"],"description":"Capability `frequency-board`. Every channel the caller may see, each with a member COUNT and the caller's own standing on it (`viewer`), in place of the id lists `GET /frequencies/:id/channels` carries. This is the read for a client that DRAWS a frequency; the lists are for a screen that edits them. Read it on tune and after a realtime reconnect, then keep it current from `channel.created` / `channel.updated` / `channel.deleted` and `membership.updated` / `membership.removed`, which carry the change (contract section 10b). `kind=group` returns the group channels only, the re-read after a `group.*` event.","parameters":[{"schema":{"type":"string","enum":["group"]},"in":"query","name":"kind","required":false},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FrequencyBoard"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/board/channels/{channelId}":{"get":{"summary":"One channel of the caller's board","tags":["channels"],"description":"Capability `frequency-board`. The re-read for a client named in a channel event's `affectedUserIds` (or when that field is null): its own standing on one channel. 404 when the channel is gone or is a managed channel the caller may not see, which a board treats the same way: remove it.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BoardChannel"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}/members/{userId}":{"put":{"summary":"Add one member to a channel","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove one member from a channel","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Set a member's talk-vs-listen designation","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTalkPermissionBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}/members":{"put":{"summary":"Add several members at once","tags":["channels"],"description":"Ids that are not members of the frequency are skipped rather than rejected; `added` reports how many were actually applied.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkChannelMembersBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkMembersAdded"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove several members at once","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkChannelMembersBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}":{"patch":{"summary":"Rename a channel or change its talk permission","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateChannelBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Channel"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Delete a channel","tags":["channels"],"description":"Neither a personal channel nor the default Public channel can be deleted.","parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}/now-playing":{"patch":{"summary":"Set or clear the channel's now-playing tag","tags":["channels"],"description":"Authorized by transmit eligibility on the channel (the on-air DJ), not by admin capability. An empty string clears the tag.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateChannelNowPlayingBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Channel"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}/force-mute":{"post":{"summary":"The admin mute on one channel: mute or unmute a member (capability: member-force-mute)","tags":["channels"],"description":"Moderation: takes effect on the media node immediately, independent of the member's own transmit toggle, and never touches their talk channels. Owner and admins (`channel.forceMute`) set and lift it; the muted member may LIFT it on themselves (`userId` their own, `muted: false`) but never set it. Published as `channel.forceMute` with `byUserId`. The frequency-wide form is `POST /frequencies/:id/members/:userId/force-mute`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForceMuteBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/frequencies/{id}/groups":{"get":{"summary":"Groups on a frequency","tags":["channels"],"description":"A group is a reusable set of members that can be linked to channels. A group with `organizationId` set is derived from station staff and is read-only.","parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Group"}}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Create a group","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Group"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/groups/{groupId}":{"patch":{"summary":"Rename a group","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateGroupBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Group"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Delete a group","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/groups/{groupId}/members/{userId}":{"put":{"summary":"Add one member to a group","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove one member from a group","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true},{"schema":{"type":"string"},"in":"path","name":"userId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/groups/{groupId}/members":{"put":{"summary":"Add several members to a group","tags":["channels"],"description":"Ids that are not members of the frequency are skipped rather than rejected; `added` reports how many were applied.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkGroupMembersBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkMembersAdded"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Remove several members from a group","tags":["channels"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkGroupMembersBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/channels/{channelId}/groups/{groupId}":{"put":{"summary":"Link a group to a channel","tags":["channels"],"description":"Every member of the group gains access to the channel, and stays in sync as the group's membership changes.","parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.\n\n`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Unlink a group from a channel","tags":["channels"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Default Response"},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.\n\n`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"patch":{"summary":"Set a linked group's talk-vs-listen designation","tags":["channels"],"description":"Applies uniformly to every member of that group on that channel.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTalkPermissionBodyInput"}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"channelId","required":true},{"schema":{"type":"string"},"in":"path","name":"groupId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORG_MANAGED_GROUP` — This group's membership is derived from its organization — edit the organization instead.\n\n`PERSONAL_CHANNEL_IMMUTABLE` — A personal channel cannot be edited or have its membership changed.\n\n`DEFAULT_CHANNEL_IMMUTABLE` — The frequency's default channel cannot be renamed, moved or deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/config":{"get":{"summary":"Public billing configuration","tags":["billing"],"description":"Anonymous. What a checkout surface needs before sign-in: whether this deployment takes payments, the Stripe publishable key, authoritative plan pricing, and the trial length. Serves the frequencytalk.com subscribe page and any client app's checkout.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingPublicConfig"}}}}}}},"/v1/billing/trial-eligibility":{"get":{"summary":"Would the caller's next subscription start with the free trial","tags":["billing"],"description":"The caller's own answer to the trial rule `/billing/subscribe` applies, computed by the same function, so a client can offer the free trial only to people who will actually get it. The trial is once per person: owning any frequency, or any past or present subscription (canceled included) on the caller's own billing account or a company they manage, means the next subscription is charged immediately. `cache-control: no-store`; ask again before showing checkout rather than holding the answer. Capability `trial-eligibility`.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingTrialEligibility"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/setup-intent":{"post":{"summary":"Begin checkout: mint a SetupIntent","tags":["billing"],"description":"Step 1 of the in-app checkout. Confirm the returned secret with the Stripe Payment Element; no money moves here. Card fields are rendered and confirmed by Stripe.js — the card number never reaches this API. There is no hosted Checkout Session alternative, so a client that cannot host a web context should send the operator to the website instead.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSetupIntentBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupIntentCreated"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscribe":{"post":{"summary":"Complete checkout: create the subscription","tags":["billing"],"description":"Step 2, after `confirmSetup` succeeded client-side. The server re-reads the SetupIntent from Stripe and pins it to the caller's customer — it never trusts the client's word. Synchronous: the response carries the final state, no webhook wait. Idempotent per intent, so a double-submit returns the same subscription. The free trial applies only to new customers (no owned frequency and no prior subscription anywhere they bill); everyone else is charged immediately.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompleteSubscriptionBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscribeResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.\n\n`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.\n\n`SETUP_NOT_COMPLETED` — Card setup has not finished for this billing account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/status":{"get":{"summary":"Everything billing the caller can see","tags":["billing"],"description":"The caller's personal account plus every organization they manage. `enforced: false` means creation is currently free on this deployment.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingStatus"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/cancel":{"post":{"summary":"Cancel a subscription at period end","tags":["billing"],"description":"Schedules cancellation for the end of the current period (or trial) — access continues until then, and no further charges occur. Idempotent; `resume` undoes it any time before the period actually ends.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionMutation"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/resume":{"post":{"summary":"Undo a scheduled cancellation","tags":["billing"],"description":"Clears `cancelAtPeriodEnd` so the subscription renews normally. Only works before the period ends — a fully-ended subscription needs a new checkout.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionMutation"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`SUBSCRIPTION_NOT_RESUMABLE` — The subscription has fully ended; start a new subscription instead of resuming.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/upgrade/start":{"post":{"summary":"Begin an Operator→Broadcast upgrade","tags":["billing"],"description":"Attaches the frequency's future station to one of your companies (organizationId) or creates a new one from companyContact, and returns a SetupIntent on that company's billing account plus a price preview: the already-paid Operator month is credited in full against the first Broadcast invoice; a still-running trial carries over instead (nothing was paid, so no credit). The station name/callsign are checked for availability here but not reserved — the station itself is created by /upgrade/complete. Abandoning checkout: DELETE /orgs/:id removes a company this call created (`organizationMinted`).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpgradeSubscriptionStartBodyInput"}}}},"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanChangeStart"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`STATION_NAME_TAKEN` — Another station already uses that name.\n\n`STATION_CALLSIGN_TAKEN` — Another station already uses that callsign (callsigns are globally unique).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PLAN_CHANGE_NOT_ALLOWED` — The subscription cannot change plan: wrong direction for its current plan, not active/trialing (fix payment first), or not claimed by a frequency.\n\n`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.\n\n`UNSUPPORTED_COUNTRY` — The supplied country is not in the platform's supported-country allowlist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/upgrade/complete":{"post":{"summary":"Complete an Operator→Broadcast upgrade","tags":["billing"],"description":"After `confirmSetup` succeeded client-side: verifies the intent, records the legal acceptance, creates the Broadcast subscription (charging the credited first month immediately — a declined card changes nothing), turns the frequency INTO the station (its name and callsign are written here) under the given company, and cancels the Operator subscription. Idempotent per SetupIntent.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpgradeSubscriptionCompleteBodyInput"}}}},"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanChangeResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`SUBSCRIPTION_REQUIRED` — This action needs an active subscription on the owning organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`STATION_NAME_TAKEN` — Another station already uses that name.\n\n`STATION_CALLSIGN_TAKEN` — Another station already uses that callsign (callsigns are globally unique).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PLAN_CHANGE_NOT_ALLOWED` — The subscription cannot change plan: wrong direction for its current plan, not active/trialing (fix payment first), or not claimed by a frequency.\n\n`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.\n\n`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.\n\n`SETUP_NOT_COMPLETED` — Card setup has not finished for this billing account.\n\n`PAYMENT_FAILED` — The card was declined when the plan-change subscription tried to charge; nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/downgrade/start":{"post":{"summary":"Begin a Broadcast→Push-to-Talk downgrade","tags":["billing"],"description":"Returns a SetupIntent on the caller's PERSONAL billing account plus a price preview: the unused Broadcast time becomes a balance credit consumed by future Operator invoices; a still-running trial carries over instead. `preview.khzRequired` means the frequency sits on a non-HAM dial number and /complete must supply a HAM-band `khz`.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanChangeStart"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PLAN_CHANGE_NOT_ALLOWED` — The subscription cannot change plan: wrong direction for its current plan, not active/trialing (fix payment first), or not claimed by a frequency.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/downgrade/complete":{"post":{"summary":"Complete a Broadcast→Push-to-Talk downgrade","tags":["billing"],"description":"Requires the company-information release sign-off (companyInfoReleaseAccepted) — all stored company information is deleted unless the station still backs other frequencies. Creates the Operator subscription on the personal account, flips the frequency to push-to-talk (re-numbering it when required), credits the unused Broadcast time, cancels the Broadcast subscription immediately, and removes the company information. Idempotent per SetupIntent.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DowngradeSubscriptionCompleteBodyInput"}}}},"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanChangeResult"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"`SUBSCRIPTION_REQUIRED` — This action needs an active subscription on the owning organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`FREQUENCY_TAKEN` — That frequency number is already allocated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`PLAN_CHANGE_NOT_ALLOWED` — The subscription cannot change plan: wrong direction for its current plan, not active/trialing (fix payment first), or not claimed by a frequency.\n\n`COMPANY_RELEASE_REQUIRED` — Downgrading to push-to-talk deletes the station's stored company information — the request must confirm the release (companyInfoReleaseAccepted: true).\n\n`AGE_ATTESTATION_REQUIRED` — The request must attest the account holder is at least 18 / the age of majority (ageConfirmed: true). details carries termsUrl + docVersion. Enforced on POST /frequencies and POST /billing/subscribe by servers advertising the \"age-attestation\" capability; the acceptance is durably recorded.\n\n`TERMS_ACCEPTANCE_REQUIRED` — The request must accept the Terms of Service (termsAccepted: true). details carries termsUrl + docVersion. Enforced on POST /frequencies, POST /billing/subscribe and both plan-change completions by servers advertising the \"terms-acceptance\" capability; the acceptance is durably recorded. First-party clients collect this with the age attestation in ONE checkbox, so the two arrive together.\n\n`HAM_NUMBER_REQUIRED_FOR_PTT` — A push-to-talk frequency must be allocated a HAM-band number.\n\n`SETUP_NOT_COMPLETED` — Card setup has not finished for this billing account.\n\n`PAYMENT_FAILED` — The card was declined when the plan-change subscription tried to charge; nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/payment-method":{"get":{"summary":"The payment method on file for a subscription","tags":["billing"],"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingPaymentMethod"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"post":{"summary":"Finish a card update","tags":["billing"],"description":"After `confirmSetup` succeeded client-side. The server re-reads the SetupIntent and pins it to this account before switching the account's customer and every live subscription onto the new payment method.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletePaymentMethodBodyInput"}}}},"parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingPaymentMethod"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`SETUP_NOT_COMPLETED` — Card setup has not finished for this billing account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/payment-method/setup-intent":{"post":{"summary":"Begin a card update: mint a SetupIntent","tags":["billing"],"description":"Same Payment Element flow as checkout, minus the plan. Confirm the secret client-side, then finish with the payment-method completion call.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupIntentCreated"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.\n\n`NO_BILLING_ACCOUNT` — No billing account exists for this organization yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/subscriptions/{subscriptionId}/invoices":{"get":{"summary":"Invoices for a subscription","tags":["billing"],"description":"Newest first, up to 24. `hostedInvoiceUrl`/`invoicePdf` are Stripe-hosted documents — open them in the system browser.","parameters":[{"schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"in":"path","name":"subscriptionId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingInvoices"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/portal-session":{"post":{"summary":"Open the Stripe Customer Portal","tags":["billing"],"description":"Cancel, change payment method, download invoices. Hosted by Stripe — open the URL in the system browser.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePortalSessionBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalSession"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_ORG_MEMBER` — The user is not a member/staff of the target organization.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.\n\n`HUMAN_ONLY` — The caller is an agent account (`users.kind = agent`, contract §25) and this operation is for a person's account only: passkeys, the account-deletion code, companies, phone numbers, Stripe mutations, sponsoring agents. Not retryable: it is a property of the account, not of the token or its scopes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NO_BILLING_ACCOUNT` — No billing account exists for this organization yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"422":{"description":"`ORGANIZATION_REQUIRED` — A company is required: give the id of one of your own (GET /orgs/mine) or the contact details to create a new one — exactly one, never both. Company names are not lookup keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`BILLING_NOT_CONFIGURED` — Billing is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/credit":{"get":{"summary":"The caller's prepaid credit","tags":["billing"],"description":"The balance of the PERSONAL billing account that pays for what the caller buys: their own for a person or a self-serve agent, the sponsor's for a sponsored agent (contract §25). `ledger` is the newest fifty movements. Credit is spent one UTC day of a credit-funded frequency at a time (`dailyDebitCents` says what today costs per plan; a month's days sum exactly to the monthly price). Topping up is `POST /billing/credit/topup` once capability `credit-balance` is advertised.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditBalance"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/credit/topup":{"post":{"summary":"Top up prepaid credit (402 challenge, then pay)","tags":["billing"],"description":"Machine payments (capability `credit-balance`, contract §26). Call it once to receive `402` with a `WWW-Authenticate: Payment ...` challenge naming the rails this server takes (cards through a Shared Payment Token, stablecoins), then call it again with `Payment-Authorization: Payment <credential>` built from that challenge. On success the credit is on the caller's billing PRINCIPAL's account (a sponsored agent tops up its sponsor), the response carries `Payment-Receipt` and the new balance. A spent credential answers `402` with a problem document; a refused one likewise; an anonymous call is `401` before any challenge. `amountCents` is between `minTopupCents` and `maxTopupCents` from `GET /billing/credit`. Rate limited per account in its own bucket. `503 MACHINE_PAYMENTS_NOT_CONFIGURED` when this server takes no machine payments.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupReceipt"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"402":{"description":"The Machine Payments Protocol challenge: `WWW-Authenticate: Payment <challenge>`, an empty body; or, for a refused credential, an RFC 9457 problem document (`application/problem+json`).","content":{"application/json":{"schema":{"type":"string","description":"The Machine Payments Protocol challenge: `WWW-Authenticate: Payment <challenge>`, an empty body; or, for a refused credential, an RFC 9457 problem document (`application/problem+json`)."}}}},"403":{"description":"A refusal that must not be paid again (the rail rejected the payer): a problem document with no challenge.","content":{"application/json":{"schema":{"type":"string","description":"A refusal that must not be paid again (the rail rejected the payer): a problem document with no challenge."}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`MACHINE_PAYMENTS_NOT_CONFIGURED` — This server takes no machine payments: `POST /v1/billing/credit/topup` has no rail to challenge with (no Stripe key and profile, and not a development server). Not retryable until an operator configures it; `rails` on `GET /v1/billing/credit` is empty meanwhile.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/billing/webhook":{"post":{"responses":{"200":{"description":"Default Response"}}}},"/v1/listen/{frequencyId}/{file}":{"get":{"summary":"Live program audio over HLS","tags":["stations"],"description":"Fetch `live.m3u8` and hand it to any HLS-capable player; everything it references — the `media.m3u8` variant and that playlist's segments — resolves to this same route. `live.m3u8` is a master playlist declaring `BANDWIDTH` and `CODECS`, so a player can report the rate it is receiving; see `GET /frequencies/{id}/stream-info` for the encoder settings behind it. Authenticate with `?ticket=` from `POST /listen/{frequencyId}/ticket` (capability `hls-tickets`; the playlist comes back with the ticket re-appended to each URI, so players need no special handling, and active playback keeps the ticket alive — see the contract's §6). `401 TICKET_EXPIRED` means mint a new ticket, not that the session ended. The legacy form `?token=<access token>` (scope `stream:listen`, checked in full per request) still works; when both are present the ticket wins. The listen tier is transmit-gated: `503 OFF_AIR` means nobody is on the air — render static and retry with backoff, honouring `Retry-After`. Audio is AAC in MPEG-TS segments a few seconds long.","parameters":[{"schema":{"type":"string"},"in":"query","name":"ticket","required":false,"description":"Connect ticket from POST /listen/{frequencyId}/ticket. The current scheme."},{"schema":{"type":"string"},"in":"query","name":"token","required":false,"description":"Legacy: access token with `stream:listen` from a member of the frequency."},{"schema":{"type":"string"},"in":"path","name":"frequencyId","required":true,"description":"The frequency to listen to."},{"schema":{"type":"string"},"in":"path","name":"file","required":true,"description":"`live.m3u8`, or a segment file the playlist references."}],"responses":{"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.\n\n`TICKET_EXPIRED` — The listen ticket on this request is missing, expired, or was minted for a different frequency. Not a sign-out: mint a fresh ticket via POST /listen/:frequencyId/ticket (refreshing the access token first if that returns UNAUTHENTICATED) and restart playback with the new playlist URL.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`STREAM_CONFLICT` — One live stream per account per frequency, whichever transport it arrives over (WebRTC or the HLS listen route). The newest request always wins the lease, so receiving this means THIS client is the one that was displaced by a newer stream elsewhere on the account. Show 'playing somewhere else' and stop; do not retry in a loop, which would fight the other client for the lease.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`OFF_AIR` — The station is not broadcasting right now (the listen tier is transmit-gated). Not a fault: render static/off-air UI and retry with backoff rather than surfacing an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/listen/{frequencyId}/ticket":{"post":{"summary":"Mint an HLS connect ticket","tags":["stations"],"description":"Authorizes a listen ONCE — scope, membership, and the account's one-stream-per-frequency lease — and returns the ticket that authenticates playback from then on (contract §6). Hand `playlistUrl` to the player verbatim. Active playback keeps the ticket alive indefinitely (each playlist poll slides its expiry), so the access token's 15-minute life never interrupts a listen; a stopped player's ticket lapses on its own within `expiresInSeconds`. `409 STREAM_CONFLICT` here means this account is already pulling the frequency somewhere else — the refusal surfaces on this plain HTTP call, never inside an opaque player error. When the conflict is a deliberate play press, repeat the mint with `{ \"takeover\": true }` (capability `session-seats`): the stream moves HERE immediately and the displaced install is told over its realtime lane (`stream.displaced`) to stop and show \"playing elsewhere\". NEVER send `takeover` from an automatic retry loop — only a human pressing play — or two machines will trade the stream forever. A takeover can still answer 409 when the holder is a live WebRTC session, which this lease cannot stop. Against a broadcast station whose stage is off_air the mint answers `503 OFF_AIR` before touching the lease — no ticket, no displacement, no listener-ledger row; treat it exactly like 503 on the playlist and retry on Retry-After (capability `hls-mint-offair`). Capability: `hls-tickets`. Station staff may send `{ \"monitor\": true }` instead (capability `air-monitor`) to hear the stream listeners get without it being a listen: no lease, no listener count.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":["object","null"],"properties":{"takeover":{"description":"Take the stream from another install of this account. Deliberate play presses only — never automatic retries.","type":"boolean"},"monitor":{"description":"Station staff only (capability `air-monitor`): hear the stream listeners get, without it being a listen. Claims no stream lease, so it neither displaces nor is displaced by the account's own player; opens no listener-ledger row, so it is not counted as a listener, carries no place, and cannot Cheer or Hush. `takeover` is ignored with it. 403 FORBIDDEN for anybody who is not the frequency's owner or admin or on the staff of the company that owns the station.","type":"boolean"}}}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"frequencyId","required":true,"description":"The frequency to listen to."}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListenTicketResponse"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.\n\n`NOT_MEMBER` — The user is not a member of the target frequency. Also sent as 404 where membership is what makes the resource visible, and as 422 on operations that act on an existing membership.\n\n`FORBIDDEN` — Authenticated, but this user may not perform this action.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"`STREAM_CONFLICT` — One live stream per account per frequency, whichever transport it arrives over (WebRTC or the HLS listen route). The newest request always wins the lease, so receiving this means THIS client is the one that was displaced by a newer stream elsewhere on the account. Show 'playing somewhere else' and stop; do not retry in a loop, which would fight the other client for the lease.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"503":{"description":"`OFF_AIR` — The station is not broadcasting right now (the listen tier is transmit-gated). Not a fault: render static/off-air UI and retry with backoff rather than surfacing an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"summary":"Stop listening: revoke this install's ticket and release the stream lease","tags":["stations"],"description":"Optional but polite: without it the lease lapses on its own (~20 s) and the ticket shortly after. Calling it on an explicit stop releases the account's one-stream-per-frequency lease IMMEDIATELY, so the next player — possibly on another device — starts without waiting out the TTL. Idempotent; holding no ticket is not an error.","parameters":[{"schema":{"type":"string"},"in":"path","name":"frequencyId","required":true,"description":"The frequency to stop listening to."}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"],"additionalProperties":false}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`INSUFFICIENT_SCOPE` — The access token's `scope` claim does not cover this operation; `details.requiredScope` names the missing one. Re-authenticating helps ONLY if the client's registered scopes have since widened — a session carries its original grant across rotation and never re-reads the client's allowlist, so a token issued before a grant stays narrow until the operator authorises again. If the client was never granted the scope, re-authenticating changes nothing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/platform/config":{"get":{"summary":"Band plans and platform configuration","tags":["platform"],"description":"Anonymous. Everything a client needs to render a dial before anyone signs in. Read the plan matching a frequency's `mode`; do not assume a band.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformConfig"}}}}}}},"/v1/platform/client-version/{clientId}":{"get":{"summary":"Native-client update policy (min version + update feed)","tags":["platform"],"description":"Anonymous, cacheable. A native client checks this on launch to decide whether to prompt or force an in-app update.","parameters":[{"schema":{"type":"string"},"in":"path","name":"clientId","required":true}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"clientId":{"type":"string"},"minVersion":{"type":"string"},"feedUrl":{"type":"string"}},"required":["clientId","minVersion","feedUrl"],"additionalProperties":false}}}}}}},"/v1/avatars":{"get":{"summary":"The platform's avatar library","tags":["avatars"],"description":"Anonymous. Revalidates on every read (`no-cache` + ETag), so a picker opened just after an operator adds artwork sees it. Ids here are what `PATCH /users/me` accepts for `avatar` and what every user DTO's `avatar` refers to. Retired avatars are withheld but STILL SERVE, so an id you cannot find here is not an error — draw the placeholder.","parameters":[{"schema":{"type":"string","enum":["true","false"]},"in":"query","name":"includeRetired","required":false,"description":"\"true\" also returns retired avatars. Operator consoles only; they are otherwise withheld."}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvatarCatalog"}}}}}}},"/v1/avatars/{id}/image":{"get":{"summary":"Avatar artwork (anonymous)","tags":["avatars"],"description":"No authentication — avatars load via `<img src>`, which cannot send a bearer header, and this is platform artwork keyed on the asset rather than on any user. Always PNG. **Pass `?v=<version>` from the catalog and the response is `immutable`: cache it by URL and never revalidate.** That is the whole of what a client must do to avoid downloading a picture twice. Omit `v` and you get a short max-age plus the same ETag, so a client that has not read the catalog yet still draws and still avoids a re-download. Retired avatars are served — somebody chose them.","parameters":[{"schema":{"type":"string"},"in":"query","name":"v","required":false,"description":"The asset's `version` from GET /avatars. A cache key; not validated."},{"schema":{"type":"string","enum":["sm","md","lg"]},"in":"query","name":"size","required":false,"description":"`sm` = 96px (rosters), `md` = 192px (picker tiles at high DPI; capability `avatar-size-md`), `lg` = 512px. Default `sm`."},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/emoji":{"get":{"summary":"The platform's emoji library","tags":["emoji"],"description":"Anonymous. Revalidates on every read (`no-cache` + ETag), so a re-read is nearly always a 304. Artwork to DRAW a character with: messages still carry only the Unicode character. Each entry's `version` changes when its artwork does, so a client re-downloads only those.","responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmojiLibrary"}}}}}}},"/v1/emoji/{id}/image":{"get":{"summary":"Emoji artwork (anonymous)","tags":["emoji"],"description":"Always PNG, with a transparent background. **Pass `?v=<version>` from the catalog and the response is `immutable`: cache it by URL and never revalidate.** Omit `v` for a short max-age plus an ETag. An id not in the catalog is 404, which a client treats as \"draw the character with the system font\".","parameters":[{"schema":{"type":"string"},"in":"query","name":"v","required":false,"description":"The entry's `version` from GET /emoji. A cache key; not validated."},{"schema":{"type":"string","enum":["sm","lg"]},"in":"query","name":"size","required":false,"description":"`sm` = 72px (inline text, picker), `lg` = 144px (large emoji, high-DPI). Default `sm`."},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/sounds":{"get":{"summary":"The platform's notification-sound library","tags":["sounds"],"description":"Anonymous. Revalidates on every read (`no-cache` + ETag), so a picker opened just after an operator adds a sound sees it. Ids here are what a contact's `ringTone` and `footsteps` accept, alongside the reserved `\"none\"` (chosen silence) and `null`. **Null is not the same as `\"none\"`**: for a ring it means the default (the entry flagged `isDefault`), for footsteps it means off. Retired sounds are withheld here but STILL SERVE, so an id you cannot find is not an error.","parameters":[{"schema":{"type":"string","enum":["true","false"]},"in":"query","name":"includeRetired","required":false,"description":"\"true\" also returns retired sounds. Operator consoles only; they are otherwise withheld."}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SoundCatalog"}}}}}}},"/v1/sounds/{id}/audio":{"get":{"summary":"Notification-sound audio (anonymous)","tags":["sounds"],"description":"No authentication — sounds load into an audio buffer the same way avatars load into an `<img>`, and this is platform audio keyed on the asset. Always 16-bit PCM WAV, which every platform decodes with no codec negotiation. **Pass `?v=<version>` from the catalog and the response is `immutable`.** `?part=out` fetches the LEAVING half of a `steps` pair; `in` is the default and the only part anything else has. Retired sounds are served — somebody chose them.","parameters":[{"schema":{"type":"string"},"in":"query","name":"v","required":false,"description":"The asset's `version` from GET /sounds. A cache key; not validated."},{"schema":{"type":"string","enum":["in","out"]},"in":"query","name":"part","required":false,"description":"`out` is the departure clip of a `steps` pair. Default `in`."},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"responses":{"404":{"description":"`NOT_FOUND` — The addressed resource does not exist, or is not visible to this caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/unfurl":{"get":{"summary":"Preview card for a link in a chat message","tags":["contacts"],"description":"Reads the page's Open Graph tags (never runs the page) and returns cleaned text plus a re-encoded image as a data URL. Always 200; `preview` is null when there is no card, the URL is not one the server will fetch, or previews are off.","parameters":[{"schema":{"type":"string","minLength":1,"maxLength":2048},"in":"query","name":"url","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkPreviewResponse"}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/errors/report":{"post":{"summary":"Relay a client-side error log to the platform task board","tags":["system"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","minLength":1,"maxLength":8000},"stackHead":{"type":"string","maxLength":4000},"context":{"description":"Structured state at the moment of the report. Kept on the card's occurrence events (truncated to 2000 characters), so a repeat report keeps its own state rather than only bumping a count.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["message"]}}}},"security":[{"bearerAuth":[]}],"responses":{"202":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"],"additionalProperties":false}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/feedback":{"post":{"summary":"Send a bug report or a feature request","tags":["system"],"description":"The suite feedback standard: one endpoint and one body for every app. Signed-in users only. Which app sent it is read from the access token and cannot be supplied. Accepted reports are triaged by staff; nothing is returned about what happens to one. A client must not retry a 202 or a 429. Repeated abusive submissions block the ACCOUNT from sending feedback (`FEEDBACK_BLOCKED`, `maySendFeedback` on the profile, the `user.feedback` event).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackBodyInput"}}}},"security":[{"bearerAuth":[]}],"responses":{"202":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]}},"required":["ok"],"additionalProperties":false}}}},"400":{"description":"`VALIDATION_ERROR` — The request body, query or params failed schema validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"401":{"description":"`UNAUTHENTICATED` — Missing, malformed or expired access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"`FEEDBACK_BLOCKED` — This ACCOUNT may not send feedback (bug reports or feature requests): it was blocked after abusive submissions, or by an operator. It is account-wide, so it holds in every app, and it lasts until an operator lifts it. Not retryable. A client hides its feedback entry point on this answer, exactly as it does for `maySendFeedback: false` on the profile and for the `user.feedback` event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"description":"`RATE_LIMITED` — Too many requests. Back off and retry; see the Retry-After header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}},"servers":[{"url":"https://frequencytalk.com","description":"Production"},{"url":"/","description":"This deployment"}],"tags":[{"name":"platform","description":"Static, anonymous platform configuration — band plans, supported countries."},{"name":"auth","description":"Login codes, token issue and refresh, WebAuthn, session management."},{"name":"oauth","description":"OAuth 2.1: PKCE authorization-code flow, token endpoint, JWKS, server metadata."},{"name":"users","description":"The signed-in user's own profile."},{"name":"stations","description":"Public station directory and branding."},{"name":"frequencies","description":"Frequency lifecycle, tuning and membership."},{"name":"organizations","description":"Stations as organizations: staff, branding, now-playing."},{"name":"channels","description":"Channels and groups within a frequency."},{"name":"billing","description":"Subscriptions and payment setup."},{"name":"agents","description":"Agent accounts (contract §25): self-serve registration, the key exchange, an agent's own keys and deletion."}],"externalDocs":{"url":"https://agent.frequencytalk.com","description":"Frequency Talk for AI agents: the skill, llms.txt, the API catalog."}}