{"openapi":"3.1.0","info":{"title":"Krotze API","summary":"Anonymous, private, self-destructing group chat.","description":"The API behind [Krotze](https://krotze.com) — the same one the web app uses.\nEverything the official client can do, a custom client can do.\n\n## There is no login\n\nThere is no password, no e-mail and no bearer token. An identity is a username\nyou pick plus a **device secret** held by each device that may act as you. A\ndevice gets its secret exactly once, in the response to `POST /api/session`\n(or `/api/devices/claim` or `/api/devices/import`), and it never travels again.\n\nThree ways to obtain credentials:\n\n| How | Endpoint | When |\n| --- | --- | --- |\n| Register a new identity | `POST /api/session` | first run |\n| Pair with an identity that already exists | `POST /api/devices/claim` | a second device, using an 8-character code |\n| Import an exported identity | `POST /api/devices/import` | moving to a new device |\n\n## Signing a request\n\nEvery authenticated call proves it holds the device secret by signing the\nrequest, rather than sending the secret. Four headers:\n\n| Header | Value |\n| --- | --- |\n| `X-Chat-Device` | the `device_id` from your credentials |\n| `X-Chat-Ts` | current Unix time in **seconds** |\n| `X-Chat-Nonce` | fresh random hex, 16–64 characters |\n| `X-Chat-Sig` | the signature, lower-case hex |\n\nThe signature is `HMAC-SHA256(secret, canonical)` where `canonical` is these\nfive lines joined by `\\n`:\n\n```\nMETHOD\nREQUEST_URI\nTIMESTAMP\nNONCE\nSHA256_HEX(body)\n```\n\nWorked example — `GET /api/state` with secret `0f1e2d…`, timestamp\n`1769000000` and nonce `a3f1…`:\n\n```\nGET\n/api/state\n1769000000\na3f19c04e7b25d8a6f0c1b3e5d7a9f21\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n```\n\n(`e3b0c4…` is SHA-256 of the empty string — a GET has no body.)\n\n### Three things that trip people up\n\n1. **`REQUEST_URI` includes the `/api` prefix and the query string.** Sign\n   `/api/channels/{uuid}/messages?after=42`, not `/channels/{uuid}/messages`.\n2. **The secret is used as a literal ASCII string**, not hex-decoded. It looks\n   like hex, but feed the 64 characters to HMAC as they are.\n3. **Multipart bodies sign as the hash of the empty string.** PHP consumes a\n   multipart body before it can be hashed, so both sides apply the same rule.\n   Only file uploads are affected.\n\nThe server allows five minutes of clock drift and burns each nonce once, so a\nrecorded request can neither be replayed nor turned into a stolen identity. A\n`401` with `reason: \"stale\"` means your clock is off — read the server's `Date`\nheader, keep the offset, and sign again.\n\n## Getting new messages\n\nThere is no long-poll or socket. Clients poll:\n\n- `GET /api/state` roughly every 4 seconds — channel list, unread counts,\n  pending notifications, and a preview of each channel's newest message.\n- `GET /api/channels/{uuid}/messages?after={id}` for the messages themselves.\n\nFor notifications while your client is **closed**, subscribe to Web Push — see\nthe `push` operations and `docs/API.md`.\n\n## Errors\n\nFailures are JSON: `{\"error\": \"…\"}`, often with a machine-readable `reason`.\nValidation failures follow Laravel's shape: `{\"message\": \"…\", \"errors\": {\"field\": [\"…\"]}}`.\n\nThe full narrative guide, including a reference client, lives in\n[`docs/API.md`](https://github.com/) in the source tree.","version":"2.6.1","license":{"name":"MIT"}},"servers":[{"url":"https://krotze.com","description":"Krotze Anonymous Chat"}],"tags":[{"name":"Identity","description":"Registering devices, pairing, importing and exporting an identity."},{"name":"Profile","description":"Username, registered devices, account deletion."},{"name":"State","description":"The poll endpoint every client lives on."},{"name":"Channels","description":"Creating, configuring and destroying channels."},{"name":"Members","description":"Join requests, approvals, removals, private conversations."},{"name":"Messages","description":"Reading, posting and deleting messages and uploads."},{"name":"Push","description":"Web Push subscriptions, for notifications while the client is closed."},{"name":"Uploads","description":"The per-member capability URLs that serve uploaded files."},{"name":"Links","description":"Deep links a client may need to open or generate."}],"components":{"securitySchemes":{"ChatDevice":{"type":"apiKey","in":"header","name":"X-Chat-Device","description":"The `device_id` from your credentials."},"ChatTs":{"type":"apiKey","in":"header","name":"X-Chat-Ts","description":"Unix seconds. More than 5 minutes out and the request is refused."},"ChatNonce":{"type":"apiKey","in":"header","name":"X-Chat-Nonce","description":"Fresh random hex, 16–64 characters. Accepted once each."},"ChatSig":{"type":"apiKey","in":"header","name":"X-Chat-Sig","description":"HMAC-SHA256 over the canonical request, lower-case hex. See the introduction."}},"parameters":{"channelUuid":{"name":"uuid","in":"path","required":true,"description":"Channel UUID. Changes if the owner rotates the invite — watch for `channel_moved`.","schema":{"type":"string","format":"uuid"}},"memberId":{"name":"memberId","in":"path","required":true,"description":"Membership id, as returned in a channel detail `members[]` entry.","schema":{"type":"integer"}},"messageId":{"name":"messageId","in":"path","required":true,"schema":{"type":"integer"}},"deviceId":{"name":"deviceId","in":"path","required":true,"schema":{"type":"integer"}},"inviteToken":{"name":"token","in":"path","required":true,"description":"The channel invite token, from an invite URL or QR code.","schema":{"type":"string"}}},"responses":{"Unauthorized":{"description":"The signature was missing, wrong, stale or replayed. `reason` says which.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string","description":"One of the values listed.","enum":["unsigned","stale","bad_nonce","device_unknown","bad_signature","replay"]}}}}}},"Forbidden":{"description":"Allowed to ask, not allowed to do. Either this identity is not approved on the server yet, or the action needs rights it does not have.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string","description":"Absent when the refusal is about channel rights rather than the account.","enum":["registration_pending","registration_denied"]}}}}}},"NotFound":{"description":"No such channel, member, message or token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ValidationFailed":{"description":"The body did not validate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}}},"TooManyRequests":{"description":"Rate limited. `Retry-After` says for how long.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds."}}}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable, safe to show to a person."}},"required":["error"]},"ValidationError":{"type":"object","properties":{"message":{"type":"string"},"errors":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"description":"Field name to the messages for it."}}},"Ok":{"type":"object","properties":{"ok":{"type":"boolean"}}},"Credentials":{"type":"object","properties":{"device_id":{"type":"string","description":"Send as `X-Chat-Device`."},"secret":{"type":"string","description":"The HMAC key. Returned **once** — store it and never send it again."},"user_id":{"type":"integer","description":"Your numeric identity id; appears as `user_id` on your own messages."},"username":{"type":["string","null"],"description":"Null until you claim one with `POST /api/profile/username`."},"status":{"type":"string","description":"Whether the server has let this identity in.","enum":["approved","pending","denied"]}},"required":["device_id","secret","user_id"]},"MessagePreview":{"type":"object","properties":{"id":{"type":"integer","description":"Newest message id in the channel."},"user_id":{"type":["integer","null"],"description":"Author; compare with your own to ignore your own messages."},"username":{"type":"string"},"excerpt":{"type":"string","description":"Message text, truncated, or the file name for an upload."}}},"StateChannel":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"name":{"type":"string","description":"For a private conversation, the other participant."},"type":{"type":"string","description":"","enum":["group","private"]},"status":{"type":"string","description":"Your membership.","enum":["pending","approved"]},"username":{"type":"string","description":"Your own username."},"is_owner":{"type":"boolean"},"pinned":{"type":"boolean"},"hidden":{"type":"boolean"},"muted":{"type":"boolean","description":"Muted channels raise no notification; the unread count still counts."},"unread":{"type":"integer","description":"Messages after your `last_read_message_id` that are not yours."},"last_message":{"anyOf":[{"$ref":"#/components/schemas/MessagePreview"},{"type":"null"}]},"retention_days":{"type":"integer","description":"Inactivity after which the whole channel deletes itself."},"invite_url":{"type":["string","null"],"description":"Owners only."},"pending_count":{"type":"integer","description":"Join requests waiting for you, if you own the channel."},"member_count":{"type":"integer"},"members_hash":{"type":"string","description":"Changes whenever anyone joins, leaves or renames. Reload the roster when it moves."},"last_activity_at":{"type":["string","null"]}}},"Notification":{"type":"object","properties":{"id":{"type":"integer"},"type":{"type":"string","description":"","enum":["join_request","join_approved","join_denied","private_invite","private_accepted","private_declined","ownership_received","member_removed","channel_destroyed","channel_moved"]},"data":{"type":"object","additionalProperties":true,"description":"Depends on `type`: `channel_uuid`, `channel_name`, `member_id`, `username`, `from_username`, `old_uuid`."},"created_at":{"type":"string","description":"","format":"date-time"}}},"StateResponse":{"type":"object","properties":{"user_id":{"type":"integer"},"username":{"type":["string","null"]},"account_status":{"type":"string","description":"While not `approved`, everything except this endpoint and the username endpoint is refused.","enum":["approved","pending","denied"]},"channels":{"type":"array","items":{"$ref":"#/components/schemas/StateChannel"}},"notifications":{"type":"array","items":{"$ref":"#/components/schemas/Notification"}},"upload_retention_days":{"type":["integer","null"],"description":"How long uploads live. Null means they are kept until their channel goes."},"upload_view_minutes":{"type":["integer","null"],"description":"How long an upload stays readable once opened. Null means no limit."},"version":{"type":"string","description":"Server version. If it differs from the build you loaded, a newer client exists."}}},"Member":{"type":"object","properties":{"id":{"type":"integer","description":"Membership id — what member-scoped endpoints take."},"username":{"type":"string"},"status":{"type":"string","description":"","enum":["pending","approved"]},"is_owner":{"type":"boolean"},"is_me":{"type":"boolean"},"joined_at":{"type":["string","null"]}}},"ChannelDetail":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"name":{"type":"string"},"type":{"type":"string","description":"","enum":["group","private"]},"is_owner":{"type":"boolean"},"my_member_id":{"type":"integer"},"my_status":{"type":"string","description":"","enum":["pending","approved"]},"pinned":{"type":"boolean"},"hidden":{"type":"boolean"},"muted":{"type":"boolean"},"retention_days":{"type":"integer"},"allow_images":{"type":"boolean"},"allow_videos":{"type":"boolean"},"allow_audio":{"type":"boolean"},"allow_zip":{"type":"boolean"},"restrict_delete":{"type":"boolean","description":"When true only the uploader and the owner may delete a file."},"invite_url":{"type":["string","null"],"description":"Owners only."},"members":{"type":"array","items":{"$ref":"#/components/schemas/Member"},"description":"Pending members are visible to the owner only."},"members_hash":{"type":"string"}}},"Message":{"type":"object","properties":{"id":{"type":"integer"},"user_id":{"type":["integer","null"],"description":"Null once the author has deleted their account."},"username":{"type":"string","description":"Live username, so a rename updates old messages too."},"body":{"type":["string","null"],"description":"Text messages only. Stored encrypted at rest."},"kind":{"type":"string","description":"","enum":["text","image","audio","video","zip"]},"reply_to":{"anyOf":[{"type":"object","properties":{"id":{"type":"integer"},"username":{"type":["string","null"]},"kind":{"type":["string","null"]},"excerpt":{"type":["string","null"]}}},{"type":"null"}]},"file_url":{"type":["string","null"],"description":"Capability URL, personal to you. Opening it starts the view window."},"thumb_url":{"type":["string","null"],"description":"Preview. Fetching it does **not** start the view window."},"file_name":{"type":["string","null"],"description":"Stored encrypted at rest."},"file_mime":{"type":["string","null"]},"file_size":{"type":["integer","null"]},"file_expires_at":{"type":["string","null"],"description":"When the upload itself disappears. Null if the server keeps uploads."},"view_expires_at":{"type":["string","null"],"description":"When your personal link stops resolving. Null until you first open it."},"created_at":{"type":"string","description":"","format":"date-time"}}},"Device":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string","description":"Self-reported, e.g. \"Firefox on Android\"."},"current":{"type":"boolean","description":"True for the device making this call."},"last_seen_at":{"type":["string","null"]},"created_at":{"type":["string","null"]}}},"PushPreferences":{"type":"object","properties":{"notify_messages":{"type":"boolean"},"notify_chat_requests":{"type":"boolean"},"notify_join_requests":{"type":"boolean"},"hide_message_text":{"type":"boolean","description":"Send \"New message in …\" instead of the sender and text."}}}}},"paths":{"/api/session":{"post":{"tags":["Identity"],"security":[],"summary":"Register a new identity and device","description":"Mints a brand-new anonymous identity and returns the only copy of the device secret.\n\nOn a server with **open registrations** anybody may call this. When registrations are closed you must pass the `invite` token from a `/register/{token}` link, and the admin may additionally require manual approval — in which case `status` comes back `pending` and almost everything is refused until an admin approves you.\n\nRate limited to 30 requests per minute.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"device_name":{"type":"string","description":"Shown in the profile's device list. **Required** — its absence marks a pre-2.0 client."},"invite":{"type":["string","null"],"description":"Registration invite token. Required while registrations are closed."}},"required":["device_name"]}}}},"responses":{"201":{"description":"Registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"403":{"description":"Registrations are closed and no valid invite was presented.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string","description":"","enum":["registration_closed"]}}}}}},"426":{"description":"`device_name` was missing — the caller is an outdated client.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/devices/claim":{"post":{"tags":["Identity"],"security":[],"summary":"Redeem a pairing code","description":"Registers this device against an identity that already exists, using the 8-character code from `POST /api/profile/devices/pair`. The new device gets its **own** secret; the existing one's is not shared.\n\nCodes last five minutes and work once. Rate limited to 10 requests per minute.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string","description":"8 characters, case-insensitive."},"device_name":{"type":"string"}},"required":["code"]}}}},"responses":{"201":{"description":"Paired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"404":{"description":"Unknown or expired code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/devices/import":{"post":{"tags":["Identity"],"security":[],"summary":"Import an exported identity","description":"Continues as the identity behind an exported token, registering this device under it. The token is stored only as a hash, so a database leak cannot reproduce it.\n\nRate limited to 10 requests per minute.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"The identity token, at least 32 characters."},"device_name":{"type":"string"}},"required":["token"]}}}},"responses":{"201":{"description":"Imported.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"404":{"description":"The token is unknown or was revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/api/state":{"get":{"tags":["State"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Everything the client needs to render itself","description":"The endpoint a client lives on: poll it about every 4 seconds.\n\nIt returns your channels with unread counts, any pending notifications, and a preview of each channel's newest message — enough to raise a notification without fetching the messages themselves.\n\nWatch `members_hash` per channel to know when to reload a roster, and `version` to notice that a newer client exists. This is also the only endpoint an identity awaiting approval may call.","responses":{"200":{"description":"Current state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/me":{"delete":{"tags":["Profile"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Delete this identity and everything it owns","description":"Irreversible. Channels you own and private conversations you are in are destroyed for everyone else too; in channels you merely joined, your messages and files are deleted and the rest stays. Your devices, memberships and push subscriptions go with you.","responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile":{"get":{"tags":["Profile"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Username and registered devices","responses":{"200":{"description":"Your profile.","content":{"application/json":{"schema":{"type":"object","properties":{"username":{"type":["string","null"]},"has_transfer_token":{"type":"boolean","description":"Whether an exported identity token is currently valid."},"devices":{"type":"array","items":{"$ref":"#/components/schemas/Device"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile/username":{"post":{"tags":["Profile"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Claim or change your username","description":"Globally unique and compared case-insensitively, so nobody can impersonate anybody. Renaming updates your old messages too, since the name is resolved live. Most actions require a username first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"username":{"type":"string","description":"2–40 characters: letters, numbers, spaces, dots, dashes, underscores. Must not start with punctuation."}},"required":["username"]}}}},"responses":{"200":{"description":"Claimed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"username":{"type":"string"}}}}}},"422":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile/devices/pair":{"post":{"tags":["Identity"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Start pairing another device","description":"Returns a short code for another device to redeem with `POST /api/devices/claim`. Good for one device and five minutes.","responses":{"200":{"description":"Code issued.","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"string","description":"8 characters from an alphabet with no easily confused letters."},"url":{"type":"string","description":"Deep link carrying the code, suitable for a QR code."},"expires_in":{"type":"integer","description":"Seconds."}}}}}},"422":{"description":"You need a username before you can pair a device.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile/devices/{deviceId}":{"delete":{"tags":["Identity"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Revoke another device","description":"That device's next request fails and it wipes its local identity. Its push subscription goes too. You cannot revoke the device you are calling from.","parameters":[{"$ref":"#/components/parameters/deviceId"}],"responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"That is the device you are using.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile/transfer":{"post":{"tags":["Identity"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Mint an identity token for export","description":"Returned once and stored only as a hash. The official client encrypts it with a passphrase before showing or saving it — see the `KRZ1` format in `docs/API.md`. Minting a new one invalidates the previous.","responses":{"200":{"description":"Token minted.","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string"},"username":{"type":"string"}}}}}},"422":{"description":"You need a username first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"delete":{"tags":["Identity"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Revoke the exported identity token","responses":{"200":{"description":"Revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/profile/swap-token":{"post":{"tags":["Identity"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Roll every credential of this identity","description":"Drops all devices, invalidates any exported token, and hands the caller a fresh device so it stays signed in. Username, channels and messages are untouched — this only changes who can act as you. Every other device is signed out immediately.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"device_name":{"type":"string"}}}}}},"responses":{"201":{"description":"New credentials.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credentials"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Create a channel","description":"You become its owner and first approved member.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"Up to 60 characters."},"retention_days":{"type":"integer","description":"Inactivity after which everything in it is deleted. 1–365, default 7."}},"required":["name"]}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"invite_url":{"type":"string"}}}}}},"422":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}":{"get":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Channel detail and member roster","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"responses":{"200":{"description":"Detail.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelDetail"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"patch":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Change channel settings (owner)","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"retention_days":{"type":"integer","description":"1–365."},"allow_images":{"type":"boolean"},"allow_videos":{"type":"boolean"},"allow_audio":{"type":"boolean"},"allow_zip":{"type":"boolean"},"restrict_delete":{"type":"boolean","description":"Only the uploader and the owner may delete a file."}}}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"delete":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Destroy a channel and all of its data","description":"Messages, uploads and memberships are removed from database and disk for everyone. Group channels: owner only. Private conversations: either participant.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"responses":{"200":{"description":"Destroyed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/pin":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Pin or unpin a channel for yourself","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"pinned":{"type":"boolean"}},"required":["pinned"]}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/hide":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Hide or unhide a channel for yourself","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"hidden":{"type":"boolean"}},"required":["hidden"]}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/mute":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Mute or unmute a channel","description":"Stored on the membership rather than the device, so the choice follows you everywhere. Muted channels raise no notification and are not pushed; unread counts still accumulate.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"muted":{"type":"boolean"}},"required":["muted"]}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/read":{"post":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Mark messages read up to an id","description":"Only ever moves forward. Drives the unread counts in `/api/state`.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"message_id":{"type":"integer"}},"required":["message_id"]}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/leave":{"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Leave a channel","description":"An owner must transfer ownership or destroy the channel instead.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"delete_own_data":{"type":"boolean","description":"Also delete every message and file of yours in it."}}}}}},"responses":{"200":{"description":"Left.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"You own this channel.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/transfer":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Hand ownership to another member (owner)","description":"You lose every owner right immediately and cannot undo it yourself.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"member_id":{"type":"integer","description":"An approved member."}},"required":["member_id"]}}}},"responses":{"200":{"description":"Transferred.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"That member already owns it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/rotate":{"post":{"tags":["Channels"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Replace a leaked invite (owner)","description":"Issues a new invite token **and** a new channel UUID, so both the join link and the channel address change and the old ones stop resolving. Members listed in `keep` move with the channel; everyone else is removed.\n\nKept members learn the new UUID from a `channel_moved` notification on their next poll, so offline clients migrate when they return. A client must handle that notification or it will lose the channel.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"keep":{"type":"array","items":{"type":"integer"},"description":"Membership ids to carry over. Omit to remove everyone but yourself."}}}}}},"responses":{"200":{"description":"Rotated.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"The new channel UUID.","format":"uuid"},"invite_url":{"type":"string"},"removed":{"type":"integer","description":"How many members were dropped."}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Private conversations cannot be rotated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/purge-uploads":{"post":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Delete every upload in a channel (owner)","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"responses":{"200":{"description":"Purged.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"deleted":{"type":"integer"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/private":{"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Ask a member for a private conversation","description":"Creates a private channel the other side must accept. If one already exists between you, that one is returned instead with `existing: true`.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"member_id":{"type":"integer"}},"required":["member_id"]}}}},"responses":{"200":{"description":"A conversation already existed.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"existing":{"type":"boolean"}}}}}},"201":{"description":"Requested; awaiting acceptance.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"existing":{"type":"boolean"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"You cannot start one with yourself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/private-response":{"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Accept or decline a private conversation","description":"Declining destroys the conversation for both sides.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"accept":{"type":"boolean"}},"required":["accept"]}}}},"responses":{"200":{"description":"Answered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Not a private conversation awaiting your answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/members/{memberId}":{"delete":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Remove a member (owner)","parameters":[{"$ref":"#/components/parameters/channelUuid"},{"$ref":"#/components/parameters/memberId"},{"name":"delete_files","in":"query","required":false,"description":"Also delete every file that member uploaded here.","schema":{"type":"boolean"}}],"responses":{"200":{"description":"Removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"You cannot remove yourself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/requests":{"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Decide several join requests at once (owner)","description":"Denied requests are deleted, so the person may apply again. Your matching `join_request` notifications are marked read for you.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"accept":{"type":"array","items":{"type":"integer"}},"deny":{"type":"array","items":{"type":"integer"}}}}}}},"responses":{"200":{"description":"Decided.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"decided":{"type":"integer"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/members/{memberId}/decision":{"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Approve or deny one join request (owner)","parameters":[{"$ref":"#/components/parameters/memberId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"approve":{"type":"boolean"}},"required":["approve"]}}}},"responses":{"200":{"description":"Decided.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"That request is no longer pending.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/join/{token}":{"get":{"tags":["Members"],"security":[],"summary":"Look up an invite","description":"Lets a client show what it is about to join before asking for an identity. No auth needed — holding the invite token is the only thing that grants this.","parameters":[{"$ref":"#/components/parameters/inviteToken"}],"responses":{"200":{"description":"Invite is valid.","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"members":{"type":"integer","description":"Approved members."}}}}}},"404":{"$ref":"#/components/responses/NotFound"}}},"post":{"tags":["Members"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Apply to join a channel","description":"You join under your username and the owner must approve you. Applying twice returns your current status rather than failing.","parameters":[{"$ref":"#/components/parameters/inviteToken"}],"responses":{"200":{"description":"You had already applied or joined.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"status":{"type":"string","description":"","enum":["pending","approved"]}}}}}},"201":{"description":"Applied; the owner has been notified.","content":{"application/json":{"schema":{"type":"object","properties":{"uuid":{"type":"string","description":"","format":"uuid"},"status":{"type":"string","description":"","enum":["pending"]}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"You need a username first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/messages":{"get":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Read messages","description":"Without `after`, the newest 100 messages in chronological order — the initial load. With `after`, up to 200 messages newer than that id, which is what the poll loop uses.\n\n`deletions` reports files removed in the last two minutes so a client can show who deleted them at the right place in the list.","parameters":[{"$ref":"#/components/parameters/channelUuid"},{"name":"after","in":"query","required":false,"description":"Return only messages newer than this id. Remember to include the query string when signing.","schema":{"type":"integer"}}],"responses":{"200":{"description":"Messages.","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"deletions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"by":{"type":["string","null"],"description":"Who deleted it."}}}}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"post":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Post a message or upload a file","description":"Send JSON for text. Send `multipart/form-data` for an upload — and remember that a multipart body signs as the hash of the **empty string**.\n\nAccepted uploads are images, audio, video and zip archives, up to 50 MB, subject to the channel's `allow_*` settings. Images are thumbnailed server-side; for video you may attach your own poster frame as `thumb`, which is re-encoded before use.","parameters":[{"$ref":"#/components/parameters/channelUuid"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string","description":"Up to 5000 characters."},"reply_to":{"type":"integer","description":"Id of a message in this channel to quote. Ignored if it does not exist."}},"required":["body"]}},"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","description":"The upload, max 50 MB.","format":"binary"},"thumb":{"type":"string","description":"Optional poster frame for a video, max 2 MB.","format":"binary"},"reply_to":{"type":"integer"}},"required":["file"]}}}},"responses":{"201":{"description":"Posted.","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"$ref":"#/components/schemas/Message"}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Unsupported file type, a kind the channel disallows, or a body that did not validate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/messages/{messageId}":{"delete":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Delete a message outright","description":"Its author or the channel owner. Removes the row and any file with it.","parameters":[{"$ref":"#/components/parameters/channelUuid"},{"$ref":"#/components/parameters/messageId"}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"$ref":"#/components/responses/NotFound"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/channels/{uuid}/messages/{messageId}/delete-file":{"post":{"tags":["Messages"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Delete an uploaded file","description":"Any member may do this unless the channel sets `restrict_delete`. Leaves a short-lived tombstone so other clients can show who removed it; the message itself is pruned within the hour.","parameters":[{"$ref":"#/components/parameters/channelUuid"},{"$ref":"#/components/parameters/messageId"}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"deleted_by":{"type":"string"}}}}}},"403":{"description":"The channel restricts deletion to the uploader and the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Not a file message, or already deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/notifications/read":{"post":{"tags":["State"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Mark notifications read","description":"Read notifications stop appearing in `/api/state`. Omit `id` to clear all of them.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer","description":"Omit to mark every unread notification read."}}}}}},"responses":{"200":{"description":"Marked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/push/key":{"get":{"tags":["Push"],"security":[],"summary":"The server's VAPID public key","description":"Pass `public_key` to `pushManager.subscribe()` as `applicationServerKey`. It is handed to every subscribing browser and is not a secret, so this needs no credentials.\n\n`enabled` is false when the operator has not configured a keypair or has switched push off; in that case fall back to raising notifications from your poll loop.","responses":{"200":{"description":"Push status.","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"public_key":{"type":["string","null"],"description":"base64url, 65-byte uncompressed P-256 point."}}}}}}}}},"/api/push/subscribe":{"post":{"tags":["Push"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Register this device for Web Push","description":"Bound to the calling device, so revoking the device also stops its notifications, and each device keeps its own preferences. Browsers rotate endpoints on their own schedule — call this on every launch; it is idempotent, and registering a new endpoint retires this device's previous one.\n\nPayloads are encrypted for your device per RFC 8291, so the push service relays text it cannot read.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"endpoint":{"type":"string","description":"From `PushSubscription.endpoint`.","format":"uri"},"keys":{"type":"object","properties":{"p256dh":{"type":"string","description":"base64url, 65 bytes."},"auth":{"type":"string","description":"base64url, 16 bytes."}},"required":["p256dh","auth"]},"notify_messages":{"type":"boolean"},"notify_chat_requests":{"type":"boolean"},"notify_join_requests":{"type":"boolean"},"hide_message_text":{"type":"boolean"}},"required":["endpoint","keys"]}}}},"responses":{"201":{"description":"Subscribed.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"preferences":{"$ref":"#/components/schemas/PushPreferences"}}}}}},"422":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/push/subscription":{"patch":{"tags":["Push"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Change what this device is notified about","description":"Only the fields you send are changed.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushPreferences"}}}},"responses":{"200":{"description":"Saved.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"preferences":{"$ref":"#/components/schemas/PushPreferences"}}}}}},"404":{"description":"This device has no subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"$ref":"#/components/responses/ValidationFailed"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}},"delete":{"tags":["Push"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Stop pushing to this device","description":"Unsubscribe in the browser as well, or it will simply be re-registered on the next launch.","responses":{"200":{"description":"Unsubscribed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/api/push/test":{"post":{"tags":["Push"],"security":[{"ChatDevice":[],"ChatTs":[],"ChatNonce":[],"ChatSig":[]}],"summary":"Send a test notification to this device","description":"Useful while wiring up a client. Rate limited to 10 requests per minute.","responses":{"200":{"description":"Accepted by the push service.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Ok"}}}},"404":{"description":"This device has no subscription.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Push is switched off on this server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"The push service rejected it; the subscription is probably stale.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/TooManyRequests"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/u/{token}/{variant}":{"get":{"tags":["Uploads"],"security":[],"summary":"Fetch an upload","description":"Uploads are never served from a public path. Each member gets their own capability URL, handed to them as `file_url` / `thumb_url` on a message, and holding that URL is the whole authorisation — so it carries no signature.\n\nTwo rules make the link ephemeral:\n\n- **The upload itself expires** after the operator's retention window (`upload_retention_days` in `/api/state`), after which the file, its thumbnail and its message are deleted for everyone.\n- **Your personal window starts the first time you fetch the full file**, and runs for `upload_view_minutes`. After that your token is destroyed and the URL stops resolving, for you alone.\n\nFetching `/thumb` does **not** start that window — that is what makes it safe to show a preview inline.","parameters":[{"name":"token","in":"path","required":true,"description":"The opaque token from `file_url` or `thumb_url`.","schema":{"type":"string"}},{"name":"variant","in":"path","required":true,"description":"Omit the segment entirely for the full file; `thumb` for the preview.","schema":{"type":"string","enum":["thumb"]}}],"responses":{"200":{"description":"The file.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Unknown token, expired upload, or a view window that has closed."}}}},"/join/{token}":{"get":{"tags":["Links"],"security":[],"summary":"Channel invite link","description":"The URL behind an invite QR code. Serves the web app, which reads the token from the path and starts the join flow. A custom client can skip the page and use `GET`/`POST /api/join/{token}` directly.","parameters":[{"$ref":"#/components/parameters/inviteToken"}],"responses":{"200":{"description":"The web app shell.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/register/{token}":{"get":{"tags":["Links"],"security":[],"summary":"Server registration invite link","description":"Handed out by an admin when the server is invite-only. Serves the web app, which keeps the token and presents it to `POST /api/session`. A custom client can read the token from the URL and do the same.","parameters":[{"name":"token","in":"path","required":true,"description":"The registration invite token.","schema":{"type":"string"}}],"responses":{"200":{"description":"The web app shell.","content":{"text/html":{"schema":{"type":"string"}}}}}}}}}