+
+
diff --git a/fluxer_docs/src/components/ThemeProvider.astro b/fluxer_docs/src/components/ThemeProvider.astro
new file mode 100644
index 000000000..fe9647157
--- /dev/null
+++ b/fluxer_docs/src/components/ThemeProvider.astro
@@ -0,0 +1,42 @@
+---
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import {Icon} from '@astrojs/starlight/components';
+---
+
+
+
+
+
+
+
+
diff --git a/fluxer_docs/src/components/ThemeSelect.astro b/fluxer_docs/src/components/ThemeSelect.astro
new file mode 100644
index 000000000..c4d31b42c
--- /dev/null
+++ b/fluxer_docs/src/components/ThemeSelect.astro
@@ -0,0 +1,66 @@
+---
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import Select from '@astrojs/starlight/components/Select.astro';
+---
+
+
+
+
+
+
+
+
diff --git a/fluxer_docs/src/content.config.ts b/fluxer_docs/src/content.config.ts
new file mode 100644
index 000000000..4921bd611
--- /dev/null
+++ b/fluxer_docs/src/content.config.ts
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import {defineCollection} from 'astro:content';
+import {docsLoader} from '@astrojs/starlight/loaders';
+import {docsSchema} from '@astrojs/starlight/schema';
+
+export const collections = {
+ docs: defineCollection({
+ loader: docsLoader(),
+ schema: docsSchema(),
+ }),
+};
diff --git a/fluxer_docs/src/content/docs/admin-api/api-keys.mdx b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx
new file mode 100644
index 000000000..88fde6118
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx
@@ -0,0 +1,259 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin API keys
+description: Admin API key objects and the five operations that manage them.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An Admin API key is a long-lived credential for the [Admin API](/admin-api/). It authenticates as the account that created it, has its own [ACL](/admin-api/#acl-registry) set, and is honoured only on paths below `/v1/admin`.
+
+Every operation on this page requires `admin_api_key:manage`. None records an Admin audit entry or reads the `X-Audit-Log-Reason` header. An operation that addresses one key returns 404 `ADMIN_API_KEY_NOT_FOUND` when no key has that identifier, when another account created the key, or when the key has expired.
+
+:::caution[Key management is scoped to the creating account]
+The wildcard ACL reaches no key created by another account, and such a key reports `ADMIN_API_KEY_NOT_FOUND`.
+:::
+
+## Admin API key object
+
+A key is owned by the account that created it, and it stores its own ACL set. Narrowing the owner and narrowing the key are separate actions.
+
+Fluxer checks both sets when a request presents a key. A request passes when all three of these hold:
+
+- The owning account holds `admin:authenticate` or `*`.
+- The owning account holds the ACL the operation requires, or `*`.
+- The key itself has that ACL, or `*`.
+
+A key therefore never has wider permission than the account behind it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key_id | snowflake | The ID of the key |
+| name | string | The name given to the key (1-100 characters) |
+| acls1 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key |
+| created_by_user_id2 | snowflake | The ID of the account that created the key, and whose identity the key assumes |
+| created_at | ISO8601 timestamp | Time the key was created |
+| last_used_at3 | ?ISO8601 timestamp | Time the key last authenticated a request, or null when it never has |
+| expires_at4 | ?ISO8601 timestamp | Time the key expires, or null when the key does not expire |
+
+1 Bounded at 111 entries, the size of the [ACL registry](/admin-api/#acl-registry). Every value written through this API is a registry member
+
+2 Never changes, so a key cannot be transferred to another account
+
+3 Written on every successful authentication, including when the authenticated operation is a read
+
+4 An expired key is absent from every listing and is reported as not found
+
+### Example
+
+```json
+{
+ "key_id": "1501314428688998182",
+ "name": "moderation-tooling",
+ "acls": ["user:lookup", "report:view", "report:resolve"],
+ "created_by_user_id": "1489200013322551296",
+ "created_at": "2026-05-14T09:12:44.183000+00:00",
+ "last_used_at": "2026-06-02T18:40:11.902000+00:00",
+ "expires_at": null
+}
+```
+
+## Admin API key creation object
+
+Only [Create Admin API key](#create-admin-api-key) returns this object. It has the raw credential and omits `last_used_at` and `created_by_user_id`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key_id | snowflake | The ID of the key |
+| key1 | string | The raw credential the key authenticates with |
+| name | string | The name given to the key (1-100 characters) |
+| created_at | ISO8601 timestamp | Time the key was created |
+| expires_at2 | ?ISO8601 timestamp | Time the key expires, or null when the key does not expire |
+| acls3 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key |
+
+1 Has the form `fa__<32 characters>` described by [token formats](/authentication/#token-formats), and is presented as `Admin ` in the `Authorization` header
+
+2 Derived from `expires_in_days` at the instant the key is created, and null when that field is omitted
+
+3 Reflects the stored set, so a value repeated in the request appears once
+
+:::caution[The secret is returned once]
+Fluxer stores the value as a password hash, so no later operation returns it and none rotates it. A key whose raw value is lost must be revoked and replaced.
+:::
+
+### Example
+
+```json
+{
+ "key_id": "1501314428688998182",
+ "key": "fa_1501314428688998182_7Qk2ZbW9xLmR4TnP0vAeJdCyHs6UgF1B",
+ "name": "moderation-tooling",
+ "created_at": "2026-05-14T09:12:44.183000+00:00",
+ "expires_at": "2026-08-12T09:12:44.183000+00:00",
+ "acls": ["user:lookup", "report:view"]
+}
+```
+
+## List Admin API keys
+
+
+
+Returns every [Admin API key](#admin-api-key-object) object the acting account created, as a bare JSON array with no ordering guarantee. Requires `admin_api_key:manage`.
+
+A key past its expiry is never returned.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | array[[Admin API key](#admin-api-key-object) object] | Keys were returned |
+
+1 An account that created no key receives an empty array
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Create Admin API key
+
+
+
+Creates a key and returns an [Admin API key creation](#admin-api-key-creation-object) object that has the raw credential, with HTTP 200 rather than 201. Requires `admin_api_key:manage`.
+
+The acting credential must already have every value in `acls`, unless it has `*`. The key authenticates immediately, and its effective permission is the intersection described under [Admin API key object](#admin-api-key-object).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The name given to the key (1-100 characters) |
+| expires_in_days?2 | integer | The number of days until the key expires (1-365) |
+| acls3 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key, each a registry value (at most 111) |
+
+1 A value that is empty after trimming is rejected, so whitespace alone is not a name
+
+2 The stored expiry is the request instant plus this many days. Omitting the field creates a key that does not expire
+
+3 An empty array produces a key that satisfies no operation. Fluxer compares `acls` against the presenting key's own ACLs, so a key cannot mint a broader key
+
+An ungrantable ACL fails with 403 `MISSING_ACL` on the first offending value, and a request that names several ungrantable ACLs still reports only that one.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Admin API key creation](#admin-api-key-creation-object) object | Key was created |
+| 400 | [error response](/admin-api/#error-response) | Body validation fails, including an `acls` value outside the [ACL registry](/admin-api/#acl-registry), returned as `INVALID_FORM_BODY` |
+| 403 | [error response](/admin-api/#error-response) | Credential type is refused, `admin_api_key:manage` is absent, or `acls` names a value the acting credential does not have |
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `admin:code:generation` bucket.
+
+## Get Admin API key
+
+
+
+Returns one [Admin API key](#admin-api-key-object) object. Requires `admin_api_key:manage`.
+
+This operation never returns the raw credential.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key_id | snowflake | The ID of the key |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Admin API key](#admin-api-key-object) object | Key was returned |
+| 404 | [error response](/admin-api/#error-response) | `ADMIN_API_KEY_NOT_FOUND`, which also covers an expired key and a key created by another account |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Update Admin API key
+
+
+
+Renames a key or replaces its ACL set, and returns the updated [Admin API key](#admin-api-key-object) object. Requires `admin_api_key:manage`.
+
+An omitted field is left unchanged, and the supplied fields take effect on the key's next authenticated request. The acting credential must already have every value in a supplied `acls`, unless it has `*`. A key with an expiry keeps it.
+
+:::caution[Supplying acls replaces the whole stored set]
+An update never rotates the credential, and no field on this route changes the expiry, so revoking the key is the only way to disable it.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key_id | snowflake | The ID of the key |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name?1 | string | The replacement name for the key (1-100 characters) |
+| acls?2 | array[string] | The complete replacement set of [ACLs](/admin-api/#acl-registry), each a registry value (at most 111) |
+
+1 A value that is empty after trimming is rejected
+
+2 An empty array leaves the key with no ACLs, the narrowest state short of revocation. An empty request body is accepted and changes nothing
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Admin API key](#admin-api-key-object) object | Key was updated |
+| 400 | [error response](/admin-api/#error-response) | Body validation fails, including an `acls` value outside the [ACL registry](/admin-api/#acl-registry), returned as `INVALID_FORM_BODY` |
+| 4031 | [error response](/admin-api/#error-response) | Credential type is refused, `admin_api_key:manage` is absent, or `acls` names a value the acting credential does not have |
+| 404 | [error response](/admin-api/#error-response) | `ADMIN_API_KEY_NOT_FOUND`, which also covers an expired key and a key created by another account |
+
+1 The ownership check runs before the ACL grant check, so a key belonging to another account returns 404 rather than 403
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Revoke Admin API key
+
+
+
+Revokes an Admin API key and answers HTTP 200 with a response body. Requires `admin_api_key:manage`.
+
+The credential stops authenticating on its next use. A request already in flight runs to completion.
+
+:::danger[Revocation is permanent]
+Every later request presenting that credential fails authentication, and no operation recovers the key ID or reissues the raw value.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key_id | snowflake | The ID of the key |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Key was revoked |
+| 404 | [error response](/admin-api/#error-response) | `ADMIN_API_KEY_NOT_FOUND`, which also covers an expired key and a key created by another account |
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/applications.mdx b/fluxer_docs/src/content/docs/admin-api/applications.mdx
new file mode 100644
index 000000000..3591f6bb7
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/applications.mdx
@@ -0,0 +1,209 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin applications
+description: Application inspection, the owner and guild selectors, and ownership transfer.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An application is an OAuth2 client, and it can own one bot account. These routes report its ownership, bot state, redirect configuration, and non-secret credential metadata. [Transfer application ownership](#transfer-application-ownership) is the only one that writes.
+
+The records are the same ones the public [Applications](/http-api/applications/) resource serves. Creation, deletion, renaming, bot provisioning, redirect URI editing, and credential rotation stay there.
+
+:::note[Admin operations report credential metadata only]
+The Admin application object reports whether a client secret or bot token hash is stored, when it was provisioned, and the non-secret bot token preview.
+:::
+
+## Admin application object
+
+The Admin view of one application. An application and its bot account share one identifier, so an application that has a bot reports the same [snowflake](/snowflakes/) in `id` and `bot_user_id`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The display name (1-256 characters) |
+| owner_user_id | snowflake | The ID of the owning account |
+| owner_username1 | ?string | The username of the owner |
+| owner_global_name1 | ?string | The display name of the owner, or null when unset |
+| owner_discriminator1 | ?string | The discriminator of the owner, as four decimal digits with leading zeroes |
+| bot_user_id | ?snowflake | The ID of the bot account, or null when the application has no bot |
+| bot_username2 | ?string | The username of the bot |
+| bot_global_name2 | ?string | The display name of the bot, or null when unset |
+| bot_discriminator2 | ?string | The discriminator of the bot, as four decimal digits with leading zeroes |
+| bot_is_public | boolean | Whether an account other than the owner can add the bot |
+| bot_require_code_grant | boolean | Whether adding the bot requires a completed authorisation code grant |
+| oauth2_redirect_uris3 | array[string] | The registered OAuth2 redirect URIs (at most 10 entries) |
+| has_client_secret | boolean | Whether a client secret hash is stored |
+| has_bot_token | boolean | Whether a bot token hash is stored |
+| bot_token_preview | ?string | The non-secret trailing preview of the bot token, or null when none is stored |
+| bot_token_created_at | ?ISO8601 timestamp | When the bot token was provisioned, or null when none is stored |
+| client_secret_created_at4 | ?ISO8601 timestamp | When the client secret was provisioned |
+| version5 | integer | The optimistic locking version of the stored record (0-2147483647) |
+
+1 Null when the owner account can no longer be read
+
+2 Null when the application has no bot, and also null when the bot account can no longer be read
+
+3 The stored set arrives in no guaranteed order
+
+4 Null when no client secret is stored, and null for the [built-in Admin application](#the-built-in-admin-application)
+
+5 Incremented by every stored change, including one made through the public [Applications](/http-api/applications/) resource
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "name": "Metronome",
+ "owner_user_id": "1483920011884392448",
+ "owner_username": "wren",
+ "owner_global_name": "Wren",
+ "owner_discriminator": "0042",
+ "bot_user_id": "1501314428688998182",
+ "bot_username": "metronome",
+ "bot_global_name": null,
+ "bot_discriminator": "0001",
+ "bot_is_public": true,
+ "bot_require_code_grant": false,
+ "oauth2_redirect_uris": ["https://example.com/oauth2/callback"],
+ "has_client_secret": true,
+ "has_bot_token": true,
+ "bot_token_preview": "kQ9x",
+ "bot_token_created_at": "2026-02-11T09:14:02.117Z",
+ "client_secret_created_at": "2026-02-11T09:13:58.004Z",
+ "version": 7
+}
+```
+
+## The built-in Admin application
+
+Fluxer serves one synthetic application for its own Admin OAuth2 client. Its ID is the fixed constant `1234567890123456789`, its `name` is `Fluxer Admin`, its `owner_user_id` is the system account `0`, and its only redirect URI is the configured Admin endpoint followed by `/oauth2_callback`.
+
+It has no bot, so every `bot_` field is null or false and `has_bot_token` is false. `has_client_secret` is true on every response that has it, `client_secret_created_at` is null, and `version` is always 1.
+
+:::caution[The built-in application is read-only]
+[Get application](#get-application) resolves it only while a client secret is configured for the deployment, and answers a null `application` otherwise. [List applications](#list-applications) never returns it. [Transfer application ownership](#transfer-application-ownership) answers 403 `FORBIDDEN`.
+:::
+
+## List applications
+
+
+
+Returns the [Admin application](#admin-application-object) objects selected by exactly one of `owner_id` and `guild_id`. Requires `application:lookup` or `application:list:by_owner`.
+
+The route returns the complete selection in one response. There is no cursor, no limit, and no name search.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| owner_id?12 | snowflake | Select the applications owned by this account |
+| guild_id?13 | snowflake | Select the applications whose bot account is a member of this guild |
+
+1 Supplying both fails with 400 `INVALID_FORM_BODY` reported against `guild_id`, and supplying neither fails against `owner_id`
+
+2 An `owner_id` naming no account fails with 404 `UNKNOWN_USER`
+
+3 A `guild_id` naming no guild fails with 404 `UNKNOWN_GUILD`
+
+`owner_id` returns every application the account owns, with or without a bot. `guild_id` returns an application only while its own bot account is a member of that guild.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| applications1 | array[[Admin application](#admin-application-object) object] | The selected applications |
+
+1 No order is guaranteed, and the array is empty when the account owns no application or the guild has no bot member
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The selected applications were returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER` when `owner_id` names no account, or `UNKNOWN_GUILD` when `guild_id` names no guild |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get application
+
+
+
+Returns one [Admin application](#admin-application-object) object wrapped in a single-member object. Requires `application:lookup`.
+
+Fluxer answers 200 with `application` set to null when the ID names no application.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application_id | snowflake | The ID of the application |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application1 | ?[Admin application](#admin-application-object) object | The resolved application, or null when no application has this ID |
+
+1 Also null for the [built-in Admin application](#the-built-in-admin-application) on a deployment with no configured Admin client secret
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The lookup completed, whether or not it resolved an application |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Transfer application ownership
+
+
+
+Moves the application to another account and returns the stored [Admin application](#admin-application-object) object. Requires `application:transfer_ownership`.
+
+Ownership is the only field this operation writes. The new owner need not be related to the application, and Fluxer does not notify the previous owner.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application_id | snowflake | The ID of the application |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| new_owner_id1 | snowflake | The account to transfer the application to |
+
+1 Fluxer resolves the application before the account, so an unknown application fails with 404 `UNKNOWN_APPLICATION` and an unknown replacement owner fails with 404 `UNKNOWN_USER`
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application | [Admin application](#admin-application-object) object | The application as stored after the transfer |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Ownership was transferred |
+| 403 | [error response](/admin-api/#error-response) | `FORBIDDEN` because the target is the [built-in Admin application](#the-built-in-admin-application) |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_APPLICATION` for the application, or `UNKNOWN_USER` for the replacement owner |
+
+### Side effects
+
+Fluxer rewrites the record with the new `owner_user_id` and increments its `version`. The application moves in both the `owner_id` selector of [List applications](#list-applications) and [List user applications](/admin-api/users/#list-user-applications). Fluxer emits no Gateway Dispatch.
+
+Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the target type `application`, the target ID equal to the application ID, and the action `transfer_ownership`. Its metadata is `old_owner_id` and `new_owner_id`. Neither owner is resolved into `related_users`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/archives.mdx b/fluxer_docs/src/content/docs/admin-api/archives.mdx
new file mode 100644
index 000000000..6862fbfd9
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/archives.mdx
@@ -0,0 +1,222 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Archives
+description: Administrative user and guild archive state, listing filters, and download grants.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An archive is a snapshot of one user account or one guild, built in the background and downloaded as a file. [Create user archive](/admin-api/users/#create-user-archive) and [Create guild archive](/admin-api/guilds/#create-guild-archive) request one. The operations here read archive state and issue time-limited download URLs.
+
+Every operation needs an [ACL](/admin-api/#acl-evaluation) covering the subject type it touches. A user archive needs `archive:view_all` or `archive:trigger:user`. A guild archive needs `archive:view_all` or `archive:trigger:guild`. Reading both types at once needs `archive:view_all`.
+
+:::note[An expired archive stops being readable]
+`expires_at` is 365 days after the archive is requested and is never extended. The archive record is removed at that instant, and no operation reads the stored file afterwards.
+:::
+
+## Archive object
+
+An archive is in one of three lifecycle states. It is building while `completed_at` and `failed_at` are both null, complete once `completed_at` is set, and failed once `failed_at` is set. A retried attempt clears `failed_at` and `error_message` when it starts, so a failed archive reads as building again while the retry runs.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| archive_id | snowflake | The ID of the archive, used with its subject to address the record |
+| subject_type | string | The [archive subject type](#archive-subject-types) the record is stored under |
+| subject_id | snowflake | The user or guild the archive was taken of |
+| requested_by | snowflake | The Admin account that requested the archive |
+| requested_at | ISO8601 timestamp | The time the archive record was created |
+| started_at | ?ISO8601 timestamp | The time construction began, or null while the task has not started |
+| completed_at | ?ISO8601 timestamp | The time construction completed, or null when it has not completed |
+| failed_at | ?ISO8601 timestamp | The time construction failed, or null when it has not failed |
+| file_size1 | ?string | The size of the stored archive object in bytes, or null before completion |
+| progress_percent2 | number | The construction progress from 0 to 100 |
+| progress_step2 | ?string | The step the construction task last reported |
+| error_message | ?string | The failure text the construction task reported, or null when it has not failed |
+| download_url_expires_at3 | ?ISO8601 timestamp | The cache expiry stamped on the stored archive object at completion, or null before completion |
+| expires_at4 | ?ISO8601 timestamp | The time the archive stops being readable |
+
+1 The size is a decimal string
+
+2 A new archive starts at 0 with the step `Queued`, and completion sets 100 and `Completed`. Failure leaves `progress_percent` where it was and sets the step to `Failed`
+
+3 Written once, at completion, to one year after the archive file was uploaded. [Create archive download](#create-archive-download) returns a URL with its own expiry, and no operation here updates this field
+
+4 The value is 365 days after `requested_at`, and every write fills the field in
+
+### Example
+
+```json
+{
+ "archive_id": "1501314428688998182",
+ "subject_type": "user",
+ "subject_id": "1478812292088791040",
+ "requested_by": "1478812292088791041",
+ "requested_at": "2026-08-14T11:02:03.000Z",
+ "completed_at": "2026-08-14T11:06:41.000Z",
+ "file_size": "48211904",
+ "progress_percent": 100,
+ "progress_step": "Completed",
+ "expires_at": "2027-08-14T11:02:03.000Z"
+}
+```
+
+## Archive subject types
+
+| Value | Description |
+| --- | --- |
+| user | The archive captures one user account |
+| guild | The archive captures one guild |
+
+[List archives](#list-archives) also accepts `all` as a filter value, and it is the default there. No path segment accepts it.
+
+## Archive download object
+
+This object uses camelCase field names, unlike every other Admin response body.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| downloadUrl1 | string | The time-limited URL the archive file is fetched from |
+| expiresAt2 | ISO8601 timestamp | The time this URL stops working |
+
+1 The URL is a presigned object storage link with no Admin credential, so possession of it grants access until it expires
+
+2 The value is seven days after the grant was issued, regardless of the archive's own `expires_at`
+
+### Example
+
+```json
+{
+ "downloadUrl": "https://storage.example.com/harvests/1501314428688998182.zip?signature=abc123",
+ "expiresAt": "2026-09-07T11:06:41.000Z"
+}
+```
+
+## List archives
+
+
+
+Returns [archive](#archive-object) objects matching the supplied filters, newest first. Requires an ACL covering the requested `subject_type`.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| subject_type?1 | string | `user`, `guild`, or `all` (default `all`) |
+| subject_id?2 | snowflake | The exact subject whose archives are returned |
+| requested_by?3 | snowflake | The Admin account that requested the returned archives |
+| limit?4 | integer | The maximum number of archives to return (1-200, default 50) |
+| include_expired?5 | boolean | Whether to include archives past their `expires_at` (default false) |
+
+1 `all` returns the newest `limit` archives across both subject types, so a busy subject type can crowd out the other
+
+2 `subject_type` also has to name `user` or `guild`
+
+3 Ignored when `subject_id` is supplied. When it does apply it runs regardless of `subject_type`
+
+4 The listing is not paginated and returns no cursor. Only a narrower filter reaches older records
+
+5 The value counts as true only for `true`, `True`, or `1`. Expired archives are dropped after `limit` rows have been read, so a response can hold fewer than `limit` archives
+
+:::caution[A trigger ACL cannot use the default filter]
+`all` is the default and needs `archive:view_all`. An account holding only `archive:trigger:user` or `archive:trigger:guild` has to set `subject_type` explicitly or the request returns 403 `MISSING_ACL`.
+:::
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| archives | array[[archive](#archive-object) object] | The archives matching the filters, newest first |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The archives were returned |
+| 400 | [error response](/admin-api/#error-response) | `subject_id` is supplied while `subject_type` is `all`. `INVALID_FORM_BODY` has `INVALID_FORMAT` on `subject_type` |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL` without an ACL covering the requested subject type |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get archive
+
+
+
+Returns one [archive](#archive-object) object. Requires an ACL covering the subject type.
+
+An archive is addressed by its subject and its identifier together.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| subject_type | string | The [archive subject type](#archive-subject-types), either `user` or `guild` |
+| subject_id | snowflake | The user or guild the archive was taken of |
+| archive_id | snowflake | The ID of the archive |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| archive1 | ?[archive](#archive-object) object | The archive, or null when the subject and identifier resolve to nothing |
+
+1 A missing archive answers 200 with a null archive, which is also the answer when the identifier belongs to a different subject and when the archive has passed `expires_at`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | A lookup was performed, whether or not it resolved |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL` without an ACL covering the subject type |
+
+### Side effects
+
+The read returns archive metadata and issues no download grant.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Create archive download
+
+
+
+Issues a temporary download URL for a completed archive. Returns an [archive download](#archive-download-object) object on success. Requires an ACL covering the subject type.
+
+:::caution[The grant is an unauthenticated bearer URL]
+The URL has its own signature and works for seven days for anyone who holds it. No operation revokes it, and it is bound to no Admin account. Each repeat of the operation issues a further independent grant.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| subject_type | string | The [archive subject type](#archive-subject-types), either `user` or `guild` |
+| subject_id | snowflake | The user or guild the archive was taken of |
+| archive_id | snowflake | The ID of a completed archive |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [archive download](#archive-download-object) object | The grant was issued |
+| 4001 | [error response](/admin-api/#error-response) | The archive is not downloadable, returned as `HARVEST_NOT_READY`, `HARVEST_FAILED`, or `HARVEST_EXPIRED` |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL` without an ACL covering the subject type |
+| 4042 | [error response](/admin-api/#error-response) | The subject and identifier resolve to nothing, returned as `UNKNOWN_HARVEST` |
+
+1 `HARVEST_FAILED` while `failed_at` is set, `HARVEST_NOT_READY` while the archive has no completion time or no stored object, and `HARVEST_EXPIRED` once `expires_at` has passed, tested in that order
+
+2 The archive record stops being readable at `expires_at`, so an archive past its expiry answers `UNKNOWN_HARVEST` rather than `HARVEST_EXPIRED`
+
+### Side effects
+
+The operation creates a presigned download URL valid for seven days and changes no field of the archive, including `download_url_expires_at`. It records no Admin audit entry.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/blocklists.mdx b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx
new file mode 100644
index 000000000..0d2ba54ca
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx
@@ -0,0 +1,705 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Blocklists
+description: The nine safety blocklists, their value forms, and the operations that manage them.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A blocklist is a stored set of values Fluxer checks account access and user content against. Nine lists exist. Each has one canonical value form, one matching rule, and its own stored fields, and Fluxer canonicalises every value before storage and before every check.
+
+Each list has its own three [Admin ACLs](/admin-api/#acl-registry). A read needs the selected list's `check` permission, an addition or an update needs its `add` permission, and a removal needs its `remove` permission, so an account holding `ban:ip:add` writes to the `ip` list and to no other. Every write records the audit reason on the [Admin audit entries](/admin-api/#admin-audit-entry-object) it produces. The reads record nothing.
+
+A list's three values are `ban:`, the list name with each hyphen written as an underscore, and the verb, so the `url-domain` list uses `ban:url_domain:check`, `ban:url_domain:add`, and `ban:url_domain:remove`. The `email-domain-suspicious` list is the one exception and uses `suspicious_email_domain:check`, `suspicious_email_domain:add`, and `suspicious_email_domain:remove`.
+
+:::note[The Admin API exposes no other blocklist]
+Fluxer synchronises disposable email domains from external feeds every six hours. No operation on this page reads or writes them.
+:::
+
+## Blocklist types
+
+`ip`, `email`, and `email-domain-suspicious` gate account access and registration. `phrase`, `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring` gate what an account may post, link, upload, or display. `email` and `email-domain-suspicious` store nothing but the value, while an `ip` row also stores a ban kind, a reason, an expiry, and its creation time.
+
+| Value | Description |
+| --- | --- |
+| ip1 | IPv4 addresses, IPv6 addresses, and CIDR ranges denied service |
+| email2 | Exact email addresses barred from registration and from being set on an account |
+| email-domain-suspicious23 | Email domains that allow registration and require the new account to verify a phone number first |
+| phrase4 | Phrases barred from content |
+| url5 | Absolute `http` and `https` URLs barred from being posted |
+| url-domain6 | Domains barred from being linked |
+| file-sha7 | SHA-256 hashes barred from being uploaded |
+| avatar-hash8 | Avatar hashes barred from being set |
+| profile-substring49 | Substrings barred from one named profile field |
+
+1 Two guards refuse an address with 400 `IP_BAN_DECLINED`: the instance exemption list, and the high blast-radius carrier network check. Fluxer records both refusals in the Admin audit log
+
+2 Stored lowercased, so a mixed-case value does not create a second row
+
+3 Fluxer never shows the list to the account holder, who sees only the verified-phone gate. A domain must match `^[a-zA-Z0-9][a-zA-Z0-9\-.]*\.[a-zA-Z]{2,}$`
+
+4 Canonicalised by NFKC normalisation, removal of control, format and variation-selector characters, lowercasing, and trimming. Match time also normalises inserted whitespace, punctuation, and compatibility glyphs
+
+5 Canonicalised before storage. A value Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url`
+
+6 Stored lowercased and matched against the lowercased hostname of a submitted URL. `match_subdomains` is stored on the row and defaults to true, and the hostname match is exact whatever its value
+
+7 Stored as lowercase hexadecimal
+
+8 Stored as the 8-character hash with any `a_` animation prefix stripped and the remainder lowercased, so the animated and static forms of one avatar are the same row
+
+9 The only scoped list. Scope and substring together identify a row, so the same substring can be stored once per [scope](#profile-substring-scopes)
+
+The fields a list accepts differ from list to list, and so does the operation set. Read the `fields` array and the three `supports_` flags of a [blocklist object](#blocklist-object) before writing to a list.
+
+## Content blocklist categories
+
+Every `url`, `url-domain`, `file-sha`, and `avatar-hash` row has a category naming where it came from. A row created through this resource without an explicit category is stored as `manual`.
+
+| Value | Description |
+| --- | --- |
+| manual | Added by an Admin through this resource |
+| urlhaus | Imported from URLhaus |
+| phishtank | Imported from PhishTank |
+| google_safe_browsing | Imported from Google Safe Browsing |
+| malware_bazaar | Imported from MalwareBazaar |
+| ncmec | Imported from an NCMEC hash set |
+| gifct | Imported from a GIFCT hash set |
+| stop_ncii | Imported from a StopNCII hash set |
+
+The request field is a free string of 1 through 64 characters, so a value outside this registry is accepted and stored verbatim. Tolerate a stored category outside these eight.
+
+## Content blocklist severities
+
+Every `url`, `url-domain`, `file-sha`, and `avatar-hash` row has a severity. A row created without an explicit severity is stored as `2`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALLOW | Record the match and take no action |
+| 1 | WARN | Record the match, warn, and allow the content |
+| 2 | BLOCK | Refuse the content |
+| 3 | BLOCK_AND_REPORT | Refuse the content and raise a report |
+
+## Profile substring scopes
+
+| Value | Description |
+| --- | --- |
+| username | Match the substring against the account username |
+| global_name | Match the substring against the account display name |
+| nickname | Match the substring against a guild nickname |
+| bio | Match the substring against the profile biography |
+| pronouns | Match the substring against the profile pronouns |
+
+## Blocklist object
+
+One entry of the blocklist catalogue returned by [List blocklists](#list-blocklists). The catalogue is a constant of the release and reports no counts.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) this entry describes |
+| description | string | The human-readable summary of what the blocklist matches and how it matches |
+| value_field1 | string | The request body field that has the value when adding |
+| fields2 | array[string] | The field names rows of this blocklist accept beyond the value (at most 8) |
+| scoped3 | boolean | Whether rows are scoped to a profile field and every operation has a scope |
+| supports_bulk_create4 | boolean | Whether [Bulk add blocklist entries](#bulk-add-blocklist-entries) is accepted |
+| supports_bulk_delete5 | boolean | Whether [Bulk remove blocklist entries](#bulk-remove-blocklist-entries) is accepted |
+| supports_update6 | boolean | Whether [Update blocklist entry](#update-blocklist-entry) is accepted |
+
+1 `ip` names `ip`, `email` names `email`, `email-domain-suspicious` and `url-domain` name `domain`, `phrase` names `phrase`, `url` names `url`, `file-sha` names `sha256_hex`, `avatar-hash` names `hashes`, and `profile-substring` names `substrings`
+
+2 Empty for `ip`, `email`, `email-domain-suspicious`, and `phrase`. `url` names `category`, `severity`, `source_url`, and `notes`. `url-domain` adds `match_subdomains`, `file-sha` adds `content_type`, and `avatar-hash` adds `reason`. `profile-substring` names `scope`, `reason`, and `notes`
+
+3 True only for `profile-substring`
+
+4 True only for `file-sha`
+
+5 True only for `avatar-hash` and `profile-substring`
+
+6 True for `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring`, which is exactly the set whose `fields` array is non-empty
+
+### Example
+
+```json
+{
+ "list_type": "url-domain",
+ "description": "Domains blocked from being linked, optionally covering every subdomain rooted at the domain.",
+ "value_field": "domain",
+ "fields": ["match_subdomains", "category", "severity", "source_url", "notes"],
+ "scoped": false,
+ "supports_bulk_create": false,
+ "supports_bulk_delete": false,
+ "supports_update": true
+}
+```
+
+## Blocklist entry object
+
+One stored row of one blocklist. Every field is present on every entry, and a field the row's blocklist does not store is null.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) the row belongs to |
+| value1 | string | The canonical stored value (1-2048 characters) |
+| scope2 | ?string | The [profile substring scope](#profile-substring-scopes) the row is stored under |
+| category3 | ?string | The [content blocklist category](#content-blocklist-categories) the row was recorded under |
+| severity3 | ?integer | The [content blocklist severity](#content-blocklist-severities) applied when the row matches |
+| source_url3 | ?string | The feed or evidence URL the row was recorded from |
+| notes4 | ?string | The internal note stored alongside the row |
+| content_type5 | ?string | The media type hint recorded alongside a `file-sha` row |
+| match_subdomains6 | ?boolean | Whether a `url-domain` row is flagged as covering subdomains |
+| reason7 | ?string | The reason stored on the row |
+| expires_at7 | ?ISO8601 timestamp | When the row expires |
+| created_at8 | ?ISO8601 timestamp | When the row was added |
+| created_by_user_id8 | ?snowflake | The Admin account that added the row, or null when unknown |
+
+1 The exact string the single-entry operations and the `after` cursor accept for that row
+
+2 Non-null only on `profile-substring`
+
+3 Non-null only on `url`, `url-domain`, `file-sha`, and `avatar-hash`, where `category` and `severity` are always populated
+
+4 Non-null only on `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring`
+
+5 Non-null only on `file-sha`
+
+6 Non-null only on `url-domain`
+
+7 Non-null only on `ip`. An address added through [Add blocklist entry](#add-blocklist-entry) is written as a permanent ban, so it reads back with the reason `platform_admin_enforcement` and a null `expires_at`
+
+8 Both null on `email`, `email-domain-suspicious`, and `phrase`, which store nothing but the value. `created_by_user_id` is additionally null on `ip`
+
+:::note[The audit entry records the accepted reason]
+An `avatar-hash` or `profile-substring` write accepts `reason`, and both lists then report `reason` as null in this object.
+:::
+
+### Example
+
+```json
+{
+ "list_type": "url-domain",
+ "value": "malicious.example.com",
+ "scope": null,
+ "category": "urlhaus",
+ "severity": 2,
+ "source_url": "https://urlhaus.example.com/entry/912",
+ "notes": null,
+ "content_type": null,
+ "match_subdomains": true,
+ "reason": null,
+ "expires_at": null,
+ "created_at": "2026-01-22T07:41:19.003Z",
+ "created_by_user_id": "1483920011884392448"
+}
+```
+
+## Blocklist check object
+
+The decision [Check blocklist entry](#check-blocklist-entry) returns for one value.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| banned1 | boolean | Whether the value is currently blocked by the named blocklist |
+
+1 Reports the decision the enforcement path would make for this value
+
+:::note[A block and a row are different things]
+A value can be blocked with no row of its own, as a single address covered by a stored CIDR range is. A row can exist while the value reads as not blocked, as an exempt address or an exempt email domain does.
+:::
+
+## Blocklist entry creation object
+
+The body of [Add blocklist entry](#add-blocklist-entry) is a union resolved by the `list_type` path segment. The field that has the value differs per blocklist, and two of the nine take an array, so one request can add up to 1000 avatar hashes or profile substrings. Supplying a field the selected blocklist does not accept is not an error. Fluxer strips the unrecognised key.
+
+### Value field
+
+| Blocklist | Field | Accepted value |
+| --- | --- | --- |
+| ip | ip | IPv4 address, IPv6 address, or CIDR range of 1 through 45 characters |
+| email | email | Email address of 1 through 254 characters |
+| email-domain-suspicious | domain | Domain of 1 through 253 characters matching `^[a-zA-Z0-9][a-zA-Z0-9\-.]*\.[a-zA-Z]{2,}$` |
+| phrase | phrase | Phrase of 1 through 500 characters |
+| url | url | Absolute `http` or `https` URL of 1 through 2048 characters |
+| url-domain | domain | Domain of 1 through 253 characters |
+| file-sha | sha256_hex | Exactly 64 hexadecimal characters |
+| avatar-hash1 | hashes | 1 through 1000 hashes, each 8 through 10 characters matching `^(a_)?[0-9a-fA-F]{8}$` |
+| profile-substring12 | substrings | 1 through 1000 substrings, each 1 through 500 characters |
+
+1 Each value is canonicalised and written as its own row with its own Admin audit entry
+
+2 `scope` is required in the same body
+
+Each field is a string, except `hashes` and `substrings`, which are `array[string]`.
+
+`avatar-hash` deduplicates the array after canonicalisation. `profile-substring` does not, so a substring repeated in one request is written once and audited once per occurrence, and a value that canonicalises to an empty string is skipped.
+
+### Additional fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope3 | string | The [profile substring scope](#profile-substring-scopes) to store the rows under |
+| category?4 | string | The [content blocklist category](#content-blocklist-categories) (1-64 characters, default `manual`) |
+| severity?4 | integer | The [content blocklist severity](#content-blocklist-severities) (0-3, default 2) |
+| match_subdomains?5 | boolean | Whether the row is flagged as covering subdomains (default true) |
+| content_type?6 | string | The media type hint (1-128 characters) |
+| source_url?4 | string | The feed or evidence URL (1-2048 characters) |
+| reason?7 | string | The reason (1-1024 characters) |
+| notes?8 | string | The internal note (1-1024 characters) |
+
+3 Required by `profile-substring`. Every other blocklist strips `scope`
+
+4 Accepted only by `url`, `url-domain`, `file-sha`, and `avatar-hash`
+
+5 Accepted only by `url-domain`
+
+6 Accepted only by `file-sha`
+
+7 Accepted only by `avatar-hash` and `profile-substring`, and recorded in the audit entry metadata
+
+8 Accepted only by `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring`
+
+## List blocklists
+
+
+
+Returns one [blocklist](#blocklist-object) object for every blocklist this instance maintains. Requires any one of the nine `check` permissions.
+
+The response is identical for every Admin and does not change when entries are added or removed.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| items1 | array[[blocklist](#blocklist-object) object] | Every blocklist exposed by this instance |
+
+1 Always the complete catalogue in one response. There is no cursor, no `has_more`, and no `next_after`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Catalogue was returned |
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## List blocklist entries
+
+
+
+Returns a value-ordered cursor page of [blocklist entry](#blocklist-entry-object) objects. Requires the selected list's `check` permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Maximum entries to return (1-200, default 50) |
+| after?1 | string | Return entries ordered strictly after this value (1-2048 characters) |
+| scope?2 | string | The [profile substring scope](#profile-substring-scopes) to list |
+
+1 Compared as a string against the canonical stored value. Fluxer does not validate it against the selected blocklist, so an impossible cursor returns no entries
+
+2 Required by `profile-substring` and rejected by every other blocklist
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| items | array[[blocklist entry](#blocklist-entry-object) object] | Entries in this page, ordered by value ascending |
+| has_more | boolean | Whether another page can be fetched with `next_after` |
+| next_after3 | ?string | The stored entry value to send as `after`, or null on the last page |
+
+3 A value cursor, so it is sent back verbatim as `after`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Entry page was returned |
+| 400 | [error response](/admin-api/#error-response) | `scope` is supplied on an unscoped blocklist or omitted on `profile-substring`, and the request returns `INVALID_FORM_BODY` |
+
+The cost of a page does not fall as the cursor advances, so walk a large list with the largest `limit` the operation accepts.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Add blocklist entry
+
+
+
+Canonicalises the submitted value or values and writes them to the selected blocklist. Returns 204 with an empty body. Requires the selected list's `add` permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) |
+
+### JSON body
+
+The body is one [blocklist entry creation object](#blocklist-entry-creation-object). `list_type` selects the shape, so a body valid for one blocklist fails validation on another.
+
+:::caution[Adding a row overwrites the existing row]
+Every write is an upsert on the canonical value. An omitted optional field is written as its default, so re-adding a `url` without a `category` resets that row to `manual`. The previous values are not recoverable.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Values were written |
+| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` because the value is not valid for the list, or a field the blocklist does not accept was supplied |
+
+A body field the selected blocklist does not accept is stripped and never produces that 400. A `url` Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url` in the `errors` array.
+
+Fluxer refuses to add an `ip` with 400 `IP_BAN_DECLINED` when the address is on the instance exemption list, and when a single address is classified as a high blast-radius mobile or carrier network. It skips the blast-radius guard for a CIDR range, and a lookup failure counts as no risk, so the address is written.
+
+The response has no body, so it does not report the canonical form that was stored. Read it back with [List blocklist entries](#list-blocklist-entries).
+
+### Side effects
+
+The written rows take effect for subsequent blocklist decisions. For every list except `email` and `email-domain-suspicious`, other nodes see the rows after a short propagation delay. No Gateway Dispatch is emitted.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded per written value, with that value in its metadata. Fluxer records an entry for a refused `ip` too, under the action `ban_ip_skipped_exempt` or `ban_ip_skipped_cgnat`, before it returns the 400.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Bulk add blocklist entries
+
+
+
+Enqueues a background job that adds up to 10000 SHA-256 hashes to the `file-sha` blocklist and returns the job ID immediately. Requires `ban:file_sha:add`.
+
+Only `file-sha` accepts this operation, reported as `supports_bulk_create` by [List blocklists](#list-blocklists). Every other list type returns 400 `INVALID_FORM_BODY`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types), which is `file-sha` here |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sha256_list1 | array[string] | The SHA-256 hashes to add (1-10000 items of exactly 64 characters) |
+
+1 The body enforces the length of each item. The job checks the alphabet and records a non-hexadecimal item as an `invalid_sha256` failure
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id | snowflake | The background job applying the hashes |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Job was queued |
+| 400 | [error response](/admin-api/#error-response) | The list type is not `file-sha` and the request returns `INVALID_FORM_BODY` |
+
+The operation returns before any hash is written. Read progress and the final counts from [Get job](/admin-api/jobs/#get-job), and stop the job with [Cancel job](/admin-api/jobs/#cancel-job). The job reports progress every 50 hashes and finishes with a summary naming how many were written and how many failed.
+
+Every hash is written with the category `manual`, the severity `2`, and a null content type, source URL, and note. The bulk path accepts no metadata, so a hash that needs any of those goes through [Add blocklist entry](#add-blocklist-entry) or takes a later [Update blocklist entry](#update-blocklist-entry).
+
+### Side effects
+
+The job lowercases each hash and writes it as an upsert. It publishes one refresh notice after the last hash, so a job cancelled part way leaves the hashes it wrote invisible to other nodes until a later write or the twelve-hourly feed sync publishes one. No Gateway Dispatch is emitted.
+
+The job records one aggregate [Admin audit entry](/admin-api/#admin-audit-entry-object) under the action `bulk_ban_file_shas`, with the submitted, successful, and failed counts. A cancelled job records none.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Bulk remove blocklist entries
+
+
+
+Canonicalises up to 1000 values and removes their rows from the selected blocklist. Returns 204 with an empty body. Requires the selected list's `remove` permission.
+
+Only `avatar-hash` and `profile-substring` accept this operation, reported as `supports_bulk_delete` by [List blocklists](#list-blocklists). Every other list type returns 400 `INVALID_FORM_BODY`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types), which is `avatar-hash` or `profile-substring` here |
+
+### JSON body
+
+The body is selected by `list_type`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| hashes1 | array[string] | Avatar hashes to remove (1-1000 items of 8 through 10 characters matching `^(a_)?[0-9a-fA-F]{8}$`) |
+| scope2 | string | The [profile substring scope](#profile-substring-scopes) the substrings are stored under |
+| substrings2 | array[string] | Substrings to remove (1-1000 items of 1 through 500 characters) |
+
+1 `avatar-hash` only, where it is the whole body
+
+2 `profile-substring` only, where both are required. The body reuses the creation shape, so `reason` and `notes` parse and are then ignored
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Values were processed |
+| 400 | [error response](/admin-api/#error-response) | The list accepts no bulk removal, or the body fails validation, and the request returns `INVALID_FORM_BODY` |
+
+The operation is idempotent and reports no counts. A value with no stored row still returns 204. Read the list before and after to tell a removal from a no-op.
+
+`avatar-hash` deduplicates the array after canonicalisation. `profile-substring` does not, so a substring repeated in one request is removed once and audited once per occurrence, and a value that canonicalises to an empty string is skipped.
+
+### Side effects
+
+Every removed row stops affecting subsequent blocklist decisions, and one refresh notice follows the last value. A value can remain blocked by another matching row. No Gateway Dispatch is emitted.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per canonical value processed.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Check blocklist entry
+
+
+
+Reports whether one value is currently blocked by the selected blocklist and returns a [blocklist check](#blocklist-check-object) object. Requires the selected list's `check` permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) |
+| entry_value1 | string | The percent-encoded value to check (1-2048 characters) |
+
+1 Percent encoding is required because a canonical value can contain a slash. Fluxer checks the value for length alone, so a value in the wrong form returns 200 with `banned` false
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope?2 | string | The [profile substring scope](#profile-substring-scopes) to check the value against |
+
+2 Required by `profile-substring` and rejected by every other blocklist
+
+### Matching rule by blocklist
+
+| Blocklist | Rule |
+| --- | --- |
+| ip34 | The address itself, any stored CIDR range containing it, and any stored address the instance treats as the same origin |
+| email | Exact match on the lowercased address |
+| email-domain-suspicious5 | Exact match on the lowercased domain |
+| phrase | Normalised phrase matching, so an obfuscated form of a stored phrase still reads as blocked |
+| url6 | Exact match on the canonicalised URL |
+| url-domain | Exact match on the lowercased hostname |
+| file-sha | Exact match on the lowercased hexadecimal digest |
+| avatar-hash | Exact match after the `a_` prefix is stripped and the hash is lowercased |
+| profile-substring | Normalised substring matching within the named scope |
+
+3 Any value that is not a single address, including a CIDR range, always reads as not blocked. An address on the instance exemption list reads as not blocked even while a row exists
+
+4 Two addresses are the same origin when they share a decision key, which is the exact address for IPv4 and the `/64` prefix for IPv6
+
+5 A domain the account policy exempts from contact-domain reputation reads as not blocked even while a row exists
+
+6 This check does not consult the `url-domain` list, so a URL blocked in practice by a stored domain reads as not blocked. Check the hostname separately
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [blocklist check](#blocklist-check-object) object | Value was checked |
+| 400 | [error response](/admin-api/#error-response) | `scope` is supplied on an unscoped blocklist or omitted on `profile-substring`, and the request returns `INVALID_FORM_BODY` |
+
+A value with no row and no covering match returns 200 with `banned` false, so this operation never reports whether a specific row exists. Use [List blocklist entries](#list-blocklist-entries) for that.
+
+For every list except `email` and `email-domain-suspicious`, a row written on another node becomes visible here after a short propagation delay.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Update blocklist entry
+
+
+
+Rewrites the stored fields of one row without removing and re-adding it. Returns 204 with an empty body. Requires the selected list's `add` permission.
+
+Only `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring` accept this operation, reported as `supports_update` by [List blocklists](#list-blocklists). A list with no stored fields has nothing to update and returns 400 `INVALID_FORM_BODY`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) |
+| entry_value | string | The percent-encoded canonical value of the row to write (1-2048 characters) |
+
+### JSON body
+
+The body is the creation shape of the selected blocklist with the value field removed, so the same field list and the same bounds apply.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope1 | string | The [profile substring scope](#profile-substring-scopes) to write the row under |
+| category?2 | string | The [content blocklist category](#content-blocklist-categories) (1-64 characters, default `manual`) |
+| severity?2 | integer | The [content blocklist severity](#content-blocklist-severities) (0-3, default 2) |
+| match_subdomains?3 | boolean | Whether the row is flagged as covering subdomains (default true) |
+| content_type?4 | string | The media type hint (1-128 characters) |
+| source_url?2 | string | The feed or evidence URL (1-2048 characters) |
+| reason?5 | string | The reason (1-1024 characters) |
+| notes?6 | string | The internal note (1-1024 characters) |
+
+1 Required by `profile-substring`. This operation takes no query parameters, so the scope travels in the body, and every other blocklist ignores it there
+
+2 Accepted only by `url`, `url-domain`, `file-sha`, and `avatar-hash`
+
+3 Accepted only by `url-domain`
+
+4 Accepted only by `file-sha`
+
+5 Accepted only by `avatar-hash` and `profile-substring`, and recorded in the audit entry metadata
+
+6 Accepted only by `url`, `url-domain`, `file-sha`, `avatar-hash`, and `profile-substring`
+
+:::caution[An omitted field is written as its default]
+Sending `{"severity": 3}` on a `url` row also resets its category to `manual` and clears its source URL and notes. Resend every field the row is meant to keep.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Row was written |
+| 400 | [error response](/admin-api/#error-response) | The list accepts no update, or the body fails validation, and the request returns `INVALID_FORM_BODY` |
+
+On the `url` blocklist, an `entry_value` Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url` in the `errors` array.
+
+The write is an upsert on the canonical value, so a value with no existing row is created. This operation never returns 404. A `url` blocked only by a stored `url-domain` row has no row of its own, and updating it through the URL silently creates a new exact-URL row.
+
+Changing the `scope` of a `profile-substring` row writes a second row under the new scope and leaves the original in place. Removing the original takes [Remove blocklist entry](#remove-blocklist-entry).
+
+### Side effects
+
+The written fields take effect for subsequent matches, and the write publishes a refresh notice. No Gateway Dispatch is emitted.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded under the same action an add records, with the canonical value. The audit entry has no previous values of the changed fields.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Remove blocklist entry
+
+
+
+Removes one row from the selected blocklist. Returns 204 with an empty body. Requires the selected list's `remove` permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| list_type | string | The [blocklist type](#blocklist-types) |
+| entry_value1 | string | The percent-encoded canonical value of the row to remove (1-2048 characters) |
+
+1 Canonicalised the same way it was on the add path and matched exactly, so a `url` blocked only by a broader `url-domain` row cannot be removed through the URL
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope?2 | string | The [profile substring scope](#profile-substring-scopes) the row is stored under |
+
+2 Required by `profile-substring` and rejected by every other blocklist
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Value was processed |
+| 400 | [error response](/admin-api/#error-response) | `scope` is supplied on an unscoped blocklist or omitted on `profile-substring`, and the request returns `INVALID_FORM_BODY` |
+
+On the `url` blocklist, an `entry_value` Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url` in the `errors` array.
+
+The operation is idempotent. A value with no stored row returns 204 and still records an Admin audit entry, so this operation never returns 404 and never reports whether anything was removed.
+
+### Side effects
+
+The removed row stops affecting subsequent blocklist decisions, and other nodes stop applying it after a short propagation delay for every list except `email` and `email-domain-suspicious`. A value can remain blocked by another matching row, such as a single address covered by a stored CIDR range. No Gateway Dispatch is emitted.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with the canonical value in its metadata.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
+
+## Block a user's current avatar
+
+
+
+Adds the account's current avatar hash to the `avatar-hash` blocklist and returns the stored hash. Requires `ban:avatar_hash:add`.
+
+This is a shortcut over [Add blocklist entry](#add-blocklist-entry) for blocking an avatar an Admin is looking at. The operation reads the current avatar hash and strips any `a_` animation prefix, so the stored value is the 8-character form. It writes the same row an add writes, with the category `manual`, the severity `2`, and a null source URL. It does not ban, suspend, or otherwise act on the account.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The account whose current avatar is blocked |
+
+### JSON body
+
+Both fields are optional. An empty body and `{}` are both valid requests.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| reason?1 | string | The reason (1-1024 characters) |
+| notes?2 | string | The internal note (1-1024 characters) |
+
+1 Recorded in the metadata of the Admin audit entry, so no read of the row returns it
+
+2 Stored on the row. Omitting it stores the generated note `banned via user shortcut user_id=`
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| hash_short | string | The stored 8-character avatar hash |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Avatar hash was written |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER` when the account does not exist, or `NOT_FOUND` when the account has no avatar |
+
+The account keeps the blocked avatar until it next sets one. Clearing it is a separate request naming `avatar` in the `fields` array of [Clear user profile fields](/admin-api/users/#clear-user-profile-fields).
+
+Every account using that image shares the same truncated prefix, so blocking the hash blocks the image for every account.
+
+### Side effects
+
+The hash takes effect for subsequent avatar uploads, and the write publishes a refresh notice. No Gateway Dispatch is emitted.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded under the action `ban_avatar_hash`, with the stored hash and the supplied reason.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `admin:ban:operation` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx b/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx
new file mode 100644
index 000000000..407c52531
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx
@@ -0,0 +1,171 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Bulk jobs
+description: Administrative bulk mutations over an explicit user or guild set.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A bulk job applies one operation to an explicit set of users or guilds. The `task` discriminator selects the payload contract, the [Admin ACL](/admin-api/#acl-registry) evaluated for the request, and the worker task that runs.
+
+Every target is a list of IDs. There is no search, role, tag, email, or IP selector, so the caller resolves its own target set first with [List users](/admin-api/users/#list-users) or [List guilds](/admin-api/guilds/#list-guilds).
+
+[Queue bulk job](#queue-bulk-job) returns a job identifier immediately and applies nothing synchronously. The caller reads progress, terminal state, and failure text through [Get job](/admin-api/jobs/#get-job). [Cancel job](/admin-api/jobs/#cancel-job) stops a run.
+
+## Bulk job creation object
+
+A bulk job creation has the identifier of the queued job.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id1 | snowflake | The job that applies the requested operation, readable through [Get job](/admin-api/jobs/#get-job) |
+
+1 Fluxer writes the ledger row before it enqueues the job, so the identifier in a 200 is always readable
+
+### Example
+
+```json
+{
+ "job_id": "1501314428688998182"
+}
+```
+
+## Bulk task types
+
+Each value is the exact `task` discriminator.
+
+### `update_user_flags`
+
+ACL `bulk:update:user_flags`. Adds and removes account flags on every targeted user. Runs as `bulkUpdateUserFlags`.
+
+### `update_suspicious_activity_flags`
+
+ACL `bulk:update:suspicious_activity`. Adds and removes verification requirements on every targeted user. Runs as `bulkUpdateSuspiciousActivityFlags`.
+
+### `update_guild_features`
+
+ACL `bulk:update:guild_features`. Adds and removes features on every targeted guild. Runs as `bulkUpdateGuildFeatures`.
+
+### `add_guild_members`
+
+ACL `bulk:add:guild_members`. Adds every targeted user to one guild. Runs as `bulkAddGuildMembers`.
+
+### `schedule_user_deletion`
+
+ACL `bulk:delete:users`. Schedules account deletion for every targeted user. Runs as `bulkScheduleUserDeletion`.
+
+Each worker task is recorded as `task_type` on the job and listed under [background job task types](/admin-api/jobs/#background-job-task-types). All five run in the `lifecycle` [processing lane](/admin-api/jobs/#processing-lanes).
+
+## Queue bulk job
+
+
+
+Queues one bulk operation. Returns a [bulk job creation](#bulk-job-creation-object) object on success.
+
+### Limitations
+
+- The body matches exactly one `task` variant.
+- The caller needs at least one of the five [bulk ACLs](#bulk-task-types) and then the exact ACL that `task` selects.
+
+:::caution[Authorisation depends on the validated body]
+Body validation runs ahead of authentication and ACL evaluation, so a malformed body answers 400 even when the request has no credential.
+:::
+
+### JSON body
+
+The `task` discriminator selects one of these five structures. Every ID array has an upper bound and no lower bound, so an empty array queues a job that processes nothing and settles as `succeeded`.
+
+#### Update user flags structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| task | string | The discriminator selecting this variant, `update_user_flags` |
+| user_ids | array[snowflake] | The users to update (max 1000 entries) |
+| add_flags?1 | array[string] | [Account flag](/admin-api/users/#account-flags) values to add (max 64, default empty) |
+| remove_flags?1 | array[string] | [Account flag](/admin-api/users/#account-flags) values to remove (max 64, default empty) |
+
+1 Each entry is one 64-bit flag value as an unsigned decimal string, such as `1024`, rather than a symbolic name. Additions are applied before removals, so a value named in both arrays ends up cleared
+
+#### Update suspicious activity flags structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| task | string | The discriminator selecting this variant, `update_suspicious_activity_flags` |
+| user_ids | array[snowflake] | The users to update (max 1000 entries) |
+| add_flags?12 | array[string] | [Suspicious activity flag](/admin-api/users/#suspicious-activity-flags) names to add (max 32, default empty) |
+| remove_flags?1 | array[string] | [Suspicious activity flag](/admin-api/users/#suspicious-activity-flags) names to remove (max 32, default empty) |
+
+1 Each entry is the symbolic flag name such as `REQUIRE_VERIFIED_PHONE`. An entry naming no known flag is ignored, and additions are applied before removals
+
+2 Adding `REQUIRE_VERIFIED_PHONE` or `REQUIRE_REVERIFIED_PHONE` also clears the deferred phone bit 1 << 16, which turns a deferred phone requirement into an immediate one
+
+#### Update guild features structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| task | string | The discriminator selecting this variant, `update_guild_features` |
+| guild_ids | array[snowflake] | The guilds to update (max 1000 entries) |
+| add_features?1 | array[string] | [Guild features](/http-api/guilds/#guild-features) to add (max 100, default empty) |
+| remove_features?1 | array[string] | [Guild features](/http-api/guilds/#guild-features) to remove (max 100, default empty) |
+
+1 The boundary accepts any string, so a name outside the registry is written to the guild's feature set. Additions are applied before removals
+
+#### Add guild members structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| task | string | The discriminator selecting this variant, `add_guild_members` |
+| guild_id | snowflake | The guild that receives the users |
+| user_ids | array[snowflake] | The users to add as members (max 1000 entries) |
+
+#### Schedule user deletion structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| task | string | The discriminator selecting this variant, `schedule_user_deletion` |
+| user_ids | array[snowflake] | The users to schedule for deletion (max 1000 entries) |
+| reason_code | integer | [Deletion reason](/admin-api/users/#deletion-reasons) recorded against every targeted account |
+| public_reason? | string | The reason shown to each account holder in the deletion notice (0-512 characters after normalisation) |
+| days_until_deletion?1 | integer | The delay before deletion in whole days (1-365, default 60) |
+
+1 Fluxer raises the value to the reason-specific minimum, 14 days for `USER_REQUESTED` and 60 days for every other code
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [bulk job creation](#bulk-job-creation-object) object | The bulk job was queued |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL` without the ACL selected by `task` |
+| 5001 | [error response](/admin-api/#error-response) | The job could not be queued |
+
+1 A full queue rejects the job, which surfaces as `INTERNAL_SERVER_ERROR`
+
+The 200 confirms that the job was queued. [Get job](/admin-api/jobs/#get-job) reports whether its mutations completed.
+
+### Side effects
+
+This operation writes no Admin audit entry. It records one [Jobs](/admin-api/jobs/) ledger row naming the acting Admin as the requester and storing the audit reason, then enqueues the worker task. When the ledger row cannot be written, Fluxer returns 500 `INTERNAL_SERVER_ERROR` and enqueues nothing.
+
+The worker processes entities in the submitted order, one at a time. A cancellation check runs before each entity, so [Cancel job](/admin-api/jobs/#cancel-job) stops the run between two entities and settles the job as `cancelled`. Every entity changed before cancellation or failure stays changed. An entity that fails, including an ID that resolves to nothing, is counted as failed and skipped, and the run continues through the rest of the set. The worker reports progress before the run starts, after every 25 entities, and once at the end. `schedule_user_deletion` reports after every 10 accounts instead. The closing progress message has the successful and failed counts.
+
+Every task writes one summary Admin audit entry when it finishes, with the action `bulk_update_user_flags`, `bulk_update_suspicious_activity_flags`, `bulk_update_guild_features`, `bulk_add_guild_members`, or `bulk_schedule_deletion`. The summary has the audit reason, the entity count, the operation-specific parameters, and the successful and failed counts. Its `target_id` is the guild for `add_guild_members` and `0` for the four tasks that target a set. A cancelled or failed job writes no summary entry.
+
+Updating account flags writes one `update_flags` entry for each account and dispatches [User Update](/gateway/events/#user-update) to the account's sessions. A change to a publicly visible flag also dispatches [Guild Member Update](/gateway/events/#guild-member-update) to every guild the account is in.
+
+Updating suspicious activity flags rewrites each account's verification requirements and dispatches [User Update](/gateway/events/#user-update). No [Guild Member Update](/gateway/events/#guild-member-update) follows. The task writes no per-account audit entry.
+
+Updating guild features writes one `update_features` entry for each guild, dispatches [Guild Update](/gateway/events/#guild-update), and reindexes the guild for search. Fluxer reconciles a guild that already has a discovery application record against the new feature set, so gaining `DISCOVERABLE` approves the record and losing it marks the record removed. A guild with no discovery record is left alone.
+
+Adding guild members bypasses the ban check and the risk gate. The task suppresses the join system message, records the join source as an Admin force add, and dispatches [Guild Member Add](/gateway/events/#guild-member-add) to the guild and [Guild Create](/gateway/events/#guild-create) to the added account's sessions. The task still enforces the per-account guild cap and the guild member cap, so an account at either ceiling is counted as failed. An account that is already a member is left unchanged and counted as successful, with no second membership and no Dispatch. Adding a bot account also records a `BOT_ADD` guild audit log entry attributed to the acting Admin.
+
+Scheduling deletion marks each account deleted. The task stores the reason code, public reason, and audit reason on the account, and reschedules its pending deletion. It dispatches [User Update](/gateway/events/#user-update), writes one `schedule_deletion` entry for each account, and emails the account holder when an address is on file. A failed email is logged and does not fail the entity.
+
+:::caution[Bulk scheduling is narrower than the single-account operation]
+The worker only schedules the deletion. It does not terminate sessions, cancel or refund a Stripe subscription, ban the account's identifiers, or resolve pending reports, all of which [Schedule user deletion](/admin-api/users/#schedule-user-deletion) performs for one account, the last two only when the reason is not `USER_REQUESTED`.
+:::
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `admin:bulk:operation` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/discovery.mdx b/fluxer_docs/src/content/docs/admin-api/discovery.mdx
new file mode 100644
index 000000000..6404c8ee0
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/discovery.mdx
@@ -0,0 +1,429 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin discovery
+description: Discovery application review, listing reads, category moves, and removal.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Admin discovery is the review side of the public guild directory. An Admin decides the application a guild manager submits, reads and edits the approved listing, moves it between categories, and removes the guild from the directory. A guild has at most one application, and the public [Discovery](/http-api/discovery/) resource drives the same lifecycle from the guild's side.
+
+Every operation except [Remove discovery listing](#remove-discovery-listing) requires `discovery:review`, which covers the four reads, [Review discovery application](#review-discovery-application), [Move discovery listings to a category](#move-discovery-listings-to-a-category), and [Update discovery listing](#update-discovery-listing). [Remove discovery listing](#remove-discovery-listing) requires `discovery:remove` instead. Neither implies the other.
+
+No operation here reads `X-Audit-Log-Reason` or records an Admin audit entry. An operation that stores a reason takes it in its request body.
+
+:::note[Review runs regardless of the discovery setting]
+An operator can disable discovery for the whole instance. Only the public [Discovery](/http-api/discovery/) routes read that state, and they return 400 `DISCOVERY_DISABLED` while it is off, so an application can be reviewed into a directory no account can see.
+:::
+
+## Admin pending application object
+
+A guild's submitted application, together with the guild details Fluxer resolves at request time.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild that applied to discovery |
+| guild_name1 | string | Current guild name |
+| guild_icon1 | ?string | Current guild icon hash, or null when the guild has none |
+| guild_owner_id1 | snowflake | Current guild owner |
+| guild_owner_username2 | ?string | Username of the owner |
+| guild_owner_global_name2 | ?string | Display name of the owner, or null when unset |
+| guild_owner_discriminator23 | ?string | Discriminator of the owner |
+| guild_member_count14 | integer | The member count on the guild record |
+| guild_nsfw_level1 | ?integer | Current [NSFW level](/http-api/guilds/#nsfw-levels) of the guild |
+| guild_features15 | array[string] | Current [guild features](/http-api/guilds/#guild-features) |
+| description | string | Listing description of 10 through 300 characters |
+| category_type6 | integer | [Discovery category](/http-api/discovery/#discovery-categories) the listing is filed under |
+| primary_language7 | ?string | [Supported primary language](/http-api/discovery/#supported-primary-languages) code |
+| custom_tags68 | array[string] | Normalised [custom tags](/http-api/discovery/#custom-tags) of the listing |
+| applied_at | ISO8601 timestamp | Time at which the guild applied |
+
+1 Resolved from the guild at request time. A guild that can no longer be resolved yields the literal name `(unknown guild)`, a null icon, the owner ID `0`, a member count of 0, a null NSFW level, an empty feature array, and null for all three owner name fields
+
+2 Read from the owner account, so it is null when the guild resolved but the owner account did not
+
+3 Rendered as four digits with leading zeroes
+
+4 The count stored on the guild record, so it is approximate
+
+5 An approved application has the discoverable feature here
+
+6 A stored null reads as `0` for the category and as an empty array for the tags
+
+7 A stored null reads as `en-US`, so this read does not produce null
+
+8 Stored already trimmed, lowercased, and whitespace collapsed, so a tag does not round-trip in the casing it was submitted with
+
+### Example
+
+```json
+{
+ "guild_id": "1478812292088791040",
+ "guild_name": "Example Guild",
+ "guild_icon": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
+ "guild_owner_id": "1478812292088791041",
+ "guild_owner_username": "example",
+ "guild_owner_global_name": "Example",
+ "guild_owner_discriminator": "0042",
+ "guild_member_count": 1204,
+ "guild_nsfw_level": 0,
+ "guild_features": ["COMMUNITY"],
+ "description": "A place to talk about building things.",
+ "category_type": 3,
+ "primary_language": "en-US",
+ "custom_tags": ["makers", "diy"],
+ "applied_at": "2026-08-19T14:03:55.000Z"
+}
+```
+
+## Admin discovery listing object
+
+An approved listing. It has every field of the [Admin pending application object](#admin-pending-application-object) with the same meaning, plus the field below.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| approved_at1 | ?ISO8601 timestamp | Time at which the application was approved |
+
+1 The review time of the application, so a guild approved automatically on submission has its submission time here
+
+## Discovery application object
+
+This is the [discovery application object](/http-api/discovery/#discovery-application-object) of the public Discovery resource. Every write on this page answers with it, so a client re-reads the listing after a write to render the enriched guild fields.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the application belongs to |
+| guild_nsfw_level?1 | ?integer | [NSFW level](/http-api/guilds/#nsfw-levels) of the guild |
+| status | string | [Discovery application status](/http-api/discovery/#discovery-application-statuses) |
+| description | string | The description shown on the listing |
+| category_type | integer | [Discovery category](/http-api/discovery/#discovery-categories) the listing is filed under |
+| primary_language | ?string | [Supported primary language](/http-api/discovery/#supported-primary-languages) code |
+| custom_tags | array[string] | Normalised [custom tags](/http-api/discovery/#custom-tags) of the listing |
+| applied_at | ISO8601 timestamp | Time at which the guild applied |
+| reviewed_at2 | ?ISO8601 timestamp | Time at which the application was approved or rejected, or null while it is pending |
+| review_reason3 | ?string | Reason recorded with the approval or rejection, or null |
+| removed_at4 | ?ISO8601 timestamp | Time at which an approved listing was removed, or null |
+| removal_reason4 | ?string | Reason recorded with the removal, or null |
+
+1 No route on this page populates it. Read the level from the [Admin pending application object](#admin-pending-application-object) or the [guild object](/http-api/guilds/#guild-object) instead
+
+2 An application approved automatically on submission has its submission time here
+
+3 Null for an approval that supplied no reason and for an automatic approval. A rejection always has one
+
+4 Both fields are set only by [Remove discovery listing](#remove-discovery-listing)
+
+### Example
+
+```json
+{
+ "guild_id": "1478812292088791040",
+ "status": "approved",
+ "description": "A place to talk about building things.",
+ "category_type": 3,
+ "primary_language": "en-US",
+ "custom_tags": ["makers", "diy"],
+ "applied_at": "2026-08-19T14:03:55.000Z",
+ "reviewed_at": "2026-08-20T09:11:02.000Z",
+ "review_reason": null,
+ "removed_at": null,
+ "removal_reason": null
+}
+```
+
+## Reviewer identity
+
+The application records the Admin that approved, rejected, or removed it. No response on this page exposes that value.
+
+## Discovery categories
+
+Each release ships a fixed category set, and no Admin operation changes it. See [discovery categories](/http-api/discovery/#discovery-categories) for the set.
+
+Every `category_type` and `category_id` here is an integer of 0 through 8. A value outside that range fails path, query, or body validation.
+
+## List discovery applications
+
+
+
+Returns every pending application as an array of [Admin pending application](#admin-pending-application-object) objects. Requires `discovery:review`.
+
+Applications arrive in storage order, keyed by application time and then by guild ID. That direction is not specified, so a client that needs a stable order sorts the array itself. The response is a bare array, and the operation accepts no cursor and no limit.
+
+A guild approved automatically on submission never appears here. An application whose stored record can no longer be read is dropped from the array. The request still succeeds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[Admin pending application](#admin-pending-application-object) object] | Pending applications were returned |
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `discovery:admin:list` bucket.
+
+## Review discovery application
+
+
+
+Approves or rejects a pending application and returns the updated [discovery application](#discovery-application-object) object. Requires `discovery:review`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild that applied to discovery |
+
+### JSON body
+
+`status` is the decision, and the constraint on `reason` depends on which one it is.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status1 | approved \| rejected | Decision to apply |
+| reason23 | string | Reason stored on the application as `review_reason` (at most 500 characters) |
+
+1 A value outside the two listed fails body validation. Neither `removed` nor `pending` is accepted
+
+2 Optional when `status` is `approved`, and omitting it stores null. An empty string fails body validation with 400 `INVALID_FORM_BODY`
+
+3 Required and at least one character when `status` is `rejected`, so a rejection without a reason fails body validation
+
+:::caution[Review is a one-way transition]
+An application that is already approved, rejected, or removed fails with 409 `DISCOVERY_APPLICATION_ALREADY_REVIEWED`, and nothing returns a reviewed application to pending. Listing a removed or rejected guild again requires a new application.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery application](#discovery-application-object) object | Decision was applied |
+| 404 | [error response](/admin-api/#error-response) | The guild holds no application, returned as `DISCOVERY_APPLICATION_NOT_FOUND` |
+| 409 | [error response](/admin-api/#error-response) | The application is not pending, returned as `DISCOVERY_APPLICATION_ALREADY_REVIEWED` |
+
+### Side effects
+
+An approval moves the application to `approved` and records the review time, the reviewing Admin, and the supplied reason. It adds the discoverable feature to the guild and indexes the listing, so the guild appears in [Search discovery guilds](/http-api/discovery/#search-discovery-guilds). [Guild Update](/gateway/events/#guild-update) fires to every session that can observe the guild. Indexing is skipped on an instance with no search backend configured, and the approval still succeeds.
+
+Fluxer moves a rejected application to `rejected` and records the review time, the reviewing Admin, and the required reason. It leaves every guild field as it is and emits no Gateway Dispatch.
+
+An approval whose guild can no longer be resolved still succeeds. Fluxer skips the feature grant and the Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `discovery:admin:action` bucket.
+
+## List discovery categories
+
+
+
+Returns the fixed [discovery category](/http-api/discovery/#discovery-categories) set as an array of [discovery category](/http-api/discovery/#discovery-category-object) objects, in ascending ID order. Requires `discovery:review`.
+
+It reports the same set on every instance running the same release and has no listing counts. Names are the release's display names and are not localised.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[discovery category](/http-api/discovery/#discovery-category-object) object] | Categories were returned |
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `discovery:admin:list` bucket.
+
+## List guilds in a discovery category
+
+
+
+Returns an offset page of the approved listings filed under one category, most members first, as an array of [Admin discovery listing](#admin-discovery-listing-object) objects. Requires `discovery:review`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| category_id | integer | [Discovery category](/http-api/discovery/#discovery-categories) of 0 through 8 |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Listings to return (1-100, default 50) |
+| offset?1 | integer | Listings to skip (0-10000, default 0) |
+
+1 Applied after the category filter and the member sort, so raise it to page a category
+
+A rebuild of the discovery search index does not change what it answers. It reports no total, so a page shorter than `limit` is the only end-of-category signal. A listing whose guild could not be resolved sorts as if it had no members and lands at the end.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[Admin discovery listing](#admin-discovery-listing-object) object] | The page was returned |
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `discovery:admin:list` bucket.
+
+## List discovery listings
+
+
+
+Returns every approved listing as an array of [Admin discovery listing](#admin-discovery-listing-object) objects. Requires `discovery:review`.
+
+Listings arrive in the same unspecified storage order as [List discovery applications](#list-discovery-applications). The response is a bare array, and the operation accepts no cursor and no limit. A pending, rejected, or removed application is never returned.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[Admin discovery listing](#admin-discovery-listing-object) object] | Approved listings were returned |
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `discovery:admin:list` bucket.
+
+## Move discovery listings to a category
+
+
+
+Files every named guild under one [discovery category](/http-api/discovery/#discovery-categories) and reports how many moved. Requires `discovery:review`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_ids1 | array[snowflake] | Guilds to move, 1 through 100 entries |
+| category_type | integer | [Discovery category](/http-api/discovery/#discovery-categories) of 0 through 8 to file every named guild under |
+
+1 Duplicate entries are collapsed before the move, so a guild named twice is attempted once and counted once
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| updated | integer | Number of distinct guilds that were moved |
+| failed_guild_ids1 | array[snowflake] | Distinct guilds that were not moved |
+
+1 A guild lands here when it holds no application, when its application is rejected or removed, or when the write failed. The response does not say which, so retry the guild through [Update discovery listing](#update-discovery-listing) to read the error
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The batch ran, whether or not every guild moved |
+
+:::caution[Some named guilds can fail inside a 200]
+Fluxer attempts the guilds one at a time and in order, and a failure does not stop the ones after it. Read `failed_guild_ids` for the guilds that did not move. The operation never returns 404.
+:::
+
+Repeating the request is safe. A pending application moves on the same terms as an approved one, so this operation can refile a guild that is still awaiting review.
+
+### Side effects
+
+A move replaces the application's `category_type` and preserves its description, language, tags, status, and timestamps. A moved listing that is already approved appears under the new category in the next public search. No guild field changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `discovery:admin:action` bucket.
+
+## Update discovery listing
+
+
+
+Updates the stored listing of one guild and returns the updated [discovery application](#discovery-application-object) object. Requires `discovery:review`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id1 | snowflake | Guild whose application is edited |
+
+1 Despite the route path, a pending application is accepted as well as an approved listing, which lets an Admin correct the wording before approving it
+
+### JSON body
+
+Every member is optional. An omitted member preserves the stored value, and an empty object is accepted and changes nothing.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| description?1 | string | Listing description of 10 through 300 characters |
+| category_type? | integer | [Discovery category](/http-api/discovery/#discovery-categories) of 0 through 8 |
+| primary_language?2 | string | [Supported primary language](/http-api/discovery/#supported-primary-languages) code |
+| custom_tags?34 | array[string] | Up to 10 [custom tags](/http-api/discovery/#custom-tags) |
+
+1 A supplied value is scanned against the instance content blocklists before the stored application is read, so a match returns 403 `CONTENT_BLOCKED` even when the guild holds no application. The scan covers banned phrases and banned URLs and domains found in the text
+
+2 A code outside the supported set fails body validation. A language is always stored, so the listing cannot be returned to having none
+
+3 A supplied array replaces the stored tags outright, so an empty array clears every tag
+
+4 Each entry is 2 through 30 characters both as submitted and after normalisation. An entry that fails that bound or the character rule fails body validation against its own index, and duplicate normalised values collapse to the first occurrence
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery application](#discovery-application-object) object | Listing was updated, or the submitted values already matched the stored ones |
+| 403 | [error response](/admin-api/#error-response) | The description matches a content blocklist, returned as `CONTENT_BLOCKED` |
+| 404 | [error response](/admin-api/#error-response) | The guild holds no application, returned as `DISCOVERY_APPLICATION_NOT_FOUND` |
+| 409 | [error response](/admin-api/#error-response) | The stored application is rejected or removed, returned as `DISCOVERY_APPLICATION_ALREADY_REVIEWED` |
+
+### Side effects
+
+The supplied members update the application while preserving its status, submission time, review time, review reason, and removal fields. Editing an approved listing does not return it to review. The new copy is visible in the next public search. Editing a pending application changes nothing a public reader can see.
+
+No guild field changes, so no [Guild Update](/gateway/events/#guild-update) fires.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `discovery:admin:action` bucket.
+
+## Remove discovery listing
+
+
+
+Removes an approved listing from discovery, strips the guild's discoverable feature, and returns the updated [discovery application](#discovery-application-object) object. Requires `discovery:remove`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The guild whose listing is removed |
+
+### JSON body
+
+The operation takes a required body even though it is a DELETE, so a request without one fails body validation.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| reason | string | Reason stored on the application as `removal_reason`, 1 through 500 characters |
+
+:::caution[Removal is terminal for this application]
+The application moves to `removed`, and the guild can be listed again only through a new application submitted to the public [Discovery](/http-api/discovery/) resource. That submission replaces the removed record and its removal reason entirely.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery application](#discovery-application-object) object | Listing was removed |
+| 4001 | [error response](/admin-api/#error-response) | The application is not approved, returned as `DISCOVERY_NOT_DISCOVERABLE` |
+| 404 | [error response](/admin-api/#error-response) | The guild holds no application, returned as `DISCOVERY_APPLICATION_NOT_FOUND` |
+
+1 A pending application is refused this way, so rejecting it through [Review discovery application](#review-discovery-application) is the only way to close it
+
+### Side effects
+
+The application moves to `removed` and records the removal time, the removing Admin, and the required reason. The discoverable feature is removed from the guild, the guild stops appearing in [Search discovery guilds](/http-api/discovery/#search-discovery-guilds), and [Guild Update](/gateway/events/#guild-update) fires to every session that can observe the guild.
+
+A removal whose guild can no longer be resolved still succeeds. Fluxer skips the feature strip and the Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `discovery:admin:action` bucket.
+
diff --git a/fluxer_docs/src/content/docs/admin-api/gateway.mdx b/fluxer_docs/src/content/docs/admin-api/gateway.mdx
new file mode 100644
index 000000000..76905500b
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/gateway.mdx
@@ -0,0 +1,288 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gateway control
+description: Node statistics, per-guild memory, voice state counts, and guild reloads.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Gateway control is the Admin view of the running [main Gateway](/gateway/overview/) cluster. It reads live node state and submits guild reload requests, and it exposes no session, presence, or call payload.
+
+Fluxer answers every operation on this page with one RPC call to the main Gateway. A call that goes unanswered returns 504 `GATEWAY_TIMEOUT`. An overloaded cluster, or one with no responder, returns 503 `SERVICE_UNAVAILABLE`. A reply that cannot be interpreted returns 502 `BAD_GATEWAY`.
+
+:::note[Every value is live state]
+The Gateway cluster answers each read at request time. Two consecutive reads can differ without any Admin action, and a node that is restarting can be absent from one response and present in the next.
+:::
+
+## Node statistics object
+
+One snapshot of the Gateway cluster, taken across the nodes the request polled. Every top-level counter except `uptime_seconds`, `node_count`, and `status` is a plain sum over those nodes, including each member of `memory`. The `nodes` array reports each polled node on its own.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status1 | string | The aggregate health of the cluster |
+| sessions | integer | The connected client Gateway sessions, summed across nodes |
+| guilds | integer | The live guild processes, summed across nodes |
+| presences | integer | The tracked presences, summed across nodes |
+| calls | integer | The live calls, summed across nodes |
+| memory | [node memory](#node-memory-object) object | The memory accounting, summed across nodes |
+| process_count | integer | The Erlang processes in use, summed across nodes |
+| process_limit | integer | The Erlang process ceiling, summed across nodes |
+| uptime_seconds2 | integer | The lowest uptime any polled node reported, in seconds |
+| node_count3 | integer | The number of nodes the request polled |
+| nodes4 | array[[gateway node](#gateway-node-object) object] | The per-node breakdown (max 1000 entries) |
+
+1 `healthy` when every polled node reported `healthy` and `degraded` when at least one did not
+
+2 The value tracks the most recently started node and drops back whenever any node restarts
+
+3 Includes nodes that did not answer
+
+4 Entries are ordered by `node_id` ascending. The node that served the request is always polled, so the array is never empty
+
+### Example
+
+```json
+{
+ "status": "healthy",
+ "sessions": 4820,
+ "guilds": 1913,
+ "presences": 4611,
+ "calls": 12,
+ "memory": {"total": "3221225472", "processes": "1610612736", "system": "1610612736"},
+ "process_count": 92114,
+ "process_limit": 2097152,
+ "uptime_seconds": 84213,
+ "node_count": 2,
+ "nodes": []
+}
+```
+
+## Gateway node object
+
+One entry for each node the request polled, including nodes that did not answer within the 10 second per-node deadline.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| node_id1 | string | The name the node reports for itself |
+| status2 | string | The health this node reported |
+| sessions | integer | The connected client Gateway sessions on this node |
+| guilds | integer | The live guild processes on this node |
+| presences | integer | The tracked presences on this node |
+| calls | integer | The live calls on this node |
+| memory | [node memory](#node-memory-object) object | The memory accounting for this node |
+| process_count | integer | The Erlang processes in use on this node |
+| process_limit | integer | The Erlang process ceiling on this node |
+| uptime_seconds | integer | The seconds since this node started |
+
+1 A node reports its `HOSTNAME` environment value when that value is a non-blank string, and its Erlang node name otherwise
+
+2 `healthy` for a node that answered and `unavailable` for one that did not. A node that did not answer reports null in place of every counter here and contributes zero to every cluster total
+
+A node entry can have further diagnostic members. A client treats a member it does not recognise as absent.
+
+## Node memory object
+
+Byte counts for one node, or for every polled node summed together. Every value is a decimal string because a JSON number cannot preserve a 64-bit byte count.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| total | string | The total bytes allocated |
+| processes | string | The bytes allocated to Erlang processes |
+| system | string | The bytes allocated outside Erlang processes |
+
+## Guild memory statistics object
+
+One entry for each live guild process the read sampled. Every value except `nsfw_level` is read from the guild process itself.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| node_id | string | The node that owns the guild process |
+| guild_id | ?snowflake | The ID of the guild the process serves, or null when the process state has no guild ID |
+| guild_name1 | string | The guild name the process holds in memory |
+| guild_icon | ?string | The icon hash the guild process holds in memory, or null when it has none |
+| nsfw_level2 | ?integer | The [NSFW level](/http-api/guilds/#nsfw-levels) resolved from stored guild data, or null when the guild could not be resolved |
+| memory3 | string | The bytes the guild process occupies, as a decimal string |
+| member_count | integer | The number of members the guild process holds |
+| session_count | integer | The number of sessions subscribed to the guild process |
+| presence_count | integer | The number of presences the guild process tracks |
+
+1 A process whose cached guild data has no name reports `Unknown`
+
+2 Null for a live process whose guild row could not be loaded and for a process that reports no `guild_id`
+
+3 The Erlang process memory of the guild process. Entries are ordered by it descending across the whole cluster, with ties broken by `guild_id` ascending
+
+## Voice state count object
+
+Cluster voice state totals, with one grouping by voice region and one by voice server.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| total_voice_states1 | integer | The voice states across the whole cluster |
+| regions2 | array[[region voice state count](#region-voice-state-count-object) object] | The counts grouped by [voice region](/admin-api/voice/#admin-voice-region-object) (max 1000 entries) |
+| servers2 | array[[server voice state count](#server-voice-state-count-object) object] | The counts grouped by [voice server](/admin-api/voice/#admin-voice-server-object) (max 5000 entries) |
+
+1 The sum of the totals each node reported, so it can differ from the sum of the entries in `regions` or `servers`
+
+2 Entries are ordered by `voice_state_count` descending, with ties broken by identifier ascending. An identifier with no voice states is absent from the array, so every entry has a count of at least 1
+
+### Example
+
+```json
+{
+ "total_voice_states": 318,
+ "regions": [{"region_id": "europe-north", "voice_state_count": 204}],
+ "servers": [{"server_id": "europe-north-server-1", "voice_state_count": 204}]
+}
+```
+
+## Region voice state count object
+
+One entry for each voice region that holds at least one voice state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the count belongs to |
+| voice_state_count | integer | The voice states attributed to the region |
+
+## Server voice state count object
+
+One entry for each voice server that holds at least one voice state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| server_id1 | string | The ID of the server the count belongs to |
+| voice_state_count | integer | The voice states attributed to the server |
+
+1 The identifier is unique only inside its region
+
+## Get Gateway node statistics
+
+
+
+Returns the [node statistics](#node-statistics-object) object for the whole Gateway cluster. Requires `gateway:memory_stats`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [node statistics](#node-statistics-object) object | Cluster state was returned |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get guild memory statistics
+
+
+
+Returns the heaviest live guild processes as [guild memory statistics](#guild-memory-statistics-object) objects. Requires `gateway:memory_stats`.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit?1 | integer | The maximum guild processes to return (100-1000, default 100) |
+
+1 The Gateway clamps the value it acts on to 500, and each node contributes at most 100 of its own guild processes. A `limit` of 1000 returns at most 500 entries
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guilds | array[[guild memory statistics](#guild-memory-statistics-object) object] | The guild processes in this response (max 1000 entries) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The guild processes were returned |
+
+### Side effects
+
+A node that does not answer within 5 seconds contributes no entries, and the request still returns 200.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get voice state counts
+
+
+
+Returns the [voice state count](#voice-state-count-object) object, grouped by voice region and by voice server. Requires `gateway:memory_stats`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [voice state count](#voice-state-count-object) object | Counts were returned |
+
+### Side effects
+
+The counts cover voice states in guild channels and in calls. A node that does not answer within 10 seconds contributes zero, and the request still returns 200.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Reload Gateway guilds
+
+
+
+Rebuilds server-side state for the supplied guilds from the database and returns how many live guild processes were selected. Requires `gateway:reload_all`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_ids1 | array[snowflake] | The guilds to reload (max 1000 entries) |
+
+1 The field is required. An empty array selects every live guild process on every active node
+
+:::danger[An empty array reloads the whole cluster]
+Sending `{"guild_ids": []}` dispatches a reload to every live guild process on every active node. Send an explicit list unless a cluster-wide reload is intended.
+:::
+
+:::note[A reload fires Guild Update]
+Every reloaded guild process fires one [Guild Update](/gateway/events/#guild-update) Dispatch to each session subscribed to it. The payload is the rebuilt guild.
+:::
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| count12 | integer | The number of live guild processes selected for reload |
+
+1 A supplied guild with no live process is skipped, so the value can be lower than the number of IDs sent. An owner node that fails or does not answer within 15 seconds contributes zero
+
+2 Taken when the processes are selected. A selected process whose guild data cannot be fetched is still counted, so the value is an upper bound on the reloads that succeeded
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Every selected guild process was dispatched |
+
+### Side effects
+
+Each node reloads its selection in batches of ten with a 100 millisecond delay between batches. A guild whose owner node cannot be resolved is not counted. The response returns once the last batch has been dispatched, so an individual reload can still be in progress when the caller receives it. No guild data is changed and no Admin audit entry is recorded.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `admin:gateway:reload` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx b/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx
new file mode 100644
index 000000000..36165915a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx
@@ -0,0 +1,84 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gift codes
+description: Administrative generation of one-use premium gift codes.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Admin gift code generation issues [premium](/http-api/premium/) codes in bulk, each one redeemable once and each granting a fixed span of entitlement. [Gifts](/http-api/gifts/) defines the public record of a code and its single redemption.
+
+This resource has that one operation. No Admin operation lists, revokes, or redeems a code, or reports who redeemed one.
+
+:::caution[A self-hosted instance answers 403]
+The error code is `FEATURE_NOT_AVAILABLE_SELF_HOSTED`. Body validation runs first, so a request that also fails validation is refused by that failure.
+:::
+
+## Gift duration units
+
+The unit and the quantity together define the span a redeemed code grants. Fluxer adds the span to the redeemer's entitlement anchor at redemption time. Lifetime gifts are not supported.
+
+| Value | Description |
+| --- | --- |
+| days | The quantity counts days added to the entitlement anchor |
+| weeks | The quantity counts weeks added to the entitlement anchor |
+| months1 | The quantity counts calendar months added to the entitlement anchor |
+| years1 | The quantity counts calendar years, applied as twelve calendar months each |
+
+1 A calendar month is added in UTC and clamped to the last day of the target month, so an anchor of 31 January extended by one month lands on 28 or 29 February
+
+## Create gift codes
+
+
+
+Generates the requested number of unredeemed gift codes. Requires `gift_codes:generate`. Returns the complete redemption links on success.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| count | integer | The number of codes to generate (1-100) |
+| duration_type | string | The [gift duration unit](#gift-duration-units) the quantity is counted in |
+| duration_quantity | integer | The quantity of the selected unit (1-3650) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| codes1 | array[string] | The complete redemption links, one for each generated code, in generation order |
+
+1 Each entry is the instance's configured gift endpoint, with one trailing slash removed if present, followed by `/` and the code
+
+```json
+{
+ "codes": [
+ "https://example.com/gift/aB3dEfGhIjKlMnOpQrStUvWxYz012345",
+ "https://example.com/gift/Zy9XwVuTsRqPoNmLkJiHgFeDcBa543210"
+ ]
+}
+```
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The codes were generated |
+| 4031 | [error response](/admin-api/#error-response) | The account does not hold `admin:authenticate`, does not hold `gift_codes:generate`, or the instance is self-hosted |
+
+1 Returned as `MISSING_PERMISSIONS` without `admin:authenticate`, as `MISSING_ACL` with that but without `gift_codes:generate`, and as `FEATURE_NOT_AVAILABLE_SELF_HOSTED` on a self-hosted instance
+
+:::caution[The codes are returned once]
+Reading a code back requires presenting it to [Get gift](/http-api/gifts/#get-gift), so a lost response cannot be recovered.
+:::
+
+### Side effects
+
+Each code is 32 characters drawn from the uppercase letters, the lowercase letters, and the digits. Each code is created unredeemed and records the system account with ID `0` as its creator, which is the creator the [gift object](/http-api/gifts/#gift-object) reports to the redeemer.
+
+Fluxer writes the codes one at a time. A failure part way through leaves the codes already written redeemable while the response reports the failure. Those codes appear in no response.
+
+The operation records no Admin audit entry and emits no Gateway Dispatch.
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `admin:code:generation` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/guilds.mdx b/fluxer_docs/src/content/docs/admin-api/guilds.mdx
new file mode 100644
index 000000000..1ff2ba8ac
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/guilds.mdx
@@ -0,0 +1,907 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin guilds
+description: Guild search, detail, mutation, deletion, membership, expressions, and Gateway lifecycle.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Admin guild operations read and change the guilds the public [Guilds](/http-api/guilds/) resource exposes. Most of them need no membership in the guild and no guild permission, and [Remove guild member](#remove-guild-member) and [Ban guild member](#ban-guild-member) are the two exceptions.
+
+[Archives](/admin-api/archives/) owns the archive lifecycle and the downloads.
+
+Reads use the `admin:lookup` bucket, which permits 200 requests per minute for each authenticated user. Writes use the `admin:guild:modify` bucket, which permits 100 requests per minute for each authenticated user. [Create guild archive](#create-guild-archive) is the one write served from the read bucket.
+
+:::danger[Guild deletion is immediate]
+[Delete guild](#delete-guild) destroys the guild at once. There is no scheduled deletion, no recovery window, and no restore operation.
+:::
+
+## Admin guild object
+
+The compact guild representation returned by [List guilds](#list-guilds) and by [List user guilds](/admin-api/users/#list-user-guilds). It has no channel or role state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild |
+| features | array[string] | The complete [guild feature](/http-api/guilds/#guild-features) set (at most 100) |
+| owner_id | snowflake | The ID of the owner |
+| owner_username1 | ?string | The username of the owner |
+| owner_global_name1 | ?string | The display name of the owner |
+| owner_discriminator1 | ?string | The owner discriminator, zero-padded to four digits |
+| icon | ?string | The icon hash, or null when unset |
+| banner | ?string | The banner hash, or null when unset |
+| member_count2 | integer | The recorded member count |
+| nsfw_level? | integer | The [NSFW level](/http-api/guilds/#nsfw-levels) |
+| nsfw?3 | boolean | Whether the guild is flagged as adult content |
+| content_warning_level?3 | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
+| content_warning_text?3 | ?string | The custom content warning text |
+| approximate_member_count?4 | integer | The member count the main Gateway reports |
+| approximate_presence_count?4 | integer | The connected member count the main Gateway reports |
+
+1 All three are null whenever the owner account is not resolved. [List guilds](#list-guilds) never resolves it, so all three are always null there
+
+2 A member connecting or disconnecting does not change it
+
+3 No operation populates these three, so they are absent from every current response
+
+4 Present only on [List user guilds](/admin-api/users/#list-user-guilds) when that operation is asked for counts
+
+### Example
+
+```json
+{
+ "id": "1471002884199612416",
+ "name": "Tidepool",
+ "features": ["DISCOVERABLE", "VANITY_URL"],
+ "owner_id": "1483920011884392448",
+ "owner_username": null,
+ "owner_global_name": null,
+ "owner_discriminator": null,
+ "icon": "b7f1c2d3e4a5968778695a4b3c2d1e0f",
+ "banner": null,
+ "member_count": 4182,
+ "nsfw_level": 0
+}
+```
+
+## Admin guild detail object
+
+The full guild representation returned by [Get guild](#get-guild). It embeds every channel and every role of the guild.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| owner_id | snowflake | The ID of the owner |
+| owner_username1 | ?string | The username of the owner |
+| owner_global_name1 | ?string | The display name of the owner |
+| owner_discriminator1 | ?string | The owner discriminator, zero-padded to four digits |
+| name | string | The name of the guild (1-100 characters) |
+| vanity_url_code | ?string | The custom invite code, or null when the guild owns none |
+| icon | ?string | The icon hash, or null when unset |
+| banner | ?string | The banner hash, or null when unset |
+| splash | ?string | The invite splash hash, or null when unset |
+| embed_splash | ?string | The embedded invite splash hash, or null when unset |
+| features | array[string] | The complete [guild feature](/http-api/guilds/#guild-features) set (at most 100) |
+| verification_level | integer | The [verification level](/http-api/guilds/#verification-levels) |
+| mfa_level | integer | The [MFA level](/http-api/guilds/#mfa-levels) required of moderators |
+| nsfw_level | integer | The [NSFW level](/http-api/guilds/#nsfw-levels) |
+| nsfw?2 | boolean | Whether the guild is flagged as adult content |
+| content_warning_level?2 | integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
+| content_warning_text?2 | ?string | The custom content warning text |
+| explicit_content_filter | integer | The [explicit content filter level](/http-api/guilds/#guild-explicit-content-filter-levels) |
+| default_message_notifications | integer | The [default message notification level](/http-api/guilds/#default-message-notification-levels) |
+| afk_channel_id | ?snowflake | The voice channel idle members are moved to, or null when unset |
+| afk_timeout | integer | The idle time before a member is moved, in seconds |
+| system_channel_id | ?snowflake | The channel system messages are sent to, or null when they are disabled |
+| system_channel_flags | integer | The [system channel flags](/http-api/guilds/#system-channel-flags) |
+| rules_channel_id | ?snowflake | The channel holding the guild rules, or null when unset |
+| disabled_operations | integer | The [disabled guild operations](/http-api/guilds/#disabled-guild-operations) bitfield |
+| member_count | integer | The recorded member count |
+| channels | array[[Admin guild channel](#admin-guild-channel-object) object] | Every channel in the guild |
+| roles | array[[Admin guild role](#admin-guild-role-object) object] | Every role in the guild |
+
+1 All three are null when the owner account cannot be resolved
+
+2 The operation does not populate these three, so they are absent from every current response
+
+### Admin guild channel object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel |
+| name | ?string | The channel name (1-100 characters) |
+| type | integer | The [channel type](/http-api/channels/#channel-types) |
+| position | integer | The sort position within the guild |
+| parent_id | ?snowflake | The parent category ID, or null when the channel is top-level |
+| nsfw | ?boolean | Whether the channel is marked age restricted |
+| nsfw_override?3 | ?boolean | The age restriction set on the channel itself |
+| content_warning_level?3 | integer | The [content warning level](/http-api/guilds/#guild-content-warning-levels) set on the channel |
+| content_warning_text?3 | ?string | The content warning text set on the channel |
+| url4 | ?string | The external channel URL (1-2048 characters) |
+
+3 The operation does not populate these three, so they are absent from every current response
+
+4 Null for every channel type that is not an external link channel
+
+### Admin guild role object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the role |
+| name | string | The role name (1-100 characters) |
+| color | integer | The packed RGB colour |
+| position | integer | The sort position within the guild |
+| permissions | string | The [permission](/http-api/permissions/) bitfield as an unsigned 64-bit decimal string |
+| hoist | boolean | Whether the role is displayed separately in the member list |
+| mentionable | boolean | Whether the role can be mentioned by anyone |
+
+## Admin guild update object
+
+The guild state [Update guild](#update-guild) reads back after applying the request. It has no channel, role, or owner identity state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild (1-100 characters) |
+| features | array[string] | The complete [guild feature](/http-api/guilds/#guild-features) set after the update (at most 100) |
+| owner_id | snowflake | The ID of the owner after the update |
+| icon | ?string | The icon hash, or null when unset or cleared |
+| banner | ?string | The banner hash, or null when unset or cleared |
+| member_count | integer | The recorded member count |
+| nsfw_level | integer | The [NSFW level](/http-api/guilds/#nsfw-levels) |
+
+## Admin guild expression object
+
+One custom emoji or one sticker of a guild, together with a resolvable media URL. The two listings return the same shape.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the emoji or sticker |
+| name | string | The expression name (1-100 characters) |
+| animated | boolean | Whether the stored image is animated |
+| creator_id | snowflake | The account that uploaded the expression |
+| media_url1 | string | The [Media Proxy](/media-proxy/routes/) URL the expression is served from (1-2048 characters) |
+
+1 Always the WebP representation, at size 160 for an emoji and size 320 for a sticker, and it has the `animated=true` selector only when `animated` is true
+
+### Example
+
+```json
+{
+ "id": "1496613881730531328",
+ "name": "party_parrot",
+ "animated": true,
+ "creator_id": "1483920011884392448",
+ "media_url": "https://media.example.com/emojis/1496613881730531328.webp?size=160&animated=true"
+}
+```
+
+## Admin guild asset purge result object
+
+One entry of the `processed` array returned by [Purge guild assets](#purge-guild-assets).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The asset that was purged |
+| asset_type | string | The [asset type](#asset-types) the ID resolved to |
+| found_in_db1 | boolean | Whether the ID matched an emoji or sticker record |
+| guild_id2 | ?snowflake | The guild the purged record belonged to |
+| guild_nsfw_level2 | ?integer | The [NSFW level](/http-api/guilds/#nsfw-levels) of that guild |
+
+1 False exactly when `asset_type` is `unknown`, in which case only the stored media was queued for removal
+
+2 Both null when `asset_type` is `unknown`, and `guild_nsfw_level` is also null when the owning guild can no longer be resolved
+
+## Admin guild asset purge error object
+
+One entry of the `errors` array returned by [Purge guild assets](#purge-guild-assets).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The asset that could not be purged, as supplied |
+| error1 | string | The reason the asset could not be purged (1-4000 characters) |
+
+1 The operation produces `Invalid numeric ID`, `Asset belongs to another guild`, and `Failed to purge asset`. Any other value is the message of the underlying failure
+
+## Asset types
+
+| Value | Description |
+| --- | --- |
+| emoji | The ID resolved to a custom emoji owned by the requested guild |
+| sticker | The ID resolved to a sticker owned by the requested guild |
+| unknown | The ID matched no emoji and no sticker record, and only associated media was queued for removal |
+
+An ID owned by a different guild appears in `errors` with no asset type.
+
+## List guilds
+
+
+
+Searches guilds through the guild search index and returns [Admin guild](#admin-guild-object) objects. Requires `guild:lookup`.
+
+The index matches `q` against the guild name, the discovery tags, the custom invite code, and the discovery description, in that order of weight. Hits are ordered by guild creation time ascending, so the oldest matching guild is first. An empty or omitted `q` matches every indexed guild.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| q?1 | string | Free-text query (1-1024 characters) |
+| limit? | integer | Maximum guilds to return (1-200, default 50) |
+| offset? | integer | Guilds to skip before returning results (0-10000, default 0) |
+
+1 A `q` of all decimal digits also resolves that value as an exact guild ID, but only while `offset` is 0. A guild the index did not return is prepended to `guilds` and adds one to `total`
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guilds | array[[Admin guild](#admin-guild-object) object] | Guilds in this page |
+| total2 | integer | The number of guilds the index reported for the query |
+
+2 The value can exceed the number of entries in `guilds`. Pagination advances with `offset`, and the operation returns no cursor
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Guild page was returned |
+| 403 | [error response](/admin-api/#error-response) | `FEATURE_TEMPORARILY_DISABLED`, because the instance has no search backend configured |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get guild
+
+
+
+Returns one guild with its channels and roles. Requires `guild:lookup`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild1 | ?[Admin guild detail](#admin-guild-detail-object) object | The guild, or null when the ID resolves to nothing |
+
+1 A guild that does not exist answers 200 with a null guild
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | A lookup was performed, whether or not it resolved |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Update guild
+
+
+
+Applies one or more field groups to a guild and returns the [Admin guild update](#admin-guild-update-object) object read back afterwards. Omitted fields are left unchanged.
+
+This is the only way to change the owner of a guild without acting as its current owner. The public [Transfer guild ownership](/http-api/guild-members/#transfer-guild-ownership) operation requires the caller to be the owner.
+
+Fluxer evaluates authorisation in two stages and reads the body between them. The account first needs at least one of `guild:update:name`, `guild:update:settings`, `guild:update:features`, `guild:update:vanity`, and `guild:transfer_ownership`. The validated body then selects a set of ACLs and every one of them is required, so a body with `name` and `nsfw` needs both `guild:update:name` and `guild:update:settings`. The wildcard satisfies both stages.
+
+- `guild:update:name` is selected by `name`.
+- `guild:update:settings` is selected by `fields`, `verification_level`, `mfa_level`, `nsfw_level`, `nsfw`, `content_warning_level`, `content_warning_text`, `explicit_content_filter`, `default_message_notifications`, and `disabled_operations`.
+- `guild:update:features` is selected by `add_features` and `remove_features`.
+- `guild:update:vanity` is selected by `vanity_url_code`.
+- `guild:transfer_ownership` is selected by `new_owner_id`.
+
+A body with no field at all selects `*`, so an account without the wildcard cannot send an empty patch.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | string | The replacement name (1-100 characters) |
+| vanity_url_code?1 | ?string | The replacement custom invite code, or null to release the current one |
+| new_owner_id?2 | snowflake | The replacement owner |
+| add_features?3 | array[string] | [Guild features](/http-api/guilds/#guild-features) to add (at most 100) |
+| remove_features?3 | array[string] | [Guild features](/http-api/guilds/#guild-features) to remove (at most 100) |
+| fields?4 | array[string] | Image fields to clear, each `icon`, `banner`, `splash`, or `embed_splash` (at most 10) |
+| verification_level? | integer | The replacement [verification level](/http-api/guilds/#verification-levels) |
+| mfa_level? | integer | The replacement [MFA level](/http-api/guilds/#mfa-levels) |
+| nsfw_level? | integer | The replacement [NSFW level](/http-api/guilds/#nsfw-levels) |
+| nsfw?5 | boolean | Accepted and ignored |
+| content_warning_level?5 | integer | Accepted and ignored |
+| content_warning_text?5 | ?string | Accepted and ignored (at most 200 characters) |
+| explicit_content_filter? | integer | The replacement [explicit content filter level](/http-api/guilds/#guild-explicit-content-filter-levels) |
+| default_message_notifications? | integer | The replacement [default message notification level](/http-api/guilds/#default-message-notification-levels) |
+| disabled_operations?6 | integer | The replacement [disabled guild operations](/http-api/guilds/#disabled-guild-operations) bitfield |
+
+1 Normalised to lowercase with whitespace folded to hyphens and consecutive hyphens collapsed. The normalised result is 2 to 32 characters, and a shorter or longer one is rejected with 400
+
+2 The operation does not confirm that the replacement account exists, and an unresolvable owner makes every `owner_` field null on later reads
+
+3 Additions are applied before removals, so a feature named in both is removed. Any string is accepted
+
+4 The array only clears an image, and no Admin operation uploads one
+
+5 Validated, selects `guild:update:settings`, and records an `update_settings` audit entry. The guild record is left unchanged
+
+6 Any integer from 0 to 2147483647, stored as supplied, including bits outside the documented registry
+
+A code already claimed by any invite is rejected with 400 `INVALID_FORM_BODY` and the validation code `THIS_VANITY_URL_IS_ALREADY_TAKEN` against `vanity_url_code`.
+
+The channel references, idle timeout, and message history cutoff of a guild are not Admin-writable. They change through the public [Modify guild](/http-api/guilds/#modify-guild) operation.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild | [Admin guild update](#admin-guild-update-object) object | The guild state read back after the request was applied |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Every selected field group was applied |
+| 400 | [error response](/admin-api/#error-response) | The custom invite code is malformed or already claimed |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL`, because the account holds none of the five update ACLs, or lacks an ACL a supplied field selects |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist |
+
+:::caution[Field groups apply one at a time]
+The groups run in a fixed order: image clears, settings, features, name, custom invite code, ownership. Each is a separate write with its own [Guild Update](/gateway/events/#guild-update) Dispatch and its own Admin audit entry. A failure part way through leaves the earlier groups applied.
+:::
+
+### Side effects
+
+Clearing an image field queues the previous stored object for deletion. Replacing the custom invite code deletes the invite record holding the previous code and creates one for the new code, while sending null deletes the previous record without creating another.
+
+Supplying `add_features` or `remove_features` reconciles an existing discovery application. The application is approved when [DISCOVERABLE](/http-api/guilds/#guild-features) becomes present and it is not already approved, and it is marked removed when `DISCOVERABLE` becomes absent and it was approved. A guild that has never applied for [discovery](/admin-api/discovery/) gains no application.
+
+Every applied group refreshes the guild's entry in the guild search index and fires one [Guild Update](/gateway/events/#guild-update) Dispatch to every session that can see the guild, including when the write changes no value.
+
+Each applied group records one Admin audit entry with the target type `guild` and the guild ID as the target. The actions are `clear_fields` with the cleared field names, `update_settings` with each applied setting, and `update_features` with the added, removed, and resulting feature sets. The remaining actions are `update_name` with the old and new names, `update_vanity` with the old and new codes, and `transfer_ownership` with the old and new owner IDs.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Delete guild
+
+
+
+Permanently deletes a guild and every record it owns. Requires `guild:delete`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Guild was deleted |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist |
+
+:::danger[A guild archive is the only way back]
+The guild, its channels, roles, memberships, messages, and attachments are destroyed as soon as the request is accepted. Take a [guild archive](#create-guild-archive) first when the content has to be retained.
+:::
+
+### Side effects
+
+Fluxer emits one [Guild Delete](/gateway/events/#guild-delete) Dispatch first, then detaches every member from the guild on the main Gateway. It deletes each member's [guild settings](/http-api/users/settings/) entry for the guild, and drops each human member's [guild folder](/http-api/users/settings/) references to the guild.
+
+Every invite, every webhook, every message of every channel, and every channel attachment are deleted. Any discovery application is deleted. The guild record is then deleted, the guild is stopped on the main Gateway, and it is removed from the guild search index.
+
+One Admin audit entry is recorded with the action `delete_guild`, the target type `guild`, and the guild ID in both the target and the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## List guild members
+
+
+
+Returns one page of [guild member](/http-api/guild-members/#guild-member-object) objects read from the main Gateway, without requiring membership. Requires `guild:list:members`.
+
+:::note[Members come from the main Gateway]
+A guild the main Gateway cannot load answers 404 `UNKNOWN_GUILD`.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Maximum members to return (1-200, default 50) |
+| offset? | integer | Members to skip before returning results (0-2147483647, default 0) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| members | array[[guild member](/http-api/guild-members/#guild-member-object) object] | Members in this page |
+| total | integer | The total member count the main Gateway reported |
+| limit1 | integer | The limit the operation applied |
+| offset1 | integer | The offset the operation applied |
+
+1 Both are echoed from the resolved query, so a request that omitted them reads back the defaults. A client advances the page by adding `limit` to `offset`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Member page was returned |
+| 500 | [error response](/admin-api/#error-response) | The API loses its connection to the main Gateway during the call |
+
+A member query the main Gateway reports as failed returns 502 `BAD_GATEWAY`, an unanswered call returns 504 `GATEWAY_TIMEOUT`, and an overloaded cluster returns 503 `SERVICE_UNAVAILABLE`.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Add guild member
+
+
+
+Adds a user to a guild without an invite. Requires `guild:force_add_member`. The operation takes no request body.
+
+Only the Admin ACL is evaluated.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The user to add |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Member was added, or the user was already a member |
+| 400 | [error response](/admin-api/#error-response) | `MAX_GUILDS` because the user is at their guild limit, or `MAX_GUILD_MEMBERS` because the guild is at its member limit |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER` because the user does not exist, or `UNKNOWN_GUILD` because the guild does not exist |
+
+### Side effects
+
+Fluxer creates the membership with the Admin force-add join source and restores a communication timeout still in force from a previous membership. The guild ban list is not consulted, so a banned user can be admitted. The suspicious activity phone gate does not run. The per-user guild limit and the guild member limit are still enforced.
+
+[Guild Member Add](/gateway/events/#guild-member-add) fires to the guild, the user's sessions are joined to the guild on the main Gateway, and the member enters guild member search when the guild has an indexed member set. The ordinary join system message is created, and with it a [Message Create](/gateway/events/#message-create) Dispatch, unless the guild sets `SUPPRESS_JOIN_NOTIFICATIONS` or has no usable system channel. A bot target additionally records a `BOT_ADD` entry in the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions).
+
+A user who is already a member keeps their existing membership. No membership is created, no counter moves, and no Dispatch is emitted. The Admin audit entry is still written.
+
+One Admin audit entry is recorded with the action `force_add_to_guild`, the target type `user`, the added user as the target, and the guild ID in the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Remove guild member
+
+
+
+Removes a member from a guild without banning them and returns 204 with an empty body. Requires `guild:kick_member`.
+
+:::caution[Removal also needs KICK_MEMBERS in the guild]
+The acting account must see the guild, hold [KICK_MEMBERS](/http-api/permissions/) in it, and rank above the target. A guild at [MFA level](/http-api/guilds/#mfa-levels) 1 also requires an enrolled authenticator.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The member to remove |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Member was removed |
+| 400 | [error response](/admin-api/#error-response) | `TWO_FACTOR_REQUIRED` in an MFA level 1 guild |
+| 4031 | [error response](/admin-api/#error-response) | `MISSING_ACCESS` when the acting account cannot see the guild, or `MISSING_PERMISSIONS` when `KICK_MEMBERS` is absent |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_MEMBER`, because the target is not a member, is the guild owner, or is the acting account |
+
+1 `MISSING_PERMISSIONS` also covers a target who outranks the acting account
+
+### Side effects
+
+Fluxer snapshots the membership metadata, including any communication timeout, so that a later rejoin restores it. It then deletes the membership, decreases the recorded member count by one, and detaches the user from the guild on the main Gateway. The member is removed from guild member search when the guild has an indexed member set.
+
+[Guild Member Remove](/gateway/events/#guild-member-remove) fires to the guild. A `MEMBER_KICK` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry names the acting Admin account and has the audit reason, and the write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
+
+The removal creates no ban record, so the user can rejoin.
+
+One Admin audit entry is recorded with the action `kick_member`, the target type `guild_member`, the removed user as the target, and the guild and user IDs in the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Ban guild member
+
+
+
+Bans a user from a guild, optionally removes their recent messages, and returns 204 with an empty body. The target need not be a member. Requires `guild:ban_member`.
+
+:::caution[A ban also needs BAN_MEMBERS in the guild]
+The acting account must hold [BAN_MEMBERS](/http-api/permissions/) in the guild, and must rank above a target who is a member. A guild at [MFA level](/http-api/guilds/#mfa-levels) 1 also requires an enrolled authenticator.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The user to ban |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| delete_message_seconds?1 | integer | The window of recent messages to delete, in seconds (0-604800) |
+| delete_message_days?1 | integer | The window of recent messages to delete, in days (0-7, default 0) |
+| reason? | ?string | The guild ban reason stored on the ban record (at most 512 characters) |
+| ban_duration_seconds?2 | integer | The ban duration in seconds, either exactly 0 or between 60 and 63072000 |
+
+1 `delete_message_seconds` wins when both are supplied, and `delete_message_days` is otherwise multiplied by 86400. A resolved window of 0 deletes no message
+
+2 Omitting the field and sending 0 both produce a permanent ban. There is no field that sets an absolute expiry timestamp
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | User was banned |
+| 400 | [error response](/admin-api/#error-response) | Duration, reason, or message window validation fails, or `TWO_FACTOR_REQUIRED` in an MFA level 1 guild |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` because `BAN_MEMBERS` is absent in the guild or the target member outranks the acting account |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_MEMBER` because the target is the acting account, or `UNKNOWN_USER` because the user does not exist |
+
+:::caution[The ban can also delete messages]
+The ban removes current membership and blocks later joins until its expiry or explicit removal. Messages inside the resolved deletion window are permanently deleted and are not restored when the ban ends.
+:::
+
+### Side effects
+
+The ban record names the acting Admin account as moderator and has the expiry, the reason, the target's last known IP address unless that address is on the ban exemption list, and the target's lowercased email address. While the ban exists the guild also blocks that address and that email, as described by [Guild moderation](/http-api/guild-moderation/#guild-ban-object), and removing the ban releases both.
+
+A positive deletion window queues a background job that deletes the target's matching messages after the response, which fires [Message Delete Bulk](/gateway/events/#message-delete-bulk) as deletion progresses.
+
+[Guild Ban Add](/gateway/events/#guild-ban-add) fires to the guild. A target who was a member is then removed, which decreases the recorded member count, detaches the user from the guild on the main Gateway, removes the member from guild member search, and fires [Guild Member Remove](/gateway/events/#guild-member-remove). The ban path snapshots no membership metadata, so a communication timeout in force at the moment of the ban is not restored on a later rejoin. This operation writes no entry to the guild's own audit log, and it emits no [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create).
+
+One Admin audit entry is recorded with the action `ban_member`, the target type `guild_member`, the banned user as the target, and the guild ID, user ID, `delete_message_days` value, and any supplied reason and duration in the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## List guild emojis
+
+
+
+Returns every custom emoji of a guild as [Admin guild expression](#admin-guild-expression-object) objects. Requires `asset:purge`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The guild the listing covers, echoed from the path |
+| emojis1 | array[[Admin guild expression](#admin-guild-expression-object) object] | Every custom emoji of the guild |
+
+1 The listing is not paginated and takes no query parameters
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Emojis were returned, or the guild owns none |
+
+The operation does not verify that the guild exists, so an unknown guild ID returns an empty listing.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## List guild stickers
+
+
+
+Returns every sticker of a guild as [Admin guild expression](#admin-guild-expression-object) objects. Requires `asset:purge`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The guild the listing covers, echoed from the path |
+| stickers1 | array[[Admin guild expression](#admin-guild-expression-object) object] | Every sticker of the guild |
+
+1 The listing is not paginated and takes no query parameters
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Stickers were returned, or the guild owns none |
+
+The operation does not verify that the guild exists, so an unknown guild ID returns an empty listing.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Purge guild assets
+
+
+
+Deletes emoji and sticker records owned by a guild and queues their stored media for removal, reporting the outcome of every ID separately. Requires `asset:purge`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ids1 | array[string] | Emoji and sticker IDs to delete with their media, each 1 to 64 characters (at most 100) |
+
+1 Each entry is trimmed, and Fluxer skips one that is then empty and any repeat of an entry already seen, so neither appears in `processed` or `errors`. An empty array is accepted and purges nothing
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| processed | array[[Admin guild asset purge result](#admin-guild-asset-purge-result-object) object] | Assets that were purged |
+| errors | array[[Admin guild asset purge error](#admin-guild-asset-purge-error-object) object] | Assets that could not be purged |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The purge attempt completed, whether or not every ID succeeded |
+
+The operation does not verify that the guild exists. Under an unknown guild ID, an ID that matches another guild's record lands in `errors` and every other ID is reported as `unknown`.
+
+:::danger[Successful asset purges cannot be undone]
+Every ID in `processed` loses its record and its stored media permanently. The operation still returns 200 when other IDs fail.
+:::
+
+### Side effects
+
+A record owned by the guild in the path is deleted, and its stored media is queued for removal. The guild then receives one [Guild Emojis Update](/gateway/events/#guild-emojis-update) or [Guild Stickers Update](/gateway/events/#guild-stickers-update) Dispatch with its complete remaining expression set. A request that purges several records emits one such Dispatch per record.
+
+A record owned by a different guild is left untouched and reported in `errors`. An ID with no record queues emoji and sticker media removal for that ID and is reported with the `unknown` asset type, so the operation also clears orphaned media.
+
+Every entry in `processed` records its own Admin audit entry, with the numeric ID as the target. A purged emoji records `purge_guild_emoji_asset` with the target type `guild_emoji`, a purged sticker records `purge_guild_sticker_asset` with `guild_sticker`, and an `unknown` ID records `purge_asset` with `asset`. Entries in `errors` record nothing.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## List guild audit logs
+
+
+
+Returns one page of the guild's own in-app audit log, without requiring guild membership or [VIEW_AUDIT_LOG](/http-api/permissions/). Requires `guild:audit_log:view`.
+
+The page has the same shape and the same semantics as the public [List guild audit logs](/http-api/guild-audit-logs/#list-guild-audit-logs) operation, including the message deletion consolidation that operation performs.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Maximum entries to return (1-100, default 50) |
+| before?1 | snowflake | Return entries older than this entry ID |
+| after?1 | snowflake | Return entries newer than this entry ID |
+| user_id?2 | snowflake | Return only entries recorded for this actor |
+| action_type?2 | integer | Return only entries with this [audit action](/http-api/guild-audit-logs/#audit-actions) value |
+
+1 The two cursors are mutually exclusive. Supplying both fails with 400 `INVALID_FORM_BODY` and the validation code `CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER` against `before`
+
+2 Supplying either filter disables the consolidation of consecutive message deletion entries
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| audit_log_entries | array[[guild audit log entry](/http-api/guild-audit-logs/#guild-audit-log-entry-object) object] | The returned page of audit entries |
+| users | array[[partial user](/http-api/users/#partial-user-object) object] | Users referenced by the returned entries |
+| webhooks | array[[audit log webhook](/http-api/guild-audit-logs/#audit-log-webhook-object) object] | Webhooks referenced by the returned entries |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Audit page was returned, or the guild has no matching entry |
+| 400 | [error response](/admin-api/#error-response) | `before` and `after` were supplied together |
+
+The operation does not verify that the guild exists, so an unknown guild ID returns an empty page.
+
+### Side effects
+
+The read records no Admin audit entry and appends no ordinary entry to the guild's own audit log. It still changes that log. Runs of consecutive message deletion entries are replaced with one bulk entry, and that replacement emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), exactly as the public operation does.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Reload guild
+
+
+
+Reloads a guild's state on the main Gateway. Requires `guild:reload`. The operation takes no request body.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Reload command completed |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist |
+| 500 | [error response](/admin-api/#error-response) | The API loses its connection to the main Gateway during the call |
+
+A reload the main Gateway reports as failed returns 502 `BAD_GATEWAY`, an unanswered call returns 504 `GATEWAY_TIMEOUT`, and an overloaded cluster returns 503 `SERVICE_UNAVAILABLE`.
+
+### Side effects
+
+The main Gateway reloads the guild's current state from storage and fires one [Guild Update](/gateway/events/#guild-update) Dispatch to every session subscribed to the guild. The operation changes no guild data.
+
+One Admin audit entry is recorded with the action `reload_guild`, the target type `guild`, and the guild ID in both the target and the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Shut down guild
+
+
+
+Unloads a guild from the main Gateway without deleting stored data. Requires `guild:shutdown`. The operation takes no request body.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Shutdown command completed |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist |
+| 500 | [error response](/admin-api/#error-response) | The API loses its connection to the main Gateway during the call |
+
+A shutdown the main Gateway reports as failed returns 502 `BAD_GATEWAY`, an unanswered call returns 504 `GATEWAY_TIMEOUT`, and an overloaded cluster returns 503 `SERVICE_UNAVAILABLE`.
+
+### Side effects
+
+The main Gateway stops the guild process. Every session subscribed to the guild receives a [Guild Delete](/gateway/events/#guild-delete) Dispatch with `unavailable` true and reconnects after one second, which starts the guild again from stored data. Stored guild data is untouched, and [Reload guild](#reload-guild) also starts a stopped guild.
+
+One Admin audit entry is recorded with the action `shutdown_guild`, the target type `guild`, and the guild ID in both the target and the metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Create guild archive
+
+
+
+Queues an asynchronous archive of the guild's channels, messages, members, roles, and settings, and returns the created [archive](/admin-api/archives/#archive-object) object. Requires `archive:trigger:guild`.
+
+Archive status and downloads are read through [Archives](/admin-api/archives/).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| include_attachments? | boolean | Whether uploaded files are included in the archive (default false) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [archive](/admin-api/archives/#archive-object) object | Archive was created and queued |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist |
+| 500 | [error response](/admin-api/#error-response) | Archive creation or queueing fails |
+
+### Side effects
+
+The archive record is created with the acting Admin account as `requested_by`, a `progress_percent` of 0, a `progress_step` of `Queued`, and an `expires_at` that is never extended. The archive is built after the response is returned. A client reads progress through [Get archive](/admin-api/archives/#get-archive).
+
+The operation emits no Gateway Dispatch and records no Admin audit entry.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
diff --git a/fluxer_docs/src/content/docs/admin-api/index.mdx b/fluxer_docs/src/content/docs/admin-api/index.mdx
new file mode 100644
index 000000000..9d45a2326
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/index.mdx
@@ -0,0 +1,589 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin API overview
+description: Admin credentials, ACL evaluation, audit entries, and the shared error contract.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The Admin API is the privileged surface for administering an instance: its accounts, guilds, messages, and configuration. Every route sits below `/v1/admin` and shares the JSON framing and error envelope of the [HTTP API](/http-api/). It applies its own credential policy, [ACL evaluation](#acl-evaluation), and rate limit buckets.
+
+## Authentication
+
+Every Admin operation accepts a user session token as a bare `Authorization` value, an access token issued by Fluxer's built-in Admin OAuth2 application as `Bearer `, or an Admin API key as `Admin `. Fluxer resolves a value in the user session token form as a session token even under the `Bearer` prefix. [HTTP authentication](/authentication/) defines the credential syntax.
+
+Fluxer evaluates the credential in a fixed order. A request that resolves no account is refused with 401 `UNAUTHORIZED`, as is a credential presented with the `Bot` scheme. An access token issued to any OAuth2 application other than the built-in Admin application is refused with 403 `ACCESS_DENIED`. An account holding neither `admin:authenticate` nor the wildcard `*` is refused with 403 `MISSING_PERMISSIONS`. An account that clears both gates but satisfies none of the [ACLs](#acl-evaluation) the operation names is refused with 403 `MISSING_ACL`.
+
+:::caution[A denial names no requirement]
+The `MISSING_ACL` body is a fixed sentence that has no ACL name and no requirement list, so a response never discloses which requirement failed.
+:::
+
+No Admin operation declares an OAuth2 scope, an MFA requirement, or a sudo requirement, so none consumes the `X-Fluxer-Sudo-Mode-JWT` header. An accepted credential together with the required ACLs is the complete authorisation boundary.
+
+An Admin API key authenticates as the account that created it, and Fluxer reads it only on a path below `/v1/admin`. A request presenting a key on any other path continues with no credential resolved. Fluxer authorises a key-authenticated request twice. The key's own ACL set must satisfy the operation, and unless the owning account holds `*` that account must itself hold one of the ACLs the operation names. Removing an ACL from either side narrows every existing key at once, with no rotation.
+
+Initial setup is the single exception. While the instance reports its setup as unconfigured, a session credential reaches the [instance configuration](/admin-api/instance/) operations without holding any ACL. The session that switches setup to configured is granted `*` immediately. Once setup is configured those operations apply the boundary above.
+
+Admin authorisation runs before request validation on every operation except [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job), whose body is validated first because its ACL is selected from that body. An account lacking the required ACL is therefore refused with 403 even when its query string or body is also malformed.
+
+## ACL evaluation
+
+An operation names one or more ACLs and admits the request when the caller holds at least one of them. The wildcard `*` satisfies every requirement on its own.
+
+An operation can stack several requirements, each evaluated separately, and the caller must satisfy all of them. [Create NCMEC report](/admin-api/messages/#create-ncmec-report) is the widest example and stacks `csam:submit_ncmec`, `message:delete`, `user:delete`, and `archive:trigger:user` as four independent requirements.
+
+The effective ACL set of a session or an Admin OAuth2 bearer credential is the set stored on the account. The effective set of an Admin API key is the set stored on the key, subject to the owner check above. [Set user ACLs](/admin-api/users/#set-user-acls) writes an account's set. Holding `admin:authenticate` or `*` is the whole definition of an account that can reach the Admin API.
+
+Two operations derive their required ACLs from the validated request body.
+
+[Update guild](/admin-api/guilds/#update-guild) maps each present body field to one ACL and requires every ACL in that set. `name` maps to `guild:update:name`, `vanity_url_code` maps to `guild:update:vanity`, `new_owner_id` maps to `guild:transfer_ownership`, `add_features` and `remove_features` map to `guild:update:features`, and `fields` together with every remaining setting maps to `guild:update:settings`. A body with none of those fields resolves to `*`, so only a wildcard holder is admitted. The route also names those five ACLs as an ordinary any-of requirement, evaluated before the body is read.
+
+[Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job) maps its `task` discriminator to one ACL and requires that one. `update_user_flags` maps to `bulk:update:user_flags`, `update_suspicious_activity_flags` maps to `bulk:update:suspicious_activity`, `update_guild_features` maps to `bulk:update:guild_features`, `add_guild_members` maps to `bulk:add:guild_members`, and `schedule_user_deletion` maps to `bulk:delete:users`.
+
+Fluxer bounds every grant separately. [Set user ACLs](/admin-api/users/#set-user-acls) and [Create Admin API key](/admin-api/api-keys/#create-admin-api-key) both refuse to write an ACL the acting Admin does not itself hold, with 403 `MISSING_ACL`. A wildcard holder is exempt. Set user ACLs additionally refuses the acting Admin's own account with 403 `ACCESS_DENIED`, and it resolves the target before the bound is evaluated, so an unknown ID fails first with 404 `UNKNOWN_USER`.
+
+[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 111 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`.
+
+:::caution[`*` satisfies every present and future ACL]
+It admits the wildcard-only shapes such as an empty [Update guild](/admin-api/guilds/#update-guild) body, lifts the per-key owner check, and lifts the escalation bound on every grant its holder makes.
+:::
+
+The [ACL registry](#acl-registry) below lists every value the instance recognises.
+
+## Admin audit entry object
+
+An Admin audit entry records one Admin action against one target.
+
+Not every Admin operation records an entry. Each Admin resource page states, per operation, whether it records one and what that entry stores.
+
+A request refused by authentication, ACL evaluation, rate limiting, or body validation records nothing.
+
+Fluxer can record more than one entry for one request. [Update guild](/admin-api/guilds/#update-guild) records one entry for each mapped field group it applies. A queued bulk job records one summary entry when it finishes, with the creating Admin and that request's audit reason. The `update_user_flags`, `update_guild_features`, and `schedule_user_deletion` tasks also record one ordinary entry for each account or guild the worker changes, with `audit_log_reason` null.
+
+An entry stores an acting Admin, a target type, a target ID, an action name, an optional audit reason, and a string metadata map. Fluxer resolves every other member at read time.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| log_id | snowflake | The ID of the audit entry |
+| admin_user_id | snowflake | The ID of the account that performed the action |
+| admin_user1 | ?[Admin user summary](#admin-user-summary-object) object | The resolved acting Admin, or null when the account no longer resolves |
+| target_type | string | The [audit target type](#audit-target-types) the entry was recorded against (1-256 characters) |
+| target_id2 | string | The target ID in decimal, which is the literal `0` for a target that has no snowflake |
+| target_user3 | ?[Admin user summary](#admin-user-summary-object) object | The resolved target account, or null when the entry names no account target |
+| target_guild4 | ?[Admin guild summary](#admin-guild-summary-object) object | The resolved target guild, or null when the entry names no guild target |
+| target_channel5 | ?[Admin channel summary](#admin-channel-summary-object) object | The resolved target channel, or null when the entry names no channel target |
+| related_users6 | map[snowflake, [Admin user summary](#admin-user-summary-object) object] | The accounts the entry references, keyed by ID |
+| related_guilds6 | map[snowflake, [Admin guild summary](#admin-guild-summary-object) object] | The guilds the entry references, keyed by ID |
+| related_channels6 | map[snowflake, [Admin channel summary](#admin-channel-summary-object) object] | The channels the entry references, keyed by ID |
+| action | string | The [audit action](#audit-actions) the service recorded (1-256 characters) |
+| audit_log_reason | ?string | The normalised [audit reason](#audit-reasons) (1-4,000 characters), or null when the request supplied none |
+| metadata7 | map[string, string] | The additional values the operation recorded |
+| created_at | ISO8601 timestamp | The time the entry was recorded |
+
+1 Resolved from `admin_user_id` at read time, so an account deleted after the entry was written reports null while `admin_user_id` still has the ID
+
+2 Serialised as a decimal string, like a [snowflake](/snowflakes/). A blocklist entry, a voice region, and a voice server record `0` here and their textual identity in `metadata`
+
+3 Resolved only when `target_type` is `user`, `guild_member`, `message_deletion`, or `message_shred`
+
+4 Resolved only when `target_type` is `guild`
+
+5 Resolved when `target_type` is `channel`, and otherwise from a `channel_id` metadata key
+
+6 Resolved once for the whole page and then repeated identically on every entry in it
+
+7 Keys are 1 to 256 characters and values are 0 to 4,000 characters. The keys an operation records are stated by that operation
+
+Fluxer populates the three `related_` maps from the acting Admin, from the resolved target, and from the metadata. A metadata key contributes when its value is a decimal integer and its name is `user_id`, `guild_id`, `channel_id`, `target_user_id`, or `admin_user_id`, or ends in `_user_id`, `_guild_id`, or `_channel_id`. An identifier that no longer resolves is omitted from the map.
+
+An entry has no request method, request path, status, duration, IP address, or field change list. An entry is never redacted on read, so a caller holding `audit_log:view` reads `audit_log_reason` and every metadata value in full.
+
+### Example
+
+```json
+{
+ "log_id": "1501314428688998182",
+ "admin_user_id": "1489200013322551296",
+ "admin_user": {
+ "id": "1489200013322551296",
+ "username": "operator",
+ "discriminator": "0042",
+ "global_name": "Operator"
+ },
+ "target_type": "user",
+ "target_id": "1493004551122763776",
+ "target_user": {
+ "id": "1493004551122763776",
+ "username": "subject",
+ "discriminator": "0007",
+ "global_name": null
+ },
+ "target_guild": null,
+ "target_channel": null,
+ "related_users": {},
+ "related_guilds": {},
+ "related_channels": {},
+ "action": "change_username",
+ "audit_log_reason": "Impersonation report 4821",
+ "metadata": {
+ "old_username": "subj3ct",
+ "new_username": "subject",
+ "discriminator": "7"
+ },
+ "created_at": "2026-05-14T09:12:44.183000+00:00"
+}
+```
+
+### Admin user summary object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the account |
+| username | string | The username of the account |
+| discriminator1 | string | The four-digit discriminator of the account |
+| global_name | ?string | The display name of the account, or null when none is set |
+
+1 Left-padded with zeroes to four digits, unlike the unpadded integer `discriminator` of the [Admin user object](/admin-api/users/#admin-user-object)
+
+### Admin guild summary object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild |
+
+### Admin channel summary object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel |
+| name | ?string | The name of the channel, or null for a direct message channel |
+| type | integer | The [channel type](/http-api/channels/#channel-types) |
+| guild_id | ?snowflake | The ID of the owning guild, or null for a direct message channel |
+
+### Audit target types
+
+| Value | Description |
+| --- | --- |
+| user | User account |
+| guild | Guild |
+| guild_member | Guild membership |
+| guild_emoji | Guild emoji asset |
+| guild_sticker | Guild sticker asset |
+| message | Message |
+| message_deletion1 | Bulk deletion of every message one account sent |
+| message_shred1 | Message shred job for one account |
+| asset | Stored media asset |
+| application | OAuth2 application |
+| report | User report |
+| search_index | Search index |
+| system_dm | System DM broadcast |
+| voice_region2 | Voice region |
+| voice_server2 | Voice server registration |
+| ip2 | IP blocklist entry or suspicious IP marker |
+| email2 | Exact email blocklist entry |
+| email_domain2 | Suspicious email domain entry |
+| phrase2 | Phrase blocklist entry |
+| url2 | Exact URL blocklist entry |
+| url_domain2 | URL domain blocklist entry |
+| file_sha2 | File SHA-256 blocklist entry |
+| avatar_hash2 | Avatar hash blocklist entry |
+| profile_substring2 | Profile substring blocklist entry |
+
+1 The target ID is the account the job acts on, and the entry resolves that account into `target_user`
+
+2 The target has no snowflake, so `target_id` is `0` and the entry's textual identity is in `metadata`
+
+The field is a free-form string, so a client MUST tolerate a value outside this table.
+
+### Audit actions
+
+An operation records one of the following actions. The field is a free-form string of 1 to 256 characters, so a client MUST tolerate a value outside this table.
+
+| Value | Description |
+| --- | --- |
+| NCMEC Report | An attachment was submitted through the NCMEC reporting integration |
+| add_suspicious_email_domain | A domain was added to the suspicious email domain list |
+| approve_registration | A pending registration was approved |
+| auto_resolve_reports_on_deletion | Reports against an account were resolved automatically as part of its deletion |
+| ban_avatar_hash | An avatar hash was added to the avatar blocklist |
+| ban_email | An address was added to the email blocklist |
+| ban_file_sha | A file hash was added to the attachment blocklist |
+| ban_ip | An address was added to the IP blocklist |
+| ban_ip_skipped_exempt | An IP blocklist entry was declined by the instance exemption list |
+| ban_ip_skipped_cgnat | An IP blocklist entry was declined by the carrier-grade NAT blast-radius guard |
+| ban_member | An account was banned from a guild |
+| ban_phrase | A phrase was added to the message phrase blocklist |
+| ban_profile_substring | A substring was added to the profile substring blocklist |
+| ban_url | A URL was added to the URL blocklist |
+| ban_url_domain | A domain was added to the URL domain blocklist |
+| bulk_add_guild_members | A bulk guild member addition was queued or applied |
+| bulk_ban_file_shas | A queued bulk file hash blocklist job was applied |
+| bulk_schedule_deletion | A bulk account deletion schedule was queued or applied |
+| bulk_update_guild_features | A bulk guild feature change was queued or applied |
+| bulk_update_suspicious_activity_flags | A bulk suspicious activity flag change was queued or applied |
+| bulk_update_user_flags | A bulk account flag change was queued or applied |
+| cancel_bulk_message_deletion | A bulk message deletion an account scheduled for itself was cancelled |
+| cancel_deletion | A scheduled account deletion was cancelled |
+| change_dob | A date of birth was changed |
+| change_email | An email address was changed |
+| change_username | A username or discriminator was changed |
+| clear_fields1 | Profile fields of an account, or fields of a guild, were cleared |
+| create_voice_region | A voice region was created |
+| create_voice_server | A voice server was registered |
+| delete_all_user_messages | Deletion of every message an account sent was queued |
+| delete_all_user_messages_dry_run | The same operation was run with `dry_run` set, so no message was deleted |
+| delete_guild | A guild was deleted |
+| delete_message | One message was deleted |
+| delete_voice_region | A voice region was deleted |
+| delete_voice_server | A registered voice server was deleted |
+| delete_webauthn_credential | A WebAuthn credential was removed from an account |
+| disable_mfa | Multi-factor authentication was disabled on an account |
+| disable_suspicious_activity | An account flagged for suspicious activity was disabled |
+| force_add_to_guild | An account was added to a guild while bypassing the ban check |
+| kick_member | A member was removed from a guild |
+| list_user_sessions | An account's login sessions were read |
+| list_webauthn_credentials | An account's WebAuthn credentials were read |
+| mark_suspicious_ip | An IP address was marked as suspicious |
+| mark_suspicious_ip_skipped_invalid | The address could not be parsed, so no marker was written |
+| mark_suspicious_ip_skipped_ipinfo_unavailable | IP intelligence was unavailable, so no marker was written |
+| mark_suspicious_ip_skipped_trusted_commercial_privacy_provider | The address belongs to a trusted commercial privacy provider, so no marker was written |
+| mark_suspicious_ip_skipped_high_blast_radius | The address failed the blast-radius guard, so no marker was written |
+| purge_asset | A stored media asset was purged |
+| purge_guild_emoji_asset | A guild emoji asset was purged |
+| purge_guild_sticker_asset | A guild sticker asset was purged |
+| queue_message_shred | A message shred job was queued |
+| queue_refresh_index | A search index rebuild was requested |
+| reject_registration | A pending registration was rejected |
+| reload_guild | One guild was reloaded on the main Gateway |
+| remove_relationship | One relationship was removed from an account |
+| remove_relationships_by_category | Every relationship in one category was removed from an account |
+| remove_suspicious_email_domain | A domain was removed from the suspicious email domain list |
+| resend_verification_email | An email verification message was requested again for an account |
+| resolve_report | A report was updated or resolved |
+| schedule_deletion | Account deletion was scheduled |
+| send_password_reset | A password reset message was requested for an account |
+| set_acls | The Admin ACL set of an account was replaced |
+| set_bot_status | The bot flag of an account was changed |
+| set_system_status | The system flag of an account was changed |
+| set_traits | Account traits were replaced |
+| shutdown_guild | One guild was stopped on the main Gateway |
+| system_dm.send | A system DM broadcast was created |
+| temp_ban | A temporary or permanent account ban was applied |
+| terminate_sessions | Every login session of an account was revoked |
+| transfer_ownership1 | Guild ownership or OAuth2 application ownership was transferred |
+| trigger_user_archive | A user archive was created |
+| unban | An account ban was lifted |
+| unban_avatar_hash | An avatar hash was removed from the avatar blocklist |
+| unban_email | An address was removed from the email blocklist |
+| unban_file_sha | A file hash was removed from the attachment blocklist |
+| unban_ip | An address was removed from the IP blocklist |
+| unban_phrase | A phrase was removed from the message phrase blocklist |
+| unban_profile_substring | A substring was removed from the profile substring blocklist |
+| unban_url | A URL was removed from the URL blocklist |
+| unban_url_domain | A domain was removed from the URL domain blocklist |
+| update_features | Guild features were added or removed |
+| update_flags | Account flags were changed |
+| update_has_verified_phone | Verified phone state was changed |
+| update_name | A guild name was changed |
+| update_premium_flags | Premium flags were changed |
+| update_settings | General guild settings were changed |
+| update_suspicious_activity_flags | Suspicious activity flags were replaced |
+| update_vanity | A guild custom invite code was changed |
+| update_voice_region | A voice region was updated |
+| update_voice_server | A registered voice server was updated |
+| verify_email | An email address was marked verified |
+
+1 The same action name is recorded for two different resources, so `target_type` disambiguates the entry
+
+
+
+## Audit reasons
+
+Fluxer reads the `X-Audit-Log-Reason` header once at the boundary, on every request to the API, Admin or not. The header is read raw and never percent-decoded, so a client that URI-encodes the value has the encoded form stored.
+
+Fluxer normalises the value by stripping control and format characters and trimming surrounding whitespace. An absent header, a blank value, and a value whose normalised length exceeds 512 characters all resolve to null. Fluxer never fails a request during this normalisation, so an over-long reason is dropped silently.
+
+Fluxer writes the resolved value to `audit_log_reason` on every entry the request records. An operation that records no entry accepts the header and does nothing with it. An Admin resource page marks an operation with the audit reason capability only where an entry stores the value.
+
+## Errors and rate limits
+
+### Error response
+
+An Admin failure uses the same envelope as the [HTTP API error response](/http-api/#error-response). The HTTP status is in the status line, and the request identifier in the `X-Request-ID` response header.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The stable machine-readable [API error code](/http-api/errors/#api-error-code-registry) |
+| message1 | string | The human-readable description of this failure |
+| errors?2 | array[[validation error](/http-api/#validation-error-object) object] | The request fields that failed validation |
+
+1 The wording varies by locale and is not a stable value for programmatic matching
+
+2 Present on an `INVALID_FORM_BODY` response, and on any other failure that has field detail
+
+Structured detail sits at the top level of the object. A client MUST treat a member it does not recognise as absent, and MUST NOT assume that two failures with the same `code` have the same extra members.
+
+### Standard response statuses
+
+Every Admin response follows the [shared response header contract](/http-api/#standard-response-headers). An HTTP 401 has no challenge header, as described under [failed authentication](/authentication/#failed-authentication). [Admin authentication](#authentication) lists the credential types each Admin operation accepts.
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 400 | [error response](#error-response) | Path, query, or body validation fails |
+| 401 | [error response](#error-response) | Admin credential is missing or unresolvable |
+| 403 | [error response](#error-response) | Credential type or ACL evaluation denies the request |
+| 404 | [error response](#error-response) | A referenced resource does not exist |
+| 429 | [error response](#error-response) | Route or global rate limit denies the request |
+| 500 | [error response](#error-response) | An unexpected server error occurs |
+| 503 | [error response](#error-response) | The in-flight request ceiling of the instance is exceeded |
+
+A 429 has `Retry-After`, `X-RateLimit-Scope`, and the route bucket headers described in [rate limit headers](/topics/rate-limits/#rate-limit-headers). A 503 has `Retry-After: 1`. Fluxer applies the concurrency ceiling before routing, so a 503 can answer any Admin path with `SERVICE_UNAVAILABLE` regardless of credentials.
+
+An Admin route bucket is keyed by the authenticated account and the credential type together, and no Admin bucket name has a path parameter. Every operation naming one bucket shares one allowance for each account, and an Admin API key draws on a separate allowance from its owner's session.
+
+Every Admin operation declares a route bucket, and each Admin resource page names the one its operations use.
+
+The global user bucket permits 50 requests per second, rising to 1,200 for an account that has the `HIGH_GLOBAL_RATE_LIMIT` flag. An account with the `RATE_LIMIT_BYPASS` flag is evaluated against no bucket at all. Instance configuration can change route and global limits as described in [rate limits](/topics/rate-limits/).
+
+Fluxer classifies a successful Admin request from an ordinary account as a user request, and the response has no informational rate limit headers.
+
+## List ACLs
+
+
+
+Returns every Admin permission string the instance recognises, in [ACL registry](#acl-registry) order. Requires `admin:authenticate`.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| acls1 | array[string] | The permission strings the Admin API recognises (at most 111 entries) |
+
+1 The response is the registry itself and does not vary with the caller's own ACL set
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | ACL registry was returned |
+| 403 | [error response](#error-response) | Credential type is refused, or the account holds neither `admin:authenticate` nor `*` |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## ACL registry
+
+The registry is returned in this order by [List ACLs](#list-acls). A value outside it authorises nothing.
+
+| Value | Description |
+| --- | --- |
+| *1 | Satisfies every Admin permission, including permissions introduced later |
+| acl:set:user | Replaces the Admin ACL set held by an account |
+| admin_api_key:manage | Creates, reads, renames, and revokes [Admin API keys](/admin-api/api-keys/) |
+| application:lookup | Reads [Admin application](/admin-api/applications/) resources, including the applications owned by one account |
+| application:list:by_owner2 | Reads the two application listings |
+| application:transfer_ownership | Transfers OAuth2 application ownership |
+| archive:trigger:guild | Creates guild [archives](/admin-api/archives/) |
+| archive:trigger:user | Creates user archives |
+| archive:view_all3 | Reads and downloads archives |
+| asset:purge | Reads a guild's emojis and stickers, and purges guild media assets |
+| audit_log:view | Reads the [Admin audit log](#admin-audit-entry-object) |
+| admin:authenticate | Permits the account to reach the Admin API at all, and admits [List ACLs](#list-acls) and [Get current Admin user](/admin-api/users/#get-current-admin-user) |
+| jobs:view | Reads background [job](/admin-api/jobs/) state |
+| jobs:cancel | Requests background job cancellation |
+| ban:email:add | Adds and updates `email` [blocklist](/admin-api/blocklists/) entries |
+| ban:email:check | Reads the `email` blocklist |
+| ban:email:remove | Removes `email` blocklist entries |
+| suspicious_email_domain:add | Adds and updates `email-domain-suspicious` blocklist entries |
+| suspicious_email_domain:check | Reads the `email-domain-suspicious` blocklist |
+| suspicious_email_domain:remove | Removes `email-domain-suspicious` blocklist entries |
+| ban:phrase:add | Adds and updates `phrase` blocklist entries |
+| ban:phrase:check | Reads the `phrase` blocklist |
+| ban:phrase:remove | Removes `phrase` blocklist entries |
+| ban:ip:add | Adds and updates `ip` blocklist entries |
+| ban:ip:check | Reads the `ip` blocklist |
+| ban:ip:remove | Removes `ip` blocklist entries |
+| ban:url:add | Adds and updates `url` blocklist entries |
+| ban:url:check | Reads the `url` blocklist |
+| ban:url:remove | Removes `url` blocklist entries |
+| ban:url_domain:add | Adds and updates `url-domain` blocklist entries |
+| ban:url_domain:check | Reads the `url-domain` blocklist |
+| ban:url_domain:remove | Removes `url-domain` blocklist entries |
+| ban:file_sha:add | Adds and updates `file-sha` blocklist entries |
+| ban:file_sha:check | Reads the `file-sha` blocklist |
+| ban:file_sha:remove | Removes `file-sha` blocklist entries |
+| ban:avatar_hash:add 4 | Adds and updates `avatar-hash` blocklist entries |
+| ban:avatar_hash:check | Reads the `avatar-hash` blocklist |
+| ban:avatar_hash:remove | Removes `avatar-hash` blocklist entries |
+| ban:profile_substring:add | Adds and updates `profile-substring` blocklist entries |
+| ban:profile_substring:check | Reads the `profile-substring` blocklist |
+| ban:profile_substring:remove | Removes `profile-substring` blocklist entries |
+| bulk:add:guild_members | Queues the bulk guild member addition task |
+| bulk:delete:users | Queues the bulk account deletion task |
+| bulk:update:guild_features | Queues the bulk guild feature task |
+| bulk:update:suspicious_activity | Queues the bulk suspicious activity flag task |
+| bulk:update:user_flags | Queues the bulk account flag task |
+| csam:submit_ncmec | Submits an attachment through the NCMEC reporting integration |
+| discovery:remove | Removes a guild from [discovery](/admin-api/discovery/) |
+| discovery:review | Reads, reviews, and edits discovery applications, categories, and listings |
+| gateway:memory_stats | Reads main [Gateway](/admin-api/gateway/) statistics, memory statistics, and voice state counts |
+| gateway:reload_all | Reloads guilds across the main Gateway |
+| gift_codes:generate | Generates [gift codes](/admin-api/gift-codes/) |
+| guild:audit_log:view | Reads a guild's ordinary audit log |
+| guild:ban_member | Bans a guild member |
+| guild:delete | Deletes a guild |
+| guild:force_add_member | Adds an account to a guild |
+| guild:kick_member | Removes a guild member |
+| guild:list:members | Reads guild member resources |
+| guild:lookup | Reads and searches Admin [guild](/admin-api/guilds/) resources, and queues and reads [search index](/admin-api/search-indexes/) rebuilds |
+| guild:reload | Reloads one guild on the main Gateway |
+| guild:shutdown | Stops one guild on the main Gateway |
+| guild:transfer_ownership5 | Transfers guild ownership |
+| guild:update:features5 | Adds and removes guild features |
+| guild:update:name5 | Updates a guild name |
+| guild:update:settings5 | Updates general guild settings and clears guild fields |
+| guild:update:vanity5 | Updates a guild custom invite code |
+| instance:config:update | Updates [instance](/admin-api/instance/) configuration, branding assets, SMTP tests, registration URLs, and pending registrations |
+| instance:config:view | Reads instance configuration |
+| instance:limit_config:update | Replaces the instance-wide limit configuration |
+| instance:limit_config:view | Reads the instance-wide limit configuration |
+| message:delete_all | Queues deletion of every message one account sent |
+| message:delete | Deletes one message |
+| message:lookup | Reads, browses, and searches [messages](/admin-api/messages/) through the Admin API |
+| message:shred | Queues an explicit message shred job and reads its state |
+| report:resolve | Updates and resolves [reports](/admin-api/reports/) |
+| report:view | Reads and searches reports |
+| report:view:reporter_pii6 | Unredacts the contact fields a reporter supplied |
+| system_dm:send | Sends an official direct message from the instance to one or many accounts |
+| system:heap_snapshot | Captures a heap snapshot of a running process |
+| user:cancel:bulk_message_deletion | Cancels the bulk message deletion an account scheduled for itself |
+| user:delete | Schedules or cancels account deletion |
+| user:disable:suspicious | Applies the suspicious account disable operation |
+| user:list:dm_channels | Reads a user's direct message and group direct message channels |
+| user:list:guilds | Reads a user's guild memberships |
+| user:list:relationships | Reads a user's relationships |
+| user:list:sessions | Reads a user's login sessions |
+| user:lookup | Reads and searches the base Admin [user](/admin-api/users/) resource |
+| user:remove:relationship | Removes one relationship or every relationship in one category |
+| user:view:contact_log | Reads a user's identity and contact change log |
+| user:view:dob6 | Unredacts user dates of birth |
+| user:view:email6 | Unredacts user email addresses and email verification state |
+| user:view:ip6 | Unredacts the last active IP address and the location derived from it |
+| user:temp_ban | Applies and lifts an account ban |
+| user:update:bot_status | Updates bot and system account state |
+| user:update:dob | Updates dates of birth |
+| user:update:email | Updates the email address, marks the address verified, resends verification, and sends a password reset |
+| user:update:flags | Updates account flags and premium flags, and terminates every login session of an account |
+| user:update:mfa | Reads and removes a user's WebAuthn credentials, and removes MFA state |
+| user:update:phone | Updates verified phone state |
+| user:update:profile | Clears profile fields |
+| user:update:suspicious_activity | Replaces suspicious activity flags |
+| user:update:traits | Replaces account traits |
+| user:update:username | Updates usernames and discriminators |
+| voice:region:create | Creates [voice](/admin-api/voice/) regions |
+| voice:region:delete | Deletes voice regions |
+| voice:region:list | Reads voice regions |
+| voice:region:update | Updates voice regions |
+| voice:server:create | Registers a voice server in a region |
+| voice:server:delete | Deletes a registered voice server |
+| voice:server:list | Reads registered voice servers |
+| voice:server:update | Updates a registered voice server |
+
+1 The first entry of the registry. An ACL set can contain it alongside named permissions
+
+2 [List applications](/admin-api/applications/#list-applications) and [List user applications](/admin-api/users/#list-user-applications) accept it in place of `application:lookup`, and no other operation reads it
+
+3 The three archive reads accept `archive:view_all`, `archive:trigger:user`, or `archive:trigger:guild`, so an Admin who can create archives can also read them
+
+4 [Block a user's current avatar](/admin-api/blocklists/#block-a-users-current-avatar) requires it as well
+
+5 [Update guild](/admin-api/guilds/#update-guild) names all five of these as one any-of requirement and then requires every one that the submitted body maps to
+
+6 The permission admits no operation of its own. It only widens what an already admitted read returns
+
+[List ACLs](#list-acls) returns these permission strings alone, with no description attached.
+
+## List audit log entries
+
+
+
+Returns a page of [Admin audit entry](#admin-audit-entry-object) objects with their resolved user, guild, and channel summaries. Requires `audit_log:view`.
+
+Reading the log records no entry of its own.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| q?1 | string | The free-text query (1-1,024 characters) |
+| admin_user_id? | snowflake | Return only entries recorded for this acting Admin |
+| target_type? | string | Return only entries recorded against this [audit target type](#audit-target-types) (1-64 characters) |
+| target_id? | string | Return only entries recorded against this target ID |
+| sort_by?2 | string | `createdAt` or `relevance` (default `createdAt`) |
+| sort_order?2 | string | `asc` or `desc` (default `desc`) |
+| limit? | integer | The maximum number of entries to return (1-200, default 50) |
+| offset? | integer | The number of entries to skip (minimum 0, default 0) |
+
+1 Supplying it runs a full-text search over the indexed `action`, `target_type`, `target_id`, and `audit_log_reason` of each entry. Omitting it pages the log in order with the same filters applied
+
+2 Honoured only when `q` is supplied, and without `q` the listing is ordered newest first. `relevance` orders by search score and ignores `sort_order`
+
+:::caution[The listing degrades without the search index]
+Without an index, `q` is ignored, and a filtered page holds only the matches among the first `limit` plus `offset` rows, so it can be far shorter than `limit` while more matches exist.
+:::
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| logs | array[[Admin audit entry](#admin-audit-entry-object) object] | The entries in this page |
+| total1 | integer | The number of entries the query matched |
+
+1 The index reports the full match count. The table fallback reports only the matches inside the rows it scanned
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Audit page was returned |
+| 403 | [error response](#error-response) | Credential type is refused, or the account holds neither `audit_log:view` nor `*` |
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:audit_log` bucket.
+
+## Get audit log entry
+
+
+
+Returns one [Admin audit entry](#admin-audit-entry-object) object with the same resolved summaries the listing returns. Requires `audit_log:view`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| log_id | snowflake | The ID of the audit entry |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Admin audit entry](#admin-audit-entry-object) object | Audit entry was returned |
+| 403 | [error response](#error-response) | Credential type is refused, or the account holds neither `audit_log:view` nor `*` |
+| 404 | [error response](#error-response) | No entry has that identifier and the request returns `NOT_FOUND` |
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:audit_log` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx
new file mode 100644
index 000000000..539c1b6a7
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx
@@ -0,0 +1,806 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin instance
+description: Instance configuration, branding, registration control, limits, and diagnostics.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Instance configuration is everything an operator can change at runtime. It covers single sign-on, Gateway rollout, registration policy, branding and legal links, instance policy, third party integrations, media retention, and the ordered limit configuration. The [Instance](/http-api/instance/) resource serves the subset published to unauthenticated clients.
+
+Every write is a merge over the stored configuration. An omitted key leaves the stored value unchanged.
+
+Reading configuration requires the [Admin ACL](/admin-api/#acl-registry) `instance:config:view`, and writing it requires `instance:config:update`. The limit configuration has its own pair, `instance:limit_config:view` and `instance:limit_config:update`. The heap snapshot requires `system:heap_snapshot`.
+
+:::note[Initial setup accepts a plain session credential]
+Until setup is marked complete, [Get instance configuration](#get-instance-configuration), [Update instance configuration](#update-instance-configuration), [Create branding asset](#create-branding-asset), and [Create SMTP test](#create-smtp-test) also accept a session credential holding no Admin ACL.
+:::
+
+That relaxation applies only to a session credential, so an Admin API key and a bearer token are evaluated normally even before setup is complete. Completing setup grants the acting session the wildcard ACL. Registration URLs, pending registrations, limit configuration, and the heap snapshot always need their own ACL.
+
+:::caution[No read returns a stored secret]
+A secret is reported by a companion boolean such as `client_secret_set`. Except for the single sign-on client secret, that boolean is true whether the value is stored here or supplied by deployment configuration.
+:::
+
+## Instance configuration object
+
+The complete runtime configuration of the deployment. Every configuration operation here except the limit configuration and the heap snapshot returns it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sso | [SSO configuration](#sso-configuration-object) object | Single sign-on settings |
+| gateway_rollout | [Gateway rollout configuration](#gateway-rollout-configuration-object) object | Gateway admission and dispatch tuning |
+| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
+| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
+| app_public | [public application configuration](#public-application-configuration-object) object | Branding, legal, setup, and registration field policy |
+| policy | [instance policy](#instance-policy-object) object | Community, direct message, premium, and gating policy |
+| integrations | [instance integrations](#instance-integrations-object) object | Third party provider settings and their resolved availability |
+| media | [instance media](#instance-media-object) object | Attachment retention settings |
+
+## SSO configuration object
+
+Single sign-on settings for the deployment's OpenID Connect provider.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled | boolean | Whether single sign-on is offered |
+| enforced1 | boolean | Whether single sign-on is the only accepted login method |
+| display_name | ?string | Provider name shown on the login screen |
+| issuer | ?string | OpenID Connect issuer |
+| authorization_url | ?string | Authorisation endpoint |
+| token_url | ?string | Token endpoint |
+| userinfo_url | ?string | Userinfo endpoint |
+| jwks_url | ?string | JWKS endpoint |
+| client_id | ?string | Registered client identifier |
+| client_secret_set | boolean | Whether a client secret is stored |
+| scope | ?string | Space-separated scope string |
+| allowed_domains2 | array[string] | Email domains permitted to sign in (max 100 entries) |
+| auto_provision | boolean | Whether a first-time sign-in creates an account |
+| redirect_uri3 | ?string | Redirect URI to register with the provider |
+
+1 A deployment that has never stored the value reports the same value as `enabled`
+
+2 Each entry is stored lowercased and IDNA encoded, duplicates are collapsed, and an empty entry is dropped
+
+3 The configured web application endpoint followed by `/auth/sso/callback`. No operation can set it
+
+## Gateway rollout configuration object
+
+Admission and dispatch tuning for the Gateway cluster.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_rollout_percentage | number | Percentage of sessions admitted to the new Gateway (0-100, default 100) |
+| session_rollout_mode | string | `modulo` or `random` (default `modulo`) |
+| guild_rollout_percentage | number | Percentage of guilds admitted to the new Gateway (0-100, default 100) |
+| rpc_request_timeout_ms | integer | Deadline for one Gateway RPC (1000-60000, default 10000) |
+| max_concurrent_session_starts | integer | Session starts admitted at once (1-10000, default 512) |
+| max_concurrent_guild_starts | integer | Guild starts admitted at once (1-10000, default 256) |
+| gateway_dispatch_relay_shards | integer | Dispatch relay shard count (1-10000, default 32) |
+| gateway_dispatch_relay_max_queue | integer | Dispatch relay queue ceiling (0-1000000, default 50000) |
+| voice_e2ee_scope | string | `guild_feature_only` or `platform_wide` (default `guild_feature_only`) |
+| voice_reconciliation_v3_percentage | number | Percentage of voice states on the newer reconciliation path (0-100, default 100) |
+| voice_reconciliation_v3_interval_ms | integer | Voice reconciliation interval (500-60000, default 2000) |
+
+Every field is present on read. A deployment that has stored nothing reports the defaults above.
+
+## Registration configuration object
+
+Registration policy in force, plus every issued registration URL and every account awaiting a decision.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mode | string | [Registration mode](#registration-modes) |
+| admin_registration_urls_enabled1 | boolean | Whether Admin-issued registration URLs are accepted |
+| urls2 | array[[registration URL](#registration-url-object) object] | Every issued registration URL, including revoked and exhausted records |
+| pending_registrations3 | array[[pending registration](#pending-registration-object) object] | Accounts awaiting a decision |
+
+1 While the value is false, registration refuses every supplied registration URL code, so an issued URL stops working without being revoked
+
+2 Ordered by creation time, newest first
+
+3 Ordered by request time, oldest first
+
+Both collections are embedded in the configuration response. There is no separate listing operation and no pagination, so every configuration read returns every issued URL.
+
+## Registration modes
+
+| Value | Description |
+| --- | --- |
+| open | Anyone can register |
+| approval | Anyone can register, and an Admin decision is required before the account can be used |
+| closed | Public registration is closed |
+
+A valid registration URL is accepted in every mode, including `closed`, and its own `approval_required` replaces the mode for the account it creates.
+
+## Registration URL object
+
+A registration URL is an invitation an Admin can issue while public registration is closed or gated. It has its own expiry, use budget, and approval requirement.
+
+Fluxer accepts a URL while it has no revocation time, has not passed its expiry, and has a use count below `max_uses`. A URL failing any of those tests is still reported here.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | Identifier of the registration URL (1-128 characters) |
+| label | ?string | Admin note attached to the URL, or null when unset |
+| created_by_user_id | snowflake | Admin account that issued the URL |
+| created_at | ISO8601 timestamp | Time the URL was issued |
+| expires_at | ?ISO8601 timestamp | Time the URL stops working, or null when it never expires |
+| max_uses | ?integer | Maximum permitted uses, or null for unlimited use |
+| use_count | integer | How many registrations have completed through the URL |
+| revoked_at | ?ISO8601 timestamp | Time the URL was revoked, or null while it has not been revoked |
+| approval_required2 | boolean | Whether an account created through the URL still needs an Admin decision |
+| last_used_at | ?ISO8601 timestamp | Time the URL was last used, or null when it never has been |
+| last_used_by_user_id | ?snowflake | Most recent account created through the URL, or null when there is none |
+
+1 A randomly generated UUID that is also the bearer code authorising registration, so every reader of the configuration can redeem an unrevoked URL
+
+2 The value replaces the instance registration mode for an account created through this URL, in both directions. A URL with the value false lets an account skip approval on an instance in `approval` mode
+
+### Example
+
+```json
+{
+ "id": "3f2a91c4-6d1e-4a77-9f0b-2c5d8e114a20",
+ "label": "Design team",
+ "created_by_user_id": "1478812292088791040",
+ "created_at": "2026-08-20T10:00:00.000Z",
+ "expires_at": null,
+ "max_uses": 25,
+ "use_count": 3,
+ "revoked_at": null,
+ "approval_required": false,
+ "last_used_at": "2026-08-29T18:22:10.000Z",
+ "last_used_by_user_id": "1500901337221828608"
+}
+```
+
+## Pending registration object
+
+One account that registered and is still waiting for an Admin decision.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | Account awaiting a decision |
+| username | string | Username chosen at registration |
+| discriminator | integer | Discriminator tag from 0 to 9999 |
+| global_name | ?string | Display name of the account, or null when unset |
+| email | ?string | Email address of the account, or null when none is stored |
+| requested_at | ISO8601 timestamp | Time the account registered |
+| registration_url_id | ?string | [Registration URL](#registration-url-object) the account signed up through (1-128 characters), or null when it registered directly |
+| client_ip1 | ?string | IP address the account registered from, or null when none was recorded |
+
+1 Stored at registration time and never refreshed, so it can be stale by the time an Admin reads it
+
+## Public application configuration object
+
+Branding, legal, setup and registration field policy, in the Admin form of what the [Instance](/http-api/instance/) resource publishes to clients.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| branding | [public branding](/http-api/instance/#public-branding-object) object | Product name, image URLs, and theme colour |
+| setup1 | object | Has the single boolean `configured` |
+| legal | [public legal configuration](/http-api/instance/#public-legal-configuration-object) object | Terms and privacy URLs |
+| registration2 | [public registration fields](/http-api/instance/#public-registration-fields-object) object | Registration field collection policy |
+
+1 The [Instance](/http-api/instance/) resource publishes `admin_url` in this object as well
+
+2 The object has the single boolean `collect_date_of_birth`
+
+## Instance policy object
+
+Community, direct message, premium and gating policy for the whole deployment.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| single_community_enabled | boolean | Whether the deployment presents one community |
+| single_community_guild_id | ?string | Guild used as that community, or null before one has been provisioned |
+| direct_messages_disabled | boolean | Whether direct messages are disabled |
+| direct_messages_locked1 | boolean | Whether the direct message setting is locked against further change |
+| premium_mode | string | [Premium mode](#premium-modes) |
+| services | object | Operator overrides for `gif_enabled`, `youtube_enabled`, and `bluesky_enabled`. Each is nullable, and null means no override |
+| services_resolved2 | object | The same three keys as concrete booleans, resolved from the override and the provider's own availability |
+| services_available3 | object | Provider availability for `gif`, `youtube`, and `bluesky`, with no operator override applied |
+| deferred_phone_gate | [deferred phone gate](#deferred-phone-gate-object) object | Delayed phone verification policy |
+
+1 The lock is set each time direct messages are re-enabled. [Update instance configuration](#update-instance-configuration) clears it when the policy object has `direct_messages_locked` set to false
+
+2 Each key is the operator override when one is set, and otherwise the matching `services_available` value
+
+3 `gif` and `youtube` report whether an API key resolves from either the stored configuration or the deployment configuration. `bluesky` reports the integration's resolved enablement
+
+## Premium modes
+
+| Value | Description |
+| --- | --- |
+| mirror | Resolve premium access from the account's own entitlement and premium flags |
+| everyone | Grant premium access to every account on a self-hosted deployment |
+
+Changing this value reloads the resolved limit configuration across every node.
+
+## Deferred phone gate object
+
+A rule that imposes phone verification in a configured window of hours after registration.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled | boolean | Whether the delayed phone requirement is applied (default false) |
+| window_hours | number | Hours after registration in which the requirement can be imposed (default 6) |
+| member_threshold | number | Guild member count above which the requirement is imposed (default 50) |
+
+## Instance integrations object
+
+Every provider reports its stored settings, a `_set` boolean in place of each secret, and the availability resolved from the stored settings and the deployment configuration.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| gif | object | `klipy_api_key_set` and `effective_available` |
+| youtube | object | `api_key_set` and `effective_available` |
+| captcha | [captcha integration](#captcha-integration-object) object | CAPTCHA provider settings |
+| email | [email integration](#email-integration-object) object | Outbound email settings |
+| bluesky | [Bluesky integration](#bluesky-integration-object) object | Bluesky client settings |
+
+## Captcha integration object
+
+CAPTCHA provider settings, and the provider resolved from them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| provider | ?string | Operator override of `hcaptcha`, `turnstile`, or `none`, or null for no override |
+| effective_provider | string | Provider actually in use, one of `hcaptcha`, `turnstile`, or `none` |
+| hcaptcha_site_key | ?string | hCaptcha site key |
+| hcaptcha_secret_key_set | boolean | Whether an hCaptcha secret is stored or supplied by deployment configuration |
+| turnstile_site_key | ?string | Turnstile site key |
+| turnstile_secret_key_set | boolean | Whether a Turnstile secret is stored or supplied by deployment configuration |
+| effective_enabled | boolean | Whether CAPTCHA verification is in force |
+
+## Email integration object
+
+Outbound email settings, and the provider resolved from them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled | ?boolean | Operator override, or null for no override |
+| effective_enabled | boolean | Whether outbound email is in force |
+| provider | ?string | Operator override of `smtp` or `none`, or null for no override |
+| effective_provider | string | Provider actually in use, either `smtp` or `none` |
+| from_email | ?string | Envelope sender address |
+| from_name | ?string | Envelope sender name |
+| smtp1 | object | `host`, `port`, `username`, `secure`, and `password_set` |
+| disable_new_ip_authorization | boolean | Whether new-IP authorisation email is suppressed by the stored setting |
+| effective_disable_new_ip_authorization2 | boolean | Whether it is suppressed once outbound email state is taken into account |
+
+1 `host`, `username`, and `secure` are nullable, `port` is a nullable integer from 1 to 65535, and `password_set` is a plain boolean
+
+2 True whenever the stored setting is true or outbound email is not in force
+
+## Bluesky integration object
+
+Client identity the deployment presents to Bluesky, and the number of signing keys it stores.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled | ?boolean | Operator override, or null for no override |
+| effective_enabled | boolean | Whether the integration is in force |
+| client_name | ?string | Client name presented to Bluesky |
+| client_uri | ?string | Client URI |
+| logo_uri | ?string | Client logo URI |
+| tos_uri | ?string | Client terms URI |
+| policy_uri | ?string | Client policy URI |
+| key_count | integer | Number of stored signing keys |
+
+## Instance media object
+
+Attachment retention overrides the operator has set, and the values in force.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| attachment_decay1 | object | Nullable operator overrides plus an `effective` object of the same keys with concrete values |
+
+1 The override keys are `enabled`, `min_size_mb`, `max_size_mb`, `max_eligible_size_mb`, `min_lifetime_days`, `max_lifetime_days`, `curve`, `renew_threshold_days`, and `renew_window_days`. Each is null when the deployment default applies, and `effective` reports the value in force. `enabled` is a boolean, `curve` is a number from 0 to 1, the `_mb` keys are positive numbers, and the `_days` keys are positive integers
+
+## Branding asset kinds
+
+| Value | Description |
+| --- | --- |
+| icon | Writes `branding.icon_url` |
+| symbol | Writes `branding.symbol_url` |
+| logo | Writes `branding.logo_url` |
+| wordmark | Writes `branding.wordmark_url` |
+| favicon | Writes `branding.favicon_url` |
+
+## SMTP test result object
+
+Whether one [Create SMTP test](#create-smtp-test) connection attempt succeeded, and the failure text when it did not.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ok | boolean | Whether the server accepted the connection and credentials |
+| error1 | ?string | Failure text, or null when the test succeeded |
+
+1 The message reported by the SMTP client, so it can repeat text supplied by the remote server
+
+### Example
+
+```json
+{
+ "ok": false,
+ "error": "Invalid login: 535 5.7.8 Authentication credentials invalid"
+}
+```
+
+## Registration URL creation object
+
+One issued registration URL together with the link an Admin hands out.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| registration_url | [registration URL](#registration-url-object) object | Record that was issued |
+| code1 | string | Bearer code that authorises registration (1-256 characters) |
+| url2 | string | Complete registration link built from the configured web application endpoint |
+
+1 Equal to `registration_url.id`, so it can be recovered from any later configuration read
+
+2 The configured web application endpoint followed by `/register?registration_url=` and the percent-encoded code
+
+## Limit configuration response object
+
+The stored limit configuration together with the deployment defaults and the metadata an editor needs.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit_config | [limit configuration](#limit-configuration-object) object | Stored configuration in force |
+| limit_config_json | string | The same document rendered as JSON indented by two spaces, for an editor to display |
+| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
+| defaults1 | map[string, map[string, integer]] | Deployment default limits, keyed by rule identifier and then by limit key |
+| metadata | map[string, [limit key metadata](#limit-key-metadata-object) object] | Presentation metadata for each limit key |
+| categories2 | map[string, string] | Display label for each metadata category |
+| limit_keys | array[string] | Every [limit key](/http-api/instance/#limit-keys) in registry order |
+| bounds?3 | map[string, object] | Optional `min` and `max` pair for each limit key |
+
+1 Built with the premium mode treated as `everyone`, so on a self-hosted deployment whose premium mode is `mirror` it omits the `premium` rule that is applied
+
+2 The keys are `messages`, `guilds`, `channels`, `expressions`, `files`, `social`, and `features`
+
+3 Absent from every response
+
+## Limit configuration object
+
+Trait definitions and the ordered rules that decide each [limit key](/http-api/instance/#limit-keys).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| traitDefinitions | array[string] | Trait names a rule filter can match |
+| rules | array[[limit rule](#limit-rule-object) object] | Ordered limit rules |
+
+The two field names are camelCase, unlike the rest of the Admin API.
+
+## Limit rule object
+
+One rule in that ordered set, with the filters that scope it and the limits it sets.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Rule identifier (at least 1 character) |
+| filters? | object | Optional `traits` and `guildFeatures` string arrays that scope the rule |
+| limits | map[string, integer] | Non-negative value for each [limit key](/http-api/instance/#limit-keys) the rule sets |
+| modifiedFields?1 | array[string] | Limit keys whose value differs from the deployment default |
+
+1 A rule whose identifier matches no default rule and no rule named `default` reports every key it sets. A rule with no differing key omits the field
+
+## Limit key metadata object
+
+Presentation metadata for one [limit key](/http-api/instance/#limit-keys), which an editor uses to render its control.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key | string | The [limit key](/http-api/instance/#limit-keys) |
+| label | string | Display label |
+| description | string | Description of what the limit bounds |
+| category | string | Category key, resolved against `categories` |
+| scope | string | `user`, `guild`, or `both` |
+| isToggle | boolean | Whether the key is a feature gate whose value is 0 or 1 |
+| unit? | string | `bytes` or `count` |
+| min? | number | Suggested minimum for an editor |
+| max? | number | Suggested maximum for an editor |
+
+## Get instance configuration
+
+
+
+Returns the [instance configuration](#instance-configuration-object) object. Requires `instance:config:view`, or a session credential until setup is marked complete.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [instance configuration](#instance-configuration-object) object | Configuration was returned |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Update instance configuration
+
+
+
+Applies a merge patch to the stored configuration and returns the resulting [instance configuration](#instance-configuration-object) object. Requires `instance:config:update`, or a session credential until setup is marked complete.
+
+:::note[Every section is optional and merged independently]
+The body has one optional object for each section, and Fluxer leaves an absent section alone. Within a section, an absent key keeps its stored value, and a key sent as null clears the stored value where the schema permits null.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sso?1 | object | Every [SSO configuration](#sso-configuration-object) field except `client_secret_set` and `redirect_uri`, plus `client_secret` |
+| gateway_rollout? | object | Any subset of the [Gateway rollout configuration](#gateway-rollout-configuration-object) fields, each bound as documented there |
+| registration? | object | `mode` and `admin_registration_urls_enabled` |
+| app_public?2 | object | `branding`, `setup`, `legal`, and `registration` sub-objects, each merged field by field |
+| integrations?3 | object | `gif`, `youtube`, `captcha`, `email`, and `bluesky` sub-objects, the last of which also has the `keys` array |
+| media? | object | `attachment_decay` overrides, each nullable to restore the deployment default |
+| policy? | [instance policy update](#instance-policy-update-structure) object | Community, direct message, premium, and gating policy |
+
+1 The supplied fields are merged over the stored configuration and the result is validated as a whole. Every supplied endpoint URL uses `https`, has no credentials and no fragment, and resolves to a publicly routable address, otherwise the request returns 400 `INVALID_FORM_BODY` with `INVALID_URL_FORMAT` or `URL_NOT_PUBLICLY_ROUTABLE`
+
+2 `branding.product_name` is 1 to 80 characters, every branding and legal URL is at most 2048 characters and nullable, `branding.theme_color` is at most 64 characters and nullable, and `setup.configured` and `registration.collect_date_of_birth` are booleans. Every string is trimmed before it is stored
+
+3 A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
+
+:::note[Single sign-on URL validation is conditional]
+Fluxer skips URL validation while the merged configuration leaves single sign-on disabled. A configuration that is both enabled and enforced fails validation against `sso` with `SSO_MISCONFIGURED` unless it resolves an authorisation endpoint, a token endpoint, a client identifier, and a claims source. `issuer` stands in for the two endpoints and the claims source.
+:::
+
+#### Instance policy update structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| single_community_enabled?1 | boolean | Whether the deployment presents one community |
+| single_community_name?1 | string | Name used only when the community has to be created (1-100 characters) |
+| direct_messages_disabled?2 | boolean | Whether direct messages are disabled |
+| direct_messages_locked?2 | boolean | Whether the direct message setting stays locked |
+| premium_mode? | string | [Premium mode](#premium-modes) |
+| services? | object | Nullable `gif_enabled`, `youtube_enabled`, and `bluesky_enabled` overrides |
+| deferred_phone_gate?3 | object | `enabled`, `window_hours`, and `member_threshold` |
+
+1 Setting `single_community_enabled` to true adopts the already designated guild when one still exists, and otherwise provisions a community using `single_community_name` or the configured product name. On a deployment whose setup is already complete, enabling it while no guild is designated fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`, as does enabling it when the acting Admin account cannot be resolved. Setting it to false only clears the flag and leaves the guild in place
+
+2 The setting can be changed only while `direct_messages_locked` is false, and a change attempted after the lock is set fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` unless the same request sets `direct_messages_locked` to false. Re-enabling direct messages sets the lock again
+
+3 `window_hours` is a positive number up to 8760 and `member_threshold` is a positive integer up to 1000000. Each key is applied on its own
+
+`direct_messages_locked` accepts only false, and a body that sets it to true fails with 400 `INVALID_FORM_BODY`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [instance configuration](#instance-configuration-object) object | The patch was applied |
+| 400 | [error response](/admin-api/#error-response) | A policy transition is refused, returned as `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` |
+
+:::caution[Sections are applied one after another]
+The order is `gateway_rollout`, `sso`, `registration`, `app_public` branding, legal, and registration fields, `integrations`, `media`, `policy`, and finally `app_public.setup`. A failure part way through leaves the earlier sections written.
+:::
+
+### Side effects
+
+Fluxer validates a Gateway rollout change against the complete stored configuration and then publishes it to the Gateway cluster. A premium mode change reloads the resolved limit configuration on every node. Enabling single community mode creates the community when none is designated, and the acting Admin becomes its owner.
+
+Initial setup completes on the first update that sets `app_public.setup.configured` to true from a session credential whose account holds neither `admin:authenticate` nor the wildcard. That update grants the account the wildcard Admin ACL and marks the deployment as bootstrapped.
+
+The operation records no Admin audit entry of its own.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Create branding asset
+
+
+
+Uploads an image, stores its [Media Proxy](/media-proxy/overview/) URL in the selected branding slot, and returns the resulting [instance configuration](#instance-configuration-object) object. Requires `instance:config:update`, or a session credential until setup is marked complete.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| kind | string | [Branding asset kind](#branding-asset-kinds) naming the slot to write |
+| image?12 | ?string | Base64 image or data URI (max 16000000 characters) |
+
+1 Omitting the key and sending null both clear the slot
+
+2 The image decodes to no more than the resolved `avatar_max_size` limit, which defaults to 10485760 bytes, and is a PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, or SVG. An animated AVIF is refused
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [instance configuration](#instance-configuration-object) object | The slot was written or cleared |
+| 4001 | [error response](/admin-api/#error-response) | The image cannot be decoded, exceeds the size ceiling, or is not an accepted format |
+
+1 Returned as 400 `INVALID_FORM_BODY` with `IMAGE_SIZE_EXCEEDS_LIMIT`, `INVALID_IMAGE_FORMAT`, or `FAILED_TO_UPLOAD_IMAGE` against `image`
+
+### Side effects
+
+The stored image is served through the Media Proxy. Non-JPEG images are re-encoded to strip metadata and JPEG images are re-encoded at quality 100. The previously referenced image is not deleted.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Create SMTP test
+
+
+
+Opens a connection to the supplied SMTP server, authenticates against it, and reports the outcome as an [SMTP test result](#smtp-test-result-object) object without storing anything. Requires `instance:config:update`, or a session credential until setup is marked complete.
+
+:::note[A rejected credential is still a successful test]
+A test that runs to completion answers 200 whether or not the server accepted the connection. The `ok` field is the verdict and `error` is the failure text.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| host | string | SMTP host to connect to (1-255 characters after trimming) |
+| port | integer | SMTP port to connect to (1-65535) |
+| username | string | SMTP username to authenticate with (1-320 characters after trimming) |
+| password | string | SMTP password to authenticate with (1-4096 characters after trimming) |
+| secure? | boolean | Whether to connect with implicit TLS (default true) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [SMTP test result](#smtp-test-result-object) object | The test ran to completion, including a rejected credential |
+
+### Side effects
+
+No configuration is written and the supplied credentials are discarded when the request completes. The connection, greeting, and socket each have a ten-second deadline.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Create registration URL
+
+
+
+Issues a registration URL an Admin can hand out while public registration is closed or gated, and returns a [registration URL creation](#registration-url-creation-object) object. Requires `instance:config:update`.
+
+:::caution[The code is the identifier]
+The returned `code` is the same value as `registration_url.id`, and every [Get instance configuration](#get-instance-configuration) response has that identifier. Treat `instance:config:view` as a registration capability on a gated deployment.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| label?1 | ?string | Admin note to attach to the URL (1-120 characters), or null for none |
+| expires_at? | ?ISO8601 timestamp | Time the URL stops working, or null for a URL that never expires |
+| max_uses? | ?integer | Maximum permitted uses (1-1000000), or null for unlimited use |
+| approval_required? | boolean | Whether an account created through the URL still needs an Admin decision (default false) |
+
+1 The label is trimmed before it is length checked, so a whitespace-only label fails body validation. Omit the key or send null for a URL with no label
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [registration URL creation](#registration-url-creation-object) object | The URL was issued |
+
+### Side effects
+
+A newly issued URL has a use count of zero, no revocation time, and the acting Admin as its issuer. It is the first entry of `registration.urls` on the next configuration read. The operation records no Admin audit entry and changes no account.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Revoke registration URL
+
+
+
+Revokes one registration URL so it can no longer be redeemed and returns the resulting [instance configuration](#instance-configuration-object) object. Requires `instance:config:update`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| registration_url_id | string | Identifier of the registration URL to revoke (1-128 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | [instance configuration](#instance-configuration-object) object | The revocation was applied |
+
+1 An identifier naming no registration URL answers 200 with the configuration unchanged. Revoking an already revoked URL keeps the original revocation time
+
+:::caution[Revocation stops only later registrations]
+An account that registered through the URL keeps its access, membership, and decision. A revocation cannot be reversed.
+:::
+
+### Side effects
+
+The URL records its revocation time and stays in the configuration response afterwards. The operation records no Admin audit entry and changes no account.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Update pending registration
+
+
+
+Approves or rejects one pending registration and returns the resulting [instance configuration](#instance-configuration-object) object. Requires `instance:config:update`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | Account awaiting a decision |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | Either `approved` or `rejected` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | [instance configuration](#instance-configuration-object) object | The decision was applied |
+
+1 An account that no longer exists still answers 200. The pending registration is removed, no trait is written, and no audit entry is recorded
+
+:::caution[A decision is final]
+Both decisions remove the pending registration, so the same account cannot be decided again here. Rejecting an account writes the `registration_rejected` trait. Reversing the decision means clearing that trait through [Set user traits](/admin-api/users/#set-user-traits).
+:::
+
+### Side effects
+
+Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait, and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
+
+One Admin audit entry with the action `approve_registration` or `reject_registration` targets the account and records the audit reason. It has no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Get limit configuration
+
+
+
+Returns the [limit configuration response](#limit-configuration-response-object) object. Requires `instance:limit_config:view`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [limit configuration response](#limit-configuration-response-object) object | The configuration was returned |
+
+### Side effects
+
+The response reflects the configuration in force on the node that serves the request, so two nodes can report different values while a change propagates.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Replace limit configuration
+
+
+
+Replaces the stored limit configuration with the supplied document and returns the resulting [limit configuration response](#limit-configuration-response-object) object. Requires `instance:limit_config:update`.
+
+:::caution[An absent custom rule is removed]
+A rule whose identifier matches a deployment default rule survives. Fluxer re-merges the write with the current defaults immediately.
+:::
+
+:::note[The stored document can differ from the body]
+The re-merge restores every default rule and back-fills every default limit key the submitted rule did not override. Read the response to see what was stored.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit_config1 | [limit configuration](#limit-configuration-object) object | Complete replacement document |
+
+1 `traitDefinitions` can be omitted and is then stored as an empty array. Every key inside a rule's `limits` map is a known [limit key](/http-api/instance/#limit-keys) and every value is a non-negative safe integer, otherwise the body fails validation. `modifiedFields` is not accepted on input and is recomputed
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [limit configuration response](#limit-configuration-response-object) object | The configuration was replaced |
+| 400 | [error response](/admin-api/#error-response) | Body validation fails, including an unknown limit key, returned as `INVALID_FORM_BODY` |
+
+### Side effects
+
+On a self-hosted deployment whose premium mode is `everyone`, Fluxer drops the `premium` trait definition and every rule filtered on the `premium` trait before storing the document.
+
+Each rule's `modifiedFields` is then recomputed and the merged document replaces the stored one. Every API node adopts the new configuration. Clients observe it through the published [limit configuration](/http-api/instance/#limit-configuration-object).
+
+The operation records no Admin audit entry.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Create heap snapshot
+
+
+
+Writes a V8 heap snapshot of the API process that serves the request and returns the file. Requires `system:heap_snapshot`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | heap snapshot file | The snapshot was written |
+
+1 The body is the raw `.heapsnapshot` file streamed as an attachment. The filename is `heap-` followed by the Unix time in milliseconds at which the snapshot was taken
+
+The 200 has `Content-Type: application/octet-stream`, `Content-Disposition: attachment`, and `Content-Length`.
+
+A client MUST NOT decode the body as JSON. The published OpenAPI document declares a JSON object of `success`, `filename`, and `size_bytes` here, so a generated client has to be overridden.
+
+:::danger[A snapshot stalls the process and exposes memory]
+Writing a snapshot blocks the process that serves the request for as long as the dump takes. The file contains whatever that process held in memory, including message content, tokens, and personal data.
+:::
+
+:::caution[Only the serving node is captured]
+A deployment behind a load balancer needs several attempts to reach a particular node, and the file grows with that process heap.
+:::
+
+### Side effects
+
+The snapshot is written to a temporary file on the serving node, streamed to the caller, and deleted once the stream closes. No configuration is changed and no Admin audit entry is recorded.
+
+### Rate limit
+
+2 requests per five minutes for each authenticated user, on the `admin:system:heap_snapshot` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/jobs.mdx b/fluxer_docs/src/content/docs/admin-api/jobs.mdx
new file mode 100644
index 000000000..48e3505e4
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/jobs.mdx
@@ -0,0 +1,303 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Jobs
+description: Background job ledger entries, statuses, lanes, task types, and cancellation.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The job ledger is Fluxer's record of the background work an instance runs. An entry reports lifecycle state, progress, attempt accounting, payload, and terminal failure text. Nothing here creates a job. The operation that needs the work queues it, such as [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job), [Create system DM broadcast](/admin-api/system-dms/#create-system-dm-broadcast), [Refresh search index](/admin-api/search-indexes/#refresh-search-index), or an [archive](/admin-api/archives/) request.
+
+Reads require `jobs:view` and cancellation requires `jobs:cancel`. Every operation on this page shares the `admin:jobs:view` bucket.
+
+:::caution[Not every task gets a ledger row]
+A large share of Fluxer's background work never gets a ledger row, and that work cannot be listed, read by ID, or cancelled here.
+:::
+
+:::note[Fan-out, embeds, and most sweeps have no ledger row]
+Mention fan-out (`handleMentions` and `handleMentionChunk`), embed extraction (`extractEmbeds`), and every periodic sweep other than the three blocklist feed syncs run this way.
+:::
+
+## Admin job object
+
+One object is one row of the ledger. No operation on this page changes any field except `cancel_requested`. A row has a 90 day time to live, after which the job is unknown to every operation here.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id | snowflake | The ID of the job |
+| task_type | string | The [background job task type](#background-job-task-types) the job runs |
+| status | string | The [job status](#job-statuses) the row currently holds |
+| progress_current1 | ?integer | The units of work completed, or null when the task has reported none |
+| progress_total1 | ?integer | The units of work expected, or null when the task has reported none or reported no total |
+| progress_message1 | ?string | The step the task last reported, or null when it has reported none |
+| error_message2 | ?string | The failure text recorded when the job was dead-lettered, or null otherwise |
+| created_at | ISO8601 timestamp | The time the job was written to the ledger |
+| started_at3 | ?ISO8601 timestamp | The time the most recent execution began, or null before the first attempt |
+| completed_at | ?ISO8601 timestamp | The time the job reached a terminal state, or null while it is not terminal |
+| requested_by_user_id4 | ?snowflake | The ID of the account recorded as the requester, or null when the queueing operation recorded none |
+| audit_log_reason5 | ?string | The reason recorded when the job was queued, or null when none was supplied |
+| jet_stream_lane6 | ?string | The [processing lane](#processing-lanes) the job runs on, or null when no lane claims the task type |
+| jet_stream_seq | ?string | The stream sequence assigned when the job was published, as a decimal string |
+| attempts7 | integer | The number of failed executions recorded so far |
+| max_attempts8 | integer | The attempt budget captured when the job was queued |
+| run_at | ?ISO8601 timestamp | The earliest permitted run time, or null for an immediate job |
+| cancel_requested9 | boolean | Whether cooperative cancellation has been requested |
+| context_link10 | ?string | The Admin console path for the entities the job acts on, or null when the task set none |
+| payload11 | ?string | The JSON rendering of the job input |
+| result | ?string | Always null |
+
+1 The task reports progress at its own cadence, and a task that never reports leaves all three null for its whole run. A bulk task reports once before it starts, again after every 25 entities, and once when it finishes, except for `schedule_user_deletion`, which reports after every 10 accounts, and the file SHA bulk ban, which reports after every 50 hashes
+
+2 Written only when the job is dead-lettered. A single failed delivery that is redelivered leaves it null
+
+3 Rewritten on every delivery, so a redelivered job reports the start of its latest attempt
+
+4 Only [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job) and the file SHA bulk ban record one, so a system DM broadcast, an archive, and a search index rebuild all report null
+
+5 The reason the queueing operation resolved from `X-Audit-Log-Reason`. A blank or over-length header resolves to null there, and the queueing operation still succeeds
+
+6 Present as soon as the job is queued
+
+7 Incremented only when an attempt fails without exhausting the lane's redelivery ceiling. A job that succeeds on its first delivery reports 0, and a dead-lettered job never counts its final failure
+
+8 Defaults to 5 and is recorded for reporting only. The [processing lane](#processing-lanes) bounds redelivery
+
+9 Set by [Cancel job](#cancel-job) while the job is queued or running, and kept on the terminal row as the record of the request. `status` reports whether the task honoured it
+
+10 An Admin console path such as `/guilds/{guild_id}`, not a Fluxer API route. A bulk task acting on many entities links only the first 50 identifiers
+
+11 Stored verbatim as JSON, so it can contain identifiers and message content. It is returned by every operation here, including the listings
+
+### Example
+
+```json
+{
+ "job_id": "1501314428688998182",
+ "task_type": "bulkUpdateUserFlags",
+ "status": "running",
+ "progress_current": 25,
+ "progress_total": 400,
+ "progress_message": "Updating flags",
+ "created_at": "2026-08-31T09:00:00.000Z",
+ "started_at": "2026-08-31T09:00:01.000Z",
+ "requested_by_user_id": "1478812292088791040",
+ "jet_stream_lane": "lifecycle",
+ "attempts": 0,
+ "max_attempts": 5,
+ "cancel_requested": false,
+ "payload": "{\"user_ids\":[\"1478812292088791040\"]}",
+ "result": null
+}
+```
+
+## Job cursor object
+
+The resume point [List jobs](#list-jobs) returns, split into the three cursor query parameters a caller sends back.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| bucket_day | string | The UTC day bucket the next page resumes in, as `YYYY-MM-DD` |
+| created_at | ISO8601 timestamp | The creation time the next page resumes before |
+| job_id | snowflake | The job the next page resumes from |
+
+### Example
+
+```json
+{
+ "bucket_day": "2026-08-30",
+ "created_at": "2026-08-30T22:41:07.512Z",
+ "job_id": "1500901337221828608"
+}
+```
+
+## Job statuses
+
+| Value | Description |
+| --- | --- |
+| queued | The job is written and available for processing |
+| running | The job is being processed |
+| succeeded | The task returned without throwing |
+| cancelled1 | The task reached a cancellation checkpoint and aborted |
+| deadletter | The job exhausted its lane's redelivery ceiling without succeeding |
+
+1 Only a task that aborts at the checkpoint settles here, as [Cancel job](#cancel-job) describes
+
+`queued` and `running` are the active statuses and the other three are terminal. [List active jobs](#list-active-jobs) returns exactly the two active statuses. Cancellation is recorded only while a job holds one of them.
+
+A job whose queue publish fails after its ledger row is written also settles as `deadletter`. That row reports `attempts` 0, `started_at` null, and `error_message` from the publish failure.
+
+## Processing lanes
+
+A lane is a consumer group with its own concurrency, acknowledgement deadline, and redelivery ceiling. Each task type belongs to exactly one lane, recorded on the job when it is queued.
+
+| Value | Description |
+| --- | --- |
+| realtime1 | Mention fan-out. Concurrency 10, 15 second deadline, 3 deliveries |
+| unfurl1 | Link preview extraction. Concurrency 20, 30 second deadline, 3 deliveries |
+| lifecycle1 | Account, guild, billing, moderation, archive, and bulk work. Concurrency 8, 60 second deadline, 25 deliveries |
+| batch1 | Periodic sweeps, index rebuilds, and queue drains. Concurrency 12, 120 second deadline, 25 deliveries |
+
+1 Concurrency is per worker process and an operator can override it per lane. The delivery count bounds retries and is the ceiling after which a failing job is dead-lettered
+
+## Background job task types
+
+`task_type` is the registered worker task name. Every registered task is listed here with its lane, and a task that is not listed is not registered.
+
+- `realtime` runs `handleMentions` and `handleMentionChunk`.
+- `unfurl` runs `extractEmbeds`.
+- `lifecycle` runs `applicationProcessDeletion`, `batchGuildAuditLogMessageDeletes`, `bulkAddGuildMembers`, `bulkBanFileShas`, `bulkDeleteSelfMessagesImmediate`, `bulkDeleteUserMessages`, `bulkDeleteUserMessagesScoped`, `bulkScheduleUserDeletion`, `bulkUpdateGuildFeatures`, `bulkUpdateSuspiciousActivityFlags`, `bulkUpdateUserFlags`, `deleteUserMessagesInGuildByTime`, `finalizeNcmecAttachmentReport`, `harvestGuildData`, `harvestUserData`, `messageShred`, `processStripeWebhook`, `reconcileUserPayments`, `revalidateUserConnections`, `sendSystemDm`, `userProcessPendingDeletion`, and `userProcessPendingDeletions`.
+- `batch` runs `expireAttachments`, `flushUserActivityBuffer`, `indexChannelMessages`, `indexGuildMembers`, `processAssetDeletionQueue`, `processBunnyPurgeQueue`, `processExpiredPremiumSweep`, `processInactivityDeletions`, `processPendingBulkMessageDeletions`, `processPremiumStateReconciliationQueue`, `prunePostgresKvTtl`, `refreshSearchIndex`, `syncDiscoveryIndex`, `syncDisposableEmailDomains`, `syncFileShaBlocklists`, and `syncUrlBlocklists`.
+
+The five tasks queued by [Bulk jobs](/admin-api/bulk-jobs/) are `bulkUpdateUserFlags`, `bulkUpdateSuspiciousActivityFlags`, `bulkUpdateGuildFeatures`, `bulkAddGuildMembers`, and `bulkScheduleUserDeletion`. `harvestUserData` and `harvestGuildData` build [archives](/admin-api/archives/), `sendSystemDm` delivers a [system DM broadcast](/admin-api/system-dms/), and `refreshSearchIndex` rebuilds a [search index](/admin-api/search-indexes/).
+
+## List jobs
+
+
+
+Returns a page of [Admin job](#admin-job-object) objects from the ledger's day buckets, newest first. Requires `jobs:view`.
+
+The ledger is bucketed by UTC day and each bucket is ordered by creation time descending. A request reads the current bucket, then each earlier bucket, until the page is full or the lookback window is exhausted.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | The maximum number of jobs to return (1-200, default 50) |
+| cursor_bucket_day?1 | string | The UTC day bucket to resume from as a `YYYY-MM-DD` date, taken from `next_cursor.bucket_day` |
+| cursor_created_at?1 | string | The creation time to resume before as an ISO 8601 timestamp (1-64 characters), taken from `next_cursor.created_at` |
+| cursor_job_id?1 | snowflake | The job to resume from, taken from `next_cursor.job_id` |
+| max_lookback_days?2 | integer | The number of earlier day buckets to scan (1-60, default 14) |
+| status? | string | The [job status](#job-statuses) a returned job must currently hold |
+| task_type?3 | string | The [background job task type](#background-job-task-types) a returned job must run (1-128 characters) |
+| requested_by_user_id? | snowflake | The ID of the account a returned job must record as its requester |
+
+1 The three cursor parameters are one value split into parts and are supplied together. A strict subset, a `cursor_bucket_day` that is not a calendar date, or a `cursor_created_at` that is not an ISO 8601 timestamp returns 400 `INVALID_FORM_BODY`, and each rejected part is named by the `path` of an element in `errors`
+
+2 The scan covers the starting bucket plus this many earlier buckets, so the default reads 15. The starting bucket is the current UTC day, or the cursor's bucket when one is supplied
+
+3 A value naming no registered task returns an empty page with 200
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| jobs | array[[Admin job](#admin-job-object) object] | The jobs in this page |
+| next_cursor | ?[job cursor](#job-cursor-object) object | The cursor for the next page, or null when the page did not fill |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The job page was returned |
+| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` because the cursor is a strict subset of its three parts or one part is malformed |
+
+### Rate limit
+
+600 requests per minute for each authenticated user, on the `admin:jobs:view` bucket.
+
+## List active jobs
+
+
+
+Returns every queued or running job as [Admin job](#admin-job-object) objects. Requires `jobs:view`.
+
+:::note[Poll this operation for active work]
+This listing takes no filters, takes no cursor, and is not paginated. A job leaves it when it reaches a terminal state.
+:::
+
+A job still active after its ledger row has passed the 90 day time to live is omitted from the response.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| jobs | array[[Admin job](#admin-job-object) object] | Every queued or running job the index holds |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The active jobs were returned |
+
+### Rate limit
+
+600 requests per minute for each authenticated user, on the `admin:jobs:view` bucket.
+
+## Get job
+
+
+
+Returns one [Admin job](#admin-job-object) object. Requires `jobs:view`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id | snowflake | The ID of the job |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job | [Admin job](#admin-job-object) object | The job |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The job was returned |
+| 40412 | `{"error": "job_not_found"}` | No ledger entry has this identifier |
+
+1 This operation answers a missing job with its own single-field body, so it has no `code` and no `errors`
+
+2 A job queued with the ledger suppressed and a job whose row has passed its 90 day time to live are both reported this way, so 404 does not mean the work never ran
+
+### Rate limit
+
+600 requests per minute for each authenticated user, on the `admin:jobs:view` bucket.
+
+## Cancel job
+
+
+
+Requests cooperative cancellation of a job and reports whether the request was recorded. Requires `jobs:cancel`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id | snowflake | The ID of the job |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| cancelled1 | boolean | Whether a cancellation request was recorded |
+
+1 False when the job is already terminal and also when no ledger entry has the identifier, so a false value does not distinguish the two. Cancelling a job that is already cancel-requested returns true again
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The request was evaluated, whether or not the flag changed |
+
+:::caution[Cancellation is cooperative and never retroactive]
+The job stops only at its next checkpoint, which for a bulk task is between two entities. Every effect already applied stays in place. Most registered tasks never check for cancellation and run to completion with the flag set.
+:::
+
+:::note[`status` reports whether the task honoured the request]
+A [bulk job](/admin-api/bulk-jobs/) task and a [system DM broadcast](/admin-api/system-dms/) both abort at the checkpoint and settle as `cancelled`. A task that never checks the flag settles as it otherwise would.
+:::
+
+### Side effects
+
+An active job records `cancel_requested` as true and stops at its next checkpoint. Fluxer leaves a terminal job unchanged. Nothing reverses the Gateway Dispatches the job already produced.
+
+### Rate limit
+
+600 requests per minute for each authenticated user, on the `admin:jobs:view` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/messages.mdx b/fluxer_docs/src/content/docs/admin-api/messages.mdx
new file mode 100644
index 000000000..370df1692
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/messages.mdx
@@ -0,0 +1,446 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin messages
+description: Message browsing and search, permanent deletion, shred jobs, and NCMEC submission.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Admin message routes read, search, and permanently delete the messages of one channel. Others report the progress of a shred job and submit one attachment to NCMEC, the National Center for Missing and Exploited Children.
+
+Shredding and bulk deletion for a whole account live under [Admin users](/admin-api/users/).
+
+## Admin message object
+
+A flattened moderation view of one message. It has the author identity, the content and timestamp, the attachments with their NCMEC submission state, and the NSFW context of the channel and guild.
+
+Every operation that returns Admin message objects also returns the matching public message objects in a parallel `message_responses` array, so a client that needs the full wire shape joins on `id`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the message |
+| channel_id | snowflake | The channel the message was sent in |
+| channel_name1 | ?string | The channel name, or null for a channel that has no name |
+| channel_nsfw1 | ?boolean | Whether the channel is marked NSFW |
+| channel_content_warning_level?2 | ?integer | The channel [content warning level](/http-api/channels/#content-warning-levels) |
+| channel_content_warning_text?2 | ?string | The channel content warning text (0-200 characters) |
+| guild_id1 | ?snowflake | The guild containing the channel, or null for a private channel |
+| guild_name1 | ?string | The guild name, or null for a private channel |
+| guild_nsfw_level1 | ?integer | The guild [NSFW level](/http-api/guilds/#nsfw-levels), or null for a private channel |
+| guild_nsfw?2 | ?boolean | Whether the guild is marked NSFW |
+| guild_content_warning_level?2 | ?integer | The guild [content warning level](/http-api/guilds/#guild-content-warning-levels) |
+| guild_content_warning_text?2 | ?string | The guild content warning text (0-200 characters) |
+| author_id | snowflake | The ID of the author |
+| author_username | string | The author username (1-100 characters) |
+| author_global_name | ?string | The author display name, or null when unset |
+| author_discriminator | string | The author discriminator tag (1-10 characters) |
+| author_avatar | ?string | The author avatar hash, or null when unset |
+| content | string | The message content (0-4,000 characters) |
+| timestamp | ISO8601 timestamp | When the message was sent |
+| attachments | array[[Admin message attachment](#admin-message-attachment-object) object] | The attachments of the message (at most 10) |
+| user_prior_ncmec_report_ids?3 | array[string] | The NCMEC report IDs previously filed against the author, ascending (at most 100) |
+
+1 Every object in one response has the same values, taken from the channel named in the request path or query. All five are null when the channel no longer exists
+
+2 Always absent from the objects these operations return. The same member is populated on the [report message context object](/admin-api/reports/#report-message-context-object)
+
+3 Written on every object these operations return, and empty when no NCMEC report has been filed against the author
+
+### Example
+
+```json
+{
+ "id": "1502771119476146176",
+ "channel_id": "1471002884199612420",
+ "channel_name": "general",
+ "channel_nsfw": false,
+ "guild_id": "1471002884199612416",
+ "guild_name": "Tidepool",
+ "guild_nsfw_level": 0,
+ "author_id": "1490118377238528000",
+ "author_username": "kestrel",
+ "author_global_name": null,
+ "author_discriminator": "0117",
+ "author_avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
+ "content": "read the pinned message",
+ "timestamp": "2026-03-04T18:19:07.220Z",
+ "attachments": [],
+ "user_prior_ncmec_report_ids": []
+}
+```
+
+### Admin message attachment object
+
+An attachment entry has the ordinary attachment metadata together with the NCMEC submission state Fluxer holds for it.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the attachment |
+| filename | string | The attachment filename (1-256 characters) |
+| url | string | The CDN URL of the attachment (1-2,048 characters) |
+| nsfw | ?boolean | Whether the attachment is marked NSFW, or null when it has no marking |
+| content_type | ?string | The recorded MIME type, or null when none was recorded |
+| width | ?integer | The pixel width, or null when no dimensions were recorded |
+| height | ?integer | The pixel height, or null when no dimensions were recorded |
+| size?4 | ?integer | The attachment size in bytes, or null when no size was recorded |
+| ncmec_status | string | The [NCMEC submission status](#ncmec-submission-status) of this attachment |
+| ncmec_report_id5 | ?string | The report ID NCMEC assigned (1-256 characters), or null when none has been assigned |
+| ncmec_failure_reason6 | ?string | The recorded reason the last submission failed (1-4,000 characters), or null |
+
+4 Always emitted, so it is null and never absent when no size was recorded
+
+5 Assigned only once NCMEC accepts the report, so it is null while `ncmec_status` is `not_submitted` and after a failed submission
+
+6 Populated only when `ncmec_status` is `failed`, and it stores the raw upstream error. The sanitised wording appears in the failing response body instead
+
+#### NCMEC submission status
+
+| Value | Description |
+| --- | --- |
+| not_submitted | The attachment has never been submitted to NCMEC |
+| submitted | The attachment was submitted to NCMEC and NCMEC accepted the report |
+| failed | The submission failed, the opened report was retracted, and the attachment can be submitted again |
+
+A submission that is still running is stored as `submitting` and returned unchanged, so tolerate a status outside this table. An attachment in that state is refused a second submission by [Create NCMEC report](#create-ncmec-report).
+
+## Message shred status object
+
+The state of one message shred job. The state expires one hour after it was last written, and an expired job is reported by `status` alone.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | not_found \| in_progress \| completed \| failed | The job state |
+| requested1 | integer | The number of message references the job was queued with |
+| total1 | integer | The number of those references the job resolved as shreddable |
+| processed1 | integer | The number of messages the job has shredded so far |
+| skipped12 | integer | The number of references the job did not shred |
+| started_at?13 | ISO8601 timestamp | When the job started |
+| completed_at?1 | ISO8601 timestamp | When the job completed |
+| failed_at?1 | ISO8601 timestamp | When the job failed |
+| error?14 | string | Why the job failed (1-4,000 characters) |
+
+1 Absent when `status` is `not_found`, a shape that has `status` alone
+
+2 Counts a reference that repeats an earlier entry, one whose IDs are not valid snowflakes, one whose message no longer exists, and one no longer authored by the target user
+
+3 Present whenever the counters are
+
+4 Present only when `status` is `failed`
+
+:::note[An expired job reports `not_found`]
+A job identifier that never existed reports `not_found` too, so the two are indistinguishable.
+:::
+
+## List channel messages
+
+
+
+Returns one page of [Admin message](#admin-message-object) objects for one channel. Requires `message:lookup`.
+
+The read ignores message history permissions, channel membership, and the age cutoff applied to ordinary members, so it returns every message the channel still holds.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Maximum messages to return (1-100, default 50) |
+| before?1 | snowflake | Return messages with a lower ID than this cursor |
+| after?12 | snowflake | Return messages with a higher ID than this cursor |
+
+1 Supplying neither selects the newest messages in the channel. Supplying both selects the newest messages below `before` and discards any at or below `after`
+
+2 Supplying `after` also flips the order of the returned array
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| messages1 | array[[Admin message](#admin-message-object) object] | Messages in this page (at most 100) |
+| message_responses?2 | array[[message](/http-api/messages/#message-object) object] | The same messages in the public wire shape, same order (at most 100) |
+| has_more3 | boolean | Whether the page was filled to `limit` |
+
+1 Ordered oldest first, except when `after` was supplied, in which case it is ordered newest first
+
+2 Written by every response this operation produces
+
+3 True when the page holds exactly `limit` entries, so a page that ends on the last message reports true and the next request returns an empty page
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Message page was returned |
+
+The response has no cursor member. Page further by sending the ID of the oldest returned message as `before`, or the ID of the newest returned message as `after`.
+
+A channel that does not exist returns an empty `messages` array with `has_more` false, so this operation never answers 404.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
+
+## Get message
+
+
+
+Returns the requested message together with the messages surrounding it, as one array of [Admin message](#admin-message-object) objects, oldest first. Requires `message:lookup`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| context_limit?1 | integer | Size of the whole returned window, counting the requested message (1-100, default 50) |
+
+1 At most `context_limit` divided by two, rounded down, are newer than the requested message and the rest are older, so the default returns at most 25 newer and 24 older
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| messages1 | array[[Admin message](#admin-message-object) object] | The requested message and its surrounding window (at most 100) |
+| message_responses? | array[[message](/http-api/messages/#message-object) object] | The same messages in the public wire shape, same order (at most 100) |
+| message_id2 | ?snowflake | The requested message ID |
+
+1 The requested message is one entry of this array, and it is absent when it no longer exists
+
+2 Echoes the path parameter and is never null here
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Window was returned |
+
+An unknown message returns 200 with the window surrounding the position the missing ID would have occupied, so this operation never returns `UNKNOWN_MESSAGE`. An unknown channel returns 200 with an empty `messages` array.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
+
+## Delete message
+
+
+
+Permanently deletes one message and purges the attachments it owned. Requires `message:delete`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Request completed and the audit entry was written |
+
+An unknown channel or message returns 200 with `success` true and still records an audit entry.
+
+:::danger[Deletion purges the message and its attachments]
+There is no grace period and no restore operation.
+:::
+
+### Side effects
+
+The attachments the message owned are purged from storage and from the CDN, the message row is deleted, and its search index document is removed.
+
+[Message Delete](/gateway/events/#message-delete) is sent to the guild when the channel belongs to one, and to each recipient when the channel is private. No other Dispatch is emitted, and deleting a pinned message here emits no [Channel Pins Update](/gateway/events/#channel-pins-update).
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with the action `delete_message` and the target type `message`, targeting the message ID. Its metadata is `channel_id` and `message_id`. The entry is written whether or not a message was found.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
+
+## Search messages
+
+
+
+Searches the message index of one channel, or resolves one message in that channel by message ID or by attachment identity. Requires `message:lookup`.
+
+The three modes return two different response bodies, and the query decides which one arrives.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The channel every returned message was sent in |
+| q?1 | string | Free-text query matched against message content (1-200 characters) |
+| message_id?2 | snowflake | Resolve the message with this ID together with its surrounding window |
+| attachment_id?23 | snowflake | Resolve the message with this attachment together with its surrounding window |
+| filename?3 | string | The exact filename of the attachment named by `attachment_id` (1-255 characters) |
+| context_limit?2 | integer | Size of the window returned by a lookup, counting the resolved message (1-100, default 50) |
+| limit?1 | integer | Maximum messages to return when searching (1-100, default 25) |
+
+1 Applies to the search mode only. Omitting `q` searches for the empty string
+
+2 `message_id` takes precedence over every other member, and `attachment_id` over `q`. A lookup ignores `limit` and a search ignores `context_limit`
+
+3 Supply both together. Sending `attachment_id` without `filename` returns 400 `INVALID_FORM_BODY` against `filename`
+
+### Search response body
+
+Returned when neither `message_id` nor `attachment_id` was supplied.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| messages4 | array[[Admin message](#admin-message-object) object] | Matching messages (at most 100) |
+| message_responses? | array[[message](/http-api/messages/#message-object) object] | The same messages in the public wire shape, same order (at most 100) |
+| total5 | integer | The number of matches that still exist |
+
+4 Holds at most `limit` entries. There is no cursor and no offset member, so this operation returns the first page of matches only
+
+5 Counts every surviving match, so it can exceed the length of `messages`
+
+### Lookup response body
+
+Returned when `message_id` or `attachment_id` was supplied. It is the same body [Get message](#get-message) returns.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| messages | array[[Admin message](#admin-message-object) object] | The resolved message and its surrounding window, oldest first (at most 100) |
+| message_responses? | array[[message](/http-api/messages/#message-object) object] | The same messages in the public wire shape, same order (at most 100) |
+| message_id6 | ?snowflake | The ID of the resolved message |
+
+6 Null only for an attachment lookup that matched no attachment, in which case `messages` is empty too. A `message_id` lookup echoes the supplied ID even when no such message exists
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | search or lookup response body | Search or lookup completed |
+
+An unknown channel, a message that does not exist, and an attachment whose filename does not match all return an empty result. This operation never answers 404.
+
+With no message search service configured the search mode returns an empty `messages` array and `total` zero. The two lookup modes are unaffected.
+
+:::note[A deleted message never appears in a result]
+A hit whose message no longer exists is dropped from the page and excluded from `total`.
+:::
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
+
+## Get message shred job
+
+
+
+Returns one [message shred status](#message-shred-status-object) object. Requires `message:shred`.
+
+Shred jobs are created by [Shred user messages](/admin-api/users/#shred-user-messages) and by the non-dry-run form of [Delete all user messages](/admin-api/users/#delete-all-user-messages). Both return the identifier this operation reads.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id1 | snowflake | The message shred job ID, taken from the queueing response |
+
+1 A snowflake issued when the job was queued, so a value that is not a decimal integer fails path validation rather than reporting `not_found`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message shred status](#message-shred-status-object) object | Job state was returned, including `not_found` for an unknown or expired job |
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
+
+## Create NCMEC report
+
+
+
+Submits one image or video attachment to NCMEC and starts the account enforcement workflow for its author. Requires `csam:submit_ncmec`, `message:delete`, `user:delete`, and `archive:trigger:user`.
+
+A caller missing more than one of the four is refused with 403 `MISSING_ACL` naming the first it does not hold, in the order listed. The wildcard satisfies all four.
+
+This operation ignores `X-Audit-Log-Reason`. The reason on every audit entry the workflow produces is synthesised from the assigned NCMEC report ID and the channel and attachment IDs, and it is returned as `audit_log_reason`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The channel the attachment was sent in |
+| message_id | snowflake | The message the attachment belongs to |
+| attachment_id | snowflake | The attachment to submit |
+| filename | string | The exact filename of the attachment (1-255 characters) |
+| source_report_id?1 | snowflake | The report whose frozen evidence has the attachment |
+| reporter_full_name | string | The full legal name of the submitting Admin (1-200 characters) |
+| confirmed_viewed2 | boolean | Confirmation that the submitting Admin has viewed the attachment |
+
+1 Required when the live message no longer exists or has no author, in which case the attachment is read from that report's frozen evidence. Supplying it otherwise only records the report ID
+
+2 The literal value `true` is the only accepted value. Any other value fails body validation
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+| ncmec_report_id | string | The report ID NCMEC assigned to this submission (1-256 characters) |
+| audit_log_reason3 | string | The synthesised reason recorded on every audit entry the workflow produced (1-4,000 characters) |
+
+3 The NCMEC report ID followed by the channel ID and attachment ID, so one string identifies the submission in the audit log and at NCMEC
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Attachment was submitted and the workflow advanced |
+| 400 | [error response](/admin-api/#error-response) | The resolved attachment is neither an image nor a video and the request returns `INVALID_FORM_BODY` against `attachment_id` |
+| 4041 | [error response](/admin-api/#error-response) | `UNKNOWN_MESSAGE` when nothing resolves the attachment, or `UNKNOWN_REPORT` when `source_report_id` names no report |
+| 409 | [error response](/admin-api/#error-response) | `NCMEC_ALREADY_SUBMITTED` when a submission for the attachment is already running or has already succeeded |
+| 500 | [error response](/admin-api/#error-response) | Submission fails and the request returns `NCMEC_SUBMISSION_FAILED` |
+
+1 The attachment resolves from a live message with an author, or from frozen report evidence
+
+:::danger[A successful request files a real NCMEC report]
+The request disables the author's account and schedules that account for deletion. Nothing withdraws a completed submission. Fluxer retracts the report only when a step after it was opened fails.
+:::
+
+### Side effects
+
+Fluxer resolves the attachment from the live message when that message still exists and has an author, and from the frozen evidence named by `source_report_id` otherwise. Fluxer accepts the attachment only when its resolved content type begins with `image/` or `video/`.
+
+Fluxer marks the attachment `submitting`, opens an NCMEC report, uploads the attachment bytes, submits its file details with the viewed flag set, and finishes the report. On success the attachment is marked `submitted` with the assigned report ID. On failure the opened report is retracted, the attachment is marked `failed` with the raw reason, and the request returns `NCMEC_SUBMISSION_FAILED`. A retraction that itself fails is logged and does not change the recorded state.
+
+When the resolved attachment has an author who has not already been enforced against, Fluxer sets the account's deleted and disabled flags, clears any temporary ban, and records the deletion reason for child sexual content. It then schedules deletion 60 days ahead, deletes every authentication session, propagates the resulting user update, and triggers a user archive for that account.
+
+Message content is deleted only after that archive completes. The archive is re-checked every 15 seconds by default, for at most 240 attempts. Once the archive is complete the reported message is deleted, its attachments are purged, and [Message Delete](/gateway/events/#message-delete) is sent.
+
+A successful submission records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `NCMEC Report`. It targets the reported user, or targets the source report when the attachment resolved to no author. Triggering the archive records a second entry with the action `trigger_user_archive` targeting the same user, skipped when the account already has an archive taken after the enforcement. Both entries have the synthesised reason returned as `audit_log_reason`.
+
+An attachment that resolved to no author is submitted to NCMEC and audited. No account is disabled, no archive is triggered, and no message content is deleted.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/reports.mdx b/fluxer_docs/src/content/docs/admin-api/reports.mdx
new file mode 100644
index 000000000..5b1e9f62b
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/reports.mdx
@@ -0,0 +1,392 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin reports
+description: Report listing and search, captured message evidence, and report resolution.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A report is one complaint filed against a message, an account, or a guild, together with the evidence captured when it was filed. These routes list and search reports, return that evidence, and resolve a report with a comment shown to the reporter.
+
+Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the category registries `category` is drawn from.
+
+Reporter contact fields require `report:view:reporter_pii` on top of `report:view`. Without it `reporter_email`, `reporter_full_legal_name`, and `reporter_country_of_residence` are null on every report, so a redacted field looks like a field the reporter never supplied.
+
+:::note[A report stores the evidence as filed]
+The captured fields are the guild name, icon hash, invite code, channel name, safety levels, and message content. Later edits to any of them do not change the report.
+:::
+
+:::note[Reporter and reported identity are current values]
+Fluxer reads the tag, username, display name, and discriminator of both accounts when it produces the response. The author identity on each captured message stays as it was captured.
+:::
+
+## Admin report object
+
+A report is written once, when the reporter files it. [Update report](#update-report) moves it from PENDING to RESOLVED with an optional comment. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for any reason other than a user request also resolves every pending report against the deleted account, with no public comment and no notice to the reporter.
+
+[List reports](#list-reports) returns this object without message context. [Get report](#get-report) returns the [Admin report detail object](#admin-report-detail-object), which extends it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| report_id | snowflake | The ID of the report |
+| reporter_id1 | ?snowflake | The account that filed the report |
+| reporter_tag12 | ?string | The reporter username and four-digit discriminator joined by `#` |
+| reporter_username12 | ?string | The username of the reporter |
+| reporter_global_name12 | ?string | The display name of the reporter, or null when unset |
+| reporter_discriminator12 | ?string | The zero-padded four-digit reporter discriminator |
+| reporter_email3 | ?string | The email address of the reporter |
+| reporter_full_legal_name3 | ?string | The legal name supplied with a Digital Services Act notice |
+| reporter_country_of_residence3 | ?string | The country of residence supplied with a Digital Services Act notice |
+| reported_at | ISO8601 timestamp | When the report was submitted |
+| status | integer | The [report status](#report-status) |
+| report_type | integer | The [report type](#report-type) |
+| category | ?string | The category the reporter filed the report under |
+| additional_info | ?string | The free-text detail the reporter supplied |
+| reported_user_id | ?snowflake | The reported account, or null for a guild report |
+| reported_user_tag2 | ?string | The reported username and four-digit discriminator joined by `#` |
+| reported_user_username2 | ?string | The username of the reported account |
+| reported_user_global_name2 | ?string | The display name of the reported account, or null when unset |
+| reported_user_discriminator2 | ?string | The zero-padded four-digit reported account discriminator |
+| reported_user_avatar_hash4 | ?string | The avatar hash of the reported account, or null when unset |
+| reported_guild_id | ?snowflake | The reported guild, or null when the report names none |
+| reported_guild_name4 | ?string | The name of the reported guild |
+| reported_guild_icon_hash4 | ?string | The icon hash of the reported guild, or null when unset |
+| reported_message_id | ?snowflake | The reported message, or null for a user or guild report |
+| reported_channel_id | ?snowflake | The channel the reported content was sent in |
+| reported_channel_name4 | ?string | The channel name, or null for a direct message channel |
+| reported_channel_nsfw5 | ?boolean | The effective NSFW state of the reported channel |
+| reported_guild_invite_code4 | ?string | The invite code the reported guild was reached by |
+| reported_guild_nsfw_level6 | ?integer | The guild [NSFW level](/http-api/guilds/#nsfw-levels) |
+| reported_guild_nsfw?47 | ?boolean | Whether the reported guild was marked NSFW |
+| reported_guild_content_warning_level?47 | ?integer | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) |
+| reported_guild_content_warning_text?47 | ?string | The guild content warning text (at most 200 characters) |
+| reported_channel_nsfw_override?47 | ?boolean | The NSFW override set on the reported channel itself |
+| reported_channel_content_warning_level?47 | ?integer | The [content warning level](/http-api/channels/#content-warning-levels) set on the channel itself |
+| reported_channel_content_warning_text?47 | ?string | The content warning text set on the channel itself (at most 200 characters) |
+| reported_channel_effective_nsfw?78 | ?boolean | Whether the channel was NSFW once the guild setting was applied |
+| reported_channel_effective_content_warning_level?78 | ?integer | The channel content warning level once the guild setting was applied |
+| reported_channel_effective_content_warning_text?78 | ?string | The channel content warning text once the guild setting was applied |
+| resolved_at9 | ?ISO8601 timestamp | When the report was resolved |
+| resolved_by_admin_id9 | ?snowflake | The Admin account that resolved the report |
+| public_comment9 | ?string | The comment shown to the reporter (at most 512 characters) |
+
+1 Null for an anonymous Digital Services Act notice, which is filed without a Fluxer account, so every reporter identity field is null exactly when `reporter_id` is null
+
+2 Resolved from the account when the response is produced, and null when the account can no longer be read
+
+3 Null unless the caller holds `report:view:reporter_pii`, and null when the reporter supplied no value
+
+4 Captured when the report was filed and never updated afterwards
+
+5 The captured effective state when the report recorded one, otherwise the current NSFW state of the reported channel
+
+6 Derived from the captured guild NSFW flag, where true is level 3 and false is level 0. Otherwise it is read from the reported guild, the filing guild, or the channel's guild
+
+7 Always emitted, so an absent member and a null member mean the same thing
+
+8 Computed when the report was filed by applying the guild setting over the channel setting
+
+9 Null while the report is PENDING and set when it moves to RESOLVED
+
+### Example
+
+```json
+{
+ "report_id": "1502771904419102720",
+ "reporter_id": "1483920011884392448",
+ "reporter_tag": "wren#0042",
+ "reporter_email": null,
+ "reported_at": "2026-03-04T18:22:10.441Z",
+ "status": 0,
+ "report_type": 0,
+ "category": "harassment",
+ "additional_info": "Repeated after being asked to stop.",
+ "reported_user_id": "1490118377238528000",
+ "reported_guild_id": "1471002884199612416",
+ "reported_message_id": "1502771119476146176",
+ "reported_channel_id": "1471002884199612420",
+ "reported_channel_nsfw": false,
+ "reported_guild_nsfw_level": 0,
+ "resolved_at": null,
+ "resolved_by_admin_id": null,
+ "public_comment": null
+}
+```
+
+## Report status
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | PENDING | Report is waiting to be reviewed |
+| 1 | RESOLVED | Report has been reviewed and resolved |
+
+## Report type
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | MESSAGE | A message was reported |
+| 1 | USER | A user was reported |
+| 2 | GUILD | A guild was reported |
+
+## Admin report detail object
+
+The detail object extends the [Admin report object](#admin-report-object) with the evidence too large to return in a list page. [Get report](#get-report) emits these three members, and [List reports](#list-reports) omits them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mutual_dm_channel_id1 | ?snowflake | The direct message channel shared by the reporter and the reported account |
+| message_context2 | array[[report message context](#report-message-context-object) object] | The messages captured around the reported content |
+| message_responses3 | array[[message](/http-api/messages/#message-object) object] | The current state of the same messages, read live |
+
+1 Set only for a USER report that has both a reporter and a reported account, and null when the two share no direct message channel
+
+2 Empty for a report that captured no message context, which is every user report and every guild report
+
+3 Resolved when the response is produced, so an edited message differs from its captured entry
+
+:::note[The live array can be shorter than the capture]
+Fluxer skips an entry when neither the capture nor the report records its channel. It also omits a message that no longer exists, or whose channel can no longer be read.
+:::
+
+## Report message context object
+
+A context entry is one message copied into the report when the report was filed. A later edit or deletion of the message does not update it. Fluxer copies its attachments into a separate evidence bucket, so they survive deletion of the original.
+
+A message report captures the reported message together with at most 25 messages before it and at most 25 after it, for at most 51 entries. A message whose author account Fluxer could not read at capture time is absent from the report.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the message |
+| channel_id1 | string | The channel the captured messages were read from |
+| channel_nsfw2 | ?boolean | The current NSFW state of that channel |
+| channel_content_warning_level?34 | ?integer | The channel content warning level |
+| channel_content_warning_text?34 | ?string | The channel content warning text |
+| guild_id5 | ?snowflake | The guild the report is scoped to, or null when the report names none |
+| guild_nsfw_level2 | ?integer | The current guild [NSFW level](/http-api/guilds/#nsfw-levels) |
+| guild_nsfw?34 | ?boolean | Whether the guild is marked NSFW |
+| guild_content_warning_level?34 | ?integer | The guild content warning level |
+| guild_content_warning_text?34 | ?string | The guild content warning text |
+| content | string | The captured message content, empty when the message had none |
+| timestamp6 | ISO8601 timestamp | When the message was sent |
+| attachments7 | array[[report message attachment](#report-message-attachment-object) object] | The captured attachment copies |
+| author_id | snowflake | The author of the message |
+| author_username | string | The captured author username |
+| author_global_name4 | ?string | The display name of the author |
+| author_discriminator | string | The captured zero-padded four-digit author discriminator |
+| author_avatar | ?string | The captured author avatar hash, or null when unset |
+| user_prior_ncmec_report_ids?38 | array[string] | The NCMEC report IDs previously filed against the author |
+
+1 The channel of the captured entry, otherwise the reported channel. An empty string when neither records one, so it does not always parse as a snowflake
+
+2 Read from the current channel or guild, and null when the record can no longer be read
+
+3 Always emitted, so an absent member and a null member mean the same thing
+
+4 Always null, because the capture stores no value for it
+
+5 The reported guild, or the guild the report was filed from, so every entry of one report has the same value
+
+6 Derived from the message ID
+
+7 Fluxer drops an evidence copy that records no file name, resolves to no channel, or receives no download URL, so this array can be shorter than the original
+
+8 Sorted ascending, with at most 100 entries, and empty when no NCMEC report has been filed against the author
+
+### Report message attachment object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the attachment |
+| filename | string | The captured file name |
+| url9 | string | The download URL for the evidence copy |
+| nsfw | ?boolean | Whether the attachment was marked NSFW |
+| content_type | ?string | The captured MIME type, or null when unknown |
+| width | ?integer | The pixel width, or null for a non-image attachment |
+| height | ?integer | The pixel height, or null for a non-image attachment |
+| size?3 | ?integer | The size in bytes, or null when unknown |
+| ncmec_status10 | string | The [NCMEC submission status](#ncmec-submission-statuses) |
+| ncmec_report_id | ?string | The NCMEC report ID, or null when the attachment was never submitted |
+| ncmec_failure_reason | ?string | The failure detail, or null unless `ncmec_status` is failed |
+
+9 A presigned URL against the report evidence bucket, valid for five minutes, so a saved response body stops resolving attachments while the evidence copy is retained
+
+10 An in-flight submission is stored as `submitting`, a value the registry below does not list
+
+#### NCMEC submission statuses
+
+| Value | Description |
+| --- | --- |
+| not_submitted | The attachment has never been submitted to NCMEC |
+| submitted | The attachment has been submitted to NCMEC |
+| failed | Submission to NCMEC was attempted and failed |
+
+## Report resolution object
+
+A report resolution is the reduced view [Update report](#update-report) returns. It has the fields the update writes and nothing else.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| report_id | snowflake | The ID of the report |
+| status | integer | The [report status](#report-status), always RESOLVED on a successful update |
+| resolved_at | ?ISO8601 timestamp | When the report was resolved |
+| public_comment | ?string | The comment shown to the reporter, or null when none was supplied |
+
+### Example
+
+```json
+{
+ "report_id": "1502771904419102720",
+ "status": 1,
+ "resolved_at": "2026-03-05T11:04:53.902Z",
+ "public_comment": "Thanks for the report. We actioned the account."
+}
+```
+
+## List reports
+
+
+
+Returns a page of [Admin report](#admin-report-object) objects without message context. Requires `report:view`.
+
+The operation has two branches. Supplying `q`, `report_type`, `category`, `reporter_id`, `reported_user_id`, `reported_guild_id`, `reported_channel_id`, `guild_context_id`, or `resolved_by_admin_id` searches the report index and returns `total`, `offset`, and `limit` beside the page. Supplying none of them lists by status alone and returns only `reports`.
+
+The branches default `status` differently. The status-only branch defaults to PENDING, so a request with no query string returns pending reports only. The search branch applies no status filter when `status` is omitted. `sort_by` and `sort_order` reach the search branch only. The status-only branch always orders by `reported_at` descending.
+
+An instance with no search backend returns 403 `FEATURE_TEMPORARILY_DISABLED` on both branches.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| q?1 | string | Free-text query matched against the searchable report fields (1-1024 characters) |
+| status? | pending \| resolved | Restrict results to one [report status](#report-status) |
+| report_type? | message \| user \| guild | Restrict results to one [report type](#report-type) |
+| category? | string | Restrict results to one submitted category (1-128 characters) |
+| reporter_id? | snowflake | Restrict results to one reporter |
+| reported_user_id? | snowflake | Restrict results to one reported account |
+| reported_guild_id? | snowflake | Restrict results to one reported guild |
+| reported_channel_id?2 | snowflake | Restrict results to one reported channel |
+| guild_context_id? | snowflake | Restrict results to reports filed from within one guild |
+| resolved_by_admin_id? | snowflake | Restrict results to one resolving Admin account |
+| sort_by?3 | created_at \| reported_at \| resolved_at | Sort field (default `reported_at`) |
+| sort_order? | asc \| desc | Sort direction, where asc is oldest first (default `desc`) |
+| limit? | integer | Maximum reports to return (1-200, default 50) |
+| offset?4 | integer | Reports to skip (0-10000, default 0) |
+
+1 Matched against `category`, `additional_info`, `reported_guild_name`, and `reported_channel_name` only. Omitting it matches every report satisfying the remaining filters
+
+2 The route accepts it, and it alone selects the search branch. It narrows nothing
+
+3 `created_at` orders by the time encoded in the report ID, and `reported_at` by the submission time stored on the report
+
+4 The next page is requested with a larger `offset`
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| reports1 | array[[Admin report](#admin-report-object) object] | Reports in this page, without message context |
+| total?23 | integer | Index hits matching the filters, less the hits on this page that no longer resolve |
+| offset?2 | integer | The offset this page was read from |
+| limit?2 | integer | The limit this page was read with |
+
+1 An index hit whose report record no longer exists is dropped and the request still succeeds, so a full page can hold fewer entries than `limit`
+
+2 Present on the search branch only, so the status-only branch reports no total
+
+3 Reduced only by the unresolvable hits of the page that was read, so it is an upper bound on what a caller can page through
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Report page was returned |
+| 403 | [error response](/admin-api/#error-response) | No search backend is configured for the instance and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get report
+
+
+
+Returns one [Admin report detail](#admin-report-detail-object) object, which adds the captured message context and the live state of those messages. Requires `report:view`.
+
+The operation answers even when the report search index is unavailable. Reporter contact fields are redacted here exactly as they are in [List reports](#list-reports).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| report_id | snowflake | The ID of the report |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Admin report detail](#admin-report-detail-object) object | Report was returned |
+| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Update report
+
+
+
+Resolves one report and returns a [report resolution](#report-resolution-object) object. Requires `report:resolve`.
+
+`status` is required and `resolved` is the only value it accepts, so this route changes no other field.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| report_id | snowflake | The ID of the report |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | resolved | The status to move the report to |
+| public_comment?1 | string | The comment shown to the reporter (0-512 characters) |
+
+1 Stored as null when it is omitted or empty, and shown to the reporter in the resolution notice otherwise
+
+:::caution[Resolution is terminal]
+Resolving a report notifies the reporter. A resolved report never returns to PENDING, and a second update against it returns 400 `REPORT_ALREADY_RESOLVED` without changing the stored public comment.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [report resolution](#report-resolution-object) object | Report was resolved |
+| 400 | [error response](/admin-api/#error-response) | The report is already resolved and the request returns `REPORT_ALREADY_RESOLVED` |
+| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` |
+
+### Side effects
+
+The report moves to RESOLVED and records `resolved_at`, `resolved_by_admin_id`, and the public comment.
+
+Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with target type `report`, target ID equal to the report ID, and action `resolve_report`. The metadata is `report_id` and `report_type`.
+
+Fluxer notifies the reporter when the report has a reporter account that still exists. The reporter receives a system direct message, and an email when the account has an address. An anonymous Digital Services Act notice has no reporter account and is resolved with no notification.
+
+A reporter who does not already have the system channel open receives [Channel Create](/gateway/events/#channel-create), and every session of the reporter receives [Message Create](/gateway/events/#message-create). Fluxer logs a delivery failure, and the request still succeeds.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx b/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx
new file mode 100644
index 000000000..d953473db
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx
@@ -0,0 +1,190 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Search indexes
+description: Administrative search index rebuilds, index names, and rebuild progress.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A search index holds the documents a search reads, and a rebuild writes those documents from current data. These two routes queue a rebuild and read its progress. The rebuild itself runs as the `refreshSearchIndex` background task described by [Jobs](/admin-api/jobs/).
+
+Both routes require the [ACL](/admin-api/#acl-evaluation) `guild:lookup`.
+
+## Search index names
+
+`index_name` is one of the following values. A value outside the set fails path validation.
+
+| Value | Scope | Description |
+| --- | --- | --- |
+| guilds | Instance-wide | Every guild, backing Admin guild search |
+| users | Instance-wide | Every account, backing Admin user search |
+| reports | Instance-wide | Every report, backing Admin report search |
+| audit_logs | Instance-wide | Every Admin audit entry, backing Admin audit search |
+| discovery1 | Instance-wide | Discovery metadata written onto the guild documents of approved listings |
+| channel_messages2 | One guild | Messages of every channel in one guild, requiring `guild_id` |
+| guild_members3 | One guild | Members of one guild, requiring `guild_id` |
+| favorite_memes4 | One user | Favourited [memes](/http-api/memes/) of one user, requiring `user_id` |
+
+1 This name has no index of its own. It reads every approved listing and writes its description, category, primary language, and tags onto the existing guild documents in batches of 200
+
+2 This name indexes no message itself. It clears the message documents of every channel in the guild, then queues one `indexChannelMessages` job for each channel
+
+3 The rebuild clears the guild's member documents first and stamps `members_indexed_at` on the guild when it finishes
+
+4 No worker handler is registered for this name. The queued job fails before it writes any progress record
+
+Every instance-wide name other than `discovery` deletes its documents before the first batch is written, so search over that index is incomplete for the whole run.
+
+:::note[A `discovery` rebuild updates documents in place]
+The guild documents stay searchable for the whole run.
+:::
+
+## Search index refresh object
+
+A receipt for one queued rebuild.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+| job_id1 | string | The ID of the queued rebuild, used to read its progress |
+
+1 The value is a [snowflake](/snowflakes/) rendered as a string. It addresses the progress record that [Get search index refresh](#get-search-index-refresh) reads. It differs from the `job_id` of the [Jobs](/admin-api/jobs/) ledger entry for the same rebuild, and appears there only inside that entry's `payload`
+
+### Example
+
+```json
+{
+ "success": true,
+ "job_id": "1501314428688998182"
+}
+```
+
+## Search index refresh progress object
+
+Progress for one queued rebuild. The object has two shapes, selected by `status`.
+
+### Not found structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | Always `not_found` |
+
+### Progress structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | One of `in_progress`, `completed`, or `failed` |
+| index_type | string | The [search index name](#search-index-names) being rebuilt |
+| total?1 | number | The number of documents expected, present while the rebuild reports progress and once it completes |
+| indexed?2 | number | The number of documents written so far |
+| started_at?3 | ISO8601 timestamp | The time the rebuild last reported progress |
+| completed_at? | ISO8601 timestamp | The time the rebuild finished, present only when `status` is `completed` |
+| failed_at? | ISO8601 timestamp | The time the rebuild failed, present only when `status` is `failed` |
+| error? | string | The failure text, present only when `status` is `failed` |
+
+1 `total` is the same value as `indexed` while the rebuild runs and becomes the real total on completion. The `discovery` rebuild reports the approved listing count from its first batch onwards
+
+2 The unit is the document the handler writes. A `channel_messages` rebuild counts the channels it queued
+
+3 Rewritten on every progress report, so its value moves forward while the rebuild runs
+
+The failure write replaces the record with the status, the index type, the error text, and the failure time, so a `failed` record has neither `total` nor `indexed`.
+
+### Example
+
+```json
+{
+ "status": "in_progress",
+ "index_type": "guilds",
+ "total": 3000,
+ "indexed": 3000,
+ "started_at": "2026-08-31T09:12:44.118Z"
+}
+```
+
+## Refresh search index
+
+
+
+Queues a rebuild of the named index. Requires `guild:lookup`. Returns a [search index refresh](#search-index-refresh-object) object on success.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| index_name | string | The [search index name](#search-index-names) to rebuild |
+
+### JSON body
+
+Every field is optional. Fluxer reads an absent or empty body as an empty object, so an instance-wide rebuild can send no body at all. A body that is not parseable JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id?1 | snowflake | The ID of the guild whose copy of the index is rebuilt |
+| user_id?2 | snowflake | The ID of the user whose copy of the index is rebuilt |
+
+1 Required by `channel_messages` and `guild_members`. Every other index name ignores it
+
+2 Required by `favorite_memes`. Every other index name ignores it
+
+Fluxer records both values on the audit entry whenever they are supplied, including on an index name that ignores them.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [search index refresh](#search-index-refresh-object) object | The rebuild was queued |
+| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` because the scope ID the index name requires is missing, or the body is not parseable JSON |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` without `admin:authenticate`, or `MISSING_ACL` without `guild:lookup` |
+| 500 | [error response](/admin-api/#error-response) | The job could not be queued |
+
+A missing scope ID names `guild_id` or `user_id` in `errors`. Fluxer queues no job and records no audit entry for that failure.
+
+:::caution[The rebuild runs after the response]
+Search results over the named index can be incomplete until the rebuild finishes.
+:::
+
+### Side effects
+
+Fluxer rebuilds the named index from current data in batches of 1,000 documents. A guild-scoped rebuild does not check that the guild exists, so an unknown ID produces an empty index.
+
+The job has an attempt budget of 1, so a rebuild that throws is not retried.
+
+Fluxer records one Admin audit entry with the action `queue_refresh_index`, the target type `search_index`, and the target ID `0`. Its metadata has `index_type`, `job_id`, and whichever of `guild_id` and `user_id` the request supplied. Fluxer emits no Gateway Dispatch.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get search index refresh
+
+
+
+Returns the [search index refresh progress](#search-index-refresh-progress-object) object for one queued rebuild. Requires `guild:lookup`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| job_id1 | string | The identifier returned by [Refresh search index](#refresh-search-index) |
+
+1 The value is bounded at 1 to 128 characters after normalisation and need not be a snowflake
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | [search index refresh progress](#search-index-refresh-progress-object) object | A progress record was returned, including the not-found shape |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_PERMISSIONS` without `admin:authenticate`, or `MISSING_ACL` without `guild:lookup` |
+
+1 An unknown identifier answers 200 with `status` set to `not_found`, which is also the answer for an expired record and for a rebuild that failed before writing its first record
+
+:::note[Progress expires after one hour]
+Each write of the progress record renews a one-hour lifetime, so a completed or failed outcome stays readable for one hour after its final update.
+:::
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/system-dms.mdx b/fluxer_docs/src/content/docs/admin-api/system-dms.mdx
new file mode 100644
index 000000000..2215958f9
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/system-dms.mdx
@@ -0,0 +1,63 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: System DMs
+description: The system direct message broadcast and the delivery job it queues.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A system DM broadcast delivers one identical message, authored by the system account, to each supplied recipient. A recipient receives a normal message in a normal direct message channel.
+
+The [Jobs](/admin-api/jobs/) resource reports delivery progress. [Cancelling that job](/admin-api/jobs/#cancel-job) stops an in-flight broadcast.
+
+## Create system DM broadcast
+
+
+
+Queues one message for delivery to every supplied recipient. Requires `system_dm:send`. Returns the number of recipients queued.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| content1 | string | The message delivered to every recipient (1-4000 characters) |
+| user_ids2 | array[snowflake] | The recipients of the broadcast (1-10,000 entries) |
+
+1 Content with no visible character is accepted and then fails for every recipient
+
+2 Fluxer keeps duplicate IDs, so a repeated recipient receives the message once per occurrence. An ID naming no account is accepted here and skipped at delivery time
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| recipient_count | integer | The number of entries in the submitted `user_ids` array |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The delivery job was queued |
+| 500 | [error response](/admin-api/#error-response) | The ledger row or the queue message could not be written |
+
+:::caution[The job continues past a failed recipient]
+The job logs every recipient it cannot deliver to. A run that reaches the last recipient settles as `succeeded` whatever the failure count.
+:::
+
+:::caution[The response has no job ID]
+Filter [List jobs](/admin-api/jobs/#list-jobs) by the `sendSystemDm` task type to find the queued work. The job is queued with no requester and no reason, so its `requested_by_user_id` and `audit_log_reason` are both null.
+:::
+
+### Side effects
+
+For each recipient the job opens the direct message channel between the system account and that recipient, then sends the message. [Channel Create](/gateway/events/#channel-create) reaches a recipient only when the channel is newly created or was closed on that recipient's side. A recipient who already has the channel open observes only [Message Create](/gateway/events/#message-create).
+
+The job opens the channel with no admission check, so a recipient who has never interacted with the system account still receives the message. It also skips direct message spam mitigation. On [Create private channel](/http-api/users/private-channels/#create-private-channel) that mitigation puts a caller holding the [SPAMMER](/http-api/users/#public-user-flags) flag into a channel only that caller sees.
+
+Before each recipient the job checks for cancellation, so cancelling stops further delivery and leaves sent messages in place. The [job](/admin-api/jobs/#admin-job-object) then settles as `cancelled`.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `system_dm.send`, the target type `system_dm`, and the target ID `0`. Its metadata has `recipient_count` and `content_length` as decimal strings, and the content itself is not recorded.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:message:operation` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/users.mdx b/fluxer_docs/src/content/docs/admin-api/users.mdx
new file mode 100644
index 000000000..9060c9866
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/users.mdx
@@ -0,0 +1,2204 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin users
+description: Reading and editing accounts, including contact fields, flags, bans, sessions, deletions, and archives.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+These routes read and edit any account on the instance. One account has more here than the public [Users](/http-api/users/) resource returns, including its contact details, its network addresses, and its lifecycle state.
+
+Each mutable field group has its own route, its own [ACL](/admin-api/#acl-registry), and its own [audit action](/admin-api/#audit-actions), so there is no consolidated update.
+
+A read records no audit entry and reads no audit reason. [List user sessions](#list-user-sessions) and [List user WebAuthn credentials](#list-user-webauthn-credentials) are the two exceptions. `POST /v1/admin/users/{user_id}/avatar-block` addresses a user path but belongs to [Blocklists](/admin-api/blocklists/#block-a-users-current-avatar).
+
+:::caution[A missing PII ACL nulls the protected fields]
+The read still succeeds, so a null field is no evidence that the account has none.
+:::
+
+## Admin user object
+
+The complete administrative view of one account. It has every stored flag, the private lifecycle fields, and the contact and network fields that the public [user object](/http-api/users/#user-object) never exposes.
+
+When the caller lacks the matching ACL, Fluxer redacts three field groups and still returns every key, so the object shape is identical for every caller.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the account |
+| username | string | The username of the account, unique only together with its discriminator (1-32 characters) |
+| discriminator1 | integer | Discriminator from 0 to 9999, unpadded |
+| global_name | ?string | The display name of the account, or null when unset |
+| bot | boolean | Whether the account is a bot |
+| system | boolean | Whether the account is an official system account |
+| flags2 | string | [Account flags](#account-flags) as an unsigned 64-bit decimal string |
+| premium_flags | integer | [Premium flags](#premium-flags) |
+| avatar | ?string | The avatar hash, or null when the account has none |
+| banner | ?string | The profile banner hash, or null when the account has none |
+| bio | ?string | The profile biography, or null when unset |
+| pronouns | ?string | The profile pronouns, or null when unset |
+| accent_color | ?integer | The profile accent colour packed into an integer, or null when unset |
+| email3 | ?string | The registered email address, or null when the account has none |
+| email_verified3 | boolean | Whether the registered address has been verified |
+| email_bounced3 | boolean | Whether delivery to the registered address hard bounced |
+| has_verified_phone | boolean | Whether the account is treated as having completed phone verification |
+| date_of_birth4 | ?string | The date of birth in `YYYY-MM-DD` form, or null when none is stored |
+| locale | ?string | The saved [locale](/topics/locales/#supported-locales), or null when the account has never set one |
+| premium_type | ?integer | [Premium type](#premium-types) |
+| premium_since | ?ISO8601 timestamp | The time the premium subscription began, or null when the account has none |
+| premium_until | ?ISO8601 timestamp | The time the premium subscription expires, or null when the account has none |
+| premium_grace_ends_at | ?ISO8601 timestamp | The time the payment grace period ends, or null when no grace period is running |
+| premium_lifetime_sequence | ?integer | The sequence number of the lifetime purchase, or null when the account holds none |
+| suspicious_activity_flags | integer | [Suspicious activity flags](#suspicious-activity-flags) |
+| phone_verification_deferred5 | boolean | Whether a stored phone requirement is deferred until the account joins a discoverable or large community |
+| temp_banned_until6 | ?ISO8601 timestamp | The time the ban expires, or null when no ban stands |
+| pending_deletion_at | ?ISO8601 timestamp | The time the scheduled deletion runs, or null when none is scheduled |
+| pending_bulk_message_deletion_at7 | ?ISO8601 timestamp | The time the account's own scheduled bulk message deletion runs |
+| deletion_reason_code | ?integer | [Deletion reason](#deletion-reasons) |
+| deletion_public_reason | ?string | The reason shown to the account holder, or null when none was supplied |
+| acls8 | array[string] | Effective [Admin ACLs](/admin-api/#acl-registry), with at most 111 entries |
+| traits9 | array[string] | The free-form operator labels set on the account, with at most 100 entries |
+| has_totp10 | boolean | Whether a TOTP authenticator is registered |
+| authenticator_types10 | array[integer] | Registered [authenticator types](/http-api/users/#authenticator-types), with at most 10 entries |
+| last_active_at | ?ISO8601 timestamp | The time of the last recorded activity, or null when none is recorded |
+| last_active_ip11 | ?string | The IP address the account was last active from |
+| last_active_ip_reverse1112 | ?string | The reverse DNS name of that IP address |
+| last_active_location1112 | ?string | The approximate location of that IP address |
+
+1 An unpadded JSON number here, unlike the zero-padded string `discriminator` of the [Admin user summary](/admin-api/#admin-user-summary-object) and the [Admin resolved user](#admin-resolved-user-object) embedded in other Admin objects
+
+2 Serialised as a decimal string
+
+3 Requires `user:view:email`. Without it `email` is null while `email_verified` and `email_bounced` are both false regardless of the stored state, so an unverified account and a redacted one are indistinguishable
+
+4 Requires `user:view:dob`, and without it the field is null
+
+5 Derived from bit `1 << 16` of `suspicious_activity_flags`, which sits outside the [suspicious activity flag](#suspicious-activity-flags) registry
+
+6 Set by [Ban user](#ban-user) for both ban modes. An expiry that has already passed is reported as null, so an elapsed temporary ban reads the same as no ban
+
+7 Written when the account holder schedules its own bulk message deletion, and cleared by [Cancel scheduled message deletion](#cancel-scheduled-message-deletion)
+
+8 The set written by [Set user ACLs](#set-user-acls), returned in stored order. This set alone decides whether the account can reach the Admin API, and the `STAFF` [account flag](#account-flags) plays no part in that
+
+9 Sorted in ascending order, unlike `acls`
+
+10 Never redacted, and returned in full to any caller the operation admitted
+
+11 Requires `user:view:ip`, and without it the field is null and no network lookup is attempted
+
+12 Resolved live from `last_active_ip` for each response, and null when the lookup fails or returns nothing. The reverse DNS result is cached for one day
+
+### Example
+
+```json
+{
+ "id": "1493004551122763776",
+ "username": "subject",
+ "discriminator": 7,
+ "global_name": null,
+ "bot": false,
+ "system": false,
+ "flags": "8796093022208",
+ "premium_flags": 0,
+ "avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
+ "email": "subject@example.com",
+ "email_verified": true,
+ "email_bounced": false,
+ "has_verified_phone": false,
+ "date_of_birth": "1998-03-21",
+ "suspicious_activity_flags": 0,
+ "phone_verification_deferred": false,
+ "temp_banned_until": null,
+ "pending_deletion_at": null,
+ "deletion_reason_code": null,
+ "acls": [],
+ "traits": ["early-signup"],
+ "has_totp": true,
+ "authenticator_types": [1, 2],
+ "last_active_at": "2026-08-30T21:04:02.771000+00:00",
+ "last_active_ip": "203.0.113.24",
+ "last_active_ip_reverse": "host24.example.com",
+ "last_active_location": "Stockholm, Sweden"
+}
+```
+
+## Account flags
+
+The `flags` field of the [Admin user object](#admin-user-object) is a 64-bit bitfield serialised as an unsigned decimal string. It has every stored flag, including the ones the [HTTP API](/http-api/users/#public-user-flags) leaves out of its public subset.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | STAFF | Account is a staff member |
+| 1 << 2 | PARTNER | Account is a partner |
+| 1 << 3 | BUG_HUNTER | Account is a bug hunter |
+| 1 << 4 | FRIENDLY_BOT | Bot accepts friend requests from users |
+| 1 << 5 | FRIENDLY_BOT_MANUAL_APPROVAL | Bot requires manual approval for friend requests |
+| 1 << 6 | SPAMMER | Account is flagged as a spammer |
+| 1 << 33 | HIGH_GLOBAL_RATE_LIMIT | Account has elevated global rate limits |
+| 1 << 34 | DELETED | Account has been deleted |
+| 1 << 35 | DISABLED_SUSPICIOUS_ACTIVITY | Account is disabled for suspicious activity |
+| 1 << 36 | SELF_DELETED | Account was self-deleted |
+| 1 << 38 | DISABLED | Account is disabled |
+| 1 << 39 | HAS_SESSION_STARTED | Account has started a session |
+| 1 << 47 | RATE_LIMIT_BYPASS | Account can bypass rate limits |
+| 1 << 48 | REPORT_BANNED | Account is banned from reporting |
+| 1 << 49 | VERIFIED_NOT_UNDERAGE | Account is verified as not underage |
+| 1 << 51 | HAS_DISMISSED_PREMIUM_ONBOARDING | Account has dismissed premium onboarding |
+| 1 << 53 | APP_STORE_REVIEWER | Account belongs to an app store reviewer |
+| 1 << 57 | STAFF_HIDDEN | Staff status is hidden from public flags |
+| 1 << 60 | AGE_VERIFIED_ADULT | Account has verified its age as an adult through card verification |
+| 1 << 61 | FORCE_INBOUND_PHONE_VERIFICATION | Account is forced through inbound phone verification regardless of prefix |
+| 1 << 62 | NOT_SUSPICIOUS | Account is permanently exempt from automatic suspicious activity flagging |
+
+## Premium flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | DISCRIMINATOR | Account has a premium discriminator |
+| 1 << 1 | BADGE_HIDDEN | Premium badge is hidden |
+| 1 << 2 | BADGE_MASKED | Premium badge is masked |
+| 1 << 3 | BADGE_TIMESTAMP_HIDDEN | Premium badge timestamp is hidden |
+| 1 << 4 | BADGE_SEQUENCE_HIDDEN | Premium badge sequence is hidden |
+| 1 << 5 | PERKS_SANITIZED | Premium perks are sanitised |
+| 1 << 6 | PURCHASE_DISABLED | Premium purchase is disabled |
+| 1 << 7 | ENABLED_OVERRIDE | Premium status has an enabled override |
+| 1 << 8 | PERKS_DISABLED | Premium perks are temporarily disabled |
+
+## Premium types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NONE | No premium subscription |
+| 1 | SUBSCRIPTION | Active premium subscription |
+| 2 | LIFETIME | Lifetime premium subscription |
+
+## Suspicious activity flags
+
+A 32-bit bitfield of verification requirements imposed on an account. [Update suspicious activity flags](#update-suspicious-activity-flags) and [Disable user for suspicious activity](#disable-user-for-suspicious-activity) both write the complete value, so a bit the request omits is cleared.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | REQUIRE_VERIFIED_EMAIL1 | Require a verified email |
+| 1 << 1 | REQUIRE_REVERIFIED_EMAIL1 | Require a reverified email |
+| 1 << 2 | REQUIRE_VERIFIED_PHONE2 | Require a verified phone |
+| 1 << 3 | REQUIRE_REVERIFIED_PHONE3 | Require a reverified phone |
+| 1 << 4 | REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE1 | Require verified email or verified phone |
+| 1 << 5 | REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE1 | Require reverified email or verified phone |
+| 1 << 6 | REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE1 | Require verified email or reverified phone |
+| 1 << 7 | REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE1 | Require reverified email or reverified phone |
+| 1 << 8 | REQUIRE_INBOUND_PHONE_VERIFICATION2 | Require inbound SMS verification, where the account holder texts a code to the instance's inbound SMS number |
+
+1 Cleared automatically whenever the account's email becomes verified, either through ordinary verification or through [Verify user email](#verify-user-email)
+
+2 Cleared automatically by [Update user phone verification](#update-user-phone-verification) when it sets `has_verified_phone` to true, together with the deferral bit `1 << 16`
+
+3 Deferrable alongside `REQUIRE_VERIFIED_PHONE`. [Update user phone verification](#update-user-phone-verification) leaves it set
+
+Bit `1 << 16` sits outside this registry. It marks a phone requirement as deferred until the account joins a discoverable or large community, and the [Admin user object](#admin-user-object) reports it as `phone_verification_deferred`. [Update suspicious activity flags](#update-suspicious-activity-flags) and [Disable user for suspicious activity](#disable-user-for-suspicious-activity) bound `flags` only as a non-negative 32-bit integer, and neither masks the submitted value, so a request with that bit sets it directly.
+
+## Deletion reasons
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 | USER_REQUESTED1 | Account holder requested deletion |
+| 2 | OTHER | Other reason |
+| 3 | SPAM | Spam |
+| 4 | CHEATING_OR_EXPLOITATION | Cheating or exploitation |
+| 5 | COORDINATED_RAIDING | Coordinated raiding |
+| 6 | AUTOMATION_OR_SELFBOT | Automation or selfbot use |
+| 7 | NONCONSENSUAL_SEXUAL_CONTENT | Non-consensual sexual content |
+| 8 | SCAM_OR_SOCIAL_ENGINEERING | Scam or social engineering |
+| 9 | CHILD_SEXUAL_CONTENT | Child sexual content |
+| 10 | PRIVACY_VIOLATION_OR_DOXXING | Privacy violation or doxxing |
+| 11 | HARASSMENT_OR_BULLYING | Harassment or bullying |
+| 12 | PAYMENT_FRAUD | Payment fraud |
+| 13 | CHILD_SAFETY_VIOLATION | Child safety violation |
+| 14 | BILLING_DISPUTE_OR_ABUSE | Billing dispute or abuse |
+| 15 | UNSOLICITED_EXPLICIT_CONTENT | Unsolicited explicit content |
+| 16 | GRAPHIC_VIOLENCE | Graphic violence |
+| 17 | BAN_EVASION | Ban evasion |
+| 18 | TOKEN_OR_CREDENTIAL_SCAM | Token or credential scam |
+| 19 | INACTIVITY | Inactivity |
+| 20 | HATE_SPEECH_OR_EXTREMIST_CONTENT | Hate speech or extremist content |
+| 21 | MALICIOUS_LINKS_OR_MALWARE | Malicious links or malware |
+| 22 | IMPERSONATION_OR_FAKE_IDENTITY | Impersonation or fake identity |
+
+1 The one value that lowers the minimum scheduling delay to 14 days and suppresses the identifier blocklisting and report auto-resolution described under [Schedule user deletion](#schedule-user-deletion)
+
+A `reason_code` request value outside this registry fails body validation with 400 `INVALID_FORM_BODY`.
+
+## Profile fields
+
+The values [Clear user profile fields](#clear-user-profile-fields) accepts in `fields`.
+
+| Value | Description |
+| --- | --- |
+| avatar | Profile avatar image |
+| banner | Profile banner image |
+| bio | Profile biography |
+| pronouns | Profile pronouns |
+| global_name | Display name |
+
+## Relationship categories
+
+| Value | Description |
+| --- | --- |
+| friend | A mutual friendship |
+| incoming_request | A friend request the account has received |
+| outgoing_request | A friend request the account has sent |
+| blocked | An account this account has blocked |
+
+The first three categories are mirrored, so removing one also removes the corresponding row on the other account. A block has no mirror row.
+
+## Admin user session object
+
+One entry for each authentication session of an account. A terminated session stays in the list as a tombstone, with `deleted_at` set. No operation returns the session token.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id_hash | string | The base64url-encoded hash of the session ID (8-256 characters) |
+| created_at | ISO8601 timestamp | The time the session was created |
+| approx_last_used_at | ISO8601 timestamp | The approximate time the session was last used |
+| client_ip1 | string | The IP address the session was created from |
+| client_ip_reverse12 | ?string | The reverse DNS name of that IP address |
+| client_os3 | ?string | The client operating system derived from the stored user agent |
+| client_platform3 | ?string | The client platform derived from the stored user agent |
+| client_location12 | ?string | The approximate location of that IP address |
+| deleted_at | ?ISO8601 timestamp | The time the session was terminated, or null while it is still active |
+
+1 Requires `user:view:ip`. Without it `client_ip` is the literal string `[redacted]`, both derived fields are null, and no network lookup is attempted
+
+2 Resolved live for each response, and null for that entry when its own lookup fails
+
+3 Derived from the stored user agent at read time, so a session recorded without one reports null for both
+
+### Example
+
+```json
+{
+ "session_id_hash": "Zm9vYmFyLXNlc3Npb24taGFzaC1leGFtcGxl",
+ "created_at": "2026-07-02T11:31:09.400000+00:00",
+ "approx_last_used_at": "2026-08-30T20:58:14.120000+00:00",
+ "client_ip": "203.0.113.24",
+ "client_ip_reverse": "host24.example.com",
+ "client_os": "Windows",
+ "client_platform": "Desktop",
+ "client_location": "Stockholm, Sweden",
+ "deleted_at": null
+}
+```
+
+## Admin resolved user object
+
+The account summary the Admin direct message channel and relationship objects embed. It has `avatar` on top of the [Admin user summary](/admin-api/#admin-user-summary-object) that audit entries embed.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the account |
+| username | string | The username of the account |
+| discriminator1 | string | The four-digit discriminator of the account |
+| global_name | ?string | The display name of the account, or null when unset |
+| avatar | ?string | The avatar hash, or null when the account has none |
+
+1 Left-padded with zeroes to four digits, unlike the unpadded integer `discriminator` of the [Admin user object](#admin-user-object)
+
+### Example
+
+```json
+{
+ "id": "1493004551122763776",
+ "username": "kelsier",
+ "discriminator": "0042",
+ "global_name": "Kelsier",
+ "avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90"
+}
+```
+
+## Admin direct message channel object
+
+One entry for each direct message or group direct message channel the account has taken part in. The field names differ from the public [channel object](/http-api/channels/#channel-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| channel_type | ?integer | [Channel type](/http-api/channels/#channel-types), or null when the channel record is unavailable |
+| channel_nsfw | ?boolean | Whether the channel is age-restricted, or null when the channel record is unavailable |
+| guild_nsfw_level1 | ?integer | [NSFW level](/http-api/guilds/#nsfw-levels) of the owning guild |
+| recipient_ids | array[snowflake] | The IDs of the channel recipients, with at most 100 entries |
+| recipients2 | array[[Admin resolved user](#admin-resolved-user-object) object] | The resolved recipient accounts, with at most 100 entries |
+| last_message_id | ?snowflake | The ID of the latest message, or null when the channel is empty |
+| is_open | boolean | Whether the channel is open in the target account's client |
+| name | ?string | The group name, or null when unnamed |
+| icon | ?string | The group icon hash, or null when the group has none |
+| owner_id | ?snowflake | The ID of the group owner, or null for a one-to-one direct message |
+
+1 Always null
+
+2 Contains only the recipients whose accounts still exist, so it can be shorter than `recipient_ids`
+
+### Example
+
+```json
+{
+ "channel_id": "1496118820043030528",
+ "channel_type": 3,
+ "channel_nsfw": false,
+ "guild_nsfw_level": null,
+ "recipient_ids": ["1493004551122763776", "1489200013322551296"],
+ "recipients": [],
+ "last_message_id": "1500992244771848192",
+ "is_open": true,
+ "name": "Weekend plans",
+ "icon": null,
+ "owner_id": "1493004551122763776"
+}
+```
+
+## Admin relationship object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| target_user_id | snowflake | The ID of the other account |
+| category | string | [Relationship category](#relationship-categories) |
+| nickname | ?string | The nickname the account assigned to the other party |
+| since | ?ISO8601 timestamp | The time the relationship was established, or null when unrecorded |
+| target1 | ?[Admin resolved user](#admin-resolved-user-object) object | The resolved other account |
+
+1 Null when the other account no longer exists, while `target_user_id` remains present
+
+### Example
+
+```json
+{
+ "target_user_id": "1489200013322551296",
+ "category": "friend",
+ "nickname": null,
+ "since": "2026-04-18T14:22:51.006000+00:00",
+ "target": {
+ "id": "1489200013322551296",
+ "username": "operator",
+ "discriminator": "0042",
+ "global_name": "Operator"
+ }
+}
+```
+
+## Admin user change log object
+
+One recorded change to an identity or contact field. The account holder's own changes are recorded here, and so are [Change user username](#change-user-username) and [Change user email](#change-user-email).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| event_id1 | string | The ID of the change entry as an unsigned 64-bit decimal string |
+| field | string | The name of the identity or contact field that changed |
+| old_value2 | ?string | The value before the change, or null when the field was unset |
+| new_value2 | ?string | The value after the change, or null when the field was cleared |
+| reason3 | ?string | The recorded reason for the change, or null when unrecorded |
+| actor_user_id | ?snowflake | The ID of the acting Admin, or null for a user-originated change |
+| event_at | ISO8601 timestamp | The time the change was recorded |
+
+1 Sent back as `page_token` to continue the walk. An entry stored without its own event ID reports the account ID here instead
+
+2 An `email` entry read without `user:view:email` keeps the entry and replaces both value fields with the literal string `[redacted]`. An absent value is null
+
+3 An Admin change records the literal value `admin_action`
+
+### Example
+
+```json
+{
+ "event_id": "1500992244771848192",
+ "field": "email",
+ "old_value": "[redacted]",
+ "new_value": "[redacted]",
+ "reason": "admin_action",
+ "actor_user_id": "1489200013322551296",
+ "event_at": "2026-08-14T07:55:31.882000+00:00"
+}
+```
+
+## Message shred entry object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel the message was sent in |
+| message_id | snowflake | The ID of the message to shred |
+
+### Example
+
+```json
+{
+ "channel_id": "1496118820043030528",
+ "message_id": "1500992244771848192"
+}
+```
+
+## List users
+
+
+
+Lists and searches accounts. Requires `user:lookup`, and `user:view:email` or `user:view:ip` for the selectors marked below. The response has an array of [Admin user](#admin-user-object) objects and a total.
+
+Fluxer honours exactly one selector, in this precedence order: `user_id`, `resolve`, `email`, `last_active_ip`, then the indexed `q` search. A lower-precedence parameter sent alongside a higher-precedence one is ignored, and the request still succeeds. A request sending no selector at all runs the indexed search with an empty query.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id?1 | array[snowflake] | Exact account IDs, with at most 100 values |
+| resolve?2 | string | One exact identifier to resolve (1-1024 characters) |
+| email?3 | string | Exact registered email address (1-320 characters) |
+| last_active_ip?4 | string | Exact last active IP address (1-64 characters) |
+| q?5 | string | Free-text indexed query (1-1024 characters) |
+| limit?6 | integer | Maximum accounts to return (1-200, default 50) |
+| offset?6 | integer | Number of results to skip (0-100000, default 0) |
+
+1 Repeat the parameter to pass more than one value. The response is unordered and has no entry for an ID that matches no account, so a client matches each returned account by its `id`
+
+2 Resolves at most one account, and a value containing `@` requires `user:view:email`. An unmatched value returns an empty array
+
+3 Matched exactly after trimming, returns at most one account, and requires `user:view:email`
+
+4 Matched exactly after trimming against the account's stored last active address, so a subnet or a partial address matches nothing. Requires `user:view:ip`
+
+5 Served by the user search index. An instance with no search backend configured returns 403 `FEATURE_TEMPORARILY_DISABLED`
+
+6 Honoured by the `last_active_ip` and `q` selectors. The `user_id`, `resolve`, and `email` selectors ignore both
+
+Fluxer chooses the `resolve` lookup from the shape of the value. A value matching `username#discriminator` resolves the tag, and a value that is entirely digits resolves the account ID. A value containing `@` resolves the email address. Every other value resolves a Stripe subscription ID.
+
+A `q` value that is entirely digits also resolves that exact account ID and places it first, even when the search index did not match it, provided `offset` is zero. That direct hit raises `total` by one when the index did not already return it.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| users | array[[Admin user](#admin-user-object) object] | The accounts in this page |
+| total1 | integer | The number of accounts the selector matched |
+
+1 The `user_id` and `resolve` selectors report the length of `users`, so `total` never exceeds the returned page for those two
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | Accounts were returned |
+| 403 | [error response](/admin-api/#error-response) | `MISSING_ACL`, because the honoured selector requires `user:view:email` or `user:view:ip` |
+| 403 | [error response](/admin-api/#error-response) | `FEATURE_TEMPORARILY_DISABLED`, because the indexed search ran on an instance with no search backend |
+
+1 A selector that matches nothing returns an empty `users` array and a `total` of zero
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get current Admin user
+
+
+
+Returns the account the request authenticated as, wrapped in a `user` member. Requires `admin:authenticate`.
+
+Every Admin credential already holds `admin:authenticate`, so every account that can reach the Admin API can read this route. `email`, `date_of_birth`, and the three IP fields are returned unredacted regardless of what the acting credential holds. `acls` reports the ACL set stored on the account, which for an Admin API key credential can be wider than what the key itself can exercise.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The account the request authenticated as |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Current Admin was returned |
+| 403 | [error response](/admin-api/#error-response) | Credential type is refused, or the account does not hold `admin:authenticate` |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get user
+
+
+
+Returns one account as a single-element array. Requires `user:lookup`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| users1 | array[[Admin user](#admin-user-object) object] | The requested account, or an empty array |
+
+1 The response is the same envelope [List users](#list-users) returns for its `user_id` selector, without the `total` member
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | The lookup completed |
+
+1 This operation does not answer 404. An ID with no matching account returns 200 with an empty `users` array, so a client checks the array length
+
+An account with a pending or completed deletion is still returned, with its lifecycle state visible through `pending_deletion_at`, `deletion_reason_code`, and `deletion_public_reason`.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Change user username
+
+
+
+Replaces the username, allocates or claims a discriminator, and returns the resulting account. Requires `user:update:username`.
+
+A target account may hold a custom discriminator on every self-hosted instance. On any other instance it may hold one when the `feature_custom_discriminator` limit admits it.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username1 | string | Replacement username (1-32 characters) |
+| discriminator?2 | string | Requested discriminator of one to four decimal digits |
+
+1 Trimmed before validation, restricted to letters, digits, and underscores, and rejected when it lowercases to `everyone` or `here` or contains `fluxer` or `system message`
+
+2 Honoured only when the target account may hold a custom discriminator. In every other case, including an omitted field, Fluxer allocates a free discriminator for the submitted username
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Username was replaced |
+| 400 | [error response](/admin-api/#error-response) | Path or body validation fails, or the requested tag is taken and the request returns `TAG_ALREADY_TAKEN` |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+`TAG_ALREADY_TAKEN` also covers a submitted username with no free discriminator left, and an allocation lock the operation cannot acquire within its 10 second wait.
+
+### Side effects
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update). Fluxer reindexes the member in each indexed guild in the background, so a member search result can briefly report the previous tag.
+
+The change is recorded in the contact change log and is returned by [List user change log](#list-user-change-log) with `reason` set to `admin_action` and `actor_user_id` set to the acting Admin.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `change_username`, target type `user`, and metadata keys `old_username`, `new_username`, and `discriminator`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Change user email
+
+
+
+Replaces the account's email address and returns the resulting account. Requires `user:update:email`.
+
+The replacement address is stored unverified. Use [Verify user email](#verify-user-email) afterwards to mark it verified, or [Resend verification email](#resend-verification-email) to let the account holder verify it.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email1 | string | Replacement email address (1-254 characters) |
+
+1 Normalised and validated as an email address. The operation performs no uniqueness check of its own
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Email address was replaced |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Side effects
+
+`email_verified` is set to false. The stored bounce marker survives the change, so an address replaced on a bounced account remains marked bounced until a verification clears it.
+
+No notice is sent to the previous or the replacement address. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and no [Guild Member Update](/gateway/events/#guild-member-update) follows.
+
+The change is recorded in the contact change log and is returned by [List user change log](#list-user-change-log), where both values are replaced with `[redacted]` for a caller without `user:view:email`.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `change_email`, target type `user`, and metadata keys `old_email` and `new_email`. The audit log is not redacted on read, so both addresses are visible to any holder of `audit_log:view`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Verify user email
+
+
+
+Marks the account's current email address as verified without a confirmation link, and returns the resulting account. Requires `user:update:email`.
+
+The operation accepts no request body and never clears verification, so the one way to mark an address unverified is to replace it with [Change user email](#change-user-email).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | Email address was marked verified |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 An account that has no stored email address is still accepted, and the verified marker is written against the absent address
+
+### Side effects
+
+`email_verified` becomes true and `email_bounced` becomes false. Every email-clearable [suspicious activity flag](#suspicious-activity-flags) is cleared from the account in the same write.
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `verify_email`, target type `user`, and a metadata key `email` with the address as it stood before the write, or the literal `null` when the account had none.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Resend verification email
+
+
+
+Runs the account holder's own verification resend path against the target account. Requires `user:update:email`. Returns an empty 204 response.
+
+:::caution[An already verified account gets no email]
+An already verified account with no email reverification [suspicious activity flag](#suspicious-activity-flags) returns 204 without storing a token or sending anything.
+:::
+
+:::note[Delivery is dropped silently]
+Nothing is sent when the instance email transport is disabled or when the address is marked hard bounced. Neither case changes the status code, and the audit entry is recorded either way.
+:::
+
+A per-address control, independent of the Admin buckets, permits three verification emails for each address in fifteen minutes. Fluxer charges it before creating the token, so a request that exhausts it returns 429 and stores no token.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The request was accepted, whether or not an email was sent |
+| 4001 | [error response](/admin-api/#error-response) | The path parameter is not a snowflake, or the account has no email address |
+| 403 | [error response](/admin-api/#error-response) | Credential type or ACL evaluation denies the request, or `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED` when the target is a bot |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 A missing address returns `INVALID_FORM_BODY` with the validation code `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS`
+
+### Side effects
+
+A single-use verification token is stored against the account ID and its current address, and the link is emailed to that address. The account's verification state is unchanged until the link is used.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `resend_verification_email`, target type `user`, and a metadata key `email`. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Send password reset
+
+
+
+Creates a password reset token and emails it to the account holder. Requires `user:update:email`. Returns an empty 204 response.
+
+:::caution[A dropped reset email still stores the token]
+Delivery is silently dropped when the instance email transport is disabled and when the address is marked hard bounced. The token is stored either way, so a dropped delivery leaves a usable reset token that nobody received.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The request was accepted, whether or not an email was sent |
+| 4001 | [error response](/admin-api/#error-response) | The path parameter is not a snowflake, or the account has no email address |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 A missing address returns `INVALID_FORM_BODY` with the validation code `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS`
+
+Unlike [Resend verification email](#resend-verification-email), this operation is subject to no per-address control and does not refuse a bot account.
+
+### Side effects
+
+A single-use password reset token is stored against the account ID and its current email address, and the token is emailed to that address. The account's password, sessions, and other credentials are unchanged until the account holder completes the reset.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `send_password_reset`, target type `user`, and a metadata key `email`. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Change user date of birth
+
+
+
+Replaces the stored date of birth and returns the resulting account. Requires `user:update:dob`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| date_of_birth1 | string | Replacement date in `YYYY-MM-DD` form |
+
+1 Exactly ten characters matching `YYYY-MM-DD`. The field is required, so there is no way to clear a stored date of birth through this operation
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Date of birth was replaced |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Side effects
+
+The new date changes what age-restricted content the account may reach. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions.
+
+The change is not written to the contact change log. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `change_dob`, target type `user`, and metadata keys `old_dob` and `new_dob`, where an absent previous value is the literal `null`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Clear user profile fields
+
+
+
+Clears the named profile fields and returns the resulting account. Requires `user:update:profile`.
+
+Clearing is the only profile mutation on this resource. No route sets a bio, a display name, an avatar, or a banner to a new value.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| fields1 | array[string] | [Profile fields](#profile-fields) to clear, with at most 10 entries |
+
+1 Required. An empty array is accepted, clears nothing, and still records an audit entry. A value outside the registry fails body validation
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The named fields were cleared |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Side effects
+
+Clearing `avatar` or `banner` schedules the previous asset for deletion.
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed, which covers `avatar` and `global_name`.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `clear_fields`, target type `user`, and a metadata key `fields` with the submitted names joined by commas.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Set user bot status
+
+
+
+Marks the account as a bot or as an ordinary account, and returns the resulting account. Requires `user:update:bot_status`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| bot1 | boolean | Whether the account is a bot |
+
+1 Required. Setting it to false also clears `system` in the same write
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Bot status was set |
+| 4031 | [error response](/admin-api/#error-response) | Credential type or ACL evaluation denies the request. `ACCESS_DENIED` when the target holds an Admin ACL and `bot` is true |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 A staff account cannot be converted into a bot. Clear its ACL set with [Set user ACLs](#set-user-acls) first
+
+### Side effects
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `set_bot_status`, target type `user`, and a metadata key `bot`. Clearing `system` as a side effect records no second entry.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Set user system status
+
+
+
+Marks the account as an official system account or removes that marker, and returns the resulting account. Requires `user:update:bot_status`, the same ACL as [Set user bot status](#set-user-bot-status).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| system1 | boolean | Whether the account is a system account |
+
+1 Required. Setting it to true on an account that is not already a bot fails validation
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | System status was set |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The non-bot case is `INVALID_FORM_BODY` with the validation code `USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER` on the `system` path
+
+### Side effects
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `set_system_status`, target type `user`, and a metadata key `system`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Set user ACLs
+
+
+
+Replaces the Admin ACL set held by the account and returns the resulting account. Requires `acl:set:user`.
+
+The submitted array replaces the stored set outright. An empty array removes every ACL, which also removes the account's Admin API access and leaves every Admin API key the account created unable to satisfy any operation.
+
+:::caution[A grant cannot exceed the acting Admin]
+A submitted set naming an ACL the acting credential does not hold fails with 403 `MISSING_ACL` on the first offending value. A wildcard holder is exempt. An Admin API key is compared against the set stored on the key.
+:::
+
+:::caution[An Admin cannot write its own ACL set]
+A path naming the acting account fails with 403 `ACCESS_DENIED` before the account is resolved.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| acls1 | array[string] | Replacement [Admin ACLs](/admin-api/#acl-registry), with at most 111 values |
+
+1 Required. A value outside the [ACL registry](/admin-api/#acl-registry) fails body validation, and a repeated value is collapsed
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The ACL set was replaced |
+| 400 | [error response](/admin-api/#error-response) | Path or body validation fails, including an `acls` value outside the [ACL registry](/admin-api/#acl-registry) |
+| 403 | [error response](/admin-api/#error-response) | Credential type or ACL evaluation denies the request, or `ACCESS_DENIED` when the path names the acting account |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+The grant bound is evaluated after the account is resolved, so an unknown ID fails first with 404.
+
+### Side effects
+
+The stored ACL set is replaced and appears in the next [Admin user object](#admin-user-object). Whether the account can reach the Admin API follows from whether the new set holds `admin:authenticate` or `*`. Narrowing the set narrows every existing session and Admin API key of the account without rotating any credential.
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `set_acls`, target type `user`, and a metadata key `acls` with the submitted values joined by commas.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Set user traits
+
+
+
+Replaces the account's trait set and returns the resulting account. Requires `user:update:traits`.
+
+Traits are free-form operator labels that have no ACL meaning and grant no capability.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| traits1 | array[string] | Replacement trait set, with at most 100 values of 1 to 128 characters |
+
+1 Required. An empty array clears the stored set entirely, and a repeated value is collapsed
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The trait set was replaced |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Side effects
+
+The stored trait set is replaced and is returned sorted by the next [Admin user object](#admin-user-object). [User Update](/gateway/events/#user-update) is emitted to the account's own sessions.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `set_traits` and target type `user`. A non-empty request has a metadata key `traits` with the submitted values joined by commas, and a request that clears the set has no metadata at all.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Update user flags
+
+
+
+Adds and removes [account flags](#account-flags) and returns the resulting account. Requires `user:update:flags`.
+
+Additions are applied before removals, so a flag named in both arrays ends up cleared. A bit outside the [account flag](#account-flags) registry is applied unchanged.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| add_flags?1 | array[string] | [Account flag](#account-flags) values to add, with at most 64 entries (default empty) |
+| remove_flags?1 | array[string] | [Account flag](#account-flags) values to remove, with at most 64 entries (default empty) |
+
+1 One 64-bit flag value as an unsigned decimal string, such as `64`. A value with several bits is applied as a mask
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Flags were updated |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+[Bulk jobs](/admin-api/bulk-jobs/) applies the same change to up to 1,000 accounts as a queued `update_user_flags` task.
+
+### Side effects
+
+The stored flag bitfield is replaced with the computed value. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_flags`, target type `user`, and the metadata keys `add_flags`, `remove_flags`, and `new_flags`. An empty array is omitted from the metadata map.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Update user premium flags
+
+
+
+Adds and removes [premium flags](#premium-flags) and returns the resulting account. Requires `user:update:flags`, the same ACL as [Update user flags](#update-user-flags).
+
+Premium flags control badge presentation, the premium override, the purchase block, and perk sanitisation. They do not grant or revoke a subscription.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| add_flags?1 | array[integer] | [Premium flag](#premium-flags) values to add, with at most 64 entries (default empty) |
+| remove_flags?1 | array[integer] | [Premium flag](#premium-flags) values to remove, with at most 64 entries (default empty) |
+
+1 Each entry is one 32-bit flag value, such as `2`. Additions are applied before removals
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Premium flags were updated |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Side effects
+
+The stored premium flag bitfield is replaced with the computed value, and premium badge presentation changes for the account. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_premium_flags`, target type `user`, and the metadata keys `add_flags`, `remove_flags`, and `new_flags`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Update user phone verification
+
+
+
+Sets whether the account is treated as having completed phone verification, and returns the resulting account. Requires `user:update:phone`.
+
+The user-facing phone verification marker is otherwise irreversible, and this is the one operation that clears it.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| has_verified_phone1 | boolean | Whether the account counts as phone verified |
+
+1 Required. Setting it to true additionally clears the `REQUIRE_VERIFIED_PHONE` and `REQUIRE_INBOUND_PHONE_VERIFICATION` [suspicious activity flags](#suspicious-activity-flags) together with the deferral bit `1 << 16`. Setting it to false clears no flag
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Phone verification state was set |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+`REQUIRE_REVERIFIED_PHONE` is outside the cleared set, so an account under a reverification requirement keeps it after this operation marks it verified.
+
+### Side effects
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_has_verified_phone`, target type `user`, and a metadata key `has_verified_phone`. When suspicious activity flags were also cleared, the entry additionally has `suspicious_activity_flags_before` and `suspicious_activity_flags_after`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Update suspicious activity flags
+
+
+
+Replaces the account's [suspicious activity flags](#suspicious-activity-flags) and returns the resulting account. Requires `user:update:suspicious_activity`.
+
+The operation imposes verification requirements without disabling the account. [Disable user for suspicious activity](#disable-user-for-suspicious-activity) also locks the account out.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| flags1 | integer | Replacement [suspicious activity flags](#suspicious-activity-flags) |
+
+1 Required. The value replaces the complete stored bitfield, so an omitted bit is cleared and a value of zero imposes no requirement at all
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Flags were replaced |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+The stored deferral bit `1 << 16` is preserved only when it was already set and the submitted value has the same non-zero deferrable phone bits as the stored value. Any other submitted value clears the deferral, so the requirement takes effect immediately.
+
+[Bulk jobs](/admin-api/bulk-jobs/) applies the same change to up to 1,000 accounts as a queued `update_suspicious_activity_flags` task.
+
+### Side effects
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. Fluxer records a `challenged` risk outcome against the account when the write changes the set and leaves at least one registry flag set.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_suspicious_activity_flags`, target type `user`, and a metadata key `flags` with the submitted value. A preserved deferral bit makes that value differ from the flags the account ends up with.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Disable user for suspicious activity
+
+
+
+Disables the account, replaces its [suspicious activity flags](#suspicious-activity-flags), destroys its password, and returns the resulting account. Requires `user:disable:suspicious`.
+
+:::caution[The stored password is destroyed]
+The account holder cannot sign in with their old password after regaining access, and recovery requires [Send password reset](#send-password-reset) or the ordinary self-service reset flow.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| flags1 | integer | Replacement [suspicious activity flags](#suspicious-activity-flags) |
+
+1 Required. The value replaces the complete stored bitfield. Unlike [Update suspicious activity flags](#update-suspicious-activity-flags), the deferral bit is never preserved
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The account was disabled |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+Fluxer adds the `DISABLED_SUSPICIOUS_ACTIVITY` [account flag](#account-flags), so every other stored flag survives. No dedicated operation clears it. [Unban user](#unban-user) clears only `DISABLED`, and [Update user flags](#update-user-flags) can remove the bit like any other.
+
+### Side effects
+
+The account is marked with `DISABLED_SUSPICIOUS_ACTIVITY`, its suspicious activity flags are replaced, and its password hash is set to null. Every authentication session is then deleted, so the account is signed out on every device.
+
+Fluxer records a `disabled_suspicious` risk outcome, together with a `challenged` outcome when the submitted `flags` is non-zero. The account holder is emailed when the account has an email address.
+
+[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted, so no connection of the account remains to receive it. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `disable_suspicious_activity`, target type `user`, and a metadata key `flags`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Ban user
+
+
+
+Disables an account temporarily or permanently and returns the resulting account. Requires `user:temp_ban`.
+
+:::note[A permanent ban is a distant expiry]
+Both modes add the `DISABLED` [account flag](#account-flags) and set `temp_banned_until`, a permanent ban to 100 years out. The field cannot tell the modes apart, and [Unban user](#unban-user) clears either one.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| duration_hours1 | integer | Whole-hour ban length, where `0` bans permanently (0-8760) |
+| reason?2 | string | Statement of reasons sent to the banned account holder (at most 512 characters) |
+
+1 Required. The expiry is computed from the moment the ban is applied, so no absolute-expiry field is accepted. The 8760 ceiling is one year
+
+2 Included in the temporary ban email and in the audit entry. It is not stored on the account, so no read operation returns it
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The account was banned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+:::caution[The ban signs the account out everywhere]
+Every authentication session of the account is deleted. [Unban user](#unban-user) restores account access, but it does not restore any deleted session.
+:::
+
+### Side effects
+
+`DISABLED` is added to the account flags and `temp_banned_until` is set to the resolved expiry. Every authentication session is then deleted, so the account is signed out on every device.
+
+An authentication attempt while the ban stands fails with 403 `ACCOUNT_SUSPENDED_TEMPORARILY`. An attempt after the expiry has passed clears the disabled state and `temp_banned_until` in the same request, so a temporary ban lifts itself without an Admin operation.
+
+When the account has an email address and `duration_hours` is greater than zero, the account holder is emailed the duration, the expiry, and the supplied `reason`. A permanent ban sends no email.
+
+[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted, so no connection of the account remains to receive it. `DISABLED` is not a public account flag, and no other account observes the change.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `temp_ban`, target type `user`, and the metadata keys `duration_hours`, `reason`, and `banned_until`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Unban user
+
+
+
+Clears the account ban and returns the resulting account. Requires `user:temp_ban`.
+
+:::caution[The audit reason reaches the account holder]
+The notification email quotes the `X-Audit-Log-Reason` value verbatim as the reason the ban was lifted, and falls back to the literal text `administrative action` when the header is absent or resolves to null.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | The ban was cleared |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The operation checks no precondition. On an account that was never banned it writes `temp_banned_until` as null, clears `DISABLED`, and still emails the account holder
+
+### Side effects
+
+`temp_banned_until` is cleared and `DISABLED` is removed from the account flags. `DISABLED_SUSPICIOUS_ACTIVITY` is a different flag and is not cleared, so an account disabled by [Disable user for suspicious activity](#disable-user-for-suspicious-activity) stays disabled.
+
+Deleted sessions are not restored. The account holder is emailed when the account has an email address. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `unban`, target type `user`, and no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Schedule user deletion
+
+
+
+Creates or replaces a recoverable account deletion schedule and returns the resulting account. Requires `user:delete`.
+
+Fluxer raises the submitted delay to the minimum for the [deletion reason](#deletion-reasons), so a request for one day under any reason other than `USER_REQUESTED` is stored as 60 days.
+
+The `X-Audit-Log-Reason` value is also stored on the account as the private deletion reason and reused as the blocklist reason for the identifier bans below.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| reason_code1 | integer | [Deletion reason](#deletion-reasons) |
+| public_reason?2 | string | Statement of reasons shown to the account holder (at most 512 characters) |
+| days_until_deletion?3 | integer | Requested whole-day delay (1-365, default 60) |
+
+1 Required, and validated against the [deletion reason](#deletion-reasons) registry. `USER_REQUESTED` selects the 14-day minimum, and every other value selects the 60-day minimum
+
+2 Stored on the account and included in the deletion email. Omitting it stores null
+
+3 The stored deadline is the request instant plus the greater of this value and the minimum for the reason code. There is no absolute-timestamp field
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The deletion schedule was stored |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+The operation accepts the acting Admin's own account and compares no ACL sets, so an Admin holding `user:delete` can schedule the deletion of a stronger Admin. Sending the operation again replaces the stored schedule outright.
+
+### Side effects
+
+`DELETED` is added to the account flags, and `pending_deletion_at`, `deletion_reason_code`, `deletion_public_reason`, and the private deletion audit reason are stored. The account is queued for final deletion at the stored deadline, replacing any previous queue entry. Every authentication session is deleted.
+
+Fluxer cancels a Stripe subscription on the account without proration and refunds the charge behind its latest invoice as fraudulent. A failure in that path is logged, and the deletion still applies.
+
+The account holder is emailed the deadline and the supplied `public_reason` when the account has an email address.
+
+For every reason code other than `USER_REQUESTED`, two additional enforcement passes run. The first adds the account's email address to the email blocklist and marks its last active address, authorised addresses, live session addresses, and session tombstone addresses as suspicious IPs.
+
+The second resolves every pending report against the account, in pages of 100, and notifies each reporter through the ordinary [report](/admin-api/reports/) path. It runs only on an instance with a report search backend. Both passes log a failure and continue, so the request still succeeds.
+
+[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days` and `reason_code`. The identifier bans record their own blocklist entries, and a non-zero report pass records a second entry with action `auto_resolve_reports_on_deletion` and a metadata key `resolved_count`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Cancel user deletion
+
+
+
+Clears a pending deletion and returns the resulting account. Requires `user:delete`.
+
+:::caution[Cancellation does not undo enforcement]
+Clearing the deadline permits the account to authenticate again, but it does not restore deleted sessions, reinstate a cancelled subscription, lift the email and IP blocklist entries the schedule wrote, or reopen the reports it resolved.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | The deletion was cancelled |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The operation checks no precondition and never returns `NO_PENDING_DELETION`. An account with no pending deletion is accepted and has the deletion fields written as null
+
+### Side effects
+
+`DELETED` and `SELF_DELETED` are both removed from the account flags, and `pending_deletion_at`, `deletion_reason_code`, `deletion_public_reason`, and the private deletion audit reason are cleared. The queued final deletion is withdrawn.
+
+The account holder is emailed when the account has an email address. The email quotes the `X-Audit-Log-Reason` value verbatim and falls back to the literal text `deletion canceled` when the header is absent or resolves to null.
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `cancel_deletion`, target type `user`, and no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## List user guilds
+
+
+
+Lists the guilds the account is a member of, in ascending guild ID order. Requires `user:list:guilds`. Both cursors apply to the account's own sorted membership list.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| before?1 | snowflake | Return guilds ordered before this guild ID |
+| after?1 | snowflake | Return guilds ordered after this guild ID |
+| limit? | integer | Maximum guilds to return (1-200, default 200) |
+| with_counts?2 | boolean | Whether to resolve live member and presence counts (default false) |
+
+1 A cursor naming a guild the account is not a member of is ignored, and the request still succeeds. `after` is applied before `before`, and the two combine to bound a window
+
+2 Only the exact strings `true`, `True`, and `1` count as true
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guilds1 | array[[Admin guild](/admin-api/guilds/#admin-guild-object) object] | The guilds in this page that the account is a member of |
+
+1 `approximate_member_count` and `approximate_presence_count` are present only when `with_counts` resolved to true. `owner_username`, `owner_global_name`, and `owner_discriminator` are resolved by this operation and are null only for an owner account that no longer exists
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The membership page was returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+The response has no paging state. Derive the next cursor from the last returned guild ID, and treat a short page as the end of the list.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## List user direct message channels
+
+
+
+Lists the direct message channels the account has taken part in. Requires `user:list:dm_channels`. The read does not open, reopen, or acknowledge a channel for the target account.
+
+:::note[The two channel classes are separate reads]
+`type` selects which channel class is returned. The default returns one-to-one direct message channels, and group direct messages need a second request with `type=group_dm`.
+:::
+
+The group branch ignores `before`, `after`, and `limit`, and returns every group the account is a recipient of in one response.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type? | string | Channel class, either `dm` or `group_dm` (default `dm`) |
+| before?1 | snowflake | Return channels with IDs lower than this channel ID |
+| after?1 | snowflake | Return channels with IDs higher than this channel ID |
+| limit? | integer | Maximum channels to return (1-200, default 50) |
+
+1 Mutually exclusive. Supplying both fails validation even when `type` is `group_dm` and neither would be used
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channels1 | array[[Admin direct message channel](#admin-direct-message-channel-object) object] | The channels in this page |
+
+1 Bounded at 200 entries for `dm` and at 500 entries for `group_dm`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The channel page was returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+The response has no paging state. Derive the next cursor from the last returned channel ID.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## List user applications
+
+
+
+Lists the OAuth2 applications the account owns. Requires `application:lookup` or `application:list:by_owner`, the same pair [List applications](/admin-api/applications/#list-applications) accepts.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the owning account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| applications1 | array[[Admin application](/admin-api/applications/#admin-application-object) object] | The applications the account owns |
+
+1 Returned in full in one response. The operation accepts no query string parameters and defines no cursor
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The applications were returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## List user relationships
+
+
+
+Lists the friends, friend requests, and blocked accounts of the account, split into one array for each [relationship category](#relationship-categories). Requires `user:list:relationships`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| friends1 | array[[Admin relationship](#admin-relationship-object) object] | The mutual friendships of the account |
+| incoming_requests1 | array[[Admin relationship](#admin-relationship-object) object] | The friend requests the account has received |
+| outgoing_requests1 | array[[Admin relationship](#admin-relationship-object) object] | The friend requests the account has sent |
+| blocked1 | array[[Admin relationship](#admin-relationship-object) object] | The accounts this account has blocked |
+
+1 A stored [relationship type](/http-api/users/relationships/#relationship-types) outside the four categories is dropped from the response
+
+The operation accepts no cursor, category filter, or limit, and returns every category in full.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The relationships were returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Clear user relationships
+
+
+
+Removes every relationship of the account in one [relationship category](#relationship-categories) and returns the removed count. Requires `user:remove:relationship`.
+
+`category` is required, so there is no request shape that clears every category at once.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| category1 | string | [Relationship category](#relationship-categories) to clear |
+
+1 Required. There is no confirmation parameter and no dry-run mode
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| removed_count1 | integer | The number of relationships that were removed |
+
+1 Counted from the target account's perspective, so a mirrored friendship contributes one
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | Matching relationships were removed |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 A category with no matching relationship succeeds with a `removed_count` of zero, and still records the audit entry
+
+Removals run one at a time, so a failure partway through leaves the earlier removals applied and answers 500.
+
+### Side effects
+
+Each matching row is deleted. A `friend`, `incoming_request`, or `outgoing_request` also deletes the mirror row on the other account, while `blocked` deletes only the one row.
+
+Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the target account, and ordinary delivery from the unblocked account resumes.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `remove_relationships_by_category`, target type `user`, and the metadata keys `category` and `removed_count`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Remove user relationship
+
+
+
+Removes one relationship row in one category. Requires `user:remove:relationship`. Returns an empty 204 response.
+
+The operation addresses one category, so an account that is both a former friend and currently blocked needs two requests.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the account that owns the relationship |
+| target_user_id | snowflake | The ID of the other account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| category | string | [Relationship category](#relationship-categories) to remove |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The relationship was removed |
+| 4041 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The same code covers an owning account that does not exist and a relationship that does not exist in the named category, so a 404 does not distinguish the two
+
+### Side effects
+
+The row is deleted, and a `friend`, `incoming_request`, or `outgoing_request` also deletes the mirror row on the other account.
+
+Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the owning account.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `remove_relationship`, target type `user`, and the metadata keys `target_user_id` and `category`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## List user sessions
+
+
+
+Lists the authentication sessions of the account, including the tombstones of terminated ones. Requires `user:list:sessions`. IP, reverse DNS, and location fields additionally require `user:view:ip`.
+
+:::caution[Listing sessions writes an audit entry]
+The `X-Audit-Log-Reason` header value is stored on the recorded entry.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sessions1 | array[[Admin user session](#admin-user-session-object) object] | The active and terminated sessions of the account |
+
+1 Active sessions are listed before terminated ones, and each group is ordered by `created_at` descending. The operation accepts no cursor or limit and returns the complete list in one response
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The sessions were returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+| 5031 | [error response](/admin-api/#error-response) | `SERVICE_UNAVAILABLE` when at least one session exists and every location lookup fails |
+
+1 Reachable only with `user:view:ip`, because no lookup is attempted without it. The audit entry is written after the lookups, so a request that fails this way records nothing
+
+Without `user:view:ip` the two derived fields are null and `client_ip` is the literal string `[redacted]`, so a redacted address is distinguishable from a failed lookup. With it, a partial reverse DNS or location failure yields null for that field alone.
+
+### Side effects
+
+This read does not mutate session state and emits no Gateway Dispatch. It performs outbound reverse DNS and geolocation lookups for each session address when the caller holds `user:view:ip`.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_user_sessions`, target type `user`, and a metadata key `session_count` counting active and terminated sessions together.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket. The other lists on this resource use the `admin:lookup` read bucket.
+
+## Terminate user sessions
+
+
+
+Terminates every active authentication session of the account. Requires `user:update:flags`, the same ACL as [Update user flags](#update-user-flags).
+
+There is no operation that revokes one session. The account's Admin API keys, bot token, and OAuth2 tokens are separate credentials and are not affected.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | empty2 | Active sessions were terminated |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 An account with no live session is accepted and still records the audit entry
+
+2 The handler emits no body at all, so a client MUST NOT read the `terminated_count` member the spec declares
+
+:::caution[A terminated session cannot be restored]
+The tombstone stays visible through [List user sessions](#list-user-sessions), but the session itself is gone.
+:::
+
+### Side effects
+
+Every active session is deleted and a termination tombstone is written for each, so the terminated sessions remain listed with a non-null `deleted_at`. Every affected client is disconnected and must authenticate again.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `terminate_sessions`, target type `user`, and no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## List user WebAuthn credentials
+
+
+
+Lists the passkeys and security keys registered on the account, as a bare JSON array. Requires `user:update:mfa`, the same ACL as [Disable user MFA](#disable-user-mfa).
+
+Like [List user sessions](#list-user-sessions), this read records an audit entry and stores the `X-Audit-Log-Reason` value on it.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | array[[WebAuthn credential](/http-api/users/mfa/#webauthn-credential-object) object] | The credentials were returned |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The body is a bare array rather than an object. An account with no credential receives an empty array
+
+The complete credential set is returned in one response, bounded by the ten credentials an account may register. No public key, attestation object, or signature counter is exposed.
+
+### Side effects
+
+This read does not mutate authenticator state and emits no Gateway Dispatch. It records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_webauthn_credentials`, target type `user`, and a metadata key `credential_count`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Delete user WebAuthn credential
+
+
+
+Deletes one passkey or security key from the account. Requires `user:update:mfa`. Returns an empty 204 response.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+| credential_id | string | WebAuthn credential ID (1-512 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The credential was deleted |
+| 4041 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 A credential that does not exist on the account also answers `UNKNOWN_USER` rather than `UNKNOWN_WEBAUTHN_CREDENTIAL`, so a 404 does not distinguish an unknown account from an unknown credential
+
+:::caution[A deleted passkey cannot be restored]
+The passkey or security key stops authenticating immediately and cannot be restored from its deleted record. The account holder must register it again to use it later.
+:::
+
+### Side effects
+
+The credential record is deleted. When it was the account's final WebAuthn credential, Fluxer removes the `WEBAUTHN` [authenticator type](/http-api/users/#authenticator-types) from the account, emits [User Update](/gateway/events/#user-update) to the account's own sessions, and resynchronises the authenticator mirror of every bot the account owns.
+
+[WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) is emitted to the target account with its remaining credentials.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `delete_webauthn_credential`, target type `user`, and a metadata key `credential_id`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Disable user MFA
+
+
+
+Clears the account's TOTP authenticator, its registered authenticator type set, and its backup codes. Requires `user:update:mfa`. Returns an empty 204 response.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | MFA state was cleared |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+:::caution[Stored WebAuthn credentials survive this operation]
+Clearing the authenticator type set removes `WEBAUTHN` from the advertised authenticators, but the credential records remain and no [WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) is emitted. Remove those with [Delete user WebAuthn credential](#delete-user-webauthn-credential).
+:::
+
+### Side effects
+
+The account's TOTP secret, authenticator type set, and every multi-factor backup code are deleted. Fluxer resynchronises the authenticator mirror of every bot the account owns. Sessions and credentials are not revoked.
+
+[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `disable_mfa`, target type `user`, and no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Delete all user messages
+
+
+
+Counts every message attributed to the account, and queues their deletion when the request is not a dry run. Requires `message:delete_all`.
+
+The operation walks the account's messages in pages of 200 before it answers, so the request time grows with the number of messages the account has sent. A misspelled `dry_run` parameter leaves the request a dry run.
+
+The operation does not resolve the target account, so it never answers `UNKNOWN_USER`. An ID with no matching account is accepted and matches no message.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the message author |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| dry_run?1 | string | Whether the operation only counts matching messages (default `true`) |
+
+1 Whitespace is trimmed and only the exact values `false`, `False`, and `0` count as false. Every other string, including `no` and `FALSE`, counts as true
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Whether the request succeeded, always true |
+| dry_run | boolean | Whether the request only counted messages |
+| channel_count1 | integer | The number of channels the account has messages in |
+| message_count | integer | The number of messages the walk matched |
+| job_id?2 | string | The ID of the queued shred job |
+
+1 Summed for each page of 200, so a channel spanning pages is counted once per page. Treat it as an upper bound
+
+2 Present only when `dry_run` resolved to false and `message_count` is above zero
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The count completed, and the deletion job was queued when applicable |
+
+:::danger[A non-dry run permanently deletes messages]
+The response only confirms that the job was queued. When `dry_run` resolves to false, the background job deletes every matching message, and no operation restores them.
+:::
+
+The queued job is an ordinary shred job, so its progress, totals, skips, and failures are read through [Get message shred job](/admin-api/messages/#get-message-shred-job).
+
+### Side effects
+
+A dry run reads and counts only. A deletion run additionally queues one shred job with every matched message identity, and the request returns before any message is processed.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with target type `message_deletion`, the target account ID as its target ID, and the metadata keys `user_id`, `channel_count`, `message_count`, and `dry_run`. The action is `delete_all_user_messages_dry_run` for a dry run and `delete_all_user_messages` otherwise. A deletion run additionally records the `queue_message_shred` entry described under [Shred user messages](#shred-user-messages).
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:message:operation` bucket.
+
+## Shred user messages
+
+
+
+Queues irreversible deletion of an explicit list of message identities and returns a job ID read through [Get message shred job](/admin-api/messages/#get-message-shred-job). Requires `message:shred`.
+
+The operation does not resolve the target account, so it never answers `UNKNOWN_USER`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the message author |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| entries1 | array[[message shred entry](#message-shred-entry-object) object] | Explicit message identities, with 1 to 1000 values |
+
+1 Required and non-empty. The entries are not checked against the target account at submission time
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Whether the request succeeded, always true |
+| job_id | string | The ID of the queued shred job |
+| requested1 | integer | The number of submitted entries |
+
+1 An upper bound on the messages the job shreds
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The shred job was queued |
+
+:::danger[Shredding deletes messages permanently]
+A shred deletes every matching message with its reactions, purges the attachments it owned, and removes it from search. Only a preceding [Create user archive](#create-user-archive) retains the original text.
+:::
+
+The job is queued with a single attempt, so a worker failure does not retry it.
+
+### Side effects
+
+The request returns before any message is processed. The job checks each referenced message again before it deletes it, and skips a missing message or one no longer authored by the target account.
+
+The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `queue_message_shred`, target type `message_shred`, the target account ID as its target ID, and the metadata keys `user_id`, `job_id`, and `requested_entries`.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:message:operation` bucket.
+
+## Cancel scheduled message deletion
+
+
+
+Cancels the bulk message deletion the account holder scheduled for itself and returns the resulting account. Requires `user:cancel:bulk_message_deletion`.
+
+[Delete all user messages](#delete-all-user-messages) queues a separate Admin job over the same messages, and cancelling this schedule leaves that job running.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [Admin user](#admin-user-object) object | The resulting account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | The scheduled deletion was cancelled, or the account had none |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
+
+1 The operation is idempotent. Calling it on an account with no scheduled deletion succeeds, returns the account unchanged, and still records the audit entry
+
+### Side effects
+
+`pending_bulk_message_deletion_at` is cleared together with the stored channel and message counts, and the account is removed from the bulk deletion queue, so no further message is deleted. Messages already deleted by an earlier run are not restored.
+
+No [User Update](/gateway/events/#user-update) is emitted, unlike every other mutating user operation on this page, so a connected client learns of the cancellation only on its next read. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `cancel_bulk_message_deletion`, target type `user`, and no metadata.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
+
+## Create user archive
+
+
+
+Creates an asynchronous archive of the account's data and returns the created [archive](/admin-api/archives/#archive-object) object. Requires `archive:trigger:user`.
+
+Archive progress, download, and expiry are documented under [Archives](/admin-api/archives/).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the archive subject |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| include_attachments?1 | boolean | Whether uploaded files are included (default false) |
+
+1 Including attachment binaries makes the archive larger and slower to build, and only an archive that includes them retains the original uploaded media
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | [archive](/admin-api/archives/#archive-object) object | The archive was queued |
+| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, and no archive record or build job is created |
+
+1 The status is 200 rather than 201, and the returned record is already stored with `progress_percent` at zero and `progress_step` set to `Queued`
+
+### Side effects
+
+Fluxer creates the archive record and queues a build job, so the response returns before any data is collected. Generating the archive reads the account's data without mutating it.
+
+No Gateway Dispatch is emitted.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## List user change log
+
+
+
+Lists the identity and contact field changes recorded for the account, newest first. Requires `user:view:contact_log`. Email values additionally require `user:view:email`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Maximum entries to return (1-200, default 50) |
+| page_token?1 | string | Cursor returned as `next_page_token` by the previous page (1-64 characters) |
+
+1 Continues the newest-first walk, so it returns entries recorded before the cursor
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| entries1 | array[[Admin user change log](#admin-user-change-log-object) object] | The identity and contact changes in this page |
+| next_page_token2 | ?string | The cursor to send as `page_token`, or null when this is the last page |
+
+1 Bounded at 200 entries, which is also the maximum `limit`
+
+2 Set only when the page returned exactly `limit` entries, so a final page that happens to be full reports a cursor whose next page is empty
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | response body | The change page was returned |
+
+1 This operation does not resolve the account, so it never answers `UNKNOWN_USER`. An ID with no matching account returns an empty page
+
+Without `user:view:email`, an `email` entry is still returned, with both value fields replaced by the literal string `[redacted]`, so the fact and the time of the change remain visible while the addresses do not.
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
diff --git a/fluxer_docs/src/content/docs/admin-api/voice.mdx b/fluxer_docs/src/content/docs/admin-api/voice.mdx
new file mode 100644
index 000000000..302200eae
--- /dev/null
+++ b/fluxer_docs/src/content/docs/admin-api/voice.mdx
@@ -0,0 +1,581 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Admin voice
+description: Voice regions, voice servers, placement eligibility, and topology publication.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A voice region is a named group of media machines, and a voice server is one machine registered inside it. Together they are the topology [Voice](/voice/) places a session into when it joins a voice channel or a call.
+
+No operation here addresses a live session, a participant, or a track. [Get voice state counts](/admin-api/gateway/#get-voice-state-counts) reports occupancy, and [List RTC regions](/http-api/channels/#list-rtc-regions) is the caller-facing view of the same regions.
+
+:::note[A change decides placements made after the reload]
+Every write takes effect once each API node reloads its topology. No write moves, disconnects, or re-places a live session.
+:::
+
+:::caution[Each write runs in three separate steps]
+It stores the record, notifies every node to reload, then records the audit entry. A failure at the second or third step answers 500 with the record already stored, so read the record back before retrying.
+:::
+
+## Media transport
+
+[LiveKit](https://livekit.io) is the media transport. Each voice server record names one LiveKit deployment and the API key pair the instance authenticates to it with.
+
+`endpoint` is that deployment's signalling URL, a `ws://` or `wss://` address. Fluxer hands it to the placed session verbatim as the `endpoint` of [Voice Server Update](/gateway/events/#voice-server-update), and the client opens its media connection there. The API rewrites the same value to `http://` or `https://` for its own room service calls, and it keeps any path prefix.
+
+`api_key` and `api_secret` mint the access token the placed session presents. The token is valid for 600 seconds, grants room admission to exactly one room, and has the track sources the member's permissions allow.
+
+A room is one voice channel. A guild channel uses the room name `guild_{guild_id}_channel_{channel_id}` and a private call uses `dm_channel_{channel_id}`.
+
+A participant is one voice connection, identified as `user_{user_id}_{connection_id}`. One account holding several connections in a channel is several participants. Going live publishes a screen share track from the participant that already exists, so a stream needs no second server, room, or grant.
+
+## Placement eligibility
+
+Four stored fields decide whether a region or a server can be chosen for one placement. Fluxer evaluates the four identically on both records, and it also skips a server whenever `is_active` is false. It resolves a region first and then a server inside it, so a caller admitted to a region whose servers all refuse it has no access to that region.
+
+`allowed_user_ids` is evaluated first and on its own. A non-empty list admits only the accounts it names and refuses every other caller whatever the remaining three fields say. An empty list gates nothing.
+
+`vip_only`, `required_guild_features`, and `allowed_guild_ids` are guild gates. When none of the three is set, the record admits every caller that passed the user gate, including a private call, which has no guild. When any of the three is set, the record admits only a placement that has a guild, so a restricted region or server is never selectable for a [call](/http-api/calls/#modify-call-region) or a group direct message.
+
+With a guild present, a guild named by `allowed_guild_ids` is admitted at once and the other two gates are not consulted. Otherwise `vip_only` requires the guild to hold the `VIP_VOICE` [guild feature](/http-api/guilds/#guild-features), and `required_guild_features` requires the guild to hold at least one of the features it names. A guild holding none of them is refused.
+
+## Admin voice region object
+
+A region record has the identity a client sees, the coordinate placement measures distance from, and the eligibility fields described under [placement eligibility](#placement-eligibility). It has no capacity, no health, and no server count.
+
+The operator supplies `id` on creation, and it is the primary key. It is the value a channel stores as its `rtc_region` and the value [Modify call region](/http-api/calls/#modify-call-region) accepts, so changing it means creating a new region and deleting the old one.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The ID of the region, chosen by the operator and never renamed (1-64 characters) |
+| name | string | The name shown for the region in a client (1-100 characters) |
+| emoji | string | The emoji the operator configured for the region (1-64 characters) |
+| latitude | number | The latitude placement measures distance from, in decimal degrees |
+| longitude | number | The longitude placement measures distance from, in decimal degrees |
+| is_default1 | boolean | Whether automatic placement falls back to this region |
+| vip_only2 | boolean | Whether the guild has to hold `VIP_VOICE` |
+| required_guild_features2 | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild, any one of which suffices (max 100) |
+| allowed_guild_ids23 | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000) |
+| allowed_user_ids3 | array[snowflake] | The accounts allowed to use the region at all (max 1000) |
+| created_at | ?ISO8601 timestamp | Time the region record was created, or null when the stored row has none |
+| updated_at | ?ISO8601 timestamp | Time the region record last changed, or null when the stored row has none |
+| servers?4 | array[[Admin voice server](#admin-voice-server-object) object] | The servers registered in the region |
+
+1 The flag is not exclusive, and setting it on a second region does not clear it on the first. Each node then treats the first region its reload enumerates as the default, and a topology with the flag on no region falls back the same way
+
+2 Setting any of these three makes the region unusable for a private call
+
+3 Duplicate entries collapse and the returned order is not the submitted order
+
+4 Present only on [List voice regions](#list-voice-regions) and [Get voice region](#get-voice-region), and only when `include_servers` resolved to true. [List voice regions](#list-voice-regions) sorts the array by server identifier, and [Get voice region](#get-voice-region) returns it in the order the store yields
+
+### Example
+
+```json
+{
+ "id": "europe-north",
+ "name": "Northern Europe",
+ "emoji": "🇸🇪",
+ "latitude": 59.33,
+ "longitude": 18.06,
+ "is_default": true,
+ "vip_only": false,
+ "required_guild_features": [],
+ "allowed_guild_ids": [],
+ "allowed_user_ids": [],
+ "created_at": "2026-02-11T08:14:00.000Z",
+ "updated_at": "2026-08-02T17:45:31.000Z"
+}
+```
+
+## Admin voice server object
+
+A server record names one LiveKit deployment, the API key pair the instance authenticates to it with, and its own copy of the eligibility fields. A server is reachable for placement only when its region is also reachable.
+
+The `region_id` and `server_id` pair addresses one server, and a server belongs to exactly one region. The same server identifier can exist in two regions, and moving a server between regions means deleting it and recreating it. Both are operator-chosen strings of 1 to 64 characters, and no operation renames either one.
+
+A server can have its own coordinate. When it does, placement measures distance from that coordinate to pick the closest server for an automatically placed session. When it does not, the server takes no part in distance comparison.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the server belongs to (1-64 characters) |
+| server_id | string | The ID of the server, unique inside its region (1-64 characters) |
+| endpoint1 | string | The LiveKit signalling URL the client opens its media connection to |
+| latitude2 | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
+| longitude2 | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
+| is_active3 | boolean | Whether the server is in rotation for new placement |
+| vip_only | boolean | Whether the guild has to hold `VIP_VOICE` |
+| required_guild_features | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild, any one of which suffices (max 100) |
+| allowed_guild_ids4 | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000) |
+| allowed_user_ids4 | array[snowflake] | The accounts allowed to use the server at all (max 1000) |
+| created_at | ?ISO8601 timestamp | Time the server record was created, or null when the stored row has none |
+| updated_at | ?ISO8601 timestamp | Time the server record last changed, or null when the stored row has none |
+
+1 An instance can configure an internal URL that the API uses for its own room service calls to one designated server, and that URL changes neither the stored value nor the value the session receives
+
+2 The two coordinates are set and cleared together, and a server with only one of them cannot be stored
+
+3 Fluxer skips an inactive server when it resolves a new placement. Server-side moderation of a session already on it keeps working
+
+4 Duplicate entries collapse and the returned order is not the submitted order
+
+:::caution[Server credentials are never returned]
+`api_key` and `api_secret` are stored on the record and accepted by the create and update bodies, but no read returns them. A lost secret has to be replaced with an update.
+:::
+
+### Example
+
+```json
+{
+ "region_id": "europe-north",
+ "server_id": "europe-north-server-1",
+ "endpoint": "wss://voice.example.com/livekit",
+ "latitude": null,
+ "longitude": null,
+ "is_active": true,
+ "vip_only": false,
+ "required_guild_features": [],
+ "allowed_guild_ids": [],
+ "allowed_user_ids": [],
+ "created_at": "2026-02-11T08:15:22.000Z",
+ "updated_at": "2026-02-11T08:15:22.000Z"
+}
+```
+
+## List voice regions
+
+
+
+Returns every [Admin voice region](#admin-voice-region-object) object in ascending display name order. Requires `voice:region:list`.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| include_servers?1 | boolean | Whether each region has its servers, sorted by server identifier (default false) |
+
+1 The value is read as a string and counts as true only for `true`, `True`, or `1`, so any other value, including `yes` and `TRUE`, is false. Supplying the parameter twice makes it an array, which fails validation
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| regions | array[[Admin voice region](#admin-voice-region-object) object] | Every configured region |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The regions were returned |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get voice region
+
+
+
+Returns one [Admin voice region](#admin-voice-region-object) object. Requires `voice:region:list`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region (1-64 characters) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| include_servers?12 | boolean | Whether the region has its servers (default true) |
+
+1 The default is true here and false on [List voice regions](#list-voice-regions)
+
+2 The value is read as a string and counts as true only for `true`, `True`, or `1`, so any other value suppresses the servers
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region1 | ?[Admin voice region](#admin-voice-region-object) object | The region, or null when no region has the identifier |
+
+1 An unknown region answers 200 with a null region, so region existence is read from the body
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | A lookup was performed, whether or not it resolved |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Create voice region
+
+
+
+Stores a region and returns it. Requires `voice:region:create`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | The ID to store the region under (1-64 characters) |
+| name | string | The name shown for the region in a client (1-100 characters) |
+| emoji | string | The emoji the operator configured for the region (1-64 characters) |
+| latitude | number | The latitude placement measures distance from, in decimal degrees |
+| longitude | number | The longitude placement measures distance from, in decimal degrees |
+| is_default? | boolean | Whether automatic placement falls back to this region (default false) |
+| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` (default false) |
+| required_guild_features?2 | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items, default empty) |
+| allowed_guild_ids? | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000, default empty) |
+| allowed_user_ids? | array[snowflake] | The accounts allowed to use the region at all (max 1000, default empty) |
+
+1 The identifier is not checked for collision. Reusing the identifier of an existing region overwrites that record in full, resets its creation time to now, and replaces every one of its collections
+
+2 Each item is 1 to 64 characters. A value that is not a real guild feature is stored as supplied and then matches no guild
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region1 | [Admin voice region](#admin-voice-region-object) object | The stored region |
+
+1 The object has no `servers` member
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The region was stored |
+
+### Side effects
+
+Each node reloads its topology after the write. One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `create_voice_region` and the target type `voice_region` records the region identifier and its name in metadata, together with the audit reason.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Update voice region
+
+
+
+Applies a partial update to a region and returns the updated record. Requires `voice:region:update`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region (1-64 characters) |
+
+### JSON body
+
+Every field is optional and an omitted field is left unchanged. An absent, empty, or whitespace-only body is read as an empty object, which validates and updates nothing but `updated_at`. A body that does not parse as JSON returns 400 `INVALID_FORM_BODY` with one element at path `body` and code `INVALID_FORMAT`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | string | The name shown for the region in a client (1-100 characters) |
+| emoji? | string | The emoji the operator configured for the region (1-64 characters) |
+| latitude? | number | The latitude placement measures distance from, in decimal degrees |
+| longitude? | number | The longitude placement measures distance from, in decimal degrees |
+| is_default? | boolean | Whether automatic placement falls back to this region |
+| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` |
+| required_guild_features?1 | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each) |
+| allowed_guild_ids?1 | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000) |
+| allowed_user_ids?1 | array[snowflake] | The accounts allowed to use the region at all (max 1000) |
+
+1 A supplied collection replaces the stored collection outright, so removing one entry means sending the complete remaining set and clearing a collection means sending an empty array
+
+The body has no identifier. An `id` member in the body is overwritten from the path and never renames the region.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region1 | [Admin voice region](#admin-voice-region-object) object | The updated region |
+
+1 The object has no `servers` member. Read the servers with [List voice servers](#list-voice-servers) or [Get voice region](#get-voice-region)
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The region was updated |
+| 404 | [error response](/admin-api/#error-response) | No region has the identifier, returned as `UNKNOWN_VOICE_REGION` |
+
+### Side effects
+
+The write sets `updated_at` to the time it ran, and each node reloads its topology. One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `update_voice_region` and the target type `voice_region` records the region identifier in metadata, together with the audit reason. The entry has no field diff.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Delete voice region
+
+
+
+Deletes a region and every server registered in it. Requires `voice:region:delete`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region (1-64 characters) |
+
+:::danger[Deleting a region deletes every server in it]
+The region record and every server record stored under it are removed in one batch. There is no confirmation step and no way to recover the server records, including their stored credentials, so read the servers back first if they have to be recreated.
+:::
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success1 | boolean | Always true |
+
+1 The value is fixed, so a client reads the status to learn whether the call succeeded
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The region was deleted |
+| 404 | [error response](/admin-api/#error-response) | No region has the identifier, returned as `UNKNOWN_VOICE_REGION` |
+
+### Side effects
+
+Each node reloads its topology once for the whole batch, and an operation that has to reach a deleted server afterwards fails. Sessions already placed in the region are not disconnected by the deletion itself.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `delete_voice_region` and the target type `voice_region` records the region identifier and its name in metadata, together with the audit reason. No `delete_voice_server` entry is written for the servers deleted with the region.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## List voice servers
+
+
+
+Returns every [Admin voice server](#admin-voice-server-object) object registered in one region. Requires `voice:server:list`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region (1-64 characters) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| servers1 | array[[Admin voice server](#admin-voice-server-object) object] | The servers registered in the region |
+
+1 A region with no server and an identifier naming no region both answer with an empty array
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The servers were returned |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Get voice server
+
+
+
+Returns one [Admin voice server](#admin-voice-server-object) object. Requires `voice:server:list`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the server belongs to (1-64 characters) |
+| server_id | string | The ID of the server (1-64 characters) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| server1 | ?[Admin voice server](#admin-voice-server-object) object | The server, or null when the pair resolves to nothing |
+
+1 An unknown server answers 200 with a null server, and an unknown region is reported the same way
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | A lookup was performed, whether or not it resolved |
+
+### Rate limit
+
+200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
+
+## Create voice server
+
+
+
+Registers a voice server in a region and returns it. Requires `voice:server:create`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the server belongs to (1-64 characters) |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| server_id1 | string | The ID to store the server under, unique inside the region (1-64 characters) |
+| endpoint2 | string | The LiveKit signalling URL the client opens its media connection to |
+| api_key | string | The LiveKit API key the instance authenticates with (1-256 characters) |
+| api_secret | string | The LiveKit API secret the instance authenticates with (1-256 characters) |
+| latitude?3 | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
+| longitude?3 | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
+| is_active? | boolean | Whether the server is in rotation for new placement (default true) |
+| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` (default false) |
+| required_guild_features? | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each, default empty) |
+| allowed_guild_ids? | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000, default empty) |
+| allowed_user_ids? | array[snowflake] | The accounts allowed to use the server at all (max 1000, default empty) |
+
+1 The pair of region and server identifier is not checked for collision. Reusing an existing pair overwrites that record in full, resets its creation time to now, and replaces both stored credentials
+
+2 The value has to parse as a URL. Validation does not constrain the scheme, and the client receives the value unchanged
+
+3 The two coordinates are supplied together and are either both null or both a number. A mismatched pair fails body validation on the `latitude` path
+
+The body has no `region_id`. A `region_id` member in the body is overwritten from the path and cannot register the server under a different region.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| server1 | [Admin voice server](#admin-voice-server-object) object | The stored server |
+
+1 Neither `api_key` nor `api_secret` appears on the object
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The server was stored |
+| 400 | [error response](/admin-api/#error-response) | A non-URL endpoint or an unpaired coordinate, returned as `INVALID_FORM_BODY` |
+| 404 | [error response](/admin-api/#error-response) | The identifier names no region, returned as `UNKNOWN_VOICE_REGION` |
+
+### Side effects
+
+Each node reloads its topology after the write. One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `create_voice_server` and the target type `voice_server` records the region identifier, the server identifier, and the endpoint in metadata, together with the audit reason. Neither credential is recorded.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Update voice server
+
+
+
+Applies a partial update to a voice server and returns the updated record. Requires `voice:server:update`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the server belongs to (1-64 characters) |
+| server_id | string | The ID of the server (1-64 characters) |
+
+### JSON body
+
+Every field is optional and an omitted field is left unchanged. An absent, empty, or whitespace-only body is read as an empty object, which validates and updates nothing but `updated_at`. A body that does not parse as JSON returns 400 `INVALID_FORM_BODY` with one element at path `body` and code `INVALID_FORMAT`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| endpoint? | string | The LiveKit signalling URL the client opens its media connection to, which has to parse as a URL |
+| api_key?1 | string | The LiveKit API key the instance authenticates with (1-256 characters) |
+| api_secret?1 | string | The LiveKit API secret the instance authenticates with (1-256 characters) |
+| latitude?2 | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
+| longitude?2 | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
+| is_active? | boolean | Whether the server is in rotation for new placement |
+| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` |
+| required_guild_features?3 | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each) |
+| allowed_guild_ids?3 | array[snowflake] | The guilds admitted without consulting the other two guild gates (max 1000) |
+| allowed_user_ids?3 | array[snowflake] | The accounts allowed to use the server at all (max 1000) |
+
+1 An omitted credential is left unchanged and a supplied one replaces it. An empty string fails the length bound, so a credential can be replaced but never cleared
+
+2 The two coordinates are supplied together and are either both null or both a number. Sending both as null clears the override and returns the server to its region coordinate
+
+3 A supplied collection replaces the stored collection outright, so removing one entry means sending the complete remaining set and clearing a collection means sending an empty array
+
+The body has no identifiers. A `region_id` or `server_id` member in the body is overwritten from the path and cannot move the server to another region.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| server | [Admin voice server](#admin-voice-server-object) object | The updated server |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The server was updated |
+| 404 | [error response](/admin-api/#error-response) | The pair resolves to no server, returned as `UNKNOWN_VOICE_SERVER` |
+
+### Side effects
+
+The write sets `updated_at` to the time it ran, and each node reloads its topology and uses the stored credentials for later calls to the server. Setting `is_active` to false stops new placement and does not move sessions already on the server.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `update_voice_server` and the target type `voice_server` records the region identifier and the server identifier in metadata, together with the audit reason. The entry records neither credential and no field diff, so a credential rotation is indistinguishable in the audit log from any other update.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
+
+## Delete voice server
+
+
+
+Deletes a voice server. Requires `voice:server:delete`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region_id | string | The ID of the region the server belongs to (1-64 characters) |
+| server_id | string | The ID of the server (1-64 characters) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success1 | boolean | Always true |
+
+1 The value is fixed, so a client reads the status to learn whether the call succeeded
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The server was deleted |
+| 404 | [error response](/admin-api/#error-response) | The pair resolves to no server, returned as `UNKNOWN_VOICE_SERVER` |
+
+### Side effects
+
+Each node reloads its topology after the removal, and server-side moderation of a session still on that server fails afterwards. Sessions already placed on the server are not disconnected by the deletion itself. The stored credentials are removed with the record and are not recoverable.
+
+One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `delete_voice_server` and the target type `voice_server` records the region identifier, the server identifier, and the endpoint the server held in metadata, together with the audit reason.
+
+### Rate limit
+
+100 requests per minute for each authenticated user, on the `admin:guild:modify` bucket.
diff --git a/fluxer_docs/src/content/docs/authentication.md b/fluxer_docs/src/content/docs/authentication.md
new file mode 100644
index 000000000..8d25e9a1d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/authentication.md
@@ -0,0 +1,233 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Authentication
+description: Credential syntax, token formats, authorisation outcomes, and sudo mode.
+---
+
+An authenticated request has one credential in the `Authorization` header. Fluxer accepts four kinds. A client acting for a person sends a user session token, an application's bot sends a bot token, a client acting on a user's behalf under OAuth2 sends an access token, and the [Admin API](/admin-api/) takes an Admin API key. The kind decides who Fluxer treats as the caller and which [authorisation policy](#authorisation-outcomes) the matched operation applies.
+
+Fluxer returns every failure named here in the standard [error response](/http-api/#error-response) envelope. The operations that issue and revoke credentials belong to the [Authentication HTTP API](/http-api/authentication/) and the [OAuth2 HTTP API](/http-api/oauth2/).
+
+:::caution[Credential namespaces are distinct]
+`Bot`, `Bearer`, and `Admin` select different validation paths. A bot token, OAuth2 access token, user session token, [upload capability](/media-proxy/upload-relay/), or [signed media path](/media-proxy/overview/) cannot substitute for another credential type.
+:::
+
+## Credential handling
+
+Every credential described here is a bearer secret, so whoever holds the value can act as its owner. A client MUST keep it out of logs, analytics, crash reports, source code, screenshots, and error messages, and MUST store it only while it needs the credential. A client MUST use TLS whenever the credential crosses a network it does not fully trust.
+
+An `Authorization` credential MUST NOT be copied into a URL. Webhook tokens, signed media paths, and relay capabilities are in their documented URLs, so a client treats each complete URL as secret until it expires or is revoked. Redirect targets, request traces, and referrer data MUST NOT disclose those URLs to an unrelated origin.
+
+## Authorization header
+
+The header value must have no leading or trailing whitespace. A padded value never authenticates. A value beginning with `Bot `, `Bearer `, or `Admin ` selects that scheme, and the remainder must be non-empty and must have no surrounding whitespace either. The three prefixes match exactly, so any other spelling is not recognised as a scheme.
+
+A value containing no space is parsed as a bare user session token. A value containing a space without a recognised scheme prefix is invalid.
+
+An invalid, unknown, or unresolvable credential leaves the request unauthenticated, and the matched operation's authorisation policy decides the outcome. An operation that requires a credential returns 401 `UNAUTHORIZED`.
+
+A user session token is sent bare, with no scheme prefix.
+
+```text
+Authorization: flx_ZDb1GURItsMuYl1zvrgxv2qLBxyNmgNSEaWT
+```
+
+The value after `Bot ` is the application's snowflake, a full stop, and the secret.
+
+```text
+Authorization: Bot 1501314428688998182.aDFplg-fYQ4of4I7dM-q9tRxkgmjslc2LSE_BTwsAUo
+```
+
+The value after `Bearer ` is an OAuth2 access token.
+
+```text
+Authorization: Bearer IE867jBd9L4M0_tGI8OUOppXVezR1u6x8Yj-Lduilxg
+```
+
+The value after `Admin ` is an Admin API key, which is read on a route below `/v1/admin` and nowhere else.
+
+```text
+Authorization: Admin fa_1508923117441703936_KaqkNax1BF3YSWHGkEPjDRKeO48jGb9F
+```
+
+### Authorisation schemes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| `Bot ` | Bot token | The token is `.`, accepted only while the application owns an active bot user and the secret is current |
+| `Bearer ` | OAuth2 access token1 | The token is resolved as an OAuth2 access token. Access is limited by its application, the account that granted it, and its scopes |
+| `flx_<36 alphanumeric characters>` | User session token | A header value containing no space is parsed as a user session token whatever its shape2 |
+| `Admin ` | Admin API key3 | The key is read only on a route below `/v1/admin`. Effective Admin ACLs are bounded by both the key and its owning user |
+
+1 One value is handled differently. `Bearer flx_` followed by 36 alphanumeric characters authenticates the user session it names
+
+2 A bare value that resolves to no live session leaves the request unauthenticated
+
+3 A valid key presented on any other route is ignored and the request stays unauthenticated
+
+## Token formats
+
+| Value | Name | Description |
+| --- | --- | --- |
+| `flx_<36 characters>` | User session token1 | The literal prefix `flx_` followed by exactly 36 characters drawn from ASCII letters and digits |
+| `.` | Bot token2 | The application's decimal snowflake, a single full stop, and the base64url secret |
+| `` | OAuth2 access token | A 43-character unpadded base64url secret presented with the `Bearer` scheme |
+| `fa__<32 characters>` | Admin API key3 | The literal prefix `fa_`, the key's decimal [snowflake](/snowflakes/), an underscore, and 32 characters drawn from ASCII letters and digits |
+
+1 The token is opaque and has no client-readable claims
+
+2 The identifier before the full stop selects which application record to check, and the secret after it is the only part that authorises the request
+
+3 A key whose identifier segment is not a decimal integer is invalid
+
+OAuth2 access tokens, OAuth2 refresh tokens, OAuth2 authorisation codes, bot token secrets, and application client secrets are 43-character unpadded base64url values.
+
+:::caution[A secret is shown once]
+A bot token, an Admin API key, and a client secret cannot be read back after the response that created them. The only retained fragment is the bot token preview, the first 8 characters of the secret.
+:::
+
+:::note[Rotation invalidates the previous value immediately]
+Rotation applies to a bot token and a client secret, and rotating a bot token also terminates every Gateway session the bot holds. An Admin API key is not rotated. It is revoked and replaced.
+:::
+
+## User session tokens
+
+A user session token authenticates an ordinary user account. It is issued by the login, registration, and session exchange operations documented in [Authentication](/http-api/authentication/). A token that does not identify a live session leaves the request unauthenticated.
+
+A session token is also the credential the Gateway [Identify](/gateway/commands/#identify) command accepts for a user session.
+
+The `Authorization` header holds a single credential. A [sudo mode](#sudo-mode) proof travels separately, in the `X-Fluxer-Sudo-Mode-JWT` header, and it proves that the already resolved account recently re-verified.
+
+## Bot tokens
+
+A bot token is the owning application's [snowflake](/snowflakes/), a single full stop, and a secret. It is valid only while the application has an active bot user and the secret is current.
+
+The Gateway accepts a bot token in [Identify](/gateway/commands/#identify), and so do [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) and [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application). Those two operations match the scheme prefix without regard to case. `GET /v1/applications/@me` requires the `Bot` prefix and returns 401 `INVALID_TOKEN` for anything else.
+
+A bot cannot use an operation restricted to ordinary user accounts, and such an operation returns 403 `ACCESS_DENIED`. An operation in [Authentication](/http-api/authentication/) that resolves an account from its request body or token, such as login, password recovery, email verification, email revert, and IP authorisation, returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED` when that account is a bot.
+
+## OAuth2 access tokens
+
+An OAuth2 access token is an unprefixed secret presented with the `Bearer` scheme. `Bearer` selects OAuth2 validation. One value is handled differently: `Bearer flx_` followed by 36 alphanumeric characters authenticates the user session it names.
+
+Every access token is bound to an account. The token operation implements the authorisation code grant and the refresh token grant alone.
+
+Fluxer admits a bearer credential only on an operation that explicitly opts in. On an operation that requires a user but has not opted in, a valid bearer credential returns 403 `ACCESS_DENIED`.
+
+An operation that opts in either requires the bearer credential outright or accepts a session credential and enforces the scope requirement only when the credential is a bearer token. An operation that requires the bearer credential outright and receives a valid session, bot, or Admin API key credential returns 401 `UNAUTHORIZED`.
+
+A missing scope returns 403 `MISSING_OAUTH_SCOPE`. An operation that names a scope names exactly one, and Fluxer matches that scope exactly against the set the token was granted. The [OAuth2 scope registry](/http-api/oauth2/#oauth2-scopes) is closed, and the authorisation code flow, token exchange, refresh, revocation, and introspection operations are specified in the [OAuth2 HTTP API](/http-api/oauth2/).
+
+### Missing OAuth2 scope body
+
+The response body has this member alongside `code` and `message`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| required_scope | string | The [OAuth2 scope](/http-api/oauth2/#oauth2-scopes) the request did not have |
+
+## Admin API keys
+
+An Admin API key is read only on a route below `/v1/admin`, and an unknown, expired, or invalid key leaves the request unauthenticated. A valid key authenticates as the user who created it and has the ACLs stored on the key.
+
+An Admin operation also accepts a user session token and an OAuth2 bearer token, and a bearer token is accepted only when it belongs to the built-in Admin OAuth2 application. A bearer token from any other application returns 403 `ACCESS_DENIED`. A request with a bot token returns 401 `UNAUTHORIZED`.
+
+On every Admin request the resolved user must hold the `admin:authenticate` ACL or the wildcard, and a user without either returns 403 `MISSING_PERMISSIONS`. A key-authenticated request is checked twice, and either failure returns 403 `MISSING_ACL`. The [Admin API](/admin-api/) hub defines the complete ACL registry, the evaluation modes, the double check, and the audit contract.
+
+## Authorisation outcomes
+
+An operation that requires a credential declares one authorisation policy. There are four.
+
+A user operation requires a resolved user and rejects an OAuth2 bearer credential it has not opted into. A user-only operation rejects a bot account as well. A bot operation accepts a bot token, which resolves the application's bot account as the request identity. An OAuth2 operation requires the `Bearer` scheme together with the scope it names. An Admin operation requires a session, Admin OAuth2 bearer, or Admin API key credential together with the required ACLs.
+
+No authorisation policy requires the `Bot` scheme itself. [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) is the only operation that requires the prefix.
+
+Fluxer still parses and resolves a credential sent to an operation that requires none. The resolved account keys the [rate limit](/topics/rate-limits/) buckets and can waive a [captcha](/topics/captcha/) requirement. Some operations read the resolved account or the raw header, and each states that on its own page.
+
+Fluxer answers with 401 when it resolves no usable identity, and with 403 when it resolves one the operation refuses.
+
+A missing, malformed, unknown, expired, or revoked credential returns 401 `UNAUTHORIZED`. A bot token on an Admin operation and a non-bearer credential on a bearer-only operation return 401 as well.
+
+A resolved identity that the operation refuses returns 403 `ACCESS_DENIED`. That is the outcome for a bot account on a user-only operation and for a bearer credential on an operation that did not opt into OAuth2. An Admin OAuth2 bearer credential issued to an application other than the built-in Admin application returns 403 `ACCESS_DENIED` as well.
+
+Other authorisation failures use `MISSING_OAUTH_SCOPE` for a missing scope, and `MISSING_ACL` or `MISSING_PERMISSIONS` for an Admin ACL failure. Fluxer sends no `WWW-Authenticate` header on a 401, so a client distinguishes the outcomes by `code` alone.
+
+The [account state gate](#account-state-gates) below runs wherever the ordinary login requirement runs.
+
+## Single sign-on enforcement
+
+An instance can enforce single sign-on. Enforcement is active only while single sign-on is enabled, its configuration is ready, and enforcement is switched on.
+
+While enforcement is active, an operation that uses a locally held credential returns 403 `SSO_REQUIRED`. [Authentication](/http-api/authentication/) defines all of them:
+
+- [Register an account](/http-api/authentication/#register-an-account) and [Log in with a password](/http-api/authentication/#log-in-with-a-password).
+- [Get discoverable WebAuthn options](/http-api/authentication/#get-discoverable-webauthn-options) and [Authenticate with WebAuthn](/http-api/authentication/#authenticate-with-webauthn).
+- [Complete login with TOTP](/http-api/authentication/#complete-login-with-totp), [Get WebAuthn MFA options](/http-api/authentication/#get-webauthn-mfa-options), and [Complete login with WebAuthn MFA](/http-api/authentication/#complete-login-with-webauthn-mfa).
+- [Verify an email address](/http-api/authentication/#verify-an-email-address) and [Resend email verification](/http-api/authentication/#resend-email-verification).
+- [Request password recovery](/http-api/authentication/#request-password-recovery), [Validate a password reset token](/http-api/authentication/#validate-a-password-reset-token), and [Reset a password](/http-api/authentication/#reset-a-password).
+- [Revert an email change](/http-api/authentication/#revert-an-email-change).
+- [Authorise an IP address](/http-api/authentication/#authorise-an-ip-address), [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation), and [Poll IP authorisation](/http-api/authentication/#poll-ip-authorisation).
+- [Get username suggestions](/http-api/authentication/#get-username-suggestions).
+
+The single sign-on callback returns the same code when the provider claims match no existing account and the instance does not auto-provision.
+
+Enforcement applies at those operations only. It does not gate password change or multi-factor management on an already authenticated account, and enabling it leaves an already issued session token, bot token, OAuth2 access token, or Admin API key valid.
+
+## Account state gates
+
+The ordinary login requirement rejects an account that has effective suspicious activity flags with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+A requirement disappears from the response as soon as it is met.
+
+### Account suspicious activity body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| data | object | An object whose `suspicious_activity_flags` member is the integer [suspicious activity flag](/admin-api/users/#suspicious-activity-flags) bitfield still outstanding |
+
+A route that explicitly admits restricted accounts still accepts the credential. These stay reachable while a requirement is outstanding:
+
+- [Get current user](/http-api/users/current-user/#get-current-user) and [Modify current user](/http-api/users/current-user/#modify-current-user).
+- [Get current user settings](/http-api/users/settings/#get-current-user-settings).
+- The [email change flow](/http-api/users/email-and-password/) with its bounced-address variants, and the email verification resend.
+- The [phone verification](/http-api/users/phone-verification/) flow.
+- Session listing and session termination.
+- The application and authorisation management operations in [Applications](/http-api/applications/) and [OAuth2](/http-api/oauth2/).
+
+An Admin operation applies no suspicious activity gate.
+
+No shared gate rejects a deleted or disabled account. Each operation that reads account state applies its own rule, and login, password, and email operations refuse a deleted account outright.
+
+## Failed authentication
+
+An unknown, expired, revoked, or malformed credential all return 401 `UNAUTHORIZED`. A client cannot tell the four apart from the response. A valid credential whose identity the operation resolves and refuses returns 403 `ACCESS_DENIED`, as [authorisation outcomes](#authorisation-outcomes) sets out.
+
+Fluxer records a malformed header and a credential that resolves nothing against the originating address. An Admin API key presented outside `/v1/admin` records nothing.
+
+Two triggers ban an address. Enough distinct rejected tokens inside the tracking window ban it on the first crossing. A failure score over its threshold bans it only after it crosses that threshold in several separate windows. The window and both thresholds are instance configuration. Fluxer never applies an automatic ban to an address it classifies as mobile. A banned address is refused before the operation runs, as [Errors](/http-api/errors/) sets out.
+
+## Sudo mode
+
+Sudo mode is a short-lived proof that the account holder recently re-verified a credential. Each operation that requires it states that on its own page, and [Multi-factor authentication](/http-api/users/mfa/#sudo-mode) defines the accepted proofs, the [sudo verification object](/http-api/users/mfa/#sudo-verification-object) fields, and the [sudo mode methods object](/http-api/users/mfa/#sudo-mode-methods-object) returned with 403 `SUDO_MODE_REQUIRED`.
+
+A sudo proof is an HS256 JSON Web Token with the account ID as its subject, the fixed claim `type` set to `sudo`, an issue time, and an expiry five minutes after issue. A client presents it in the `X-Fluxer-Sudo-Mode-JWT` request header. An invalid, expired, or account-mismatched token is treated as absent. A bad proof and a missing proof produce the same response.
+
+Fluxer mints a token only for an account holding a multi-factor authenticator, so a password-only account re-verifies for each operation that requires sudo mode. [Create WebAuthn registration options](/http-api/users/mfa/#create-webauthn-registration-options) and [Disable current account](/http-api/users/current-user/#disable-current-account) mint no token and return no header even for a multi-factor account. A bot account satisfies sudo mode immediately. So does an account that has neither a password nor a multi-factor authenticator.
+
+:::note[A sudo proof covers every account session]
+The check covers only the signature, the `type` claim, the subject, and the expiry. Revoking the session that obtained a proof leaves that proof valid.
+:::
+
+## Gateway authentication
+
+`GET /v1/gateway/bot` accepts a bot token with the `Bot` prefix, with the `Bearer` prefix, or with no prefix at all, and an absent or empty header returns 401 `MISSING_AUTHORIZATION`. The operation then checks the form alone. The value must not begin with `flx_` and must have a decimal identifier before an interior full stop, so a user session token returns 401 `INVALID_AUTH_TOKEN`.
+
+A value in bot token form that matches no application receives the same response as a valid bot token. The same bot token is the credential in the [Identify command](/gateway/commands/#identify).
+
+A user session presents its session token in Identify. The Gateway does not read the `Authorization` header. An invalid or revoked credential closes the connection with [close code `4004`](/gateway/opcodes-and-close-codes/#close-codes). An Identify payload that has no `token` closes with `4002` and reason `Invalid identify payload`.
+
+## Other credential surfaces
+
+A webhook execution operation has the webhook identifier and token in its request path, and it accepts no `Authorization` credential. [Webhooks](/http-api/webhooks/) defines its contract.
+
+The [Media Proxy API](/media-proxy/overview/) does not read the `Authorization` header on an ordinary media or relay route. A stored object is addressed by its path, external media is authorised by its path signature, and a relay request is authorised by the capability embedded in its URL. [Upload relay](/media-proxy/upload-relay/) defines the capability contract.
diff --git a/fluxer_docs/src/content/docs/conventions.md b/fluxer_docs/src/content/docs/conventions.md
new file mode 100644
index 000000000..407e2c242
--- /dev/null
+++ b/fluxer_docs/src/content/docs/conventions.md
@@ -0,0 +1,144 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: API conventions
+description: Normative keywords, protocol subjects, wire table notation, and endpoint entry structure.
+---
+
+This page defines the notation every reference page uses: the wire tables, the type names, the footnote markers, the endpoint entry layout, and what an omitted field means. An operation that states a different rule and names the difference overrides anything here. A code example illustrates the contract and never overrides prose, a wire table, a registry, or a state-transition table.
+
+## Normative language
+
+MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL have their [BCP 14](https://www.rfc-editor.org/info/bcp14) meanings only when they appear in all capitals. A keyword in any other form has its ordinary English meaning, including one that is part of an identifier such as `TWO_FACTOR_REQUIRED`. Literal wire text, including an error message template, retains its exact wording.
+
+Normative force does not depend on a keyword appearing. A direct statement such as "Fluxer returns HTTP 204" defines observable behaviour, and the values in wire tables, registries, algorithms, and state-transition tables are part of the contract.
+
+## Protocol subjects
+
+These subjects have the meanings below throughout the reference.
+
+| Subject | Description |
+| --- | --- |
+| Fluxer | The deployment whose behaviour this reference documents, also written as the instance |
+| caller | The party whose credential, permissions, and address one operation evaluates |
+| guild | A community with its own channels, roles, members, and configuration, defined by [Guilds](/http-api/guilds/) |
+| session | One established Gateway connection, which is the subject of ordering, delivery, replay, and shard rules |
+| user | An account represented by the user object, including a bot account |
+| bot | A user account owned by an application and flagged as a bot |
+| account | The subject used where a rule holds for a user and a bot alike |
+| application | An OAuth2 application record that owns client credentials and at most one bot account |
+| operator | The party configuring and running a deployment |
+| administrator | An account acting through the Admin API under Admin ACLs |
+| resource | The object family and operation set that one reference page defines |
+
+A Dispatch is one Gateway server-to-client event, and a command is one client-to-server message. The reference writes ordinary user or non-bot user where a rule excludes bots.
+
+Several of these words have an unrelated second sense. An authentication session is the stored login record defined by [Authentication](/authentication/), a voice server is the registered media machine defined by [Admin Voice](/admin-api/voice/), and a guild administrator is a member holding guild permissions.
+
+## Wire table notation
+
+A wire table describes one payload, parameter set, or object under `Field`, `Type`, and `Description` columns. A field name ending in `?`, before any footnote marker, is optional. A type beginning with `?` is nullable and permits JSON `null`. A field that is both has the marker in both positions, as in the field `communication_disabled_until?` with the type `?ISO8601 timestamp`. The name written in the table cell is authoritative.
+
+The `Type` column uses this notation.
+
+| Notation | Meaning |
+| --- | --- |
+| `snowflake` | An unsigned decimal string defined by [Snowflakes](/snowflakes/) |
+| `decimal string` | Any other unsigned integer as a JSON string, because its range exceeds what a JSON number preserves exactly |
+| `array[type]` | An array of the named type |
+| `map[key, value]` | A JSON object keyed by the first type with values of the second |
+| `ISO8601 timestamp` | An ISO 8601 timestamp string, which is also the representation of a timestamp field unless its description names another one |
+| A link followed by `object` | The object defined at that link |
+| `integer string`, `base64 string` | That representation in a JSON string |
+| `binary`, `file` | A multipart file part |
+
+The description of a duration field names its unit.
+
+The type of a union lists its alternatives separated by a vertical bar, written as `type \| type` in a table cell. A field that accepts a small fixed set of literal values lists those exact wire values in the same form, as in `emoji \| sticker`.
+
+A superscript marker such as 1 refers to the numbered footnote written beneath its table or paragraph. Numbering restarts in every table. A footnote records a presence condition, gate, bound, or computed value that does not fit in a description cell.
+
+Bitfield tables and enumeration tables with symbolic names share the `Value`, `Name`, and `Description` columns. A table is a bitfield when every non-zero value cell holds a shift expression of the form `1 << n`. An enumeration whose values have no symbolic name uses `Value` and `Description` alone.
+
+A registry is closed when its page states that it is complete, gives an exact count, or states that a value outside it is rejected. A value absent from a closed registry is unsupported even when its wire type could represent it.
+
+A state-transition table uses `Event and condition`, `Action`, and `Next state` columns. Its first cell names an event one state accepts and then the condition that selects this outcome. A state accepts an event only when it appears in that state's table or in a table the section declares for every open state.
+
+## Omission and null
+
+On a request that modifies a stored entity and accepts a subset of its fields, omitting an optional field leaves the stored value unchanged. Sending `null` for a nullable field clears it. A field that is optional but not nullable can be set or left unchanged. A field that is nullable but not optional is always present, even when its value is `null`.
+
+An operation that departs from either default states the departure in the field's description, in a footnote, or beside its body table. A departure can run in either direction, so an operation can accept `null` without clearing and can change a stored value that the request never named. An operation can also define an empty string or an empty array as the clearing value, and a supplied array replaces the stored collection completely.
+
+[Modify meme](/http-api/memes/#modify-meme) accepts this body, which leaves the stored tags unchanged, clears the alt text, and sets the name:
+
+```json
+{
+ "alt_text": null,
+ "name": "party horn"
+}
+```
+
+On a response, the page that owns a field states what an absent field means. That is commonly that the operation did not populate it, that the object variant does not own it, or that its value has been cleared. Each of those is distinct from a present field whose value is `null`.
+
+## Endpoint entries
+
+An operation entry opens with its method and path. The operation's path parameter table defines each braced segment in the path, such as `{guild_id}`, under the same name without the braces.
+
+The method and path can be followed by capability labels. Only the labels shown apply, and a route whose only authorisation is a signed path or a capability URL has none.
+
+| Label | Meaning |
+| --- | --- |
+| `Unauthenticated` | The operation accepts a request with no credential |
+| `Bot` | The operation accepts a bot token |
+| `Audit reason` | The operation reads the `X-Audit-Log-Reason` request header |
+| `MFA` | Multi-factor authentication or an elevated [sudo session](/http-api/users/mfa/#sudo-mode) can be required, under the condition the operation states |
+
+A label that is an [OAuth2 scope](/http-api/oauth2/#oauth2-scopes) name means the operation accepts an OAuth2 bearer credential and requires that scope of a bearer caller.
+
+Prose then states the contract. The subsections that apply follow it in this order:
+
+1. `Limitations`, which lists the preconditions an operation stacks, one trigger to a bullet.
+2. `Path parameters`.
+3. `Query parameters`.
+4. `Request headers`.
+5. The request body, under `JSON body`, `Form body`, `Multipart body`, or `Request body`.
+6. Any subsection the surface defines for itself.
+7. `Response body`.
+8. `Response`.
+9. `Response headers`, which states a header the operation sets for itself.
+10. `Side effects`.
+11. `Rate limit`, which states the bucket the operation draws on.
+
+A subsection that does not apply is omitted. An object that a page defines has its own field table under `Structure`.
+
+A response table uses `Status`, `Body`, and `Condition` columns. A `Body` cell uses the same type notation as a wire table, names `empty` where the response has no body, and names `response body` where the preceding subsection defines it. A response table has no header column. The shared contract is defined once under [standard response headers](/http-api/#standard-response-headers). A header an operation sets for itself is stated in prose under the operation.
+
+## Describing behaviour
+
+A present-tense statement about Fluxer states an observable contract. An internal storage, service, queue, or worker detail appears only where it determines an observable ordering rule, durability guarantee, limit, timeout, error, or security boundary.
+
+Each failure names the value a client branches on. An HTTP API or Admin API failure names its status and its stable error `code`. An OAuth2 protocol failure answers with the RFC 6749 envelope, which has no Fluxer `code` and is matched on its `error` value. A Media Proxy failure has a plain-text reason phrase and no machine-readable code, so a client branches on its HTTP status. A WebSocket failure names its close code and, where the protocol defines one, the exact close reason.
+
+A human-readable message can be localised, so a client matches only the machine-readable value its surface defines.
+
+## Limits and bounds
+
+Unless a page states otherwise, a limit is inclusive. A bound written as less than or before excludes the stated value.
+
+An array bound counts elements. A string bound names its unit, written as characters, UTF-8 bytes, UTF-16 code units, or grapheme clusters, and a bound that names more than one unit applies all of them. A bound on an encoded value states whether it applies before or after decoding.
+
+## Examples and notes
+
+A `json` example shows one valid or representative wire value, and a `text` example shows an expression or a string form. Every identifier, token, hash, and host in an example is fabricated, and an example host uses the reserved `example.com` domain.
+
+Inside inline code, angle brackets mark descriptive placeholder text standing for a real value, as in `Bot ` or `attachment://`. An ellipsis inside a JSON string value elides content the example does not need to show. Where inline code quotes an exact response body, the brackets are part of the literal value.
+
+A note states a consequence or a relationship to another rule. A caution states a detail a client would otherwise get wrong, which covers a contract that breaks the opposite assumption, a value the client must keep confidential, and an effect that cannot be undone. A danger is reserved for an outcome that destroys stored data, files an external report, revokes every credential on an account, or discloses a credential no later operation can return. All three are binding.
+
+## Independent protocol surfaces
+
+The HTTP API and the Gateway each own an error registry, and the Media Proxy API owns none because its failures have no code. The Gateway alone defines opcodes and close codes.
+
+The Admin API is a privileged namespace inside the HTTP API. It shares the `/v1` prefix, the request and response framing, the error envelope, and the standard response headers. It adds its own credential policy, ACL registry, audit contract, and rate limit registry.
+
+An identifier other than a [snowflake](/snowflakes/) reaches a second surface only where that surface names it, as the Media Proxy API does for the upload capability and the asset hash the HTTP API issues.
diff --git a/fluxer_docs/src/content/docs/gateway/commands.md b/fluxer_docs/src/content/docs/gateway/commands.md
new file mode 100644
index 000000000..7cb7606b6
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/commands.md
@@ -0,0 +1,490 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Client commands
+description: Every main Gateway client command, its payload, bounds, result, and close behaviour.
+---
+
+A client command is a payload a client sends to Fluxer over the [Gateway](/gateway/overview/) WebSocket. Each one has an integer [opcode](/gateway/opcodes-and-close-codes/#opcodes) in `op` and the command data in `d`. Fluxer answers with a [Dispatch](/gateway/events/) event, a close frame, or nothing at all.
+
+Except for [Heartbeat](#heartbeat), [Identify](#identify), and [Resume](#resume), every command needs an authenticated session. Sending one too early closes with `4003` and reason `Not authenticated`. Heartbeat and Resume are accepted in any open state, and Identify is accepted only while the connection is unauthenticated.
+
+## Command index
+
+| Opcode | Command | Result |
+| --- | --- | --- |
+| 1 | Heartbeat | Opcode `11` Heartbeat ACK |
+| 2 | Identify | [Ready](/gateway/events/#ready), a close frame, or silence when the payload is held or discarded |
+| 3 | Presence Update | No direct response |
+| 4 | Voice State Update | [Voice State Ack](/gateway/events/#voice-state-ack), [Voice State Update](/gateway/events/#voice-state-update), and [Voice Server Update](/gateway/events/#voice-server-update) when state changes |
+| 6 | Resume | Replayed Dispatches followed by [Resumed](/gateway/events/#resumed), Invalid Session, or a close frame |
+| 8 | Request Guild Members | One or more [Guild Members Chunk](/gateway/events/#guild-members-chunk) events |
+| 14 | Lazy Request | [Guild Sync](/gateway/events/#guild-sync) and [Guild Member List Update](/gateway/events/#guild-member-list-update) |
+| 15 | Request Guild Counts | [Guild Counts Update](/gateway/events/#guild-counts-update) |
+| 16 | Request Channel Member Counts | [Channel Member Counts Update](/gateway/events/#channel-member-counts-update) |
+
+## Payload handling
+
+A frame that fails the size, decompression, or decoding checks closes the connection without consuming any budget. A frame that decodes to something other than an object closes with `4002` and reason `Decode failed`, and an object with no `op` closes with `4002` and reason `Invalid payload`.
+
+Fluxer charges every command that gets past those checks against the [source IP, session, and connection payload budgets](/gateway/limits-and-rate-limits/#connection-and-command-rate-limits) before it handles the opcode. Each session gets its own session budget, so two sessions of one account never share one, and Fluxer skips that budget while the connection is unauthenticated.
+
+An opcode outside the registry closes with `4001` and reason `Unknown opcode` once a session is attached, and with `4003` while the connection is unauthenticated. A payload that has `op` but no `d` also closes with `4001`, except for Identify, which closes with `4005`.
+
+:::note[Most command payloads are permissive]
+Identify, Resume, and Presence Update reject a malformed payload with a close. The [bounded query commands](#bounded-requests) coerce or discard whatever they cannot parse, so a wrong field type usually produces an empty result.
+:::
+
+## Heartbeat
+
+Opcode `1` has the last Dispatch sequence the client processed. Use `null` before the first Dispatch.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| d | ?integer | The last Dispatch sequence the client processed, or null before the first Dispatch |
+
+```json
+{
+ "op": 1,
+ "d": 42
+}
+```
+
+Before authentication the Gateway accepts any `d` value and sends Opcode `11`. A payload with no `d` key closes with `4001` and reason `Unknown opcode`.
+
+Once a session exists, a `d` that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. Every integer is accepted. A sequence below the one the session has already acknowledged leaves the acknowledged sequence unchanged. Any other integer becomes the acknowledged sequence and trims every retained Dispatch at or below it from the replay buffer.
+
+When the session ends, Fluxer sends Opcode `9` with `d: false`, after which heartbeats are acknowledged again. A heartbeat that arrives in the short window between the session ending and that frame closes with `4007`.
+
+See [Gateway overview](/gateway/overview/#heartbeats) for the timing contract.
+
+## Identify
+
+Opcode `2` authenticates and creates a new session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The account or bot token, with no HTTP authentication prefix |
+| properties | [identify properties](#identify-properties-object) object | The software and capabilities of the connecting client |
+| presence?1 | ?[initial presence](#initial-presence-object) object | The presence to publish when the session starts |
+| ignored_events?2 | ?array[string] | The Dispatch event names this session does not want, at most 256 entries |
+| flags? | integer | A non-negative [session flags](#session-flags) bitfield (default `0`) |
+| initial_guild_id?3 | ?snowflake | The [snowflake](/snowflakes/) of one guild the session joins as active |
+| shard?4 | ?array[integer] | The `[shard_id, shard_count]` pair |
+
+1 Null and an omitted key both select the account's saved status and saved custom status
+
+2 Names are upper-cased and deduplicated. See [Event filtering](/gateway/event-filtering/) for the exact suppression rule
+
+3 That guild is marked active and already synced when the session connects to it, so it delivers active traffic without a [Lazy Request](#lazy-request) and sends no [Guild Sync](/gateway/events/#guild-sync). Fluxer discards a value that is not a canonical decimal Snowflake string, and the Identify still succeeds
+
+4 `shard_count` is an integer from 1 through 16,384, and `shard_id` is an integer that is at least 0 and below `shard_count`
+
+```json
+{
+ "op": 2,
+ "d": {
+ "token": "...",
+ "properties": {
+ "os": "Linux",
+ "browser": "Fluxer Client",
+ "device": "desktop"
+ },
+ "presence": {
+ "status": "online",
+ "afk": false,
+ "mobile": false
+ },
+ "ignored_events": ["TYPING_START"],
+ "flags": 2,
+ "initial_guild_id": "1189375284394692608"
+ }
+}
+```
+
+`token` and `properties` are required. Fluxer ignores unknown fields. There is no `intents` field and no intent system.
+
+The following faults close with `4002` and reason `Invalid identify payload`:
+
+- A missing `token` or `properties`.
+- A `properties` value that is not an object.
+- A `properties` object whose `os`, `browser`, or `device` is missing or is not a string.
+- An `ignored_events` value that is not an array of strings, or one that holds more than 256 entries.
+- A `flags` value that is not a non-negative integer.
+
+A malformed `shard` closes with `4010` and reason `Invalid shard`.
+
+Fluxer refuses a bot session that resolves to more than 2,500 guilds after any `shard` filter is applied. The connection closes with `4011` and reason `Sharding required`. A user session is never refused for its guild count.
+
+A token the backend rejects closes with `4004` and reason `Invalid token`. A non-bot account that already holds 100 live sessions closes with `4008` and reason `Too many sessions`, and a bot credential is not bounded by that count. An Identify sent on a socket that already has a session attached closes with `4005` and reason `Already authenticated`, whether or not it has `d`.
+
+### Session flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 1 | DEBOUNCE_MESSAGE_REACTIONS | Coalesce runs of reaction additions into [Message Reaction Add Many](/gateway/events/#message-reaction-add-many) |
+
+Bit `0` and every bit above `1` are undefined. An undefined bit is accepted and ignored without closing the connection.
+
+`DEBOUNCE_MESSAGE_REACTIONS` applies to a reaction in a direct message or group direct message. A reaction in a guild channel is never coalesced and arrives as its own [Message Reaction Add](/gateway/events/#message-reaction-add).
+
+### Identify properties object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| os | string | The operating system the client runs on |
+| browser | string | The client library identifier |
+| device | string | The device or application identifier |
+| e2ee_capable?1 | boolean | Whether the client can take part in end-to-end encrypted voice |
+| mobile?2 | boolean | Whether the session is mobile (default false) |
+| latitude?3 | string | The client latitude as a decimal string of 1 through 32 characters |
+| longitude?3 | string | The client longitude as a decimal string of 1 through 32 characters |
+
+1 Only the exact value `true` sets it. A session without it is refused from an end-to-end encrypted voice channel with `VOICE_E2EE_REQUIRED`
+
+2 Read only when `presence` is absent or null, in which case it decides the session's mobile flag. Otherwise the [initial presence object](#initial-presence-object) decides it
+
+3 Used to order the Ready `rtc_regions` array by distance
+
+`os`, `browser`, and `device` are required strings. The remaining fields are optional hints. Fluxer accepts and ignores unrecognised properties.
+
+`latitude` and `longitude` are accepted here only as strings. A number fails validation and the whole session start fails, so send `"52.52"`. Both must be sent together to have any effect, and a string that does not parse as a number counts as absent. Omit them when the client has no location, and Ready orders `rtc_regions` by region ID instead.
+
+### Initial presence object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status?1 | string | The initial status, accepting `online`, `idle`, `dnd`, `invisible`, or `offline` |
+| afk?2 | boolean | Whether the session is away (default false) |
+| mobile?2 | boolean | Whether the presence is mobile (default false) |
+| custom_status?3 | ?[custom status](#custom-status-object) object | The custom status this session publishes |
+
+1 The account's saved status wins when `status` is absent, when it is null or any other non-string, when it is the string `unknown`, and when it is `online` while the saved status is not `online`. The empty string resolves to `online`, and the remaining accepted values are used as sent
+
+2 Only the exact value `true` sets the flag. Every other value resolves to false
+
+3 Read only when the account has no saved custom status, and stored as sent with no validation
+
+Identify accepts `offline` as a distinct initial status, and [Presence Update](#presence-update) normalises `offline` to `invisible`. A session whose resolved status is `offline` or `invisible` publishes `status: "offline"` and a null `custom_status` to other users.
+
+### Custom status object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text? | ?string | The text of the custom status, from 1 through 128 characters |
+| expires_at?1 | ?ISO8601 timestamp | The time the custom status expires |
+| emoji_id?2 | ?snowflake | The [snowflake](/snowflakes/) of the custom emoji |
+| emoji_name?3 | ?string | The Unicode emoji, from 1 through 32 characters |
+
+1 The timestamp is in the future
+
+2 The Snowflake names an emoji that exists. Fluxer drops the field from the published object, without failing the rest of it, when the account has no global expressions entitlement
+
+3 A single Unicode emoji. Fluxer strips the field before validation when `emoji_id` is supplied
+
+Only these fields are read. [Presence Update](#presence-update) validates the object against the account before publishing it. [Identify](#identify) does not validate it, and reads it only when the account has no saved custom status.
+
+The published presence adds `emoji_animated` to this object.
+
+## Resume
+
+Opcode `6` restores a retained session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The token that owns the retained session |
+| session_id | string | The session ID from [Ready](/gateway/events/#ready) |
+| seq | integer | The last Dispatch sequence the client processed |
+
+All three fields are required. A missing field, a non-string `token` or `session_id`, or a `seq` that is not an integer closes with `4002` and reason `Invalid resume payload`.
+
+An unknown or expired session produces Opcode `9` with `d: false` and leaves the socket unauthenticated. A token that does not own the session closes with `4004` and reason `Invalid token`. A `seq` above the session's current sequence, or below the sequence it has already acknowledged, closes with `4007` and reason `Invalid sequence`. A `seq` below the [replay floor](/gateway/limits-and-rate-limits/#replay-and-backpressure), the highest sequence already dropped from the buffer, also produces Opcode `9` with `d: false`. A negative `seq` closes with `4000` and reason `Session unavailable`, and so does a session that cannot be reached. None of those closes destroys a separately retained session.
+
+A successful Resume replays every retained Dispatch strictly above `seq` in order and finishes with [Resumed](/gateway/events/#resumed). It also replaces the session's socket, and the displaced socket receives Opcode `7` followed by a close.
+
+Fluxer processes Resume in any authentication state. A socket that already has a session attached still processes one, and the named session takes the attached session's place. Send Resume only on a fresh socket.
+
+## Presence Update
+
+Opcode `3` replaces the current session presence.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | The status to publish, accepting `online`, `idle`, `dnd`, `invisible`, or `offline` |
+| afk?1 | boolean | Whether the session is away (default false) |
+| mobile?1 | boolean | Whether the session is mobile (default false) |
+| custom_status?2 | ?[custom status](#custom-status-object) object | The custom status that replaces the current one |
+
+1 Only the exact value `true` sets the flag. Every other value resolves to false
+
+2 An object identical to the current one in `text`, `expires_at`, `emoji_id`, and `emoji_name` is reused without revalidation
+
+```json
+{
+ "op": 3,
+ "d": {
+ "status": "idle",
+ "afk": true,
+ "mobile": false,
+ "custom_status": {
+ "text": "away from keyboard"
+ }
+ }
+}
+```
+
+`status` is required. A payload that is not an object, an object with no `status` key, and a `status` string outside the accepted set all close with `4002` and reason `Invalid presence payload`. The empty string resolves to `online`.
+
+Fluxer accepts a `status` that is not a string. Null and a Boolean publish the session as offline, and every other non-string value resolves to `online`.
+
+`offline` is normalised to `invisible`, so a Presence Update cannot publish a session as offline while it is connected.
+
+`custom_status` is replaced only when the key is present. Omitting the key keeps the current custom status, `null` clears it, and a value that is neither an object nor null is ignored. An object the backend rejects, such as an `emoji_id` that names no emoji or an `expires_at` in the past, leaves the current custom status in place. The connection stays open.
+
+The published presence has a [custom status](#custom-status-object) object and no activities.
+
+Presence Update has a dedicated limit of five accepted commands per 20 seconds on one WebSocket. A further update inside that window is discarded without closing the connection, after it has consumed the shared payload budgets.
+
+## Voice State Update
+
+Opcode `4` joins, moves, updates, or leaves the voice membership associated with the current Gateway session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id? | ?snowflake | The guild containing the voice channel, where null selects the DM and group DM call context |
+| channel_id? | ?snowflake | The channel to join or move to, where null leaves the current membership in that context |
+| connection_id?1 | ?string | The existing voice connection this update applies to |
+| self_mute?2 | boolean | Whether the client has muted its own microphone (default false) |
+| self_deaf?2 | boolean | Whether the client has deafened its own output (default false) |
+| self_video?2 | boolean | Whether the client publishes camera video (default false) |
+| self_stream?5 | boolean | Whether this connection advertises a screenshare track (default false) |
+| is_mobile?2 | boolean | Whether this is a mobile voice client (default false) |
+| viewer_stream_keys?3 | ?array[string] | The stream keys this connection is watching, where an omitted key keeps the current list and null clears it |
+| latitude? | number or string | The client latitude, used to pick a voice region |
+| longitude? | number or string | The client longitude, used to pick a voice region |
+| mutation_id? | string | A client-generated identity echoed in [Voice State Ack](/gateway/events/#voice-state-ack) |
+| runtime_epoch? | string | The client runtime generation echoed in Voice State Ack |
+| base_version?4 | integer | The voice state version this update was computed against |
+
+1 A `channel_id` with no `connection_id` opens a new connection, and a `channel_id` with one updates or moves that connection. An update that leaves one guild, meaning a non-null `guild_id` with `channel_id: null`, requires a `connection_id`, and one that omits it is refused with `VOICE_MISSING_CONNECTION_ID`
+
+2 Only `true` and the string `"true"` set the flag. Every other value resolves to false
+
+3 Every entry is a stream key whose scope, guild, and channel match this update. An entry that fails that check, or a value that is not an array, refuses the update with `VOICE_INVALID_STATE`, and an entry naming a connection that does not exist refuses it with `VOICE_CONNECTION_NOT_FOUND`
+
+4 A non-negative integer. Fluxer treats every other value as absent, which disables the staleness check
+
+5 Only `true` and the string `"true"` set it, and Fluxer publishes `false` when the member lacks `STREAM` in the channel. The screenshare track rides this same connection, so setting the flag mints no grant and sends no [Voice Server Update](/gateway/events/#voice-server-update)
+
+```json
+{
+ "op": 4,
+ "d": {
+ "guild_id": "1189375284394692608",
+ "channel_id": "1189375284394692610",
+ "self_mute": false,
+ "self_deaf": false,
+ "self_video": false,
+ "self_stream": false,
+ "mutation_id": "e0a1c2",
+ "base_version": 7
+ }
+}
+```
+
+Every field is optional. A non-null `guild_id` or `channel_id` is a canonical decimal [snowflake](/snowflakes/) string, and a positive integer is also accepted. Every other value fails validation, and Fluxer drops the update with no close code and no event.
+
+`guild_id: null` with `channel_id: null` and a `connection_id` leaves the DM or group DM call connection with that identifier. The same shape without a `connection_id` disconnects the session from every voice membership it holds. `guild_id: null` with a `channel_id` selects the DM or group DM call context.
+
+`latitude` and `longitude` accept a number or a string here, and Fluxer coerces both to a string.
+
+The command has no `session_id` field. The current Gateway session is the membership identity.
+
+Joining or replacing a grant produces [Voice Server Update](/gateway/events/#voice-server-update) with the token and endpoint for the media connection, and [Voice State Update](/gateway/events/#voice-state-update) for every session that can see the channel.
+
+When a guild update has `mutation_id`, Fluxer also reports the outcome to the requesting session as [Voice State Ack](/gateway/events/#voice-state-ack), whose `status` is `applied` or `rejected` and whose `error_code` names the exact refusal. Without `mutation_id` a refusal produces no event at all, and the DM and group DM call context never acks.
+
+`base_version` is a staleness check for an update that names an existing guild connection. An update whose `base_version` is more than one behind that connection's current voice state version is rejected with `stale_base_version`. The check runs after the member, channel, and connection lookups and before the permission checks. It does not apply to opening a new connection, to leaving a channel, or to the DM and group DM call context.
+
+The first two updates in a rolling one-second window are processed immediately. Later updates enter a per-session queue that holds at most 64 commands and drains one command every 500 ms. A newer update replaces an older queued update for the same `guild_id` and `connection_id` pair, and a full queue discards its oldest entry before accepting the new one.
+
+## Request Guild Members
+
+Opcode `8` requests bounded member chunks.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id?1 | snowflake | The ID of the single guild to query |
+| guild_ids?1 | array[snowflake] | A non-empty array of the guild IDs to query |
+| query?2 | string | The display name prefix to match (default empty string) |
+| limit?3 | integer | The result limit, from 0 through 100 and clamped (default 0) |
+| user_ids?4 | array[snowflake] | The explicit user IDs to select, at most 100 (default empty) |
+| presences?5 | boolean | Whether results include presences (default false) |
+| nonce? | string | A value of at most 32 bytes echoed in each chunk |
+
+1 A non-empty `guild_ids` array wins. `guild_id` is read only when `guild_ids` is absent or empty
+
+2 Matched case-insensitively as a prefix of the member's display name, which is the guild nickname, then the global name, then the username
+
+3 A `limit` of `0` with a non-empty `query` resolves to 25 results, and a `limit` of `0` with an empty `query` requests the full member list up to 100,000 entries
+
+4 A non-empty `user_ids` array selects those members directly and ignores `query` and `limit`
+
+5 Only the exact value `true` sets it. Presences whose status is `offline` or `invisible` are omitted from the result
+
+```json
+{
+ "op": 8,
+ "d": {
+ "guild_id": "1189375284394692608",
+ "query": "ann",
+ "limit": 50,
+ "presences": true,
+ "nonce": "a1b2c3"
+ }
+}
+```
+
+The command never closes the connection. Invalid input is coerced or discarded:
+
+- A guild ID that is not a positive Snowflake abandons the whole request.
+- Duplicate guild IDs are collapsed.
+- A `user_ids` array longer than 100 entries abandons the whole request. Individual entries that are not positive Snowflakes are dropped.
+- A `limit` that is not a non-negative integer becomes `0`, and a larger value is clamped to `100`.
+- A `query` that is not a string becomes the empty string.
+- A `nonce` that is not a string of at most 32 bytes becomes null.
+
+The nonce is echoed only when the request named exactly one guild.
+
+Fluxer skips a guild the session is not currently connected to. A request that resolves to no connected guild produces no chunk.
+
+A bot requests one guild at a time, and a bot request naming two or more guilds is abandoned.
+
+An empty `query`, a `limit` of `0`, and an empty `user_ids` together request the complete member list. A human account requesting the complete list needs `MANAGE_ROLES`, `KICK_MEMBERS`, and `BAN_MEMBERS` together in that guild, and a request holding only some of them is dropped silently. The guild owner and any member with `ADMINISTRATOR` satisfy that check. A bot requesting the complete list is limited to one accepted request per guild every 30 seconds. A request inside that window produces [Rate Limited](/gateway/events/#rate-limited) and no member chunk.
+
+Results arrive as [Guild Members Chunk](/gateway/events/#guild-members-chunk) in pages of at most 1,000 members, each with `chunk_index` and `chunk_count`. Those chunks are delivered live and are never retained for Resume replay.
+
+Only one member request runs at a time on one WebSocket. While one runs, a newer request replaces any earlier pending request and starts when the active one finishes. [Bounded requests](#bounded-requests) covers the four-slot limit this command shares with the other three.
+
+## Lazy Request
+
+Opcode `14` sets the per-guild subscriptions that decide member list, typing, and synchronisation traffic for the session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| subscriptions1 | map[snowflake, [guild subscription](#guild-subscription-object) object] | The subscription options for each guild, keyed by guild ID |
+
+1 Fluxer keeps each guild's options for the session and applies them again when the session connects to that guild later
+
+```json
+{
+ "op": 14,
+ "d": {
+ "subscriptions": {
+ "1189375284394692608": {
+ "active": true,
+ "typing": true,
+ "member_list_channels": {
+ "1189375284394692610": [[0, 99]]
+ }
+ }
+ }
+ }
+}
+```
+
+`subscriptions` is an object whose keys are canonical decimal Snowflake strings. A key that does not parse, a value that is not an object, and a guild the session is not connected to are all skipped without affecting the rest of the command. The command never closes the connection.
+
+### Guild subscription object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| active?1 | boolean | Whether the guild is active for this session |
+| sync?1 | boolean | Whether the session requests a [Guild Sync](/gateway/events/#guild-sync) for the guild |
+| typing? | boolean | Whether the guild delivers [Typing Start](/gateway/events/#typing-start), overriding the active flag |
+| member_list_channels?2 | map[snowflake, array[array[integer]]] | The member list windows to subscribe to, keyed by channel ID |
+| members? | array[snowflake] | The explicit member IDs to subscribe to, at most 1,000 |
+
+1 Both are Booleans when present. Any other value abandons the rest of the command silently, without a close and without a result
+
+2 A coalesced subscription waits 100 ms before Fluxer applies it, and ranges arriving inside that window merge into the ranges already buffered for the same channel. A request that has no ranges for a channel discards the ranges already buffered for it
+
+Fluxer applies each option only when its key is present, in the order `active`, `sync`, `member_list_channels`, `members`, `typing`.
+
+Marking a guild active changes how much traffic it produces, and [Event filtering](/gateway/event-filtering/) specifies the difference. A transition from passive to active, and a transition from active to passive, both imply a sync even when `sync` is absent. Every sync request, implied or explicit, is dropped when the guild is already marked synced for that session. Going passive clears that mark, so the next sync request produces a fresh Guild Sync.
+
+`member_list_channels` maps a channel ID to a list of `[start, end]` ranges. A range needs `start` at least 0, `end` at least `start`, `end` at most 100,000, and `end - start` at most 99. Ranges that fail those bounds are dropped, and each channel keeps at most the first 10 that pass. A channel key that is not a Snowflake is skipped.
+
+Fluxer applies a subscription at once when the guild has no coalescing window open, its buffer is empty, and the channel's member list is already built. That request opens the window. Fluxer buffers it as applied and does not apply it a second time when the window closes. Every other subscription waits out the window, including one for a channel whose member list is not built yet and one arriving while the window is open.
+
+`VIEW_CHANNEL` and `VIEW_CHANNEL_MEMBERS` together govern the member list subscription, and both are evaluated for each channel separately. A channel the session cannot view, or can view without holding `VIEW_CHANNEL_MEMBERS` there, receives no [Guild Member List Update](/gateway/events/#guild-member-list-update) while its siblings subscribe normally.
+
+Subscribing a channel to at least one range drops the session's other member list subscriptions in that guild, so one session holds at most one member list per guild.
+
+`members` entries that are not Snowflakes are dropped, and the first 1,000 that pass are kept. A member the session shares no viewable channel with is dropped as well.
+
+`typing` is a Boolean, and Fluxer ignores any other value. It decides [Typing Start](/gateway/events/#typing-start) delivery for the guild independently of the active flag.
+
+## Request Guild Counts
+
+Opcode `15` requests current count records.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_ids | array[snowflake] | The guild IDs to query, deduplicated, sorted, and truncated to 100 |
+| nonce? | string | A value from 1 through 64 bytes echoed in the result |
+
+```json
+{
+ "op": 15,
+ "d": {
+ "guild_ids": ["1189375284394692608"],
+ "nonce": "counts-1"
+ }
+}
+```
+
+Entries that are not positive Snowflakes are dropped. A guild the session is not connected to is skipped. A `nonce` outside the length bound is omitted from the result. The command never closes the connection.
+
+Results arrive in one [Guild Counts Update](/gateway/events/#guild-counts-update). Each guild is queried with a 2,000 ms deadline under an overall 3,000 ms batch deadline, so a slow guild is omitted from the result.
+
+## Request Channel Member Counts
+
+Opcode `16` requests count records for channels in one guild.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild whose channels are queried |
+| channel_ids | array[snowflake] | The channel IDs to query, deduplicated, sorted, and truncated to 25 |
+| channel_id?1 | snowflake | The ID of the single channel to query, read only when `channel_ids` is absent |
+| nonce? | string | A value from 1 through 64 bytes echoed in the result |
+
+1 A `channel_ids` value that is present but is not an array also falls back to `channel_id`
+
+```json
+{
+ "op": 16,
+ "d": {
+ "guild_id": "1189375284394692608",
+ "channel_ids": ["1189375284394692610"],
+ "nonce": "channel-counts-1"
+ }
+}
+```
+
+A `guild_id` that is not a positive Snowflake, or a guild the session is not connected to, produces an empty result. Channel entries that are not positive Snowflakes are dropped. The command never closes the connection.
+
+Results arrive in one [Channel Member Counts Update](/gateway/events/#channel-member-counts-update) with a 2,000 ms guild deadline. A channel is counted only when the requesting session holds both `VIEW_CHANNEL` and `VIEW_CHANNEL_MEMBERS` there, and every other named channel is omitted from the result.
+
+## Bounded requests
+
+One WebSocket processes at most four bounded requests at once across Request Guild Members, Lazy Request, Request Guild Counts, and Request Channel Member Counts. A command that arrives when all four slots are taken is dropped, without a close and without a result. Each request has a 10,000 ms deadline, after which it produces no further events.
+
+Request Guild Members also keeps one replaceable pending request while another member request is active, whether or not a slot is free.
diff --git a/fluxer_docs/src/content/docs/gateway/event-filtering.md b/fluxer_docs/src/content/docs/gateway/event-filtering.md
new file mode 100644
index 000000000..603a293c2
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/event-filtering.md
@@ -0,0 +1,146 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Event filtering
+description: The four gates a Dispatch passes before it reaches a socket, and the two client controls.
+---
+
+A [Dispatch](/gateway/events/) is one event Fluxer sends to a connected client. Each one passes four independent gates on its way to a socket, and a client shapes its traffic with [Lazy Request](/gateway/commands/#lazy-request) subscriptions and the [Identify](/gateway/commands/#identify) `ignored_events` list.
+
+Fluxer has no intent bitfield. A client ported from a protocol that uses intents replaces its intent mask with those two mechanisms. There is no `intents` field, no intent close code, and no privileged-intent approval.
+
+## The four gates
+
+Fluxer evaluates a guild-scoped Dispatch against these gates in order.
+
+| Gate | Name | What it decides |
+| --- | --- | --- |
+| 1 | Guild availability | Whether the guild dispatches anything except [Guild Update](/gateway/events/#guild-update) |
+| 2 | Permission and visibility | Which sessions may see the event at all |
+| 3 | Guild subscription state | Whether a passive session in a large guild falls inside the fixed subset that still receives it |
+| 4 | Session-level filters | Whether the shard filter, and then the `ignored_events` list, drops it inside the session after the guild has already chosen the recipients |
+
+A guild with the `UNAVAILABLE_FOR_EVERYONE` or `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` feature fails gate 1. `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` decides whether the guild hands a session its full state or an `unavailable` stub when the session connects. Gate 1 has no staff exemption, so a staff session also receives nothing but Guild Update while the feature is set.
+
+An account-scoped Dispatch skips gates 1 through 3 and is subject only to gate 4. Direct message traffic, relationship changes, and account record changes arrive that way.
+
+## Permission and visibility
+
+The guild resolves each event to one of five recipient sets.
+
+| Event class | Events | Recipients |
+| --- | --- | --- |
+| Channel-scoped | [Channel Create](/gateway/events/#channel-create), [Channel Update](/gateway/events/#channel-update), [Channel Delete](/gateway/events/#channel-delete)1, [Message Create](/gateway/events/#message-create), [Message Delete Bulk](/gateway/events/#message-delete-bulk), [Typing Start](/gateway/events/#typing-start), [Channel Pins Update](/gateway/events/#channel-pins-update), [Webhooks Update](/gateway/events/#webhooks-update) | Sessions that can view the channel |
+| Message-access filtered | [Message Update](/gateway/events/#message-update), [Message Delete](/gateway/events/#message-delete), [Message Reaction Add](/gateway/events/#message-reaction-add), [Message Reaction Remove](/gateway/events/#message-reaction-remove), [Message Reaction Remove All](/gateway/events/#message-reaction-remove-all), [Message Reaction Remove Emoji](/gateway/events/#message-reaction-remove-emoji) | Sessions that can view the channel and can access that message |
+| Invite | [Invite Create](/gateway/events/#invite-create), [Invite Delete](/gateway/events/#invite-delete) | Sessions holding `MANAGE_CHANNELS` on the invite's channel2 |
+| Audit log | [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) | Sessions holding `VIEW_AUDIT_LOG` in the guild |
+| Guild-wide | Everything else | Every session connected to the guild |
+
+1 Channel Delete is filtered against the guild state as it was before the deletion, so the session that could see the channel is the session that learns it is gone
+
+2 The channel is read from the payload's `channel_id`, and from a nested `channel.id` when that field is absent. An invite payload with neither field reaches no session
+
+Every one of those sets also excludes a session whose connection to the guild is still in flight, so a session receives none of the events above until the guild has given it that initial state.
+
+Channel visibility is `VIEW_CHANNEL` on the channel, plus two extensions. A category is visible when at least one of its children is visible. A user with a live voice connection in a channel keeps virtual access to it whenever the channel would otherwise stop being visible. That covers a role or overwrite change removing `VIEW_CHANNEL`, and a move into a channel the user cannot view. Virtual access is keyed by user, so it applies to every session of that user. It is dropped when the user's voice connection to the channel ends.
+
+Message access is `READ_MESSAGE_HISTORY` on the channel. Without that permission a session still receives events for messages newer than the guild's message history cutoff. A guild that sets no cutoff offers no such fallback, so a session without `READ_MESSAGE_HISTORY` receives none of the message-access filtered events there.
+
+[Channel Update Bulk](/gateway/events/#channel-update-bulk) is filtered twice. The guild chooses the recipient set guild-wide, and then each recipient's copy of the payload keeps only the channels that recipient can view. A recipient left with an empty `channels` array receives no Dispatch at all.
+
+[Voice State Update](/gateway/events/#voice-state-update) takes its own path. The guild sends it straight to the sessions that can view the channel the voice state names, or, when the state names no channel, the channel the user just left. A voice state with no connection ID is not broadcast at all.
+
+### Excluding the acting session
+
+[Message Reaction Add](/gateway/events/#message-reaction-add) and [Message Reaction Remove](/gateway/events/#message-reaction-remove) accept a `session_id` on the originating HTTP request. Fluxer excludes that session from the resulting Dispatch and strips the field before it sends the payload. Every other mutation delivers its Dispatch to the acting session like any other eligible session.
+
+## Active and passive guilds
+
+A user session is passive in every guild until [Lazy Request](/gateway/commands/#lazy-request) marks that guild `active: true`. A bot session is never passive. A guild with 250 members or fewer is active for every session, so the rule below applies only to a passive user session in a guild with more than 250 members.
+
+Such a session receives exactly this set:
+
+- [Guild Update](/gateway/events/#guild-update)
+- [Guild Delete](/gateway/events/#guild-delete)
+- [Guild Role Update](/gateway/events/#guild-role-update) and [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk)
+- [Channel Create](/gateway/events/#channel-create), [Channel Update](/gateway/events/#channel-update), [Channel Update Bulk](/gateway/events/#channel-update-bulk), and [Channel Delete](/gateway/events/#channel-delete)
+- [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create)
+- [Passive Updates](/gateway/events/#passive-updates)
+- [Message Create](/gateway/events/#message-create) when the message mentions the session's user
+- [Guild Member Update](/gateway/events/#guild-member-update) and [Guild Member Remove](/gateway/events/#guild-member-remove) when the subject is the session's own user
+
+Every other Dispatch the guild produces is suppressed:
+
+- [Message Update](/gateway/events/#message-update), [Message Delete](/gateway/events/#message-delete), and [Message Delete Bulk](/gateway/events/#message-delete-bulk)
+- Every reaction event
+- [Invite Create](/gateway/events/#invite-create) and [Invite Delete](/gateway/events/#invite-delete)
+- [Channel Pins Update](/gateway/events/#channel-pins-update)
+- [Webhooks Update](/gateway/events/#webhooks-update)
+- [Guild Member Add](/gateway/events/#guild-member-add)
+- [Guild Role Create](/gateway/events/#guild-role-create) and [Guild Role Delete](/gateway/events/#guild-role-delete)
+- [Guild Ban Add](/gateway/events/#guild-ban-add) and [Guild Ban Remove](/gateway/events/#guild-ban-remove)
+- [Guild Emojis Update](/gateway/events/#guild-emojis-update) and [Guild Stickers Update](/gateway/events/#guild-stickers-update)
+
+[Voice State Update](/gateway/events/#voice-state-update) never reaches this gate. It takes the separate path described above, so a passive session in a large guild still receives it for every channel it can view.
+
+A message mentions the session's user when the payload names that user in `mentions`, names one of that user's roles in `mention_roles`, sets `mention_here`, or sets `mention_everyone`. The guild reads only the first 100 entries of `mention_roles`, so a message that mentions more roles than that can miss a passive recipient.
+
+Every 30 seconds a passive session receives [Passive Updates](/gateway/events/#passive-updates). The payload has the changed per-channel `last_message_id` watermarks and the changed voice states for the channels it can view, so the session keeps unread state and voice rosters correct without the suppressed message events. A cycle that finds nothing changed sends nothing.
+
+### Typing is decided separately
+
+[Typing Start](/gateway/events/#typing-start) never follows the rule above. When the session set `typing` for the guild through [Lazy Request](/gateway/commands/#lazy-request), that value alone decides delivery. Without an override the event follows the active state, so a passive session in a large guild does not receive it.
+
+The override applies to every session, including a bot session. A bot suppresses Typing Start in one guild through that override alone.
+
+### Member lists
+
+[Guild Member List Update](/gateway/events/#guild-member-list-update) has its own subscription. A session receives it only for a channel it named in `member_list_channels`, and only while it can view that channel and holds `VIEW_CHANNEL_MEMBERS` on it. One session holds at most one member list subscription per guild.
+
+## Presence subscriptions
+
+[Presence Update](/gateway/events/#presence-update) skips gates 1 through 3 and is subject only to gate 4. A guild dispatches it only to a session that named the subject in the `members` array of a [Lazy Request](/gateway/commands/#lazy-request), that can view at least one channel the subject can view, and that does not belong to the subject.
+
+A session that no longer shares a viewable channel with the subject is dropped from that subject's subscriber set, so a client that regains access MUST resend `members` to restore delivery. Each `members` array replaces the session's previous subscription set for that guild.
+
+The session holds a presence back in two cases. Every presence that arrives before [Ready](/gateway/events/#ready) is held. Fluxer releases the queue once it has dispatched Ready. A held presence whose subject already appears in the Ready `presences` array is dropped, and the session sends the rest in one burst. When Ready has not been dispatched within 10,000 milliseconds of session start, a fallback timer releases the queue. After that the session buffers a guild presence whose `guild_id` names a guild it is not connected to, and an account-scoped presence for a user that is neither a friend nor a recipient of a group direct message it belongs to.
+
+A bot session holds no friend or group direct message presence subscriptions, so a bot receives a presence through this guild path alone.
+
+## Ignored events
+
+Identify accepts `ignored_events`, an array of up to 256 Dispatch event names. Fluxer upper-cases and deduplicates the names at Identify. An absent field and a JSON `null` both mean the empty list. Any other value that is not an array of strings, and any array holding more than 256 entries, close the connection with `4002` and reason `Invalid identify payload`. A Dispatch whose `t` appears in the list is dropped and never enters the replay buffer.
+
+```json
+{
+ "op": 2,
+ "d": {
+ "token": "...",
+ "properties": {"os": "Linux", "browser": "bot", "device": "bot"},
+ "ignored_events": ["TYPING_START", "PRESENCE_UPDATE"]
+ }
+}
+```
+
+One exception overrides the list. [Message Create](/gateway/events/#message-create) is delivered even when `MESSAGE_CREATE` is ignored, if the message names the session's user in `mentions`, sets `mention_here`, or sets `mention_everyone`. A role mention does not defeat the list.
+
+A suppressed Dispatch consumes no sequence number, so a client MUST NOT expect a gap in the sequence where the list dropped one.
+
+The list is fixed for the lifetime of the session. Changing it requires a new Identify.
+
+:::note[The guild computes an ignored event anyway]
+`ignored_events` reduces socket traffic and replay pressure.
+:::
+
+## The shard filter
+
+A session that identified with a `shard` pair whose `shard_id` is not 0 drops every Dispatch that does not name a guild. A Dispatch names a guild through a non-empty `guild_id`, or through a non-empty `id` on a [Guild Create](/gateway/events/#guild-create), [Guild Update](/gateway/events/#guild-update), [Guild Delete](/gateway/events/#guild-delete), or [Guild Sync](/gateway/events/#guild-sync) payload. [Rate Limited](/gateway/events/#rate-limited), [Guild Counts Update](/gateway/events/#guild-counts-update), and [Channel Member Counts Update](/gateway/events/#channel-member-counts-update) answer a command the session sent, and each passes the gate whatever its payload holds.
+
+Account-level traffic, direct message traffic, relationship changes, and calls therefore never reach a session on a shard other than 0.
+
+A session on shard 0, and a session that identified without a `shard` pair, filter nothing at this gate. Fluxer still applies guild ownership at Identify, as [Sharding](/gateway/overview/#sharding) describes, so a shard 0 session is only ever connected to the guilds its shard owns.
+
+## What a bot should send
+
+A bot session is never passive, so `active` changes nothing for it and a typical bot needs no [Lazy Request](/gateway/commands/#lazy-request) at all. It shapes traffic with `ignored_events` and pulls members with [Request Guild Members](/gateway/commands/#request-guild-members).
+
+A bot that does not process typing or reactions saves the most by ignoring `TYPING_START`, `MESSAGE_REACTION_ADD`, and `MESSAGE_REACTION_REMOVE`. Ignoring `PRESENCE_UPDATE` saves a bot nothing, because a bot receives no presence until it subscribes to one.
diff --git a/fluxer_docs/src/content/docs/gateway/events.md b/fluxer_docs/src/content/docs/gateway/events.md
new file mode 100644
index 000000000..a7d9bd259
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/events.md
@@ -0,0 +1,1138 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gateway events
+description: Every main Gateway Dispatch event, its payload, its delivery scope, and its replay behaviour.
+---
+
+A Dispatch is a message from the [Gateway](/gateway/overview/). It tells a client that something happened, such as a new message arriving or a member joining a guild. Every Dispatch has [Opcode](/gateway/opcodes-and-close-codes/#opcodes) 0, the name of the event, and the event's data. [Event filtering](/gateway/event-filtering/) defines the gates each one passes on its way to a socket.
+
+## Dispatch envelope
+
+| Field | Type | Description |
+| --- | --- | --- |
+| op | integer | Value 0 |
+| t | string | Uppercase event name |
+| s | integer | Non-negative session sequence |
+| d | any | Payload defined for the named event |
+
+```json
+{
+ "op": 0,
+ "t": "MESSAGE_CREATE",
+ "s": 17,
+ "d": { }
+}
+```
+
+[Ready](#ready) establishes sequence 1. Live Dispatches advance it by one. Replayed Dispatches keep their original sequence and can have gaps. [Resumed](#resumed) has the session's current sequence without advancing it and establishes the new live baseline. The sequence is local to one Gateway session and orders nothing across shards or HTTP operations.
+
+## Dispatch delivery
+
+A guild-scoped Dispatch is filtered by guild availability, then by channel visibility and permissions, then by the session's active or passive state, and finally by the session-level shard filter and `ignored_events` list. An account-scoped Dispatch is subject only to the session-level filters. [Event filtering](/gateway/event-filtering/) defines each gate.
+
+Most guild-scoped Dispatches have a `guild_id` string. Three identify the guild as `id`: [Guild Create](#guild-create), [Guild Sync](#guild-sync), and every [Guild Delete](#guild-delete) other than the one the guild itself dispatches when the guild is deleted. [Guild Counts Update](#guild-counts-update) and [Channel Member Counts Update](#channel-member-counts-update) have no top-level `guild_id`, and each entry in their `counts` array has its own.
+
+The originating session is excluded from a Dispatch only for [Message Reaction Add](#message-reaction-add) and [Message Reaction Remove](#message-reaction-remove) in a guild channel, and only when the request supplied a `session_id`. That field is removed from the payload. The same field on a direct message or group direct message reaction is forwarded to every recipient unchanged and excludes nobody. The actor that issues any other mutation receives the resulting Dispatch like every other eligible session.
+
+A Dispatch is buffered for [Resume](/gateway/commands/#resume) replay unless it is [Guild Sync](#guild-sync), [Guild Member List Update](#guild-member-list-update), or [Guild Members Chunk](#guild-members-chunk). Those three are delivered live and never retained. A single oversized Dispatch is delivered but not retained, as [Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) describes. [Ready](#ready) and the guild burst that follows it for a bot session are also sent outside the replay buffer. The initial [Call Create](#call-create) events are retained like any other Dispatch and are replayed on Resume.
+
+## Dispatch events
+
+| Event | Description | Scope |
+| --- | --- | --- |
+| [Ready](#ready) | The initial session state after a successful Identify | Session lifecycle |
+| [Resumed](#resumed) | Retained replay completes after a successful Resume | Session lifecycle |
+| [Sessions Replace](#sessions-replace) | The account's live session presence set is replaced | Current user |
+| [Auth Session Change](#auth-session-change) | The account's authentication session is rotated | Current user |
+| [Rate Limited](#rate-limited) | A member request is refused by its budget | Command response |
+| [User Update](#user-update) | The current user's account record changes | Current user |
+| [User Settings Update](#user-settings-update) | The current user's account-wide settings change | Current user |
+| [User Guild Settings Update](#user-guild-settings-update) | One guild notification record changes | Current user |
+| [User Note Update](#user-note-update) | The current user writes or clears a private note | Current user |
+| [User Pinned DMs Update](#user-pinned-dms-update) | The current user's pinned private channel set is replaced | Current user |
+| [User Connections Update](#user-connections-update) | The current user's external connection set is replaced | Current user |
+| [WebAuthn Credentials Update](#webauthn-credentials-update) | The current user's WebAuthn credential set is replaced | Current user |
+| [Relationship Add](#relationship-add) | The current user gains a relationship | Current user |
+| [Relationship Update](#relationship-update) | One of the current user's relationships changes | Current user |
+| [Relationship Remove](#relationship-remove) | The current user loses a relationship | Current user |
+| [Saved Message Create](#saved-message-create) | The current user saves a message | Current user |
+| [Saved Message Delete](#saved-message-delete) | The current user unsaves a message | Current user |
+| [Recent Mention Delete](#recent-mention-delete) | The current user removes a recent mention | Current user |
+| [Favorite Meme Create](#favorite-meme-create) | The current user saves a meme | Current user |
+| [Favorite Meme Update](#favorite-meme-update) | One of the current user's memes changes | Current user |
+| [Favorite Meme Delete](#favorite-meme-delete) | The current user deletes a meme | Current user |
+| [Guild Create](#guild-create) | A guild becomes available to the session | Guild connection |
+| [Guild Sync](#guild-sync) | A subscribed session receives a replacement guild snapshot | Guild connection |
+| [Guild Update](#guild-update) | A guild's configuration changes | Guild connection |
+| [Guild Delete](#guild-delete) | A guild leaves the session's visibility or becomes unavailable | Guild connection |
+| [Guild Role Create](#guild-role-create) | A role is created in a guild | Guild connection |
+| [Guild Role Update](#guild-role-update) | Exactly one role record changes | Guild connection |
+| [Guild Role Update Bulk](#guild-role-update-bulk) | One operation changes several role records together | Guild connection |
+| [Guild Role Delete](#guild-role-delete) | A role is deleted from a guild | Guild connection |
+| [Guild Emojis Update](#guild-emojis-update) | A guild's emoji collection is replaced | Guild connection |
+| [Guild Stickers Update](#guild-stickers-update) | A guild's sticker collection is replaced | Guild connection |
+| [Channel Create](#channel-create) | A channel becomes visible to the session | Channel visibility |
+| [Channel Update](#channel-update) | A visible channel changes | Channel visibility |
+| [Channel Update Bulk](#channel-update-bulk) | One operation changes several channels together | Channel visibility |
+| [Channel Delete](#channel-delete) | A channel leaves the session's visibility | Channel visibility |
+| [Channel Recipient Add](#channel-recipient-add) | A user joins a group direct message the session belongs to | Private channel |
+| [Channel Recipient Remove](#channel-recipient-remove) | A user leaves a group direct message the session belongs to | Private channel |
+| [Webhooks Update](#webhooks-update) | The webhook set of a viewable guild channel changes | Channel visibility |
+| [Invite Create](#invite-create) | An invite is created | Invite audience |
+| [Invite Delete](#invite-delete) | An invite is deleted | Invite audience |
+| [Guild Member Add](#guild-member-add) | A user becomes a member of a connected guild | Guild connection |
+| [Guild Member Update](#guild-member-update) | A member's guild state or public user representation changes | Guild connection |
+| [Guild Member Remove](#guild-member-remove) | A user stops being a member of a connected guild | Guild connection |
+| [Guild Members Chunk](#guild-members-chunk) | A bounded member result answers Request Guild Members | Command response |
+| [Guild Member List Update](#guild-member-list-update) | A subscribed member list resynchronises the subscriber's ranges | Member list subscription |
+| [Guild Audit Log Entry Create](#guild-audit-log-entry-create) | An audit log entry is written in a guild | Holders of `VIEW_AUDIT_LOG` |
+| [Guild Ban Add](#guild-ban-add) | A guild ban is created | Guild connection |
+| [Guild Ban Remove](#guild-ban-remove) | A guild ban is removed | Guild connection |
+| [Presence Update](#presence-update) | One visible presence changes | Presence subscription |
+| [Presence Update Bulk](#presence-update-bulk) | A recovering guild delivers its visible presences together | Guild connection |
+| [Passive Updates](#passive-updates) | Passive channel watermarks and voice states advance for one session | Passive session |
+| [Message Create](#message-create) | A visible message is created | Channel visibility |
+| [Message Update](#message-update) | A visible message changes and is republished in full | Message access |
+| [Message Delete](#message-delete) | One visible message is deleted | Message access |
+| [Message Delete Bulk](#message-delete-bulk) | Several messages in one channel are deleted together | Channel visibility |
+| [Message ACK](#message-ack) | The current user's read state advances for a channel | Current user |
+| [Message Reaction Add](#message-reaction-add) | A user adds a reaction to a message | Message access |
+| [Message Reaction Add Many](#message-reaction-add-many) | A debouncing session receives several reaction additions as one event | Message access |
+| [Message Reaction Remove](#message-reaction-remove) | One user's reaction is removed from a message | Message access |
+| [Message Reaction Remove All](#message-reaction-remove-all) | Every reaction is removed from a message at once | Message access |
+| [Message Reaction Remove Emoji](#message-reaction-remove-emoji) | Every reaction using one emoji is removed from a message | Message access |
+| [Typing Start](#typing-start) | A visible user begins typing in a channel | Channel visibility |
+| [Channel Pins Update](#channel-pins-update) | A channel's most recent pin time changes | Channel visibility |
+| [Channel Pins ACK](#channel-pins-ack) | The current user acknowledges a channel's pins | Current user |
+| [Voice State Update](#voice-state-update) | A guild or call participant's voice state changes | Channel visibility |
+| [Voice State Ack](#voice-state-ack) | The session's own voice mutation is applied or rejected | Current session |
+| [Voice Server Update](#voice-server-update) | The session receives or replaces its own voice grant | Current session |
+| [Entrance Sound Play](#entrance-sound-play) | A participant's entrance sound plays in a voice channel | Voice channel |
+| [Call Create](#call-create) | A DM or group DM call begins or becomes visible | Call recipient |
+| [Call Update](#call-update) | The ringing set, participant roster, or region of a call changes | Call recipient |
+| [Call Delete](#call-delete) | A call ends or becomes unavailable | Call recipient |
+| [Guild Counts Update](#guild-counts-update) | Member and online counts are returned for connected guilds | Command response |
+| [Channel Member Counts Update](#channel-member-counts-update) | Per-channel member and online counts are returned for one guild | Command response |
+
+## Session and current user
+
+### READY
+
+The initial session state. Sent once after a successful [Identify](/gateway/commands/#identify), always with sequence 1.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id | string | Identifier for [Resume](/gateway/commands/#resume) |
+| version | integer | Gateway API version the connection negotiated, always `1` |
+| user | [user](/http-api/users/#user-object) object | The authenticated account in its private representation |
+| guilds1 | array[[guild ready object](#guild-ready-object)] | The session's guilds |
+| private_channels | array[[channel](/http-api/channels/#channel-object) object] | Direct message and group direct message channels |
+| relationships2 | array[[relationship](/http-api/users/relationships/#relationship-object) object] | The account's relationships |
+| presences3 | array[[presence object](#presence-object)] | Visible presences at connection time |
+| users4 | array[[partial user](/http-api/users/#partial-user-object) object] | Users referenced by the payload |
+| sessions | array[[session presence object](#session-presence-object)] | The account's other live sessions |
+| read_states | array[[read state](/http-api/read-states/#read-state-object) object] | Per-channel read state |
+| user_settings | ?[user settings](/http-api/users/#user-settings-object) object | Account-wide settings |
+| user_guild_settings | array[[user guild settings](/http-api/users/settings/#user-guild-settings-object) object] | Per-guild notification settings |
+| notes | map[snowflake, string] | Private notes keyed by user ID |
+| pinned_dms | array[snowflake] | Pinned private channel IDs |
+| favorite_memes | array[[meme](/http-api/memes/#meme-object) object] | Saved memes |
+| webauthn_credentials | array[[WebAuthn credential object](#webauthn-credential-object)] | Registered WebAuthn credentials |
+| rtc_regions | array[[RTC region object](#rtc-region-object)] | Voice regions, ordered nearest first |
+| country_code | string | Country resolved from the connecting address, `US` when the address resolves to none |
+| latitude? | string | Latitude resolved from the connecting address, rendered as a decimal string |
+| longitude? | string | Longitude resolved from the connecting address, rendered as a decimal string |
+| auth_session_id_hash? | string | Base64url hash identifying the authentication session |
+| shard? | array[integer] | The accepted `[shard_id, shard_count]` pair, present only when Identify supplied one |
+| _timings? | object | HTTP-side timing breakdown, present only for a staff account |
+| _timings_gw? | object | Gateway-side timing breakdown, present only for a staff account |
+
+1 A bot session always receives an empty array here and the guilds arrive as the [Guild Create](#guild-create) burst described below
+
+2 Each entry has its `user` field removed and the removed accounts appear in `users` instead, so a client resolves a relationship through the entry's `id`
+
+3 A bot session always receives an empty array here
+
+4 Collected from the account's relationships, the recipients of its private channels, and the members in `guilds`, deduplicated by account ID. A bot session always receives an empty array here
+
+Ready is sent outside the replay buffer, so a [Resume](/gateway/commands/#resume) never replays it.
+
+A bot session receives one [Guild Create](#guild-create) per available guild immediately after Ready, and one [Guild Delete](#guild-delete) per unavailable guild. Those Dispatches are also sent outside the replay buffer.
+
+Shortly after Ready, every session receives one [Call Create](#call-create) for each of its private channels that has an active call.
+
+#### Guild ready object
+
+The same structure appears in Ready, [Guild Create](#guild-create), and [Guild Sync](#guild-sync).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | Guild ID |
+| properties | [guild](/http-api/guilds/#guild-object) object | Guild record without its roles, channels, emojis, stickers, or members |
+| roles | array[[guild role](/http-api/permissions/#guild-role-object) object] | Every role in the guild |
+| channels | array[[channel](/http-api/channels/#channel-object) object] | Channels the session can view |
+| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | Guild emojis |
+| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | Guild stickers |
+| members1 | array[[guild member](/http-api/guild-members/#guild-member-object) object] | The members the session needs immediately |
+| member_count | integer | Total member count |
+| online_count2 | integer | Online member count |
+| presences3 | array[[presence object](#presence-object)] | Always an empty array |
+| voice_states | array[[voice state object](#voice-state-object)] | Voice states in channels the session can view |
+| joined_at | ?ISO8601 timestamp | When the account joined the guild, null when the account is not a member |
+| unavailable? | boolean | Whether the guild is unavailable |
+| unavailable_hidden? | boolean | Whether an unavailable guild is hidden from the client |
+
+1 The session's own member object plus the member object of every participant named by `voice_states`, and nothing else. A client that needs the rest of the roster asks for it with [Request Guild Members](/gateway/commands/#request-guild-members) or subscribes to a member list through [Lazy Request](/gateway/commands/#lazy-request)
+
+2 Counts the members that hold a live Gateway session and publish a status other than `offline` or `invisible`. Every recipient receives the same guild-wide figure. [Guild Counts Update](#guild-counts-update) reports a per-viewer count
+
+3 Guild presences arrive as separate [Presence Update](#presence-update) and [Presence Update Bulk](#presence-update-bulk) Dispatches
+
+An unavailable guild is reduced to `id` and `unavailable: true`, plus `unavailable_hidden: true` when the guild is hidden. It has none of the other fields.
+
+Inside [Ready](#ready), and inside the [Guild Create](#guild-create) burst a bot receives immediately after Ready, each member of this object has its `user` replaced by `{"id": "..."}`. On a user session the removed accounts appear in the Ready payload's `users` array. A bot's `users` array is empty, so a bot pulls those accounts with [Request Guild Members](/gateway/commands/#request-guild-members). A [Guild Create](#guild-create) sent later in the session, and every [Guild Sync](#guild-sync), have the members with `user` intact.
+
+#### Session presence object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id | string | Session identifier, or the literal `all` for the aggregate entry |
+| status | string | `online`, `idle`, `dnd`, `invisible`, or `offline` |
+| afk | boolean | Whether the session is away |
+| mobile | boolean | Whether the session is mobile |
+
+The first entry always has `session_id: "all"` and the account's flattened status.
+
+#### WebAuthn credential object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Credential ID |
+| name | string | Credential name |
+| created_at | ISO8601 timestamp | When the credential was registered |
+| last_used_at | ?ISO8601 timestamp | When the credential was last used |
+
+#### RTC region object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Region ID |
+| name | string | Human-readable region name |
+| emoji | string | Region emoji |
+
+The array always begins with a synthetic entry whose `id` is `automatic`, `name` is `Automatic`, and `emoji` is the globe. Choosing that entry leaves the region to the server. Every other entry is a region the account can select, ordered by distance from the `latitude` and `longitude` in the [Identify properties](/gateway/commands/#identify-properties-object). Fluxer orders by distance only when both values parse as finite numbers, and otherwise orders by region ID.
+
+### RESUMED
+
+Sent after a successful [Resume](/gateway/commands/#resume) has replayed every retained Dispatch above the supplied sequence.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| _timings_gw? | object | Gateway-side timing breakdown, present only for a staff account |
+
+The payload is otherwise empty. Resumed has the session's current sequence in `s` without advancing it, and that sequence becomes the new live baseline.
+
+### SESSIONS_REPLACE
+
+The account's set of live sessions changed. The payload is a bare JSON array of [session presence objects](#session-presence-object) and replaces the client's copy in full. [Ready](#ready) sends the initial set as `sessions`.
+
+### AUTH_SESSION_CHANGE
+
+The account's authentication session was rotated, for example by a password change on another device.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| old_auth_session_id_hash | string | Base64url hash of the authentication session that was replaced |
+| new_auth_session_id_hash | string | Base64url hash of the replacement authentication session |
+| new_token | string | The token that replaces the one the client currently holds |
+
+Every session of the account receives the event, including the one that caused the rotation. A client MUST use `new_token` for every later HTTP request and for any later [Resume](/gateway/commands/#resume) or [Identify](/gateway/commands/#identify). A client whose own `auth_session_id_hash` from [Ready](#ready) equals `old_auth_session_id_hash` MUST replace it with `new_auth_session_id_hash`.
+
+### RATE_LIMITED
+
+A [Request Guild Members](/gateway/commands/#request-guild-members) command was refused by the bot full-member-list budget. Delivered to the requesting session alone.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| opcode | integer | The refused opcode, which is always `8` |
+| retry_after | number | Seconds until the request can be retried |
+| meta | object | Context for the refused request |
+
+`meta` has `guild_id` and, when the request named exactly one guild and supplied a valid nonce, `nonce`.
+
+That budget admits one unfiltered member request per bot account and guild every 30,000 ms, and `retry_after` is the remainder of that window expressed in seconds. Every other command refusal is silent.
+
+### USER_UPDATE
+
+The current user's account record changed. The payload is the complete [user object](/http-api/users/#user-object) in its private representation.
+
+Fluxer also pushes the change to every account that holds a presence subscription for this user. Those accounts receive it as a [Presence Update](#presence-update) whose `user` is the new representation, so a username or avatar change reaches them without a second lookup. Fluxer sends none of those when the user's published presence is `offline`.
+
+### USER_SETTINGS_UPDATE
+
+The current user's account-wide settings changed. The payload is the complete [user settings object](/http-api/users/#user-settings-object).
+
+Fluxer republishes the account's presence on every settings update, whether or not `status` or `custom_status` changed. A `status` of `offline` in the payload is treated as `invisible`, and it forces every live session of the account to that status.
+
+### USER_GUILD_SETTINGS_UPDATE
+
+One guild's notification settings changed. The payload is the complete user guild settings object for that guild.
+
+### USER_NOTE_UPDATE
+
+The current user wrote or cleared a private note.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | User the note is about |
+| note | string | Note text, empty when cleared |
+
+### USER_PINNED_DMS_UPDATE
+
+The current user's pinned private channel set changed. The payload is a bare JSON array of channel ID strings in pinned order and replaces the client's copy in full. [Ready](#ready) sends the initial set as `pinned_dms`.
+
+### USER_CONNECTIONS_UPDATE
+
+The current user's external connection set changed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| connections | array[connection object] | Every connection the account holds |
+
+The array replaces the client's copy in full.
+
+### WEBAUTHN_CREDENTIALS_UPDATE
+
+The current user's WebAuthn credential set changed. The payload is a bare JSON array of [WebAuthn credential objects](#webauthn-credential-object) and replaces the client's copy in full. [Ready](#ready) sends the initial set as `webauthn_credentials`.
+
+### RELATIONSHIP_ADD
+
+The current user gained a relationship. The payload is the complete relationship object.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The other user's ID |
+| type | integer | [Relationship type](/http-api/users/relationships/#relationship-types) |
+| user | [partial user](/http-api/users/#partial-user-object) object | The other user |
+| since? | ISO8601 timestamp | When the relationship was created, absent when the record has no timestamp |
+| nickname | ?string | Private nickname for the other user |
+| share_voice_activity | boolean | Whether the current user shares voice activity with this friend on the Active Now panel |
+| friend_shares_voice_activity1 | boolean | Whether the other user shares voice activity with the current user |
+
+1 Always `true` on this Dispatch, even for a friendship whose counterpart has sharing off
+
+A client that needs the real value MUST read it from [List relationships](/http-api/users/relationships/#list-relationships).
+
+### RELATIONSHIP_UPDATE
+
+An existing relationship changed. The payload has the same structure as [Relationship Add](#relationship-add).
+
+`friend_shares_voice_activity` is the counterpart's real setting only on the pair of Dispatches that [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) sends to both parties of each friendship. Every other Relationship Update sends `true`.
+
+### RELATIONSHIP_REMOVE
+
+A relationship ended.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The other user's ID |
+
+### SAVED_MESSAGE_CREATE
+
+The current user saved a message. The payload is the complete [message object](/http-api/messages/#message-object) as that user sees it.
+
+### SAVED_MESSAGE_DELETE
+
+The current user unsaved a message.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_id | snowflake | The message that is no longer saved |
+
+### RECENT_MENTION_DELETE
+
+The current user removed a message from the recent mention feed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_id | snowflake | The message removed from the feed |
+
+### FAVORITE_MEME_CREATE
+
+The current user saved a meme. The payload is the complete [meme object](/http-api/memes/#meme-object).
+
+### FAVORITE_MEME_UPDATE
+
+One of the current user's memes changed. The payload is the complete [meme object](/http-api/memes/#meme-object).
+
+### FAVORITE_MEME_DELETE
+
+The current user deleted a meme.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| meme_id | snowflake | The deleted meme |
+
+## Guilds and channels
+
+### GUILD_CREATE
+
+A guild became available to the session. The payload is a [guild ready object](#guild-ready-object).
+
+Every collection in the event replaces the client's copy for that guild.
+
+A user session receives Guild Create when a guild becomes available after Ready, for example after joining one or after an unavailable guild recovers. A bot session receives one for every guild in the burst that follows Ready.
+
+### GUILD_SYNC
+
+A session that asked for a sync through [Lazy Request](/gateway/commands/#lazy-request) receives a replacement snapshot of the guild. The payload is a [guild ready object](#guild-ready-object) and has the same replacement semantics as [Guild Create](#guild-create).
+
+Fluxer sends a sync when the subscription flips the guild between active and passive, and when `sync: true` names a guild the session has not already synced. A second `sync: true` for an already-synced guild sends nothing.
+
+### GUILD_UPDATE
+
+A guild's configuration changed. The payload is the complete [guild object](/http-api/guilds/#guild-object) with `guild_id` added, which repeats the object's own `id`.
+
+An unavailable guild still dispatches Guild Update, and nothing else. A client learns from it that a guild entered or left the unavailable state.
+
+### GUILD_DELETE
+
+A guild left the session's visibility, or became unavailable.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | Guild ID |
+| guild_id?1 | snowflake | Repeats `id` |
+| unavailable? | boolean | True when the guild is temporarily unavailable |
+| unavailable_hidden? | boolean | True when an unavailable guild is hidden from the client |
+
+1 Present only when the guild itself is deleted. The payload has `id` alone when the account leaves a guild or is removed from one, and `id` with `unavailable` in the unavailable form
+
+Without `unavailable`, the account is no longer a member and the client discards the guild. With `unavailable: true`, the guild is retained in a placeholder state and a later [Guild Create](#guild-create) restores it.
+
+### GUILD_ROLE_CREATE
+
+A role was created in a guild.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the role belongs to |
+| role | [guild role](/http-api/permissions/#guild-role-object) object | The created role |
+
+### GUILD_ROLE_UPDATE
+
+Exactly one role record changed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the role belongs to |
+| role | [guild role](/http-api/permissions/#guild-role-object) object | The role's complete updated representation |
+
+### GUILD_ROLE_UPDATE_BULK
+
+One operation changed several roles together, most often a reorder.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the roles belong to |
+| roles | array[[guild role](/http-api/permissions/#guild-role-object) object] | Every changed role in its complete updated representation |
+
+### GUILD_ROLE_DELETE
+
+A role was deleted from a guild.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the role belonged to |
+| role_id | snowflake | The deleted role |
+
+### GUILD_EMOJIS_UPDATE
+
+A guild's emoji collection changed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the emojis belong to |
+| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | The complete emoji collection |
+
+The array replaces the client's copy for that guild. Fluxer does not send per-emoji create, update, or delete events.
+
+### GUILD_STICKERS_UPDATE
+
+A guild's sticker collection changed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the stickers belong to |
+| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | The complete sticker collection |
+
+The array replaces the client's copy for that guild. Fluxer does not send per-sticker create, update, or delete events.
+
+### CHANNEL_CREATE
+
+A channel became visible to the session, whether newly created or newly permitted. The payload is the complete [channel object](/http-api/channels/#channel-object), with `guild_id` present for a guild channel.
+
+### CHANNEL_UPDATE
+
+A visible channel changed. The payload is the complete [channel object](/http-api/channels/#channel-object).
+
+### CHANNEL_UPDATE_BULK
+
+One operation changed several channels together, most often a reorder.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the channels belong to |
+| channels | array[[channel](/http-api/channels/#channel-object) object] | Every changed channel in its complete updated representation |
+
+Each recipient's copy of `channels` is trimmed to the channels that recipient can view, so two sessions in the same guild can receive different arrays from one operation. A recipient whose trimmed array would be empty receives no Dispatch at all and consumes no sequence number.
+
+### CHANNEL_DELETE
+
+A channel left the session's visibility, whether deleted or newly hidden. The payload is the complete [channel object](/http-api/channels/#channel-object) as it was before the change.
+
+Recipients are the sessions that could see the channel before it was deleted.
+
+### CHANNEL_RECIPIENT_ADD
+
+A user joined a group direct message the session belongs to.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Group direct message channel |
+| user | [partial user](/http-api/users/#partial-user-object) object | The user that joined |
+
+### CHANNEL_RECIPIENT_REMOVE
+
+A user left a group direct message the session belongs to.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Group direct message channel |
+| user | [partial user](/http-api/users/#partial-user-object) object | The user that left |
+
+### WEBHOOKS_UPDATE
+
+The webhook set of a guild channel changed. The event has no webhook data, so a client that needs the new set reads it over the HTTP API.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the channel belongs to |
+| channel_id | snowflake | Channel whose webhooks changed |
+
+### INVITE_CREATE
+
+An invite was created. The payload is the complete [invite object](/http-api/invites/#invite-object) extended with its [invite metadata](/http-api/invites/#invite-metadata-object).
+
+A guild invite reaches the sessions holding `MANAGE_CHANNELS` on the invite's channel. A group direct message invite reaches every recipient of that group, with no permission check.
+
+### INVITE_DELETE
+
+An invite was deleted.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The deleted invite code |
+| channel_id? | snowflake | Channel the invite pointed at, absent when the invite stored none |
+| guild_id?1 | snowflake | Guild the invite belonged to |
+
+1 Present on every guild invite. A group direct message invite has no `guild_id`
+
+Recipients are chosen the same way as for [Invite Create](#invite-create).
+
+## Members and moderation
+
+### GUILD_MEMBER_ADD
+
+A user became a member of a guild the session is connected to. The payload is the complete [guild member object](/http-api/guild-members/#guild-member-object) with `guild_id` added.
+
+### GUILD_MEMBER_UPDATE
+
+A member's guild state or public user representation changed. The payload is the complete [guild member object](/http-api/guild-members/#guild-member-object) with `guild_id` added.
+
+In a large guild, a passive session receives this event only when the subject is its own user.
+
+### GUILD_MEMBER_REMOVE
+
+A user stopped being a member of a guild the session is connected to.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the user left |
+| user1 | object | The user that is no longer a member |
+
+1 The object has `id` alone. No other account field is sent, so a client MUST resolve the account from state it already holds
+
+In a large guild, a passive session receives this event only when the subject is its own user.
+
+### GUILD_MEMBERS_CHUNK
+
+Answers [Request Guild Members](/gateway/commands/#request-guild-members) for the requesting session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the members belong to |
+| members | array[[guild member](/http-api/guild-members/#guild-member-object) object] | Up to 1,000 members |
+| chunk_index | integer | Zero-based index of this chunk |
+| chunk_count | integer | Total chunks in this response |
+| presences? | array[[presence object](#presence-object)] | Present only when the request set `presences` and at least one member has a visible presence |
+| nonce? | string | Echoed only when the request named exactly one guild and supplied a valid nonce |
+
+A request that matches no member still produces one chunk with an empty `members` array, `chunk_index` 0, and `chunk_count` 1.
+
+This event is delivered live and is never retained for [Resume](/gateway/commands/#resume) replay.
+
+### GUILD_MEMBER_LIST_UPDATE
+
+A member list the session subscribed to through [Lazy Request](/gateway/commands/#lazy-request) resynchronises the subscriber's ranges.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the list belongs to |
+| id | string | List identifier, which is always the channel ID as a string |
+| channel_id? | snowflake | Channel the list is scoped to |
+| member_count | integer | Total members in the list |
+| online_count | integer | Online members in the list |
+| groups | array[[member list group object](#member-list-group-object)] | Group headers in list order |
+| ops | array[[member list operation object](#member-list-operation-object)] | Operations to apply |
+
+#### Member list group object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | Group identifier, which is a hoisted role ID, `online`, or `offline` |
+| count | integer | Members in the group |
+
+1 Hoisted role groups come first in role order, then `online`, then `offline`
+
+A group whose count is `0` is omitted. The `offline` group is also omitted once it holds more than 1,000 members, and in that case the offline members are omitted from `items` as well. `member_count` can then exceed the number of items a client can ever read back.
+
+#### Member list operation object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| op | string | Operation kind, which is always `SYNC` |
+| range | array[integer] | Inclusive `[start, end]` range this operation replaces |
+| items | array[[member list item object](#member-list-item-object)] | Replacement items for the range |
+
+`SYNC` is the only operation Fluxer sends. A client MUST ignore an operation whose `op` it does not recognise and whose range fails the bounds in [Lazy Request](/gateway/commands/#lazy-request).
+
+#### Member list item object
+
+Each item has exactly one of the two fields.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| group? | [member list group object](#member-list-group-object) | A group header occupying one list position |
+| member?1 | [guild member](/http-api/guild-members/#guild-member-object) object | A member of the list |
+
+1 Extended with a `presence` field that always exists. It is the guild's [presence object](#presence-object) for that member when the member is visibly online to the guild, and otherwise the placeholder `{"status": "offline", "mobile": false, "afk": false}`
+
+### GUILD_AUDIT_LOG_ENTRY_CREATE
+
+An audit log entry was written. The payload has the shape of a [guild audit log entry object](/http-api/guild-audit-logs/#guild-audit-log-entry-object) with `guild_id` added. It always has `id`, `action_type`, `user_id`, and `target_id`. It has `reason` when the request supplied one, `options` when the entry recorded any metadata, and `changes` when at least one change survives scrubbing.
+
+The `ip` change key is stripped from `changes`, so an entry whose only change was `ip` has no `changes` at all. A client MUST treat an absent `options` or `changes` as an empty set.
+
+Recipients are every session in the guild that holds `VIEW_AUDIT_LOG`, including the session that performed the action.
+
+### GUILD_BAN_ADD
+
+A guild ban was created.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the ban was created in |
+| user1 | object | The banned user |
+
+1 The object has `id` alone. No other account field is sent
+
+### GUILD_BAN_REMOVE
+
+A guild ban was removed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the ban was removed from |
+| user1 | object | The unbanned user |
+
+1 The object has `id` alone. No other account field is sent
+
+### PRESENCE_UPDATE
+
+One visible presence changed. The payload is a [presence object](#presence-object).
+
+A session receives a presence for a friend, for a recipient of a group direct message it belongs to, and for a guild member it subscribed to through the `members` array of a [Lazy Request](/gateway/commands/#lazy-request). A bot session holds no friend or group direct message subscription, so the guild path is the only one that reaches it.
+
+The session holds every Presence Update from Identify until it dispatches [Ready](#ready), so a client never receives a presence before its initial state arrives. Right after Ready the session drops every held presence for a subject the Ready `presences` array already covers and releases the rest in one burst. When Ready has not been dispatched 10,000 ms after Identify, the session releases the queue anyway.
+
+After the burst a Dispatch is held again when the session cannot place it. The session holds a guild-scoped Presence Update while it is not connected to the guild in `guild_id`. It holds an account-scoped one while the subject is neither a friend nor a recipient of a group direct message the session belongs to. A later [Relationship Add](#relationship-add), [Relationship Update](#relationship-update), [Channel Create](#channel-create), [Channel Update](#channel-update), or [Channel Recipient Add](#channel-recipient-add) naming the same account releases the held Dispatch. The hold queue keeps at most 2,048 entries and drops the oldest, so a held presence that is never placed is eventually discarded.
+
+#### Presence object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [partial user](/http-api/users/#partial-user-object) object | The user the presence belongs to |
+| status | string | `online`, `idle`, `dnd`, or `offline` |
+| mobile | boolean | Whether the user's active status comes from a mobile session |
+| afk | boolean | Whether every one of the user's sessions is away |
+| custom_status | ?[custom status object](#custom-status-object) | The user's custom status |
+| guild_id? | snowflake | Guild context, present when the presence arrived through a guild |
+
+An account's published `status` is the highest-precedence status across its live sessions, resolved in the order `dnd`, `online`, `idle`, `invisible`, and finally `offline` when no session is live. A session that selected `invisible`, and an account with no live session, both publish `status: "offline"`. A session that lost its transport is published as `offline` 5,000 ms later, even though it stays resumable for the rest of its 60,000 ms retention window, and a successful [Resume](/gateway/commands/#resume) republishes the status it last selected.
+
+`mobile` is true only when the account's resolved status is `online` and at least one online session declared itself mobile. `afk` is false whenever `mobile` is true, and otherwise true only when every live session is away.
+
+`custom_status` is suppressed to `null` whenever the published status is `offline`, so an invisible account never leaks one.
+
+#### Custom status object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text | ?string | Custom status text |
+| expires_at | ?ISO8601 timestamp | When the custom status expires |
+| emoji_id | ?snowflake | Custom emoji ID |
+| emoji_name | ?string | Unicode emoji, or the custom emoji's name |
+| emoji_animated | boolean | Whether the custom emoji is animated |
+
+### PRESENCE_UPDATE_BULK
+
+Several visible presences are delivered in one Dispatch. A guild sends this only when it leaves the unavailable state. Every session that stayed connected receives it immediately after the [Guild Create](#guild-create) that restores the guild.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild context applied to every entry |
+| presences1 | array[[presence object](#presence-object)] | The presences, at most 500 per Dispatch |
+
+1 Only visibly online presences are included, and the recipient's own presence is removed. A batch that would be empty produces no Dispatch, and a set larger than 500 is split across consecutive Dispatches
+
+Every entry has the batch's guild context. A client MUST treat each entry as if it named `guild_id` itself.
+
+### PASSIVE_UPDATES
+
+A passive session in a guild with more than 250 members receives the changes it would otherwise have missed. The guild runs the cycle every 30,000 ms and sends nothing when the cycle finds no change.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the update covers |
+| channels | map[snowflake, snowflake] | Changed `last_message_id` per channel |
+| voice_states?1 | array[[voice state object](#voice-state-object)] | Changed voice states in channels the session can view |
+
+1 Omitted when the set would be empty. A participant that left a viewable channel since the previous cycle appears here with `channel_id` set to null
+
+`channels` contains only the channels whose `last_message_id` changed since the previous update for that session, and only channels that session can view. `voice_states` contains only the voice states whose `version` advanced.
+
+## Messages and reactions
+
+### MESSAGE_CREATE
+
+A visible message was created. The payload is the complete [message object](/http-api/messages/#message-object) with the fields below added.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_type | integer | [Channel type](/http-api/channels/#channel-types) of the channel the message was created in |
+| nicks? | map[snowflake, string] | Group direct message nicknames, present only for a group direct message that stores at least one |
+| mention_here? | boolean | Always `true` when present, and present only when the message has a here mention |
+| guild_id? | snowflake | Guild the channel belongs to |
+| member?1 | [guild member](/http-api/guild-members/#guild-member-object) object | The author's guild member object, present in a guild channel |
+
+1 The `user` field is removed from it, and the account is in the message's `author`
+
+Message Create alone overrides both the passive filter and the `ignored_events` list, and the two use different tests. The passive filter is defeated by a direct mention, a mention of one of the user's roles, an everyone mention, or a here mention. The `ignored_events` list is defeated by a direct, everyone, or here mention alone.
+
+### MESSAGE_UPDATE
+
+A visible message changed. The payload is the complete current [message object](/http-api/messages/#message-object). In a guild channel it is extended with `guild_id` and with `member`, the author's guild member object with its `user` field removed. It has no `channel_type`, `nicks`, or `mention_here`.
+
+A recipient must hold `READ_MESSAGE_HISTORY` on the channel, or the message must be newer than the guild's message history cutoff.
+
+### MESSAGE_DELETE
+
+One visible message was deleted.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The deleted message |
+| channel_id | snowflake | Channel the message was in |
+| content?1 | ?string | Content the message held before it was deleted |
+| author_id?1 | snowflake | Account that wrote the message |
+| guild_id? | snowflake | Guild the channel belongs to |
+| member?2 | [guild member](/http-api/guild-members/#guild-member-object) object | The author's guild member object, present in a guild channel |
+
+1 Both fields are omitted when the deletion came from moderation tooling, and `author_id` is also omitted for a message with no author
+
+2 The `user` field is removed from it, and the whole field is absent when `author_id` is absent or the author is no longer a member
+
+### MESSAGE_DELETE_BULK
+
+Several messages in one channel were deleted together.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ids | array[snowflake] | The deleted messages |
+| channel_id | snowflake | Channel the messages were in |
+| guild_id? | snowflake | Guild the channel belongs to |
+
+### MESSAGE_ACK
+
+The current user's read state advanced for a channel, usually because another of the account's sessions read it.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel whose read state advanced |
+| message_id | snowflake | Message the read state now points at |
+| mention_count | integer | Remaining mention count for the channel |
+| manual? | boolean | Whether the acknowledgement was explicit |
+| version? | string | Read state version as a decimal string |
+
+### MESSAGE_REACTION_ADD
+
+A user added a reaction to a message.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | User that reacted |
+| channel_id | snowflake | Channel the message is in |
+| message_id | snowflake | Message that was reacted to |
+| emoji | [reaction emoji object](#reaction-emoji-object) | The emoji |
+| guild_id? | snowflake | Guild the channel belongs to |
+| member? | [guild member](/http-api/guild-members/#guild-member-object) object | The reacting user's guild member object, present in a guild channel |
+
+In a guild channel the session named by the request's `session_id` is excluded and that field is removed from the payload. In a direct message or group direct message the field is delivered as `session_id` and excludes nobody, so a client MUST tolerate receiving its own reaction back.
+
+#### Reaction emoji object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Unicode emoji, or the custom emoji's name |
+| id? | snowflake | Custom emoji ID, absent for a Unicode emoji |
+| animated?1 | boolean | Whether the custom emoji is animated |
+
+1 Present only on the [Message Reaction Add](#message-reaction-add) that creates the first reaction with that emoji on the message. An addition to an emoji that already has a reactor, a [Message Reaction Remove](#message-reaction-remove), and a [Message Reaction Remove Emoji](#message-reaction-remove-emoji) omit the field
+
+Neither `id` nor `animated` is ever null. A Unicode reaction omits both, so a client distinguishes the two forms by the presence of `id`. A client MUST NOT read an absent `animated` as `false`.
+
+### MESSAGE_REACTION_ADD_MANY
+
+A session that set the `DEBOUNCE_MESSAGE_REACTIONS` [session flag](/gateway/commands/#session-flags) coalesces a run of reaction additions in a direct message or group direct message into one Dispatch. A reaction in a guild channel is never coalesced and arrives as its own [Message Reaction Add](#message-reaction-add). The session opens a 650 ms window on the first addition and sends the coalesced Dispatch when the window closes. The window holds at most 512 additions and drops the oldest beyond that.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | Channel the message is in |
+| message_id1 | snowflake | Message that was reacted to |
+| guild_id?1 | snowflake | Guild the channel belongs to |
+| reactions | array[[reaction addition object](#reaction-addition-object)] | The coalesced additions, in arrival order |
+
+1 Taken from the first addition in the window. The window is per session, so a run that spans several messages reports every addition under the first message's channel, message, and guild, and a client MUST read each addition's own identity from that entry
+
+A window that closes holding exactly one addition sends [Message Reaction Add](#message-reaction-add) instead, and a session without the flag receives one Message Reaction Add per addition.
+
+#### Reaction addition object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | User that reacted |
+| emoji | [reaction emoji object](#reaction-emoji-object) | The emoji |
+| member? | [guild member](/http-api/guild-members/#guild-member-object) object | The reacting user's guild member object, present in a guild channel |
+
+### MESSAGE_REACTION_REMOVE
+
+One user's reaction was removed from a message.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | User whose reaction was removed |
+| channel_id | snowflake | Channel the message is in |
+| message_id | snowflake | Message the reaction was on |
+| emoji | [reaction emoji object](#reaction-emoji-object) | The emoji |
+| guild_id? | snowflake | Guild the channel belongs to |
+| member? | [guild member](/http-api/guild-members/#guild-member-object) object | The user's guild member object, present in a guild channel |
+
+Exclusion works exactly as it does for [Message Reaction Add](#message-reaction-add), so the acting session is dropped in a guild channel and kept in a private one.
+
+For a debouncing session, a removal that matches a still-buffered addition by message, user, and emoji removes both, so neither reaches the socket.
+
+### MESSAGE_REACTION_REMOVE_ALL
+
+Every reaction was removed from a message at once.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel the message is in |
+| message_id | snowflake | Message whose reactions were cleared |
+| guild_id? | snowflake | Guild the channel belongs to |
+
+### MESSAGE_REACTION_REMOVE_EMOJI
+
+Every reaction using one emoji was removed from a message.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel the message is in |
+| message_id | snowflake | Message the reactions were on |
+| emoji | [reaction emoji object](#reaction-emoji-object) | The emoji whose reactions were removed |
+| guild_id? | snowflake | Guild the channel belongs to |
+
+### TYPING_START
+
+A visible user began typing in a channel.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel the user is typing in |
+| user_id | snowflake | User that started typing |
+| timestamp | integer | Unix seconds when typing started |
+| guild_id? | snowflake | Guild the channel belongs to |
+| member? | [guild member](/http-api/guild-members/#guild-member-object) object | The typing user's guild member object, present in a guild channel |
+
+The `typing` override set through [Lazy Request](/gateway/commands/#lazy-request) decides delivery in a guild. With no override, a session receives the event when it is active in the guild or when the guild has 250 members or fewer, so a passive session in a small guild still receives it. A guild that sets the `TYPING_EVENTS` bit in its [disabled operations](/http-api/guilds/#disabled-guild-operations) produces the event for nobody.
+
+### CHANNEL_PINS_UPDATE
+
+A channel's most recent pin time changed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel whose pins changed |
+| last_pin_timestamp | ?ISO8601 timestamp | Time of the most recent pin, null when nothing is pinned |
+| guild_id? | snowflake | Guild the channel belongs to |
+
+### CHANNEL_PINS_ACK
+
+The current user acknowledged a channel's pins. Every session of the account receives it, including the one that issued the acknowledgement.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel whose pins were acknowledged |
+| timestamp | ISO8601 timestamp | Time the acknowledgement recorded for the channel |
+
+## Voice and calls
+
+### VOICE_STATE_UPDATE
+
+A participant's voice state changed. The payload is a [voice state object](#voice-state-object).
+
+Recipients are the sessions that can view the voice channel, passive sessions included. A passive session in a large guild also receives the changed voice states through [Passive Updates](#passive-updates).
+
+A `channel_id` of null means the participant left.
+
+#### Voice state object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | ?snowflake | Guild the voice channel belongs to, null in a call |
+| channel_id | ?snowflake | Voice channel, null when the participant left |
+| user_id | ?snowflake | Participant |
+| connection_id | ?string | Voice connection identity |
+| session_id | ?string | Gateway session that owns the connection |
+| member | ?[guild member](/http-api/guild-members/#guild-member-object) object | The participant's guild member object, null in a call |
+| mute | boolean | Server mute |
+| deaf | boolean | Server deafen |
+| self_mute | boolean | Local microphone mute |
+| self_deaf | boolean | Local output deafen |
+| self_video | boolean | Whether the participant publishes camera video |
+| self_stream1 | boolean | Whether the connection advertises a screenshare track |
+| is_mobile | boolean | Whether the participant is on a mobile client |
+| suppress | boolean | Whether the participant is suppressed |
+| viewer_stream_keys | array[string] | Streams this connection is watching |
+| e2ee_capable | boolean | Whether the participant's client supports end-to-end encrypted voice |
+| version | integer | Monotonic version of this participant's voice state |
+
+1 Publisher-asserted. In a guild voice channel Fluxer sets it to false when the participant lacks `STREAM`
+
+The broadcast form has no `region_id`, `server_id`, `latitude`, or `longitude`.
+
+### VOICE_STATE_ACK
+
+Reports the outcome of the session's own [Voice State Update](/gateway/commands/#voice-state-update). Sent only when that command supplied `mutation_id`, and delivered to the requesting session alone.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mutation_id | string | The `mutation_id` the command supplied |
+| runtime_epoch1 | string | The `runtime_epoch` the command supplied |
+| connection_id | ?string | Voice connection the mutation applied to |
+| guild_id | ?snowflake | Guild the mutation applied to |
+| channel_id | ?snowflake | Channel the mutation applied to |
+| status | string | `applied` or `rejected` |
+| server_version | integer | The voice state version after the mutation |
+| canonical_state2 | [voice state object](#voice-state-object) | The authoritative voice state, an empty object when none exists |
+| error_code? | string | Stable rejection code, present only when `status` is `rejected` |
+| error_message? | string | Human-readable rejection message |
+
+1 Echoed back unchanged. A command that omitted the field produces the literal string `undefined` here, so a client MUST compare the value against the epoch it sent
+
+2 Also has the `region_id` and `server_id` fields a [Voice State Update](#voice-state-update) omits, and never has coordinates. Both extra fields are internal routing identity, and a client MUST NOT depend on them
+
+A mutation whose `base_version` is more than one behind the server's current version is rejected after the connection lookup and before the permission checks, with `error_code` and `error_message` both set to `stale_base_version`.
+
+Every other rejection has one of these codes, with `error_message` set to the registry text for the same code.
+
+#### `VOICE_CONNECTION_NOT_FOUND`
+
+The named voice connection does not exist and no matching pending connection could be restored.
+
+#### `VOICE_PENDING_EXPIRED`
+
+The pending voice connection expired before the mutation arrived.
+
+#### `VOICE_INVALID_STATE`
+
+A `viewer_stream_keys` entry is malformed, names another channel, or names a connection that is not in the channel.
+
+#### `VOICE_MEMBER_TIMED_OUT`1
+
+The member is timed out.
+
+#### `VOICE_PERMISSION_DENIED`1
+
+The user lacks `VIEW_CHANNEL` or `CONNECT` on the target channel.
+
+#### `VOICE_CHANNEL_FULL`1
+
+The channel is at its `user_limit`.
+
+#### `VOICE_CONNECTION_LIMIT_REACHED`1
+
+The user holds too many voice connections.
+
+#### `VOICE_CAMERA_USER_LIMIT`
+
+The channel already has 25 users with cameras enabled.
+
+#### `VOICE_E2EE_REQUIRED`1
+
+The channel is end-to-end encrypted and the client does not support it.
+
+1 Checked only when the mutation moves the connection to a different channel. A mutation that keeps the connection in its current channel skips these checks and is applied
+
+Only a command that supplies `connection_id` can produce an ack. A command that omits it opens a new connection, and a `connection_id` that belongs to another user is rejected before any other check. A refusal of either kind produces no Dispatch. Without `mutation_id`, a refused voice state update also produces no Dispatch at all.
+
+### VOICE_SERVER_UPDATE
+
+The session received or replaced its own voice grant. Delivered to the requesting session alone.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The LiveKit access token this connection presents |
+| endpoint | string | The LiveKit signalling URL to connect to, a `ws://` or `wss://` address |
+| connection_id | string | The voice connection the grant covers |
+| channel_id | snowflake | The channel the grant covers |
+| guild_id?1 | snowflake | The guild the channel belongs to |
+| e2ee_key?2 | string | The key material the channel's end-to-end encryption uses |
+
+1 Present for a guild voice channel and absent for a call, so a client reads the scope from this field
+
+2 Present only when the channel is end-to-end encrypted
+
+Fluxer uses LiveKit for voice media. There is no second voice websocket, no voice opcode set, and no UDP discovery step. A client opens a LiveKit connection to `endpoint`, presents `token` there, and speaks the LiveKit protocol from that point on. [Voice](/voice/) states the room naming, the participant identity, and the track sources a grant admits.
+
+A grant is issued when a connection opens, when it moves to another channel, and when its region changes. Toggling `self_mute`, `self_video`, or `self_stream` produces no new grant. A call region change reissues one grant to each participant of the call.
+
+### ENTRANCE_SOUND_PLAY
+
+A participant asked for their entrance sound to play in a voice channel they are already connected to.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | Participant whose sound plays |
+| channel_id | snowflake | Voice channel |
+| guild_id | ?snowflake | Guild the channel belongs to, null for a call |
+| sound_id | snowflake | Entrance sound |
+| hash | string | Content hash of the sound file |
+| url | string | URL to fetch the sound from |
+| duration_ms | integer | Sound duration in milliseconds |
+| content_type | string | MIME type of the sound file |
+
+Recipients are every other account with a voice state in that channel, one Dispatch each and at most one per account. The requesting account never receives its own sound.
+
+### CALL_CREATE
+
+A direct message or group direct message call began, or became visible in the session's initial state.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel the call is in |
+| message_id | snowflake | Call message that opened the call |
+| region | ?string | Voice region serving the call, null until one is chosen |
+| ringing | array[snowflake] | Recipients currently being rung |
+| voice_states1 | array[[voice state object](#voice-state-object)] | Participants, ordered by participant ID |
+| recipients?2 | array[snowflake] | Every recipient of the channel |
+| created_at?2 | integer | Unix milliseconds when the call was opened |
+
+1 Each entry also has the `region_id` and `server_id` fields a [Voice State Update](#voice-state-update) omits. Both are internal routing identity, and a client MUST NOT depend on them
+
+2 Present only when the session pulled the call's state for itself
+
+A session pulls the call's state for itself in two cases. The first is the Call Create it receives shortly after [Ready](#ready) for a private channel that already has a call. The second is the Call Create that reattaches the session to a call it lost, whether or not that loss produced a [Call Delete](#call-delete).
+
+Recipients are every recipient of the channel, whether or not they joined the call. The same set receives [Call Update](#call-update) and [Call Delete](#call-delete).
+
+### CALL_UPDATE
+
+The ringing set, participant roster, or region of an active call changed. The payload has the same structure as [Call Create](#call-create) without `recipients` and `created_at`.
+
+A Call Update is sent only when the computed payload differs from the last one the call published, so an operation with no visible effect produces nothing.
+
+### CALL_DELETE
+
+A call ended, or became unavailable.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | Channel the call was in |
+| unavailable?1 | boolean | True when the call became unavailable |
+
+1 Absent when the call ended
+
+With `unavailable: true` the call became unreachable. The session schedules a reattach 1,000 ms later and retries with backoff up to 15 times. A successful reattach delivers a fresh [Call Create](#call-create) with `recipients` and `created_at`, and an exhausted one delivers nothing further. A client MUST hold the call in a placeholder state.
+
+## Count response events
+
+### GUILD_COUNTS_UPDATE
+
+Answers [Request Guild Counts](/gateway/commands/#request-guild-counts) for the requesting session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| counts | array[[guild count entry object](#guild-count-entry-object)] | One entry per guild that answered in time |
+| nonce? | string | Echoed when the request supplied a valid nonce |
+
+A guild that is not connected, or that missed its deadline, has no entry in `counts`.
+
+#### Guild count entry object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the counts describe |
+| member_count | integer | Total members |
+| online_count1 | integer | Online members visible to the requesting account |
+
+1 Counts only the online members that share at least one channel the requesting account can view. An account holding `ADMINISTRATOR` receives the guild's whole online count instead, and an account that can view no channel at all receives `1` when it is itself online and `0` when it is not
+
+### CHANNEL_MEMBER_COUNTS_UPDATE
+
+Answers [Request Channel Member Counts](/gateway/commands/#request-channel-member-counts) for the requesting session.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| counts | array[[channel count entry object](#channel-count-entry-object)] | One entry per channel that answered |
+| nonce? | string | Echoed when the request supplied a valid nonce |
+
+A channel the session cannot view, and a channel on which it lacks `VIEW_CHANNEL_MEMBERS`, has no entry in `counts`.
+
+#### Channel count entry object
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | Guild the channel belongs to |
+| channel_id | snowflake | Channel the counts describe |
+| member_count | integer | Members that can view the channel |
+| online_count | integer | Online members that can view the channel |
+
+## Resource representation
+
+Every resource object named on this page has the representation defined by the [HTTP API](/http-api/). A Dispatch payload with a resource object has the same fields, with the guild-scoped events adding `guild_id` and the message and reaction events adding `member` as documented above.
+
+Two reductions are specific to the Gateway and appear nowhere in the HTTP API. [Ready](#ready) strips `user` from each relationship and from each guild member and hoists those accounts into its `users` array. The `member` added to a message event has its own `user` removed, and the account is in the message's `author`. A client MUST resolve those accounts from the surrounding payload.
+
+Every Dispatch payload also drops six fields the Gateway keeps for its own indexing: `recipient_ids`, `role_index`, `channel_index`, `member_role_index`, `role_perms_cache`, and `overwrite_perms_cache`.
diff --git a/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md b/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md
new file mode 100644
index 000000000..67e93a3ea
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md
@@ -0,0 +1,105 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Limits and rate limits
+description: Main Gateway session, command, replay, backpressure, and admission limits.
+---
+
+Every main Gateway connection has limits on what it sends, how long its session lives, how many commands it runs at once, and how much Fluxer replays to it. Exceeding one closes the connection with a [close code](/gateway/opcodes-and-close-codes/#close-codes), discards the payload, or coerces the value. The [close reason registry](/gateway/opcodes-and-close-codes/#close-reasons) defines each exact reason.
+
+## Transport and encoding
+
+[Framing](/gateway/overview/#framing) owns the protocol version, the payload bound, and the compression contract. One inbound WebSocket message is limited to 4,096 bytes on the wire and to a further 4,096 bytes after decompression, and either bound closes with `4002` and reason `Payload too large`.
+
+A compressed message that decompresses past 10 MiB closes with `4002` and reason `Decompression failed`, before the 4,096-byte bound is reached.
+
+## Session lifecycle
+
+Hello advertises a heartbeat interval of 41,250 ms. The Gateway checks the heartbeat state every 13,750 ms. When 37,125 ms have elapsed since the last acknowledgement it sends Opcode `1` and waits. If no new acknowledgement is accepted before the elapsed time passes 45,000 ms, the connection closes with `4009` and reason `Heartbeat timeout`.
+
+There is no separate authentication deadline. The heartbeat rule alone closes a socket that never authenticates.
+
+A disconnected session remains resumable for 60,000 ms. Its presence is published as `offline` 5,000 ms after the socket drops, well before the retention window ends. A successful Resume inside the window restores the status the session last selected.
+
+One user credential holds at most 100 live sessions. A further Identify closes with `4008` and reason `Too many sessions`. A bot credential is bounded by the guild ceiling below.
+
+Shard counts run from 1 through 16,384. One bot shard covers at most 2,500 guilds. A malformed shard pair closes with `4010` for every credential. A bot assignment above the guild ceiling closes with `4011` and reason `Sharding required`. A user session is never refused for its guild count.
+
+One Gateway node admits 512 concurrent session starts by default, which an operator configures as `max_concurrent_session_starts` in the Gateway rollout config. Setting `session_rollout_percentage` to zero pauses session starts entirely, and a value below 100 admits only that share.
+
+`session_rollout_mode` decides which share. The default `modulo` hashes the account ID, so one account is admitted or refused consistently at a given percentage. The alternative `random` draws once per admission attempt, and the percentage is a share of session starts. One account can be admitted on one attempt and refused on the next.
+
+:::note[A refused session start is held and retried]
+The Gateway refuses a session start for draining, capacity, paused starts, the rollout percentage, or a failed backend RPC. It keeps the Identify payload and tries again after a jittered 1,000 ms to 1,999 ms delay until it succeeds. A rollout config change retries a held payload immediately.
+:::
+
+## Session start limit
+
+[`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) returns a [session start limit](/http-api/gateway/#session-start-limit-object) object for client compatibility. Its four values are constants. Admission is bounded by the source IP Identify budget, the per-user session count, the node's concurrent session-start bucket, and the rollout percentage.
+
+## Connection and command rate limits
+
+A Gateway node running with `FLUXER_DISABLE_RATE_LIMITS` set to `1`, `true`, or `TRUE` disables nine budgets together. Six are the connection payload budget, the session payload budget, the source IP payload budget, the source IP connection ceiling, the Presence Update budget, and the Voice State Update queue. The other three are the source IP Identify budget, the per-user session count, and the 30-second complete member list budget. The figures below are the enforced defaults.
+
+One WebSocket accepts 600 client payloads in a rolling 60-second window. One authenticated session accepts 600 client payloads in each fixed 60-second bucket. One source IP address accepts 6,000 client payloads in each fixed 60-second bucket. Exceeding any of these budgets closes the current connection with `4008` and reason `Rate limited`.
+
+Fluxer evaluates the three payload budgets before any command-specific budget. The session budget is skipped while the connection is unauthenticated.
+
+One source IP address holds 256 concurrent Gateway WebSockets. A further connection closes with `4008` and reason `Too many connections` before Hello is sent.
+
+One source IP address makes 300 Identify attempts in each fixed 60-second window. A further attempt is discarded without a reply and without a close. The payload is not retained and is never retried, so the client sends a new Identify itself once the budget recovers, before the heartbeat deadline closes the socket.
+
+Presence Update accepts five commands per WebSocket in a rolling 20-second window. A further update is discarded without closing the connection.
+
+Voice State Update processes the first two commands per session in a rolling one-second window immediately. Later updates enter a per-session queue that holds at most 64 commands and drains one command every 500 ms. A newer update replaces an older queued update for the same `guild_id` and `connection_id` pair, and a full queue discards its oldest entry before accepting the new one.
+
+Request Guild Members has one command-specific budget. A bot requesting a complete member list is limited to one accepted request per guild every 30 seconds, and a request inside that window produces [Rate Limited](/gateway/events/#rate-limited). The budget is keyed by the account and the guild together, so reconnecting does not reset it.
+
+## Bounded commands
+
+One WebSocket processes at most four bounded requests at once across [Request Guild Members](/gateway/commands/#request-guild-members), [Lazy Request](/gateway/commands/#lazy-request), [Request Guild Counts](/gateway/commands/#request-guild-counts), and [Request Channel Member Counts](/gateway/commands/#request-channel-member-counts). Each request has a 10,000 ms deadline, after which it stops. Events already emitted are not retracted, so a command that emits its result in several events can deliver a partial result and then stop.
+
+Request Guild Members keeps one replaceable pending request while another member request is active. The most recent further request replaces any earlier pending one and starts when the active request finishes.
+
+A bounded command that finds all four slots occupied is dropped. There is no queue, no result event, and no close.
+
+Request Guild Counts queries each guild with a 2,000 ms deadline under an overall 3,000 ms batch deadline. Request Channel Member Counts queries its guild with a 2,000 ms deadline. A guild that misses its deadline is omitted from the result.
+
+## Replay and backpressure
+
+Resume history retains at most 4,096 Dispatch events and 16,777,216 bytes of retained event data, the 16 MiB total ceiling. When either bound is exceeded, the oldest retained events are dropped from the front of the buffer.
+
+One Dispatch larger than 2,097,152 bytes, the 2 MiB single-event ceiling, is delivered to the socket and never retained. The connection is not closed for it, so a client that resumes across such an event does not receive it again.
+
+Both byte bounds measure the in-memory size of the event inside the session process. That size differs from the JSON payload on the wire, so both figures are approximate.
+
+[Guild Members Chunk](/gateway/events/#guild-members-chunk) is delivered live and is never retained for Resume, whatever its size.
+
+[Guild Sync](/gateway/events/#guild-sync) and [Guild Member List Update](/gateway/events/#guild-member-list-update) are delivered with a sequence and never retained. Those two and Guild Members Chunk are the only events excluded by name. Every other Dispatch is retained, including the pre-encoded guild fan-out that broadcasts one event to every eligible session. A replay is therefore a subset of the sequence range it covers, which is why replay frames have sequence gaps.
+
+A heartbeat with a sequence discards every retained Dispatch at or below that sequence and records it as the acknowledged sequence, so a client MUST acknowledge only a sequence whose events it has finished processing. A heartbeat with `null`, and a heartbeat with a sequence below the acknowledged sequence, change nothing. Resume neither acknowledges nor evicts. A client that never heartbeats with a sequence keeps its full window until the count or byte bound evicts from the front.
+
+Resume closes with `4007` on a `seq` below the acknowledged sequence, and on a `seq` above the session's current sequence.
+
+An eviction from the count or byte bound raises a replay floor to the highest sequence it dropped. A Resume with a `seq` below that floor produces Opcode `9` with `d: false` and no close, so a client that reconnects long after it fell behind Identifies again.
+
+The session holds every Presence Update Dispatch in a pending queue until it dispatches [Ready](/gateway/events/#ready). After Ready, a Dispatch is held only when it names a guild the session is not connected to, or a user who is neither a friend nor a group DM recipient. The queue holds at most 2,048 entries and discards its oldest entry when full. The session releases the queue right after Ready and discards every held entry for a user the `presences` array already covers. A 10,000 ms timer releases it when Ready has not been dispatched by then. A held entry is also released for one user when a relationship or a channel brings that user into scope.
+
+Fluxer drops a presence cast to a session process whose mailbox already holds more than 5,000 messages. A session that cannot keep up sheds presence casts and stays connected.
+
+## Command payload limits
+
+Request Guild Members accepts at most 100 user IDs, a nonce of at most 32 bytes, and a result limit from 0 through 100. Duplicate guild IDs are collapsed, an out-of-range limit is clamped, and an oversized nonce becomes null. An oversized `user_ids` array abandons the request. The nonce is echoed only when the request named exactly one guild.
+
+Lazy Request accepts at most 10 member list ranges per channel, each with `end` at most 100,000 and `end - start` at most 99, and at most 1,000 explicit member IDs per guild. Ranges and IDs that fail validation are dropped, and valid entries past either ceiling are truncated.
+
+Request Guild Counts accepts at most 100 guild IDs after deduplication. Request Channel Member Counts accepts at most 25 channel IDs after deduplication. Both nonces run from 1 through 64 bytes, and a nonce outside that bound is omitted from the result.
+
+Identify accepts at most 256 `ignored_events` entries. A longer array closes with `4002` and reason `Invalid identify payload`. Every other bound on this page coerces or drops. [Client commands](/gateway/commands/) states the exact coercion or drop rule for each field.
+
+## Voice admission
+
+Voice admission follows the enclosing guild, DM, group DM, channel, and permission rules. A refusal is reported as [Voice State Ack](/gateway/events/#voice-state-ack) with an `error_code` and closes nothing. A Voice State Update that has no `mutation_id` produces no event when it is refused.
+
+A guild voice channel admits at most `user_limit` users, where `0` means unlimited. A channel in which any participant has a camera enabled additionally admits at most 25 users in total, whatever its `user_limit`. A channel that already holds 25 users with cameras enabled refuses a further camera with `VOICE_CAMERA_USER_LIMIT`.
+
+One user holds at most `voice_connection_limit` simultaneous voice connections in one guild voice channel. The field is part of the [channel object](/http-api/channels/#channel-object), defaults to 5, and is accepted from 1 through 100. Pending connections that have not yet expired count against it.
diff --git a/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md b/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md
new file mode 100644
index 000000000..ca3e25c66
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md
@@ -0,0 +1,193 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Opcodes and close codes
+description: Numeric registry for main Gateway opcodes, close codes, and exact close reasons.
+---
+
+An opcode is the number that names a [Gateway payload](/gateway/overview/#gateway-payload). A close code is the number the [Gateway](/gateway/overview/) sends when it ends a connection. Every value on this page belongs to the main Gateway alone. [Gateway overview](/gateway/overview/) describes the connection and its state machine, and [Client commands](/gateway/commands/) describes the payloads for these opcodes.
+
+## Opcodes
+
+| Op | Name | Direction |
+| --- | --- | --- |
+| 0 | Dispatch | Server to client |
+| 1 | Heartbeat | Bidirectional |
+| 2 | Identify | Client to server |
+| 3 | Presence Update | Client to server |
+| 4 | Voice State Update | Client to server |
+| 5 | Voice Server Ping | Reserved |
+| 6 | Resume | Client to server |
+| 7 | Reconnect | Server to client |
+| 8 | Request Guild Members | Client to server |
+| 9 | Invalid Session1 | Server to client |
+| 10 | Hello | Server to client |
+| 11 | Heartbeat ACK | Server to client |
+| 12 | Gateway Error | Reserved |
+| 14 | Lazy Request | Client to server |
+| 15 | Request Guild Counts | Client to server |
+| 16 | Request Channel Member Counts | Client to server |
+
+1 Fluxer never sends Opcode 9 with `d: true`, so an Invalid Session is never an instruction to resume
+
+### Payload and when it is sent
+
+| Op | Payload | When sent |
+| --- | --- | --- |
+| 0 | Event-specific data | Fluxer sends a named event with `t` and a session sequence with `s`1 |
+| 1 | The last processed sequence or `null` from the client, and `null` from the server | The client sends on the advertised schedule. The server requests an immediate heartbeat once 90 per cent of the advertised interval has elapsed since the last acknowledgement2 |
+| 2 | [Identify object](/gateway/commands/#identify) | The client starts one authenticated session |
+| 3 | [Presence Update object](/gateway/commands/#presence-update) | The client replaces its session presence |
+| 4 | [Voice State Update object](/gateway/commands/#voice-state-update) | The client joins, moves, updates, or leaves voice |
+| 5 | Not implemented | Never sent. A client that sends it is closed3 |
+| 6 | [Resume object](/gateway/commands/#resume) | The client restores a retained session4 |
+| 7 | No `d` field | The Gateway asks the client to replace the connection and then closes with `4000`5 |
+| 8 | [Request Guild Members object](/gateway/commands/#request-guild-members) | The client requests bounded member chunks |
+| 9 | Literal `false` | The session named by Resume could not be reached, the supplied `seq` is below the replay floor, or an established session ended6 |
+| 10 | Object with `heartbeat_interval` | Sent as the WebSocket is accepted, before any client payload is read |
+| 11 | No `d` field | Acknowledges an accepted client heartbeat |
+| 12 | Not implemented | Never sent. Errors reach the client as a Dispatch or a close frame |
+| 14 | [Lazy Request object](/gateway/commands/#lazy-request) | The client replaces bounded guild subscriptions |
+| 15 | [Request Guild Counts object](/gateway/commands/#request-guild-counts) | The client requests current guild count records |
+| 16 | [Request Channel Member Counts object](/gateway/commands/#request-channel-member-counts) | The client requests channel count records |
+
+1 [Resumed](/gateway/events/#resumed) has the session's current sequence without advancing it, and a replayed Dispatch keeps the sequence it was first sent with
+
+2 The advertised interval is 41,250 ms, so the threshold is 37,125 ms and the acknowledgement deadline is 45,000 ms. Both are tested on a 13,750 ms timer and acted on at the first tick at or after them
+
+3 A client that sends Opcode 5 or 12 gets the same close as a client that sent an undefined opcode
+
+4 Resume is accepted whether or not a session is already attached to the connection
+
+5 Opcode 7 precedes the close when the Gateway node is draining, when the session is fenced for a cluster handoff, and when a Resume from a new socket displaces this one
+
+6 After the frame, a socket whose session ended is unauthenticated. After a failed Resume, a socket that already held a session still holds it
+
+The registry is complete. Opcode 13 and every value above 16 are undefined.
+
+Fluxer resolves an inbound payload in this order.
+
+1. A payload that has no `d` reaches no command handler. Identify closes with `4005`. Every other opcode, defined or not, closes with `4001`.
+2. Heartbeat and Resume are handled whether or not a session is attached.
+3. Identify closes with `4005` when a session is already attached.
+4. Every remaining opcode closes with `4003` while no session is attached.
+5. With a session attached, Presence Update, Voice State Update, Request Guild Members, Lazy Request, Request Guild Counts, and Request Channel Member Counts are handled. Every other opcode, including a server opcode and an undefined value, closes with `4001`.
+
+:::note[Unknown server opcodes are forward compatible]
+A client that receives an unknown opcode SHOULD log it and ignore the frame. It MUST NOT close or reconnect solely because the server used an opcode newer than this registry.
+:::
+
+See [Client commands](/gateway/commands/) for field tables, validation bounds, examples, and results.
+
+## Close codes
+
+| Code | Name | Meaning |
+| --- | --- | --- |
+| 4000 | Unknown error | The Gateway drained the connection, or a session operation could not be completed |
+| 4001 | Unknown opcode | The opcode is undefined, is a server opcode, or the payload has no `d` |
+| 4002 | Decode error | The payload size, compression stream, encoding, or command fields are invalid |
+| 4003 | Not authenticated | An authenticated command arrived before Identify or Resume attached a session |
+| 4004 | Authentication failed | The token is invalid, or it does not own the retained session named by Resume |
+| 4005 | Already authenticated | Identify arrived while a session was attached, or with no `d` |
+| 4007 | Invalid sequence | Heartbeat or Resume supplied a sequence outside the bounds that command accepts, as stated in [Invalid sequence](#invalid-sequence) |
+| 4008 | Rate limited | A concurrent connection, connection payload, source IP payload, user payload, or session count budget was exceeded |
+| 4009 | Session timeout | More than 45,000 ms elapsed since the last accepted heartbeat acknowledgement while the Gateway was awaiting one |
+| 4010 | Invalid shard | The Identify `shard` value is not a valid `[shard_id, shard_count]` pair1 |
+| 4011 | Sharding required | More than 2,500 guilds resolve to one bot Gateway session2 |
+| 4012 | Invalid API version | The `v` connection parameter is absent or is not `1` |
+
+1 `shard_count` is an integer from 1 to 16384, and `shard_id` is a non-negative integer below `shard_count`
+
+2 The count is taken after the shard filter, so a bot clears it by identifying with a `shard_count` large enough to divide its guilds
+
+Code 4006 is unassigned, and no code above 4012 is defined. [Event filtering](/gateway/event-filtering/) describes how a client bounds the events its session receives.
+
+### Session outcome
+
+| Code | Resumable | Resulting state |
+| --- | --- | --- |
+| 4000 | Conditional | Closed. A session attached to the connection remains recoverable1 |
+| 4001 | Conditional | Closed. An already established session stays retained |
+| 4002 | Conditional | Closed. An already established session stays retained |
+| 4003 | No | Closed. No session exists for this connection |
+| 4004 | Conditional | Closed. Resume does not invalidate the session it named2 |
+| 4005 | Conditional | Closed. A session established by an earlier Identify stays retained |
+| 4007 | No for that attempt | Closed. The named session stays retained until its window expires |
+| 4008 | Conditional | Closed. An already established session stays retained |
+| 4009 | Conditional | Closed. An already established session stays retained |
+| 4010 | No | Closed. No session was created |
+| 4011 | No | Closed. No session was created |
+| 4012 | No | Closed before Hello. No session exists |
+
+1 A session the close leaves without a socket is retained for 60,000 ms measured from the moment the socket ends, so a close begins a fresh window. A session displaced by a Resume from a new socket is already attached to that socket and enters no window
+
+2 A Resume that fails token verification leaves the named session in place for the rest of its retention window, so a later Resume with the owning token still recovers it. An Identify that fails token verification leaves nothing to recover
+
+A session fenced for a cluster handoff stops on this node once its state has been copied to the node taking it over. Fluxer holds that copy for 120,000 ms and sweeps it on a 10,000 ms timer. The next Resume consumes it.
+
+`Resumable` describes only whether an already established session can still be recovered with [Resume](/gateway/commands/#resume). The 60,000 ms retention window and the bounded replay buffer described in [Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) apply unchanged.
+
+:::caution[Reconnecting unchanged reproduces `4004`, `4010`, and `4012`]
+A client changes the token, the shard pair, or the version before it reconnects.
+:::
+
+### Invalid sequence
+
+Close `4007` follows one rule for [Heartbeat](/gateway/commands/#heartbeat) and another for [Resume](/gateway/commands/#resume).
+
+Heartbeat tests the value's type alone, and only once a session is attached. Before Identify or Resume attaches one, the Gateway accepts every `d` and answers with Opcode 11. With a session attached, the Gateway accepts a `d` that is `null` or any integer, and every other value closes with `4007`. A sequence below the acknowledged sequence leaves that bound unchanged, and any other integer sets it and trims the replay buffer. A heartbeat that arrives in the short window between the session process ending and the socket noticing also closes with `4007`.
+
+Resume tests two bounds and its `seq` must clear both.
+
+- The current sequence. A `seq` above the last sequence the session dispatched closes with `4007`.
+- The acknowledged sequence. A `seq` below the last acknowledged sequence closes with `4007`. A heartbeat with a higher sequence moves this bound.
+
+A third bound, the replay floor, produces no close. It is the highest sequence the replay buffer has evicted. When a `seq` is inside both bounds but below the floor, the Gateway sends Opcode 9 with `d: false` and the named session stays retained until its window expires.
+
+## Close reasons
+
+The Gateway sends an exact reason string with every application close.
+
+| Reason | Code | Cause |
+| --- | --- | --- |
+| Invalid API version | 4012 | The `v` connection parameter is absent or is not `1` |
+| Too many connections | 4008 | The source IP already holds 256 concurrent Gateway connections |
+| Encode failed | 4002 | Hello could not be encoded1 |
+| Compression failed: zstd-stream | 4002 | Hello could not be compressed on a connection that negotiated zstd1 |
+| Payload too large | 4002 | An inbound message exceeded 4,096 bytes on the wire, or exceeded 4,096 bytes after decompression |
+| Decompression failed | 4002 | An inbound compressed message could not be decompressed |
+| Decode failed | 4002 | The payload is not valid JSON, or decodes to something other than an object |
+| Invalid payload | 4002 | The decoded object has no `op` |
+| Rate limited | 4008 | The connection, source IP, or user client payload budget was exceeded |
+| Unknown opcode | 4001 | The opcode is undefined, is a server opcode, or the payload has no `d` |
+| Not authenticated | 4003 | An authenticated command arrived before a session was attached |
+| Already authenticated | 4005 | Identify arrived while a session was attached, or with no `d` |
+| Invalid identify payload | 4002 | Identify is missing `token` or `properties`, or a field has the wrong type |
+| Invalid shard | 4010 | The Identify `shard` value is not a valid `[shard_id, shard_count]` pair |
+| Sharding required | 4011 | More than 2,500 guilds resolve to one bot session after the shard filter |
+| Too many sessions | 4008 | The user already holds 100 live Gateway sessions2 |
+| Invalid token | 4004 | The Identify token is invalid, or the Resume token does not own the named session |
+| Failed to start session | 4000 | Session creation returned a failure the Gateway does not classify3 |
+| Invalid resume payload | 4002 | Resume is not an object, or `token` or `session_id` is missing or is not a string, or `seq` is missing or is not an integer |
+| Invalid sequence | 4007 | Heartbeat or Resume supplied a sequence outside the bounds stated in [Invalid sequence](#invalid-sequence) |
+| Invalid presence payload | 4002 | Presence Update is not an object, has no `status`, or has a `status` string that is not a known value |
+| Session unavailable | 4000 | The retained session could not be reached while Resume was in progress |
+| Session drain requested; reconnect to continue | 4000 | Opcode `7` was sent immediately before the close4 |
+| Heartbeat timeout | 4009 | No new heartbeat acknowledgement was accepted within 45,000 ms of the preceding acknowledgement |
+
+1 Both reasons are produced only while the Hello frame is being written. A later outbound frame that cannot be encoded or compressed is dropped and the connection stays open
+
+2 A bot credential is bounded by the guild count that forces sharding, and this budget does not apply to it
+
+3 The Gateway holds the Identify and retries it silently after a classified transient failure, so the connection stays open. That covers a paused rollout, a draining node, an ineligible account, an Identify rate limit, a saturated start budget, and a failed session RPC
+
+4 The Gateway node is draining, the session is being fenced for a cluster handoff, or a Resume from a new socket displaced this one
+
+Reason strings are stable wire values. A client branches on the code and MAY record the reason for diagnosis.
+
+:::note[Some refusals send nothing]
+A held or discarded Identify, an over-budget Presence Update, a dropped bounded request, and a rejected voice state update produce no close and no reason. [Client commands](/gateway/commands/) states which command behaves that way.
+:::
+
+## Ordinary WebSocket closes
+
+A transport can end with no Fluxer application close code, as happens on a network failure, an intermediary reset, and an ordinary `1000` or `1001` close. An established session remains available for 60,000 ms after the transport ends. Ending the transport does not extend that window or its bounded replay history.
diff --git a/fluxer_docs/src/content/docs/gateway/overview.md b/fluxer_docs/src/content/docs/gateway/overview.md
new file mode 100644
index 000000000..5c4ebb943
--- /dev/null
+++ b/fluxer_docs/src/content/docs/gateway/overview.md
@@ -0,0 +1,314 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gateway overview
+description: The main Gateway connection, framing, state machine, heartbeats, resumption, and sharding.
+---
+
+The main Gateway is a persistent WebSocket. It sends account and [guild](/http-api/guilds/) events and accepts a small set of bounded client commands. Every other read or mutation is an [HTTP API](/http-api/) operation.
+
+:::note[Each surface has its own framing and authentication]
+The main Gateway, the HTTP API, and the [Media Proxy API](/media-proxy/overview/) each define their own. Nothing negotiated on one applies to another.
+:::
+
+## Connecting
+
+A client opens the socket, waits for Hello, sends Identify, and then heartbeats for the life of the connection.
+
+1. Read `endpoints.gateway` from the [instance discovery document](/http-api/instance/#get-instance-discovery) and open a WebSocket to it with `?v=1&encoding=json`.
+2. Read Opcode 10 [Hello](#hello-and-session-creation) and take `heartbeat_interval` from it.
+3. Send Opcode 2 [Identify](/gateway/commands/#identify) with the token and `properties`.
+4. Read [Ready](/gateway/events/#ready) and keep its `session_id` for a later [Resume](#resuming-a-session).
+5. Send Opcode 1 [Heartbeat](#heartbeats) with the last Dispatch sequence every `heartbeat_interval` milliseconds.
+
+```json
+{
+ "op": 2,
+ "d": {
+ "token": "flx_ZDb1GURItsMuYl1zvrgxv2qLBxyNmgNSEaWT",
+ "properties": {
+ "os": "Linux",
+ "browser": "Fluxer Client",
+ "device": "desktop"
+ }
+ }
+}
+```
+
+`token` and `properties` are the only required fields. The token is the raw account or bot token. It has no HTTP authentication prefix, so a bot sends the token without the `Bot ` prefix the HTTP API requires. [Client commands](/gateway/commands/#identify) defines the rest. Everything the server sends after Ready is a [Dispatch](/gateway/events/#dispatch-delivery), which is one event payload with its name in `t` and its data in `d`.
+
+## Protocol version
+
+Gateway version `1` is the only version. The `v=1` connection parameter selects it. The Gateway closes with `4012` and reason `Invalid API version` for any other value and for an omitted one, before it sends Hello.
+
+## Discovering the endpoint
+
+Read `endpoints.gateway` from the [instance discovery document](/http-api/instance/#get-instance-discovery). A bot can instead call [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information), which returns the same WebSocket URL together with a recommended [shard](#sharding) count and a [session start limit](/http-api/gateway/#session-start-limit-object) object. That route rejects any credential outside bot token form with 401 `INVALID_AUTH_TOKEN`.
+
+## Connection parameters
+
+Append the connection parameters to the discovered URL.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| v | integer | The Gateway protocol version, which is `1` |
+| encoding?1 | string | The payload encoding |
+| compress?2 | string | The compression stream, which accepts `zstd-stream` or `none` and defaults to no compression |
+| stream?3 | string | The flag that enables the compression stream, which accepts `1` or `true` |
+
+1 Only `json` is implemented. Every value, including an absent one, selects JSON
+
+2 Any other value selects no compression
+
+3 `compress=zstd-stream` selects compression only when `stream` is also `1` or `true`. Without the flag the connection is uncompressed
+
+Unknown parameters are ignored. An unrecognised `compress` or `stream` value selects no compression and the connection stays open. A client MUST read the negotiated representation from the frame type it receives.
+
+The reference client connects with `?v=1&encoding=json&compress=zstd-stream&stream=1`.
+
+:::caution[A connection can close before Hello]
+The Gateway validates the version and applies the concurrent connection limit before sending Hello, so a rejected connection can close with no payload.
+:::
+
+## Gateway payload
+
+Every logical message uses the Gateway payload.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| op | integer | [Gateway opcode](/gateway/opcodes-and-close-codes/#opcodes) |
+| d?1 | any | The opcode payload, required in every client-to-server payload and opcode-dependent in server-to-client payloads |
+| s? | integer | The non-negative session sequence, present only on Dispatch |
+| t? | string | The event name, present only on Dispatch |
+
+1 Heartbeat ACK and Reconnect have no `d` at all, and Invalid Session has the Boolean `false`
+
+A decoded payload that is not a JSON object closes with `4002` and reason `Decode failed`. An object with no `op` closes with `4002` and reason `Invalid payload`. An object with `op` but no `d` closes with `4001` and reason `Unknown opcode`, except for Identify, which closes with `4005`.
+
+A Dispatch is a server-to-client event payload. Every live Dispatch advances the session sequence by one. A session starts at sequence 0, so the [Ready](/gateway/events/#ready) sequence is 1.
+
+A replayed Dispatch keeps its original sequence, and a replayed run can have gaps, because several event families are delivered live and never retained. [Resumed](/gateway/events/#resumed) has the current sequence and does not advance it, which sets the new live baseline. The sequence is local to one Gateway session and has no meaning across sessions or shards.
+
+## Framing
+
+One inbound WebSocket message is limited to 4,096 bytes on the wire, and a compressed inbound message is limited to a further 4,096 bytes after decompression. A message past either bound closes with `4002` and reason `Payload too large`. An inbound message that cannot be decompressed closes with `4002` and reason `Decompression failed`.
+
+JSON payloads are UTF-8 objects. An uncompressed client payload is sent in a text frame, and a payload the client compressed with the negotiated zstd stream is sent in a binary frame.
+
+The Gateway does not inspect the inbound frame type. It reads the bytes from the negotiated compression alone. A client MUST send every payload in the negotiated representation. On a connection with no negotiated compression the payload is uncompressed, and on a `zstd-stream` connection every client payload goes through the same compression stream in order.
+
+### JSON integer representation
+
+Snowflakes are decimal strings. See [Snowflakes](/snowflakes/) for the identifier contract. Sequences, counts, versions, and bitfields are JSON numbers.
+
+## Compression
+
+`zstd-stream` is a continuous stream in both directions. A client MUST feed every server frame to the same decompressor in arrival order and produce every client frame from the same compressor.
+
+The server compresses at level 3, and one WebSocket message has exactly one Gateway payload.
+
+Hello is already compressed on a connection that negotiated `zstd-stream`, so the first frame such a connection receives is a binary frame.
+
+When the server cannot load the zstd streaming implementation, the connection closes with `4002` and reason `Compression failed: zstd-stream` as it tries to send that first frame.
+
+:::note[Compression is negotiated once]
+A connection cannot change its compression stream after the upgrade. Changing it requires a new WebSocket, which means a new Hello and either a new Identify or a Resume.
+:::
+
+## Signalling state machine
+
+A connection moves through five states: Opening, Unauthenticated, Starting, Replaying, and Ready. The tables below give every event a state accepts, the action it triggers, and the state it lands in. Heartbeat is accepted in every open state, and Closed is terminal for that WebSocket.
+
+### Opening
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| WebSocket accepted. `v=1` and connection capacity available | Send Hello | Unauthenticated |
+| Invalid version. `v` is absent or is not `1` | Close with `4012` and reason `Invalid API version` | Closed |
+| Concurrent connection limit reached. The source IP already holds 256 connections | Close with `4008` and reason `Too many connections` | Closed |
+
+### Unauthenticated
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| Identify. Valid Identify payload and Identify capacity available | Begin session creation | Starting |
+| Identify. Gateway draining, node at capacity, session starts paused, or the account outside the session rollout | Hold the payload and retry it in the background | Unauthenticated |
+| Identify. The source IP Identify budget is exhausted | Discard the payload without a reply | Unauthenticated |
+| Resume. Valid Resume payload | Resolve the retained session | Starting |
+| Authenticated command. Any command other than Heartbeat, Identify, or Resume | Close with `4003` and reason `Not authenticated` | Closed |
+
+### Starting
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| Session creation succeeds. Identify was accepted | Send Ready | Ready |
+| Session creation fails permanently. Invalid token, invalid shard, sharding required, or too many sessions | Close with the mapped code and reason | Closed |
+| Session creation fails without a mapped code | Close with `4000` and reason `Failed to start session` | Closed |
+| Session creation fails temporarily. Draining, at capacity, RPC failure, timeout, or the account outside the session rollout | Hold the Identify and retry it in the background | Unauthenticated |
+| Resume succeeds. The retained session accepted the sequence | Replay retained Dispatches | Replaying |
+| Session cannot be resumed. Resume named an unknown or expired session, or a `seq` below the replay floor | Send Invalid Session with `d: false` | Unauthenticated |
+
+### Replaying
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| Retained replay completes. All retained Dispatches were sent | Send Resumed | Ready |
+| Authenticated command. The session is attached | Process the command | Replaying |
+| Identify. A session is attached | Close with `4005` and reason `Already authenticated` | Closed |
+| Resume. Valid Resume payload | Attach the named session to this socket in place of the current one | Starting |
+
+### Ready
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| Authenticated command. The command is valid in the session | Process the command | Ready |
+| Identify. A session is attached | Close with `4005` and reason `Already authenticated` | Closed |
+| Resume. Valid Resume payload | Attach the named session to this socket in place of the current one | Starting |
+
+### Replaying or Ready
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| The session process ends. The session was terminated while this socket held it | Send Invalid Session with `d: false` | Unauthenticated |
+| Gateway drain or session transfer. The node stops serving this session | Send Reconnect and close with `4000` | Closed |
+| The session is resumed elsewhere. Another socket attached this session with Resume | Send Reconnect and close with `4000` | Closed |
+
+### Any open state
+
+| Event and condition | Action | Next state |
+| --- | --- | --- |
+| Heartbeat. No session is attached, or the payload is `null`, or the attached session accepts the sequence | Send Heartbeat ACK | Same state |
+| Heartbeat deadline. The connection is awaiting an acknowledgement and more than 45,000 ms have passed since the last one | Close with `4009` and reason `Heartbeat timeout` | Closed |
+| Invalid frame or payload. Size, decompression, or decoding validation fails | Close with the applicable close code | Closed |
+| Transport terminates. A session exists | Retain the session for 60,000 ms | Closed |
+
+An opcode outside the registry, and a server opcode sent by a client, close with `4001` once a session is attached and with `4003` while the connection is unauthenticated.
+
+## Hello and session creation
+
+The server sends Opcode 10 Hello while accepting the WebSocket.
+
+```json
+{
+ "op": 10,
+ "d": {
+ "heartbeat_interval": 41250
+ }
+}
+```
+
+The advertised interval is in milliseconds and is authoritative for the connection.
+
+Opcode 2 Identify creates a session. A successful Identify sends [Ready](/gateway/events/#ready), whose `session_id` identifies the retained session. Fluxer publishes no separate resume URL, so a Resume reconnects to the same Gateway endpoint the client discovered.
+
+An invalid token closes with `4004` and reason `Invalid token`. A user account that already holds 100 live sessions closes with `4008` and reason `Too many sessions`, and a bot credential is not bounded by that maximum. A malformed shard pair closes with `4010` and reason `Invalid shard`. A bot shard assignment that resolves more than 2,500 guilds closes with `4011` and reason `Sharding required`.
+
+:::note[A held Identify sends nothing back]
+A draining node, a node at capacity, paused session starts, and an account outside the rollout percentage all keep the payload and retry it every 1 to 2 seconds.
+:::
+
+:::caution[An over-budget Identify is dropped and the client resends]
+Once the source IP Identify budget is exhausted, Fluxer keeps no copy of the payload. The client sends its next Identify itself, at a rate well below that budget.
+:::
+
+## Heartbeats
+
+Opcode 1 is accepted before and after authentication. Before a session exists its payload is ignored and the server still acknowledges. Once a session exists, send the most recently processed Dispatch sequence, or `null` before any Dispatch.
+
+```json
+{
+ "op": 1,
+ "d": 42
+}
+```
+
+The server answers with Opcode 11 Heartbeat ACK, which has no `d`. Once a session is attached, a `d` value that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. A session that does not answer within 5,000 ms closes with the same code and reason.
+
+The Gateway also runs its own timer, which ticks every 13,750 ms. On the first tick at or after 37,125 ms since the last acknowledgement, it sends Opcode 1 with `d: null` and marks the connection as awaiting an acknowledgement. On the first tick more than 45,000 ms after that acknowledgement, it closes with `4009` and reason `Heartbeat timeout`. A connection that never answers is therefore asked at 41,250 ms and closed at 55,000 ms.
+
+A client MUST answer the server's Opcode 1 with its own Opcode 1.
+
+The Gateway resets the elapsed time and clears the awaiting state when it accepts a client Heartbeat. Its own Opcode 1 does neither, so the deadline keeps running from the last client Heartbeat.
+
+A heartbeat with a sequence permanently trims every retained Dispatch at or below that sequence from the replay buffer and records it as the acknowledged sequence. A client MUST send the sequence it has actually processed, because a later Resume from a lower sequence closes with `4007`.
+
+## Resuming a session
+
+Opcode 6 supplies the original token, the Ready `session_id`, and the last processed Dispatch sequence.
+
+```json
+{
+ "op": 6,
+ "d": {
+ "token": "...",
+ "session_id": "6f1d0b7c9a2e4f83b5c1d9e7a4f20b13",
+ "seq": 42
+ }
+}
+```
+
+A successful Resume replays every retained Dispatch above `seq` in order and ends with [Resumed](/gateway/events/#resumed).
+
+All three fields are required. A missing field, a `token` or `session_id` that is not a string, or a `seq` that is not an integer closes with `4002` and reason `Invalid resume payload`.
+
+The Gateway retains a disconnected session for 60,000 ms. An accepted `seq` is no greater than the session's current sequence and no less than the sequence the session has already acknowledged. A `seq` outside either bound closes with `4007` and reason `Invalid sequence`.
+
+An unknown or expired session sends Opcode 9 with `d: false` and leaves the socket unauthenticated. A `seq` inside both bounds but below the replay floor sends the same Opcode 9. The replay floor is the highest sequence the replay buffer has evicted. A token that does not own the named session closes with `4004` and reason `Invalid token`. A session that cannot be reached closes with `4000` and reason `Session unavailable`. None of those failures destroys a separately retained session.
+
+A successful Resume replaces the session's socket and restores the presence status the session last selected. A session whose socket dropped is published as `offline` after 5,000 ms, so a Resume later than that republishes the restored status.
+
+Unlike Identify, Resume is accepted in every open state. A socket that already has a session attached still processes a Resume and attaches the named session in its place. Send Resume only on a fresh socket.
+
+When the resumed session was attached to a different socket, that socket receives Opcode 7 Reconnect and then closes with `4000`.
+
+:::caution[Retention covers reconnection recovery only]
+[Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) states the exact bounds, and several high-volume Dispatch events are never retained.
+:::
+
+## Reconnect
+
+Opcode 7 Reconnect asks the client to open a new WebSocket. The Gateway sends it when the node drains the session, when the node transfers the session to another node, and to the socket a successful Resume displaces.
+
+```json
+{
+ "op": 7
+}
+```
+
+The current socket then closes with `4000` and reason `Session drain requested; reconnect to continue`. The session can be resumed while it remains inside its retention bounds.
+
+## Invalid session
+
+Opcode 9 with `d: false` means the named session cannot be resumed. The Gateway sends it when Resume names an unknown or expired session, when Resume names a `seq` below the replay floor, and when the session process attached to a live connection ends.
+
+```json
+{
+ "op": 9,
+ "d": false
+}
+```
+
+The socket returns to the unauthenticated state, so it stays open and can Identify again. Fluxer never sends `d: true`.
+
+## Sharding
+
+In Identify, a client MAY supply `shard` as a `[shard_id, shard_count]` pair. `shard_id` is a non-negative integer below `shard_count`, and `shard_count` is from 1 through 16,384. An omitted or null value applies no sharding. A malformed pair closes with `4010` and reason `Invalid shard`.
+
+A guild belongs to `((guild_id >> 22) % shard_count)`, computed on the integer value of the guild's decimal snowflake string.
+
+The pair selects the session's guild membership. At Identify, Fluxer filters the account's guild list to the guilds the shard owns, and the session connects only to those.
+
+For a user session, the filtered set is also the [Ready](/gateway/events/#ready) `guilds` array. For a bot session, it is the guild burst of [Guild Create](/gateway/events/#guild-create) and [Guild Delete](/gateway/events/#guild-delete) Dispatches that follows Ready. Ready echoes the accepted pair back as `shard`.
+
+Fluxer checks only a bot session against the guild ceiling. A bot whose shard owns more than 2,500 guilds closes with `4011` and reason `Sharding required`. A bot that supplies no pair is checked against its whole guild list. A user session is bounded by the 100-session-per-user limit alone, whatever its guild count.
+
+:::note[Shard 0 also receives account-level traffic]
+The per-Dispatch shard filter in [Dispatch delivery](/gateway/events/#dispatch-delivery) runs only when `shard_id` is not 0. No other shard receives a copy, so handle those events on shard 0.
+:::
+
+Fluxer has no large bot tier, no shard-count alignment requirement, and no Identify concurrency buckets. `GET /v1/gateway/bot` returns a fixed recommendation.
+
+## Ordering
+
+Dispatch ordering applies within one Gateway session. It creates no total order across shards, HTTP responses, or Media Proxy operations.
+
+[Guild Create](/gateway/events/#guild-create) and [Guild Sync](/gateway/events/#guild-sync) are replacement boundaries for the guild they name. Everything else is a delta against the state those boundaries established.
diff --git a/fluxer_docs/src/content/docs/http-api/applications.mdx b/fluxer_docs/src/content/docs/http-api/applications.mdx
new file mode 100644
index 000000000..2c7cd3513
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/applications.mdx
@@ -0,0 +1,598 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Applications
+description: Application objects, bot accounts, redirect URIs, credential rotation, and deletion.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An application is an OAuth2 client owned by one user account, and every bot account is registered under one. A user owns at most 25. The authorisation code grant, the scope registry, and the tokens issued to third-party clients belong to the [OAuth2 resource](/http-api/oauth2/).
+
+## Access rules
+
+Every route except [Get public application](#get-public-application) requires a credential. A user-only route rejects a bot token and an OAuth2 bearer with 403 `ACCESS_DENIED`. An account with an outstanding required action is accepted everywhere here.
+
+The three sudo-gated routes read `X-Fluxer-Sudo-Mode-JWT`. A valid proof for the authenticated account satisfies [sudo mode](/http-api/users/mfa/#sudo-mode) on its own, and Fluxer echoes it back in the response header. An account with no authenticator proves sudo mode with `password` in the body, because Fluxer issues it no token.
+
+:::caution[Credentials are shown once]
+A client secret is returned only by [Create application](#create-application) and [Reset client secret](#reset-client-secret), and a bot token only by [Create application](#create-application) and [Reset bot token](#reset-bot-token). Both are stored as one-way hashes.
+:::
+
+## Rate limits
+
+A bucket name ending in `::client_id` still counts one allowance for the authenticated user across every application that user owns. [List owned applications](#list-owned-applications), [Get current application](#get-current-application), [Get application](#get-application), [Get public application](#get-public-application), and [List OAuth2 authorisations](/http-api/oauth2/#list-oauth2-authorisations) all draw on one `oauth_dev:clients:list` allowance. [Get bot application](#get-bot-application) draws on the same bucket, keyed by the bot account the token names. That allowance is separate from the owner's.
+
+## Application object
+
+The application record as its owner sees it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The name of the application (1-100 characters) |
+| redirect_uris1 | array[string] | The redirect URIs registered for the application |
+| bot_public | boolean | Whether any eligible user can install the bot |
+| bot_require_code_grant | boolean | Whether bot installation requires an OAuth2 code grant |
+| client_secret?2 | string | The client secret that authenticates token exchange, introspection, and revocation |
+| bot?3 | [application bot](#application-bot-object) object | The bot account the application owns |
+
+1 Always emitted, and an empty array when the application has registered none. [Create application](#create-application) and [Update application](#update-application) accept at most 10 entries
+
+2 Present only in the response to [Create application](#create-application) and [Reset client secret](#reset-client-secret), which are the only operations that issue one
+
+3 Present only when the operation resolved the bot account, which excludes [Reset client secret](#reset-client-secret), an application that owns no bot account, and an application whose bot account record can no longer be read
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "name": "Party Parrot",
+ "redirect_uris": ["https://example.com/callback"],
+ "bot_public": true,
+ "bot_require_code_grant": false
+}
+```
+
+## Application bot object
+
+The bot account an application owns. [Create application](#create-application) creates exactly one bot account with the application, so the bot user [snowflake](/snowflakes/) always equals the application ID.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the bot user, always equal to the application ID |
+| username | string | The username of the bot account, 1 to 32 ASCII letters, digits, or underscores |
+| discriminator | string | The discriminator of the bot account, always four decimal digits with leading zeroes |
+| avatar?12 | ?string | The avatar hash of the bot account, or null when none is stored |
+| banner?12 | ?string | The banner hash of the bot account, or null when none is stored |
+| bio | ?string | The profile biography of the bot account, or null when none is stored |
+| token?3 | string | The bot token issued for the account |
+| mfa_enabled?4 | boolean | Whether the bot account has an authenticator configured |
+| authenticator_types?4 | array[integer] | The [authenticator types](/http-api/users/#authenticator-types) configured on the bot account |
+| flags5 | integer | [Bot flags](#bot-flags) |
+
+1 Declared optional by the schema and always emitted
+
+2 An animated hash retains its `a_` prefix, which is the animation indicator for this object. A bot account always holds the animated avatar and animated banner entitlements, so neither hash is ever stripped or suppressed
+
+3 Present only in the response to [Create application](#create-application). [Reset bot token](#reset-bot-token) returns the new token in the enclosing [bot token reset](#bot-token-reset-object) object instead
+
+4 Declared optional by the schema and always emitted, except on the [public application](#public-application-object) object, which omits both. `authenticator_types` is an empty array when the account has no authenticator, and `mfa_enabled` is true exactly when that array is not empty
+
+5 The bitfield is the account's [public user flags](/http-api/users/#public-user-flags), and [Update bot profile](#update-bot-profile) sets only the two [bot flags](#bot-flags) in it
+
+:::note[Bot token format]
+A bot token is the application ID, a full stop, and an opaque secret of 32 random bytes encoded as base64url.
+:::
+
+The API identifies the application from the token itself and rejects an invalid secret without revealing whether the application exists. The built-in `Fluxer Admin` application owns no bot account, so a token bearing its ID is always rejected.
+
+## Public application object
+
+The application as any caller sees it, including a caller that presents no credential.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The name of the application |
+| icon1 | ?string | The avatar hash of the bot account, used as the application icon |
+| description | ?string | Always null on this object |
+| redirect_uris | array[string] | The redirect URIs registered for the application |
+| scopes2 | array[string] | [OAuth2 scopes](/http-api/oauth2/#oauth2-scopes) the application can currently be authorised for |
+| bot_public3 | boolean | Whether the requester can install the bot |
+| bot4 | ?[application bot](#application-bot-object) object | The bot account the application owns |
+| current_user?5 | ?[partial user](/http-api/users/#partial-user-object) object | The requesting account |
+
+1 Null when the application has no bot account or that account has no avatar, and the stored hash is reported unchanged
+
+2 Contains `bot` when the application owns a bot account, and is otherwise empty
+
+3 Reported as true for the application owner even when the bot is not public
+
+4 Null when the application has no bot account. Its `token`, `mfa_enabled`, and `authenticator_types` are never populated here
+
+5 Declared optional by the schema and always emitted, and null when the request has no credential that resolves to an account
+
+## Current bot application object
+
+The application as the bot token that application issued sees it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The name of the application |
+| icon1 | ?string | The avatar hash of the bot account, used as the application icon |
+| description1 | ?string | The biography of the bot account, used as the application description |
+| bot_public | boolean | Whether any eligible user can install the bot |
+| bot_require_code_grant | boolean | Whether bot installation requires an OAuth2 code grant |
+| verify_key2 | string | A compatibility placeholder |
+| owner3 | [partial user](/http-api/users/#partial-user-object) object | The account that owns the application |
+| bot?4 | [application bot](#application-bot-object) object | The bot account the application owns |
+| redirect_uris?5 | array[string] | The redirect URIs registered for the application |
+
+1 Read from the bot account, so it changes with [Update bot profile](#update-bot-profile), and it is null when the application has no bot account
+
+2 Fluxer persists no application signing key
+
+3 The request fails with 401 `INVALID_TOKEN` when the owning account no longer exists
+
+4 Absent when the application has no bot account, and its `token` is never populated here
+
+5 Declared optional by the schema and always emitted, and an empty array when the application has registered no redirect URI
+
+## Bot profile object
+
+The bot account's profile fields after an update. [Update bot profile](#update-bot-profile) is the only operation that returns this shape, and it has no bot token.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the bot user |
+| username | string | The username of the bot account |
+| discriminator | string | The discriminator of the bot account, always four decimal digits with leading zeroes |
+| avatar1 | ?string | The avatar hash of the bot account, or null when none is stored |
+| banner1 | ?string | The banner hash of the bot account, or null when none is stored |
+| bio | ?string | The profile biography of the bot account, or null when none is stored |
+| flags | integer | [Bot flags](#bot-flags) |
+
+1 An animated hash retains its `a_` prefix, which is the animation indicator for this object. A bot account's stored banner is never suppressed
+
+## Bot flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 4 | FRIENDLY_BOT | The bot accepts friend requests from users |
+| 1 << 5 | FRIENDLY_BOT_MANUAL_APPROVAL | The bot requires manual approval for friend requests |
+
+These two bits are a subset of the account's [public user flags](/http-api/users/#public-user-flags), so a bot account that also has `STAFF`, `PARTNER`, `BUG_HUNTER`, or `SPAMMER` reports that bit in the same field. A client MUST ignore a bit it does not recognise.
+
+## Bot token reset object
+
+The response to [Reset bot token](#reset-bot-token). The fields are the new token and the bot account it belongs to.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The newly issued bot token |
+| bot1 | [application bot](#application-bot-object) object | The bot account the token belongs to |
+
+1 The nested object omits its own `token` member
+
+## List owned applications
+
+
+
+Returns every [application](#application-object) object the current user owns.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[application](#application-object) object] | The applications were returned |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token (`ACCESS_DENIED`) |
+
+The listing is unpaginated because one user can own at most 25 applications. No entry has a client secret or bot token. The system account owns the built-in `Fluxer Admin` application, which never appears in this listing.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `oauth_dev:clients:list` bucket.
+
+## Get current application
+
+
+
+Returns the caller's own applications, selected by credential type. A user session returns the same array as [List owned applications](#list-owned-applications). A bot token returns the [current bot application](#current-bot-application-object) object for the application that issued the token.
+
+An OAuth2 bearer credential is rejected.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[application](#application-object) object] \| [current bot application](#current-bot-application-object) object | The current application data was returned |
+| 401 | [error response](/http-api/#error-response) | Authentication is missing or invalid (`UNAUTHORIZED`) |
+| 401 | [error response](/http-api/#error-response) | The presented bot token belongs to an application whose owner account no longer exists (`INVALID_TOKEN`) |
+| 403 | [error response](/http-api/#error-response) | The credential is a bearer token (`ACCESS_DENIED`) |
+
+:::note[Response shape depends on the credential]
+The body is an array for a user session and a single object for a bot token. A client that accepts both credential types MUST branch on the received shape.
+:::
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `oauth_dev:clients:list` bucket.
+
+## Get bot application
+
+
+
+Returns the [current bot application](#current-bot-application-object) object for the application that issued the presented bot token. The route accepts only the `Bot` scheme in the `Authorization` header.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [current bot application](#current-bot-application-object) object | The application was returned |
+| 401 | [error response](/http-api/#error-response) | The header is absent or does not use the `Bot` scheme (`INVALID_TOKEN`) |
+| 401 | [error response](/http-api/#error-response) | The header does not resolve to a live bot token (`INVALID_TOKEN`) |
+| 401 | [error response](/http-api/#error-response) | The token names an application or owner account that no longer exists (`INVALID_TOKEN`) |
+
+Every rejection reports the same `INVALID_TOKEN`, so a caller cannot distinguish a malformed credential from a valid secret whose application has since been deleted.
+
+### Rate limit
+
+60 requests per minute for each authenticated account, or for each client IP address when no account resolved, on the `oauth_dev:clients:list` bucket.
+
+## Get public application
+
+
+
+Returns the [public application](#public-application-object) object for any application. Authentication is not required. A credential that resolves to an account also populates `current_user` and reports the owner's view of `bot_public`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [public application](#public-application-object) object | The application was returned |
+| 400 | [error response](/http-api/#error-response) | The application ID is not a valid snowflake |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+This representation exposes the application's complete registered redirect URI list and its bot profile to anonymous callers. It has no client secret, no bot token, no owner identity, and nothing about the owner's MFA enrolment. An application owner should not register a redirect URI whose hostname is itself sensitive.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, or for each client IP address when the request has no credential, on the `oauth_dev:clients:list` bucket.
+
+## Create application
+
+
+
+Creates an application together with its bot account. Returns the [application](#application-object) object with the initial client secret and bot token.
+
+An unclaimed account cannot create an application. An account is unclaimed while it holds no password credential, is not a bot, and does not have the single sign-on trait.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The CAPTCHA proof for the request |
+| X-Captcha-Type?2 | string | The CAPTCHA provider to verify against, either `hcaptcha` or `turnstile` |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Fluxer skips verification when CAPTCHA is disabled for the instance, when the account has the CAPTCHA exemption flag, or when the caller's contact has the CAPTCHA exemption capability, as described by [CAPTCHA handling](/topics/captcha/)
+
+2 Fluxer verifies against the instance's configured provider when the header is absent
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The name of the application (1-100 characters) |
+| redirect_uris?23 | ?array[string] | The redirect URIs to register, at most 10 |
+| bot_public? | boolean | Whether any eligible user can install the bot (default true) |
+| bot_require_code_grant? | boolean | Whether bot installation requires an OAuth2 code grant (default false) |
+
+1 Rejected with 403 `CONTENT_BLOCKED` when content moderation blocks the value
+
+2 Null registers no redirect URI. Each entry is normalised and then bounded at 1 to 256 characters
+
+3 Each URI must parse as an absolute URL with a host and must use the HTTPS scheme. HTTP is accepted only when the host is `localhost`, a `.localhost` subdomain, an IPv4 literal, or a bracketed IPv6 literal. A URI that content moderation blocks is rejected with 403 `CONTENT_BLOCKED`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [application](#application-object) object | The application and its bot were created |
+| 400 | [error response](/http-api/#error-response) | The body fails validation |
+| 400 | [error response](/http-api/#error-response) | CAPTCHA proof is missing (`CAPTCHA_REQUIRED`) or rejected (`INVALID_CAPTCHA`) |
+| 400 | [error response](/http-api/#error-response) | The account is unclaimed4 |
+| 400 | [error response](/http-api/#error-response) | The owner already has 25 applications (`MAX_APPLICATIONS`) |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | The name, a redirect URI, or the derived bot username is blocked (`CONTENT_BLOCKED`) |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs, including when no bot username can be allocated (`BOT_USER_GENERATION_FAILED`) |
+
+4 `UNCLAIMED_ACCOUNT_CANNOT_CREATE_APPLICATIONS`
+
+Fluxer derives the bot username from the application name when that name yields an available tag, and otherwise assigns a random username. The resulting username and discriminator can both differ from the application name.
+
+### Side effects
+
+Fluxer creates the application and the bot account together. The bot starts with no avatar, banner, biography, or global display name. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per hour for each authenticated user, on the `oauth_dev:clients:create` bucket.
+
+## Get application
+
+
+
+Returns one owned [application](#application-object) object. The caller must own the application.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [application](#application-object) object | The application was returned |
+| 400 | [error response](/http-api/#error-response) | The application ID is not a valid snowflake |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+Fluxer reports existence before ownership, so an application the caller does not own returns 403 `ACCESS_DENIED` rather than 404.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `oauth_dev:clients:list` bucket.
+
+## Update application
+
+
+
+Updates one owned application and returns the resulting [application](#application-object) object. The caller must own the application.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name?1 | string | The name of the application (1-100 characters) |
+| redirect_uris?2 | ?array[string] | The redirect URIs to register, at most 10 |
+| bot_public? | boolean | Whether any eligible user can install the bot |
+| bot_require_code_grant? | boolean | Whether bot installation requires an OAuth2 code grant |
+
+1 Rejected with 403 `CONTENT_BLOCKED` when content moderation blocks the value
+
+2 Each entry is normalised, bounded at 1 to 256 characters, and validated on the same terms as [Create application](#create-application). A supplied array becomes the complete registered set, null clears every registered URI, and an omitted field leaves the current set unchanged
+
+:::caution[Redirect URIs are replaced]
+Removing a redirect URI takes effect immediately for new authorisation requests. An authorisation code that was already issued against the removed URI still requires that exact URI at [Exchange OAuth2 token](/http-api/oauth2/#exchange-oauth2-token), and previously issued access and refresh tokens are unaffected.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [application](#application-object) object | The application was updated |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | The name or a redirect URI is blocked (`CONTENT_BLOCKED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+### Side effects
+
+The submitted fields replace the corresponding application configuration. Neither credential is rotated, and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `oauth_dev:clients:update::client_id` bucket, shared with [Update bot profile](#update-bot-profile).
+
+## Update bot profile
+
+
+
+Updates the bot account owned by an application and returns the resulting [bot profile](#bot-profile-object) object. The caller must own the application.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username?1 | string | The new username for the bot account (1-32 characters) |
+| discriminator?2 | string \| integer | The discriminator, 1 to 4 decimal digits parsed as an integer |
+| avatar?3 | ?string | The base64-encoded avatar image, or null to clear the stored avatar |
+| banner?3 | ?string | The base64-encoded banner image, or null to clear the stored banner |
+| bio?4 | ?string | The profile biography (0-1024 characters), or null to clear the stored value |
+| bot_flags?5 | integer | [Bot flags](#bot-flags) |
+
+1 Trimmed before validation and restricted to ASCII letters, digits, and underscores. The value cannot be `everyone` or `here` and cannot contain `fluxer` or `system message`. A change that alters more than letter case always reallocates the discriminator. A username the profile substring blocklist rejects returns 403 `CONTENT_BLOCKED`
+
+2 Accepted only when it equals the bot's current discriminator. Any other value returns the field code `BOT_DISCRIMINATOR_CANNOT_BE_CHANGED`
+
+3 The value can include a `data:...;base64,` prefix, which Fluxer strips before it measures the payload. The remaining base64 payload is bounded at 13981016 characters for both fields, the encoded length of a full 10485760-byte image, and a longer value is rejected with the field code `BASE64_LENGTH_INVALID`
+
+4 Rejected with 403 `CONTENT_BLOCKED` when content moderation or the profile substring blocklist blocks the value
+
+5 Fluxer reads only the two [bot flags](#bot-flags) from the supplied bitfield and sets or clears each to match. It ignores every other bit
+
+Fluxer checks the decoded bytes of `avatar` and `banner` against the instance's avatar byte ceiling, which applies to both fields and defaults to 10 MiB. Each image also passes the format allowlist and the animation rules of the asset policy for the field it sets. Pixel dimensions are never checked.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [bot profile](#bot-profile-object) object | The profile was updated |
+| 400 | [error response](/http-api/#error-response) | The path or body fails validation |
+| 400 | [error response](/http-api/#error-response) | No discriminator can be allocated for the requested username (`USERNAME_NOT_AVAILABLE`) |
+| 400 | [error response](/http-api/#error-response) | The discriminator differs from the current one |
+| 400 | [error response](/http-api/#error-response) | The image is malformed, oversized, or an unsupported format |
+| 400 | [error response](/http-api/#error-response) | The image is marked explicit (`EXPLICIT_CONTENT_CANNOT_BE_SENT`) |
+| 400 | [error response](/http-api/#error-response) | The tag change allowance is exhausted |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | The username or biography is blocked (`CONTENT_BLOCKED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`), or it has no bot account (`BOT_USER_NOT_FOUND`) |
+
+This operation always clears the bot's global display name, so a bot is always presented by its username. The write consumes the tag change allowance only when it changes the username or the discriminator. Fluxer reports an exhausted allowance as a body validation entry on `username` with the code `USERNAME_CHANGED_TOO_MANY_TIMES` rather than as 429.
+
+:::note[Only null clears a stored value]
+An omitted `avatar`, `banner`, or `bio` leaves the stored value untouched. Sending `{"bio": null}` clears the biography, and sending `{}` changes nothing except the global display name.
+:::
+
+### Side effects
+
+Fluxer applies the supplied profile fields to the bot account. Replaced avatar and banner assets stop appearing after the update, and a username change that alters more than letter case also replaces the discriminator with a random free one for the new username. The operation emits a [User Update](/gateway/events/#user-update) Gateway Dispatch to the bot account's own sessions.
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `oauth_dev:clients:update::client_id` bucket, shared with [Update application](#update-application), and a change to the resulting tag is additionally limited to 5 changes per 3 hours for each bot account.
+
+## Reset bot token
+
+
+
+Rotates the bot token and returns the [bot token reset](#bot-token-reset-object) object with the new token. The caller must own the application and must prove sudo mode.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### JSON body
+
+The body is a [sudo verification](/http-api/users/mfa/#sudo-verification-object) object. Its fields are not required when the request already has a valid proof.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [bot token reset](#bot-token-reset-object) object | The token was rotated |
+| 400 | [error response](/http-api/#error-response) | The path or sudo body fails validation, or the supplied password or MFA code is wrong |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | Sudo mode is required and was not proven (`SUDO_MODE_REQUIRED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`), or it has no bot account (`BOT_USER_NOT_FOUND`) |
+
+:::caution[Rotation disconnects the bot]
+The previous token stops authenticating the moment the new one is stored, and every live main Gateway session the bot holds is terminated.
+:::
+
+A running bot reconnects only after its operator installs the returned token.
+
+### Side effects
+
+Fluxer stores the new token and terminates every active main Gateway session belonging to the bot account. The bot's guild memberships, group direct message memberships, and OAuth2 grants are unchanged, and no resource Dispatch is emitted.
+
+### Rate limit
+
+10 requests per hour for each authenticated user, on the `oauth_dev:clients:rotate_secret::client_id` bucket, shared with [Reset client secret](#reset-client-secret).
+
+## Reset client secret
+
+
+
+Rotates the OAuth2 client secret and returns the [application](#application-object) object with the new secret. The caller must own the application and must prove sudo mode.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### JSON body
+
+The body is a [sudo verification](/http-api/users/mfa/#sudo-verification-object) object. Its fields are not required when the request already has a valid proof.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [application](#application-object) object | The client secret was rotated |
+| 400 | [error response](/http-api/#error-response) | The path or sudo body fails validation, or the supplied password or MFA code is wrong |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | Sudo mode is required and was not proven (`SUDO_MODE_REQUIRED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+The returned application object has the new `client_secret` and no `bot` member. A caller that needs both reads [Get application](#get-application) afterwards.
+
+### Side effects
+
+The previous client secret stops authenticating token exchange, introspection, and revocation immediately. Existing access and refresh tokens remain valid until they expire or are revoked, and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per hour for each authenticated user, on the `oauth_dev:clients:rotate_secret::client_id` bucket, shared with [Reset bot token](#reset-bot-token).
+
+## Delete application
+
+
+
+Deletes one owned application and anonymises its bot account, then returns 204 with an empty body. The caller must own the application and must prove sudo mode.
+
+Removing the bot from its guilds emits one [Guild Member Remove](/gateway/events/#guild-member-remove) Gateway Dispatch per guild, and each removal also delivers [Guild Delete](/gateway/events/#guild-delete) to the bot's own Gateway sessions.
+
+:::danger[Deleting an application strips its bot account]
+Deletion permanently destroys the application, its client secret, and its bot token, strips the bot account's identity, and removes the bot from every guild.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+
+### JSON body
+
+The body is a [sudo verification](/http-api/users/mfa/#sudo-verification-object) object. Its fields are not required when the request already has a valid proof.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The application was deleted |
+| 400 | [error response](/http-api/#error-response) | The path or sudo body fails validation, or the supplied password or MFA code is wrong |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) |
+| 403 | [error response](/http-api/#error-response) | Sudo mode is required and was not proven (`SUDO_MODE_REQUIRED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+A 204 response means the removal has finished. Repeating the request afterwards returns 404 `UNKNOWN_APPLICATION`.
+
+### Side effects
+
+Fluxer reattributes every message the bot authored to a freshly created placeholder account with the deleted-account representation, and it creates that placeholder only when the bot authored at least one message. The bot's tag is released for reuse.
+
+The bot is removed from every guild it is a member of, and the bot account record is retained and rewritten to the deleted-account representation. That representation has the username `DeletedUser`, the global name `Deleted User`, and the discriminator `0000`, with no email, no password, no authenticators, no avatar, banner, biography, pronouns, accent colour, timezone, or date of birth, and only the deleted flag set. Fluxer then deletes the application record.
+
+The bot token and the client secret stop authenticating as soon as the application record is gone. No further token exchange, refresh, introspection, or revocation succeeds for the application. Access and refresh tokens already issued to it are not deleted, and an outstanding access token keeps authenticating a scope-gated route until it expires. [Get current OAuth2 authorisation](/http-api/oauth2/#get-current-oauth2-authorisation) and [Get OAuth2 user information](/http-api/oauth2/#get-oauth2-user-information) resolve the application as well, so both return 401 `INVALID_TOKEN` for that token.
+
+### Rate limit
+
+10 requests per hour for each authenticated user, on the `oauth_dev:clients:delete::client_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/authentication.mdx b/fluxer_docs/src/content/docs/http-api/authentication.mdx
new file mode 100644
index 000000000..7b0952551
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/authentication.mdx
@@ -0,0 +1,1519 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Authentication resource
+description: Registration, login, credentials, sessions, recovery, SSO, IP authorisation, and desktop handoff.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The Authentication resource covers signing up, signing in, recovering an account, and moving a session to a new device. Every route on this page is below `/v1/auth`.
+
+## Shared behaviour
+
+[Authentication](/authentication/) and [HTTP authentication](/http-api/#authentication) define credential syntax and the credential namespaces. Changing the credentials of an account that is already signed in is covered by [Email and password changes](/http-api/users/email-and-password/), [Multi-factor authentication](/http-api/users/mfa/), and [Phone verification](/http-api/users/phone-verification/).
+
+:::caution[Authentication material is confidential and opaque]
+A client MUST treat every session token, one-use token, ticket, SSO state, and handoff code as confidential. It MUST NOT parse one, expose it to untrusted code, or write it to logs, analytics, or a URL the operation did not assign.
+:::
+
+Most operations need no `Authorization` credential, because the request has a one-use token, MFA ticket, WebAuthn assertion, or account credential of its own. [List authentication sessions](#list-authentication-sessions), [terminate authentication sessions](#terminate-authentication-sessions), [resend email verification](#resend-email-verification), [log out](#log-out), and [complete desktop handoff](#complete-desktop-handoff) are the only operations on this page that read an `Authorization` credential. Complete desktop handoff reads the header only when its body omits the `token` field. The first three require an ordinary user session, and each rejects a bot token and an OAuth2 bearer token with 403 `ACCESS_DENIED`. All three still admit a session whose account is flagged for suspicious activity.
+
+Every route has a route bucket and is also subject to the [global HTTP limit](/topics/rate-limits/). A route bucket is keyed by the authenticated user when the request has a resolvable credential and by the client IP address otherwise. Bucket or global denial returns 429 `RATE_LIMITED`.
+
+An invalid JSON shape returns 400 `INVALID_FORM_BODY` with [validation error object](/http-api/#validation-error-object) entries in `errors`. An unexpected failure returns 500 `INTERNAL_SERVER_ERROR`. Every enumerated code on this page is registered in the [error registry](/http-api/errors/), and every snowflake field is the decimal string form defined by [Snowflakes](/snowflakes/).
+
+When SSO is both enabled and enforced, every local authentication operation returns 403 `SSO_REQUIRED`. The SSO status route, SSO start and completion, logout, both session routes, and every handoff route stay available under enforcement.
+
+:::caution[SSO enforcement precedes every other check]
+The enforcement check runs before CAPTCHA verification, before the route rate limit, before authentication, and before body validation. An enforced instance therefore answers a malformed or unauthenticated local authentication request with 403 `SSO_REQUIRED`.
+:::
+
+A successful sign-in creates one authentication session and issues its token. Fluxer sets no ceiling on live sessions and evicts none when a further session starts, so an account accumulates one session per sign-in until it revokes them through [terminate authentication sessions](#terminate-authentication-sessions).
+
+Revoking a session, whether the account revoked it or an administrator terminated it, stops its token authenticating requests. Its main Gateway connection receives [Invalid Session](/gateway/overview/#invalid-session) with `d: false` and stays open, unauthenticated. No Gateway Dispatch is emitted for the revocation itself, so a client learns of it from that frame or from the next request that fails to authenticate.
+
+
+
+## Authentication token response object
+
+A newly issued session token and the account it belongs to. Password login without a second factor, MFA completion, discoverable WebAuthn authentication, immediate registration, password reset, email reversion, SSO completion, IP authorisation polling, and handoff polling all return these fields.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token1 | string | The newly issued user session token |
+| user_id | snowflake | The authenticated user ID |
+| user | [partial user](/http-api/users/#partial-user-object) object | The public representation of the authenticated account |
+
+1 The token is the literal prefix `flx_` followed by exactly 36 base62 characters, and this response is the only way to read it
+
+:::caution[A session token is returned once]
+A lost session token cannot be recovered through any operation. The account MUST authenticate again to obtain a new session.
+:::
+
+:::note[The successful result has no discriminator]
+Password login, registration, and password reset each return either this object or an alternative shape. The successful shape has no `mfa` or `registration_pending_approval` member of its own, so a client MUST branch on the presence of `token`.
+:::
+
+## SSO status object
+
+The public single sign-on state. The same object is embedded by the [instance discovery document](/http-api/instance/#instance-discovery-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled1 | boolean | Whether SSO can currently be started on this instance |
+| enforced2 | boolean | Whether SSO is required for every user |
+| display_name | ?string | The configured provider display name, or null when none is set |
+| redirect_uri | string | The default OAuth2 redirect URI used for the provider callback |
+
+1 The value is true only when the operator has enabled SSO and the resolved provider configuration is complete, so a partially configured provider reports false
+
+2 The value is true only when `enabled` is also true, and every local authentication operation then returns 403 `SSO_REQUIRED`
+
+## SSO start object
+
+The parameters for sending the user to the identity provider, bound to one new SSO state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| authorization_url | string | The provider authorisation URL with the state, nonce, and PKCE challenge |
+| state1 | string | The one-use CSRF state |
+| redirect_uri | string | The callback URI bound to this state |
+
+1 The state is consumed by the first [complete SSO](#complete-sso) attempt that resolves it
+
+A client MUST return the state unchanged and MUST NOT interpret its contents.
+
+## SSO completion response object
+
+SSO completion always issues a session. It extends the [authentication token response](#authentication-token-response) with the redirect bound to the consumed state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The newly issued user session token |
+| user_id | snowflake | The authenticated user ID |
+| user | [partial user](/http-api/users/#partial-user-object) object | The public representation of the authenticated account |
+| redirect_to1 | string | The sanitised redirect that was bound to the consumed SSO state |
+
+1 The value is the empty string when [start SSO](#start-sso) received no `redirect_to` or when the supplied value did not survive sanitisation
+
+:::note[SSO completion never issues an MFA challenge]
+Completion does not consult the account's second factor and cannot return an [MFA challenge response](#mfa-challenge-response). A client MUST NOT branch on an `mfa` member here.
+:::
+
+## Registration pending approval response object
+
+The ID of a registration that an administrator has yet to approve. Registration returns this object when the account enters approval.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| registration_pending_approval | boolean | Always `true` |
+| user_id | snowflake | The registered account ID awaiting approval |
+
+## Partial user object
+
+Every authenticated result on this page embeds the public account representation. The [partial user object](/http-api/users/#partial-user-object) on the Users resource defines it, together with its [reply mention preferences](/http-api/users/#reply-mention-preferences).
+
+
+
+## MFA challenge response object
+
+The ticket and the method list a client needs to finish a login with a second factor. Password login returns this object when the account has one.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mfa | boolean | Always `true` |
+| ticket1 | string | The ticket consumed by a TOTP or WebAuthn MFA completion |
+| allowed_methods2 | array[string] | The methods available to this account, drawn from `totp` and `webauthn` (max 10 items) |
+| totp | boolean | Whether the account holds the time-based one-time password authenticator type |
+| webauthn | boolean | Whether at least one WebAuthn credential is registered |
+
+1 The ticket is retained for five minutes, is destroyed after five failed code attempts, and is consumed by the completion that issues the session
+
+2 The array lists the account's authenticators in the fixed order `totp` then `webauthn`, and it omits either value the account does not hold
+
+:::note[TOTP completion accepts an unconsumed backup code]
+There is no backup code completion route and `backup_code` never appears in `allowed_methods`. [Complete login with TOTP](#complete-login-with-totp) reads it from `code`, and only while the account holds the TOTP authenticator type. Fluxer never reports whether one remains.
+:::
+
+
+
+## WebAuthn authentication options object
+
+Both WebAuthn option operations return a PublicKeyCredential request options object that a browser passes straight to its credential request. The [WebAuthn authentication options object](/http-api/users/mfa/#webauthn-authentication-options-object) on the Multi-factor authentication resource defines its fields, together with its [WebAuthn credential descriptor](/http-api/users/mfa/#webauthn-credential-descriptor-object) and [WebAuthn client extension inputs](/http-api/users/mfa/#webauthn-client-extension-inputs-object) objects.
+
+A challenge issued for MFA completion is additionally bound to its ticket and account, so it cannot be replayed against discoverable login. The MFA route lists every registered credential for the resolved account in `allowCredentials`, while the discoverable route omits the member because discovery happens at the authenticator.
+
+
+
+## WebAuthn assertion object
+
+A WebAuthn authentication request has the browser credential result as `response` and the original server challenge as `challenge`. Fluxer accepts additional WebAuthn fields. The [WebAuthn assertion object](/http-api/users/mfa/#webauthn-assertion-object) on the Multi-factor authentication resource defines the fields, together with its [WebAuthn assertion response](/http-api/users/mfa/#webauthn-assertion-response-object) and [WebAuthn client extension results](/http-api/users/mfa/#webauthn-client-extension-results-object) objects.
+
+## Authentication session object
+
+One live session belonging to the authenticated account. The listing is ordered by approximate last activity, newest first.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id_hash1 | string | The base64url SHA-256 digest of the session token |
+| client_info? | ?[client info](#client-info-object) object | The parsed client metadata recorded when the session was created |
+| masked_ip | ?string | The semi-redacted IP address recorded for the session |
+| approx_last_used_at? | ?ISO8601 timestamp | The approximate time of the last request that used this session |
+| current2 | boolean | Whether this session supplied the credential for the current request |
+
+1 This digest is the only session identifier the API exposes, and it is the exact value accepted by [terminate authentication sessions](#terminate-authentication-sessions)
+
+2 The field is false on every entry
+
+A client that needs to identify its own session MUST compare the base64url SHA-256 digest of its own token against `id_hash`.
+
+## Client info object
+
+The parsed device metadata recorded for a session or a pending handoff.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| platform? | ?string | The recorded client platform, resolved from the `User-Agent` |
+| os? | ?string | The recorded operating system |
+| browser?1 | ?string | The recorded browser |
+| device | string | The device class, either `mobile` or `desktop` |
+| location? | ?[client location](#client-location-object) object | The approximate geolocation derived from the recorded IP address |
+
+1 A native or Electron client reports null, and a session created by an unparseable request reports null. A handoff omits the member entirely
+
+Fluxer recognises a native Fluxer client from a `User-Agent` beginning `Fluxer Android`, `Fluxer iOS`, `Fluxer Linux`, `Fluxer Desktop`, or `Fluxer Client`. Its `platform` is the instance product name followed by the resolved operating system, with `Lite` inserted before the operating system for a non-mobile one. A native client that resolves no operating system reports the product name followed by `Lite`, so `platform` is never null for a native client. An Electron client reports the product name followed by the resolved operating system, and the product name alone when no operating system resolves. Fluxer parses every other client from the recorded `User-Agent`, and that client's platform is the browser name, or the operating system name when no browser resolves, and null when neither resolves.
+
+Fluxer resolves `device` from the recorded `User-Agent` and, for a native client, from the operating system reported through `X-Fluxer-Client-Properties`. A native or Electron client is `mobile` only when the resolved operating system is iOS or Android. Every other client is `mobile` when the parsed platform type is mobile or tablet, and `desktop` otherwise.
+
+A session reports a null `location` when the recorded IP address resolves to no location. A handoff always reports the object, and each of its three members is null when that component is unavailable. For a session, an address with a known region and country but no known city reports the region as `city`, the country as `region`, and null as `country`.
+
+## Client location object
+
+The approximate geolocation Fluxer derives from the IP address recorded for a session or a handoff.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| city? | ?string | The approximate city |
+| region? | ?string | The approximate region |
+| country? | ?string | The approximate country |
+
+## Password reset validity object
+
+The result of checking a password reset token without consuming it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| valid1 | boolean | Whether the token is currently valid and unexpired |
+
+1 A password reset token expires one hour after it is issued
+
+## IP authorisation poll object
+
+The state of one IP authorisation ticket, as observed by the device whose sign-in was held.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| completed | boolean | Whether the authorisation link has been used and a session has been issued |
+| token?1 | ?string | The newly issued user session token |
+| user_id?1 | ?snowflake | The authenticated user ID |
+| user?1 | ?[partial user](/http-api/users/#partial-user-object) object | The public representation of the authenticated account |
+
+1 The three fields are present together only when `completed` is true, and a still-pending ticket returns `completed` alone
+
+## Username suggestions object
+
+The username candidates derived from a display name.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| suggestions1 | array[string] | The generated username candidates (max 20 items) |
+
+1 The array has at most one candidate. It is empty when the supplied display name derives no permitted username
+
+## Handoff initiation object
+
+The code that identifies one pending desktop handoff.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The handoff code to present to the approving device |
+| expires_at | ISO8601 timestamp | The time at which the handoff expires, five minutes after creation |
+| poll_secret? | string | The secret the initiating device presents to read the issued token |
+
+1 The code is 12 characters drawn from the alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789`, rendered as two groups of six separated by a hyphen
+
+:::caution[The code alone cancels a handoff]
+Anyone who learns the code reads the pending device metadata and cancels the handoff. The token needs `poll_secret` as well. A client MUST show the code only to the person performing the handoff.
+:::
+
+## Handoff information object
+
+The device metadata shown to the approving device before it transfers a session.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | The state, either `pending` or `expired` |
+| client_info?1 | ?[client info](#client-info-object) object | The initiating device metadata |
+
+1 The value is null whenever the status is `expired`
+
+## Handoff status object
+
+The state of one desktop handoff as observed by the device that initiated it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status1 | string | The state, one of `pending`, `expired`, or `completed` |
+| token?2 | ?string | The newly issued user session token |
+| user_id?2 | ?snowflake | The authenticated user ID |
+| user?2 | ?[partial user](/http-api/users/#partial-user-object) object | The public representation of the authenticated account |
+
+1 An unknown code and an expired code both report `expired`
+
+2 The three fields are present together only when the status is `completed`
+
+## Get SSO status
+
+
+
+Reads the public single sign-on state of the instance. Authentication is not required. Returns an [SSO status](#sso-status-object) object.
+
+The operation stays available while SSO is enforced. It shares the `auth:sso:start` bucket, which permits 10 requests per 10 seconds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [SSO status](#sso-status-object) object | The public SSO state was read |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+## Start SSO
+
+
+
+Creates one SSO state with PKCE and nonce material. Authentication is not required. Returns an [SSO start](#sso-start-object) object.
+
+The returned `authorization_url` is the configured provider authorisation endpoint with `response_type` set to `code`, the configured `client_id` and `scope`, the resolved `redirect_uri`, the returned `state`, and a freshly generated `nonce`. It also has a `code_challenge` computed as the base64url SHA-256 digest of the state's code verifier, with `code_challenge_method` set to `S256`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| redirect_to?1 | ?string | The post-authentication redirect to bind to the state |
+| redirect_uri?2 | ?string | The provider callback URI to use instead of the configured default |
+
+1 Fluxer sanitises the value before binding it to the state and discards a value that does not survive, which the eventual [SSO completion response](#sso-completion-response-object) reports as the empty string. Sanitisation keeps the trimmed value only when it begins with a single `/`, is at most 2,048 characters, and contains no carriage return or line feed
+
+2 The two accepted values are the instance default reported as `redirect_uri` by [get SSO status](#get-sso-status) and the mobile callback `fluxer://auth/sso/callback`, and any other value returns the field code `INVALID_URL_FORMAT`. The accepted value is bound to the state and reused at the token exchange
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [SSO start](#sso-start-object) object | The state was created |
+| 400 | [error response](/http-api/#error-response) | The body is malformed or the callback URI is not an accepted value |
+| 403 | [error response](/http-api/#error-response) | SSO is disabled or its resolved configuration is incomplete, returning `FEATURE_TEMPORARILY_DISABLED` |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The returned state can be completed once and lives for ten minutes. It binds the PKCE verifier, nonce, redirect, and callback URI to the flow. No account state changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per 10 seconds, on the `auth:sso:start` bucket.
+
+## Complete SSO
+
+
+
+Consumes the SSO state, exchanges the authorisation code, verifies the identity token against the provider key set and the bound nonce, resolves or provisions the account, and creates a user session. Authentication is not required. Returns an [SSO completion response](#sso-completion-response-object).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The authorisation code returned by the provider (1-4096 characters) |
+| state1 | string | The state returned by [start SSO](#start-sso) (1-4096 characters) |
+
+1 The state is consumed on the first call that resolves it, so a repeated request with the same state returns the field code `INVALID_OR_EXPIRED_SSO_STATE`
+
+:::note[Identity binding is one-to-one]
+A provider subject is bound to exactly one Fluxer account, and an account accepts exactly one provider subject. Presenting a subject that is already linked to a different account, or presenting a new subject for an account that already holds a different linked subject, returns the field code `SSO_IDENTITY_MISMATCH`.
+:::
+
+:::caution[A verified provider email adopts a local account]
+When the subject is not yet linked, Fluxer resolves the account by the verified email address in the provider claim. It adopts any existing account holding that address and binds the subject to it, whether or not that account has a password or an enrolled authenticator. An operator therefore MUST enable SSO only for a provider that controls the addresses it asserts, because a provider asserting an arbitrary verified address takes over the matching local account.
+:::
+
+Fluxer refuses the adoption only when the account already holds a different provider subject.
+
+The provider exchange and claim resolution can return the field codes `INVALID_SSO_AUTHORIZATION_CODE`, `INVALID_SSO_TOKEN`, `FAILED_TO_FETCH_SSO_USER_INFO`, `FAILED_TO_PARSE_SSO_USER_INFO`, `SSO_PROVIDER_DID_NOT_RETURN_EMAIL`, `SSO_IDENTITY_MISMATCH`, `SSO_MISCONFIGURED`, `INVALID_EMAIL_ADDRESS`, `EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO`, or `SSO_UNABLE_TO_ALLOCATE_DISCRIMINATOR`. An unverified provider email uses the field code `INVALID_SSO_TOKEN`.
+
+Closed registration returns 403 `REGISTRATION_CLOSED`, an unknown account on an instance with automatic provisioning disabled returns 403 `SSO_REQUIRED`, an account awaiting approval returns 403 `REGISTRATION_PENDING_APPROVAL`, and account suspension returns 403 `ACCOUNT_SUSPENDED_TEMPORARILY` or 403 `ACCOUNT_SUSPENDED_PERMANENTLY`. A provisioned username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
+
+A verified provider email that adopts a bot account returns 403 `BOT_USER_AUTH_SESSION_CREATION_DENIED`, and one that adopts a rejected registration returns 403 `REGISTRATION_REJECTED`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [SSO completion response](#sso-completion-response-object) | SSO completed and a session was issued |
+| 400 | [error response](/http-api/#error-response) | The body, state, provider exchange, claims, or resolved email is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO, registration policy, a bot account, or account suspension rejects completion |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | The identity provider exchange could not be completed |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Fluxer consumes the SSO state exactly once, before it attempts the provider exchange. A failed exchange still burns the state. A client MUST then start a fresh flow. On first sign-in, the provider identity becomes exclusive to the new account. The account starts with a verified email, no password, no authenticator, and its default settings. Provisioning joins no guild and accepts no invite, so no [Guild Member Add](/gateway/events/#guild-member-add) is emitted.
+
+Approval mode registration instead returns 403 `REGISTRATION_PENDING_APPROVAL` and creates no session. Otherwise the operation creates one authentication session and clears an expired temporary suspension.
+
+### Rate limit
+
+15 requests per 10 seconds, on the `auth:sso:complete` bucket.
+
+## Register an account
+
+
+
+Creates an ordinary account. Returns an [authentication token response](#authentication-token-response) when the instance admits the account immediately and a [registration pending approval response](#registration-pending-approval-response-object) when it does not. Emits a [Guild Member Add](/gateway/events/#guild-member-add) Gateway event when an invite or instance community admission takes effect.
+
+This is a local authentication operation and it verifies [CAPTCHA](/topics/captcha/) when CAPTCHA is enabled. Registration additionally permits 3 attempts per hour for each client IP address and 15 per hour for each client subnet, which is the IPv4 /24 or IPv6 /48 network. A supplied email address permits 3 attempts per 15 minutes of its own. Those three allowances are separate from the route bucket, and only a deployment that relaxes registration rate limits disables them.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The proof issued by the CAPTCHA provider |
+| X-Captcha-Type?2 | string | The provider that issued the proof, either `hcaptcha` or `turnstile` |
+| Accept-Language?3 | string | The language tag that selects the locale stored on the new account |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`, and verification is skipped entirely when CAPTCHA is disabled for the instance
+
+2 Omitting the header selects the instance's configured provider
+
+3 The parsed locale becomes the account locale and selects the language of the verification email
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email?1 | string | The account email address |
+| username?2 | string | The account username (1-32 characters, letters, digits, and underscores) |
+| global_name? | string | The display name after normalisation (1-32 characters) |
+| password?3 | string | The account password (8-256 characters) |
+| date_of_birth?4 | string | The date of birth in exact `YYYY-MM-DD` form |
+| consent?5 | boolean | Whether the terms of service and privacy policy are accepted (default false) |
+| invite_code? | ?string | The invite accepted immediately after registration (0-256 characters) |
+| registration_url_code?6 | ?string | The administrator-issued registration URL code (1-256 characters) |
+| theme? | string | The initial theme preference, one of `dark`, `dark_legacy`, `coal`, `light`, or `system` |
+
+1 Omitting the email address creates an unclaimed account, which has no recovery path until it is claimed. An address whose domain has no usable DNS records, or whose top-level domain is blocked by account policy, returns the field code `INVALID_EMAIL_ADDRESS`, and an address already in use returns `EMAIL_ALREADY_IN_USE`
+
+2 Omitting the username derives one from `global_name` when that value produces a permitted username, and otherwise allocates a generated username, in both cases with a server-allocated discriminator
+
+3 The password is checked against the public breached-password corpus described under [reset a password](#reset-a-password), and a match returns the field code `PASSWORD_IS_TOO_COMMON`
+
+4 The field is required when the instance [collects date of birth](/http-api/instance/#public-registration-fields-object). An absent or blank value, and a value in `YYYY-MM-DD` shape that is not a real calendar date, return the field code `INVALID_DATE_OF_BIRTH_FORMAT`. A value that is not ten characters in `YYYY-MM-DD` shape fails schema validation with the field code `STRING_LENGTH_EXACT` or `INVALID_FORMAT`
+
+5 A false or absent value returns the field code `MUST_AGREE_TO_TOS_AND_PRIVACY_POLICY` on the official instance and on any instance that publishes a terms or privacy document
+
+6 A code supplied on an instance with administrator registration URLs disabled, and a code that does not resolve, both return 400 `REGISTRATION_URL_INVALID`. A valid code overrides the closed registration mode and supplies its own approval setting in place of the instance approval mode
+
+Fluxer resolves the region from the client IP address, and an age below the minimum for that region returns the field code `MUST_BE_MINIMUM_AGE`. That minimum is 13 years unless account policy sets a different minimum for the region, and the applied minimum appears only in the localised message.
+
+Registration returns 403 `REGISTRATION_CLOSED` when the instance is closed and no valid registration URL was supplied. A username whose discriminator space is exhausted returns the field code `TOO_MANY_USERS_WITH_THIS_USERNAME`. A username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
+
+:::caution[Registration reports an address already in use]
+[Log in with a password](#log-in-with-a-password) and [request password recovery](#request-password-recovery) are enumeration-safe and registration is not. An address that already holds an account returns the field code `EMAIL_ALREADY_IN_USE`, so an unauthenticated caller learns from the response whether that address is registered.
+:::
+
+:::caution[Registration is not idempotent]
+There is no retry key. A repeated request with the same values creates a second account, or fails on the duplicate email address when one was supplied. A client that loses the response MUST resolve the outcome by attempting a login.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) \| [registration pending approval response](#registration-pending-approval-response-object) | Registration completed or entered the approval state |
+| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, account fields, or registration URL code is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement, registration policy, or content moderation rejects the registration |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, IP, or subnet bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation applies the instance's registration, email-domain, breached-password, and regional policies before creating the account, and its risk policy can set suspicious activity flags on the created account. It records the accepted terms and privacy policy, authorises the registering client IP address, and sends an email verification message when the instance sends email. An instance that sends no email marks the address verified at creation instead.
+
+Registration can accept a supplied or instance-configured invite, and the account can join the instance community. Each join emits [Guild Member Add](/gateway/events/#guild-member-add) to the affected guild's sessions. Registration policy can suppress invite admission.
+
+Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. Every other successful registration creates one session and returns its token.
+
+### Rate limit
+
+10 requests per 10 seconds, on the `auth:register` bucket.
+
+## Log in with a password
+
+
+
+Validates local email and password credentials. Authentication is not required. Returns an [authentication token response](#authentication-token-response) when no second factor and no IP approval are outstanding, and an [MFA challenge response](#mfa-challenge-response) when the account has a second factor.
+
+This is a local authentication operation and it verifies CAPTCHA when CAPTCHA is enabled. Login additionally permits 10 attempts per client IP address in 30 minutes, keyed by the exact IPv4 address or the IPv6 /64 network, and 5 attempts per email address in 15 minutes. Every admitted attempt consumes both allowances, whether or not the credentials turn out to be correct, and a successful login clears neither.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The proof issued by the CAPTCHA provider |
+| X-Captcha-Type?2 | string | The provider that issued the proof, either `hcaptcha` or `turnstile` |
+| X-Fluxer-Client-Properties?3 | string | The base64-encoded JSON with the reporting client's `os` |
+| User-Agent?3 | string | The client string recorded on the created session and shown by [list authentication sessions](#list-authentication-sessions) |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`, and verification is skipped entirely when CAPTCHA is disabled for the instance
+
+2 Omitting the header selects the instance's configured provider
+
+3 Both values feed the [client info](#client-info-object) object recorded on the session, and `X-Fluxer-Client-Properties` is read only for a native Fluxer `User-Agent`
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email | string | The account email address |
+| password | string | The account password (8-256 characters) |
+| invite_code?1 | ?string | The invite accepted immediately after a successful login (0-256 characters) |
+
+1 The invite is accepted only on a login that completes without a second factor, and a failure to accept it does not fail the login. A login held for IP authorisation discards the invite
+
+:::note[Unknown accounts and unknown passwords are indistinguishable]
+An unknown email address and an incorrect password both return the same paired field codes `INVALID_EMAIL_OR_PASSWORD` on `email` and `password`.
+:::
+
+A new client IP address on an account that has neither a second factor nor the app store reviewer flag returns 403 `IP_AUTHORIZATION_REQUIRED`. That error body has `ip_authorization_required` set to true, the `ticket` used by the three IP authorisation operations, the account `email`, and `resend_available_in` set to 30 seconds.
+
+When the instance has disabled new-IP authorisation or sends no email, Fluxer authorises the client IP address silently and the login proceeds. An account that already has a second factor never enters IP authorisation.
+
+Account policy can return 403 `REGISTRATION_PENDING_APPROVAL`, 403 `REGISTRATION_REJECTED`, 403 `ACCOUNT_SUSPENDED_TEMPORARILY`, or 403 `ACCOUNT_SUSPENDED_PERMANENTLY`. A bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`.
+
+:::note[A verified password reactivates the account]
+A correct password on an account the user had disabled clears the disabled state. A correct password on an account with a pending self-scheduled deletion cancels that deletion. Both happen during the login, before any second factor is requested, with no confirmation step and no reactivation ticket.
+:::
+
+The login clears an expired temporary suspension the same way. It does not clear a live temporary or permanent administrator suspension, and returns that suspension's 403 instead.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) \| [MFA challenge response](#mfa-challenge-response) | The credentials are accepted |
+| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, email, or password is invalid |
+| 403 | [error response](/http-api/#error-response) | Account policy, SSO enforcement, or IP authorisation prevents the login |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, or IP bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+A successful login on an account with a second factor returns an MFA ticket valid for five minutes and issues no session. When IP approval is required, the authorisation ticket remains valid for 15 minutes and the account receives an authorisation message.
+
+A successful login with no outstanding MFA or IP approval creates one authentication session. It accepts the supplied invite first, which emits [Guild Member Add](/gateway/events/#guild-member-add) to the guild's sessions.
+
+### Rate limit
+
+10 requests per 10 seconds, on the `auth:login` bucket.
+
+## Complete login with TOTP
+
+
+
+Consumes an MFA ticket and validates a time-based one-time password or an unconsumed backup code, then creates the session. Authentication is not required. Returns an [authentication token response](#authentication-token-response).
+
+This is a local authentication operation. MFA verification additionally permits 10 failed attempts per account in 15 minutes and destroys the ticket after 5 failed attempts.
+
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The authenticator code or an unconsumed backup code |
+| ticket | string | The ticket returned by [password login](#log-in-with-a-password) |
+
+1 A validated authenticator code is claimed for 30 seconds, so the same code cannot be presented twice. A backup code is consumed permanently on the attempt that accepts it
+
+An expired or unknown ticket returns the field code `SESSION_TIMEOUT` on `ticket`. An account with no TOTP enrolment returns the field code `TOTP_NOT_ENABLED` on `code`. An incorrect code, and any code presented after the per-account or per-ticket attempt allowance is exhausted, returns the field code `INVALID_CODE` on `code`. A ticket that resolves to a bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) | The ticket and code are accepted |
+| 400 | [error response](/http-api/#error-response) | The body, ticket, or code is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation or the ticket resolves to a bot account |
+| 404 | [error response](/http-api/#error-response) | The ticket resolves to an account that no longer exists |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Success consumes the ticket, clears the per-account and per-ticket failed-attempt counters, and creates one authentication session. A failure consumes one attempt from each counter. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+5 requests per minute, on the shared `auth:login:mfa` bucket.
+
+## Get WebAuthn MFA options
+
+
+
+Resolves the account from an MFA ticket and creates a challenge restricted to that account's registered credentials. Authentication is not required. Returns a [WebAuthn authentication options](#webauthn-authentication-options) object.
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket1 | string | The ticket returned by [password login](#log-in-with-a-password) (1-256 characters) |
+
+1 The ticket is read but not consumed
+
+A client MUST still complete the returned challenge through [complete login with WebAuthn MFA](#complete-login-with-webauthn-mfa) before the ticket expires. An expired or unknown ticket returns the field code `SESSION_TIMEOUT` on `ticket`. An account with no registered credential returns 400 `NO_PASSKEYS_REGISTERED`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [WebAuthn authentication options](#webauthn-authentication-options) object | The ticket resolves to an account with at least one credential |
+| 400 | [error response](/http-api/#error-response) | The body or ticket is invalid, or the account has no registered credential |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation issues a one-use WebAuthn challenge valid for five minutes and bound to this MFA flow, account, and ticket. It changes no account state and emits no Gateway Dispatch.
+
+### Rate limit
+
+5 requests per minute, on the shared `auth:login:mfa` bucket.
+
+## Complete login with WebAuthn MFA
+
+
+
+Consumes the MFA ticket, verifies the WebAuthn assertion against the challenge, and creates the session. Authentication is not required. Returns an [authentication token response](#authentication-token-response).
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| response | [WebAuthn assertion](#webauthn-assertion) object | The browser credential assertion |
+| challenge1 | string | The challenge returned by [get WebAuthn MFA options](#get-webauthn-mfa-options) |
+| ticket | string | The ticket returned by [password login](#log-in-with-a-password) |
+
+1 The challenge is consumed before verification and is accepted only when its bound context, account, and ticket all match this request
+
+An expired or unknown ticket returns the field code `SESSION_TIMEOUT` on `ticket`. A challenge mismatch, an unknown credential, a stored public key that cannot be decoded, a signature counter that the authenticator did not advance, and a failed signature verification all return 401 `PASSKEY_AUTHENTICATION_FAILED`. A verified assertion whose reported signature counter cannot be read returns 500 `INVALID_WEBAUTHN_AUTHENTICATION_COUNTER`.
+
+This operation shares the MFA attempt allowances of [complete login with TOTP](#complete-login-with-totp). It permits 10 failed attempts per account in 15 minutes and destroys the ticket after 5 failed attempts, and an exhausted allowance returns the field code `INVALID_CODE` on `ticket`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) | The ticket and assertion are accepted |
+| 400 | [error response](/http-api/#error-response) | The body or ticket is invalid |
+| 401 | [error response](/http-api/#error-response) | Challenge or assertion verification fails |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation or the ticket resolves to a bot account |
+| 404 | [error response](/http-api/#error-response) | The ticket resolves to an account that no longer exists |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | The verified assertion reported no readable signature counter, or the request could not be completed |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Success consumes the challenge and the ticket, records the credential's new signature counter and last use, and creates one authentication session. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+5 requests per minute, on the shared `auth:login:mfa` bucket.
+
+## Get discoverable WebAuthn options
+
+
+
+Creates a challenge for passwordless authentication with a discoverable credential. Authentication is not required. Returns a [WebAuthn authentication options](#webauthn-authentication-options) object.
+
+This is a local authentication operation.
+
+The request has no body. The response omits `allowCredentials` and requests `required` user verification.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [WebAuthn authentication options](#webauthn-authentication-options) object | A discoverable challenge was created |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation issues a one-use discoverable WebAuthn challenge valid for five minutes and bound to the discoverable context alone. It changes no account state and emits no Gateway Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds, on the `auth:webauthn:options` bucket.
+
+## Authenticate with WebAuthn
+
+
+
+Resolves the account from the presented credential ID, verifies the assertion with user verification required, applies account suspension policy, and creates the session. Authentication is not required. Returns an [authentication token response](#authentication-token-response).
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| response | [WebAuthn assertion](#webauthn-assertion) object | The browser credential assertion |
+| challenge1 | string | The challenge returned by [get discoverable WebAuthn options](#get-discoverable-webauthn-options) |
+
+1 The challenge is consumed before verification and is accepted only when it was issued for the discoverable context, so a challenge issued for MFA or for sudo mode cannot be used here
+
+An unknown credential ID, a challenge mismatch, a stored public key that cannot be decoded, a signature counter that the authenticator did not advance, and a failed signature verification all return 401 `PASSKEY_AUTHENTICATION_FAILED`. A verified assertion whose reported signature counter cannot be read returns 500 `INVALID_WEBAUTHN_AUTHENTICATION_COUNTER`. Account suspension returns 403 `ACCOUNT_SUSPENDED_TEMPORARILY` or 403 `ACCOUNT_SUSPENDED_PERMANENTLY`.
+
+A bot account returns 403 `BOT_USER_AUTH_SESSION_CREATION_DENIED`. An account that has not been admitted returns 403 `REGISTRATION_PENDING_APPROVAL` or 403 `REGISTRATION_REJECTED`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) | The assertion and account policy are accepted |
+| 400 | [error response](/http-api/#error-response) | The body is malformed |
+| 401 | [error response](/http-api/#error-response) | Challenge or assertion verification fails |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement, account suspension, a bot account, or an unadmitted registration rejects the login |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | The verified assertion reported no readable signature counter, or the request could not be completed |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Success consumes the challenge, records the credential's new signature counter and last use, clears an expired temporary suspension, and creates one authentication session. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per 10 seconds, on the `auth:webauthn:authenticate` bucket.
+
+## Log out
+
+
+
+Revokes the session identified by the user session token in the `Authorization` header. Returns 204 with no body.
+
+The request has no body. The operation requires a credential that resolves to an account, and it admits one flagged for suspicious activity. An absent, malformed, or unknown token returns 401 `UNAUTHORIZED`. An OAuth2 bearer token returns 403 `ACCESS_DENIED`. A bot token is accepted and revokes nothing, because a bot holds no session.
+
+:::note[Logging out revokes exactly the calling session]
+The account's other sessions, its OAuth2 grants, and any outstanding MFA ticket, IP authorisation ticket, or desktop handoff all survive. Use [terminate authentication sessions](#terminate-authentication-sessions) to revoke other sessions.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The session was revoked, or the credential is a bot token |
+| 401 | [error response](/http-api/#error-response) | The token is absent, malformed, or names no live session |
+| 403 | [error response](/http-api/#error-response) | The credential is an OAuth2 bearer token |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Session revocation fails unexpectedly |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Revoking a live session ends its Gateway session. The connection receives Invalid Session with `d: false` and stays open, unauthenticated. No Gateway Dispatch is emitted for the revocation.
+
+### Rate limit
+
+20 requests per 10 seconds, on the `auth:logout` bucket.
+
+## Verify an email address
+
+
+
+Consumes an email verification token and marks the account's current address verified. Authentication is not required. Returns 204 with no body.
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The token delivered by email, exactly 64 characters |
+
+An unknown, already consumed, or deleted-account token returns the field code `INVALID_OR_EXPIRED_VERIFICATION_TOKEN`.
+
+:::caution[Verification marks the account's current address]
+Fluxer never compares the account's address against the one the token was issued for. An account that changes its address while a verification token is outstanding can therefore verify the new address with the old token.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The address was verified |
+| 400 | [error response](/http-api/#error-response) | The body or token is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation, or the token resolves to a bot account |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation marks the current address verified, clears its bounced state, and clears every suspicious activity flag that verifying or reverifying an email address satisfies. It emits a [User Update](/gateway/events/#user-update) Gateway Dispatch to the account's own sessions.
+
+### Rate limit
+
+10 requests per minute, on the `auth:verify` bucket.
+
+## Resend email verification
+
+
+
+Issues and sends a new email verification token for the authenticated account. Requires a user session token for an ordinary user. Returns 204 with no body.
+
+An account locked by suspicious activity is still permitted. This is a local authentication operation.
+
+The request has no body. When the current address is already verified and no reverification suspicious activity flag is set, the operation returns 204 and sends nothing.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A message was sent or verification was already complete |
+| 401 | [error response](/http-api/#error-response) | The user session credential is missing or invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation, or the credential is a bot token or an OAuth2 bearer token |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, or per-address bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation creates one 64-character verification token bound to the account and its current address, valid for 24 hours, and sends the verification message. Previously issued verification tokens remain valid. It emits no Gateway Dispatch.
+
+### Rate limit
+
+10 requests per minute, on the `auth:verify:resend` bucket, and the target address additionally permits 3 messages in 15 minutes.
+
+## Request password recovery
+
+
+
+Accepts a password recovery request for an email address. Authentication is not required. Returns 204 with no body.
+
+This is a local authentication operation and it verifies CAPTCHA when CAPTCHA is enabled. Fluxer consumes both extra allowances before it validates the address, and exhausting either returns 429.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The proof issued by the CAPTCHA provider |
+| X-Captcha-Type?2 | string | The provider that issued the proof, either `hcaptcha` or `turnstile` |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`, and verification is skipped entirely when CAPTCHA is disabled for the instance
+
+2 Omitting the header selects the instance's configured provider
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email1 | string | The address that receives the reset link |
+
+1 An address whose domain has no usable DNS records returns the field code `INVALID_EMAIL_ADDRESS`, while an address that passes DNS validation but belongs to no account returns the ordinary success response
+
+:::note[Account existence is not disclosed]
+An address that resolves to no account produces the same 204 response as an address that resolves to an ordinary account. Only DNS validation failure, the route bucket, the two extra allowances, and an address belonging to a bot account can produce a different outcome.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The request was accepted regardless of whether the account exists |
+| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, or email address is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation, or the address belongs to a bot account |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, or client IP bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Fluxer sends one 64-character reset token by email to an address that resolves to an account. The token is bound to that account and its current address, and it is valid for one hour. Nothing else changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+5 requests per minute, on the `auth:forgot` bucket, and recovery additionally permits 20 attempts per client IP address and 5 attempts per email address in each 30-minute window.
+
+## Validate a password reset token
+
+
+
+Checks a reset token without consuming it. Authentication is not required. Returns a [password reset validity](#password-reset-validity-object) object.
+
+This is a local authentication operation.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The password reset token, exactly 64 characters |
+
+A well-formed token that is unknown, already consumed, or bound to a deleted account returns the same object with `valid` set to false.
+
+:::caution[Validity does not promise the reset succeeds]
+The check resolves the token and confirms that its account exists and is not deleted. A token this operation reports as valid is still refused there for a bot account, for an account under a live temporary suspension, or for a replacement password in the breached-password corpus.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [password reset validity](#password-reset-validity-object) object | The well-formed token was checked |
+| 400 | [error response](/http-api/#error-response) | The token path parameter fails validation |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+This read does not consume the token, mutate account state, or emit a Gateway Dispatch.
+
+:::caution[The token travels in the path]
+A token in the path reaches proxy logs, browser history, and referrer headers. A client SHOULD read the token from the recovery link and send it in this one request, and SHOULD NOT route the user's browser to the API path directly.
+:::
+
+### Rate limit
+
+20 requests per minute, on the `auth:reset:validate` bucket.
+
+## Reset a password
+
+
+
+Consumes a valid reset token and replaces the account password, then issues a new session or an MFA challenge. Authentication is not required. Returns an [authentication token response](#authentication-token-response) when the account has no second factor, and an [MFA challenge response](#mfa-challenge-response) when it has one.
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The password reset token, exactly 64 characters |
+| password1 | string | The replacement password (8-256 characters) |
+
+1 The password is checked against the public breached-password corpus before the token is spent, and a match returns the field code `PASSWORD_IS_TOO_COMMON`
+
+An unknown or already consumed token, and a token bound to a deleted account, return the field code `INVALID_OR_EXPIRED_RESET_TOKEN`. A live temporary suspension returns 403 `ACCOUNT_SUSPENDED_TEMPORARILY`. A permanent suspension returns the field code `INVALID_OR_EXPIRED_RESET_TOKEN` and never 403 `ACCOUNT_SUSPENDED_PERMANENTLY`. A bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`.
+
+:::note[The breached-password check is an outbound request]
+The corpus is the public Pwned Passwords range service at `https://api.pwnedpasswords.com`, which is not configurable. Fluxer sends only the first five hexadecimal characters of the candidate password's SHA-1 digest to that service. The check fails open.
+:::
+
+Fluxer treats the password as unbreached when the service returns a non-success status, returns a malformed body, or does not answer inside five seconds. The same check runs during [registration](#register-an-account) and [email reversion](#revert-an-email-change).
+
+:::caution[Resetting a password revokes every existing session]
+A successful reset terminates every authentication session on the account, including the one that any other device is holding. The reset then issues one fresh session of its own, unless the account has a second factor, in which case it issues an MFA ticket and the session follows the factor.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) \| [MFA challenge response](#mfa-challenge-response) | The password was replaced |
+| 400 | [error response](/http-api/#error-response) | The body, token, or replacement password is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement, a bot account, or a live temporary suspension rejects the mutation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation replaces the password, records the change time, clears an expired temporary suspension, terminates every authentication session on the account, and consumes the presented reset token. Every terminated session's Gateway connection receives Invalid Session with `d: false` and stays open, unauthenticated. Other outstanding reset tokens are not invalidated, so a second recovery link issued earlier still works.
+
+An account with no second factor then receives one new session and its token. An account with a second factor receives a five-minute MFA ticket instead, and the session is created by the MFA completion.
+
+### Rate limit
+
+10 requests per minute, on the `auth:reset` bucket.
+
+## Revert an email change
+
+
+
+Consumes the token delivered to the previous email address, restores that address, replaces the password, and rebuilds the account's credential state. Returns an [authentication token response](#authentication-token-response). Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+The token is valid for 24 hours after the address change that issued it. This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The email reversion token, exactly 64 characters |
+| password1 | string | The replacement password (8-256 characters) |
+
+1 The value becomes the account's new password, and Fluxer checks it against the public breached-password corpus, returning the field code `PASSWORD_IS_TOO_COMMON` on a match
+
+:::danger[Reversion destroys every other credential]
+Completing a reversion terminates every authentication session, clears the account's entire second-factor enrolment, and replaces the authorised IP address set with the single IP address that made the request. Any other device, passkey, or backup code held by whoever performed the original email change stops working immediately, and none of it can be restored.
+:::
+
+An unknown or already consumed token, and a token bound to an account that no longer exists, return the field code `INVALID_OR_EXPIRED_REVERT_TOKEN`. Account suspension returns 403 `ACCOUNT_SUSPENDED_TEMPORARILY` or 403 `ACCOUNT_SUSPENDED_PERMANENTLY`, and a bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`.
+
+The replacement password is checked against the same breached-password corpus described under [reset a password](#reset-a-password) before the token is spent.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [authentication token response](#authentication-token-response) | The address and credential recovery completed |
+| 400 | [error response](/http-api/#error-response) | The body, token, or replacement password is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement, a bot account, or account suspension rejects the mutation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation restores the previous email address and marks it verified, sets the replacement password, clears the TOTP secret and every authenticator type, deletes every MFA backup code and every registered WebAuthn credential, and terminates every authentication session. It clears the authorised IP address set and then authorises the requesting client IP address alone.
+
+Every terminated session's Gateway connection receives Invalid Session with `d: false` and stays open, unauthenticated. [User Update](/gateway/events/#user-update) is emitted for the account, and Fluxer records the contact change.
+
+The operation then creates one new authentication session and returns it.
+
+### Rate limit
+
+10 requests per minute, on the `auth:email_revert` bucket.
+
+## List authentication sessions
+
+
+
+Lists every live authentication session belonging to the authenticated account, newest activity first. Requires a user session token for an ordinary user. Returns an array of [authentication session](#authentication-session-object) objects.
+
+An account locked by suspicious activity is still permitted.
+
+The request has no body and takes no parameters.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[authentication session](#authentication-session-object) object] | The live sessions were read |
+| 401 | [error response](/http-api/#error-response) | The user session credential is missing or invalid |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or an OAuth2 bearer token |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+This read does not mutate session state and emits no Gateway Dispatch.
+
+### Rate limit
+
+40 requests per 10 seconds, on the `auth:sessions` bucket.
+
+## Terminate authentication sessions
+
+
+
+Deletes the named authentication sessions. Requires a user session token for an ordinary user, and [sudo mode](/http-api/users/mfa/#sudo-mode). Returns 204 with no body.
+
+An account locked by suspicious activity is still permitted. The caller MUST send a valid sudo token in the `X-Fluxer-Sudo-Mode-JWT` header, or supply a password or MFA proof in the body.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id_hashes1 | array[string] | The session digests to delete (max 100 entries) |
+| password?2 | string | The password proof for an account with no second factor (8-256 characters) |
+| mfa_method?3 | string | The proof method, either `totp` or `webauthn` |
+| mfa_code? | string | The authenticator code or an unconsumed backup code when the method is `totp` (1-32 characters) |
+| webauthn_response? | [WebAuthn assertion](#webauthn-assertion) object | The assertion when the method is `webauthn` |
+| webauthn_challenge? | string | The challenge bound to the sudo mode assertion |
+
+1 Each value is the base64url `id_hash` from [list authentication sessions](#list-authentication-sessions). Fluxer ignores an unknown identifier, and an empty array deletes nothing and still returns 204
+
+2 The password is accepted only when the account has no second factor, and a value that does not match returns the field code `INVALID_PASSWORD`
+
+3 The MFA proof is accepted only when the account has a second factor. Any failure returns the field code `INVALID_MFA_CODE` on `mfa_code`, and a successful proof issues a fresh sudo token
+
+A `totp` method reads `mfa_code` as an authenticator code and accepts an unconsumed backup code in its place. A `webauthn` method reads `webauthn_response` and `webauthn_challenge` together, and it accepts only a challenge that was issued for the sudo context.
+
+:::caution[The caller can delete its own session]
+The operation deletes exactly the sessions identified by the supplied `id_hash` values. Because `current` is false on every entry, a client that wants to keep its own session MUST compare the base64url SHA-256 digest of its own token against `id_hash` and omit that identifier. Revoking the calling credential still returns 204.
+:::
+
+Missing or unusable proof returns 403 `SUDO_MODE_REQUIRED`, whose error body has top-level `has_mfa` and `methods` members, and `methods` reports whether `totp` and `webauthn` are available.
+
+:::note[An unclaimed account needs no proof]
+An account that has neither a password nor a second factor satisfies sudo mode with no body proof and no sudo token, and Fluxer issues none. Every other account MUST present a valid sudo token, a password, or an MFA proof.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Every named session was absent or deleted |
+| 400 | [error response](/http-api/#error-response) | The body or the supplied proof is invalid |
+| 401 | [error response](/http-api/#error-response) | The user session credential is missing or invalid |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or an OAuth2 bearer token, or sudo mode is required |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Each named session is deleted, and its Gateway connection receives Invalid Session with `d: false` and stays open, unauthenticated. A fresh MFA proof mints a new sudo token, while an accepted incoming token is echoed unchanged. Fluxer sets whichever token results in the `X-Fluxer-Sudo-Mode-JWT` response header. A password proof on an account with no second factor mints no token, so the header is not set unless the request already had one. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+20 requests per 10 seconds, on the `auth:sessions:logout` bucket.
+
+## Authorise an IP address
+
+
+
+Consumes the authorisation token delivered by email, authorises the pending client IP address, and completes the waiting login. Authentication is not required. Returns 204 with no body.
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The authorisation token delivered by email |
+
+An unknown, expired, already consumed, or account-mismatched token returns the field code `INVALID_OR_EXPIRED_AUTHORIZATION_TOKEN`. A token that resolves to an account that no longer exists returns 404 `UNKNOWN_USER`, and one that resolves to a bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`. Session creation additionally rejects an account that has not been admitted or is suspended, with the codes listed under [log in with a password](#log-in-with-a-password).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The IP address was authorised and the login result was published |
+| 400 | [error response](/http-api/#error-response) | The body or authorisation token is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation or the token resolves to a bot account |
+| 404 | [error response](/http-api/#error-response) | The token resolves to an account that no longer exists |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The operation adds the pending client IP address to the account's authorised set and creates one authentication session using the IP address, user agent, and reported operating system captured when the login was attempted. It consumes the authorisation token and the ticket for the pending login.
+
+The session token is then published against the ticket and remains readable by [poll IP authorisation](#poll-ip-authorisation) for 60 seconds. The caller of this operation receives 204 and no token of its own. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+5 requests per minute, on the `auth:authorize_ip` bucket.
+
+## Resend IP authorisation
+
+
+
+Sends the authorisation message for an outstanding IP authorisation ticket again. Authentication is not required. Returns 204 with no body.
+
+This is a local authentication operation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket1 | string | The ticket returned by the `IP_AUTHORIZATION_REQUIRED` login error |
+
+1 The resend reuses the authorisation token already bound to the ticket, so a message delivered by an earlier send remains valid
+
+An unknown or expired ticket returns the field code `INVALID_OR_EXPIRED_AUTHORIZATION_TICKET`. A resend before the delay elapses returns 429 `IP_AUTHORIZATION_RESEND_COOLDOWN` with a `Retry-After` header and a top-level `resend_available_in` in seconds. A second resend returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`.
+
+:::caution[A lost response still spends the resend]
+There is no retry key. A client that loses the response cannot tell whether the message was delivered, and a retry returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED` once the first request marked the resend used.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The message was sent again |
+| 400 | [error response](/http-api/#error-response) | The body or ticket is invalid, or the ticket's single resend is already used |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route or global bucket denies the request, or the resend delay has not elapsed |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Fluxer sends the authorisation message to the address associated with the login attempt and marks the ticket's single resend used whether or not delivery succeeds. No account state changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+5 requests per minute, on the `auth:ip_authorization_resend` bucket, and each ticket additionally permits exactly one resend, no earlier than 30 seconds after the ticket was issued.
+
+## Poll IP authorisation
+
+
+
+Reads the login result associated with an IP authorisation ticket. Authentication is not required. Returns an [IP authorisation poll](#ip-authorisation-poll-object) object.
+
+This is a local authentication operation.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The ticket returned by the `IP_AUTHORIZATION_REQUIRED` login error |
+
+An unknown or expired ticket returns the field code `INVALID_OR_EXPIRED_AUTHORIZATION_TICKET`, which is also the outcome once the 60-second result retention has elapsed.
+
+:::note[Reading the completed result does not consume it]
+Repeated polling returns the same token, so anyone holding the ticket within the 60-second retention window can read it. A completed token is a live session credential from the moment it first appears.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [IP authorisation poll](#ip-authorisation-poll-object) object | The ticket was read |
+| 400 | [error response](/http-api/#error-response) | The query is malformed or the ticket is unknown or expired |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 404 | [error response](/http-api/#error-response) | The completed result names an account that no longer exists, returning `UNKNOWN_USER` |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Rate limit
+
+60 requests per minute, on the `auth:ip_authorization_poll` bucket.
+
+## Get username suggestions
+
+
+
+Derives username candidates from a display name. Authentication is not required. Returns a [username suggestions](#username-suggestions-object) object.
+
+This is a local authentication operation. It shares the `auth:register` bucket, which permits 10 requests per 10 seconds.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| global_name | string | The display name after normalisation (1-32 characters) |
+
+:::note[A returned candidate can be taken before use]
+The operation performs no availability check and holds nothing. Registration allocates its own discriminator regardless of the suggestion.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [username suggestions](#username-suggestions-object) object | Candidates were generated |
+| 400 | [error response](/http-api/#error-response) | The body or display name is invalid |
+| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+This read does not reserve a username, mutate account state, or emit a Gateway Dispatch.
+
+## Initiate desktop handoff
+
+
+
+Creates a pending handoff and returns the code the initiating device shows to an already signed-in device. Authentication is not required. Returns a [handoff initiation](#handoff-initiation-object) object.
+
+The request has no body. Fluxer derives the device metadata shown to the approving device from the request itself.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| User-Agent? | string | The client string presented to the approving device and recorded on the session the handoff creates |
+| X-Fluxer-Client-Properties? | string | The base64-encoded JSON with the reporting client's `os`, read only for a native Fluxer `User-Agent` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [handoff initiation](#handoff-initiation-object) object | A handoff request was created |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The handoff remains pending for five minutes and records the initiating device's client IP address, user agent, and reported operating system. Initiation creates no session and emits no Gateway Dispatch.
+
+### Rate limit
+
+10 requests per minute, on the `auth:handoff:initiate` bucket.
+
+## Get desktop handoff information
+
+
+
+Describes the device that initiated a pending handoff so that the approving device can show it before approving, and marks the code approvable. Authentication is not required. Returns a [handoff information](#handoff-information-object) object.
+
+Fluxer counts failed code attempts against the client IP address and blocks it after 5 failures in 15 minutes. A pending result records no failure, while an unknown or expired code records one.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The handoff code |
+
+1 Fluxer normalises the value by removing hyphens and whitespace and upper-casing the remainder, and the normalised result is exactly 12 characters from the handoff alphabet
+
+:::caution[A successful lookup makes the code completable]
+Reading the handoff information marks the code as inspected but does not approve the transfer. [Complete desktop handoff](#complete-desktop-handoff) rejects a code that has never been inspected. An approving client MUST show the initiating device information and obtain explicit user confirmation before it calls the completion operation.
+:::
+
+Anyone who can reach the API and knows the code can mark it inspected. Declining the request in a client discards only what that client is showing, and the inspected state remains until the handoff is completed or the code expires.
+
+A code that is not exactly 12 characters from the handoff alphabet after hyphens and whitespace are removed returns 400 `INVALID_HANDOFF_CODE`. A code whose three lookups are already spent, and a request from a client IP address that has exhausted its failed-attempt allowance, return the same error code. An unknown or expired code returns 200 with the `expired` status and records one failed attempt.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [handoff information](#handoff-information-object) object | The pending or expired state was read |
+| 400 | [error response](/http-api/#error-response) | The code is malformed, its lookup budget is spent, or the client IP attempt allowance is exhausted |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+A successful lookup consumes one of the code's three lookups and records the code as inspected, which is the state [complete desktop handoff](#complete-desktop-handoff) requires. That record lasts for the remaining life of the code, and only completion and cancellation remove it. An expired result records one failed attempt against the client IP address. No account state changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per minute, on the `auth:handoff:info` bucket, and each code permits at most three successful lookups in total, counted on the code itself.
+
+## Complete desktop handoff
+
+
+
+Approves a pending handoff by issuing a new session to the initiating device on the authority of a live one. Requires the approving device's own user session token. Returns 204 with no body.
+
+Fluxer reads that token from the `Authorization` header, or from the body `token` field when the body supplies one.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The handoff code shown by the initiating device |
+| token?2 | string | The approving device's own user session token |
+| user_id3 | snowflake | The account the approving session belongs to |
+
+1 The code uses the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information), and only a code that operation has already inspected is accepted
+
+2 The body token takes precedence over the `Authorization` header, and a request that supplies neither returns 401 `UNAUTHORIZED`
+
+3 A supplied token that resolves to a different account returns `SESSION_TOKEN_MISMATCH`
+
+:::caution[The approving token may travel in the body]
+Sending the token in `token` puts a live credential into a JSON payload. A client SHOULD use the `Authorization` header and omit `token`.
+:::
+
+A token that resolves to no live session returns 401 `INVALID_TOKEN`. A code that is not exactly 12 characters from the handoff alphabet after hyphens and whitespace are removed returns 400 `INVALID_HANDOFF_CODE`. A code that has never been inspected through [get desktop handoff information](#get-desktop-handoff-information), an unknown or already completed code, and a request from a client IP address that has exhausted its failed-attempt allowance all return the same error code. A code whose five minutes have elapsed is discarded with its handoff and returns `INVALID_HANDOFF_CODE`. `HANDOFF_CODE_EXPIRED` is returned only when the stored handoff is still present after its five minutes.
+
+Session creation can additionally return 403 `BOT_USER_AUTH_SESSION_CREATION_DENIED` for a bot account, 403 `REGISTRATION_PENDING_APPROVAL` or 403 `REGISTRATION_REJECTED` for an account that has not been admitted, and the suspension codes listed under [log in with a password](#log-in-with-a-password) for a suspended account.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The handoff was completed |
+| 400 | [error response](/http-api/#error-response) | The body, code, expiry state, or session ownership is invalid |
+| 401 | [error response](/http-api/#error-response) | The approving credential is missing, invalid, or revoked |
+| 403 | [error response](/http-api/#error-response) | The approving session belongs to a bot or to an account awaiting approval or rejected |
+| 404 | [error response](/http-api/#error-response) | The approving session resolves to an account that no longer exists |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+Completion creates one new authentication session for the initiating device and publishes its token against the code. The token stays readable through [get desktop handoff status](#get-desktop-handoff-status) for whatever remains of the handoff's five minutes. Completion also discards the pending handoff, so the code cannot be completed a second time and a later [get desktop handoff information](#get-desktop-handoff-information) reports `expired`.
+
+The approving session remains valid. The new session takes the initiating device's client IP address, user agent, and reported operating system from the handoff record. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per minute, on the `auth:handoff:complete` bucket, and a client IP address is blocked after 5 failed code attempts in 15 minutes.
+
+## Get desktop handoff status
+
+
+
+Reports the state of a handoff to the device that initiated it. Authentication is not required. Returns a [handoff status](#handoff-status-object) object.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The handoff code, using the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information) |
+
+:::note[This route never returns the token]
+A completed handoff reports `pending` here, because the token needs `poll_secret`. Read it with [get desktop handoff status with the poll secret](#get-desktop-handoff-status-with-the-poll-secret).
+:::
+
+The code is the only credential this route checks. The initiating device MUST poll for the completion itself.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [handoff status](#handoff-status-object) object | The state was read, or an unknown code was reported as expired |
+| 400 | [error response](/http-api/#error-response) | The code is malformed, returning `INVALID_HANDOFF_CODE` |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+A poll leaves the handoff unchanged. A poll against a completed handoff records a failed attempt for the polling IP address, because this route presents no `poll_secret`. No account state changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+60 requests per minute, on the `auth:handoff:status` bucket.
+
+## Get desktop handoff status with the poll secret
+
+
+
+Reports the state of a handoff and delivers the new session token once, to a caller that presents the poll secret issued at initiation. Authentication is not required. Returns a [handoff status](#handoff-status-object) object.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The handoff code, using the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information) |
+
+### Request body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| poll_secret | string | The secret returned by [initiate desktop handoff](#initiate-desktop-handoff) |
+
+:::caution[The completed status is delivered exactly once]
+Reading a completed handoff deletes the stored token as it returns it. The very next poll with the same code reports `expired`, so the initiating device MUST retain the token when it is first received.
+:::
+
+A secret that does not match reports `pending` and records a failed attempt for the polling IP address. The route reports no distinct error for a wrong secret, so a caller cannot tell a wrong secret from a handoff the approving device has not finished.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [handoff status](#handoff-status-object) object | The state was read, or an unknown code was reported as expired |
+| 400 | [error response](/http-api/#error-response) | The code is malformed, returning `INVALID_HANDOFF_CODE` |
+| 404 | [error response](/http-api/#error-response) | The completed handoff names an account that no longer exists, returning `UNKNOWN_USER` |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+A poll that matches the secret deletes the stored token as it delivers it, so later polls report `expired`. Every other poll leaves the handoff unchanged. No account state changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+60 requests per minute, on the `auth:handoff:status` bucket.
+
+## Cancel a desktop handoff
+
+
+
+Discards a handoff and everything stored against its code. Authentication is not required. Returns 204 with no body.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The handoff code, using the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information) |
+
+An unknown or already expired code is an idempotent success.
+
+:::caution[Anyone holding the code can cancel]
+The route checks no credential and the code is its only input, so a party that learns the code can cancel a handoff the initiating device is still waiting on. The initiating device observes this as the `expired` status.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The handoff was discarded, or no handoff existed for that code |
+| 400 | [error response](/http-api/#error-response) | The code is malformed, returning `INVALID_HANDOFF_CODE` |
+| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
+| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
+| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
+
+### Side effects
+
+The pending handoff, its inspected record, and any completed token that has not yet been read are all deleted. Cancelling does not revoke a session that a completion already created. When the initiating device never polled that completion, the cancellation leaves a live session no device holds a token for. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+10 requests per minute, on the `auth:handoff:cancel` bucket.
+
diff --git a/fluxer_docs/src/content/docs/http-api/billing.mdx b/fluxer_docs/src/content/docs/http-api/billing.mdx
new file mode 100644
index 000000000..677148b16
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/billing.mdx
@@ -0,0 +1,577 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Billing
+description: Checkout, card preapproval, gift purchase, age verification, refunds, and the Stripe webhook.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+These routes take payment for premium, verify an account holder's age, and refund the most recent purchase. Each of those finishes on the payment provider's own pages, so a route here creates the provider session and returns its URL for the browser to open. [Receive Stripe webhook](#receive-stripe-webhook) takes the signed events the provider sends back. The [Premium resource](/http-api/premium/) owns entitlement state, mirrored billing data and subscription self-service.
+
+Every route here is hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. Every route except [receive Stripe webhook](#receive-stripe-webhook) and [continue localised card preapproval](#continue-localised-card-preapproval) is user-only.
+
+:::note[Every response is a Fluxer object]
+The responses are Fluxer redirect, eligibility, refund and acknowledgement objects. The only payment provider values in them are the identifiers on a [refund](#refund-object).
+:::
+
+## Redirect URL object
+
+One absolute URL that completes a billing operation in a browser. [Create subscription checkout](#create-subscription-checkout), [create localised card preapproval](#create-localised-card-preapproval), [create gift checkout](#create-gift-checkout) and [create age verification session](#create-age-verification-session) all answer with it. The object has no session identifier, so a client sends the browser to the returned value.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | Absolute URL that completes the operation in a browser |
+
+1 Ordinarily an externally hosted payment provider session URL. [Create subscription checkout](#create-subscription-checkout) can answer with the Fluxer premium callback URL instead
+
+### Example
+
+```json
+{
+ "url": "https://checkout.example.com/c/pay/cs_test_9f8e7d6c5b4a"
+}
+```
+
+## Localised card preapproval result object
+
+The state of one card preapproval flow. A localised recurring price is offered only to a card issued in the matching country, so Fluxer creates the paid session only after a separate setup mode session has proven the card country. `status` says which of the four variants the object is, and each variant defines its own members.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | [Preapproval status](#preapproval-statuses) |
+| url1 | string | Paid checkout URL |
+| reason2 | string | [Preapproval rejection reason](#preapproval-rejection-reasons) |
+| actual_country?3 | ?string | Detected two-letter card issuing country |
+
+1 Present and required only when `status` is `ready`. It is stable for the lifetime of the continuation token
+
+2 Present and required only when `status` is `rejected`
+
+3 Defined only inside the `rejected` variant, where it is optional. It has a value only for `country_mismatch`, and is null there when the card reported no issuing country
+
+### Example
+
+```json
+{
+ "status": "rejected",
+ "reason": "country_mismatch",
+ "actual_country": "PT"
+}
+```
+
+## Preapproval statuses
+
+| Value | Description |
+| --- | --- |
+| pending | The setup session has not completed, or a concurrent continuation is already resolving the same token |
+| ready | The card was approved and the paid checkout URL is available |
+| rejected | The card was refused and the paid checkout cannot continue |
+| expired | The token is empty, unknown, or has passed its one-day lifetime |
+
+## Preapproval rejection reasons
+
+| Value | Description |
+| --- | --- |
+| country_mismatch | Card issuing country does not match the country recorded on the preapproval session |
+| missing_customer | Preapproval session metadata had no payment provider customer |
+| missing_payment_method | Completed setup intent resolved no payment method |
+| missing_setup_intent | Completed session had no setup intent |
+| payment_method_not_card | Approved payment method is not a card |
+| unknown | Preapproval failed without a more specific public reason |
+
+## Refund eligibility object
+
+The verdict on whether the account's most recent purchase can still be refunded without operator involvement. When no refundable purchase was resolved, `eligible` is false, every invoice member is null, and `cooldown_expires_at` still reports an active cooldown unless the reason is `feature_unavailable`. A client reads `eligible` and `reason` before rendering any amount or timestamp.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| eligible | boolean | Whether the latest purchase can be refunded through [refund latest purchase](#refund-latest-purchase) |
+| reason | ?string | [Refund ineligibility reason](#refund-ineligibility-reasons), or null when the purchase is eligible |
+| invoice_id1 | ?string | Payment provider invoice ID, or null when no refundable purchase was resolved |
+| invoice_amount_paid_cents2 | ?integer | Invoice amount paid in the currency minor unit |
+| currency3 | ?string | Lowercase provider currency of the invoice |
+| paid_at4 | ?ISO8601 timestamp | Time the invoice payment settled |
+| refund_window_expires_at5 | ?ISO8601 timestamp | End of the refund window |
+| cooldown_expires_at6 | ?ISO8601 timestamp | End of the account refund cooldown, or null when no cooldown is active |
+| cancels_subscription7 | boolean | Whether refunding the invoice also cancels the subscription that produced it |
+
+1 The first invoice among the five most recent invoices of the account's payment provider customer that is paid, has a positive amount, and resolves a payment intent or a charge
+
+2 The field name states cents, but the value is always the minor unit of the invoice currency
+
+3 Reported as `usd` when the resolved invoice has no currency of its own
+
+4 Taken from the settlement time of the resolved invoice payment, and from the invoice creation time when that payment has no settlement timestamp
+
+5 The value of `paid_at` plus exactly three days
+
+6 The account's first self-service refund time plus exactly 30 days, and null once that instant has passed
+
+7 True when the resolved invoice was produced by a subscription, which the confirmed refund then cancels immediately
+
+### Example
+
+```json
+{
+ "eligible": true,
+ "reason": null,
+ "invoice_id": "in_1QaBcDeFgHiJkLmN",
+ "invoice_amount_paid_cents": 499,
+ "currency": "usd",
+ "paid_at": "2026-08-29T11:04:12.000Z",
+ "refund_window_expires_at": "2026-09-01T11:04:12.000Z",
+ "cooldown_expires_at": null,
+ "cancels_subscription": true
+}
+```
+
+## Refund ineligibility reasons
+
+| Value | Description |
+| --- | --- |
+| no_refundable_purchase | The account has no paid invoice with a positive amount and a resolvable payment reference among the invoices considered |
+| outside_refund_window | The latest refundable purchase settled more than 3 days ago |
+| cooldown_active | The account performed a self-service refund less than 30 days ago |
+| feature_unavailable | The deployment is self-hosted, or the payment provider is not configured, so self-service refunds are not offered |
+
+## Refund object
+
+The refund that [refund latest purchase](#refund-latest-purchase) created against the resolved invoice. It describes the request the provider accepted, so `status` decides whether money has moved.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| invoice_id | string | Payment provider invoice ID that was refunded |
+| payment_intent_id | ?string | Payment provider payment intent ID, or null when the invoice resolved only a charge |
+| charge_id | ?string | Payment provider charge ID, or null when the invoice resolved only a payment intent |
+| refund_id | ?string | Payment provider refund ID of the created refund |
+| refunded_amount_cents1 | integer | Amount refunded so far in the invoice currency minor unit |
+| invoice_amount_paid_cents | integer | Amount paid on the refunded invoice in the invoice currency minor unit |
+| currency2 | string | Lowercase provider currency of the invoice |
+| subscription_id3 | ?string | Payment provider subscription that was cancelled along with the refund |
+| status4 | ?string | Provider status of the refund |
+
+1 Zero until the provider confirms the refund succeeded. Once confirmed it is the amount the provider accepted, which is one minor unit short of `invoice_amount_paid_cents` in the Pix case
+
+2 Reported as `usd` when the resolved invoice has no currency of its own
+
+3 Null while the refund is still pending, and null for a one-off purchase
+
+4 The provider value, such as `pending`, `succeeded` or `failed`. Money moves only once it is `succeeded`
+
+### Example
+
+```json
+{
+ "invoice_id": "in_1QaBcDeFgHiJkLmN",
+ "payment_intent_id": "pi_3QaBcDeFgHiJkLmN",
+ "charge_id": null,
+ "refund_id": "re_3QaBcDeFgHiJkLmN",
+ "refunded_amount_cents": 499,
+ "invoice_amount_paid_cents": 499,
+ "currency": "usd",
+ "subscription_id": "sub_1QaBcDeFgHiJkLmN",
+ "status": "succeeded"
+}
+```
+
+## Webhook received object
+
+The acknowledgement [receive Stripe webhook](#receive-stripe-webhook) returns once it authenticates the event and queues it for asynchronous processing.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| received | boolean | Always true after the event is authenticated and accepted for processing |
+
+### Example
+
+```json
+{
+ "received": true
+}
+```
+
+## Create subscription checkout
+
+
+
+Creates a recurring premium checkout session and returns a [redirect URL](#redirect-url-object) object.
+
+### Limitations
+
+- An unknown price returns 400 `STRIPE_INVALID_PRODUCT`.
+- A gift price, a price outside the resolved country catalogue, and an omitted country code for a price that requires one all return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
+- An unclaimed account returns 400 `UNCLAIMED_ACCOUNT_CANNOT_MAKE_PURCHASES`.
+- An unverified email address returns 403 `PURCHASE_EMAIL_VERIFICATION_REQUIRED`.
+- The purchase-disabled premium flag returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `purchase_disabled`.
+- A lifetime Visionary account cannot buy a recurring subscription and returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime`.
+- An account whose payment provider customer already holds a subscription in the `active`, `trialing`, `past_due`, `unpaid`, `incomplete` or `paused` state returns the same code with the reason `existing_subscription`, unless the conversion below applies.
+
+`reason` is a top-level member of the error response, and an `existing_subscription` refusal also has the blocking status in the top-level `subscription_status` member.
+
+Fluxer evaluates the lifetime refusal before the unclaimed account, unverified email and purchase flag refusals, so a lifetime Visionary account submitting a recurring price always receives the reason `lifetime` whatever its account state.
+
+An account already holding an `active` or `trialing` subscription can submit a registered recurring non-gift price for the other billing cycle. Fluxer schedules a billing cycle change to the submitted cycle at the end of the current period, exactly as [change subscription billing cycle](/http-api/premium/#change-subscription-billing-cycle) with `effective_at` set to `period_end` does. The response is 200 with `url` set to the Fluxer premium callback URL.
+
+The conversion requires a stored billing cycle on the account that differs from the submitted price's cycle. Any other blocking subscription status, no stored billing cycle, and a matching cycle all still produce 403 `PREMIUM_PURCHASE_BLOCKED`. When scheduling the change fails, the caller receives that failure.
+
+:::note[Only this route converts a blocked purchase]
+A blocked purchase always fails on [create localised card preapproval](#create-localised-card-preapproval) and [continue localised card preapproval](#continue-localised-card-preapproval).
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| price_id | string | Registered recurring price ID (1-256 characters) |
+| country_code?1 | string | Two-letter country used to select the regional price catalogue (2 characters) |
+| client_geoip_country_code?2 | string | Two-letter country the client previously observed for itself (2 characters) |
+| eu_withdrawal_waiver_accepted?3 | boolean | Whether the digital content withdrawal waiver was expressly accepted |
+| pricing_mode? | string | Either `localized` or `base` (default `localized`) |
+| payment_method?4 | string | Either `card`, `pix` or `upi` (default `card`) |
+| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
+
+1 Required whenever the resolved price is not denominated in USD or EUR, and validated against the price catalogue for that country
+
+2 Read to resolve the effective country for the withdrawal waiver, and recorded with the payment
+
+3 Considered only when the effective country is inside the European Union or European Economic Area. The effective country is the request-time geolocation country, then the client-observed country, then the pricing country
+
+4 `pix` requires a BRL recurring price and `upi` requires an INR recurring price. Both are rejected for a gift price or a non-recurring price with 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [redirect URL](#redirect-url-object) object | The checkout session was created, or a blocked purchase was converted into a scheduled billing cycle change |
+| 4005 | [error response](/http-api/#error-response) | The price, the account, or the payment provider blocks the checkout |
+| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+5 The price is unknown or not valid for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
+
+### Side effects
+
+The operation creates a payment provider customer when the account has none, records the customer identifier on the account, and reconciles the account's stored subscription identifier with whatever the provider reports for that customer. Where a blocking subscription can still provision premium, Fluxer repairs the account's premium type, start, end, cancellation flag, billing cycle and grace deadline before it refuses the request.
+
+It then creates an externally hosted checkout with terms of service consent, the withdrawal waiver text attached to that consent, automatic tax, tax identifier collection and promotion codes. It records a pending payment row with the resolved countries and the waiver decision.
+
+A converted purchase creates no checkout session and no payment row. Fluxer builds or updates the subscription schedule, clears pending cancellation, and sends [User Update](/gateway/events/#user-update) to every account session.
+
+Premium entitlement otherwise changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook).
+
+### Rate limit
+
+3 requests per minute for each authenticated user, on the `stripe:checkout:subscription` bucket.
+
+## Create localised card preapproval
+
+
+
+Creates a setup mode session that captures and verifies a card before a localised recurring purchase, and returns a [redirect URL](#redirect-url-object) object.
+
+### Limitations
+
+- The claimed account, verified email and purchase flag requirements of [create subscription checkout](#create-subscription-checkout) apply unchanged, with the same codes.
+- An absent `country_code`, a `pricing_mode` of `base`, and a resolved price that is not a recurring price denominated in a currency other than USD and EUR each return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
+- The submitted price is always recurring, so the lifetime block and the existing subscription block both apply and return 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime` or `existing_subscription`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| price_id | string | Registered localised recurring price ID (1-256 characters) |
+| country_code1 | string | Two-letter country used to select the regional price catalogue (2 characters) |
+| client_geoip_country_code? | string | Two-letter country the client previously observed for itself (2 characters) |
+| eu_withdrawal_waiver_accepted? | boolean | Whether the digital content withdrawal waiver was expressly accepted |
+| pricing_mode?2 | string | Either `localized` or `base` (default `localized`) |
+| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
+
+1 Declared optional by the shared request schema, and this operation refuses a request that omits it
+
+2 A submitted `base` returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`
+
+A submitted `payment_method` is validated against the enum and then discarded. The setup session is always restricted to cards.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [redirect URL](#redirect-url-object) object | The setup session was created |
+| 4003 | [error response](/http-api/#error-response) | The country, the price, the account, or the payment provider blocks the setup session |
+| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+3 The country or pricing mode is not localised, the price is unknown or not valid for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
+
+### Side effects
+
+The operation creates a payment provider customer when necessary and reconciles subscription state exactly as [create subscription checkout](#create-subscription-checkout) does, then creates an externally hosted setup session restricted to cards.
+
+It also records a preapproval flow keyed by a freshly generated continuation token, holding the price, country, business flag, waiver decision and observed countries. The flow expires one day after its most recent state change. The token is returned in the session's success callback URL.
+
+The flow stays pending until [receive Stripe webhook](#receive-stripe-webhook) processes the matching completion event. It is approved only when the card issuing country equals the pricing country. Otherwise the flow records a [preapproval rejection reason](#preapproval-rejection-reasons), and for `country_mismatch` the detected country. No entitlement changes.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:checkout:subscription:preapproval` bucket.
+
+## Continue localised card preapproval
+
+
+
+Reports the current state of a preapproval flow and creates the paid checkout session once the card is approved, returning a [localised card preapproval result](#localised-card-preapproval-result-object) object.
+
+The continuation token is the credential, and no credential supplied on the request selects the flow.
+
+An empty token, an unknown token, and a token whose one-day flow has expired are all reported as `expired`, so the operation never discloses whether a flow exists.
+
+### Limitations
+
+- A recorded price that is no longer registered returns 400 `STRIPE_INVALID_PRODUCT`.
+- A recorded price that no longer belongs to the recorded country catalogue returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
+- The claimed account, verified email, purchase flag, lifetime and existing subscription refusals all apply to the account that opened the flow.
+
+Creating the paid session repeats the complete preparation [create subscription checkout](#create-subscription-checkout) performs, against the account, price and country recorded on the flow.
+
+:::caution[Approval is resolved once]
+One continuation at a time can resolve an approved flow. While one call creates the paid session, another call for the same flow is reported as `pending`.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | Continuation token issued by [create localised card preapproval](#create-localised-card-preapproval) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [localised card preapproval result](#localised-card-preapproval-result-object) object | The current flow state was returned |
+| 4001 | [error response](/http-api/#error-response) | The recorded price, the recorded account, or the payment provider blocks the flow |
+| 403 | [error response](/http-api/#error-response) | The recorded account has an unverified email address, or its purchase is blocked |
+| 404 | [error response](/http-api/#error-response) | The recorded account record no longer exists |
+
+1 The recorded price is unknown or is no longer valid for the recorded country catalogue, the recorded account is unclaimed, the payment provider is not configured, or the payment provider rejected the paid session and returns `STRIPE_ERROR`
+
+### Side effects
+
+A `pending`, `rejected` or `expired` result changes nothing. For an approved flow, Fluxer attempts to make the approved card the customer's default invoice payment method, then creates the paid checkout even when that update is unavailable.
+
+It applies the checkout preparation described by [create subscription checkout](#create-subscription-checkout) with the approved price and country. The same token then always resolves to the same paid checkout URL. Entitlement changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook).
+
+### Rate limit
+
+30 requests per minute for each client IP address, or for the authenticated user when a credential happens to be supplied, on the `stripe:checkout:subscription:preapproval:continue` bucket.
+
+## Create gift checkout
+
+
+
+Creates a one-off premium gift checkout session and returns a [redirect URL](#redirect-url-object) object.
+
+### Limitations
+
+- The claimed account, verified email and purchase flag requirements of [create subscription checkout](#create-subscription-checkout) apply unchanged, with the same codes.
+- The submitted price is a registered gift price for the resolved country catalogue, so a recurring price returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
+
+Neither the lifetime block nor the existing subscription block applies here, so a lifetime Visionary account can buy gifts.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| price_id | string | Registered gift price ID (1-256 characters) |
+| country_code?1 | string | Two-letter country used to select the regional gift price catalogue (2 characters) |
+| client_geoip_country_code? | string | Two-letter country the client previously observed for itself (2 characters) |
+| eu_withdrawal_waiver_accepted? | boolean | Whether the digital content withdrawal waiver was expressly accepted |
+| pricing_mode? | string | Either `localized` or `base` (default `localized`) |
+| is_business? | boolean | Whether to require a billing address for tax invoicing (default false) |
+
+1 Required whenever the resolved gift price is not denominated in USD or EUR
+
+A submitted `pix` or `upi` is validated against the enum and then discarded. The session restricts no payment method type, so the provider offers whatever the deployment's provider account accepts for the resolved currency.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [redirect URL](#redirect-url-object) object | The checkout session was created |
+| 4002 | [error response](/http-api/#error-response) | The price, the account, or the payment provider blocks the checkout |
+| 403 | [error response](/http-api/#error-response) | The email address is unverified, or the purchase is blocked |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+2 The price is unknown or is not a gift price for the resolved catalogue, the account is unclaimed, the payment provider is not configured, or the payment provider rejected the request and returns `STRIPE_ERROR`
+
+### Side effects
+
+The operation creates a payment provider customer when necessary, creates an externally hosted one-off checkout with invoice creation enabled, and records a pending payment row for the session. No [gift](/http-api/gifts/#gift-object) exists yet. The gift is created only after [receive Stripe webhook](#receive-stripe-webhook) processes the matching paid event, after which it appears in [List current user gifts](/http-api/users/gifts/#list-current-user-gifts).
+
+### Rate limit
+
+3 requests per minute for each authenticated user, on the `stripe:checkout:gift` bucket.
+
+## Create age verification session
+
+
+
+Creates a setup mode session that verifies the account holder controls a credit card, and returns a [redirect URL](#redirect-url-object) object. It takes no request body.
+
+### Limitations
+
+- A deployment with no configured provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` before any other check.
+- An account that already has the age-verified adult flag then returns 400 `AGE_VERIFICATION_ALREADY_VERIFIED`.
+
+:::note[Only a credit card satisfies verification]
+The session requests 3-D Secure authentication on the card and takes no payment. Verification succeeds only when the resulting payment method reports credit funding, and a debit or prepaid card leaves the account unverified without an error here.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [redirect URL](#redirect-url-object) object | The verification session was created |
+| 4001 | [error response](/http-api/#error-response) | The account is already age verified, or the provider is not configured or rejected the request |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+1 A rejected request returns `STRIPE_ERROR`
+
+### Side effects
+
+The operation creates a payment provider customer when necessary, records the customer identifier on the account, and creates an externally hosted setup session restricted to cards.
+
+Adult age verification is granted only after [receive Stripe webhook](#receive-stripe-webhook) processes the matching completion event and confirms a credit card. That later grant sets the age-verified adult flag and sends [User Update](/gateway/events/#user-update) to every session owned by the account.
+
+### Rate limit
+
+3 requests per minute for each authenticated user, on the `age_verification` bucket.
+
+## Get refund eligibility
+
+
+
+Returns the [refund eligibility](#refund-eligibility-object) object for the authenticated account.
+
+A deployment with no configured payment provider answers with `eligible` false and the reason `feature_unavailable`. Otherwise the operation lists the five most recent invoices for the account's payment provider customer and selects the first that is paid, has a positive amount, and resolves a payment intent or a charge.
+
+A provider listing failure is logged and treated as no refundable purchase, and the request still succeeds. An account that has never had a payment provider customer provisioned is treated the same way.
+
+:::note[The same object appears inside premium state]
+[Get premium state](/http-api/premium/#get-premium-state) returns this object as `billing.refund_eligibility`, computed from the mirrored invoices, so the two can disagree while the mirror is behind.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [refund eligibility](#refund-eligibility-object) object | The eligibility state was returned |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `stripe:refund:eligibility` bucket.
+
+## Refund latest purchase
+
+
+
+Refunds the account's most recent eligible purchase and returns the [refund](#refund-object) object.
+
+### Limitations
+
+- A deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE`.
+- An account with no selectable invoice returns 400 `STRIPE_NO_PURCHASE_HISTORY`.
+- A purchase that settled more than 3 days ago returns 403 `STRIPE_REFUND_OUTSIDE_WINDOW`.
+- An account whose 30-day cooldown is still running returns 403 `STRIPE_REFUND_COOLDOWN_ACTIVE`.
+
+The same invoice selection as [get refund eligibility](#get-refund-eligibility) applies.
+
+Repeating the request returns the same provider refund. After a refund the provider reports as failed or cancelled, the account can attempt the refund again.
+
+:::note[A Pix refund is short one minor unit]
+When the resolved charge was paid with Pix and the invoice amount is greater than one minor unit, the requested refund is the invoice amount minus one minor unit.
+:::
+
+:::danger[A confirmed refund is irreversible]
+Once the provider confirms the refund succeeded, the subscription that produced the invoice is cancelled immediately with no proration and no final invoice, and the 30-day cooldown begins. Neither effect can be undone through this API.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [refund](#refund-object) object | The refund was created, or the idempotency key resolved an earlier identical refund |
+| 4001 | [error response](/http-api/#error-response) | The account has no refundable purchase, or the payment provider cannot refund it |
+| 403 | [error response](/http-api/#error-response) | The purchase is outside the refund window, or the refund cooldown is active |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
+
+1 The payment provider is not configured, or the payment method could not be determined or the provider rejected the refund, both of which return `STRIPE_ERROR`
+
+### Side effects
+
+The operation creates a refund for the invoice amount paid, reduced by one minor unit in the Pix case, with the reason `requested_by_customer`.
+
+A refund the provider confirms as succeeded in the same call cancels the subscription that produced the invoice and starts the 30-day cooldown from that moment. A refund still reported as pending or as awaiting further action does neither. [Receive Stripe webhook](#receive-stripe-webhook) cancels the subscription and starts the cooldown when it processes the provider event that confirms the refund.
+
+A confirmed refund that cancels the account's current subscription clears the stored subscription, billing cycle, cancellation flag and grace deadline, and every account session receives [User Update](/gateway/events/#user-update). A cancellation that fails is logged and left for the webhook to reconcile.
+
+### Rate limit
+
+3 requests per minute for each authenticated user, on the `stripe:refund:latest` bucket.
+
+## Receive Stripe webhook
+
+
+
+Authenticates a payment provider event against the exact raw request body and enqueues it for asynchronous processing, returning a [webhook received](#webhook-received-object) object. The signature header is the credential.
+
+### Limitations
+
+- A missing signature header returns 400 `STRIPE_WEBHOOK_SIGNATURE_MISSING`.
+- A deployment with no configured payment provider client or webhook secret then returns 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`.
+- A signature header that does not verify returns 401 `STRIPE_WEBHOOK_SIGNATURE_INVALID`.
+
+The route verifies the signature with the payment provider library's default timestamp tolerance.
+
+:::caution[The signature covers the exact bytes]
+A proxy that re-encodes, reorders or pretty-prints the payload invalidates the signature.
+:::
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Stripe-Signature | string | Payment provider signature computed over the exact raw request body |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [webhook received](#webhook-received-object) object | The event was authenticated and enqueued |
+| 400 | [error response](/http-api/#error-response) | The signature header is missing, or webhook processing is not configured for this deployment |
+| 401 | [error response](/http-api/#error-response) | The signature did not verify against the raw body |
+| 500 | [error response](/http-api/#error-response) | The event could not be enqueued |
+
+### Side effects
+
+The response precedes fulfilment, and a redelivered event is applied once.
+
+A recognised event first refreshes the local mirror of its provider object, covering customers, products, prices, checkout sessions, subscriptions, invoices, payment intents, payment methods, charges, refunds and disputes. That mirror is what [Get premium state](/http-api/premium/#get-premium-state) reads.
+
+Later processing can create a purchased gift, grant or extend premium, settle or fail a checkout, update invoice failure state, record or clear the post-cancellation grace deadline, apply subscription changes, and resolve a card preapproval. It also completes a donation and sends its confirmation email, and completes adult age verification. A dispute or fraud warning reverses a gift and emails the redeemer whose entitlement was withdrawn, and a dispute that closes in Fluxer's favour restores the account and emails that account holder. A refund reverses entitlement and settles the self-service refund.
+
+Each account-addressed message is sent only when the receiving account has an email address. The resulting account changes reach connected sessions as [User Update](/gateway/events/#user-update).
+
+### Rate limit
+
+300 requests per minute for each client IP address, or for the authenticated user when a credential happens to be supplied, on the `stripe:webhook` bucket. The bucket is exempt from the global bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/calls.mdx b/fluxer_docs/src/content/docs/http-api/calls.mdx
new file mode 100644
index 000000000..4752e4704
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/calls.mdx
@@ -0,0 +1,288 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Calls
+description: Call eligibility, region selection, ringing, and call session end.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A call is the live voice session of a direct message or a group direct message. [Channels](/http-api/channels/) defines the channel itself, its recipient set, and the region list a caller can select.
+
+Joining and leaving a call is a main Gateway operation. [Voice State Update](/gateway/commands/#voice-state-update) requests the placement, and [Call Create](/gateway/events/#call-create), [Call Update](/gateway/events/#call-update), and [Call Delete](/gateway/events/#call-delete) publish the resulting call state.
+
+Every route on this page is user-only. Bot and OAuth2 credentials are rejected.
+
+## Access rules
+
+Four routes share one boundary. [Get call eligibility](#get-call-eligibility), [Modify call region](#modify-call-region), [Ring call recipients](#ring-call-recipients), and [Stop ringing call recipients](#stop-ringing-call-recipients) each resolve the channel named by the path parameter first, then apply the same three checks in order.
+
+Fluxer answers 404 `UNKNOWN_CHANNEL` for a channel ID that names no channel. A channel that exists but is neither a direct message nor a group direct message returns 400 `INVALID_CHANNEL_TYPE_FOR_CALL`. That type check runs before the membership check, so any authenticated account can learn that an arbitrary channel exists whenever that channel is not a private channel.
+
+A private channel whose recipient set does not contain the caller returns 404 `UNKNOWN_CHANNEL`. An absent channel returns the same code, so a caller cannot tell the two apart.
+
+[End call session](#end-call-session) applies none of these checks.
+
+## Voice moderation in a private call
+
+A private call has no moderator and no permission overwrites. No account other than the participant itself can change that participant's voice state, and the owner of a group direct message is no exception. The `mute`, `deaf`, and `suppress` fields of a [voice state](/gateway/events/#voice-state-object) that belongs to a call are therefore always false. Only the participant's own [Voice State Update](/gateway/commands/#voice-state-update) changes `self_mute`, `self_deaf`, `self_video`, or `self_stream`.
+
+[Modify guild member](/http-api/guild-members/#modify-guild-member) is the only operation that applies a moderator mute, applies a moderator deafen, or forces a disconnect. It is addressed by guild ID, so it can never reach a call. No route on this page and no Gateway command disconnects another participant from a call. A participant leaves a call at its own request, when its Gateway session ends, or when Fluxer reconciles against the media server and finds its media connection gone.
+
+Silencing another participant happens in the client. A client MAY mute a participant or change per-participant volume. The client MUST keep each setting local to the listening device, so neither reaches the Gateway or any other participant.
+
+Two operations on this page name another recipient. [Ring call recipients](#ring-call-recipients) adds named recipients to the ringing set of a call and [Stop ringing call recipients](#stop-ringing-call-recipients) removes them from it. Any current recipient MAY call either one. Neither changes anything for a recipient who has already connected, and neither writes a voice state.
+
+## Call eligibility object
+
+Fluxer computes eligibility for one caller against one private channel at the moment of the read. No Gateway event has the result, and neither field reports whether a call is already running in the channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ringable1 | boolean | Whether the authenticated user can initiate an audible ring in this channel |
+| silent2 | boolean | Whether a newly initiated call notifies the other recipient without audible ringing |
+
+1 False when the caller is already connected to the channel's call, when a direct message caller has never claimed its credentials, and when the other recipient's incoming call policy excludes the caller
+
+2 The field is only meaningful for a direct message, and it is `false` for a group direct message and whenever `ringable` is `false`
+
+### Example
+
+```json
+{
+ "ringable": true,
+ "silent": false
+}
+```
+
+## Get call eligibility
+
+
+
+Returns the [call eligibility object](#call-eligibility-object) for a direct message or group direct message. The caller must satisfy the [access rules](#access-rules).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the direct message or group direct message channel |
+
+1 A guild channel ID is rejected with 400 `INVALID_CHANNEL_TYPE_FOR_CALL`
+
+A direct message reports `ringable` as false when the other recipient's incoming call policy excludes the caller. That policy is the `incoming_call_flags` bitfield of the recipient's [user settings](/http-api/users/settings/). Fluxer reads the nobody flag, the friends-only flag, an existing friendship, a mutual friend, a mutual guild, and finally the everyone flag, in that order. The silent-everyone flag reports `silent` as true for a caller admitted by the mutual friend, mutual guild, or everyone branch.
+
+A recipient who has never stored settings reports `ringable` as true and `silent` as false, and a direct message that has lost its other recipient reports the same pair. A group direct message reports `ringable` as true unless the caller is already connected to its call, and it always reports `silent` as false.
+
+Fluxer evaluates the same policy again on a later [Ring call recipients](#ring-call-recipients) request.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [call eligibility](#call-eligibility-object) object | Eligibility was returned |
+| 400 | [error response](/http-api/#error-response) | Channel is not a direct message or group direct message and the request returns `INVALID_CHANNEL_TYPE_FOR_CALL` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user and channel ID, on the `channel:call:get::channel_id` bucket.
+
+## Modify call region
+
+
+
+Changes the RTC region of an active direct message or group direct message call. Returns 204 with an empty body. Emits a [Call Update](/gateway/events/#call-update) Gateway event.
+
+### Limitations
+
+- The caller must satisfy the [access rules](#access-rules).
+- An active call must exist in the channel.
+- A named region must be accessible to the caller.
+
+The caller does not have to be connected to the call.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the direct message or group direct message channel |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region?1 | ?string | The ID of the [RTC region](/http-api/channels/#rtc-region-object) to select (1 through 64 characters) |
+| latitude?2 | string | The latitude the client reports (1 through 32 characters) |
+| longitude?2 | string | The longitude the client reports (1 through 32 characters) |
+
+1 An omitted field requests no change, and an explicit null and the exact value `automatic` both select automatic routing and skip the accessibility check
+
+2 Validated for length and then discarded, so it never affects the selected region
+
+Any other unknown or inaccessible `region` returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_OR_RESTRICTED_RTC_REGION` on the `region` field. The body can be omitted. Fluxer treats a missing, empty, or whitespace-only body as an empty object, which requests no change. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path.
+
+A region identifier is the `id` of an [RTC region object](/http-api/channels/#rtc-region-object). No route enumerates the regions a call accepts, and [List RTC regions](/http-api/channels/#list-rtc-regions) answers only for a guild voice channel.
+
+A region or voice server restricted to named guilds, to a guild feature, or to VIP voice is never selectable for a call. A region or voice server whose user allowlist excludes the caller is unselectable, and so is a region whose servers are all inactive or all closed to the caller. An instance with voice disabled accepts any region string.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Region was updated, or no change was requested |
+| 400 | [error response](/http-api/#error-response) | Channel is not a direct message or group direct message and the request returns `INVALID_CHANNEL_TYPE_FOR_CALL`, or the region is unknown or restricted and the request returns `INVALID_FORM_BODY` with the validation code `INVALID_OR_RESTRICTED_RTC_REGION` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL`, or no active call exists and the request returns `NO_ACTIVE_CALL` |
+
+### Side effects
+
+Supplying `region` records the new region. When the published call state changes, Fluxer emits [Call Update](/gateway/events/#call-update) to every recipient the call was created with, then issues each connected participant a fresh [Voice Server Update](/gateway/events/#voice-server-update) for the newly selected region. Selecting the region the call already holds emits neither. Omitting `region` changes nothing and emits no Dispatch.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:call:update::channel_id` bucket.
+
+## Ring call recipients
+
+
+
+Starts a direct message or group direct message call, or adds ringing recipients to an existing call. Returns 204 with an empty body. Emits [Message Create](/gateway/events/#message-create), [Call Create](/gateway/events/#call-create), [Channel Create](/gateway/events/#channel-create), and [Call Update](/gateway/events/#call-update) Gateway events.
+
+### Limitations
+
+- The caller must satisfy the [access rules](#access-rules).
+- Every explicitly named recipient must be a current recipient other than the caller.
+- A direct message also requires the caller to satisfy the direct message send policy against the other recipient.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the direct message or group direct message channel |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| recipients?1 | array[snowflake] | The IDs of the recipients to ring |
+| latitude?2 | string | The latitude the client reports (1 through 32 characters) |
+| longitude?2 | string | The longitude the client reports (1 through 32 characters) |
+
+1 Omitting the field targets every other current recipient, and an explicit empty array creates the call and reopens the channel for every other recipient while ringing nobody
+
+2 Validated for length and then discarded, so a new call is always created with automatic routing
+
+The body can be omitted. Fluxer treats a missing, empty, or whitespace-only body as an empty object, which is the same as omitting `recipients`. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path.
+
+An identifier that names a non-recipient or the caller itself returns 400 `INVALID_FORM_BODY` with the validation code `USER_NOT_IN_CHANNEL`. A repeated identifier is accepted and rings that recipient once.
+
+A named recipient is only rung when the incoming call policy described by [Get call eligibility](#get-call-eligibility) admits the caller audibly, so a recipient admitted only under the silent-everyone flag is notified without being rung. A direct message ignores the named set when choosing whom to ring and always evaluates the policy against the other recipient.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Ring operation completed |
+| 400 | [error response](/http-api/#error-response) | Channel is not a direct message or group direct message and the request returns `INVALID_CHANNEL_TYPE_FOR_CALL` |
+| 400 | [error response](/http-api/#error-response) | A named identifier is not a current recipient and the request returns `INVALID_FORM_BODY` with the validation code `USER_NOT_IN_CHANNEL` |
+| 400 | [error response](/http-api/#error-response) | The direct message send policy rejects the caller and the request returns `CANNOT_SEND_MESSAGES_TO_USER` |
+| 400 | [error response](/http-api/#error-response) | That same policy rejects a caller who has never claimed its credentials with `UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES` |
+| 400 | [error response](/http-api/#error-response) | A concurrent request already created the call and the request returns `CALL_ALREADY_EXISTS` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+Fluxer first reopens the private channel for the caller and for every notified recipient, and each account for which the channel was closed receives [Channel Create](/gateway/events/#channel-create).
+
+When no call exists, Fluxer then stores a call system message with the initial participant set and creates the call with [Call Create](/gateway/events/#call-create) that has the initial ringing set and the automatically selected region. It raises the unread mention count of every other recipient that is not a bot and has not blocked the caller. It acknowledges the message for the initiating user without a Dispatch, and only then delivers the stored message with [Message Create](/gateway/events/#message-create). Fluxer sends [Call Create](/gateway/events/#call-create) before [Message Create](/gateway/events/#message-create).
+
+When a call already exists, Fluxer instead extends the ringing set and emits [Call Update](/gateway/events/#call-update) when the set grows. A recipient who is already connected to the call is never added to the ringing set.
+
+Each rung recipient holds a ringing entry for 30 seconds. Fluxer then drops the entry and emits [Call Update](/gateway/events/#call-update) again. When that expiry leaves the call with no connected participant and no other ringing recipient, Fluxer removes the call in the same step, which emits [Call Delete](/gateway/events/#call-delete) and stamps the call system message with its ended timestamp. A call that rang at least one recipient and that nobody joined therefore ends 30 seconds after the ring. A call created with an empty ringing set arms no ring timer, so it ends on the 120 second idle timer instead. An explicit empty `recipients` array produces such a call, and so does a ring that admits no candidate audibly.
+
+Whenever Fluxer removes a call, it rewrites that call's system message with the ended timestamp and with every account that ever connected, and publishes the rewritten message to every recipient of the private channel as [Message Update](/gateway/events/#message-update).
+
+The call's recipient list is fixed when the call is created. Changing the recipient set of a group direct message afterwards neither ends the call nor updates that list. [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient), [Remove group direct message recipient](/http-api/channels/#remove-group-direct-message-recipient), and [Delete or leave channel](/http-api/channels/#delete-or-leave-channel) leave a running call in place. A recipient added later receives no Dispatch for it.
+
+### Rate limit
+
+5 requests per 10 seconds for each authenticated user and channel ID, on the `channel:call:ring::channel_id` bucket.
+
+## Stop ringing call recipients
+
+
+
+Removes recipients from the ringing set of an active direct message or group direct message call. Returns 204 with an empty body. Emits a [Call Update](/gateway/events/#call-update) Gateway event.
+
+### Limitations
+
+- The caller must satisfy the [access rules](#access-rules).
+- An active call must exist in the channel.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the direct message or group direct message channel |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| recipients?1 | array[snowflake] | The IDs of the recipients to stop ringing |
+| latitude?2 | string | The latitude the client reports (1 through 32 characters) |
+| longitude?2 | string | The longitude the client reports (1 through 32 characters) |
+
+1 Omitting the field targets the caller alone, which is how a client declines its own incoming call, and an explicit empty array removes nobody
+
+2 Validated for length and then discarded by this operation
+
+The body can be omitted. Fluxer treats a missing, empty, or whitespace-only body as an empty object, which stops ringing the caller alone. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path. An identifier that is not currently ringing is accepted and changes nothing.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Stop-ringing operation completed |
+| 400 | [error response](/http-api/#error-response) | Channel is not a direct message or group direct message and the request returns `INVALID_CHANNEL_TYPE_FOR_CALL` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL`, or no active call exists and the request returns `NO_ACTIVE_CALL` |
+
+### Side effects
+
+Each named recipient loses its ringing entry and its 30 second ring timer. When the ringing set changes, Fluxer emits [Call Update](/gateway/events/#call-update) to every recipient the call was created with. This operation never removes the call itself.
+
+The 120 second idle timer removes a call left with no connected participant and no ringing recipient, which emits [Call Delete](/gateway/events/#call-delete). This operation does not restart that timer. The timer starts when the call is created and restarts on each join and on each expiry that still finds a connected participant or a ringing recipient, so removal can follow within a fraction of that window.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:call:stop_ringing::channel_id` bucket.
+
+## End call session
+
+
+
+Answers 204 for any well-formed channel ID and takes no body. The route resolves no channel, performs no call operation, and emits no Gateway Dispatch.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the direct message or group direct message channel |
+
+1 The value is only checked for snowflake form, so any well-formed ID is accepted whether or not it names a channel the caller can reach
+
+:::caution[Leaving a call is a Gateway operation]
+A participant leaves by sending [Voice State Update](/gateway/commands/#voice-state-update) with a null channel and the `connection_id` to drop.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Request was accepted |
+
+A call ends the moment its last connected participant leaves, whether or not a recipient is still ringing. Fluxer publishes [Call Delete](/gateway/events/#call-delete) at that moment. A call that no participant ever joined ends instead when its last ringing entry expires, and a call whose ringing set was emptied by [Stop ringing call recipients](#stop-ringing-call-recipients) ends on the Gateway's 120 second idle timer.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:call:update::channel_id` bucket, shared with [Modify call region](#modify-call-region).
diff --git a/fluxer_docs/src/content/docs/http-api/channels.mdx b/fluxer_docs/src/content/docs/http-api/channels.mdx
new file mode 100644
index 000000000..a896407c8
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/channels.mdx
@@ -0,0 +1,787 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Channels
+description: Channel objects, settings, recipients, permission overwrites, slowmode, and RTC regions.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A channel is where a conversation happens, in text or in voice. A [guild](/http-api/guilds/) owns text, voice, category and link channels. Outside a guild, a channel is a direct message, a group direct message, or the personal notes channel.
+
+[Messages](/http-api/messages/) defines message content, and [Guild channels](/http-api/guild-channels/) defines guild-scoped listing, creation and reordering. [Calls](/http-api/calls/) defines ringing, the region of a private call, and ending a call. [Streams](/http-api/streams/) defines the stream keys and previews of a Go Live screen share.
+
+## Channel object
+
+A channel object always has its identity and type. Every other field is present only for the [channel types](#channel-types) that own it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel |
+| type | integer | The [type of the channel](#channel-types) |
+| guild_id? | snowflake | The ID of the guild, present only for a guild text, voice, category, or link channel |
+| name?1 | string | The name of the channel, present for a guild channel and for a group direct message |
+| topic? | ?string | The topic of the channel, present only for a guild text or voice channel |
+| url? | ?string | The destination URL, present only for a guild link channel |
+| icon? | ?string | The icon hash, present only for a group direct message |
+| owner_id? | ?snowflake | The ID of the owner, present only for a group direct message |
+| position?2 | integer | The sort position, present only for a guild channel |
+| parent_id? | ?snowflake | The ID of the parent category, null when the channel sits at the top level |
+| bitrate?3 | ?integer | The voice bitrate in bits per second, present only for a guild voice channel |
+| user_limit?4 | ?integer | The configured member occupancy limit, present only for a guild voice channel |
+| voice_connection_limit?5 | ?integer | The number of simultaneous voice connections one user may hold, present only for a guild voice channel |
+| rtc_region?6 | ?string | The ID of the selected RTC region, present only for a guild voice channel |
+| last_message_id? | ?snowflake | The ID of the most recent message, null when the channel has none |
+| last_pin_timestamp? | ?ISO8601 timestamp | The time a message was most recently pinned, or null when nothing has ever been pinned |
+| permission_overwrites?7 | array[[permission overwrite](#permission-overwrite-object) object] | The overwrites applied to this channel, present only for a guild channel |
+| recipients?8 | array[[partial user](/http-api/users/#partial-user-object) object] | The other recipients of a direct message or group direct message (max 49) |
+| nsfw?9 | boolean | Whether the channel has its own age restriction, present only for a guild channel |
+| nsfw_override?9 | ?boolean | Whether this channel overrides the inherited age restriction, or null when it inherits |
+| content_warning_level?10 | integer | The [content warning level](#content-warning-levels) stored on this channel, present only for a guild channel |
+| content_warning_text?10 | ?string | The content warning text stored on this channel, or null when the channel inherits |
+| rate_limit_per_user?11 | integer | The slowmode interval in seconds, present only for a guild text or voice channel |
+| nicks?12 | map[snowflake, string] | The group direct message nicknames keyed by the decimal user ID (each 1-32 characters) |
+
+1 Omitted when a group direct message stores no name, and never present on a direct message or on the personal notes channel
+
+2 A channel that stores no position reports `0`
+
+3 A guild voice channel that stores no bitrate reports `0`
+
+4 The value `0` means no occupancy limit
+
+5 A guild voice channel that stores no per-user connection limit reports the default `5`
+
+6 Null selects automatic routing. [List RTC regions](#list-rtc-regions) returns the available identifiers
+
+7 Always present for a guild channel, and empty when the channel stores no overwrite
+
+8 Excludes the authenticated user, omitted entirely when no other recipient remains, and never present on the personal notes channel
+
+9 A channel with no explicit override reports `nsfw` false and `nsfw_override` null
+
+10 A channel that inherits reports `content_warning_level` `0` and `content_warning_text` null
+
+11 A channel that configures no slowmode reports `0`
+
+12 Omitted when the group stores no nickname. Clearing a nickname removes its key from the map
+
+:::note[Omission and null are distinct]
+An absent field is one this channel type does not own. A field present with `null` is one the type owns and that has no value.
+:::
+
+A category owns no `parent_id`. A client that reads the absent key as `null` sees a channel whose parent was cleared.
+
+:::caution[Age and warning fields report this channel alone]
+The API enforces the resolved state, so a client must apply the inherited category state before it shows an age restriction or a content warning.
+:::
+
+An age restriction and a content warning both resolve through this channel first, then the parent category, and finally the guild. A category has no parent category and resolves through itself and then the guild.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "type": 0,
+ "guild_id": "1501314428688990000",
+ "name": "general",
+ "topic": "Anything goes",
+ "position": 3,
+ "parent_id": "1501314428688991111",
+ "permission_overwrites": [],
+ "nsfw": false,
+ "nsfw_override": null,
+ "content_warning_level": 0,
+ "content_warning_text": null,
+ "rate_limit_per_user": 0,
+ "last_message_id": "1501320000000000000",
+ "last_pin_timestamp": null
+}
+```
+
+## Channel types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | GUILD_TEXT | Guild text channel |
+| 1 | DM | Direct message channel between exactly two accounts |
+| 2 | GUILD_VOICE | Guild voice channel, which also has messages, pins, and slowmode |
+| 3 | GROUP_DM | Group direct message channel |
+| 4 | GUILD_CATEGORY | Guild category, which owns no parent and no messages |
+| 998 | GUILD_LINK | Guild link channel, which has a destination URL and no messages |
+| 999 | DM_PERSONAL_NOTES1 | Personal notes channel |
+
+1 Its channel ID is exactly the owning user ID, and it has no recipient and no name. Fluxer creates it on first authenticated access
+
+## Permission overwrite object
+
+A permission overwrite changes the effective guild permissions for one role or one member in one channel. `allow` and `deny` are decimal strings because a permission mask exceeds the range a JSON number preserves. [Permissions](/http-api/permissions/) defines the individual flags.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | snowflake | The ID of the role or member |
+| type | integer | The [permission overwrite type](#permission-overwrite-types) of the identifier in `id` |
+| allow2 | decimal string | The bitfield of permissions this overwrite allows |
+| deny2 | decimal string | The bitfield of permissions this overwrite denies |
+
+1 One identifier addresses one overwrite, so storing an overwrite for an identifier that already has one replaces it, even when the two name different types
+
+2 Every bit outside the defined permission set is discarded on write, and both fields are `0` when the overwrite grants and denies nothing
+
+No route bounds the number of overwrites a channel stores. [Modify channel](#modify-channel) and [Create guild channel](/http-api/guild-channels/#create-guild-channel) accept a `permission_overwrites` array of any length, and no [limit key](/http-api/instance/#limit-keys) caps it.
+
+### Example
+
+```json
+{
+ "id": "1501314428688990000",
+ "type": 0,
+ "allow": "3072",
+ "deny": "0"
+}
+```
+
+## Permission overwrite types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ROLE | Overwrite applies to a guild role |
+| 1 | MEMBER | Overwrite applies to a guild member |
+
+## Content warning levels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | INHERIT | Inherit the content warning of the parent category and then of the guild |
+| 1 | CONTENT_WARNING | Apply the content warning stored on this channel |
+
+## Channel slowmode state object
+
+The slowmode state combines the channel's configured interval with the authenticated user's next permitted send time.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| rate_limit_per_user | integer | The configured slowmode interval in seconds, or `0` when slowmode is disabled |
+| retry_after_ms1 | integer | The remaining delay in milliseconds before the user can send |
+| next_send_allowed_at1 | ?ISO8601 timestamp | The next permitted send time, or null when no delay applies |
+| can_bypass2 | boolean | Whether the user bypasses slowmode through [BYPASS_SLOWMODE](/http-api/permissions/) |
+
+1 Both report no delay in a private channel, for an interval of `0`, for a bot caller, and for a caller holding `BYPASS_SLOWMODE`
+
+2 False whenever the delay is not accounted, so a bot caller and a private channel both report `false` even though neither is delayed
+
+### Example
+
+```json
+{
+ "rate_limit_per_user": 10,
+ "retry_after_ms": 4200,
+ "next_send_allowed_at": "2026-08-31T09:14:02.000Z",
+ "can_bypass": false
+}
+```
+
+## RTC region object
+
+An RTC region names one voice routing target the deployment operates. Its identifier is the value a guild voice channel stores in `rtc_region`, and the name and emoji exist only for presentation.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The ID of the RTC region |
+| name | string | The name shown for the region in a client |
+| emoji | string | The emoji the operator configured for the region |
+
+### Example
+
+```json
+{
+ "id": "eu-central",
+ "name": "Frankfurt",
+ "emoji": "🇩🇪"
+}
+```
+
+## Get channel
+
+
+
+Returns the [channel object](#channel-object) visible to the authenticated user.
+
+### Limitations
+
+- A guild channel requires [VIEW_CHANNEL](/http-api/permissions/).
+- A guild channel that resolves to an age restriction also requires a satisfied age verification.
+- A private channel requires current recipient access.
+- The personal notes channel requires ownership.
+
+Fluxer enforces age verification only for a guild text, voice, or link channel. A guild category is never gated on it, even when it has the override its children inherit.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel to return |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel](#channel-object) object | Channel was returned |
+| 403 | [error response](/http-api/#error-response) | Caller is not a guild member or lacks `VIEW_CHANNEL`, each returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The guild exists but the caller's membership state cannot be resolved and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+:::note[The guild resolves after the channel]
+A channel whose guild is gone fails with `UNKNOWN_GUILD` rather than `UNKNOWN_CHANNEL`.
+:::
+
+### Side effects
+
+Requesting the personal notes channel of the authenticated user creates that channel when it does not already exist, and returns it directly without a [Channel Create](/gateway/events/#channel-create) Dispatch.
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:read::channel_id` bucket.
+
+## Get channel slowmode state
+
+
+
+Returns the authenticated user's [channel slowmode state object](#channel-slowmode-state-object). The caller needs the same access as [Get channel](#get-channel).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel whose slowmode state is returned |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel slowmode state](#channel-slowmode-state-object) object | State was returned |
+| 403 | [error response](/http-api/#error-response) | Caller is not a guild member or lacks `VIEW_CHANNEL`, each returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The guild exists but the caller's membership state cannot be resolved and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The read does not advance the caller's slowmode countdown. Requesting the personal notes channel of the authenticated user creates that channel when it does not already exist, with no [Channel Create](/gateway/events/#channel-create) Dispatch.
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:read::channel_id` bucket.
+
+## List RTC regions
+
+
+
+Returns the [RTC region objects](#rtc-region-object) the authenticated user can select for a guild voice channel, in ascending display name order. User-only.
+
+### Limitations
+
+- The caller needs the same access as [Get channel](#get-channel).
+- The channel must be a guild voice channel.
+
+Fluxer returns a region only when the caller passes every restriction configured for it and at least one active voice server in that region is also accessible to the caller. The array can be empty. [Placement eligibility](/admin-api/voice/#placement-eligibility) states the four stored fields those restrictions come from.
+
+:::note[An unconfigured deployment skips the channel check]
+On a deployment that configures no voice topology, a missing channel, an invisible channel, and a non-voice channel all return 200 with `[]`.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild voice channel |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[RTC region](#rtc-region-object) object] | Available regions were returned |
+| 400 | [error response](/http-api/#error-response) | Channel is not a guild voice channel and the request returns `INVALID_CHANNEL_TYPE` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or uses an OAuth2 bearer credential, or the guild exists but the caller's membership state cannot be resolved, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller is not a guild member or lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:read::channel_id` bucket.
+
+## Modify channel
+
+
+
+Modifies a guild channel or a group direct message and returns the updated [channel object](#channel-object). Emits a [Channel Update](/gateway/events/#channel-update) Gateway event.
+
+### Limitations
+
+- A guild channel requires [VIEW_CHANNEL](/http-api/permissions/) and [MANAGE_CHANNELS](/http-api/permissions/) in that channel.
+- Supplying `permission_overwrites` also requires [MANAGE_ROLES](/http-api/permissions/) in that channel.
+- Supplying `rtc_region` on a guild voice channel also requires [UPDATE_RTC_REGION](/http-api/permissions/).
+- A caller who is not the guild owner cannot allow a permission bit outside their own effective permissions in that channel, and cannot remove a denied bit outside them either.
+- A group direct message requires the caller to be a current recipient.
+- Ownership transfer requires the caller to be the current owner, and so does setting another recipient's nickname or clearing every nickname.
+
+`MANAGE_CHANNELS` and `MANAGE_ROLES` are [elevated permissions](/http-api/permissions/#elevated-permissions). Each also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. A caller who holds the permission without that authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/).
+
+:::caution[Supplying `permission_overwrites` deletes the absent overwrites]
+The submitted array becomes the complete overwrite collection of the channel.
+:::
+
+[Set channel permission overwrite](#set-channel-permission-overwrite) and [Delete channel permission overwrite](#delete-channel-permission-overwrite) each mutate one overwrite and leave the rest untouched.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel to modify |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Features?1 | string | A comma-separated [feature declaration](/http-api/permissions/#feature-gated-permission-bits) |
+
+1 Read only when `permission_overwrites` is supplied. A [feature-gated bit](/http-api/permissions/#feature-gated-permission-bits) the declaration does not name is copied from the stored overwrite
+
+### JSON body
+
+The stored channel type selects the body variant, and a `type` field in the request is ignored. A direct message and the personal notes channel match no variant, and Fluxer rejects both with 400 `INVALID_FORM_BODY`.
+
+#### Guild channel body
+
+Every field is optional, and an omitted field preserves its current value. The four guild variants share one field set, and a field the stored type does not own is accepted and discarded.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name?1 | ?string | The name of the channel (1-100 characters after normalisation) |
+| topic?2 | ?string | The topic of the channel (1-1,024 characters), or null to clear it |
+| url?3 | ?string | The destination of a guild link channel (1-2,048 characters, `http` or `https`), or null to clear it |
+| parent_id?4 | ?snowflake | The ID of the parent category, where null or the snowflake `0` moves the channel to the top level |
+| bitrate?5 | ?integer | The voice bitrate in bits per second (8,000-320,000) |
+| user_limit?5 | ?integer | The voice occupancy limit (0-99, where `0` configures no limit) |
+| voice_connection_limit?5 | ?integer | The number of simultaneous voice connections one user may hold (1-100) |
+| permission_overwrites?6 | array[[permission overwrite](#permission-overwrite-object) object] | The complete overwrite collection that replaces the stored one |
+| nsfw_override?7 | ?boolean | Whether this channel overrides the inherited age restriction |
+| nsfw?8 | ?boolean | Whether the channel is age restricted, a legacy switch superseded by `nsfw_override` |
+| content_warning_level?9 | integer | The [content warning level](#content-warning-levels) to store on this channel |
+| content_warning_text?10 | ?string | The content warning text (max 200 characters) |
+| rate_limit_per_user?11 | ?integer | The slowmode interval in seconds (0-21,600) for a guild text or voice channel |
+| rtc_region?12 | ?string | The ID of the RTC region (1-64 characters) for a guild voice channel, where null selects automatic routing |
+
+1 An explicit null preserves the current name. In a guild without flexible channel names, Fluxer lowercases a guild text channel name, replaces its whitespace with hyphens, and removes disallowed punctuation
+
+2 An explicit null clears the stored topic
+
+3 Applied only to a guild link channel. An explicit null clears the destination
+
+4 A category rejects any parent with `CATEGORIES_CANNOT_HAVE_PARENTS`
+
+5 Applied only to a guild voice channel, and an explicit null clears the stored value
+
+6 Each entry has `id`, `type`, and the optional `allow` and `deny` masks, each defaulting to `0`
+
+7 An explicit null returns the channel to inheriting from its parent category and then its guild
+
+8 `nsfw_override` wins when both are supplied. Alone, `true` maps to `nsfw_override` `true` and `false` maps to null
+
+9 Only `0` and `1` are accepted, and `0` returns the channel to inheriting
+
+10 Trimmed. A value empty after trimming, or an explicit null, clears the stored text
+
+11 Applied only to a guild text or voice channel. An explicit null preserves the current interval
+
+12 Applied only to a guild voice channel. Supplying the field at all, including as null, requires [UPDATE_RTC_REGION](/http-api/permissions/)
+
+A `url` that is not an absolute `http` or `https` URL with a host returns 400 `INVALID_FORM_BODY` with the code `INVALID_URL_FORMAT` on the path `url`.
+
+Three failures apply to `parent_id`. A parent that does not exist in the same guild returns 400 `INVALID_FORM_BODY` with the code `INVALID_PARENT_CHANNEL` on the path `parent_id`. A parent that is not a category returns `PARENT_MUST_BE_CATEGORY` the same way.
+
+Naming a category other than the current parent also checks capacity. A full category returns 400 `MAX_CATEGORY_CHANNELS` with the ceiling it reached, which is the deployment's [`max_channels_per_category`](/http-api/instance/#limit-keys) limit and defaults to 50.
+
+Fluxer accepts an overwrite mask in two encodings. A decimal string holds at most 9223372036854775807, and a larger one returns 400 `INVALID_FORM_BODY` with the code `INTEGER_OUT_OF_INT64_RANGE`. A JSON integer holds at most 9007199254740991, and a number outside the safe integer range returns `INVALID_INTEGER_FORMAT`.
+
+Every bit outside the defined permission set is discarded. A [feature-gated bit](/http-api/permissions/#feature-gated-permission-bits) the `X-Fluxer-Features` declaration does not name is copied from the stored overwrite.
+
+A non-null `rtc_region` names a region [List RTC regions](#list-rtc-regions) returns for this guild. Any other value returns 400 `INVALID_FORM_BODY` with the code `INVALID_OR_RESTRICTED_RTC_REGION` on the path `rtc_region`. A deployment that configures no voice topology validates no region and stores any accepted string.
+
+#### Group direct message body
+
+Every field is optional, and an omitted field preserves its current value.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | ?string | The name of the group (1-100 characters after normalisation), or null to clear it |
+| icon?1 | ?base64 string | The base64-encoded icon image (at most 13,981,014 encoded characters), or null to clear it |
+| owner_id?2 | ?snowflake | The ID of the new owner |
+| nicks?3 | ?map[snowflake, ?string] | The nicknames keyed by the decimal user ID (each 0-32 characters, or null) |
+
+1 Raw base64 or a data URL, with 1 through 13,981,014 characters after the prefix
+
+2 An explicit null requests no transfer. The caller must be the current owner and the target a current recipient
+
+3 An explicit null clears every nickname and requires ownership. A non-owner may address only their own key
+
+Decoded icon bytes are at most the instance-configured `avatar_max_size` [limit](/http-api/instance/#limit-keys), which defaults to the 10 MiB ceiling of 10485760 bytes. A larger image is rejected on the path `icon` with the code `IMAGE_SIZE_EXCEEDS_LIMIT`. PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG are accepted, and an animated AVIF is rejected on the same path with `INVALID_IMAGE_FORMAT`.
+
+An `owner_id` naming an account that is not a current recipient returns 404 `UNKNOWN_USER`. A bot target returns 400 `CANNOT_TRANSFER_OWNERSHIP_TO_BOT`.
+
+Fluxer trims a stored nickname, and a value that is null or empty after trimming clears that one nickname. A `nicks` key that is neither a current recipient nor the caller returns 404 `UNKNOWN_USER`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel](#channel-object) object | Channel was modified, or the submitted values already matched the stored state |
+| 403 | [error response](/http-api/#error-response) | A required permission, ownership, or permission bit authority is absent and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | A concurrent change removed the caller from the group direct message and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The guild exists but the caller's membership state cannot be resolved and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the caller is not a recipient of the group direct message, each returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | A referenced owner or nickname target is not a current recipient and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+A guild channel change emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild. Replacing a category's permission overwrites also replaces them on each child whose overwrites still exactly match the category's previous values, with one Dispatch per changed child. A child whose overwrites had diverged is left unchanged.
+
+Changing `rate_limit_per_user` clears the channel's current slowmode state so the new interval applies immediately. Changing `rtc_region` on a guild voice channel moves its active voice connections to the selected region.
+
+A request that changes at least one field creates a channel update audit log entry with no reason. Supplying `permission_overwrites` also creates the overwrite audit log entries the change implies.
+
+A group direct message change emits [Channel Update](/gateway/events/#channel-update) to every current recipient. A name or successful icon change creates a system message delivered with [Message Create](/gateway/events/#message-create), and replacing the icon permanently deletes the previous one. Ownership and nickname changes create no system message and no audit entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:update::channel_id` bucket.
+
+## Delete or leave channel
+
+
+
+Deletes a guild channel, closes a direct message, or removes the caller from a group direct message, and returns 204 with an empty body. Emits a [Channel Delete](/gateway/events/#channel-delete) Gateway event.
+
+### Limitations
+
+- Deleting a guild channel requires [VIEW_CHANNEL](/http-api/permissions/) and [MANAGE_CHANNELS](/http-api/permissions/).
+- Closing a direct message or leaving a group direct message requires current recipient access.
+- The personal notes channel cannot be deleted, and the request returns 400 `CANNOT_EXECUTE_ON_DM`.
+- Setting `delete_messages` also requires [sudo mode](/http-api/users/mfa/#sudo-mode) for a user session, satisfied by a valid sudo proof or by the verification fields in the body. A bot token always satisfies it.
+
+`MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions). It also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. A caller who holds the permission without that authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/). The sudo requirement on `delete_messages` applies at every guild MFA level.
+
+:::danger[A guild channel deletion is immediate and permanent]
+Deleting a guild channel destroys the channel record, every message in it, every stored attachment of those messages, its invites, and its webhooks. There is no pending state, no grace period, and no restore.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel to delete, close, or leave |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| silent?1 | boolean | Whether to suppress the leave system message of a group direct message (default false) |
+| delete_messages?1 | boolean | Whether to delete the caller's own messages in this channel first (default false) |
+
+1 Only the exact trimmed values `true`, `True`, and `1` are true. An absent parameter and every other value, including `TRUE` and `yes`, are false
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT?1 | string | An existing sudo mode proof, read when `delete_messages` is set |
+
+1 An accepted proof is echoed back in the same response header, and completing the MFA path issues a fresh one
+
+### JSON body
+
+The body is optional. Fluxer reads it only when `delete_messages` is set and no existing sudo proof satisfies the requirement.
+
+
+
+| Field | Type | Description |
+| --- | --- | --- |
+| password?1 | string | The account password, offered as the sudo proof |
+| mfa_method?2 | string | The verification method, either `totp` or `webauthn` |
+| mfa_code? | string | The authenticator code (1-32 characters), supplied with the `totp` method |
+| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | The WebAuthn assertion for sudo verification |
+| webauthn_challenge? | string | The challenge (1-256 characters) bound to the sudo mode assertion |
+
+1 Accepted only for an account with no configured second factor. A rejected password returns 400 `INVALID_FORM_BODY` with the code `INVALID_PASSWORD` on the path `password`
+
+2 Accepted only for an account with a configured second factor. A rejected code or assertion returns 400 `INVALID_FORM_BODY` with the code `INVALID_MFA_CODE` on the path `mfa_code`
+
+An account that stores neither a password nor a second factor is verified without any proof.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Channel was deleted, closed, or left |
+| 400 | [error response](/http-api/#error-response) | Target is the personal notes channel and the request returns `CANNOT_EXECUTE_ON_DM` |
+| 403 | [error response](/http-api/#error-response) | Caller is not a guild member or lacks `VIEW_CHANNEL` or `MANAGE_CHANNELS`, each returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | Sudo verification is required and the request returns `SUDO_MODE_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | A concurrent change removed the caller from the group direct message and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The guild exists but the caller's membership state cannot be resolved and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+Deleting a guild category first clears the `parent_id` of every child channel and emits [Channel Update](/gateway/events/#channel-update) for each one. The category itself is then deleted like any other guild channel.
+
+Deleting a guild channel deletes its invites and webhooks, purges the stored attachments of its messages, deletes its messages, and removes those messages from search. Fluxer then emits [Channel Delete](/gateway/events/#channel-delete) to every session subscribed to the guild, records a channel delete audit log entry with the previous channel state and no reason, and removes the channel record. A guild that named the channel as its system, rules, or AFK channel has that reference cleared and emits [Guild Update](/gateway/events/#guild-update).
+
+Closing a direct message marks the channel closed for the caller alone and emits [Channel Delete](/gateway/events/#channel-delete) to that caller. The channel, its messages, and the other recipient's view are untouched.
+
+Leaving a group direct message removes the caller from the recipient set, removes the caller's nickname, and closes the channel for the caller. If the caller owned the group, ownership transfers to one of the remaining recipients chosen at random.
+
+Remaining recipients receive [Channel Recipient Remove](/gateway/events/#channel-recipient-remove), and unless `silent` is true also a removal system message through [Message Create](/gateway/events/#message-create). The leaving caller receives [Channel Delete](/gateway/events/#channel-delete). No [Channel Update](/gateway/events/#channel-update) reports the ownership transfer or the removed nickname, so a client tracking `owner_id` or `nicks` must refetch the channel.
+
+Leaving as the final recipient permanently deletes the channel's messages and the channel record, with no system message.
+
+Setting `delete_messages` deletes the caller's authored messages in this channel after sudo verification and before the channel operation runs, and the response waits for that deletion to finish.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:delete::channel_id` bucket.
+
+## Add group direct message recipient
+
+
+
+Adds a user to a group direct message and returns 204 with an empty body. Emits a [Channel Recipient Add](/gateway/events/#channel-recipient-add) Gateway event.
+
+### Limitations
+
+- The caller must be a current recipient of the group.
+- The caller and the target must be friends, including when the caller is a bot.
+- The target's group direct message admission policy must allow the caller.
+- An instance with CAPTCHA enabled also requires a CAPTCHA proof.
+
+Fluxer evaluates the target's admission policy in this order.
+
+1. A target that has never stored settings admits every caller.
+2. A nobody setting rejects every caller. An everyone setting admits every caller.
+3. A friends-only setting admits only a caller the target has as a friend.
+4. Otherwise Fluxer admits the caller through an existing friendship, then through a mutual friend when the friends-of-friends setting is enabled, and then through a mutual guild when the guild-members setting is enabled.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the group direct message channel |
+| user_id | snowflake | The ID of the user to add |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | A CAPTCHA proof |
+| X-Captcha-Type?2 | string | The CAPTCHA provider, either `hcaptcha` or `turnstile` |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`, as does a proof for a provider the instance holds no secret key for. [CAPTCHA](/topics/captcha/) states when an instance skips verification
+
+2 `hcaptcha` or `turnstile` selects that provider whether or not the instance configured it. The instance's configured provider is used when the header is absent or has any other value
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Recipient was added, or was already a recipient |
+| 400 | [error response](/http-api/#error-response) | CAPTCHA proof is missing or rejected, the caller and target are not friends and the request returns `NOT_FRIENDS_WITH_USER` |
+| 400 | [error response](/http-api/#error-response) | The group is already full and the request returns `MAX_GROUP_DM_RECIPIENTS` |
+| 400 | [error response](/http-api/#error-response) | The channel is not a group direct message and the request returns `INVALID_CHANNEL_TYPE` |
+| 403 | [error response](/http-api/#error-response) | Caller is not a recipient, or the target's admission policy rejects the caller, each returning `MISSING_ACCESS` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` |
+
+The `MAX_GROUP_DM_RECIPIENTS` body has `max_recipients` reporting the exact ceiling that was reached. That ceiling is the deployment's [`max_group_dm_recipients`](/http-api/instance/#limit-keys) limit resolved for the caller, and it defaults to 50.
+
+### Side effects
+
+A successful addition stores the new recipient set and opens the channel for the added user. The added user receives [Channel Create](/gateway/events/#channel-create), existing recipients receive [Channel Recipient Add](/gateway/events/#channel-recipient-add), and every recipient receives the addition system message through [Message Create](/gateway/events/#message-create). Adding a user who is already a recipient changes nothing, consumes no capacity, and emits no Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:update::channel_id` bucket. The `user:group_dm:recipient:add` protection bucket adds 10 requests per hour for each authenticated user, and does not consume the global limit.
+
+## Remove group direct message recipient
+
+
+
+Removes a user from a group direct message and returns 204 with an empty body. Emits a [Channel Recipient Remove](/gateway/events/#channel-recipient-remove) Gateway event.
+
+### Limitations
+
+- The caller must be a current recipient.
+- A caller may remove themself regardless of ownership.
+- Removing another recipient requires ownership of the group.
+- Setting `delete_messages` takes effect only when `user_id` is the caller, and it then requires sudo verification on the same terms as [Delete or leave channel](#delete-or-leave-channel).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the group direct message channel |
+| user_id | snowflake | The ID of the recipient to remove |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| silent?1 | boolean | Whether to suppress the recipient removal system message (default false) |
+| delete_messages?1 | boolean | Whether to delete the caller's own messages in this channel before self-removal (default false) |
+
+1 Only the exact trimmed values `true`, `True`, and `1` are true. An absent parameter and every other value, including `TRUE` and `yes`, are false
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT?1 | string | An existing sudo mode proof, read when `delete_messages` is set for self-removal |
+
+1 An accepted proof is echoed back in the same response header, and completing the MFA path issues a fresh one
+
+### JSON body
+
+The optional body has the same [sudo verification fields](#sudo-verification-fields) as [Delete or leave channel](#delete-or-leave-channel). Fluxer reads it only when a self-removal sets `delete_messages` and the request has no existing sudo proof.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Recipient was removed |
+| 400 | [error response](/http-api/#error-response) | Channel is not a group direct message and the request returns `INVALID_CHANNEL_TYPE`, or the target is not a recipient and the request returns `INVALID_FORM_BODY` with the code `USER_NOT_IN_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller is not a recipient and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The caller is not the owner while removing another recipient and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | Sudo verification is required and the request returns `SUDO_MODE_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+The removal has the same state and Dispatch effects as leaving through [Delete or leave channel](#delete-or-leave-channel). It stores the reduced recipient set, removes the recipient's nickname, and closes the channel for the recipient. When the removed recipient owned the group, ownership transfers to one of the remaining recipients chosen at random.
+
+The removed recipient receives [Channel Delete](/gateway/events/#channel-delete). Remaining recipients receive [Channel Recipient Remove](/gateway/events/#channel-recipient-remove), and the removal system message through [Message Create](/gateway/events/#message-create) unless `silent` is true. No [Channel Update](/gateway/events/#channel-update) reports the ownership transfer or the removed nickname.
+
+Removing the final recipient permanently deletes the channel's messages, removes them from search, and deletes the channel record, with no system message. Setting `delete_messages` on a self-removal deletes the caller's messages in this channel before the removal runs.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:update::channel_id` bucket.
+
+## Set channel permission overwrite
+
+
+
+Creates or replaces one permission overwrite on a guild channel and returns 204 with an empty body. Requires [MANAGE_ROLES](/http-api/permissions/) in that channel. Emits a [Channel Update](/gateway/events/#channel-update) Gateway event.
+
+### Limitations
+
+- A caller without [ADMINISTRATOR](/http-api/permissions/) cannot allow a permission bit outside their own effective permissions in that channel, and cannot remove a denied bit outside them either.
+
+The operation does not require `VIEW_CHANNEL` and never returns `TWO_FACTOR_REQUIRED` or `NSFW_CONTENT_AGE_RESTRICTED`.
+
+:::note[The identifier alone keys the overwrite]
+`type` records whether the identifier names a role or a member, and does not change which entry is written. An overwrite can be stored for an identifier that names nothing, and [Delete channel permission overwrite](#delete-channel-permission-overwrite) removes it.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild channel |
+| overwrite_id | snowflake | The ID of the role or member the overwrite represents |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Features?1 | string | A comma-separated [feature declaration](/http-api/permissions/#feature-gated-permission-bits) |
+
+1 A [feature-gated bit](/http-api/permissions/#feature-gated-permission-bits) the declaration does not name is copied from the stored overwrite, so the request can neither set nor clear it
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type1 | integer | The [permission overwrite type](#permission-overwrite-types) of `overwrite_id` |
+| allow?2 | ?decimal string | The bitfield of permissions to allow (default `0`) |
+| deny?2 | ?decimal string | The bitfield of permissions to deny (default `0`) |
+
+1 Only `0` and `1` are accepted
+
+2 A decimal string of at most 9223372036854775807, or a JSON integer of at most 9007199254740991. An omitted or null value is treated as `0`
+
+A larger decimal string returns 400 `INVALID_FORM_BODY` with the code `INTEGER_OUT_OF_INT64_RANGE`. A JSON number outside the safe integer range returns `INVALID_INTEGER_FORMAT`, and so does a string that is not all digits. Every bit outside the defined permission set is discarded before the authority check and before the change is applied.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Overwrite was created or replaced |
+| 403 | [error response](/http-api/#error-response) | `MANAGE_ROLES` in the channel or permission bit authority is absent, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or is not a guild channel, each returning `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the submitted overwrite exactly matches the stored one. When the channel is a category, every child whose overwrites exactly matched the category's previous values receives the same replacement and its own Channel Update.
+
+The operation records an overwrite create or update audit entry with no reason. An unchanged overwrite records no audit entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:update::channel_id` bucket.
+
+## Delete channel permission overwrite
+
+
+
+Deletes one permission overwrite from a guild channel and returns 204 with an empty body. Requires [MANAGE_ROLES](/http-api/permissions/) in that channel. Emits a [Channel Update](/gateway/events/#channel-update) Gateway event.
+
+### Limitations
+
+- A caller without [ADMINISTRATOR](/http-api/permissions/) must already hold every permission bit the removed overwrite denied.
+
+The operation is idempotent. It does not require `VIEW_CHANNEL` and never returns `TWO_FACTOR_REQUIRED` or `NSFW_CONTENT_AGE_RESTRICTED`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild channel |
+| overwrite_id1 | snowflake | The ID of the role or member the overwrite represents |
+
+1 The identifier alone selects the overwrite. The request has no overwrite type
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Overwrite was deleted, or no overwrite existed for the identifier |
+| 403 | [error response](/http-api/#error-response) | `MANAGE_ROLES` in the channel or permission bit authority is absent, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or is not a guild channel, each returning `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the identifier names no existing overwrite. Removing an overwrite that exists also records an overwrite delete audit entry with the previous overwrite state and no reason. An identifier that names no existing overwrite records no audit entry. When the target channel is a category, propagation follows the same exact-match rule as overwrite replacement.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:update::channel_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/connections.mdx b/fluxer_docs/src/content/docs/http-api/connections.mdx
new file mode 100644
index 000000000..b77537e2c
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/connections.mdx
@@ -0,0 +1,533 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Connections
+description: External account connections, domain ownership proof, Bluesky authorisation, visibility flags, and display ordering.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A connection links a Fluxer account to an outside identity the account has proved it controls. The two [connection types](#connection-types) are a DNS domain and a Bluesky account authorised through atproto OAuth. A profile renders an account's connections as the `connected_accounts` field of the [full user profile object](/http-api/users/#full-user-profile-object).
+
+Every route that reads or writes a connection is user-only. A bot token is rejected with 403 `ACCESS_DENIED`, and so is an OAuth2 bearer except on [List connections](#list-connections), which accepts a bearer with the `connections` [scope](/http-api/oauth2/#oauth2-scopes). An account with an outstanding required action is rejected with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. No connection identifier is a snowflake.
+
+[Get Bluesky client metadata](#get-bluesky-client-metadata) and [Get Bluesky JWKS](#get-bluesky-jwks) are the exception. They read no credential, publish no account data, and serve the atproto authorisation servers the [Bluesky authorisation flow](#start-bluesky-authorisation) talks to.
+
+## Account connection limit
+
+An account holds at most 20 connections in total across every [connection type](#connection-types). Fluxer reports the ceiling as 400 `CONNECTION_LIMIT_REACHED` with an [error response](/http-api/#error-response) body.
+
+[Initiate connection](#initiate-connection), [Verify and create connection](#verify-and-create-connection), and the provider callback that completes the [Bluesky authorisation flow](#start-bluesky-authorisation) each enforce the ceiling. [Start Bluesky authorisation](#start-bluesky-authorisation) does not.
+
+## Connection object
+
+The pair of [type](#connection-types) and `id` identifies a connection. Both values appear in the path of every per-connection operation. A connection exists only after its external proof has succeeded once.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | The ID of the connection, unique within its [connection type](#connection-types) |
+| type | string | [Connection type](#connection-types) |
+| name2 | string | The domain or Bluesky handle shown for the connection |
+| verified3 | boolean | Whether the most recent proof succeeded |
+| visibility_flags4 | integer | [Connection visibility flags](#connection-visibility-flags) |
+| sort_order5 | integer | The display order within the account's connection list (0-2147483647) |
+
+1 A `domain` connection identifier is 64 lowercase hexadecimal characters, the SHA-256 of the owning account identifier and the lowercased domain joined by one ASCII colon, so deleting and recreating the same domain reproduces the same identifier. A `bsky` connection identifier is a random UUID assigned when the connection is first created
+
+2 For a `domain` connection the name is the domain exactly as it was submitted. For a `bsky` connection it is the Bluesky handle resolved at the most recent completion of the [Bluesky authorisation flow](#start-bluesky-authorisation), so it changes when the account is renamed upstream and is not refreshed by [Verify connection](#verify-connection)
+
+3 A connection is created only after a proof succeeds, so the value starts true, and only a failed [Verify connection](#verify-connection) against an already verified connection sets it to false
+
+4 The stored value is the integer the client supplied and is not masked against the defined bits
+
+5 A newly created connection receives the number of connections the account already held, and [Update connection](#update-connection) and [Reorder connections](#reorder-connections) rewrite it
+
+A `domain` connection is identified by the submitted domain, which equals `name`. A `bsky` connection is identified by the account's decentralised identifier while `name` is the current handle, so the connection survives an upstream rename.
+
+## Connection types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| bsky1 | BLUESKY | A Bluesky account authorised through atproto OAuth |
+| domain | DOMAIN | A domain the account has proved it controls |
+
+1 [Initiate connection](#initiate-connection) rejects this type with 400 `BLUESKY_OAUTH_NOT_ENABLED` before it consults the connection ceiling. A Bluesky connection is created only by completing the flow that begins at [Start Bluesky authorisation](#start-bluesky-authorisation)
+
+## Connection visibility flags
+
+The flags of one connection decide which viewers of the owner's profile see it. Fluxer evaluates them against a viewer with the same rule as the [profile field privacy flags](/http-api/users/#profile-field-privacy-flags).
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | EVERYONE | Anyone permitted to view the profile can see the connection |
+| 1 << 1 | FRIENDS | Friends can see the connection |
+| 1 << 2 | MUTUAL_GUILDS | Members of a mutual guild can see the connection |
+
+A connection created without an explicit value receives EVERYONE. A value of `0` hides the connection from every viewer, including the owner's read of their own profile. A client MUST ignore a bit it does not recognise.
+
+An unverified connection is never rendered on a profile, whatever its flags. A viewer who is not permitted to see the full profile sees no connections at all. [List connections](#list-connections) ignores the flags and returns every connection the account holds.
+
+## Connection verification object
+
+Returned by [Initiate connection](#initiate-connection). No connection exists until [Verify and create connection](#verify-and-create-connection) succeeds.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token1 | string | The value to publish at the domain as proof of ownership |
+| type | string | [Connection type](#connection-types) |
+| id2 | string | The domain being verified |
+| instructions3 | string | The human-readable instructions for placing the token |
+| initiation_token4 | string | The signed token accepted by [Verify and create connection](#verify-and-create-connection) |
+
+1 The token is 64 lowercase hexadecimal characters. Repeating the operation for the same target returns the same token, so a client MAY leave a published record in place across attempts
+
+2 The value is the `identifier` exactly as submitted, with no trimming, lowercasing, or other normalisation
+
+3 The instructions name both the DNS TXT record and the well-known path described under [Domain ownership proof](#domain-ownership-proof)
+
+4 The value is a base64url JSON payload and its base64url HMAC-SHA256 signature separated by one ASCII full stop. It binds the account identifier, the type, the identifier, and the verification token, and has an expiry 30 minutes after issue
+
+:::caution[Anyone holding the initiation token can read it]
+The payload is ordinary base64url and has the verification token in the clear. Treat the initiation token as a credential and do not log or forward it.
+:::
+
+## Domain ownership proof
+
+Fluxer attempts a `domain` proof against the stored identifier exactly as it was submitted. [Initiate connection](#initiate-connection) accepts any string of 1 to 253 characters and applies no syntax check of its own, so a malformed value is accepted and then fails its proof.
+
+Fluxer first resolves the TXT records of `_fluxer.`. It issues the query in parallel to a fixed set of eight public DNS resolvers, each with a 2000 millisecond timeout and one attempt. The proof succeeds when any resolver returns a record whose concatenated strings equal `fluxer-verification=` exactly. A resolver that fails or times out is treated like an absent record and produces no separate error.
+
+When DNS does not prove ownership, Fluxer issues one HTTPS `GET` to `https:///.well-known/fluxer-verification` with a 5000 millisecond timeout. It follows up to 5 redirects. A sixth redirect fails the proof, as does a redirect response that has no `Location` header. The proof succeeds only when the status of the final response is in the 2xx range and the whitespace-trimmed body equals the token exactly. A response body over the 16384 byte ceiling fails the proof, and a declared `Content-Length` over that ceiling fails it before the body is read.
+
+The instance outbound URL policy applies to the initial request and to every redirect it follows. A redirect target must use the `http` or `https` scheme. Fluxer lowercases the host and strips one trailing dot before the check.
+
+A host that is an IP literal cannot sit in a loopback, private, shared, link-local, documentation, benchmarking, multicast, or otherwise reserved range. An IPv6 literal that embeds an IPv4 address is checked against the IPv4 ranges. A `domain` identifier that is a public IP literal skips the name checks below and can complete the HTTPS proof.
+
+Any other host must be a fully qualified domain name of at most 253 characters with at least one dot. Every label must be 1 to 63 characters of ASCII letters, digits, and hyphens that neither begins nor ends with a hyphen. The final label cannot consist only of digits, and every address the host resolves to must be a public internet address. A host that fails any of those checks fails the proof, so a domain pointed at a loopback, link-local, or private range is never provable this way.
+
+[Verify and create connection](#verify-and-create-connection) takes the token from the signed initiation token, and [Verify connection](#verify-connection) reuses the connection's stored verification token, so a recheck expects the same DNS record or document as the original proof.
+
+:::note[Only the DNS record has the prefix]
+The DNS record value has the `fluxer-verification=` prefix and the well-known document does not. A document that contains the prefixed form fails the HTTPS proof.
+:::
+
+## Bluesky authorisation object
+
+Returned by [Start Bluesky authorisation](#start-bluesky-authorisation).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| authorize_url1 | string | The atproto authorisation server URL the user is sent to |
+
+1 The URL is issued by the handle's own authorisation server, so its origin varies with the account being connected
+
+## List connections
+
+
+
+Returns every [connection object](#connection-object) the authenticated account holds, in ascending `sort_order`.1 An account with no connections returns an empty array.
+
+Fluxer checks the scope before it checks the account state, so a bearer without `connections` is rejected with 403 `MISSING_OAUTH_SCOPE` even when the account also has an outstanding required action.
+
+1 The sort is stable and has no secondary key, so two connections that share a `sort_order` are returned in their stored order, which is ascending by [connection type](#connection-types) and then descending by `id`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[connection](#connection-object) object] | Collection was returned, possibly as an empty array |
+| 403 | [error response](/http-api/#error-response) | The bearer token lacks the `connections` scope and the request returns `MISSING_OAUTH_SCOPE` |
+
+:::caution[The Gateway array is not sorted]
+Every [User Connections Update](/gateway/events/#user-connections-update) Dispatch has the complete connection list in stored order, so a client MUST sort it by `sort_order` itself.
+:::
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `connection:list` bucket.
+
+## Initiate connection
+
+
+
+Begins domain ownership verification and returns a [connection verification object](#connection-verification-object). This operation creates no connection.
+
+Only the `domain` type is accepted. A Bluesky connection is created through [Start Bluesky authorisation](#start-bluesky-authorisation).
+
+Fluxer checks the requested type first, then the 20-connection ceiling, and then the duplicate identifier. An account at the ceiling receives the ceiling failure even when the submitted identifier would also have been a duplicate.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | string | [Connection type](#connection-types), which must be domain |
+| identifier | string | The domain to prove ownership of (1-253 characters) |
+| visibility_flags?1 | integer | [Connection visibility flags](#connection-visibility-flags) (0-2147483647) |
+
+1 Accepted by the schema and then ignored, because [Verify and create connection](#verify-and-create-connection) chooses the visibility when it creates the connection
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | [connection verification](#connection-verification-object) object | Verification was initiated |
+| 400 | [error response](/http-api/#error-response) | The requested type is bsky and the request returns `BLUESKY_OAUTH_NOT_ENABLED` |
+| 409 | [error response](/http-api/#error-response) | A connection of the same type already exists for that identifier, compared case-insensitively, and the request returns `CONNECTION_ALREADY_EXISTS` |
+
+### Side effects
+
+This operation creates no connection and emits no Gateway Dispatch. It returns a signed initiation token that expires 30 minutes after issue and a deterministic verification token that the caller MUST publish at the domain before calling [Verify and create connection](#verify-and-create-connection).
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `connection:create` bucket, which is shared with [Start Bluesky authorisation](#start-bluesky-authorisation).
+
+## Verify and create connection
+
+
+
+Checks the [domain ownership proof](#domain-ownership-proof) described by a signed initiation token. Creates and returns the [connection object](#connection-object) on success.
+
+The type, identifier, and verification token all come from the signed token, so a caller cannot verify a target it did not initiate. Fluxer re-evaluates the 20-connection ceiling and the duplicate identifier before it attempts the proof, and a target that became a duplicate since the token was issued is refused with no DNS or HTTPS lookup.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| initiation_token | string | The signed initiation token returned by [Initiate connection](#initiate-connection) |
+| visibility_flags?1 | integer | [Connection visibility flags](#connection-visibility-flags) (0-2147483647) |
+
+1 An omitted value stores EVERYONE
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | [connection](#connection-object) object | Proof succeeded and the connection was created |
+| 4001 | [error response](/http-api/#error-response) | The initiation token is not usable and the request returns `CONNECTION_INITIATION_TOKEN_INVALID` |
+| 403 | [error response](/http-api/#error-response) | The proof failed and the request returns `CONNECTION_VERIFICATION_FAILED` |
+| 409 | [error response](/http-api/#error-response) | A connection of the same type already exists for that identifier, compared case-insensitively, and the request returns `CONNECTION_ALREADY_EXISTS` |
+
+1 A malformed token, a token whose HMAC does not verify, an expired token, and a token issued to another account all produce the same code, so a caller cannot distinguish them
+
+### Side effects
+
+A successful proof creates one connection with `verified` set to true, `sort_order` set to the number of connections the account already held, and `visibility_flags` set to the supplied value or EVERYONE. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. A failed proof creates no connection and emits no Dispatch.
+
+The initiation token stays usable until it expires, so a caller that publishes its proof late can retry with the same token.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `connection:verify_and_create` bucket.
+
+## Update connection
+
+
+
+Updates the visibility or display order of one existing connection and returns 204 with an empty body. Omitting a field leaves the stored value unchanged.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | string | [Connection type](#connection-types) |
+| connection_id | string | The ID of the connection, unique within its [connection type](#connection-types) |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| visibility_flags? | integer | [Connection visibility flags](#connection-visibility-flags) (0-2147483647) |
+| sort_order? | integer | The new display order (0-2147483647) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Connection was updated |
+| 4041 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` |
+
+1 Both path parameters must name the same connection, so a correct identifier under the wrong type does not match
+
+### Side effects
+
+The supplied fields replace the corresponding connection values. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. The Dispatch is emitted even when every supplied value already matched, and even when the body supplies no field at all.
+
+Assigning a `sort_order` that another connection already holds is permitted. [List connections](#list-connections) then resolves the tie by stored order.
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `connection:update` bucket, which is shared with [Reorder connections](#reorder-connections).
+
+## Delete connection
+
+
+
+Permanently removes one connection and returns 204 with an empty body.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | string | [Connection type](#connection-types) |
+| connection_id | string | The ID of the connection, unique within its [connection type](#connection-types) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Connection was deleted |
+| 404 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` |
+
+### Side effects
+
+The connection is deleted and the remaining connection list is published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. Deletion does not renumber the `sort_order` of the remaining connections, so the surviving values can have a gap.
+
+Deleting a `bsky` connection does not revoke its stored atproto authorisation, which is retained for 24 hours after it was last written and then expires on its own.
+
+:::caution[Deletion is immediate]
+There is no grace period and no restore operation. Recreating a `domain` connection requires a fresh [Initiate connection](#initiate-connection) and a republished proof, and recreating a `bsky` connection requires completing the OAuth flow again.
+:::
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `connection:delete` bucket.
+
+## Verify connection
+
+
+
+Rechecks the external proof of an existing connection and returns the resulting [connection object](#connection-object).
+
+A `domain` connection is rechecked against the [domain ownership proof](#domain-ownership-proof) using its stored identifier and stored verification token. A `bsky` connection is rechecked by restoring the stored atproto session for its decentralised identifier and resolving that account's current public handle.
+
+A `bsky` recheck succeeds only while the stored atproto session survives. That session is retained for 24 hours after it was last written, so a connection whose session has expired fails the recheck until the user completes [Start Bluesky authorisation](#start-bluesky-authorisation) again.
+
+:::note[A recheck never renames a connection]
+The handle a `bsky` recheck resolves proves the session is live and is then discarded. Only completing the [Bluesky authorisation flow](#start-bluesky-authorisation) writes `name`.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | string | [Connection type](#connection-types) |
+| connection_id | string | The ID of the connection, unique within its [connection type](#connection-types) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [connection](#connection-object) object | Proof succeeded |
+| 4001 | [error response](/http-api/#error-response) | A bsky connection is rechecked on an instance with no Bluesky OAuth client and the request returns `BLUESKY_OAUTH_NOT_ENABLED` |
+| 403 | [error response](/http-api/#error-response) | The proof failed and the request returns `CONNECTION_VERIFICATION_FAILED` |
+| 404 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` |
+
+1 Every other recheck failure is reported as a failed proof. The route reads the configured OAuth client alone and never consults the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled`
+
+### Side effects
+
+A successful proof sets `verified` to true, refreshes the last-verified timestamp, and sets the original verification timestamp to now when the connection had none. It then publishes the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions.
+
+A failed proof against a connection that was verified sets `verified` to false, clears the original verification timestamp, and refreshes the last-verified timestamp. A failed proof against a connection that was already unverified writes nothing. Both publish the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch, so every session of the account observes the current `verified` value.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `connection:verify` bucket.
+
+## Reorder connections
+
+
+
+Assigns the display order of the listed connections from their position in the array and returns 204 with an empty body.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| connection_ids12 | array[string] | The connection IDs in their new display order (1-20 entries) |
+
+1 An entry that names no connection owned by the caller is skipped without an error, and a connection that is not named keeps its current order, so a partial array reorders only the connections it names
+
+2 An entry is matched by `id` alone, and a repeated entry is applied once for each occurrence, so the last occurrence of a repeated identifier decides its final order
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Connections were reordered |
+
+### Side effects
+
+Entries are applied in array order and each named connection receives its array index as the new `sort_order`. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions, even when the order was already identical.
+
+Fluxer writes every `sort_order` in one atomic batch. A write that fails returns 500, applies no entry, and publishes no Dispatch.
+
+A partial array can leave two connections sharing a `sort_order`, which [List connections](#list-connections) resolves by stored order.
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `connection:update` bucket, which is shared with [Update connection](#update-connection).
+
+## Start Bluesky authorisation
+
+
+
+Normalises a Bluesky handle, begins the atproto OAuth authorisation flow, and returns a [Bluesky authorisation object](#bluesky-authorisation-object) whose URL the user is sent to. This operation creates no connection.
+
+Fluxer trims surrounding whitespace, then removes a leading `https://bsky.app/profile/` or `http://bsky.app/profile/` prefix matched case-insensitively, then removes one leading `@`. Everything that remains becomes the handle verbatim, so a profile URL with a further path segment submits that segment as part of the handle and fails upstream resolution.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| handle | string | The Bluesky handle or profile URL to connect (1-253 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | [Bluesky authorisation](#bluesky-authorisation-object) object | Authorisation was initiated |
+| 4002 | [error response](/http-api/#error-response) | Bluesky connections are unavailable on this instance and the request returns `BLUESKY_OAUTH_NOT_ENABLED`, or the flow could not be started and the request returns `BLUESKY_OAUTH_AUTHORIZATION_FAILED` |
+| 4093 | [error response](/http-api/#error-response) | A bsky connection whose name equals the normalised handle already exists and the request returns `CONNECTION_ALREADY_EXISTS` |
+
+1 This operation does not consult the 20-connection ceiling, which is enforced only when the provider callback creates the connection
+
+2 Every authorisation failure other than an unavailable integration collapses into one code, including an unresolvable handle, a handle whose authorisation server is unreachable, and a rejected client registration, so a client MUST NOT derive a remedy from it
+
+3 The comparison is case-insensitive and runs against the stored connection `name`, so an account whose upstream handle has since changed is not detected as a duplicate here and refreshes its existing connection at the callback
+
+:::note[Two independent availability gates]
+This operation requires both a configured Bluesky OAuth client and the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled`. A missing client and a cleared flag both produce `BLUESKY_OAUTH_NOT_ENABLED`, and the response does not say which.
+:::
+
+### Side effects
+
+The authorisation state is stored for one hour and identifies the account when the unauthenticated provider callback arrives. No connection is created and no Gateway Dispatch is emitted.
+
+:::caution[The authorisation state is single-use]
+The provider callback reads and deletes the state in one operation. A replay and a callback more than one hour after issue both redirect with `callback_failed`.
+:::
+
+### Completing the flow
+
+The returned `authorize_url` is an atproto authorisation server URL. The client sends the user there, the user approves the request, and the authorisation server calls the instance's registered redirect URI, `/connections/bluesky/callback` on the `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object). A client MUST NOT call that route directly.
+
+Fluxer consumes the state, exchanges the authorisation code for an atproto session, resolves the account's decentralised identifier and current handle, writes the connection, and then redirects the browser to `/connection-callback` on the `webapp` [instance endpoint](/http-api/instance/#instance-endpoints-object).
+
+That redirect has `status=connected` on success. On failure it has `status=error` together with one `reason` value.
+
+| Value | Description |
+| --- | --- |
+| not_enabled | The instance has no configured Bluesky OAuth client |
+| state_invalid1 | The failure message names a state or an expiry |
+| callback_failed2 | Any other failure of the callback itself |
+| unknown3 | Any failure after the callback itself succeeded |
+
+1 A callback arriving with no `state` query parameter reports `state_invalid`, and a callback whose stored state is gone does not
+
+2 A replayed callback and a callback more than one hour after issue both land here
+
+3 An account already holding 20 connections lands here, as does any unexpected internal failure, so a client MUST NOT derive a remedy from this value
+
+On success, an account that has no `bsky` connection for that decentralised identifier receives a new one with `verified` set to true, EVERYONE visibility, and the number of connections it already held as its order. An account that already has one keeps its identifier and `sort_order`, has its `name` rewritten to the current handle, and has `verified` set back to true. Either path publishes the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to that account's own sessions.
+
+Completing the flow is the only way a `bsky` connection `name` is written, so it is the only way to reconcile an upstream handle rename. It renews the 24-hour atproto session that [Verify connection](#verify-connection) rechecks.
+
+The callback does not consult the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled`. An authorisation started while the flag was set completes normally after an operator clears it, as long as the OAuth client is still configured.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `connection:create` bucket, which is shared with [Initiate connection](#initiate-connection).
+
+## Get Bluesky client metadata
+
+
+
+Returns the atproto OAuth client metadata document that registers this instance with every atproto authorisation server.
+
+The URL of this document is the `client_id` of the client. The authorisation server named by a handle fetches it while [Start Bluesky authorisation](#start-bluesky-authorisation) resolves that handle, and reads the redirect URI, the scope, and the client authentication method out of it. An instance that does not serve it to the public internet fails every authorisation with `BLUESKY_OAUTH_AUTHORIZATION_FAILED`. An instance that does not serve [Get Bluesky JWKS](#get-bluesky-jwks) authorises normally and then redirects with `callback_failed`, because the key set is read later, at the code exchange.
+
+Both documents are served whenever the instance has a configured Bluesky OAuth client. Clearing the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled` stops [Start Bluesky authorisation](#start-bluesky-authorisation) and leaves both documents in place.
+
+### Response body
+
+The document is the same for every request. Its URLs are built from the `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object), its display members come from instance configuration, and the protocol members are fixed.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| client_id | string | The URL this document is served from, which is also the atproto identifier of the client |
+| client_name | string | The name an authorisation server displays to the account holder |
+| client_uri | string | The `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object) with any trailing slash removed |
+| logo_uri?1 | string | The logo an authorisation server displays to the account holder |
+| tos_uri?1 | string | The terms of service document offered to the account holder |
+| policy_uri?1 | string | The privacy policy document offered to the account holder |
+| redirect_uris | array[string] | The one URL an authorisation server may return to, `/connections/bluesky/callback` on `api_public` |
+| grant_types | array[string] | The two grants the client uses, `authorization_code` and `refresh_token` |
+| response_types | array[string] | The one response type the client accepts, `code` |
+| scope | string | The scope requested at authorisation, `atproto` |
+| application_type | string | The client type, `web` |
+| subject_type | string | The subject identifier type, `public` |
+| token_endpoint_auth_method | string | The way the client authenticates at the token endpoint, `private_key_jwt` |
+| token_endpoint_auth_signing_alg | string | The algorithm the client signs that assertion with, `ES256` |
+| authorization_signed_response_alg | string | The algorithm the client expects a signed authorisation response in, `RS256` |
+| dpop_bound_access_tokens | boolean | Whether the client binds its access tokens to a DPoP proof, always true |
+| jwks_uri | string | The URL of [Get Bluesky JWKS](#get-bluesky-jwks) |
+
+1 Present only when the operator configured a non-empty value
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | client metadata document | Instance has a configured Bluesky OAuth client |
+| 4041 | object | Instance has no configured Bluesky OAuth client |
+
+1 The body is `{"error": "Bluesky OAuth is not enabled"}`, so it has no `code` and no `errors` member. The client is unconfigured when the operator disabled Bluesky, when no signing key is configured, and when a configured key was rejected at load
+
+### Rate limit
+
+60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, which is shared with [Get Bluesky JWKS](#get-bluesky-jwks).
+
+## Get Bluesky JWKS
+
+
+
+Returns the JSON Web Key Set holding the public half of every configured Bluesky OAuth signing key.
+
+An atproto authorisation server reads this document, named by `jwks_uri` in [Get Bluesky client metadata](#get-bluesky-client-metadata), to verify the `private_key_jwt` assertion the instance signs at the token endpoint. The server fetches it at the code exchange that completes the flow, and again at every session refresh, long after the account holder has left the browser.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| keys | array[object] | The public half of each configured signing key, in configuration order |
+
+The array holds at least one entry on any 200. Each entry has the public members of one ES256 key. There is no `alg` and no `use`, so a verifier that demands either one rejects the set.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| kty | string | The key type, `EC` |
+| kid | string | The identifier the operator gave the key, which the signed assertion names in its header |
+| key_ops | array[string] | The operations the public key permits, `verify`, `encrypt`, and `wrapKey` |
+| crv | string | The curve, `P-256` |
+| x | string | The base64url encoded X coordinate |
+| y | string | The base64url encoded Y coordinate |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | JSON Web Key Set | Instance has a configured Bluesky OAuth client |
+| 4041 | object | Instance has no configured Bluesky OAuth client |
+
+1 The body is `{"error": "Bluesky OAuth is not enabled"}`, so it has no `code` and no `errors` member
+
+### Rate limit
+
+60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, which is shared with [Get Bluesky client metadata](#get-bluesky-client-metadata).
diff --git a/fluxer_docs/src/content/docs/http-api/deployment-availability.md b/fluxer_docs/src/content/docs/http-api/deployment-availability.md
new file mode 100644
index 000000000..87b20027d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/deployment-availability.md
@@ -0,0 +1,67 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Deployment availability
+description: The hosted-only routes and the instance flags that report deployment kind.
+---
+
+A small set of routes exists only on the deployment Fluxer hosts. An operator runs the same release, and most of the HTTP API is identical on both.
+
+A self-hosted deployment does not register those routes. A request to one returns 404 `NOT_FOUND` with no feature-specific code. A caller cannot tell an unavailable route from an unrecognised path.
+
+The API decides registration once at process start from the deployment configuration. No credential, permission, premium state, or OAuth2 scope changes the answer. A client resolves the deployment kind from instance discovery.
+
+## Deployment kind
+
+Every deployment reports its kind in `self_hosted` on the [instance features object](/http-api/instance/#instance-features-object), which the unauthenticated [instance discovery document](/http-api/instance/#get-instance-discovery) publishes before a client holds any credential. `self_hosted` alone decides whether the API registers the routes below.
+
+`stripe_enabled` on the same object reports the payment provider toggle alone. A hosted deployment that reports it false still serves every route in the table below. A deployment reporting `stripe_enabled` true with no provider secret key configured behaves exactly like one reporting it false.
+
+:::caution[Read `self_hosted` for the deployment kind]
+Neither flag promises that a provider-dependent operation succeeds.
+:::
+
+Without a provider client, the answer depends on the operation. An operation that has to reach the provider fails with 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, and [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) fails with 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`. Three read operations report the absence in a 200 body instead. [Get refund eligibility](/http-api/billing/#get-refund-eligibility) reports `eligible` false with the reason `feature_unavailable`, [Get current subscription price](/http-api/premium/#get-current-subscription-price) reports null, and [Get price IDs](/http-api/premium/#get-price-ids) reports the configured price IDs with every amount null.
+
+## Hosted-only routes
+
+| Method | Route | Operation |
+| --- | --- | --- |
+| POST | /v1/donations/request-link | [Request donation management link](/http-api/donations/#request-donation-management-link) |
+| GET | /v1/donations/manage | [Manage donation](/http-api/donations/#manage-donation) |
+| POST | /v1/donations/checkout | [Create donation checkout](/http-api/donations/#create-donation-checkout) |
+| GET | /v1/gifts/{code} | [Get gift](/http-api/gifts/#get-gift) |
+| POST | /v1/gifts/{code}/redeem | [Redeem gift](/http-api/gifts/#redeem-gift) |
+| GET | /v1/users/@me/gifts1 | [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) |
+| POST | /v1/stripe/checkout/subscription | [Create subscription checkout](/http-api/billing/#create-subscription-checkout) |
+| POST | /v1/stripe/checkout/subscription/preapproval | [Create localised card preapproval](/http-api/billing/#create-localised-card-preapproval) |
+| POST | /v1/stripe/checkout/subscription/preapproval/continue | [Continue localised card preapproval](/http-api/billing/#continue-localised-card-preapproval) |
+| POST | /v1/stripe/checkout/gift | [Create gift checkout](/http-api/billing/#create-gift-checkout) |
+| POST | /v1/stripe/webhook2 | [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) |
+| POST | /v1/users/@me/age-verification1 | [Create age verification session](/http-api/billing/#create-age-verification-session) |
+| GET | /v1/premium/refund-eligibility3 | [Get refund eligibility](/http-api/billing/#get-refund-eligibility) |
+| POST | /v1/premium/refund-latest | [Refund latest purchase](/http-api/billing/#refund-latest-purchase) |
+| GET | /v1/premium/price-ids | [Get price IDs](/http-api/premium/#get-price-ids) |
+| GET | /v1/premium/current-subscription-price4 | [Get current subscription price](/http-api/premium/#get-current-subscription-price) |
+| POST | /v1/premium/customer-portal | [Create customer portal](/http-api/premium/#create-customer-portal) |
+| POST | /v1/premium/grace/end | [End premium grace period](/http-api/premium/#end-premium-grace-period) |
+| POST | /v1/premium/cancel-subscription | [Cancel subscription](/http-api/premium/#cancel-subscription) |
+| POST | /v1/premium/reactivate-subscription | [Reactivate subscription](/http-api/premium/#reactivate-subscription) |
+| POST | /v1/premium/change-subscription | [Change subscription billing cycle](/http-api/premium/#change-subscription-billing-cycle) |
+| POST | /v1/premium/cancel-pending-subscription-change | [Cancel pending subscription change](/http-api/premium/#cancel-pending-subscription-change) |
+| POST | /v1/premium/visionary/rejoin | [Rejoin Visionary guild](/http-api/premium/#rejoin-visionary-guild) |
+
+1 These two are the only `/users/@me` routes a self-hosted deployment does not serve
+
+2 The webhook takes no credential and is authenticated by the provider signature header alone
+
+3 The same object appears as `billing.refund_eligibility` on [Get premium state](/http-api/premium/#get-premium-state), which every deployment serves, and there a self-hosted deployment reports `eligible` false with the reason `feature_unavailable`
+
+4 The same object appears as `billing.current_subscription_price` on [Get premium state](/http-api/premium/#get-premium-state), which every deployment serves
+
+## Registered routes that resolve differently
+
+A route every deployment registers can still produce a different answer on a self-hosted instance. Each operation page documents that difference.
+
+Premium state is the clearest case. Every deployment registers [Get premium state](/http-api/premium/#get-premium-state) and [Set premium perks disabled](/http-api/premium/#set-premium-perks-disabled). A self-hosted instance still reports premium state and still records the perks-disabled flag. The response repeats the deployment kind in `self_hosted` on the [effective premium state object](/http-api/premium/#effective-premium-state-object). That flag alone does not make `is_premium` true, because a self-hosted deployment grants premium to every account only while its instance [premium mode](/admin-api/instance/#premium-modes) is `everyone`. While that mode is in force it also overrides the perks-disabled flag, so `is_premium` stays true while `premium_perks_disabled` is true.
+
+The other instance feature flags published by [instance discovery](/http-api/instance/#instance-features-object) work the same way. `voice_enabled`, `presigned_attachment_uploads`, and `emails_enabled` each switch off a capability that the surrounding routes still expose, so a client reads the flag.
diff --git a/fluxer_docs/src/content/docs/http-api/discovery.mdx b/fluxer_docs/src/content/docs/http-api/discovery.mdx
new file mode 100644
index 000000000..815d6fa71
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/discovery.mdx
@@ -0,0 +1,574 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Discovery
+description: Guild discovery listings, the application lifecycle, and joining an approved guild.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Discovery is the public directory of guilds any account can browse and join. A guild manager applies to have a guild listed, and an operator reviews that application through the [Admin Discovery API](/admin-api/discovery/).
+
+[Join discovery guild](#join-discovery-guild) is user-only and rejects a bot token with 403 `ACCESS_DENIED`. Every other route accepts a user session token or a bot token.
+
+On a guild an operator has marked unavailable, Fluxer refuses [Apply for discovery](#apply-for-discovery), [Edit discovery application](#edit-discovery-application), [Withdraw discovery application](#withdraw-discovery-application), and [Get discovery status](#get-discovery-status) with 403 `MISSING_ACCESS` before the route runs. The gate does not cover [Join discovery guild](#join-discovery-guild).
+
+The four routes under `/v1/guilds/{guild_id}/discovery` require [MANAGE_GUILD](/http-api/permissions/), which is an [elevated permission](/http-api/permissions/#elevated-permissions). While the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated, a caller other than the owner also needs an enrolled authenticator, and receives 400 `TWO_FACTOR_REQUIRED` without one. A guild that does not exist returns 404 `UNKNOWN_GUILD`. Both a non-member and a member without the permission return 403 `MISSING_PERMISSIONS`. The routes answer 503 `SERVICE_UNAVAILABLE` when the check cannot be admitted, and 504 `GATEWAY_TIMEOUT` when it does not answer in time.
+
+:::note[Discovery is an optional instance capability]
+Searching, applying, editing, withdrawing, and joining fail with 400 `DISCOVERY_DISABLED` while an operator has it disabled. [List discovery categories](#list-discovery-categories) and [Get discovery status](#get-discovery-status) keep answering.
+:::
+
+## Discovery guild object
+
+One search result. Every field except the two counts is read from the discovery search index.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild at index time |
+| icon3 | ?string | The icon hash of the guild, or null when it stores none |
+| banner3 | ?string | The banner hash of the guild, or null when it stores none |
+| description1 | ?string | The description supplied on the application, or null |
+| category_type1 | integer | The [discovery category](#discovery-categories) the listing is filed under |
+| primary_language1 | ?string | The [supported primary language](#supported-primary-languages) code of the listing, or null |
+| custom_tags1 | array[string] | The normalised [custom tags](#custom-tags) of the listing |
+| member_count2 | integer | The current member count of the guild |
+| online_count2 | integer | The current online member count of the guild |
+| features | array[string] | The [guild features](/http-api/guilds/#guild-features) the guild has |
+| verification_level4 | integer | The effective [verification level](/http-api/guilds/#verification-levels) of the guild |
+
+1 The four listing fields come from the guild's approved application as it stood at index time, and an entry that stores no category is reported as category `0`
+
+2 Refreshed from the main Gateway at response time. When that refresh fails, the operation still succeeds and reports the indexed member count with an `online_count` of `0`
+
+3 An animated hash retains its `a_` prefix, which is the animation indicator for this object
+
+4 A listed guild is reported at least at level `1`, so a guild that stores level `0` is reported as `1` while it remains discoverable
+
+:::note[Index values lag the guild record]
+A guild that changed its name, icon, banner, features, or verification level since the last synchronisation is still reported with the indexed values, for up to 15 minutes.
+:::
+
+### Example
+
+```json
+{
+ "id": "1489002177550843904",
+ "name": "Example guild",
+ "icon": "a_9f1c2d3e4b5a60718293a4b5c6d7e8f9",
+ "banner": null,
+ "description": "A place to talk about the example project.",
+ "category_type": 4,
+ "primary_language": "en-US",
+ "custom_tags": ["open source", "rust"],
+ "member_count": 4120,
+ "online_count": 318,
+ "features": ["DISCOVERABLE"],
+ "verification_level": 1
+}
+```
+
+## Discovery search result object
+
+One page of matching listings, together with the total and the per-category counts.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guilds | array[[discovery guild](#discovery-guild-object) object] | The matching approved guilds on this page of results |
+| total1 | integer | The total number of guilds matching the query |
+| category_counts2 | array[[discovery category count](#discovery-category-count-object) object] | The match count for each category under the current filters |
+
+1 The count describes the complete match set, so it bounds paging through `offset`
+
+2 Computed with the `category` filter removed and every other filter applied, so the counts describe what selecting a different category would return. A category with no match is omitted, and the array is ordered by ascending category
+
+## Discovery category count object
+
+One category and the number of listings it holds under the current filters.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| category_type | integer | The [discovery category](#discovery-categories) counted |
+| count | integer | The number of matching guilds in that category |
+
+## Discovery application object
+
+One guild's discovery listing and review state. A guild has at most one application. A rejected or removed application remains readable with its outcome and reason until the guild applies again or withdraws it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| guild_nsfw_level?1 | ?integer | The [NSFW level](/http-api/guilds/#nsfw-levels) of the guild |
+| status | string | The [application status](#discovery-application-statuses) of the listing |
+| description | string | The description shown on the listing |
+| category_type | integer | The [discovery category](#discovery-categories) the listing is filed under |
+| primary_language2 | ?string | The [supported primary language](#supported-primary-languages) code of the listing, or null |
+| custom_tags | array[string] | The normalised [custom tags](#custom-tags) of the listing |
+| applied_at | ISO8601 timestamp | The time at which the application was submitted |
+| reviewed_at3 | ?ISO8601 timestamp | The time at which the application was approved or rejected, or null |
+| review_reason4 | ?string | The reason recorded with the approval or rejection, or null |
+| removed_at5 | ?ISO8601 timestamp | The time at which an approved listing was removed, or null |
+| removal_reason5 | ?string | The reason recorded with the removal, or null |
+
+1 No route here populates the field. The [guild object](/http-api/guilds/#guild-object) has the guild's NSFW level
+
+2 An application that supplies no language is stored with the default `en-US`, so the public routes never produce the null form
+
+3 Set at review time. An application approved automatically on submission has its submission time here
+
+4 Always null for an automatic approval
+
+5 Set only when an operator removes an approved listing. A guild that withdraws its own application deletes the record
+
+### Example
+
+```json
+{
+ "guild_id": "1489002177550843904",
+ "status": "pending",
+ "description": "A place to talk about the example project.",
+ "category_type": 4,
+ "primary_language": "en-US",
+ "custom_tags": ["open source", "rust"],
+ "applied_at": "2026-08-04T11:22:19.000Z",
+ "reviewed_at": null,
+ "review_reason": null,
+ "removed_at": null,
+ "removal_reason": null
+}
+```
+
+## Discovery status object
+
+The application state of one guild and its current eligibility to apply.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application | ?[discovery application](#discovery-application-object) object | The current application of the guild, or null when it has never applied or has withdrawn |
+| eligible1 | boolean | Whether the guild currently satisfies the requirement to apply |
+| min_member_count2 | integer | The number of members the instance currently requires |
+
+1 False whenever discovery is disabled for the instance, whatever the guild would otherwise satisfy
+
+2 Read from instance configuration, so it is identical for every guild and defaults to `1`
+
+### Example
+
+```json
+{
+ "application": null,
+ "eligible": true,
+ "min_member_count": 1
+}
+```
+
+## Discovery category object
+
+One entry of the fixed category registry, as [List discovery categories](#list-discovery-categories) returns it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | The [discovery category](#discovery-categories) value |
+| name | string | The display name of the category |
+
+## Discovery categories
+
+The release fixes the set, so an operator cannot add, rename, or remove a category.
+
+| Value | Name | Display name |
+| --- | --- | --- |
+| 0 | GAMING | Gaming |
+| 1 | MUSIC | Music |
+| 2 | ENTERTAINMENT | Entertainment |
+| 3 | EDUCATION | Education |
+| 4 | SCIENCE_AND_TECHNOLOGY | Science & Technology |
+| 5 | CONTENT_CREATOR | Content Creator |
+| 6 | ANIME_AND_MANGA | Anime & Manga |
+| 7 | MOVIES_AND_TV | Movies & TV |
+| 8 | OTHER | Other |
+
+Every category value on the wire is one of these integers.
+
+A value outside 0 through 8 fails body or query validation with the validation code `INVALID_FORMAT`, so [Apply for discovery](#apply-for-discovery) and [Edit discovery application](#edit-discovery-application) reject it before the listing is stored.
+
+The display names above are the strings [List discovery categories](#list-discovery-categories) returns. They are not localised, so the same ID has the same name for every account.
+
+## Discovery application statuses
+
+| Value | Description |
+| --- | --- |
+| pending | Application has been submitted and awaits an operator decision |
+| approved | Guild is listed and has the discoverable feature |
+| rejected | Application was refused, and the guild can submit a new one |
+| removed | Approved listing was withdrawn by an operator, and the guild can submit a new one |
+
+A guild holding a pending or approved application cannot submit another. A guild whose application is rejected or removed can apply again, and the new submission replaces the previous record entirely.
+
+## Supported primary languages
+
+Fluxer stores one primary language on each listing, drawn from the closed set below. The set is specific to discovery, and the client [locale registry](/topics/locales/#supported-locales) does not apply here. An application that supplies no language is stored as `en-US`.
+
+| Value | Name | Native name |
+| --- | --- | --- |
+| ar | Arabic | العربية |
+| bg | Bulgarian | Български |
+| cs | Czech | Čeština |
+| da | Danish | Dansk |
+| de | German | Deutsch |
+| el | Greek | Ελληνικά |
+| en-US | English | English |
+| es-ES | Spanish (Spain) | Español (España) |
+| es-419 | Spanish (Latin America) | Español (Latinoamérica) |
+| fi | Finnish | Suomi |
+| fr | French | Français |
+| he | Hebrew | עברית |
+| hi | Hindi | हिन्दी |
+| hr | Croatian | Hrvatski |
+| hu | Hungarian | Magyar |
+| id | Indonesian | Bahasa Indonesia |
+| it | Italian | Italiano |
+| ja | Japanese | 日本語 |
+| ko | Korean | 한국어 |
+| lt | Lithuanian | Lietuvių |
+| nl | Dutch | Nederlands |
+| no | Norwegian | Norsk |
+| pl | Polish | Polski |
+| pt-BR | Portuguese (Brazil) | Português (Brasil) |
+| ro | Romanian | Română |
+| ru | Russian | Русский |
+| sv-SE | Swedish | Svenska |
+| th | Thai | ไทย |
+| tr | Turkish | Türkçe |
+| uk | Ukrainian | Українська |
+| vi | Vietnamese | Tiếng Việt |
+| zh-CN | Chinese (Simplified) | 中文 (简体) |
+| zh-TW | Chinese (Traditional) | 中文 (繁體) |
+
+## Custom tags
+
+A listing has up to ten custom tags. Fluxer normalises a tag by trimming it, lowercasing it, and collapsing each run of whitespace to one space. Both the submitted string and its normalised form must be 2 through 30 characters, so a submitted string longer than 30 characters is rejected even when trimming would bring it inside the bound. The normalised form must begin with a letter or digit and otherwise contain only letters, digits, spaces, hyphens, underscores, plus signs, and ampersands.
+
+Duplicate normalised values collapse to the first occurrence. A submitted array longer than ten entries fails body validation. An entry that fails these constraints is rejected against its own index.
+
+## Search discovery guilds
+
+
+
+Searches the approved discovery listings and returns a [discovery search result object](#discovery-search-result-object).
+
+The caller needs no permission and no relationship to the matched guilds.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| query?1 | string | The free-text query (at most 100 characters) |
+| category?2 | integer | The single [discovery category](#discovery-categories) results are restricted to (0-8) |
+| language?3 | string | The single [supported primary language](#supported-primary-languages) results are restricted to |
+| tag?4 | string | The single [custom tag](#custom-tags) results are restricted to (at most 30 characters) |
+| sort_by?5 | string | The result ordering, one of `member_count`, `online_count`, or `relevance` |
+| limit? | integer | The number of results per page (1-48, default 24) |
+| offset? | integer | The number of matches to skip (at least 0, default 0) |
+
+1 An omitted query matches every approved listing, so the operation doubles as a browse of the complete directory
+
+2 A value outside 0 through 8 is rejected by query validation
+
+3 A language outside the [supported set](#supported-primary-languages) is rejected by query validation
+
+4 Trimmed, lowercased, and whitespace-normalised like a [custom tag](#custom-tags), so equivalent spellings match and a value empty after trimming applies no filter
+
+5 Only `member_count` selects a distinct ordering. `online_count`, `relevance`, and an omitted field all order by relevance. Both orderings are descending
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery search result](#discovery-search-result-object) object | Search completed, possibly with no match |
+| 400 | [error response](/http-api/#error-response) | Discovery is disabled for the instance and the request returns `DISCOVERY_DISABLED` |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | No search backend is configured for the instance and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+
+1 Only a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `discovery:search` bucket, which is not partitioned by query.
+
+## List discovery categories
+
+
+
+Returns an array of every [discovery category object](#discovery-category-object) in ascending `id` order, which is also the intended display order. The caller needs no permission.
+
+The route answers even while discovery is disabled for the instance.
+
+A client that wants a translated label supplies its own translation keyed on `id`, and MUST NOT invent a label for an ID this response does not have.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[discovery category](#discovery-category-object) object] | Categories were returned |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED`, or the account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+1 Only a bearer credential produces `ACCESS_DENIED`. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `discovery:categories` bucket.
+
+## Join discovery guild
+
+
+
+Admits the authenticated account into an approved discovery guild and returns 204 with an empty body. Emits [Guild Create](/gateway/events/#guild-create), [Guild Member Add](/gateway/events/#guild-member-add), [User Settings Update](/gateway/events/#user-settings-update), [User Guild Settings Update](/gateway/events/#user-guild-settings-update), and [Message Create](/gateway/events/#message-create) Gateway events.
+
+The caller needs no invite and no permission.
+
+:::caution[Only an approved listing admits]
+A pending, rejected, removed, or absent application fails with 400 `DISCOVERY_NOT_DISCOVERABLE`, even when the guild has the discoverable feature for another reason. A guild with invites disabled refuses admission with 403 `INVITES_DISABLED`.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the approved guild to join |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Account was admitted, or was already a member of the guild |
+| 400 | [error response](/http-api/#error-response) | Discovery is disabled for the instance and the request returns `DISCOVERY_DISABLED` |
+| 400 | [error response](/http-api/#error-response) | The guild has no approved application and the request returns `DISCOVERY_NOT_DISCOVERABLE` |
+| 400 | [error response](/http-api/#error-response) | The caller already holds the maximum number of guilds and the request returns `MAX_GUILDS` |
+| 400 | [error response](/http-api/#error-response) | The guild is full and the request returns `MAX_GUILD_MEMBERS` |
+| 4032 | [error response](/http-api/#error-response) | Caller is a bot, presents a bearer credential, or holds a revoked account and the request returns `ACCESS_DENIED` |
+| 4031 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The guild has the invites-disabled feature and the request returns `INVITES_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | The caller is banned from the guild directly or by address and the request returns `USER_BANNED_FROM_GUILD` or `USER_IP_BANNED_FROM_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The approved application names a guild whose record no longer exists and the request returns `UNKNOWN_GUILD` |
+
+1 An account whose phone requirement was deferred is re-evaluated against the target guild at join time, so an account that satisfies the standing check can still be refused here
+
+2 Only a bot token or a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+:::note[A guild that never existed returns 400, not 404]
+No approved application names it, so the request fails with `DISCOVERY_NOT_DISCOVERABLE` before any guild record is read.
+:::
+
+### Side effects
+
+An account that is already a member receives the same 204 response with no Dispatch. Otherwise the operation creates the membership, records discovery as its join source, and adds the guild to the caller's settings and folder layout.
+
+The joining account's sessions receive [Guild Create](/gateway/events/#guild-create). [Guild Member Add](/gateway/events/#guild-member-add) is dispatched guild-wide and reaches whichever sessions [event filtering](/gateway/event-filtering/) selects. A bot session always receives it, and a passive user session in a guild with more than 250 members does not receive it until [Lazy Request](/gateway/commands/#lazy-request) marks that guild active. The joining account receives [User Settings Update](/gateway/events/#user-settings-update) when the join changes its restricted guild set or its folder layout, and [User Guild Settings Update](/gateway/events/#user-guild-settings-update) when its account default hides muted channels.
+
+Unless join notifications are suppressed or the guild stores no system channel, the operation creates a [USER_JOIN](/http-api/messages/#message-types) system message in that channel and delivers [Message Create](/gateway/events/#message-create). The new member is indexed for [Guild member search](/http-api/guild-member-search/) only in a guild whose member list has already been indexed. No invite use is consumed.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `discovery:join` bucket, which is not partitioned by guild.
+
+## Apply for discovery
+
+
+
+Submits the discovery listing application of a guild and returns the resulting [discovery application object](#discovery-application-object). Requires [MANAGE_GUILD](/http-api/permissions/). An automatic approval emits a [Guild Update](/gateway/events/#guild-update) Gateway event.
+
+:::note[A verified or partnered guild is approved immediately]
+Its submission time is also its review time, it gains the discoverable feature, and it appears in search. Every other application stays pending until an operator approves it through the [Admin Discovery API](/admin-api/discovery/).
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| description1 | string | The listing description (10-300 characters) |
+| category_type2 | integer | The [discovery category](#discovery-categories) to file the listing under (0-8) |
+| primary_language?3 | string | The [supported primary language](#supported-primary-languages) code of the listing (default en-US) |
+| custom_tags?4 | array[string] | The [custom tags](#custom-tags) to store (at most 10) |
+
+1 Scanned against the instance content blocklists before the guild is read and before the eligibility and duplicate checks run, and a match returns 403 `CONTENT_BLOCKED`
+
+2 A value outside 0 through 8, a negative value, or a non-integer value is rejected by body validation
+
+3 A code outside the supported set is rejected by body validation, and an omitted field stores `en-US`
+
+4 Each entry is normalised and validated as described by [custom tags](#custom-tags), and an omitted field stores an empty array
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery application](#discovery-application-object) object | Application was stored as pending, or was approved immediately |
+| 400 | [error response](/http-api/#error-response) | Discovery is disabled for the instance and the request returns `DISCOVERY_DISABLED`, or the guild has fewer members than the instance requires and the request returns `DISCOVERY_INSUFFICIENT_MEMBERS` |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise [MANAGE_GUILD](/http-api/permissions/) because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The guild is marked unavailable and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The description matches a content blocklist and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks [MANAGE_GUILD](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+| 409 | [error response](/http-api/#error-response) | The guild already holds a pending or approved application and the request returns `DISCOVERY_ALREADY_APPLIED` |
+
+1 Only a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Side effects
+
+The submitted listing replaces a previous rejected or removed application.
+
+A pending application changes no guild feature, does not appear in search, and emits no Dispatch. An automatic approval adds the discoverable feature, indexes the listing so that [Search discovery guilds](#search-discovery-guilds) returns it, and delivers [Guild Update](/gateway/events/#guild-update) to every session that can see the guild.
+
+### Rate limit
+
+5 requests per minute for each authenticated user and guild ID, on the `discovery:apply::guild_id` bucket, which is shared with [Edit discovery application](#edit-discovery-application) and [Withdraw discovery application](#withdraw-discovery-application).
+
+## Edit discovery application
+
+
+
+Updates the stored listing of a guild and returns the updated [discovery application object](#discovery-application-object). Requires [MANAGE_GUILD](/http-api/permissions/). The operation emits no Gateway Dispatch.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+Every field is optional, and an omitted field preserves the stored value.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| description?1 | string | The listing description (10-300 characters) |
+| category_type?2 | integer | The [discovery category](#discovery-categories) to file the listing under (0-8) |
+| primary_language?3 | string | The [supported primary language](#supported-primary-languages) code of the listing |
+| custom_tags?4 | array[string] | The [custom tags](#custom-tags) to store (at most 10) |
+
+1 A supplied value is scanned against the instance content blocklists before the stored application is read, and a match returns 403 `CONTENT_BLOCKED`
+
+2 A value outside 0 through 8 is rejected by body validation
+
+3 A supplied code replaces the stored one. A listing always stores a language, so no update clears it
+
+4 A supplied array becomes the complete stored tag list, so an empty array clears every tag
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery application](#discovery-application-object) object | Listing was updated, or the submitted values already matched the stored ones |
+| 400 | [error response](/http-api/#error-response) | Discovery is disabled for the instance and the request returns `DISCOVERY_DISABLED` |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise [MANAGE_GUILD](/http-api/permissions/) because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The guild is marked unavailable and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The description matches a content blocklist and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks [MANAGE_GUILD](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The guild exists but holds no application, and the request returns `DISCOVERY_APPLICATION_NOT_FOUND` |
+| 409 | [error response](/http-api/#error-response) | The stored application is rejected or removed and the request returns `DISCOVERY_APPLICATION_ALREADY_REVIEWED` |
+
+1 Only a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Side effects
+
+The supplied fields update the listing while preserving its status, submission time, review time, and review reason. Editing an approved listing reindexes its result for [Search discovery guilds](#search-discovery-guilds). A pending listing remains absent from search. No guild feature changes.
+
+### Rate limit
+
+5 requests per minute for each authenticated user and guild ID, on the `discovery:apply::guild_id` bucket, which is shared with [Apply for discovery](#apply-for-discovery) and [Withdraw discovery application](#withdraw-discovery-application).
+
+## Withdraw discovery application
+
+
+
+Deletes the application record of a guild and returns 204 with an empty body. Requires [MANAGE_GUILD](/http-api/permissions/). Withdrawing an approved listing emits a [Guild Update](/gateway/events/#guild-update) Gateway event.
+
+:::caution[Withdrawal destroys the application and review history]
+The stored review time, review reason, removal time, and removal reason are deleted with the application. A guild that applies again is treated as a new applicant.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Application was deleted |
+| 400 | [error response](/http-api/#error-response) | Discovery is disabled for the instance and the request returns `DISCOVERY_DISABLED` |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise [MANAGE_GUILD](/http-api/permissions/) because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The guild is marked unavailable and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks [MANAGE_GUILD](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The guild exists but holds no application, and the request returns `DISCOVERY_APPLICATION_NOT_FOUND` |
+
+1 Only a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Side effects
+
+The application is permanently deleted. Withdrawing an approved listing also removes the discoverable feature, removes the listing from the discovery index, and delivers [Guild Update](/gateway/events/#guild-update) to every session that can see the guild. Withdrawing a pending, rejected, or removed application changes no guild feature and emits no Dispatch. Existing members are unaffected.
+
+### Rate limit
+
+5 requests per minute for each authenticated user and guild ID, on the `discovery:apply::guild_id` bucket, which is shared with [Apply for discovery](#apply-for-discovery) and [Edit discovery application](#edit-discovery-application).
+
+## Get discovery status
+
+
+
+Returns the [discovery status object](#discovery-status-object) of one guild, including for a guild that has never applied. Requires [MANAGE_GUILD](/http-api/permissions/).
+
+The route answers even while discovery is disabled for the instance, reporting `eligible` as false in that case. It computes `eligible` from the current member count.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [discovery status](#discovery-status-object) object | Status was returned |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise [MANAGE_GUILD](/http-api/permissions/) because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 4031 | [error response](/http-api/#error-response) | Caller presents a bearer credential or a revoked account and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The guild is marked unavailable and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks [MANAGE_GUILD](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 Only a bearer credential produces this code. A session, bot, or bearer token that no longer resolves to an account returns 401 `UNAUTHORIZED`
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and guild ID, on the `discovery:status::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/donations.mdx b/fluxer_docs/src/content/docs/http-api/donations.mdx
new file mode 100644
index 000000000..819cefafa
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/donations.mdx
@@ -0,0 +1,185 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Donations
+description: Donation currencies and intervals, checkout sessions, and the donor management link.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A donation is a one-off or recurring payment, taken on an externally hosted checkout page. Fluxer tracks it by email address, and it grants no [premium](/http-api/premium/). [Billing](/http-api/billing/) defines the payment provider webhook that completes one.
+
+None of the three routes here takes a credential, and all three are hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. A valid credential presented anyway keys the rate limit to the account.
+
+Fluxer uses a submitted address exactly as written and matches a donor by exact equality, so `Donor@example.com` and `donor@example.com` address two different donors.
+
+:::note[Donation management requires a completed donation]
+The payment provider webhook writes the donor record, and nothing else writes one.
+:::
+
+## Donation currencies
+
+Amounts are expressed in the minor unit of the selected currency. Each currency has its own inclusive bounds.
+
+| Value | Description |
+| --- | --- |
+| usd | The United States dollar (500-100000 minor units) |
+| eur | The euro (500-100000 minor units) |
+| brl | The Brazilian real (2500-500000 minor units) |
+| inr | The Indian rupee (50000-10000000 minor units) |
+| pln | The Polish zloty (2000-400000 minor units) |
+| try | The Turkish lira (25000-5000000 minor units) |
+
+## Donation intervals
+
+| Value | Description |
+| --- | --- |
+| month | The donation recurs every month |
+| year | The donation recurs every year |
+| null | The donation is taken once |
+
+## Donation checkout object
+
+A donation checkout is one absolute URL.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | The absolute URL to send the donor to |
+
+1 The externally hosted checkout session URL. A recurring donation for an address that already holds an active recurring donation returns the public donation management page
+
+### Example
+
+```json
+{
+ "url": "https://checkout.example.com/c/pay/cs_test_a1b2c3d4e5f6"
+}
+```
+
+## Request donation management link
+
+
+
+Accepts a request for a single-use donation management link. Returns 204 with an empty body.
+
+### Limitations
+
+- The submitted address must be syntactically valid.
+- The address must not exceed 254 characters.
+- The address domain must publish usable mail or address records.
+
+A failure of any of those returns 400 `INVALID_FORM_BODY` with an `errors` entry on `email`.
+
+:::caution[Every address answers the same 204]
+An address that resolves to no donor receives no email, and the response looks identical either way.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email123 | string | The donor email address (max 254 characters) |
+
+1 An empty string is reported as a missing address
+
+2 A padded address fails syntax validation
+
+3 The domain is resolved by MX record first, and by A or AAAA record only when it publishes no MX record. A transient resolver failure counts as valid. Fluxer caches the result for 30 minutes when valid and 5 minutes when not
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The request was accepted, whether or not an email was sent |
+| 400 | [error response](/http-api/#error-response) | The address domain publishes no usable mail or address records |
+
+### Side effects
+
+Everything happens before the response. Fluxer resolves the address domain, looks up the donor, and, when a donor exists, creates a token and sends the email. A delivery failure is absorbed. An address held as hard bounced and a transport error both leave the token stored and still answer 204.
+
+Fluxer creates a single-use token of 64 lowercase hexadecimal characters, valid for 15 minutes, only when the address resolves to a donor. Issuing a new link deletes every earlier token for the same address, and a superseded link returns 400 `DONATION_MAGIC_LINK_INVALID`.
+
+### Rate limit
+
+3 requests per hour for each client IP address or authenticated user, on the `donation:request_link` bucket.
+
+## Manage donation
+
+
+
+Consumes a donation management token. Answers 302 with `Location` set to the externally hosted billing portal for the matching donor. The token is the credential.
+
+Fluxer checks the token in a fixed order. An unknown token returns 400 `DONATION_MAGIC_LINK_INVALID`. A token past its 15 minute lifetime returns 400 `DONATION_MAGIC_LINK_EXPIRED`. An already consumed token returns 400 `DONATION_MAGIC_LINK_USED`, and a token that is both expired and consumed is reported as expired. None of those three consumes the token.
+
+A token whose address no longer resolves to a donor holding a payment provider customer is still consumed, and the redirect goes to the public donation page. For a donor holding a customer, a deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` and a provider failure returns 400 `STRIPE_ERROR`, both after the token has already been consumed.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token12 | string | The management token, exactly 64 characters |
+
+1 A token whose case was altered in transit returns 400 `DONATION_MAGIC_LINK_INVALID`
+
+2 Consumed as soon as it is found valid, so a portal creation failure leaves the token spent
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 302 | empty | The token was consumed, and `Location` is either the billing portal session or the public donation page |
+| 400 | [error response](/http-api/#error-response) | The token is unknown, expired or already consumed, the payment provider is not configured, or the payment provider rejected the portal creation |
+
+### Side effects
+
+The token is consumed. A resolved donor holding a payment provider customer receives a portal session, and closing that session returns the donor to the public donation page. An address that resolves to no donor, or to a donor with no customer, redirects to the public donation page and creates no portal session.
+
+### Rate limit
+
+10 requests per minute for each client IP address or authenticated user, on the `donation:manage` bucket.
+
+## Create donation checkout
+
+
+
+Creates a one-off or recurring donation checkout session. Returns a [donation checkout](#donation-checkout-object) object on success.
+
+An amount outside the selected currency's bounds fails validation with 400 `INVALID_FORM_BODY` and an `errors` entry on `amount_cents`. A deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` before the address is resolved, and a provider failure while creating the session returns 400 `STRIPE_ERROR`.
+
+A recurring donation for an address that already holds an active recurring donation returns the public donation management page for that address, with the percent-encoded address in `email` and the literal value `active_subscription` in `alert`. An address holds an active recurring donation when its donor record has a subscription identifier and a current period end still in the future, and has no scheduled cancellation.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email1 | string | The donor email address (max 254 characters) |
+| amount_cents2 | integer | Amount in the minor unit of `currency`, within that currency's [bounds](#donation-currencies) |
+| currency | string | Lowercase [donation currency](#donation-currencies) |
+| interval | ?string | [Donation interval](#donation-intervals), or null for a one-off donation |
+| is_business?3 | boolean | Whether to require a billing address for tax invoicing |
+
+1 Subject to the same syntax, length and domain record checks that [request donation management link](#request-donation-management-link) applies. The domain check runs after the amount bounds check, so an amount error is reported first
+
+2 The field name states cents, but the value is the minor unit of the selected currency. A fractional value is rejected
+
+3 Only `true` requires a billing address. `false` and an omitted key both leave billing address collection to the payment provider
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [donation checkout](#donation-checkout-object) object | The checkout session was created, or the donor was redirected to donation management |
+| 400 | [error response](/http-api/#error-response) | The amount is outside the bounds for the currency, the address domain publishes no usable records, the payment provider is not configured and returns `STRIPE_PAYMENT_NOT_AVAILABLE`, or the provider rejected the request and returns `STRIPE_ERROR` |
+
+### Side effects
+
+The operation creates an externally hosted checkout for the submitted amount, currency, and interval. Tax identifier collection is enabled and automatic tax is disabled. A recurring donation uses subscription mode, and a one-off donation uses payment mode with invoice creation. The session returns the donor to the public donation success page once it completes, and to the public donation page if it is cancelled.
+
+The session records that it is a donation, the submitted address, whether the donation is one-off or recurring, and the business flag. Fluxer reuses an existing donor's payment provider customer when one exists, and otherwise the provider creates one during checkout.
+
+This operation writes nothing to the donor record. [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) creates or updates the donor record on completion, makes the address eligible for [Request donation management link](#request-donation-management-link), and sends a confirmation email.
+
+### Rate limit
+
+5 requests per minute for each client IP address or authenticated user, on the `donation:checkout` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/downloads.mdx b/fluxer_docs/src/content/docs/http-api/downloads.mdx
new file mode 100644
index 000000000..96ffd411b
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/downloads.mdx
@@ -0,0 +1,513 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Desktop downloads
+description: Desktop release metadata, artifact and checksum downloads, test builds, and the storage catch-all.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The download resource serves the desktop application builds a deployment has stored. It also serves a SHA-256 checksum for each build and the release feed files beside them. Every route here is unauthenticated. A request that sends a credential anyway receives the same status and body. No route here declares a rate limit bucket, and none draws on the global allowance.
+
+## Prefix and mounting
+
+Fluxer registers the routes under `/dl`, and the six desktop routes under `/dl/desktop`. A client builds the URL against `api_client` from the [instance endpoints object](/http-api/instance/#instance-endpoints-object).
+
+The prefix mounts at the root and at `/v1`, so `/v1/dl/desktop/...` resolves for the six routes that name their own segments.
+
+:::caution[The catch-all has no `/v1` form]
+[Download stored object](#download-stored-object) builds its storage key from the raw request path and keeps only a path beginning `/dl`. A request to `/v1/dl/...` returns 404 while the same path without that prefix returns the object.
+:::
+
+Every other route on this reference takes the `/v1` form, and [HTTP API](/http-api/) states that a client MUST use it.
+
+## Methods
+
+Every route answers `GET`. Five registrations also bind `HEAD`: the two checksum routes, the two artifact routes, and the catch-all. [Get latest desktop version](#get-latest-desktop-version) and [List desktop versions](#list-desktop-versions) bind `GET` alone, and a `HEAD` still reaches them under the [shared rule for HEAD](/http-api/#request-format).
+
+A `HEAD` on either JSON route runs the same resolution as the `GET` and returns its status and headers with no body. It can therefore answer 404.
+
+On a checksum route, a `HEAD` returns 200 with no body. The headers are `Content-Type`, `Content-Disposition`, `Cache-Control`, and the `Content-Length` of the checksum line.
+
+A `HEAD` on an artifact route or the catch-all reads object metadata. It returns 200 with `Content-Type`, `Content-Disposition`, `Accept-Ranges`, `Cache-Control`, `Content-Length`, and, where storage reports them, `ETag` and `Last-Modified`. It ignores `Range` and answers neither 206 nor 416. It never redirects to a [presigned storage URL](#redirects). Fluxer resolves a [country redirect](#redirects) before it reads the method, so a `HEAD` receives that 302 exactly as a `GET` does.
+
+## Release channels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| stable | Stable | The channel a deployment publishes for general use |
+| canary | Canary | The channel that receives a build ahead of `stable` |
+
+The channel selects the storage prefix and the product name in a filename. A `canary` file is named `Fluxer-Canary` or `Fluxer Canary`, and a `stable` file is named `Fluxer`.
+
+## Platforms and architectures
+
+| Value | Name | Description |
+| --- | --- | --- |
+| win32 | Windows | The Windows build target |
+| darwin | macOS | The macOS build target |
+| linux | Linux | The Linux build target |
+
+The path segment with one of those values is named `plat`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| x64 | x64 | The 64-bit x86 architecture |
+| arm64 | ARM64 | The 64-bit ARM architecture |
+
+A channel, a platform, and an architecture together name one coordinate.
+
+## Package formats
+
+| Value | Name | Description |
+| --- | --- | --- |
+| setup | Setup | The Windows installer executable |
+| portable | Portable | The Windows archive that stores its data beside the executable |
+| dmg | DMG | The macOS disk image |
+| zip | ZIP | The macOS application archive |
+| appimage | AppImage | The Linux portable application image |
+| deb | DEB | The Debian package |
+| rpm | RPM | The RPM package |
+| tar_gz | TAR.GZ | The Linux compressed tarball |
+
+The registry is closed, and a `format` outside it returns 400 `INVALID_FORM_BODY`.
+
+Each format resolves on one platform. `setup` and `portable` resolve on `win32`, `dmg` and `zip` on `darwin`, and `appimage`, `deb`, `rpm`, and `tar_gz` on `linux`. A format paired with any other platform returns 404.
+
+On `darwin` a `dmg` or `zip` request tries the `universal` file before the architecture-specific one, so `x64` and `arm64` can resolve to the same file.
+
+## Checksum requests
+
+A client appends `.sha256` to the format segment to read the checksum, and sends the bare format to read the file. `tar_gz.sha256` is the checksum of `tar_gz`.
+
+The checksum route claims any last segment matching `[a-z_]+\.sha256` before the artifact route sees it.
+
+:::note[The constrained route claims the segment first]
+A segment ending in `.sha256` that names no [package format](#package-formats), such as `exe.sha256`, reaches the checksum route and fails there with 400 `INVALID_FORM_BODY`.
+:::
+
+The pattern is lowercase, so `appimage.SHA256` matches neither checksum route. It reaches the artifact route, where the format schema rejects it with the same status and code.
+
+## Version info object
+
+A version info object describes one release at a coordinate and the file it has for each format.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| version | string | The release version in `MAJOR.MINOR.PATCH` form |
+| pub_date1 | ISO8601 timestamp | The moment the release was published |
+| minimum_system_version?2 | ?string | The lowest operating system version the release supports |
+| files3 | map[string, [version file](#version-file-object) object] | The download entry for each [package format](#package-formats) the release has |
+
+1 Copied from the coordinate's manifest without reformatting. A coordinate with no usable manifest reports the newest storage modification time among the objects of that release instead
+
+2 Present only where the coordinate has a manifest and that manifest names a value
+
+3 A format that resolves no file is absent from the map
+
+### Example
+
+```json
+{
+ "version": "1.4.2",
+ "pub_date": "2026-08-19T11:04:00.000Z",
+ "minimum_system_version": "10.15.0",
+ "files": {
+ "dmg": {
+ "url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg",
+ "sha256": "3b1f5c0d9e7a24486cf0b1d3a5e87209cc4d61fba0937e5528d1c4a67b0e93f2",
+ "checksum_url": "https://api.example.com/dl/desktop/stable/darwin/arm64/1.4.2/dmg.sha256"
+ }
+ }
+}
+```
+
+## Version file object
+
+A version file object is the download entry for one format of one release.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | The absolute URL of the versioned download for this format |
+| sha2562 | ?string | The hash of the file as 64 lowercase hexadecimal characters, or null where no checksum was found |
+| checksum_url1 | ?string | The absolute URL of the checksum file, or null where `sha256` is null |
+
+1 Built from `api_client` in the [instance endpoints object](/http-api/instance/#instance-endpoints-object), with no `/v1` segment
+
+2 Read from the sibling `.sha256` object in storage, or from the manifest entry where the manifest named the file that resolved. A value outside `^[a-f0-9]{64}$` is reported as null
+
+## Checksum files
+
+A checksum response is one `sha256sum` line: the hash, two spaces, the resolved filename, and a trailing newline.
+
+```text
+2f6d1a4b8c3e07f95ab61d4c2e8035971fd0ba46c7e213985cd0f74a6b1e82c3 Fluxer-1.4.2-linux-x86_64.AppImage
+```
+
+The response has `Content-Type: text/plain; charset=utf-8`, a `Content-Disposition` of `attachment` naming the resolved filename with `.sha256` appended, and a `Content-Length` counting the encoded line. It sets no `Accept-Ranges`, `ETag`, or `Last-Modified`, and it ignores a `Range` header.
+
+Fluxer reads the hash from the sibling `.sha256` object or from the manifest entry, and treats a value that is not 64 lowercase hexadecimal characters as absent. That case returns 404.
+
+## Artifact resolution
+
+Fluxer resolves a coordinate to one storage key before it answers. It reads `manifest.json` under the coordinate prefix first and takes the filename that manifest records for the requested format. Fluxer falls back to a listing when that manifest is absent, is not valid JSON, is not a manifest object, describes another coordinate, or names a file storage does not hold. The fallback lists the objects under the prefix and takes the highest version whose filename parses for the requested format.
+
+The listing skips any name containing `/`, the names `manifest.json`, `RELEASES.json`, and `releases.json`, and any name ending in `.sha256`, `.blockmap`, or `.yml`.
+
+A coordinate that resolves no file returns 404 with the plain text body `Not Found`.
+
+## Response headers and caching
+
+Every artifact response has `Accept-Ranges: bytes` and a `Content-Disposition` of `attachment` naming the resolved file, unless the stored object has its own `Content-Disposition`. The filename is percent-encoded inside the header, so a name containing a space is written with `%20`.
+
+| Response | Cache-Control |
+| --- | --- |
+| Latest version metadata and the version list | `public, max-age=300` |
+| Latest artifact and latest checksum | `no-store` |
+| Versioned artifact and versioned checksum under `desktop/` | `public, max-age=31536000` |
+| A release feed filename, and anything under `desktop-test/` | `public, max-age=300` |
+| Any `desktop/` artifact on a deployment that configures country redirects | `private, no-store` |
+| A redirect to a presigned storage URL | `no-store` |
+
+A release feed filename is `manifest.json`, a name ending in `.yml` or `.yaml`, a name beginning `RELEASES`, or a name beginning `releases` or `assets` and ending in `.json`.
+
+## Redirects
+
+Two deployment settings answer a download with 302.
+
+A deployment that reports `self_hosted` false on the [instance features object](/http-api/instance/#instance-features-object) and configures a country list for GitHub redirects resolves the caller's country on every `desktop/` artifact request. When a complete and verified release descriptor names the file, a caller in a listed country receives 302 to that GitHub release asset. Every other outcome streams from storage, and every response under this setting has `Cache-Control: private, no-store`.
+
+A deployment that issues presigned download URLs answers a `GET` with 302 to a storage URL valid for 900 seconds. That redirect has `Cache-Control: no-store` and `Accept-Ranges: bytes`. The setting is off by default.
+
+:::caution[A 302 has no bytes and no range]
+The client follows `Location` to read the file. Fluxer produces the redirect before it reads any `Range` header, so range handling on a redirected download belongs to the target.
+:::
+
+## Test builds
+
+Every route accepts `test` as a query parameter. The value `1` or `true`, matched without regard to case, resolves the object against the `desktop-test/` storage prefix. Any other value is read as false.
+
+On the six desktop routes the flag replaces the prefix outright. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all.
+
+A `url` and a `checksum_url` built for a request that sent the flag repeat `?test=1`, so a client following either one stays on the test prefix.
+
+:::caution[`?test=` fails the schema with 400 `INVALID_FORM_BODY`]
+The empty value normalises to null under [input normalisation](/http-api/#input-normalisation). `?limit=`, `?before=`, and `?after=` fail the same way.
+:::
+
+## Get latest desktop version
+
+
+
+Returns the [version info](#version-info-object) object for the newest release at the coordinate.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [version info](#version-info-object) object | A release resolved at the coordinate |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | No release resolved at the coordinate |
+
+A client reads `files` for the format it wants. A format whose file resolved no checksum reports `sha256` and `checksum_url` as null.
+
+### Response headers
+
+The 200 has `Cache-Control: public, max-age=300`. The 404 has `Content-Type: text/plain`.
+
+## List desktop versions
+
+
+
+Returns the releases stored at the coordinate, newest first.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | The maximum number of releases to return (1 through 100, default 25) |
+| before?1 | string | The version to page below, exclusive |
+| after?1 | string | The version to page above, exclusive |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+1 Compared component by component as numbers, and both bounds can be sent together to select a range
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| versions | array[[version info](#version-info-object) object] | The releases on this page, ordered newest first |
+| has_more | boolean | Whether the filtered set held more releases than `limit` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The listing completed, possibly with no release |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+
+This route never answers 404. A coordinate that holds no object returns 200 with an empty array.
+
+Fluxer builds the listing from storage objects and ignores the manifest, so a release the manifest names but storage does not hold is absent here. `pub_date` reports the newest storage modification time among that release's objects, and `minimum_system_version` is never reported.
+
+A format is listed only where a filename under the coordinate parses for it, and `sha256` is reported only where the sibling `.sha256` object exists and holds a valid hash.
+
+### Response headers
+
+The 200 has `Cache-Control: public, max-age=300`.
+
+## Download latest desktop artifact
+
+
+
+Streams the newest file at the coordinate for one package format.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+| format | string | The [package format](#package-formats) to resolve |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | artifact bytes | The complete file was streamed |
+| 206 | artifact bytes | The requested byte range was streamed |
+| 302 | empty | The deployment [redirects](#redirects) this download |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | No file resolved for the format at the coordinate |
+| 416 | empty | The requested range is unsatisfiable |
+
+The stored file keeps its own name, so the resolved filename has the release version even though the request named `latest`. A client that follows this route on every check reads a different file once the coordinate publishes a new release.
+
+### Response headers
+
+The 200 has `Content-Type`1, `Content-Disposition`2, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control: no-store`, `ETag`3, and `Last-Modified`3. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
+
+1 The stored media type, and `application/octet-stream` where storage reports none
+
+2 `attachment` naming the resolved filename, percent-encoded
+
+3 Sent where storage reports the value
+
+## Download latest desktop checksum
+
+
+
+Returns the [checksum file](#checksum-files) for the newest file at the coordinate.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+| format1 | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
+
+1 The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | checksum line | The file and a valid hash both resolved |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | No file resolved for the format, or the resolved file has no valid hash |
+
+This hash is the value [Get latest desktop version](#get-latest-desktop-version) reports as `sha256` for the same format. The line names the file this coordinate's `latest` download streams.
+
+### Response headers
+
+The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control: no-store`. The 404 has `Content-Type: text/plain`.
+
+## Download desktop artifact
+
+
+
+Streams one released file by version and package format.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+| version1 | string | The release version to resolve |
+| format | string | The [package format](#package-formats) to resolve |
+
+1 Three decimal components matching `^\d+\.\d+\.\d+$`, so a two-component or suffixed version returns 400 `INVALID_FORM_BODY`
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | artifact bytes | The complete file was streamed |
+| 206 | artifact bytes | The requested byte range was streamed |
+| 302 | empty | The deployment [redirects](#redirects) this download |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | No file resolved for the version and format |
+| 416 | empty | The requested range is unsatisfiable |
+
+Fluxer reads the manifest only where the manifest names the requested version. Otherwise it tries the known filename spellings for the coordinate in turn and streams the first one storage holds, so a version published under an older naming scheme still resolves.
+
+### Response headers
+
+The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`1, `ETag`2, and `Last-Modified`2. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
+
+1 `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/` or for a [release feed filename](#response-headers-and-caching)
+
+2 Sent where storage reports the value
+
+## Download desktop checksum
+
+
+
+Returns the [checksum file](#checksum-files) for one released file.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The [release channel](#release-channels) to resolve |
+| plat | string | The [platform](#platforms-and-architectures) to resolve |
+| arch | string | The [architecture](#platforms-and-architectures) to resolve |
+| version | string | The release version to resolve |
+| format1 | string | The [package format](#package-formats) to resolve, followed by `.sha256` |
+
+1 The complete segment matches `[a-z_]+\.sha256` for the route to claim it, and the part before the suffix names a format in the closed registry
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to resolve under `desktop-test/` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | checksum line | The file and a valid hash both resolved |
+| 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | No file resolved for the version and format, or no valid hash was found for it |
+
+Fluxer reads the hash from the sibling `.sha256` object for the resolved file. Where that object is absent and the requested version is the newest one at the coordinate, Fluxer uses the manifest hash for that format instead. Any other case returns 404.
+
+### Response headers
+
+The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `Content-Length`, and `Cache-Control`1. The 404 has `Content-Type: text/plain`.
+
+1 `public, max-age=31536000` for a released file, and `public, max-age=300` under `desktop-test/`
+
+## Download stored object
+
+
+
+Streams one stored object addressed by its storage key. A release feed file has no format segment and no version segment, so this is the only route that reaches one.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| path1 | string | The storage key under the downloads bucket, which can contain `/` separators |
+
+1 Taken from the request path with the `/dl` prefix removed, then normalised. It begins `desktop/` or `desktop-test/` after normalisation, and every other key returns 404
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| test? | string | The [test build](#test-builds) flag, `1` or `true` to rewrite a `desktop/` key to `desktop-test/` |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Range? | string | The standard byte range, answered with 206 and a `Content-Range` header |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | object bytes | The complete object was streamed |
+| 206 | object bytes | The requested byte range was streamed |
+| 302 | empty | The deployment [redirects](#redirects) this download |
+| 400 | [error response](/http-api/#error-response) | The `test` value fails its schema and the request returns `INVALID_FORM_BODY` |
+| 404 | `Not Found` | The key is outside the two permitted prefixes, the request has the `/v1` prefix, or storage holds no such object |
+| 416 | empty | The requested range is unsatisfiable |
+
+:::caution[The catch-all reaches two prefixes only]
+A key that does not begin `desktop/` or `desktop-test/` returns 404 before Fluxer reads storage.
+:::
+
+Fluxer also rejects the key when it is empty, when normalisation leaves it beginning `..` or `/`, or when any segment is `.`, `..`, or contains a NUL character. Each of those returns the same 404, so a caller cannot tell a traversal attempt from a missing object.
+
+A key can have the platform and architecture in one hyphenated segment, as in `desktop/stable/linux-x64/manifest.json`. Fluxer also tries that key with the segment split, as `desktop/stable/linux/x64/manifest.json`. It reads the requested form first and streams whichever of the two storage holds.
+
+### Response headers
+
+The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`1, `ETag`2, and `Last-Modified`2. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`.
+
+1 `public, max-age=31536000` for a released artifact, and `public, max-age=300` for a [release feed filename](#response-headers-and-caching) or a key under `desktop-test/`
+
+2 Sent where storage reports the value
diff --git a/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx b/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx
new file mode 100644
index 000000000..fe8b6004d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx
@@ -0,0 +1,361 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Entrance sounds
+description: Personal entrance sound library, per-scope selections, upload validation, and playback.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An entrance sound is a short audio clip an account plays while it is connected to a voice channel. An account uploads a clip once into its own library, then assigns it to a scope: globally, in guild voice channels, in private calls, or in one named guild. Playback publishes no media. [Voice](/voice/) defines voice state and placement.
+
+Every route on this page is user-only, requires no permission, and acts on the caller's own library.
+
+## Clip limits
+
+Every bound below is a fixed constant of the instance, and none is resolved from an account [limit rule](/http-api/instance/#limit-rule-object).
+
+| Bound | Value |
+| --- | --- |
+| Clips per account | 8 |
+| Decoded clip size1 | 1048576 bytes |
+| Encoded upload payload1 | 1398134 characters |
+| Clip duration2 | 100 through 5200 milliseconds |
+| Display name | 1 through 32 characters after normalisation |
+
+1 The character bound is the 1 MiB decoded ceiling expressed in base64 with 32 characters of slack, so it is the bound an oversized payload reaches first
+
+2 Only [Upload entrance sound](#upload-entrance-sound) applies the lower bound
+
+The [instance discovery](/http-api/instance/#limit-keys) document publishes a `feature_voice_entrance_sounds` limit key. That key gates the feature in the client. No route on this page consults it, so a library can be read, written, and played back regardless of its resolved value.
+
+## Supported containers
+
+The container is detected from the decoded bytes, so a filename, a `data:` media type, or any other declared type has no effect on the result. The detected container resolves to one of four stored extensions, and every other input fails with `ENTRANCE_SOUND_INVALID_FORMAT`.
+
+| Detected container | Stored extension | Stored content type |
+| --- | --- | --- |
+| MP3, MPEG1 | mp3 | audio/mpeg |
+| Ogg2 | ogg | audio/ogg |
+| MP4, QuickTime, AAC3 | m4a | audio/mp4 |
+| WAV | wav | audio/wav |
+
+1 An MPEG program stream resolves here as well as bare MPEG audio
+
+2 An audio Ogg and a video Ogg both resolve here
+
+3 The container alone decides this row, so an MP4 or QuickTime file with a video stream is accepted as long as a duration can be measured from it. A bare AAC stream outside an MP4 container is not detected and is rejected
+
+FLAC, WebM, Matroska, AIFF, 3GPP, AVI, FLV, WMV, MPEG-TS, and every image type resolve to no extension and are rejected.
+
+:::caution[The uploaded bytes are stored verbatim]
+The decoded bytes are written exactly as they arrived, under the extension and content type the table assigns. A QuickTime upload stays a QuickTime file, and Fluxer serves it as `audio/mp4` under an `m4a` extension.
+:::
+
+## Entrance sound object
+
+One stored audio clip owned by one account. Every field except `name` is fixed at upload, and `id` is the only handle the other routes accept.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the sound |
+| name | string | The display label shown for the clip (1-32 characters) |
+| hash1 | string | The content digest of the stored audio, 16 hexadecimal characters |
+| extension | string | The stored extension, one of `mp3`, `ogg`, `m4a`, and `wav`, chosen by the [detected container](#supported-containers) |
+| content_type | string | The MIME type the clip is stored and served with, derived from `extension` |
+| duration_ms2 | integer | The measured duration in milliseconds (0-5200) |
+| size_bytes | integer | The decoded size in bytes (0-1048576) |
+| url3 | string | The absolute CDN URL the clip is fetched from |
+| created_at | ISO8601 timestamp | The time the clip was uploaded |
+
+1 The leading 16 hexadecimal characters of the MD5 digest of the decoded bytes, so two clips with identical audio have an identical `hash`
+
+2 Measured from the decoded audio and rounded to the nearest millisecond
+
+3 Built as `/entrance-sounds/{user_id}/{hash}.{extension}` on the instance's media endpoint. The [route that serves it](/media-proxy/routes/#get-entrance-sound) accepts no credential, so anyone holding the URL can fetch the audio
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "name": "airhorn",
+ "hash": "9f2c41ab77de0135",
+ "extension": "mp3",
+ "content_type": "audio/mpeg",
+ "duration_ms": 1240,
+ "size_bytes": 20416,
+ "url": "https://cdn.example.com/entrance-sounds/1490000000000000000/9f2c41ab77de0135.mp3",
+ "created_at": "2026-08-31T09:12:04.118Z"
+}
+```
+
+## Entrance sound selection object
+
+One scope and the clip assigned to it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope_id | string | [Entrance sound scope](#entrance-sound-scopes) the selection applies to |
+| sound_id | snowflake | The clip assigned to that scope |
+
+A scope with no assigned sound has no selection object. Clearing a scope deletes its entry.
+
+### Example
+
+```json
+{
+ "scope_id": "guild:1490000000000000123",
+ "sound_id": "1501314428688998182"
+}
+```
+
+## Entrance sound scopes
+
+| Value | Description |
+| --- | --- |
+| global | Applies wherever no narrower scope is selected |
+| guilds | Applies in guild voice channels |
+| dms | Applies in direct message and group calls |
+| `guild:{guild_id}` | Applies in one named guild, where the ID is 1 through 20 digits |
+
+`scope_id` must match one of the four forms exactly. Any other value fails with the validation code `INVALID_FORMAT` at the `scope_id` path.
+
+:::note[Fluxer stores scopes but never resolves them]
+A selection records which sound belongs to which scope and nothing more. [Play entrance sound](#play-entrance-sound) names the sound explicitly, so the client decides which selection applies to a channel.
+:::
+
+## List entrance sounds
+
+
+
+Returns the current account's complete entrance sound library together with its per-scope selections.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sounds | array[[entrance sound](#entrance-sound-object) object] | Every clip the account owns, at most 8 |
+| selections | array[[entrance sound selection](#entrance-sound-selection-object) object] | Every scope that currently has a sound assigned |
+
+Both members are always present and both arrays are empty for an account that has uploaded nothing. A selection always names a sound present in `sounds`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Library was returned |
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `user:entrance_sound:list` bucket.
+
+## Upload entrance sound
+
+
+
+Decodes, validates, and stores one audio clip in the current account's library, and returns the created [entrance sound object](#entrance-sound-object).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The display label shown for the clip (1-32 characters) |
+| audio2 | base64 string | The audio bytes of the clip, base64 encoded (1-1398134 characters) |
+
+1 Trimmed before its length is measured
+
+2 A `data:` URI prefix is accepted and discarded, and only the part after the first comma is measured and decoded
+
+Invisible characters in `name` are not stripped and count toward the 32 character bound.
+
+### Validation
+
+Every failure below is a 400 `INVALID_FORM_BODY`. One response can report two of the first four rows when both fields are bad. The remaining rows are reported one at a time, in the order the table lists them.
+
+| Failure | Validation code | Path |
+| --- | --- | --- |
+| The field is absent or is a literal empty string1 | `INVALID_FORMAT` | name or audio |
+| The normalised name is empty or longer than 32 characters | `STRING_LENGTH_INVALID` | name |
+| The encoded payload is empty or longer than 1398134 characters2 | `BASE64_LENGTH_INVALID` | audio |
+| The payload is not valid base64 | `INVALID_BASE64_FORMAT` | audio |
+| The library already holds 8 clips | `ENTRANCE_SOUND_QUOTA_REACHED` | audio |
+| The decoded bytes exceed 1048576 bytes | `ENTRANCE_SOUND_SIZE_EXCEEDS_LIMIT` | audio |
+| The bytes resolve to no [supported container](#supported-containers), or their duration cannot be measured | `ENTRANCE_SOUND_INVALID_FORMAT` | audio |
+| The measured duration is above 5200 ms or below 100 ms | `ENTRANCE_SOUND_DURATION_EXCEEDS_LIMIT` | audio |
+| The validated clip could not be written to storage | `FAILED_TO_UPLOAD_IMAGE` | audio |
+
+1 An empty string is read as null under [input normalisation](/http-api/#input-normalisation), and neither field accepts null
+
+2 Measured after any `data:` prefix is discarded, so a value that is whitespace only, or that ends at its first comma, has zero payload characters and fails here
+
+The first four rows are checked before the quota, so an account already holding 8 clips receives `BASE64_LENGTH_INVALID` or `INVALID_BASE64_FORMAT` for a malformed or over-long payload.
+
+Both duration bounds report the same code at the `audio` path, and the entry has no member naming which of the two was crossed.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | [entrance sound](#entrance-sound-object) object | Clip was stored |
+| 400 | [error response](/http-api/#error-response) | Name or audio fails [validation](#validation) |
+
+### Side effects
+
+The operation assigns no scope and emits no Gateway Dispatch.
+
+:::note[Identical audio shares one stored object]
+Uploading the same bytes twice creates a second entry with the same `hash`, so both entries have the same `url`. Fluxer deletes the stored object only when the last entry that references it is removed.
+:::
+
+### Rate limit
+
+5 requests per 5 minutes for each authenticated user, on the `user:entrance_sound:upload` bucket.
+
+## Rename entrance sound
+
+
+
+Changes the display label of one clip and returns the updated [entrance sound object](#entrance-sound-object).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sound_id | snowflake | The ID of the sound |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | Replacement display label (1-32 characters) |
+
+1 Required, and normalised and bounded exactly as the `name` of [Upload entrance sound](#upload-entrance-sound)
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [entrance sound](#entrance-sound-object) object | Clip was renamed |
+| 400 | [error response](/http-api/#error-response) | Name normalises to the empty string or exceeds 32 characters and the request returns `STRING_LENGTH_INVALID` at the `name` path, or the account owns no such clip and the request returns `ENTRANCE_SOUND_NOT_FOUND` at the `sound_id` path |
+
+:::note[An unknown clip returns 400, not 404]
+Fluxer reports a clip owned by another account exactly like one that never existed, so a caller cannot tell ownership from existence. [Set entrance sound selection](#set-entrance-sound-selection) and [Play entrance sound](#play-entrance-sound) report an unknown clip the same way.
+:::
+
+### Side effects
+
+Only the label changes. The stored audio is untouched, and every selection pointing at the clip keeps pointing at it. Fluxer emits no Gateway Dispatch.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:entrance_sound:mutate` bucket, which is shared with [Delete entrance sound](#delete-entrance-sound) and [Set entrance sound selection](#set-entrance-sound-selection).
+
+## Delete entrance sound
+
+
+
+Deletes one clip from the current account's library, clears every scope selection that pointed at it, and returns 204 with an empty body. An unknown clip, or a clip owned by another account, is a successful no-op.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sound_id | snowflake | The ID of the sound |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Clip was deleted, or no owned clip existed |
+
+### Side effects
+
+Fluxer removes every selection naming the clip first, then the library entry, then the stored audio object unless another clip in the library has the same `hash` and `extension`. A storage failure is logged, and the request still returns 204, so the entry is removed either way. Fluxer emits no Gateway Dispatch.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the shared `user:entrance_sound:mutate` bucket.
+
+## Set entrance sound selection
+
+
+
+Assigns one of the account's clips to one [scope](#entrance-sound-scopes), or clears that scope, and returns 204 with an empty body. Each call addresses exactly one scope, and a scope that already has a sound is overwritten.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope_id | string | [Entrance sound scope](#entrance-sound-scopes) to write (1-32 characters) |
+| sound_id1 | ?snowflake | The clip to assign, or null to clear the scope |
+
+1 Required. An explicit null clears the scope, succeeds even when the scope had no selection, and looks up no clip
+
+Fluxer accepts a `guild:{guild_id}` scope on its shape alone. It neither resolves the guild nor checks membership, so a selection can name a guild the account has left. A non-null `sound_id` must name a clip the account owns, and Fluxer writes the scope only after that lookup succeeds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Selection was written or cleared |
+| 400 | [error response](/http-api/#error-response) | Scope is unrecognised and the request returns `INVALID_FORMAT` at the `scope_id` path, or the account owns no such clip and the request returns `ENTRANCE_SOUND_NOT_FOUND` at the `sound_id` path |
+
+### Side effects
+
+Fluxer writes or removes the scope's selection. One clip can be selected in any number of scopes at once. The operation emits no Gateway Dispatch, so the caller's other sessions learn about the change only by listing the library again.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the shared `user:entrance_sound:mutate` bucket.
+
+## Play entrance sound
+
+
+
+Fans the caller's chosen clip out to everyone else connected to a voice channel and returns 204 with an empty body. The caller must already hold a voice state in that channel and must own the clip. Emits an [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Gateway event.
+
+Nothing is published to the media servers. Each recipient receives a Dispatch naming the clip's CDN URL, then fetches and plays it locally.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the voice channel the clip is played in |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sound_id | snowflake | The clip to play, drawn from the caller's own library |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Fan-out was attempted for every other connected account |
+| 400 | [error response](/http-api/#error-response) | Caller holds no voice state in the channel and the request returns `ENTRANCE_SOUND_INVALID_SCOPE` at the `channel_id` path, or the account owns no such clip and the request returns `ENTRANCE_SOUND_NOT_FOUND` at the `sound_id` path |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist and the request returns `UNKNOWN_CHANNEL` |
+
+:::caution[Connection is the only authorisation]
+Fluxer evaluates no channel permission and checks no guild membership.
+:::
+
+:::caution[Any account can probe channel existence]
+Fluxer resolves the channel before the connection check. A channel the caller is not connected to returns 400, and an ID naming no channel returns 404.
+:::
+
+The connection check runs before the clip is looked up, so a caller who is not connected never learns whether the named clip exists.
+
+### Side effects
+
+Fluxer sends one [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Dispatch to every other account holding a voice state in the channel, at most once for each account, and never to the caller. A recipient whose Dispatch fails is skipped, and the request still returns 204.
+
+Fluxer addresses the Dispatch to the account, so every session that account holds receives it, including sessions that are not in the channel. A client filters on `channel_id`.
+
+### Rate limit
+
+3 requests per 30 seconds for each authenticated user and channel ID, on the `voice:entrance_sound:play::user_id::channel_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/errors.md b/fluxer_docs/src/content/docs/http-api/errors.md
new file mode 100644
index 000000000..b6d8ce37f
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/errors.md
@@ -0,0 +1,2145 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Errors
+description: The error envelope, the status fallback mapping, and both code registries.
+---
+
+A failed request returns a JSON [error response](/http-api/#error-response). It has a stable `code` a client matches on and a localised `message` for a person to read. Every HTTP API and Admin API failure uses this shape. An OAuth2 protocol failure is the one exception.
+
+:::caution[Match on `code` alone]
+The `message` field is rendered from a template, is translated per request, and can change wording without notice. An HTTP status is not enough on its own, because many distinct codes share one status.
+:::
+
+## Selecting the error code
+
+An error response always has `code` and `message`. A failure with more to report adds its own members beside those two. Every added member sits at the top level of the response. A rate limit denial adds `global` and `retry_after`, a missing scope adds `required_scope`, and a validation failure adds `errors`.
+
+:::note[OAuth2 endpoints use a different envelope]
+An OAuth2 protocol failure raised by the [OAuth2 resource](/http-api/oauth2/) answers with the RFC 6749 shape. That body is `error` and `error_description` and nothing else, so its values appear in no registry here.
+:::
+
+## Supplementary members
+
+The members a failure adds are fixed by its code, so a client reads only the members documented for the code it matched. `errors` reports field violations. `retry_after` states the delay before another attempt is admitted, `global` distinguishes a global rate limit denial from a route one, `required_scope` names a missing OAuth2 scope, and `has_mfa` and `methods` state the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply.
+
+The two IP ban codes have their own members. `GLOBAL_IP_BANNED` and `GLOBAL_IP_TEMPORARILY_BANNED` both have `ip_address` with the normalised client address, `appeal_email` with the address an appeal is sent to, `appeals_supported` which is `true` only for the permanent ban, and `ban_kind` which is `permanent` or `temporary_24h`. `expires_at` is an ISO 8601 timestamp when the ban records an expiry and `null` otherwise, including on every permanent ban.
+
+## Validation failure codes
+
+Fluxer answers a field-level failure with 400 and a top-level `errors` array. Each element identifies one failed input field. The [validation error object](/http-api/#validation-error-object) documents the element shape.
+
+A boundary schema validates one of four request targets: the JSON body, the form body, the query string, and the path parameters. A failure on any of them returns the top-level code `INVALID_FORM_BODY`, and each element has a `code` drawn from the [validation error code registry](#validation-error-code-registry) together with a localised `message`.
+
+An operation can also report against a named field without the boundary schema. That failure returns `INVALID_FORM_BODY` as well. Its element has an enumerated `code` and localised `message` when the failure declares a registry code. Otherwise it has a fixed English `message` written at the failure site and no `code`. Every element `code` a client observes is a registry value.
+
+[Modify current user settings](/http-api/users/settings/#modify-current-user-settings) is the one operation that answers such a failure with the top-level code `VALIDATION_ERROR` instead of `INVALID_FORM_BODY`. Its trusted domain, age restriction, and synced preferences decisions each report one element with a registry `code` and a fixed English `message`.
+
+The `path` of an element is the dot-joined position of the failed value, so a nested field appears as `embeds.0.title`. A failure with no field position uses the literal path `root`. Fluxer produces at most one element for each distinct pair of `path` and `code`.
+
+### Example
+
+```json
+{
+ "code": "INVALID_FORM_BODY",
+ "message": "Invalid form body.",
+ "errors": [
+ {
+ "path": "embeds.0.title",
+ "code": "CONTENT_EXCEEDS_MAX_LENGTH",
+ "message": "Text must not exceed 256 characters."
+ }
+ ]
+}
+```
+
+:::note[An empty body reports the missing fields]
+An empty or whitespace-only body becomes `{}`, so the response reports the fields the schema then finds missing. A body that does not parse as JSON returns 400 `INVALID_FORM_BODY` with one element at path `body` and code `INVALID_FORMAT`.
+:::
+
+Fluxer normalises empty values on all four targets before validation runs. An empty string becomes `null` wherever it appears, including inside an array element. A nested object becomes `null` when it holds no members, and it becomes `null` when every one of its members is `null` after Fluxer has applied the same rule to each of them. The top-level object itself is never replaced, so a request that sends nothing still reaches the schema as an object and fails on the fields the schema requires.
+
+:::caution[An enumerated validation failure answers 400 alone]
+A validation failure whose elements have enumerated codes answers 400 with its elements in `errors`, and its top-level code is `INVALID_FORM_BODY` everywhere except [Modify current user settings](/http-api/users/settings/#modify-current-user-settings). Any other status, retry guidance, or response header from the original failure is dropped.
+:::
+
+### Default schema failure codes
+
+A boundary schema constraint can name its own [validation code](#validation-error-code-registry). When it names none, Fluxer maps the failure to one of the codes below by the kind of constraint that failed.
+
+| Constraint | Code | Description |
+| --- | --- | --- |
+| String longer than its maximum | CONTENT_EXCEEDS_MAX_LENGTH1 | A string value exceeded the maximum length the schema declares |
+| Email format | INVALID_EMAIL_ADDRESS | A value declared as an email address did not parse as one |
+| UUID format | INVALID_SNOWFLAKE | A value declared as a UUID did not parse as one |
+| Any other constraint | INVALID_FORMAT2 | Every constraint kind not listed above |
+
+1 A string shorter than its minimum reports `INVALID_FORMAT`, and so does a length bound on anything other than a string
+
+2 Covers a type, union, enumerated value, unknown key, minimum length, numeric or date bound, multiple, key, element, or custom constraint, a constraint kind the mapping does not recognise, and a constraint that names a code outside the registry
+
+## HTTP status fallback codes
+
+A failure that has an HTTP status and no recognised Fluxer error code takes the fallback `code` its status selects below. An unclassified 401, 413, 422, or 429 falls back to `GENERAL_ERROR`. An error named by an operation or by one of the registries below keeps that more specific code.
+
+An unrecognised failure returns 500 with `INTERNAL_SERVER_ERROR` and a generic message that names no detail. A failure that has a registered API code but no status returns 400, except `GENERAL_ERROR`, which returns 500.
+
+| Status | Code | Description |
+| --- | --- | --- |
+| 400 | BAD_REQUEST | The request was malformed or failed a precondition |
+| 403 | FORBIDDEN | The caller lacks permission for the operation |
+| 404 | NOT_FOUND1 | The target resource does not exist or is not visible |
+| 405 | METHOD_NOT_ALLOWED | The HTTP method is not accepted on the route |
+| 409 | CONFLICT | The request conflicts with the current state of the resource |
+| 410 | GONE | The resource is permanently unavailable |
+| 500 | INTERNAL_SERVER_ERROR2 | An unexpected internal failure occurred |
+| 501 | NOT_IMPLEMENTED | The requested capability is not implemented |
+| 502 | BAD_GATEWAY | An upstream dependency returned an invalid response |
+| 503 | SERVICE_UNAVAILABLE | The service is temporarily unable to handle the request |
+| 504 | GATEWAY_TIMEOUT | An upstream dependency did not respond in time |
+| default3 | GENERAL_ERROR | Any status without a specific mapping |
+
+1 Also the code a request that matches no route receives, so an unrouted path and an unreadable resource are indistinguishable from the body alone
+
+2 Also the code an unrecognised failure receives, with the generic localised message
+
+3 A failure that lands on this row keeps its own status
+
+## Client errors as an abuse signal
+
+Every `4xx` response to a request that resolved no authenticated user contributes a weighted abuse signal keyed by the client IP identity. A 429 weighs 3, a 401 weighs 0.75, a 403 weighs 0.5, and every other 4xx weighs 0.25. Fluxer records a signal only when the client address is public and not exempt. An IPv4 client is keyed by its exact address and an IPv6 client by its `/64`. One request records at most one client error signal. A denial produced by an existing IP ban records no signal.
+
+Fluxer records a second signal of weight 1 for a credential that fails to resolve. A request that presents an unrecognised token and is answered 401 contributes both. That signal also records a hash of the credential presented, and Fluxer tracks the number of distinct hashes seen for one IP identity beside the score.
+
+The score and the credential hashes accumulate inside a fixed window, and the window restarts once it elapses. Two triggers fire an automatic ban. The credential trigger fires the first time the count of distinct rejected credentials reaches its threshold. The score trigger fires only after the score has crossed its threshold in three separate windows, which is the default. Both thresholds depend on the address classification, which is datacentre, anonymising, mobile, or residential. An unclassified address takes the residential thresholds. Fluxer never bans a mobile address automatically.
+
+:::caution[An automatic ban answers every request for 24 hours]
+The window length, both thresholds, and the number of windows the score trigger requires are instance configuration. A tripped ban lasts 24 hours by default. While it holds, Fluxer answers every request from that identity with 403 and the code `GLOBAL_IP_TEMPORARILY_BANNED`.
+:::
+
+## API error code registry
+
+These codes appear in the top-level `code` field of an error response, transmitted as the exact JSON string shown. The registry is closed and holds exactly 257 codes. Each entry states the leading sentence of the English source message, without its final full stop. Those messages call a [guild](/http-api/guilds/) a community.
+
+:::note[The rendered `message` fills in the braced values]
+A description containing a value in braces is an ICU MessageFormat template. `You've reached the maximum of {count, plural, one {# emoji} other {# emojis}}` renders as a complete sentence with the applicable limit.
+:::
+
+:::note[A rendered `message` can run longer than the registry entry]
+Several source messages append a further recovery sentence with a template value such as a maximum size, a format list, or a retry delay. A client branches on `code` and reads the members the operation documents.
+:::
+
+### `ACCESS_DENIED`
+
+You don't have access to this resource or feature
+
+### `ACCOUNT_SUSPENDED_PERMANENTLY`
+
+This account has been permanently suspended
+
+### `ACCOUNT_SUSPENDED_TEMPORARILY`
+
+This account has been temporarily suspended
+
+### `ACCOUNT_SUSPICIOUS_ACTIVITY`
+
+Your account is locked due to suspicious activity
+
+### `ACCOUNT_TOO_NEW_FOR_GUILD`
+
+Your account is too new to send messages in this community
+
+### `ACLS_MUST_BE_NON_EMPTY`
+
+ACLs must be non-empty
+
+### `ADMIN_API_KEY_NOT_FOUND`
+
+Admin API key wasn't found
+
+### `AGE_VERIFICATION_ALREADY_VERIFIED`
+
+You've already completed age verification
+
+### `ALREADY_FRIENDS`
+
+You're already friends with this user
+
+### `APPLICATION_NOT_OWNED`
+
+You don't own this application
+
+### `BAD_GATEWAY`
+
+Bad gateway
+
+### `BAD_REQUEST`
+
+Bad request
+
+### `BLUESKY_OAUTH_AUTHORIZATION_FAILED`
+
+We couldn't resolve that Bluesky handle
+
+### `BLUESKY_OAUTH_CALLBACK_FAILED`
+
+We couldn't complete the Bluesky connection
+
+### `BLUESKY_OAUTH_NOT_ENABLED`
+
+Bluesky connections are not enabled on this instance
+
+### `BLUESKY_OAUTH_STATE_INVALID`
+
+The authorization request has expired or is invalid
+
+### `BOTS_CANNOT_CREATE_GUILDS`
+
+Bots can't create communities
+
+### `BOTS_CANNOT_SEND_FRIEND_REQUESTS`
+
+Bots can't send friend requests
+
+### `BOT_ALREADY_IN_GUILD`
+
+This bot is already in the community
+
+### `BOT_IS_PRIVATE`
+
+This bot is private
+
+### `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`
+
+Bot users can't use auth endpoints
+
+### `BOT_USER_AUTH_SESSION_CREATION_DENIED`
+
+Bot users can't create auth sessions
+
+### `BOT_USER_GENERATION_FAILED`
+
+Bot user generation failed
+
+### `BOT_USER_NOT_FOUND`
+
+Bot user wasn't found
+
+### `CALL_ALREADY_EXISTS`
+
+A call is already in progress in this channel
+
+### `CANNOT_BLOCK_SYSTEM_USER`
+
+You can't block the system user
+
+### `CANNOT_EDIT_OTHER_USER_MESSAGE`
+
+You can't edit another user's message
+
+### `CANNOT_EXECUTE_ON_DM`
+
+This action can't be performed in a DM
+
+### `CANNOT_MODIFY_SYSTEM_WEBHOOK`
+
+You can't edit system messages
+
+### `CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY`
+
+You can't redeem {premium_tier_name} with Visionary
+
+### `CANNOT_REPORT_GUILD`
+
+You can't report this community
+
+### `CANNOT_REPORT_OWN_GUILD`
+
+You can't report your own community
+
+### `CANNOT_REPORT_OWN_MESSAGE`
+
+You can't report your own message
+
+### `CANNOT_REPORT_YOURSELF`
+
+You can't report yourself
+
+### `CANNOT_SEND_EMPTY_MESSAGE`
+
+You can't send an empty message
+
+### `CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER`
+
+You can't send a friend request to a blocked user
+
+### `CANNOT_SEND_FRIEND_REQUEST_TO_SELF`
+
+You can't send a friend request to yourself
+
+### `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`
+
+You can't send messages in a non-text channel
+
+### `CANNOT_SEND_MESSAGES_TO_USER`
+
+You can't send messages to this user
+
+### `CANNOT_SHRINK_RESERVED_SLOTS`
+
+You can't shrink reserved slots
+
+### `CANNOT_TRANSFER_OWNERSHIP_TO_BOT`
+
+Community ownership can't be transferred to a bot
+
+### `CAPTCHA_REQUIRED`
+
+Captcha is required
+
+### `COMMUNICATION_DISABLED`
+
+Communication is disabled
+
+### `CONFLICT`
+
+Conflict
+
+### `CONNECTION_ALREADY_EXISTS`
+
+A connection of this type with this identifier already exists
+
+### `CONNECTION_INITIATION_TOKEN_INVALID`
+
+The connection initiation token is invalid or has expired
+
+### `CONNECTION_INVALID_TYPE`
+
+The connection type is not supported
+
+### `CONNECTION_LIMIT_REACHED`
+
+You've reached the maximum number of connections ({limit})
+
+### `CONNECTION_NOT_FOUND`
+
+Unknown connection
+
+### `CONNECTION_VERIFICATION_FAILED`
+
+Connection verification failed
+
+### `CONTENT_BLOCKED`
+
+This content was blocked by safety systems
+
+### `DELETION_FAILED`
+
+We couldn't delete the resource
+
+### `DIRECT_MESSAGES_DISABLED`
+
+Direct messages and friend requests are disabled on this instance
+
+### `DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `DISCOVERY_ALREADY_APPLIED`
+
+This community has already applied for discovery
+
+### `DISCOVERY_APPLICATION_ALREADY_REVIEWED`
+
+This discovery application has already been reviewed
+
+### `DISCOVERY_APPLICATION_NOT_FOUND`
+
+Discovery application wasn't found
+
+### `DISCOVERY_DISABLED`
+
+Discovery isn't available on this instance
+
+### `DISCOVERY_INSUFFICIENT_MEMBERS`
+
+This community doesn't meet the minimum member count for discovery
+
+### `DISCOVERY_NOT_DISCOVERABLE`
+
+This community isn't listed in discovery
+
+### `DISCRIMINATOR_REQUIRED`
+
+Discriminator is required
+
+### `DONATION_AMOUNT_INVALID`
+
+Donation amount is invalid
+
+### `DONATION_MAGIC_LINK_EXPIRED`
+
+Magic link has expired
+
+### `DONATION_MAGIC_LINK_INVALID`
+
+Magic link is invalid
+
+### `DONATION_MAGIC_LINK_USED`
+
+Magic link has already been used
+
+### `EMAIL_SERVICE_NOT_TESTABLE`
+
+Email service is temporarily unavailable
+
+### `EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `EXPLICIT_CONTENT_CANNOT_BE_SENT`
+
+Explicit content can't be sent
+
+### `FEATURE_NOT_AVAILABLE_SELF_HOSTED`
+
+This feature isn't available on self-hosted instances
+
+### `FEATURE_TEMPORARILY_DISABLED`
+
+This feature is temporarily disabled
+
+### `FILE_SIZE_TOO_LARGE`
+
+File size is too large
+
+### `FORBIDDEN`
+
+Forbidden
+
+### `FRIEND_REQUEST_BLOCKED`
+
+User does not accept friend requests at this time
+
+### `FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `GATEWAY_TIMEOUT`
+
+Gateway timeout
+
+### `GENERAL_ERROR`
+
+Something went wrong
+
+### `GIFT_CODE_ALREADY_REDEEMED`
+
+Gift code is already redeemed
+
+### `GLOBAL_IP_BANNED`
+
+Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators
+
+### `GLOBAL_IP_TEMPORARILY_BANNED`
+
+Your IP address {ipAddress} has been temporarily blocked from the Fluxer API for 24 hours because of abusive or unusual access patterns
+
+### `GONE`
+
+Gone
+
+### `GROUP_DM_RECIPIENTS_NOT_ADDABLE`
+
+One or more selected users can't be added to this group DM
+
+### `GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `GUILD_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `GUILD_PHONE_VERIFICATION_REQUIRED`
+
+You need to add a phone number to send messages in this community
+
+### `GUILD_TEMPLATE_INVALID`
+
+The community template data is invalid or malformed
+
+### `GUILD_VERIFICATION_REQUIRED`
+
+Community verification is required
+
+### `HANDOFF_CODE_EXPIRED`
+
+Handoff code expired
+
+### `HARVEST_EXPIRED`
+
+Your data export request has expired
+
+### `HARVEST_FAILED`
+
+Your data export request failed
+
+### `HARVEST_NOT_READY`
+
+Your data export is still being prepared
+
+### `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`
+
+This instance policy change is not allowed
+
+### `INTERNAL_SERVER_ERROR`
+
+Internal server error
+
+### `INVALID_ACLS_FORMAT`
+
+Invalid ACLs format
+
+### `INVALID_API_ORIGIN`
+
+Invalid API origin
+
+### `INVALID_AUTH_TOKEN`
+
+Invalid or expired authorization token
+
+### `INVALID_BOT_FLAG`
+
+Invalid bot flag
+
+### `INVALID_CAPTCHA`
+
+Invalid captcha
+
+### `INVALID_CHANNEL_TYPE`
+
+Invalid channel type
+
+### `INVALID_CHANNEL_TYPE_FOR_CALL`
+
+Calls can only be made in direct messages or group DMs
+
+### `INVALID_DSA_REPORT_TARGET`
+
+Invalid DSA report target
+
+### `INVALID_DSA_TICKET`
+
+Invalid DSA ticket
+
+### `INVALID_DSA_VERIFICATION_CODE`
+
+Invalid DSA verification code
+
+### `INVALID_FLAGS_FORMAT`
+
+Invalid flags format
+
+### `INVALID_FORM_BODY`
+
+Invalid form body
+
+### `INVALID_HANDOFF_CODE`
+
+Invalid handoff code
+
+### `INVALID_PERMISSIONS_INTEGER`
+
+Permissions must be a valid integer
+
+### `INVALID_PERMISSIONS_NEGATIVE`
+
+Permissions must be non-negative
+
+### `INVALID_PHONE_NUMBER`
+
+Invalid phone number
+
+### `INVALID_PHONE_VERIFICATION_CODE`
+
+Invalid phone verification code
+
+### `INVALID_REQUEST`
+
+Invalid request
+
+### `INVALID_STREAM_KEY_FORMAT`
+
+Invalid stream key format
+
+### `INVALID_STREAM_THUMBNAIL_PAYLOAD`
+
+Invalid stream thumbnail payload
+
+### `INVALID_SUSPICIOUS_FLAGS_FORMAT`
+
+Invalid suspicious flags format
+
+### `INVALID_SYSTEM_FLAG`
+
+Invalid system flag
+
+### `INVALID_TIMESTAMP`
+
+Invalid timestamp
+
+### `INVALID_TOKEN`
+
+Invalid or expired authorization token
+
+### `INVALID_WEBAUTHN_AUTHENTICATION_COUNTER`
+
+Invalid WebAuthn authentication counter
+
+### `INVALID_WEBAUTHN_CREDENTIAL`
+
+Failed to verify WebAuthn credential
+
+### `INVALID_WEBAUTHN_CREDENTIAL_COUNTER`
+
+Invalid WebAuthn credential counter
+
+### `INVALID_WEBAUTHN_PUBLIC_KEY_FORMAT`
+
+Invalid WebAuthn public key format
+
+### `INVITES_DISABLED`
+
+Invites are disabled
+
+### `IP_AUTHORIZATION_REQUIRED`
+
+IP authorization is required
+
+### `IP_AUTHORIZATION_RESEND_COOLDOWN`
+
+IP authorization resend is on cooldown
+
+### `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`
+
+IP authorization resend limit exceeded
+
+### `IP_BAN_DECLINED`
+
+This IP address cannot be added to the blocklist
+
+### `MAX_APPLICATIONS`
+
+You've reached the maximum of {limit, plural, one {# application} other {# applications}}
+
+### `MAX_BOOKMARKS`
+
+You've reached the maximum of {count, plural, one {# bookmark} other {# bookmarks}}
+
+### `MAX_CATEGORY_CHANNELS`
+
+You've reached the maximum of {count, plural, one {# category channel} other {# category channels}}
+
+### `MAX_EMOJIS`
+
+You've reached the maximum of {count, plural, one {# emoji} other {# emojis}}
+
+### `MAX_FAVORITE_MEMES`
+
+You've reached the maximum of {count, plural, one {# favorite meme} other {# favorite memes}}
+
+### `MAX_FRIENDS`
+
+You've reached the maximum of {count, plural, one {# friend} other {# friends}}
+
+### `MAX_GROUP_DMS`
+
+You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}
+
+### `MAX_GROUP_DM_RECIPIENTS`
+
+You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}
+
+### `MAX_GUILDS`
+
+You've reached the maximum of {count, plural, one {# community} other {# communities}}
+
+### `MAX_GUILD_CHANNELS`
+
+You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}
+
+### `MAX_GUILD_MEMBERS`
+
+You've reached the maximum of {count, plural, one {# community member} other {# community members}}
+
+### `MAX_GUILD_ROLES`
+
+You've reached the maximum of {count, plural, one {# community role} other {# community roles}}
+
+### `MAX_INVITES`
+
+You've reached the maximum of {count, plural, one {# invite} other {# invites}}
+
+### `MAX_REACTIONS`
+
+You've reached the maximum of {count, plural, one {# reaction} other {# reactions}}
+
+### `MAX_STICKERS`
+
+You've reached the maximum of {count, plural, one {# sticker} other {# stickers}}
+
+### `MAX_WEBHOOKS_PER_CHANNEL`
+
+You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel
+
+### `MAX_WEBHOOKS_PER_GUILD`
+
+You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community
+
+### `MEDIA_METADATA_ERROR`
+
+Media metadata error
+
+### `METHOD_NOT_ALLOWED`
+
+Method not allowed
+
+### `MFA_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `MISSING_ACCESS`
+
+You don't have access to this resource or feature
+
+### `MISSING_ACL`
+
+Missing ACL
+
+### `MISSING_AUTHORIZATION`
+
+Missing or invalid authorization header
+
+### `MISSING_OAUTH_SCOPE`
+
+The requested scope isn't supported or you don't have permission to request it
+
+### `MISSING_PERMISSIONS`
+
+You don't have the permissions required to perform this action
+
+### `NCMEC_ALREADY_SUBMITTED`
+
+This content has already been submitted to NCMEC
+
+### `NCMEC_SUBMISSION_FAILED`
+
+We couldn't submit the report to NCMEC
+
+### `NOT_A_BOT_APPLICATION`
+
+This application isn't a bot
+
+### `NOT_FOUND`
+
+Not found
+
+### `NOT_FRIENDS_WITH_USER`
+
+You're not friends with this user
+
+### `NOT_IMPLEMENTED`
+
+Not implemented
+
+### `NO_ACTIVE_CALL`
+
+No active call
+
+### `NO_ACTIVE_SUBSCRIPTION`
+
+No active subscription
+
+### `NO_PASSKEYS_REGISTERED`
+
+No passkeys are registered
+
+### `NO_PENDING_DELETION`
+
+Invalid request
+
+### `NO_USERS_WITH_FLUXERTAG_EXIST`
+
+There are too many users with this username
+
+### `NSFW_CONTENT_AGE_RESTRICTED`
+
+NSFW content is age restricted
+
+### `PASSKEY_AUTHENTICATION_FAILED`
+
+Passkey authentication failed
+
+### `PHONE_ADD_NOT_ELIGIBLE`
+
+You are not eligible to add a phone number to your account
+
+### `PHONE_ALREADY_USED`
+
+Phone number is already in use
+
+### `PHONE_COUNTRY_NOT_SUPPORTED`
+
+We don't send verification texts to this country. Use a mobile number from another country, or email support@fluxer.app and a person will review your account
+
+### `PHONE_GATE_ESCAPE_UNAVAILABLE`
+
+This account cannot postpone the phone verification check
+
+### `PHONE_INBOUND_VERIFICATION_REQUIRED`
+
+This number is verified by texting us instead of us texting you. Start phone verification again to get the code and the number to text
+
+### `PHONE_LOOKUP_UNAVAILABLE`
+
+Our phone number check is down right now, so we stopped before sending your code. This is on us, not your number. Wait a few minutes and try the same number again
+
+### `PHONE_NUMBER_NOT_IN_SERVICE`
+
+Your carrier says this number isn't in service. Check the number and try again, or email support@fluxer.app if it's correct
+
+### `PHONE_NUMBER_NOT_MOBILE`
+
+This isn't a mobile number, so it can't receive our text. Use a mobile number, or email support@fluxer.app if you think that's wrong
+
+### `PHONE_RATE_LIMIT_EXCEEDED`
+
+Phone rate limit exceeded
+
+### `PHONE_VERIFICATION_NEEDS_REVIEW`
+
+We couldn't verify this number automatically. Email support@fluxer.app and a person will review your account
+
+### `PHONE_VERIFICATION_REQUIRED`
+
+Phone verification is required
+
+### `PREMIUM_PURCHASE_BLOCKED`
+
+No active subscription
+
+### `PREVIEW_MUST_BE_JPEG`
+
+Preview must be JPEG
+
+### `PROCESSING_FAILED`
+
+We couldn't process the request
+
+### `PROFILE_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `PURCHASE_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `RATE_LIMITED`
+
+You're being rate limited
+
+### `REACTION_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `REGISTRATION_CLOSED`
+
+Registration is closed on this instance
+
+### `REGISTRATION_PENDING_APPROVAL`
+
+This registration is waiting for admin approval
+
+### `REGISTRATION_REJECTED`
+
+This registration request was rejected
+
+### `REGISTRATION_URL_INVALID`
+
+This registration link is invalid or has expired
+
+### `REPORT_ALREADY_RESOLVED`
+
+Report already resolved
+
+### `REPORT_BANNED`
+
+You've been banned from submitting reports
+
+### `REPORT_EMAIL_VERIFICATION_REQUIRED`
+
+Email verification is required for this action
+
+### `RESOURCE_LOCKED`
+
+This resource is being modified
+
+### `RESPONSE_VALIDATION_ERROR`
+
+Response validation failed: {errors}
+
+### `SERVICE_UNAVAILABLE`
+
+Service unavailable
+
+### `SESSION_TOKEN_MISMATCH`
+
+Session token mismatch
+
+### `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS`
+
+This instance is a single community, so additional communities cannot be created
+
+### `SINGLE_COMMUNITY_CANNOT_DELETE`
+
+The community for this instance cannot be deleted
+
+### `SINGLE_COMMUNITY_CANNOT_LEAVE`
+
+You cannot leave the community for this instance
+
+### `SLOWMODE_RATE_LIMITED`
+
+Slowmode rate limited
+
+### `SMS_VERIFICATION_UNAVAILABLE`
+
+Service unavailable
+
+### `SSO_REQUIRED`
+
+Invalid request
+
+### `STREAM_KEY_CHANNEL_MISMATCH`
+
+Stream key channel mismatch
+
+### `STREAM_KEY_SCOPE_MISMATCH`
+
+Stream key scope mismatch
+
+### `STRIPE_ERROR`
+
+Payment processing encountered an error
+
+### `STRIPE_GIFT_REDEMPTION_IN_PROGRESS`
+
+Gift code redemption is in progress
+
+### `STRIPE_INVALID_PRODUCT`
+
+Invalid product selection
+
+### `STRIPE_INVALID_PRODUCT_CONFIGURATION`
+
+Invalid product configuration
+
+### `STRIPE_NO_ACTIVE_SUBSCRIPTION`
+
+No active subscription found
+
+### `STRIPE_NO_PURCHASE_HISTORY`
+
+No purchase history found
+
+### `STRIPE_NO_SUBSCRIPTION`
+
+No subscription found
+
+### `STRIPE_PAYMENT_NOT_AVAILABLE`
+
+Payment processing is temporarily unavailable
+
+### `STRIPE_REFUND_COOLDOWN_ACTIVE`
+
+You can self-serve refund once every 30 days
+
+### `STRIPE_REFUND_OUTSIDE_WINDOW`
+
+This purchase is outside the 3-day self-serve refund window
+
+### `STRIPE_SUBSCRIPTION_ALREADY_CANCELING`
+
+Subscription is already set to cancel at period end
+
+### `STRIPE_SUBSCRIPTION_NOT_CANCELING`
+
+Subscription isn't set to cancel
+
+### `STRIPE_WEBHOOK_NOT_AVAILABLE`
+
+Webhook processing isn't available
+
+### `STRIPE_WEBHOOK_SIGNATURE_INVALID`
+
+Stripe webhook signature is invalid
+
+### `STRIPE_WEBHOOK_SIGNATURE_MISSING`
+
+Stripe webhook signature is missing
+
+### `SUDO_MODE_REQUIRED`
+
+Sudo mode is required
+
+### `TAG_ALREADY_TAKEN`
+
+This tag is already taken
+
+### `TEMPORARY_INVITE_REQUIRES_PRESENCE`
+
+Temporary invite requires presence
+
+### `TEST_HARNESS_DISABLED`
+
+Test harness is disabled
+
+### `TEST_HARNESS_FORBIDDEN`
+
+Test harness is forbidden
+
+### `TWO_FACTOR_REQUIRED`
+
+Two-factor authentication is required
+
+### `TWO_FA_NOT_ENABLED`
+
+Two-factor authentication isn't enabled
+
+### `UNAUTHORIZED`
+
+Unauthorized
+
+### `UNCLAIMED_ACCOUNT_CANNOT_ACCEPT_FRIEND_REQUESTS`
+
+You're not friends with this user
+
+### `UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_CREATE_APPLICATIONS`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS`
+
+You need to complete your account setup before you can create communities
+
+### `UNCLAIMED_ACCOUNT_CANNOT_JOIN_GROUP_DMS`
+
+You can't add yourself to a group DM
+
+### `UNCLAIMED_ACCOUNT_CANNOT_JOIN_ONE_ON_ONE_VOICE_CALLS`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_JOIN_VOICE_CHANNELS`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_MAKE_PURCHASES`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES`
+
+You can't send messages to this user
+
+### `UNCLAIMED_ACCOUNT_CANNOT_SEND_FRIEND_REQUESTS`
+
+Bots can't send friend requests
+
+### `UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES`
+
+Invalid request
+
+### `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS`
+
+You need to complete your account setup before you can submit reports
+
+### `UNKNOWN_APPLICATION`
+
+Unknown application
+
+### `UNKNOWN_CHANNEL`
+
+Channel wasn't found
+
+### `UNKNOWN_EMOJI`
+
+Unknown emoji
+
+### `UNKNOWN_FAVORITE_MEME`
+
+Unknown favorite meme
+
+### `UNKNOWN_GIFT_CODE`
+
+Unknown gift code
+
+### `UNKNOWN_GUILD`
+
+Community wasn't found
+
+### `UNKNOWN_HARVEST`
+
+Unknown harvest
+
+### `UNKNOWN_INVITE`
+
+Invite wasn't found or is no longer valid
+
+### `UNKNOWN_MEMBER`
+
+Member wasn't found in this community
+
+### `UNKNOWN_MESSAGE`
+
+Message wasn't found
+
+### `UNKNOWN_REPORT`
+
+Unknown report
+
+### `UNKNOWN_ROLE`
+
+Role wasn't found
+
+### `UNKNOWN_STICKER`
+
+Unknown sticker
+
+### `UNKNOWN_SUSPICIOUS_FLAG`
+
+Unknown suspicious flag
+
+### `UNKNOWN_USER`
+
+User wasn't found
+
+### `UNKNOWN_USER_FLAG`
+
+The specified user flag isn't recognized
+
+### `UNKNOWN_VOICE_REGION`
+
+Unknown voice region
+
+### `UNKNOWN_VOICE_SERVER`
+
+Unknown voice server
+
+### `UNKNOWN_WEBAUTHN_CREDENTIAL`
+
+Unknown WebAuthn credential
+
+### `UNKNOWN_WEBHOOK`
+
+Unknown webhook
+
+### `UPDATE_FAILED`
+
+We couldn't update the resource
+
+### `USERNAME_NOT_AVAILABLE`
+
+This username is not available
+
+### `USER_BANNED_FROM_GUILD`
+
+This user is banned from this community
+
+### `USER_IP_BANNED_FROM_GUILD`
+
+This user's IP is banned from this community
+
+### `USER_NOT_IN_VOICE`
+
+This user isn't in voice
+
+### `USER_OWNS_GUILDS`
+
+This user owns communities
+
+### `VALIDATION_ERROR`
+
+Validation failed
+
+### `VOICE_CHANNEL_FULL`
+
+Voice channel is full
+
+### `WEBAUTHN_CREDENTIAL_LIMIT_REACHED`
+
+You've reached the maximum of {count, plural, one {# WebAuthn credential} other {# WebAuthn credentials}}
+
+
+## Validation error code registry
+
+These codes appear in the `code` field of an element in the top-level `errors` array on an `INVALID_FORM_BODY` response. Each names one specific input constraint. A schema failure whose constraint names no code of its own reports one of the [default schema failure codes](#default-schema-failure-codes). The registry is closed and holds exactly 236 codes. Each entry states the leading sentence of the English source message, the same way.
+
+### `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES`
+
+You've changed your accent color too often recently
+
+### `AFK_CHANNEL_MUST_BE_IN_GUILD`
+
+AFK channel must be in this community
+
+### `AFK_CHANNEL_MUST_BE_VOICE`
+
+AFK channel must be a voice channel
+
+### `AGE_RESTRICTED`
+
+This sensitive content filter isn't available for your age
+
+### `ALL_CHANNELS_MUST_BELONG_TO_GUILD`
+
+All channels must belong to this community
+
+### `ANIMATED_AVATARS_REQUIRE_PREMIUM`
+
+Animated avatars require Premium
+
+### `ANIMATED_GUILD_BANNER_REQUIRES_FEATURE`
+
+Animated community banner requires ANIMATED_BANNER feature
+
+### `ATTACHMENTS_NOT_ALLOWED_FOR_MESSAGE`
+
+Attachments aren't allowed for this message
+
+### `ATTACHMENT_FIELDS_REQUIRED`
+
+`attachment_id`, `channel_id`, `message_id`, and `expires_at` are required
+
+### `ATTACHMENT_IDS_MUST_BE_VALID_INTEGERS`
+
+`attachment_id`, `channel_id`, and `message_id` must be valid integers
+
+### `ATTACHMENT_ID_NOT_FOUND_IN_MESSAGE`
+
+Attachment with ID {attachmentId} wasn't found in the message
+
+### `ATTACHMENT_MUST_BE_IMAGE`
+
+Attachment "{filename}" must be an image file (png, jpg, jpeg, webp, or gif)
+
+### `AT_LEAST_ONE_ENTRY_IS_REQUIRED`
+
+At least one entry is required
+
+### `AT_LEAST_ONE_RECIPIENT_REQUIRED`
+
+At least one recipient is required
+
+### `AVATAR_CHANGED_TOO_MANY_TIMES`
+
+You've changed your avatar too often recently
+
+### `BANNERS_REQUIRE_PREMIUM`
+
+Banners require Premium
+
+### `BANNER_CHANGED_TOO_MANY_TIMES`
+
+You've changed your banner too often recently
+
+### `BASE64_LENGTH_INVALID`
+
+Base64 string length must be between {min} and {maxLength} characters
+
+### `BIO_CHANGED_TOO_MANY_TIMES`
+
+You've changed your bio too often recently
+
+### `BOT_DISCRIMINATOR_CANNOT_BE_CHANGED`
+
+Bot discriminator can't be changed
+
+### `BOT_SEARCH_SCOPE_UNAVAILABLE`
+
+Bots can only search within a single community or channel
+
+### `BUCKET_IS_REQUIRED`
+
+`bucket` is required
+
+### `CANNOT_ADD_YOURSELF_TO_GROUP_DM`
+
+You can't add yourself to a group DM
+
+### `CANNOT_DELETE_MORE_THAN_100_MESSAGES`
+
+You can't delete more than {max, plural, one {# message} other {# messages}} at once
+
+### `CANNOT_DM_YOURSELF`
+
+You can't DM yourself
+
+### `CANNOT_EDIT_ATTACHMENT_METADATA`
+
+Users with MANAGE_MESSAGES can only edit attachment descriptions, not other metadata
+
+### `CANNOT_LEAVE_GUILD_AS_OWNER`
+
+You can't leave a community as the owner
+
+### `CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF`
+
+You can't position a channel relative to itself or its descendants
+
+### `CANNOT_PRELOAD_MORE_THAN_100_CHANNELS`
+
+You can't preload more than {max, plural, one {# channel} other {# channels}} at once
+
+### `CANNOT_REFERENCE_ATTACHMENTS_WITHOUT_ATTACHMENTS`
+
+You can't reference attachments when no attachments are provided
+
+### `CANNOT_REORDER_EVERYONE_ROLE`
+
+You can't reorder the @everyone role
+
+### `CANNOT_REPLY_TO_SYSTEM_MESSAGE`
+
+You can't reply to a system message
+
+### `CANNOT_SET_HOIST_FOR_EVERYONE_ROLE`
+
+You can't set hoist position for the @everyone role
+
+### `CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER`
+
+You can't specify both `before` and `after`
+
+### `CATEGORIES_CANNOT_HAVE_PARENTS`
+
+Categories can't have parents
+
+### `CATEGORIES_CANNOT_HAVE_PARENT_CHANNEL`
+
+Categories can't have a parent channel
+
+### `CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM`
+
+Changing your discriminator requires Premium
+
+### `CHANNEL_DOES_NOT_EXIST`
+
+Channel not found
+
+### `CHANNEL_ID_IS_REQUIRED`
+
+`channel_id` is required
+
+### `CHANNEL_MUST_BE_DM_OR_GROUP_DM`
+
+Channel must be a DM or a group DM
+
+### `CHANNEL_MUST_BE_VOICE`
+
+Channel must be a voice channel
+
+### `CHANNEL_NAME_EMPTY_AFTER_NORMALIZATION`
+
+Channel name can't be empty after normalization
+
+### `CHANNEL_NOT_FOUND`
+
+Channel not found
+
+### `COLOR_VALUE_TOO_HIGH`
+
+Color value must not exceed 0xffffff
+
+### `COLOR_VALUE_TOO_LOW`
+
+Color value must be at least 0x000000
+
+### `CONTENT_EXCEEDS_MAX_LENGTH`
+
+Text must not exceed {maxLength, plural, one {# character} other {# characters}}
+
+### `CONTEXT_CHANNEL_OR_GUILD_ID_REQUIRED`
+
+A context channel or community ID is required
+
+### `CUSTOM_EMOJIS_REQUIRE_PREMIUM_OUTSIDE_SOURCE`
+
+You can't use custom emojis outside their source communities without Premium
+
+### `CUSTOM_EMOJI_NOT_FOUND`
+
+Custom emoji wasn't found
+
+### `CUSTOM_STICKERS_IN_DMS_REQUIRE_PREMIUM`
+
+You can't use custom stickers in DMs without Premium
+
+### `CUSTOM_STICKERS_REQUIRE_PREMIUM_OUTSIDE_SOURCE`
+
+You can't use custom stickers outside their source communities without Premium
+
+### `CUSTOM_STICKER_NOT_FOUND`
+
+Custom sticker wasn't found
+
+### `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`
+
+Discoverable communities must have a verification level of at least Low
+
+### `DISCRIMINATOR_INVALID_FORMAT`
+
+Discriminator must be {min}–{max} digits
+
+### `DISCRIMINATOR_OUT_OF_RANGE`
+
+Discriminator must be between {min} and {max}
+
+### `DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED`
+
+Duplicate attachment IDs aren't allowed
+
+### `DUPLICATE_FILE_INDEX`
+
+Duplicate file index: {index}
+
+### `DUPLICATE_RECIPIENTS_NOT_ALLOWED`
+
+Duplicate recipients aren't allowed
+
+### `EMAIL_ALREADY_IN_USE`
+
+Email is already in use
+
+### `EMAIL_DOMAIN_NOT_ALLOWED_FOR_SSO`
+
+This email domain isn't allowed for single sign-on
+
+### `EMAIL_IS_REQUIRED`
+
+Email is required
+
+### `EMAIL_LENGTH_INVALID`
+
+Email address must be between {min} and {max} characters
+
+### `EMAIL_MUST_BE_CHANGED_VIA_TOKEN`
+
+Email must be changed via token
+
+### `EMAIL_TOKEN_EXPIRED`
+
+Email token expired
+
+### `EMBEDS_EXCEED_MAX_CHARACTERS`
+
+Embeds must not exceed {maxCharacters, plural, one {# character} other {# characters}} in total
+
+### `EMBED_INDEX_OUT_OF_BOUNDS`
+
+Embed index {embedIndex} is out of bounds (message has {embedCount, plural, one {# embed} other {# embeds}})
+
+### `EMBED_SPLASH_REQUIRES_FEATURE`
+
+Embed splash requires INVITE_SPLASH feature
+
+### `ENTRANCE_SOUND_DURATION_EXCEEDS_LIMIT`
+
+Entrance sounds can be at most {max_ms, number}ms long
+
+### `ENTRANCE_SOUND_INVALID_FORMAT`
+
+Entrance sounds must be MP3, OGG, M4A, or WAV audio
+
+### `ENTRANCE_SOUND_INVALID_SCOPE`
+
+Invalid entrance sound scope
+
+### `ENTRANCE_SOUND_NAME_LENGTH_INVALID`
+
+Entrance sound names must be between 1 and {max, number} characters
+
+### `ENTRANCE_SOUND_NOT_FOUND`
+
+Entrance sound not found
+
+### `ENTRANCE_SOUND_QUOTA_REACHED`
+
+You've reached the limit of {max, number} saved entrance sounds
+
+### `ENTRANCE_SOUND_SIZE_EXCEEDS_LIMIT`
+
+Entrance sounds can be at most {max_bytes, number} bytes
+
+### `FAILED_TO_FETCH_SSO_USER_INFO`
+
+We couldn't fetch your SSO user info
+
+### `FAILED_TO_PARSE_MULTIPART_FORM_DATA`
+
+We couldn't parse the multipart form data
+
+### `FAILED_TO_PARSE_SSO_USER_INFO`
+
+We couldn't read the response from your SSO provider
+
+### `FAILED_TO_UPLOAD_IMAGE`
+
+We couldn't upload the image
+
+### `FAVORITE_MEME_NAME_REQUIRED`
+
+Favorite meme name is required
+
+### `FAVORITE_MEME_NOT_FOUND`
+
+Favorite meme wasn't found
+
+### `FILENAME_EMPTY_AFTER_NORMALIZATION`
+
+Filename can't be empty after normalization
+
+### `FILENAME_INVALID_CHARACTERS`
+
+Filename contains invalid characters
+
+### `FILENAME_LENGTH_INVALID`
+
+Filename must be between {min} and {max} characters
+
+### `FILE_INDEX_EXCEEDS_MAXIMUM`
+
+File index {index} exceeds the maximum allowed index of {maxIndex}
+
+### `FILE_NOT_FOUND`
+
+File not found
+
+### `FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT`
+
+Forwarded messages can't include content, embeds, attachments, or stickers
+
+### `FORWARD_REFERENCE_REQUIRES_CHANNEL_AND_MESSAGE`
+
+Forward message reference must include `channel_id` and `message_id`
+
+### `GLOBAL_NAME_CANNOT_CONTAIN_RESERVED_TERMS`
+
+Display name can't contain "system message"
+
+### `GLOBAL_NAME_LENGTH_INVALID`
+
+Global name must be between {min} and {max} characters
+
+### `GLOBAL_NAME_RESERVED_VALUE`
+
+Global name can't be "everyone" or "here"
+
+### `GUILD_BANNER_REQUIRES_FEATURE`
+
+Community banner requires BANNER feature
+
+### `GUILD_FEATURE_NOT_TOGGLEABLE`
+
+This feature cannot be toggled
+
+### `GUILD_ID_MUST_MATCH_REFERENCED_MESSAGE`
+
+Community ID must match the channel the referenced message was fetched from
+
+### `GUILD_ID_REQUIRED_FOR_SEARCH_INDEX`
+
+Community ID is required for channel message and member search indexes
+
+### `IMAGE_SIZE_EXCEEDS_LIMIT`
+
+Image size exceeds {maxSize} bytes
+
+### `INTEGER_OUT_OF_INT64_RANGE`
+
+Integer value is out of the valid int64 range
+
+### `INVALID_AUDIT_LOG_REASON`
+
+Invalid audit log reason
+
+### `INVALID_BASE64_FORMAT`
+
+The provided value is in an invalid format
+
+### `INVALID_CHANNEL_ID`
+
+Invalid channel ID: {channelId}
+
+### `INVALID_CODE`
+
+Invalid code
+
+### `INVALID_DATE_OF_BIRTH_FORMAT`
+
+Invalid date of birth format
+
+### `INVALID_EMAIL_ADDRESS`
+
+Invalid email address format
+
+### `INVALID_EMAIL_FORMAT`
+
+Invalid email address format
+
+### `INVALID_EMAIL_LOCAL_PART`
+
+Invalid email address format
+
+### `INVALID_EMAIL_OR_PASSWORD`
+
+Invalid email or password
+
+### `INVALID_EMAIL_TOKEN`
+
+Invalid email token
+
+### `INVALID_FILE_FIELD_NAME`
+
+Invalid file field name: {key}
+
+### `INVALID_FORMAT`
+
+The provided value is in an invalid format
+
+### `INVALID_IMAGE_FORMAT`
+
+Invalid image format
+
+### `INVALID_INTEGER_FORMAT`
+
+Invalid integer format
+
+### `INVALID_ISO_TIMESTAMP`
+
+Must be a valid ISO timestamp
+
+### `INVALID_JSON_IN_PAYLOAD_JSON`
+
+Invalid JSON in `payload_json`
+
+### `INVALID_MESSAGE_DATA`
+
+Invalid message data
+
+### `INVALID_MFA_CODE`
+
+Invalid MFA code
+
+### `INVALID_OR_EXPIRED_AUTHORIZATION_TICKET`
+
+Invalid or expired authorization ticket
+
+### `INVALID_OR_EXPIRED_AUTHORIZATION_TOKEN`
+
+Invalid or expired authorization token
+
+### `INVALID_OR_EXPIRED_RESET_TOKEN`
+
+Invalid or expired password reset token
+
+### `INVALID_OR_EXPIRED_REVERT_TOKEN`
+
+Invalid or expired revert token
+
+### `INVALID_OR_EXPIRED_SSO_STATE`
+
+Invalid or expired SSO state
+
+### `INVALID_OR_EXPIRED_TICKET`
+
+Invalid or expired ticket
+
+### `INVALID_OR_EXPIRED_VERIFICATION_TOKEN`
+
+Invalid or expired verification token
+
+### `INVALID_OR_RESTRICTED_RTC_REGION`
+
+Invalid or restricted RTC region: {region}
+
+### `INVALID_PARENT_CHANNEL`
+
+Invalid parent channel
+
+### `INVALID_PASSWORD`
+
+Invalid email or password
+
+### `INVALID_PROOF_TOKEN`
+
+Invalid proof token
+
+### `INVALID_ROLE_ID`
+
+Invalid role ID: {roleId}
+
+### `INVALID_RTC_REGION`
+
+Invalid RTC region: {region}
+
+### `INVALID_SNOWFLAKE`
+
+Invalid snowflake
+
+### `INVALID_SNOWFLAKE_FORMAT`
+
+Invalid snowflake
+
+### `INVALID_SSO_AUTHORIZATION_CODE`
+
+Invalid SSO authorization code
+
+### `INVALID_SSO_TOKEN`
+
+Invalid SSO token
+
+### `INVALID_TIMEOUT_VALUE`
+
+Invalid timeout value
+
+### `INVALID_TIMEZONE_IDENTIFIER`
+
+Invalid timezone identifier
+
+### `INVALID_TRUSTED_DOMAINS`
+
+The wildcard (*) can't be combined with specific domains
+
+### `INVALID_URL_FORMAT`
+
+Invalid URL format
+
+### `INVALID_URL_OR_ATTACHMENT_FORMAT`
+
+Invalid URL format or attachment reference
+
+### `INVALID_VERIFICATION_CODE`
+
+Invalid verification code
+
+### `INVITE_SPLASH_REQUIRES_FEATURE`
+
+Invite splash requires INVITE_SPLASH feature
+
+### `MAX_FAVORITE_MEME_TAGS_EXCEEDED`
+
+You can only add up to {limit, plural, one {# tag} other {# tags}} per favorite meme
+
+### `MEDIA_ALREADY_IN_FAVORITE_MEMES`
+
+This media is already in your favorite memes
+
+### `MESSAGES_ARRAY_REQUIRED_AND_NOT_EMPTY`
+
+`messages` array is required and must not be empty
+
+### `MESSAGES_WITH_SNAPSHOTS_CANNOT_BE_EDITED`
+
+Messages with snapshots can't be edited
+
+### `MESSAGE_HISTORY_CUTOFF_BEFORE_GUILD_CREATION`
+
+Message history cutoff can't be before the community was created
+
+### `MESSAGE_HISTORY_CUTOFF_IN_FUTURE`
+
+Message history cutoff can't be in the future
+
+### `MESSAGE_IDS_CANNOT_BE_EMPTY`
+
+`message_ids` can't be empty
+
+### `MULTIPLE_FILES_FOR_INDEX_NOT_ALLOWED`
+
+Multiple files for index {index} aren't allowed
+
+### `MUST_AGREE_TO_TOS_AND_PRIVACY_POLICY`
+
+You must agree to the Terms of Service and Privacy Policy
+
+### `MUST_BE_MINIMUM_AGE`
+
+You must be at least {minAge, plural, one {# year} other {# years}} old to create an account
+
+### `MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS`
+
+You must enable 2FA on your account before requiring it for moderators
+
+### `MUST_HAVE_EMAIL_TO_CHANGE_IT`
+
+You must have an email address to change it
+
+### `MUST_START_SESSION_BEFORE_SENDING`
+
+You must start a session before sending messages
+
+### `NAME_EMPTY_AFTER_NORMALIZATION`
+
+Name can't be empty after normalization
+
+### `NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO`
+
+Only image or video attachments can be reported to NCMEC
+
+### `NEW_EMAIL_MUST_BE_DIFFERENT`
+
+New email must be different from your current email
+
+### `NOT_A_VALID_UNICODE_EMOJI`
+
+Not a valid unicode emoji
+
+### `NO_FILE_FOR_ATTACHMENT`
+
+No file was uploaded for attachment with ID {attachmentId}
+
+### `NO_FILE_FOR_ATTACHMENT_METADATA`
+
+No file was uploaded for attachment metadata with ID {attachmentId}
+
+### `NO_NEW_EMAIL_REQUESTED`
+
+No new email was requested
+
+### `NO_ORIGINAL_EMAIL_ON_RECORD`
+
+No original email is on record
+
+### `NO_UPLOADED_PARTS_TO_FINALIZE`
+
+No uploaded parts are available to finalize
+
+### `NO_VALID_MEDIA_IN_MESSAGE`
+
+No valid media was found in the message
+
+### `ORIGINAL_EMAIL_ALREADY_VERIFIED`
+
+Original email is already verified
+
+### `ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST`
+
+Original email must be verified first
+
+### `ORIGINAL_VERIFICATION_NOT_REQUIRED`
+
+Original verification isn't required for this flow
+
+### `PARENT_CHANNEL_NOT_IN_GUILD`
+
+Parent channel isn't present in the community
+
+### `PARENT_MUST_BE_CATEGORY`
+
+Parent must be a category
+
+### `PARSE_AND_USERS_OR_ROLES_CANNOT_BE_USED_TOGETHER`
+
+You can't use `parse` together with `allowed_mentions.users` or `allowed_mentions.roles`
+
+### `PASSWORD_IS_TOO_COMMON`
+
+Your password is too common
+
+### `PASSWORD_LENGTH_INVALID`
+
+String length must be between {min} and {max} characters
+
+### `PASSWORD_NOT_SET`
+
+Password isn't set
+
+### `PHONE_NUMBER_INVALID_FORMAT`
+
+Phone number must be in E.164 format (for example, +1234567890)
+
+### `PRECEDING_CHANNEL_MUST_SHARE_PARENT`
+
+Preceding channel must share the same parent as the moved channel
+
+### `PRECEDING_CHANNEL_NOT_IN_GUILD`
+
+Preceding channel isn't present in the community
+
+### `PRONOUNS_CHANGED_TOO_MANY_TIMES`
+
+You've changed your pronouns too often recently
+
+### `RECIPIENT_IDS_CANNOT_BE_EMPTY`
+
+Recipient IDs can't be empty
+
+### `RECIPIENT_IDS_MUST_BE_STRINGS`
+
+Recipient IDs must be strings
+
+### `RECIPIENT_IDS_MUST_BE_VALID_SNOWFLAKES`
+
+Recipient IDs must be valid snowflakes
+
+### `REFERENCED_ATTACHMENT_NOT_FOUND`
+
+Referenced attachment "{filename}" wasn't found in message attachments
+
+### `ROWS_IS_REQUIRED`
+
+`rows` is required
+
+### `SESSION_TIMEOUT`
+
+Session timed out
+
+### `SIZE_BYTES_MUST_BE_VALID_INTEGER`
+
+`size_bytes` must be a valid integer
+
+### `SNOWFLAKE_OUT_OF_RANGE`
+
+Invalid snowflake
+
+### `SSO_IDENTITY_MISMATCH`
+
+SSO identity mismatch between id_token and userinfo
+
+### `SSO_MISCONFIGURED`
+
+SSO is misconfigured
+
+### `SSO_PROVIDER_DID_NOT_RETURN_EMAIL`
+
+Your SSO provider didn't return an email address
+
+### `SSO_TEST_CODE_MISSING_EMAIL`
+
+SSO test code is missing the email payload
+
+### `SSO_UNABLE_TO_ALLOCATE_DISCRIMINATOR`
+
+We couldn't allocate a discriminator for your SSO account
+
+### `STRING_LENGTH_EXACT`
+
+String must be exactly {length} characters
+
+### `STRING_LENGTH_INVALID`
+
+String length must be between {min} and {max} characters
+
+### `SYSTEM_CHANNEL_MUST_BE_IN_GUILD`
+
+System channel must be in this community
+
+### `SYSTEM_CHANNEL_MUST_BE_TEXT`
+
+System channel must be a text channel
+
+### `TAG_ALREADY_TAKEN`
+
+This tag is already taken
+
+### `THIS_VANITY_URL_IS_ALREADY_TAKEN`
+
+This vanity URL is already taken
+
+### `TICKET_ALREADY_COMPLETED`
+
+This ticket has already been completed
+
+### `TIMEOUT_CANNOT_EXCEED_365_DAYS`
+
+Timeout can't be longer than {maxDays, plural, one {# day} other {# days}} from now
+
+### `TOO_LARGE`
+
+Synced preferences exceed {max_bytes} bytes
+
+### `TOO_MANY_EMBEDS`
+
+Too many embeds
+
+### `TOO_MANY_FILES`
+
+Too many files
+
+### `TOO_MANY_USERS_WITH_THIS_USERNAME`
+
+There are too many users with this username
+
+### `TOO_MANY_USERS_WITH_USERNAME_TRY_DIFFERENT`
+
+There are too many users with this username
+
+### `TOTP_NOT_ENABLED`
+
+Authenticator app two-factor isn't enabled for this account
+
+### `UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN`
+
+Unclaimed accounts can only set email via token
+
+### `UNRESOLVED_ATTACHMENT_URL`
+
+Unresolved `attachment://` URL detected
+
+### `UPLOADED_ATTACHMENT_NOT_FOUND`
+
+Uploaded attachment {filename} wasn't found
+
+### `URL_LENGTH_INVALID`
+
+URL must be between {min} and {max} characters
+
+### `URL_NOT_PUBLICLY_ROUTABLE`
+
+URL must resolve to a publicly routable address
+
+### `USERNAME_CANNOT_CONTAIN_RESERVED_TERMS`
+
+Username can't contain "fluxer" or "system message"
+
+### `USERNAME_CHANGED_TOO_MANY_TIMES`
+
+You've changed your username too often recently
+
+### `USERNAME_INVALID_CHARACTERS`
+
+Username can only contain Latin letters (a-z, A-Z), numbers (0-9), and underscores (_)
+
+### `USERNAME_LENGTH_INVALID`
+
+Username must be between {min} and {max} characters
+
+### `USERNAME_RESERVED_VALUE`
+
+Username can't be "everyone" or "here"
+
+### `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS`
+
+This user doesn't have an email address
+
+### `USER_IS_NOT_BANNED`
+
+This user isn't banned
+
+### `USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER`
+
+User must be a bot to be marked as a system user
+
+### `USER_NOT_IN_CHANNEL`
+
+This user isn't in the channel
+
+### `VALUE_MUST_BE_INTEGER_IN_RANGE`
+
+The value for `{name}` must be an integer between {minValue} and {maxValue}
+
+### `VANITY_URL_CODE_ALREADY_TAKEN`
+
+Vanity URL code is already taken
+
+### `VANITY_URL_CODE_CANNOT_CONTAIN_FLUXER`
+
+Vanity URL code can't contain "fluxer"
+
+### `VANITY_URL_CODE_LENGTH_INVALID`
+
+Vanity URL code must be between {min} and {max} characters
+
+### `VANITY_URL_INVALID_CHARACTERS`
+
+Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-)
+
+### `VANITY_URL_REQUIRES_FEATURE`
+
+Vanity URL requires VANITY_URL feature
+
+### `VERIFICATION_CODE_EXPIRED`
+
+Verification code has expired
+
+### `VERIFICATION_CODE_NOT_ISSUED`
+
+No verification code has been issued
+
+### `VISIONARY_REQUIRED_FOR_DISCRIMINATOR`
+
+You must be on the Visionary lifetime plan to use that discriminator
+
+### `VOICE_ACTIVITY_SHARING_ON_COOLDOWN`
+
+You can only update your voice activity sharing default once every 24 hours
+
+### `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS`
+
+Voice channels can't be positioned above text channels within the same category
+
+### `VOICE_MESSAGES_ATTACHMENT_DURATION_REQUIRED`
+
+Voice message attachments must specify a duration
+
+### `VOICE_MESSAGES_ATTACHMENT_MUST_BE_AUDIO`
+
+Voice message attachments must be audio files
+
+### `VOICE_MESSAGES_ATTACHMENT_WAVEFORM_REQUIRED`
+
+Voice message attachments must include waveform data
+
+### `VOICE_MESSAGES_CANNOT_HAVE_CONTENT`
+
+Voice messages can't have text content
+
+### `VOICE_MESSAGES_CANNOT_HAVE_EMBEDS`
+
+Voice messages can't have embeds
+
+### `VOICE_MESSAGES_CANNOT_HAVE_FAVORITE_MEMES`
+
+Voice messages can't have favorite memes
+
+### `VOICE_MESSAGES_CANNOT_HAVE_STICKERS`
+
+Voice messages can't have stickers
+
+### `VOICE_MESSAGES_DURATION_EXCEEDS_LIMIT`
+
+Voice message duration can't exceed {maxDuration, plural, one {# second} other {# seconds}}
+
+### `VOICE_MESSAGES_REQUIRE_SINGLE_ATTACHMENT`
+
+Voice messages must contain exactly one attachment
+
+### `WEBHOOK_NAME_LENGTH_INVALID`
+
+Webhook name must be between {min} and {max} characters
+
+
+## Localisation
+
+An error `message` is rendered in one resolved locale. Fluxer uses the configured locale of the authenticated account when the request is authenticated and the account has one. Otherwise it negotiates the request `Accept-Language` value against the canonical [supported locale registry](/topics/locales/#supported-locales). [Locales](/topics/locales/#negotiation) defines the resolution algorithm, including weights, language subtag reduction, and the `en-US` result.
+
+A failure raised before the request locale is resolved, such as an IP ban denial, cannot see the account locale and negotiates `Accept-Language` on its own. That negotiation reads the header entries in order, ignores quality weights, and falls back to `en-US`.
+
+Fluxer localises the `message` of a validation element that has a `code` the same way. A validation element with no `code` has the fixed English string written at the failure site. The `code` field is never localised, either in the envelope or in a validation element.
+
+A code whose catalogue entry is missing for the resolved locale falls back to its English source template. A code with no registered template at all falls back to the message the failure supplied or to the code itself.
diff --git a/fluxer_docs/src/content/docs/http-api/expressions.mdx b/fluxer_docs/src/content/docs/http-api/expressions.mdx
new file mode 100644
index 000000000..87743af64
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/expressions.mdx
@@ -0,0 +1,158 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Expressions
+description: Cross-guild lookup of a custom emoji or sticker by its own ID.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An expression is a custom emoji or a sticker that one guild owns. The two routes here resolve an expression from its own identifier, without membership of the owning guild.
+
+| Object | Route | Owning resource |
+| --- | --- | --- |
+| [Emoji metadata](#emoji-metadata-object) | [Get emoji metadata](#get-emoji-metadata) | [Guild emojis](/http-api/guild-emojis/) |
+| [Sticker metadata](#sticker-metadata-object) | [Get sticker metadata](#get-sticker-metadata) | [Guild stickers](/http-api/guild-stickers/) |
+
+Both routes are read-only, and neither writes an audit entry or emits a [Gateway Dispatch](/gateway/events/).
+
+An identifier that names no stored expression returns 404 `UNKNOWN_EMOJI` or 404 `UNKNOWN_STICKER`. A stored expression whose guild no longer exists returns 404 `UNKNOWN_GUILD`.
+
+Both routes return the metadata even when the owning guild has [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features).
+
+:::note[Tags and the uploader need a guild-scoped operation]
+Tags, the uploader, and the adult content classification come from the guild-scoped operations, which require membership of the owning guild.
+:::
+
+## Emoji metadata object
+
+One custom emoji, readable from outside its guild. It adds the owning guild to the [guild emoji object](/http-api/guild-emojis/#guild-emoji-object) and drops the adult content classification and the uploader.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | snowflake | The ID of the emoji |
+| guild_id2 | snowflake | The ID of the guild that owns the emoji |
+| name3 | string | The name of the emoji (2-32 characters) |
+| animated4 | boolean | Whether the stored image is animated |
+| allow_cloning5 | boolean | Whether the owning guild permits the emoji to be copied |
+
+1 Unique across every guild, so the emoji is addressable without its guild
+
+2 This resource has no guild name and no guild icon
+
+3 [Create guild emoji](/http-api/guild-emojis/#create-guild-emoji) restricts the value to ASCII letters, digits, and underscore, and cloning copies it unchanged
+
+4 Fluxer detects this from the submitted image at creation, and the value never changes
+
+5 False exactly when the owning guild has [CLONE_EMOJI_DISABLED](/http-api/guilds/#guild-features)
+
+Every member is always present, and no member is nullable.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "guild_id": "1489002177550843904",
+ "name": "party_parrot",
+ "animated": true,
+ "allow_cloning": true
+}
+```
+
+## Sticker metadata object
+
+One sticker, readable from outside its guild. It adds the owning guild to the [guild sticker object](/http-api/guild-stickers/#guild-sticker-object) and drops the description, the tags, the adult content classification, and the uploader.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | snowflake | The ID of the sticker |
+| guild_id2 | snowflake | The ID of the guild that owns the sticker |
+| name3 | string | The name of the sticker (2-30 characters) |
+| animated4 | boolean | Whether the stored image is animated |
+| allow_cloning5 | boolean | Whether the owning guild permits the sticker to be copied |
+
+1 Unique across every guild, so the sticker is addressable without its guild
+
+2 This resource has no guild name and no guild icon
+
+3 The value accepts any character
+
+4 Fluxer detects this from the submitted image at creation, and the value never changes
+
+5 False exactly when the owning guild has [CLONE_STICKER_DISABLED](/http-api/guilds/#guild-features)
+
+Every member is always present, and no member is nullable.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998183",
+ "guild_id": "1489002177550843904",
+ "name": "shipit",
+ "animated": false,
+ "allow_cloning": false
+}
+```
+
+## Get emoji metadata
+
+
+
+Returns the [emoji metadata object](#emoji-metadata-object) of any guild emoji. The route requires no membership of the owning guild and evaluates no permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emoji_id | snowflake | The ID of the emoji |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [emoji metadata](#emoji-metadata-object) object | Metadata was returned |
+| 4041 | [error response](/http-api/#error-response) | Emoji does not exist, or its owning guild no longer exists |
+
+1 The [error code](/http-api/errors/) is `UNKNOWN_EMOJI` for a missing emoji and `UNKNOWN_GUILD` when the emoji record survives but its guild does not
+
+:::caution[The emoji's guild is read again at clone time]
+[Clone guild emoji](/http-api/guild-emojis/#clone-guild-emoji) resolves the source emoji and the source guild again. When the source guild disables cloning between the two calls, the clone fails with 403 `MISSING_ACCESS`.
+:::
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `guild:emoji:metadata::user_id` bucket, and every emoji the account looks up draws on that one budget.
+
+## Get sticker metadata
+
+
+
+Returns the [sticker metadata object](#sticker-metadata-object) of any guild sticker. The route requires no membership of the owning guild and evaluates no permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sticker_id | snowflake | The ID of the sticker |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [sticker metadata](#sticker-metadata-object) object | Metadata was returned |
+| 4041 | [error response](/http-api/#error-response) | Sticker does not exist, or its owning guild no longer exists |
+
+1 The [error code](/http-api/errors/) is `UNKNOWN_STICKER` for a missing sticker and `UNKNOWN_GUILD` when the sticker record survives but its guild does not
+
+:::caution[The sticker's guild is read again at clone time]
+[Clone guild sticker](/http-api/guild-stickers/#clone-guild-sticker) resolves the source sticker and the source guild again. When the source guild disables cloning between the two calls, the clone fails with 403 `MISSING_ACCESS`.
+:::
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `guild:sticker:metadata::user_id` bucket, and every sticker the account looks up draws on that one budget.
diff --git a/fluxer_docs/src/content/docs/http-api/gateway.mdx b/fluxer_docs/src/content/docs/http-api/gateway.mdx
new file mode 100644
index 000000000..2222d538a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/gateway.mdx
@@ -0,0 +1,112 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gateway
+description: Bot discovery of the main Gateway endpoint, shard count, and session start limit.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Gateway information tells a bot where to open its connection to the [main Gateway](/gateway/overview/). The object has that URL, a shard count, and a session start budget. Framing, opcodes, heartbeats, resumption, and close codes belong to the [Gateway overview](/gateway/overview/).
+
+The one route here reads `Authorization` itself. Only the [bot token](/authentication/#bot-tokens) form is accepted. A user client instead reads the same WebSocket URL from `endpoints.gateway` in the [instance discovery document](/http-api/instance/#instance-endpoints-object) and presents its session token inside [Identify](/gateway/commands/#identify).
+
+## Gateway information object
+
+Every field is always present.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | The `ws` or `wss` URL of the main Gateway endpoint |
+| shards2 | integer | The shard count recommended for the authenticated bot |
+| session_start_limit3 | [session start limit](#session-start-limit-object) object | The advertised session start budget |
+
+1 The deployment's configured Gateway endpoint, byte for byte the string [instance endpoints](/http-api/instance/#instance-endpoints-object) publishes as `endpoints.gateway`
+
+2 Always the literal `1`
+
+3 Every member of the object is a constant
+
+Fluxer appends no query string, so a client appends the [connection parameters](/gateway/overview/#connection-parameters) itself. A client MUST NOT upgrade a published `ws` value, because a deliberately plain HTTP deployment advertises one.
+
+### Example
+
+```json
+{
+ "url": "wss://gateway.example.com",
+ "shards": 1,
+ "session_start_limit": {
+ "total": 1000,
+ "remaining": 999,
+ "reset_after": 14400000,
+ "max_concurrency": 1
+ }
+}
+```
+
+[Identify](/gateway/commands/#identify) accepts a `shard` pair whose `shard_count` element is from 1 through 16,384 under the [sharding](/gateway/overview/#sharding) contract, whatever `shards` reports. A bot session whose shard is assigned more than 2,500 guilds closes with `4011`, and the bot must shard further. A user session is never checked against the guild ceiling.
+
+:::note[The response is currently static]
+Fluxer resolves only `url` from deployment configuration. `shards` and `session_start_limit` are fixed values that do not track the bot's guild count. Starting a session decrements neither value.
+:::
+
+## Session start limit object
+
+A session start limit reports how many new Gateway sessions a bot may open in a window. Fluxer publishes four constants here for client-library compatibility.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| total1 | integer | The session starts allowed across the window, always `1000` |
+| remaining1 | integer | The session starts reported as left, always `999` |
+| reset_after2 | integer | The milliseconds reported until the budget resets, always `14400000` |
+| max_concurrency3 | integer | The number of concurrent [Identify](/gateway/commands/#identify) buckets, always `1` |
+
+1 Fluxer keeps no session-start ledger, so neither a request nor an Identify changes either value
+
+2 14400000 ms is four hours, and Fluxer runs no timer against it
+
+3 Fluxer runs no Identify concurrency bucket, so a bot MAY identify its shards without pacing them against this value
+
+The limits the Gateway actually enforces live in [Session lifecycle](/gateway/limits-and-rate-limits/#session-lifecycle). Fluxer budgets Identify per source address. It caps a user account at a fixed number of concurrent sessions. Neither bound is reported here.
+
+:::caution[A remaining session start does not guarantee admission]
+A non-zero `remaining` reserves nothing, and neither does the constant `max_concurrency`. [Session admission](/gateway/limits-and-rate-limits/#session-lifecycle) can still hold or reject a connection.
+:::
+
+## Get Gateway information
+
+
+
+Returns a [Gateway information object](#gateway-information-object).
+
+The route matches the scheme prefix without regard to case and accepts `Bot `, `Bearer `, or a bare token, then trims the remainder. An empty remainder returns 401 `MISSING_AUTHORIZATION`. A remainder beginning with `flx_` returns 401 `INVALID_AUTH_TOKEN`, because that is the user session token prefix. The route accepts any other remainder only when it has a full stop that is neither its first nor its last character, with decimal digits before it.
+
+:::caution[Only the token form is enforced]
+The route checks the shape of the credential only. A well-formed value naming no live application receives the same 200 as a real bot token.
+:::
+
+Validate a token with [Identify](/gateway/commands/#identify) or [Get bot application](/http-api/applications/#get-bot-application) instead.
+
+A 200 has the informational [rate limit headers](/topics/rate-limits/#rate-limit-headers) only for the `Bot ` prefix. Fluxer keys the bare form and the `Bearer ` form on the client IP address.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [Gateway information](#gateway-information-object) object | The credential is in bot token form |
+| 4011 | [error response](/http-api/#error-response) | The credential is absent, is empty after its scheme prefix, or is not in bot token form |
+
+1 An absent or empty credential uses `MISSING_AUTHORIZATION`, and every other rejected value uses `INVALID_AUTH_TOKEN`
+
+### Side effects
+
+Fluxer records an authentication failure signal of weight 1 against the client IP address for every credential it does not resolve to an account. A bot token resolves only under the `Bot ` prefix, so the bare form and the `Bearer ` form record that signal even on the requests this route answers 200. A client avoids that signal by presenting the `Bot ` prefix.
+
+A 401 adds a client error signal of weight 0.75, but only when the request resolved no account. A live user session token resolves an account under the `Bearer ` prefix and under no prefix, so the 401 it receives records no signal. Both signals feed the automatic address ban described in [Errors](/http-api/errors/).
+
+### Rate limit
+
+60 requests per minute for each authenticated account, or for each client IP address when no account resolved, on the `gateway:bot_info` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/gifs.mdx b/fluxer_docs/src/content/docs/http-api/gifs.mdx
new file mode 100644
index 000000000..e8ebe1d3a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/gifs.mdx
@@ -0,0 +1,332 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: GIFs
+description: Provider-backed GIF search, featured and trending listings, search suggestions, and share registration.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A GIF is one animated media item held by the deployment's active GIF provider. Fluxer stores none of them, so every route on this page reads the provider live and returns provider URLs beside signed [Media Proxy](/media-proxy/overview/) URLs.
+
+Every route requires a session credential. A bot token and an OAuth2 bearer credential are both refused with 403 `ACCESS_DENIED`. [Save meme from URL](/http-api/memes/#save-meme-from-url) saves a result into the account's own collection.
+
+## Provider availability
+
+`klipy` is the only provider name a deployment binds. [Get instance discovery](/http-api/instance/#get-instance-discovery) publishes it as the [GIF provider](/http-api/instance/#gif-provider-object) object. An instance that has bound no provider key refuses every route on this page with 403 `FEATURE_TEMPORARILY_DISABLED`. That same key drives `gif_enabled` in [service availability](/http-api/instance/#service-availability-object), a flag an operator can also set by hand, so a client that reads `gif_enabled` as true can still receive the 403.
+
+A provider outage, a request that outlives its deadline, and an unreadable provider payload all surface as 503 `SERVICE_UNAVAILABLE`. The deadline is 12 seconds everywhere except [Register a GIF share](#register-a-gif-share), which uses 3 seconds. An operator can change both.
+
+## Locale and country
+
+Every route takes an optional `locale` drawn from the [supported locale registry](/topics/locales/#supported-locales), in the query string on the four GET routes and in the body of [Register a GIF share](#register-a-gif-share). It defaults to `en-US`. A tag outside the registry fails with 400 `INVALID_FORM_BODY`.
+
+Fluxer also derives a two-letter country from the requesting address by geolocation, and uses `US` when geolocation resolves none. No route accepts the country as a parameter, so a client cannot override it. [Get GIF search suggestions](#get-gif-search-suggestions) is the one route that ignores it.
+
+## Provider headers
+
+Every response under `/gifs`, `/tenor`, and `/klipy` has these three headers, including error responses and responses to a path no route claims. The three values repeat the [GIF provider](/http-api/instance/#gif-provider-object) object, so a client follows a provider change without refetching `/.well-known/fluxer`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-GIF-Provider1 | string | The machine name of the active provider, which is `klipy` on every deployment today |
+| X-Fluxer-GIF-Provider-Display-Name1 | string | The provider name a client displays |
+| X-Fluxer-GIF-Provider-Attribution-Required1 | string | The literal `true` or `false`, stating whether a client shows the provider attribution mark |
+
+1 All three are absent together while the instance has bound no provider key, which is the same condition that answers 403 `FEATURE_TEMPORARILY_DISABLED`
+
+## Result freshness
+
+Fluxer caches one answer for each combination of operation, locale, country, and query. Past the refresh bound a request still receives the stored answer, and past the discard bound it waits for a fresh answer.
+
+| Answer | Refresh bound | Discard bound |
+| --- | --- | --- |
+| Search results | 30 seconds | 5 minutes |
+| Search suggestions | 1 minute | 10 minutes |
+| Featured GIFs and trending GIFs | 5 minutes | 30 minutes |
+| Featured categories | 24 hours | 48 hours |
+
+A deployment can configure a further cache in front of those bounds. Neither cache is keyed by the caller, so two accounts sending the same query in the same country receive the same answer. No route on this page invalidates a cached answer.
+
+## GIF object
+
+A GIF object is one media item the active provider owns. Every URL in it resolves to bytes the provider hosts.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | The provider-stable identifier of the GIF |
+| slug1 | string | The canonical slug used to share the GIF or resolve it again |
+| provider | string | The name of the provider that produced the GIF (1-32 characters) |
+| title | string | The title the provider gives the GIF |
+| url | string | The provider page URL for the GIF |
+| src2 | string | The direct media URL of the format the server chose |
+| proxy_src2 | string | The [Media Proxy](/media-proxy/overview/) URL of that same format |
+| width2 | integer | The width of the chosen format in pixels |
+| height2 | integer | The height of the chosen format in pixels |
+| media3 | map[string, [GIF media format](/http-api/memes/#gif-media-format-object) object] | The formats keyed by [GIF media format name](/http-api/memes/#gif-media-format-names) |
+| placeholder?4 | ?string | The compact thumbhash placeholder rendered while the GIF loads |
+
+1 The active provider writes the same slug into both, so `id` is the value [Register a GIF share](#register-a-gif-share) takes
+
+2 Copied from the `webm` entry of `media` when the provider returned one, and from the first entry it returned otherwise
+
+3 Empty when no format the provider returned could be proxied. No key is guaranteed, so a client walks a priority list
+
+4 The active provider emits none, so the field is absent from every GIF these routes return. [Resolve GIF URLs](/http-api/memes/#resolve-gif-urls) is the operation that produces one
+
+A GIF has no snowflake and no creation timestamp. `id` is the provider's own identifier, so it does not sort by time, and no other Fluxer route resolves it.
+
+### Example
+
+```json
+{
+ "id": "goat-plays-banjo-4",
+ "slug": "goat-plays-banjo-4",
+ "provider": "klipy",
+ "title": "Goat Plays Banjo",
+ "url": "https://klipy.com/gifs/goat-plays-banjo-4",
+ "src": "https://static.example.com/goat-plays-banjo.webm",
+ "proxy_src": "https://media.example.com/external/9f21c0/goat-plays-banjo.webm",
+ "width": 220,
+ "height": 229,
+ "media": {
+ "webm": {
+ "src": "https://static.example.com/goat-plays-banjo.webm",
+ "proxy_src": "https://media.example.com/external/9f21c0/goat-plays-banjo.webm",
+ "width": 220,
+ "height": 229
+ },
+ "tinygif": {
+ "src": "https://static.example.com/goat-plays-banjo-tiny.gif",
+ "proxy_src": "https://media.example.com/external/4ad117/goat-plays-banjo-tiny.gif",
+ "width": 137,
+ "height": 90
+ }
+ }
+}
+```
+
+## GIF category object
+
+A GIF category object pairs a category search term with a preview GIF for that term.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | The search term the category stands for, translated for the requested locale |
+| src1 | string | The direct media URL of the preview GIF |
+| proxy_src1 | string | The [Media Proxy](/media-proxy/overview/) URL of the preview GIF |
+| gif | ?[GIF](#gif-object) object | The preview GIF, or null when the preview lookup returned nothing |
+
+1 Copied from the same field of `gif`, and the empty string when `gif` is null
+
+To open the category, a client sends `name` as the `q` of [Search GIFs](#search-gifs).
+
+## Deprecated vendor paths
+
+Two vendor-named prefixes serve the same five handlers as `/gifs`. Each answers exactly as its successor does and has the same [provider headers](#provider-headers).
+
+| Method | Deprecated path | Successor |
+| --- | --- | --- |
+| GET | `/v1/tenor/search` | [Search GIFs](#search-gifs) |
+| GET | `/v1/tenor/featured` | [Get featured GIFs](#get-featured-gifs) |
+| GET | `/v1/tenor/trending-gifs` | [Get trending GIFs](#get-trending-gifs) |
+| GET | `/v1/tenor/suggest` | [Get GIF search suggestions](#get-gif-search-suggestions) |
+| POST | `/v1/tenor/register-share` | [Register a GIF share](#register-a-gif-share) |
+| GET | `/v1/klipy/search` | [Search GIFs](#search-gifs) |
+| GET | `/v1/klipy/featured` | [Get featured GIFs](#get-featured-gifs) |
+| GET | `/v1/klipy/trending-gifs` | [Get trending GIFs](#get-trending-gifs) |
+| GET | `/v1/klipy/suggest` | [Get GIF search suggestions](#get-gif-search-suggestions) |
+| POST | `/v1/klipy/register-share` | [Register a GIF share](#register-a-gif-share) |
+
+:::caution[The vendor prefixes spell trending differently]
+Trending is `/trending` under `/gifs` and `/trending-gifs` under both vendor prefixes. The other four paths keep the same last segment on all three prefixes.
+:::
+
+A response from one of the ten has three further headers.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Deprecation | string | The literal value `true` |
+| Link | string | The literal value `; rel="successor-version"` |
+| Warning1 | string | A 299 warning naming the successor prefix |
+
+1 The exact value is `299 - "Use /gifs/* instead - these vendor-specific paths are deprecated and will be removed."`
+
+The vendor name in the path selects nothing. A request to `/v1/tenor/search` reaches the same active provider as `/v1/gifs/search`, and the `provider` field of every GIF it returns names that provider.
+
+## Search GIFs
+
+
+
+Searches the active provider for GIFs matching a query. Returns at most 50 [GIF objects](#gif-object) in the order the provider returned them.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| q | string | The search term (1-256 characters) |
+| locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` |
+
+Fluxer does not stem, expand, or spell-correct the term. A term that matches nothing returns 200 with an empty array.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[GIF](#gif-object) object] | Results were returned, possibly as an empty array |
+| 400 | [error response](/http-api/#error-response) | `q` is absent, empty, or longer than 256 characters, or `locale` is outside the registry, each returning `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 503 | [error response](/http-api/#error-response) | The provider failed, the request outlived its deadline, or the `gifs` service answered with a payload the API could not read, each returning `SERVICE_UNAVAILABLE` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `gif:search` bucket.
+
+## Get featured GIFs
+
+
+
+Returns the provider's featured GIFs and the category terms offered alongside them.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| gifs1 | array[[GIF](#gif-object) object] | The featured GIFs for the resolved locale and country |
+| categories2 | array[[GIF category](#gif-category-object) object] | The category terms offered for the resolved locale |
+
+1 At most one entry. [Get trending GIFs](#get-trending-gifs) is the route that returns a full listing
+
+2 At most 50 entries, deduplicated by search term and kept in the order the provider returned them
+
+Fluxer resolves the category list against the country `US` for every request, whatever country geolocation produced, so two callers in different countries receive the same `categories` and different `gifs`. A category whose preview lookup returned nothing is still listed, with `gif` null and both URLs empty.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Featured GIFs and categories were returned |
+| 400 | [error response](/http-api/#error-response) | `locale` is outside the registry and the request returns `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 503 | [error response](/http-api/#error-response) | The provider failed, the request outlived its deadline, or the `gifs` service answered with a payload the API could not read, each returning `SERVICE_UNAVAILABLE` |
+
+Fluxer fetches both halves in one operation. A failure on either one fails the whole request and returns no partial body.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `gif:featured` bucket.
+
+## Get trending GIFs
+
+
+
+Returns at most 50 trending [GIF objects](#gif-object) for the resolved locale and country.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[GIF](#gif-object) object] | Trending GIFs were returned, possibly as an empty array |
+| 400 | [error response](/http-api/#error-response) | `locale` is outside the registry and the request returns `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 503 | [error response](/http-api/#error-response) | The provider failed, the request outlived its deadline, or the `gifs` service answered with a payload the API could not read, each returning `SERVICE_UNAVAILABLE` |
+
+Trending and featured read the same provider listing, trending for 50 results and featured for one. The two are cached separately, so the single GIF in a [featured](#get-featured-gifs) response can be older than the first entry here.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `gif:trending` bucket.
+
+## Get GIF search suggestions
+
+
+
+Returns search-term suggestions for a partial query, as an array of strings.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| q | string | The partial search term (1-256 characters) |
+| locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` |
+
+This route sends no country to the provider. Its answers are keyed by locale and query alone, and do not vary with the requesting address.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[string] | Suggestions were returned, possibly as an empty array |
+| 400 | [error response](/http-api/#error-response) | `q` is absent, empty, or longer than 256 characters, or `locale` is outside the registry, each returning `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 503 | [error response](/http-api/#error-response) | The provider failed, the request outlived its deadline, or the `gifs` service answered with a payload the API could not read, each returning `SERVICE_UNAVAILABLE` |
+
+Each suggestion is a term a client sends back as the `q` of [Search GIFs](#search-gifs).
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `gif:suggest` bucket.
+
+## Register a GIF share
+
+
+
+Tells the active provider that the caller is sharing one of its GIFs, and returns 204 with an empty body.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | The `id` of the shared [GIF object](#gif-object) (1-300 characters) |
+| q?2 | ?string | The search term that produced the GIF (0-256 characters) |
+| locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` |
+
+1 The provider reads it as a slug or a slug-id token. Fluxer does not check it against a GIF it has returned, so an identifier the provider does not recognise fails at the provider
+
+2 An omitted or null value is sent to the provider as the empty string
+
+The deadline on this route is 3 seconds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The provider accepted the registration |
+| 400 | [error response](/http-api/#error-response) | `id` is absent, empty, or longer than 300 characters, `q` is longer than 256 characters, or `locale` is outside the registry, each returning `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 503 | [error response](/http-api/#error-response) | The provider rejected the registration, the `gifs` service did not answer within the deadline, or it answered with something other than an acknowledgement, each returning `SERVICE_UNAVAILABLE` |
+
+### Side effects
+
+The registration reaches the provider and changes no Fluxer state, so it emits no Gateway Dispatch and does not add the GIF to the account's [memes](/http-api/memes/).
+
+Fluxer neither caches nor deduplicates the call, so registering the same `id` twice reaches the provider twice.
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `gif:register_share` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/gifts.mdx b/fluxer_docs/src/content/docs/http-api/gifts.mdx
new file mode 100644
index 000000000..31044ea03
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/gifts.mdx
@@ -0,0 +1,172 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gifts
+description: Gift code objects, duration units, public lookup, and one-time redemption.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A gift is a code that grants premium to the account that redeems it. Until then it belongs to no account. [Create gift checkout](/http-api/billing/#create-gift-checkout) defines how one is bought.
+
+Both routes are hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. [Redeem gift](#redeem-gift) is user-only.
+
+:::caution[A code is a bearer credential]
+A gift records no recipient, so Fluxer binds the code to whichever eligible account presents it first. [Get gift](#get-gift) takes no credential and returns the full public record for any code. Treat an unredeemed code as a secret.
+:::
+
+## Gift object
+
+A gift records a duration. Fluxer computes the entitlement window at redemption time from the redeemer's existing state.
+
+Both creation paths record a creator. A completed gift checkout records the purchaser, and an Admin API gift records the system account with ID `0` rather than the administrator that requested it. No operation unredeems a code.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The code presented to [Redeem gift](#redeem-gift) |
+| duration_type | string | The [gift duration unit](#gift-duration-units) that `duration_quantity` is measured in |
+| duration_quantity2 | integer | The number of `duration_type` units the gift grants |
+| redeemed3 | boolean | Whether the code has already been redeemed |
+| created_by?4 | ?[partial user](/http-api/users/#partial-user-object) object | The account that created the gift |
+
+1 Exactly 32 characters drawn from the uppercase letters, the lowercase letters and the digits, regenerated until it does not collide with an existing code
+
+2 Non-negative, and positive on every gift the current code paths create. The exact value `0` means lifetime Visionary entitlement and appears only on a record that predates that constraint
+
+3 Derived from the stored redemption timestamp. Neither the redemption time nor the redeeming account appears in this object
+
+4 Always present and non-null on this route. A creator ID that resolves to no account becomes a placeholder partial with the unresolved ID, `DeletedUser`, `0000` and `Deleted User`
+
+### Example
+
+```json
+{
+ "code": "aZ3kQ9mR2tX7bN4vC8wL5yH1sD6gF0pJ",
+ "duration_type": "months",
+ "duration_quantity": 1,
+ "redeemed": false,
+ "created_by": {
+ "id": "1501314428688998182",
+ "username": "quill",
+ "discriminator": "0001",
+ "global_name": "Quill",
+ "avatar": null,
+ "avatar_color": null,
+ "flags": 0
+ }
+}
+```
+
+## Gift duration units
+
+A purchased gift is always whole months or whole years, because a purchase of twelve months is normalised to one year. The day and week units exist for codes created through the Admin API.
+
+| Value | Description |
+| --- | --- |
+| days | The quantity counts days added to the entitlement anchor |
+| weeks | The quantity counts weeks added to the entitlement anchor |
+| months1 | The quantity counts calendar months added to the entitlement anchor |
+| years1 | The quantity counts calendar years, applied as twelve calendar months each |
+
+1 A calendar month is added in UTC and clamped to the last day of the target month, so 31 January extended by one month lands on 28 or 29 February
+
+## Get gift
+
+
+
+Reads a gift by its code. Returns the [gift](#gift-object) object on success.
+
+A code that does not exist and a code that has been revoked both return 404 `UNKNOWN_GIFT_CODE`, so a revoked code is never distinguishable from one that was never issued.
+
+A chargeback or a refund for the purchase revokes the gift when [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) processes it and the gift is still unredeemed. The gift also leaves [List current user gifts](/http-api/users/gifts/#list-current-user-gifts), so the buyer has no route that reports the reversal. A gift that was already redeemed when the same event arrives stays readable, and Fluxer recomputes the redeemer's entitlement from their remaining redeemed gifts.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The code to look up (1-32 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [gift](#gift-object) object | The gift was returned |
+| 404 | [error response](/http-api/#error-response) | No gift exists for the code, or the gift was revoked, and the request returns `UNKNOWN_GIFT_CODE` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user or client IP address, on the `gift:get` bucket. A supplied credential only keys the bucket. A credential that does not resolve leaves the bucket keyed by the client IP address, and the request still succeeds.
+
+## Redeem gift
+
+
+
+Redeems a gift for the authenticated account and returns 204 with an empty body. User-only. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+### Limitations
+
+- The redeeming account must be claimed, and an unclaimed account returns 400 `UNCLAIMED_ACCOUNT_CANNOT_MAKE_PURCHASES`.
+- The account must have a verified email address, and an unverified address returns 403 `PURCHASE_EMAIL_VERIFICATION_REQUIRED`.
+- The account must not have the purchase-disabled premium flag, and the flag returns 403 `PREMIUM_PURCHASE_BLOCKED` with a top-level `reason` member set to `purchase_disabled`.
+- An account already holding lifetime Visionary entitlement receives 400 `CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY`.
+
+A consumed code receives 400 `GIFT_CODE_ALREADY_REDEEMED`. A code another request is currently redeeming receives 400 `STRIPE_GIFT_REDEMPTION_IN_PROGRESS`. A code that does not exist or has been revoked receives 404 `UNKNOWN_GIFT_CODE`.
+
+:::note[The refusal order differs from checkout]
+The claimed account, verified email and purchase flag refusals are evaluated before the lifetime refusal here. [Create subscription checkout](/http-api/billing/#create-subscription-checkout) evaluates the same two groups in the opposite order.
+:::
+
+:::caution[Redemption is single-use per code]
+One redemption can be in flight for a code across the whole deployment, and a second concurrent request is refused at once.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The code to redeem (1-32 characters) |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The proof issued by the CAPTCHA provider |
+| X-Captcha-Type?2 | string | The CAPTCHA provider, either `hcaptcha` or `turnstile` |
+
+1 A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Verification is skipped when CAPTCHA is disabled, when the account has the exemption flag, or when the caller's contact has the exemption capability
+
+2 Any other value, including an omitted header, falls back to the instance's configured provider
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The gift was redeemed and the entitlement was applied |
+| 400 | [error response](/http-api/#error-response) | CAPTCHA failed, the code is already redeemed, a redemption is already in flight, the account is unclaimed, the account holds lifetime entitlement, the payment provider rejected the subscription work, or the Visionary guild join for a lifetime gift failed |
+| 403 | [error response](/http-api/#error-response) | The email address is unverified, or purchases are disabled for the account |
+| 404 | [error response](/http-api/#error-response) | No gift exists for the code, the gift was revoked, the authenticated account record no longer exists, or the configured Visionary guild does not exist |
+
+### Side effects
+
+The gift becomes redeemed, so [Get gift](#get-gift) reports `redeemed` as true and [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) shows the redemption time and the redeemer to the buyer.
+
+A gift with a positive quantity extends recurring premium. Fluxer stacks the duration onto the payment provider subscription only when the account's premium type is subscription, its premium end is unset or in the future, it has a stored subscription identity, and the instance has a payment provider configured. Stacking adds the duration to the existing trial end, or to the current period end when no trial end is set, and applies the update without proration.
+
+The stacked path and the unstacked path both set the gift extension end to the gift duration added to the latest of the current time, the current premium end and the existing gift extension end. Both also clear an active grace deadline and set the premium type when the account has none. The stacked path also records the account as having ever purchased.
+
+The provider refuses a stacking attempt when the subscription is unknown, already cancelled, or has neither a trial end nor a current period end. That refusal clears the stored subscription identity, the billing cycle and any pending cancellation. The redemption then continues unstacked. Any other provider failure aborts the redemption and returns 400 `STRIPE_ERROR`, and the code stays unredeemed.
+
+A gift whose quantity is `0` grants lifetime Visionary entitlement. Fluxer then cancels an active subscription immediately, without proration and without a final invoice, and that cancellation also clears the stored subscription identity, the billing cycle and any pending cancellation. The account then receives lifetime premium with no premium end and no grace deadline.
+
+A lifetime gift that has no Visionary sequence leaves the account's own sequence alone, and an account that already holds one is not joined to the guild. Fluxer allocates a sequence to an account that holds none and joins it to the Visionary guild as described by [Rejoin Visionary guild](/http-api/premium/#rejoin-visionary-guild), including its Gateway Dispatches.
+
+When a lifetime gift has a Visionary sequence, Fluxer clears the recorded slot of whatever account holds it and joins the redeeming account to the Visionary guild whether or not it already holds a sequence. The gift's sequence overwrites the account's stored sequence, and the slot is reserved for the redeeming account. No current creation path records a sequence on a gift.
+
+A Visionary guild join that fails aborts the entitlement grant, so the redemption is rolled back and the code stays unredeemed. An account at its guild ceiling returns 400 `MAX_GUILDS` and a Visionary guild at its member ceiling returns 400 `MAX_GUILD_MEMBERS`. A subscription the same request already cancelled is not restored.
+
+Every entitlement change sends [User Update](/gateway/events/#user-update) to each session owned by the redeemer. Immediate subscription cancellation for a lifetime gift, and the clearing of a refused subscription identity, each produce their own User Update.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `gift:redeem` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx b/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx
new file mode 100644
index 000000000..9818b4c98
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx
@@ -0,0 +1,481 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild audit logs
+description: Guild audit log entries, their action registry, and the audit reason contract.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An audit log entry records one change made to a guild and the account that made it. It has a numeric action type, the ID of the thing changed, an option map, and a list of before-and-after values. Only [List guild audit logs](#list-guild-audit-logs) reads them.
+
+## Audit log reason
+
+An audit-capable route accepts the `X-Audit-Log-Reason` request header, listed with the other [standard request headers](/http-api/#standard-request-headers). Fluxer reads the value verbatim and never percent-decodes it, so a caller that percent-encodes the reason stores and reads back the percent-encoded form.
+
+Fluxer trims the value. A value that is blank before trimming, empty after it, or longer than 512 characters after it counts as no reason at all. None of the three fails the request, so a reason that misses the bound is dropped and the operation still succeeds.
+
+The operation writes the accepted reason onto its entry, returns it as the entry `reason` field, and sends it in the [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) Dispatch.
+
+:::note[Some operations record no reason at all]
+Channel modification and deletion, message deletion, bulk message deletion, pinning, unpinning, bot addition, and most [permission overwrite](#permission-overwrite-change-fields) entries never have one.
+:::
+
+## Guild audit log response object
+
+One page of audit history together with the accounts and webhooks it references. Only `audit_log_entries` is paginated.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| audit_log_entries1 | array[[guild audit log entry](#guild-audit-log-entry-object) object] | The page of entries this request returned |
+| users2 | array[[partial user](/http-api/users/#partial-user-object) object] | The accounts the returned entries name |
+| webhooks3 | array[[audit log webhook](#audit-log-webhook-object) object] | The webhooks a returned webhook action targets |
+
+1 The page ordering is defined by the [query parameters](#query-parameters) of the request that produced it
+
+2 Deduplicated by user ID, and has no user named only by an option field or by a change value
+
+3 One object for each distinct webhook that a returned webhook action targets and that still exists, so a deleted webhook resolves to no object
+
+`users` holds the actor of every returned entry and, for an action whose `target_id` names an account, that account as well. An account that can no longer be resolved is still returned, as the deleted-account representation with the username `DeletedUser`, the discriminator `0000`, the global name `Deleted User`, a null avatar, a null avatar colour, and `flags` of `0`.
+
+## Guild audit log entry object
+
+One recorded guild change. An entry is immutable once written, except for a run of message deletion entries, which [List guild audit logs](#list-guild-audit-logs) can replace with a single consolidated entry. The entry ID is a [snowflake](/snowflakes/), so it has the exact creation time.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the entry, whose [snowflake](/snowflakes/) timestamp is the time it was written |
+| action_type | integer | [Audit action](#audit-actions) value that identifies the recorded operation |
+| user_id1 | snowflake | The ID of the user that performed the action |
+| target_id2 | ?string | The ID of the affected entity |
+| reason?3 | string | The [audit log reason](#audit-log-reason) recorded with the operation |
+| options?4 | [audit log options](#audit-log-options-object) object | The context fields this [audit action](#audit-actions) records |
+| changes?5 | array[[audit log change](#audit-log-change-object) object] | The fields the mutation changed |
+
+1 Every entry has the acting user ID, and an entry produced by consolidation has the actor of the consolidated run
+
+2 A decimal snowflake string for every action except [INVITE_CREATE](#audit-actions) and [INVITE_DELETE](#audit-actions), where it is the invite code, and null for [MESSAGE_BULK_DELETE](#audit-actions)
+
+3 The field is omitted when the operation recorded no reason, and an entry written by consolidation never has one
+
+4 The field is omitted when the action recorded no option key that this registry publishes
+
+5 The field is omitted when the action records no field change, so it is never returned as an empty array
+
+:::note[An entry expires 45 days after the write]
+An expired entry is removed from storage, so a page never returns one older than that horizon. A consolidated entry is a fresh write and starts a fresh 45-day expiry of its own.
+:::
+
+### Example
+
+```json
+{
+ "id": "1501314428688998184",
+ "action_type": 24,
+ "user_id": "1489002177550843905",
+ "target_id": "1489002177550843906",
+ "reason": "Nickname violated the rules",
+ "changes": [{"key": "nick", "old_value": "spam bot", "new_value": null}]
+}
+```
+
+## Audit actions
+
+The Value column is the numeric `action_type`, which is also the value the `action_type` query filter accepts. Each description names what `target_id` identifies and which [audit log options](#audit-log-options-object) the action records. The overwrite options are `id`, `type`, and `channel_id`. The invite options are `max_age`, `max_uses`, `temporary`, `channel_id`7, and `inviter_id`7.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 | GUILD_UPDATE1 | Guild settings or ownership were changed. `target_id` is the guild, and the action records no options |
+| 10 | CHANNEL_CREATE | Guild channel was created. `target_id` is the channel, and the action records `type` |
+| 11 | CHANNEL_UPDATE | Guild channel was changed. `target_id` is the channel, and the action records `type` |
+| 12 | CHANNEL_DELETE | Guild channel was deleted. `target_id` is the channel, and the action records `type` |
+| 13 | CHANNEL_OVERWRITE_CREATE | Channel permission overwrite was created. `target_id` is the role or member, and the action records the overwrite options |
+| 14 | CHANNEL_OVERWRITE_UPDATE | Channel permission overwrite was changed. `target_id` is the role or member, and the action records the overwrite options |
+| 15 | CHANNEL_OVERWRITE_DELETE | Channel permission overwrite was deleted. `target_id` is the role or member, and the action records the overwrite options |
+| 20 | MEMBER_KICK2 | Member was removed by a moderator. `target_id` is the user, and the action records no options |
+| 21 | MEMBER_PRUNE3 | Inactive members were pruned. `target_id` is nothing, and the action records no options |
+| 22 | MEMBER_BAN_ADD | Guild ban was created or replaced. `target_id` is the user, and the action records `delete_member_days`4 |
+| 23 | MEMBER_BAN_REMOVE | Guild ban was removed. `target_id` is the user, and the action records no options |
+| 24 | MEMBER_UPDATE5 | Guild member state was changed. `target_id` is the user, and the action records no options |
+| 25 | MEMBER_ROLE_UPDATE5 | One role was added to or removed from a member. `target_id` is the user, and the action records no options |
+| 26 | MEMBER_MOVE6 | Member voice session was moved to another channel. `target_id` is the user, and the action records `channel_id` and `count` |
+| 27 | MEMBER_DISCONNECT6 | Member voice session was disconnected. `target_id` is the user, and the action records `channel_id` and `count` |
+| 28 | BOT_ADD | Bot was added to the guild. `target_id` is the user, and the action records `temporary` |
+| 30 | ROLE_CREATE | Role was created. `target_id` is the role, and the action records no options |
+| 31 | ROLE_UPDATE | Role was changed. `target_id` is the role, and the action records no options |
+| 32 | ROLE_DELETE | Role was deleted. `target_id` is the role, and the action records no options |
+| 40 | INVITE_CREATE | Invite was created. `target_id` is the invite code, and the action records the invite options |
+| 41 | INVITE_UPDATE8 | Invite was changed. `target_id` is the invite code, and the action records no options |
+| 42 | INVITE_DELETE | Invite was deleted. `target_id` is the invite code, and the action records the invite options |
+| 50 | WEBHOOK_CREATE | Webhook was created. `target_id` is the webhook, and the action records `channel_id`7 |
+| 51 | WEBHOOK_UPDATE | Webhook was changed. `target_id` is the webhook, and the action records `channel_id`7 |
+| 52 | WEBHOOK_DELETE | Webhook was deleted. `target_id` is the webhook, and the action records `channel_id`7 |
+| 60 | EMOJI_CREATE | Emoji was created. `target_id` is the emoji, and the action records no options |
+| 61 | EMOJI_UPDATE | Emoji was changed. `target_id` is the emoji, and the action records no options |
+| 62 | EMOJI_DELETE | Emoji was deleted. `target_id` is the emoji, and the action records no options |
+| 72 | MESSAGE_DELETE | Single message was deleted by a moderator. `target_id` is the message, and the action records `channel_id` |
+| 73 | MESSAGE_BULK_DELETE9 | Several messages were deleted in one operation. `target_id` is nothing, and the action records `channel_id` and `count` |
+| 74 | MESSAGE_PIN | Message was pinned. `target_id` is the message, and the action records `channel_id` and `message_id` |
+| 75 | MESSAGE_UNPIN | Message was unpinned. `target_id` is the message, and the action records `channel_id` and `message_id` |
+| 90 | STICKER_CREATE | Sticker was created. `target_id` is the sticker, and the action records no options |
+| 91 | STICKER_UPDATE | Sticker was changed. `target_id` is the sticker, and the action records no options |
+| 92 | STICKER_DELETE | Sticker was deleted. `target_id` is the sticker, and the action records no options |
+
+1 Recorded by [Modify guild](/http-api/guilds/#modify-guild), by [Modify guild custom invite URL](/http-api/guilds/#modify-guild-custom-invite-url), and by [Transfer guild ownership](/http-api/guild-members/#transfer-guild-ownership), and `target_id` is the guild in every case
+
+2 A kick entry is always recorded with no change list, so the removed membership state is not published through the entry
+
+3 The value is defined by the wire contract and is accepted by the `action_type` filter, and no current guild operation records it
+
+4 The option is always recorded. Its value is the legacy `delete_message_days` body field of [Create or replace guild ban](/http-api/guild-moderation/#create-or-replace-guild-ban), which defaults to `0`, so a ban that used `delete_message_seconds` or sent no body at all records `"0"`
+
+5 The entry has no `options`, so a member role change does not name the role it added or removed. The caller reads that from the [guild member change fields](#guild-member-change-fields)
+
+6 `count` is always `1`. `channel_id` is the destination channel for a move and the vacated channel for a disconnect
+
+7 The option is recorded only when the recorded entity has that value, so an invite with no channel or no inviter records neither, and a webhook with no channel records no option at all
+
+8 The filter accepts this value, and no current guild operation records this action
+
+9 A bulk deletion entry is recorded either by a bulk message deletion operation or by the consolidation described under [List guild audit logs](#list-guild-audit-logs)
+
+## Audit log options object
+
+Every field is present only for the actions listed against it in the [audit actions](#audit-actions) registry. Only the keys in this registry are published, and the response omits `options` entirely when an entry recorded none of them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | string | The decimal ID of the channel the action concerns |
+| count1 | number | The number of entities the action affected |
+| delete_member_days2 | string | The whole days of the banned member's messages the ban deleted |
+| id | string | The decimal ID of the role or member an overwrite applies to |
+| integration_type3 | number | The type of the integration the entry names |
+| message_id | string | The decimal ID of the single message the action concerns |
+| members_removed3 | number | The number of memberships the action removed |
+| role_name3 | string | The name of the role the action concerns |
+| type4 | number | The channel type, or the permission overwrite type |
+| inviter_id | string | The decimal ID of the user that created the invite |
+| max_age | number | The configured invite lifetime in seconds |
+| max_uses | number | The configured maximum use count |
+| temporary | boolean | Whether the recorded entity is temporary |
+| uses3 | number | The invite use count recorded with the action |
+
+1 The value is the length of the consolidated run for [MESSAGE_BULK_DELETE](#audit-actions) and is always `1` for the two voice actions
+
+2 Read from the legacy `delete_message_days` body field of [Create or replace guild ban](/http-api/guild-moderation/#create-or-replace-guild-ban), so a ban that supplied only `delete_message_seconds` records `"0"`
+
+3 The field is defined by the wire contract and no current guild operation records it
+
+4 The value is a [channel type](/http-api/channels/#channel-types) for the three channel actions and a [permission overwrite type](/http-api/channels/#permission-overwrite-types) for the three overwrite actions
+
+## Audit log change object
+
+One field a mutation changed, with the value on each side of the change.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key1 | string | The name of the field that changed, drawn from the [change fields](#change-fields) of the entity the action targets |
+| old_value?2 | audit log change value | The value the field held before the mutation |
+| new_value?2 | audit log change value | The value the field holds after the mutation |
+
+1 A change whose key is `ip` is never published through this resource, and `changes` is omitted entirely when no other change remains
+
+2 A creation has only `new_value`, a deletion has only `old_value`, and a modification has both, so a change object always has at least one of the two
+
+A change value is a string, a JSON number, a boolean, `null`, an array of strings, an array of numbers, or an object with `added` and `removed` string arrays. The last shape comes only from the [permissions_diff](#guild-role-change-fields) key of a role modification.
+
+:::note[A 64-bit domain value is a decimal string]
+A permission mask or a snowflake is serialised as a decimal string. A bounded number such as a position or a colour remains a JSON number.
+:::
+
+:::caution[A change list holds only the differing fields]
+An update records one change object per differing field, so an unchanged field is absent. An identity field never appears in an update change list, though it does appear in a creation or deletion list, where only one side of the comparison exists.
+:::
+
+### Change fields
+
+Each table below names the fields an action of that family can record.
+
+#### Guild change fields
+
+Recorded by [GUILD_UPDATE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id1 | string | The decimal ID of the guild itself |
+| name | string | The name the guild was given by this change (1-100 characters) |
+| owner_id | string | The decimal ID of the guild owner |
+| vanity_url_code | ?string | The configured custom invite code, or null when the guild has none |
+| icon_hash | ?string | The guild icon hash, or null when the guild has no icon |
+| banner_hash | ?string | The guild banner hash, or null when the guild has no banner |
+| banner_width | ?number | The guild banner width in pixels, or null when the guild has no banner |
+| banner_height | ?number | The guild banner height in pixels, or null when the guild has no banner |
+| splash_hash | ?string | The guild splash hash, or null when the guild has no splash |
+| splash_width | ?number | The guild splash width in pixels, or null when the guild has no splash |
+| splash_height | ?number | The guild splash height in pixels, or null when the guild has no splash |
+| splash_card_alignment | number | The [splash card alignment](/http-api/guilds/#splash-card-alignments) recorded with the change |
+| embed_splash_hash | ?string | The guild embed splash hash, or null when the guild has no embed splash |
+| embed_splash_width | ?number | The guild embed splash width in pixels, or null when the guild has no embed splash |
+| embed_splash_height | ?number | The guild embed splash height in pixels, or null when the guild has no embed splash |
+| features2 | array[string] | The [guild features](/http-api/guilds/#guild-features) held at this side of the change, in ascending order |
+| verification_level | number | The [verification level](/http-api/guilds/#verification-levels) recorded with the change |
+| mfa_level | number | The [MFA level](/http-api/guilds/#mfa-levels) recorded with the change |
+| nsfw_level | number | The [NSFW level](/http-api/guilds/#nsfw-levels) recorded with the change |
+| nsfw | boolean | Whether the guild is marked age restricted |
+| content_warning_level | number | The [guild content warning level](/http-api/guilds/#guild-content-warning-levels) recorded with the change |
+| content_warning_text | ?string | The configured content warning text, or null when the guild sets none |
+| explicit_content_filter | number | The [guild explicit content filter level](/http-api/guilds/#guild-explicit-content-filter-levels) recorded with the change |
+| default_message_notifications | number | The [default message notification level](/http-api/guilds/#default-message-notification-levels) recorded with the change |
+| system_channel_id | ?string | The decimal ID of the configured system channel, or null when the guild sets none |
+| system_channel_flags | number | The [system channel flags](/http-api/guilds/#system-channel-flags) mask recorded with the change |
+| rules_channel_id | ?string | The decimal ID of the configured rules channel, or null when the guild sets none |
+| afk_channel_id | ?string | The decimal ID of the configured AFK voice channel, or null when the guild sets none |
+| afk_timeout | number | The AFK timeout in seconds |
+| disabled_operations | number | The [disabled guild operations](/http-api/guilds/#disabled-guild-operations) mask recorded with the change |
+| member_count | number | The member count recorded with the mutation |
+| message_history_cutoff | ?ISO8601 timestamp | The configured message history cutoff, or null when the guild sets none |
+
+1 The field holds the same value on both sides of every guild action, so it never produces a change object
+
+2 Fluxer sorts the array by string value on both sides of the comparison, so a change object appears only when a feature was added or removed
+
+#### Channel change fields
+
+Recorded by [CHANNEL_CREATE](#audit-actions), [CHANNEL_UPDATE](#audit-actions), and [CHANNEL_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | string | The decimal ID of the channel itself |
+| type | number | The [channel type](/http-api/channels/#channel-types) recorded with the change |
+| name | ?string | The name the channel was given by this change |
+| topic | ?string | The topic the channel was given by this change, or null when it has none |
+| parent_id | ?string | The decimal ID of the parent category, or null when the channel sits at the top level |
+| position | number | The position of the channel among its siblings |
+| nsfw | ?boolean | Whether the channel is marked age restricted, or null when it has no override |
+| content_warning_level | number | The [content warning level](/http-api/channels/#content-warning-levels) recorded with the change |
+| content_warning_text | ?string | The configured content warning text, or null when the channel sets none |
+| rate_limit_per_user | number | The slowmode interval in seconds |
+| user_limit | ?number | The configured voice occupancy limit |
+| voice_connection_limit | ?number | The configured per-user voice connection limit |
+| bitrate | ?number | The voice bitrate in bits per second |
+| rtc_region | ?string | The pinned voice region, or null when the channel is routed automatically |
+| permission_overwrite_count1 | number | The number of permission overwrites the channel has |
+
+1 Only the count is recorded. An individual overwrite mutation records its own [permission overwrite](#permission-overwrite-change-fields) entry
+
+#### Permission overwrite change fields
+
+Recorded by the three [channel overwrite actions](#audit-actions). Every value in this family is a string.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The decimal ID of the role or member the overwrite applies to |
+| type | string | The decimal [permission overwrite type](/http-api/channels/#permission-overwrite-types) |
+| allow | string | The decimal [permission](/http-api/permissions/) mask the overwrite grants |
+| deny | string | The decimal [permission](/http-api/permissions/) mask the overwrite denies |
+
+#### Guild member change fields
+
+Recorded by [MEMBER_UPDATE](#audit-actions) and [MEMBER_ROLE_UPDATE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | string | The decimal ID of the member itself |
+| nick | ?string | The nickname the member holds in this guild, or null when none is set |
+| roles | array[string] | The assigned role IDs as decimal strings, in ascending string order |
+| avatar_hash | ?string | The guild avatar hash, or null when the member has no guild avatar |
+| banner_hash | ?string | The guild banner hash, or null when the member has no guild banner |
+| bio | ?string | The guild profile bio, or null when none is set |
+| pronouns | ?string | The guild profile pronouns, or null when none is set |
+| accent_color | ?number | The guild profile accent colour, or null when none is set |
+| deaf | boolean | Whether a moderator has deafened the member |
+| mute | boolean | Whether a moderator has muted the member |
+| communication_disabled_until | ?ISO8601 timestamp | The time the communication restriction expires, or null when the member is not restricted |
+| temporary | boolean | Whether the membership is temporary |
+
+#### Voice move change fields
+
+Recorded by [MEMBER_MOVE](#audit-actions) and [MEMBER_DISCONNECT](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | string | The decimal ID of the voice channel the session occupied |
+
+1 A move records the previous channel as `old_value` and the destination as `new_value`, while a disconnect records only `old_value`
+
+#### Guild ban change fields
+
+Recorded by [MEMBER_BAN_ADD](#audit-actions) and [MEMBER_BAN_REMOVE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | string | The decimal ID of the banned user |
+| moderator_id | string | The decimal ID of the user that issued the ban |
+| banned_at | ISO8601 timestamp | The time the ban was issued |
+| expires_at | ?ISO8601 timestamp | The expiry of a temporary ban, or null for a permanent ban |
+| reason | ?string | The stored [ban](/http-api/guild-moderation/#guild-ban-object) reason, which is separate from the audit reason |
+
+#### Guild role change fields
+
+Recorded by [ROLE_CREATE](#audit-actions), [ROLE_UPDATE](#audit-actions), and [ROLE_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| role_id | string | The decimal ID of the role itself |
+| name | string | The name the role was given by this change (1-100 characters) |
+| permissions | string | The decimal [permission](/http-api/permissions/) mask the role grants |
+| position | number | The position of the role in the guild hierarchy |
+| hoist_position | ?number | The separate hoisting position, or null when the role has none |
+| color | number | The colour recorded for the role |
+| icon_hash | ?string | The role icon hash, or null when the role has no icon |
+| unicode_emoji | ?string | The role Unicode emoji, or null when the role has none |
+| hoist | boolean | Whether the role is displayed separately |
+| mentionable | boolean | Whether the role is mentionable |
+| permissions_diff1 | object | The permission names this change added and removed |
+
+1 Recorded only by [Modify guild role](/http-api/permissions/#modify-guild-role), and only when the mask actually changed. The value is an object with `added` and `removed` string arrays of permission names
+
+`permissions_diff` arrives as `new_value` with no `old_value`, and the same change list still has the `permissions` change. The role position and hoist position operations record [ROLE_UPDATE](#audit-actions) without it.
+
+#### Invite change fields
+
+Recorded by [INVITE_CREATE](#audit-actions) and [INVITE_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The code that identifies the invite |
+| channel_id | ?string | The decimal ID of the channel the invite points at |
+| guild_id | ?string | The decimal ID of the guild the invite belongs to |
+| inviter_id | ?string | The decimal ID of the user that created the invite |
+| uses | number | The use count recorded with the change |
+| max_uses | number | The configured maximum use count |
+| max_age | number | The configured invite lifetime in seconds |
+| temporary | boolean | Whether accepting the invite grants a temporary membership |
+| created_at | ISO8601 timestamp | The time the invite was created |
+
+#### Webhook change fields
+
+Recorded by [WEBHOOK_CREATE](#audit-actions), [WEBHOOK_UPDATE](#audit-actions), and [WEBHOOK_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The decimal ID of the webhook itself |
+| guild_id | ?string | The decimal ID of the guild the webhook belongs to |
+| channel_id | ?string | The decimal ID of the channel the webhook posts to |
+| name | string | The name the webhook posts under (1-80 characters) |
+| creator_id | ?string | The decimal ID of the user that created the webhook |
+| avatar_hash | ?string | The webhook avatar hash, or null when no avatar is set |
+| type1 | number | The kind of webhook the change recorded |
+
+1 The value is 1 for an incoming webhook and 2 for a channel follower webhook
+
+#### Emoji change fields
+
+Recorded by [EMOJI_CREATE](#audit-actions), [EMOJI_UPDATE](#audit-actions), and [EMOJI_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emoji_id | string | The decimal ID of the emoji itself |
+| name | string | The name the emoji was given by this change (2-32 characters) |
+| animated | boolean | Whether the emoji is animated |
+| creator_id | string | The decimal ID of the user that created the emoji |
+
+#### Sticker change fields
+
+Recorded by [STICKER_CREATE](#audit-actions), [STICKER_UPDATE](#audit-actions), and [STICKER_DELETE](#audit-actions).
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sticker_id | string | The decimal ID of the sticker itself |
+| name | string | The name the sticker was given by this change (2-30 characters) |
+| description | ?string | The sticker description, or null when it has none |
+| animated | boolean | Whether the sticker is animated |
+| creator_id | string | The decimal ID of the user that created the sticker |
+
+## Audit log webhook object
+
+A reduced [webhook object](/http-api/webhooks/#webhook-object). It omits the execution token, application ID, and creating user, and it names the avatar hash `avatar_hash` rather than `avatar`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the webhook |
+| type1 | integer | The kind of webhook this record describes |
+| guild_id | ?snowflake | The ID of the guild containing the webhook |
+| channel_id | ?snowflake | The ID of the channel the webhook sends messages to |
+| name | string | The name the webhook posts under |
+| avatar_hash | ?string | The avatar hash of the webhook, or null when no avatar is set |
+
+1 The value is 1 for an incoming webhook and 2 for a channel follower webhook
+
+## List guild audit logs
+
+
+
+Returns one page of guild audit history as a [guild audit log response](#guild-audit-log-response-object) object. Requires [VIEW_AUDIT_LOG](/http-api/permissions/) in the target guild, and no MFA elevation applies.
+
+A guild ID that names no guild returns 404 `UNKNOWN_GUILD`. A non-member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is visible to any authenticated caller.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild whose audit log is read |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit?1 | integer | The maximum number of entries to return (0-100, default 50) |
+| before?2 | snowflake | The cursor selecting entries older than this audit entry ID |
+| after?2 | snowflake | The cursor selecting entries newer than this audit entry ID |
+| user_id?3 | snowflake | The acting user ID the page is filtered by |
+| action_type?3 | integer | The [audit action](#audit-actions) value the page is filtered by |
+
+1 A negative value and a value above 100 are both rejected, and the accepted value `0` is processed as `1`
+
+2 The two cursors are mutually exclusive, and supplying both returns 400 `INVALID_FORM_BODY` with [`CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER`](/http-api/errors/#validation-error-code-registry) against `before`
+
+3 Supplying either filter disables message deletion consolidation for the request, so a page filtered by actor or action returns the unconsolidated entries
+
+The read is ordered from newest to oldest by entry ID. `before` selects entries below the cursor and `after` selects entries above it, both read in that same descending order. An `after` page therefore begins with the newest entry above the cursor.
+
+Consolidation breaks that ordering. A consolidated entry keeps the array position of the first entry of the run it replaces. Its ID is freshly minted and larger than every stored entry on the page, so a client that needs a strictly descending array sorts the page by ID itself.
+
+A page holds fewer entries than `limit` only when the guild has no further matching entries in that direction.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild audit log response](#guild-audit-log-response-object) object | Page was returned |
+| 400 | [error response](/http-api/#error-response) | Both cursors were supplied, or a filter value is invalid |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, or `VIEW_AUDIT_LOG` is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for a guild with [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features), or [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) without the instance staff flag, and `MISSING_PERMISSIONS` otherwise
+
+:::caution[An unfiltered read rewrites message deletion entries]
+A request supplying neither `user_id` nor `action_type` deletes every run of two or more consecutive [MESSAGE_DELETE](#audit-actions) entries sharing one actor and one channel, and writes one [MESSAGE_BULK_DELETE](#audit-actions) entry in its place.
+:::
+
+:::note[The same consolidation also runs in the background]
+Fluxer also applies it to the 250 most recent entries about 30 seconds after a message deletion entry is written.
+:::
+
+### Side effects
+
+Consolidation writes the replacement entry with a fresh snowflake, `options.count` set to the run length, and no reason. It emits one [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) Dispatch, which the Gateway delivers only to guild sessions holding `VIEW_AUDIT_LOG`. A read that finds no run writes nothing and emits no Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:audit_logs::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-channels.mdx b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx
new file mode 100644
index 000000000..da719339d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx
@@ -0,0 +1,288 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild channels
+description: Guild channel types, creation, and bulk hierarchy repositioning.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A guild channel is one of the rooms a guild is divided into, including the categories that group them. The operations here list, create, and reorder a guild's channels, and the [Channels resource](/http-api/channels/) defines the channel object and every per-channel operation.
+
+## Access rules
+
+A caller who is not a member of an existing guild receives 403 `MISSING_PERMISSIONS`. Every operation here returns 404 `UNKNOWN_GUILD` for a guild that does not exist.
+
+A request whose path names a guild that has [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) is refused with 403 `MISSING_ACCESS` before the operation runs.
+
+## Guild channel types
+
+These are the [channel types](/http-api/channels/#channel-types) that can exist inside a guild, and exactly the values [Create guild channel](#create-guild-channel) accepts in `type`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | GUILD_TEXT | Text channel that has messages and slowmode |
+| 2 | GUILD_VOICE | Voice channel that has messages, voice sessions, and Go Live streams |
+| 4 | GUILD_CATEGORY | Category that groups other guild channels and supplies inherited permission overwrites |
+| 998 | GUILD_LINK | Channel whose only content is an external destination URL |
+
+## Create permission overwrite object
+
+The initial overwrite collection supplied when a channel is created. The stored form returned afterwards is the [permission overwrite object](/http-api/channels/#permission-overwrite-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the role or member the overwrite targets |
+| type1 | integer | [Permission overwrite type](/http-api/channels/#permission-overwrite-types) |
+| allow?2 | decimal string | The [permission](/http-api/permissions/) bits the overwrite grants (default 0) |
+| deny?2 | decimal string | The [permission](/http-api/permissions/) bits the overwrite removes (default 0) |
+
+1 Required. `0` names a role and `1` names a member, and no other value is accepted
+
+2 A decimal string of at most 9223372036854775807, or a JSON integer of at most 9007199254740991. Null is rejected
+
+A larger decimal string returns 400 `INVALID_FORM_BODY` with the code `INTEGER_OUT_OF_INT64_RANGE`. A JSON number outside the safe integer range returns `INVALID_INTEGER_FORMAT`, and so does a string that is not all digits.
+
+An undefined bit never fails the authority comparison described by [Create guild channel](#create-guild-channel). It is stored as supplied and echoed back by every later read of the channel.
+
+A denied bit is never compared against the caller's own permissions. A bit set in both masks resolves to an allow during [permission computation](/http-api/permissions/#permission-computation).
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "type": 0,
+ "allow": "1024",
+ "deny": "2048"
+}
+```
+
+## Channel position object
+
+One entry of the bulk hierarchy update. Entries are applied in array order, each against the result of the previous one.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel to move |
+| position?1 | integer | Index among the destination siblings (minimum 0) |
+| parent_id?2 | ?snowflake | New parent category, or null to move the channel to the guild root |
+| preceding_sibling_id?3 | ?snowflake | Sibling that sits directly before this channel, or null to place it first |
+| lock_permissions?4 | boolean | Whether to copy the destination category's overwrites onto the moved channel (default false) |
+
+1 Read only when `preceding_sibling_id` is omitted. Sending `preceding_sibling_id` at all, including as null, makes it inert
+
+2 An omitted field keeps the channel's current parent. A category given any parent is refused with `CATEGORIES_CANNOT_HAVE_PARENTS`
+
+3 A preceding sibling must share the destination parent, so a category is valid here only when the moved channel's destination is the guild root
+
+4 The copy runs only when the same entry also moves the channel into a category, and it replaces the moved channel's overwrites
+
+A supplied `position` is clamped to the number of destination siblings and converted into the preceding sibling at that index. An entry that omits both `position` and `preceding_sibling_id` appends a voice channel to the end of its destination siblings, and inserts any other channel directly before the first voice sibling.
+
+Naming a category as the preceding sibling places the moved channel after that category and after every channel already inside it.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "parent_id": "1501314428688998100",
+ "preceding_sibling_id": null,
+ "lock_permissions": true
+}
+```
+
+## List guild channels
+
+
+
+Returns every [channel object](/http-api/channels/#channel-object) in the guild that the authenticated member can see. Membership is the only requirement.
+
+Fluxer evaluates [channel visibility](/http-api/permissions/#permission-computation) per channel. A channel is included when the member holds [VIEW_CHANNEL](/http-api/permissions/) in it, or when the member currently holds temporary access to it through a voice transition. A category is included when at least one child grants the member `VIEW_CHANNEL`. A member with no visible channel receives an empty array.
+
+:::note[Every visible channel arrives in one body]
+The route accepts no limit and no cursor. Each entry is a complete channel object whose `permission_overwrites` array is always present and always complete.
+:::
+
+A guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), which defaults to 500.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[channel](/http-api/channels/#channel-object) object] | Visible channels were returned |
+| 403 | [error response](/http-api/#error-response) | Caller is not a member of the guild, returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user and guild ID, on the `guild:channels:list::guild_id` bucket.
+
+## Create guild channel
+
+
+
+Creates a guild channel and returns its [channel object](/http-api/channels/#channel-object) with status 200. Requires [MANAGE_CHANNELS](/http-api/permissions/) at guild level. Emits a [Channel Create](/gateway/events/#channel-create) Gateway event.
+
+### Limitations
+
+- Supplying `permission_overwrites` additionally requires [MANAGE_ROLES](/http-api/permissions/) at guild level.
+
+`MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+`type` is required and decides which kind of channel is created. Every type accepts the same field set, so a field that has no meaning for the selected type is accepted and then ignored.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | integer | [Guild channel type](#guild-channel-types) |
+| name12 | string | Channel name (1-100 characters after normalisation) |
+| topic? | ?string | Channel topic (1-1024 characters) |
+| url?3 | ?string | Destination URL for a link channel |
+| parent_id?4 | ?snowflake | Parent category |
+| bitrate?5 | ?integer | Voice channel bitrate in bits per second (8000-320000, default 64000) |
+| user_limit?56 | ?integer | Voice channel occupancy limit (0-99, default 0) |
+| voice_connection_limit?5 | ?integer | Simultaneous voice connections permitted for one user in a voice channel (1-100, default 5) |
+| rate_limit_per_user? | ?integer | Slowmode interval in seconds (0-21600, default 0) |
+| permission_overwrites?7 | array[[create permission overwrite](#create-permission-overwrite-object) object] | Complete initial overwrite collection |
+| nsfw?8 | boolean | Legacy age restriction for this channel (default false) |
+| nsfw_override?8 | ?boolean | Explicit age restriction, or null to inherit from the parent category and then the guild |
+| content_warning_level?9 | integer | [Content warning level](/http-api/channels/#content-warning-levels) (default 0) |
+| content_warning_text?10 | ?string | Content warning text (max 200 characters), or null to inherit |
+
+1 Normalisation strips control, join, bidirectional, and tag code points, collapses whitespace runs to one space, and trims. An empty result is rejected with `NAME_EMPTY_AFTER_NORMALIZATION`
+
+2 A text channel name is additionally lowercased, its whitespace replaced with hyphens, and ASCII punctuation other than `-`, `.`, and `_` removed, unless the guild has [TEXT_CHANNEL_FLEXIBLE_NAMES](/http-api/guilds/#guild-features). That pass rewrites the stored name and never fails the request
+
+3 An `http` or `https` URL of at most 2048 characters with a host and no embedded credentials. It is stored for every channel type
+
+4 Names a category in the same guild. A category cannot be given a parent
+
+5 Stored only for the voice variant, where the stated default applies when the field is omitted or null, and discarded for every other type
+
+6 The value `0` means no occupancy limit
+
+7 When omitted, a new channel with a parent category copies that category's current overwrites. A present field replaces that inheritance completely, including with an empty array. Null is rejected
+
+8 `nsfw_override` takes precedence, and `nsfw` supplies the value only when `nsfw_override` is absent. Omitting both stores an explicit `false`, and only a null `nsfw_override` inherits
+
+9 The value `1` enables the warning on this channel. Every other accepted value stores `0` and inherits from the parent category and then the guild
+
+10 The value is trimmed, and an empty result is stored as null
+
+A value longer than 10000 characters is rejected with `STRING_LENGTH_INVALID` before normalisation runs. A parent that does not exist in this guild returns `INVALID_PARENT_CHANNEL`, and one that is not a category returns `PARENT_MUST_BE_CATEGORY`.
+
+A parent category holds at most the instance-configured `max_channels_per_category` [limit](/http-api/instance/#limit-keys), defaulting to 50, and the guild holds at most `max_guild_channels`, defaulting to 500. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each message has the resolved limit. The new channel is created with no RTC region.
+
+The new channel takes a position derived from its siblings. A category, and a channel created with no parent, is placed after the highest position in the guild. A voice channel created inside a category is placed after the last voice sibling, or after the last text or link sibling when the category holds no voice channel. A text or link channel is placed after the last text or link sibling, and a channel with no such sibling is placed directly after the category itself. The rest of the guild is not renumbered, so two channels can hold the same stored position until the next [hierarchy update](#modify-guild-channel-positions) renumbers them.
+
+:::caution[Overwrites are checked against the parent category]
+Fluxer compares the allowed bits of every supplied overwrite against the caller's effective permissions in the parent category, or in the guild when no parent is supplied. A bit the caller does not hold there fails the entire request with 403 `MISSING_PERMISSIONS`.
+:::
+
+Only the allowed mask is compared, so a supplied deny is stored without any authority check. A caller holding `ADMINISTRATOR` in that context holds every bit, and every requested bit passes.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel](/http-api/channels/#channel-object) object | Channel was created |
+| 40012 | [error response](/http-api/#error-response) | Body, type, name, parent, URL, voice setting, or content warning text is invalid, a channel limit is reached, or the caller has no enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS`, is not a member of the guild, supplied overwrites without `MANAGE_ROLES`, or allowed a permission they do not hold in the parent context, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+
+1 A schema or parent failure returns `INVALID_FORM_BODY` and names the field with `CATEGORIES_CANNOT_HAVE_PARENTS`, `INVALID_PARENT_CHANNEL`, or `PARENT_MUST_BE_CATEGORY`. A capacity failure returns the top-level `MAX_GUILD_CHANNELS` or `MAX_CATEGORY_CHANNELS`
+
+2 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after `MANAGE_CHANNELS` has been confirmed, so a caller without the permission receives 403 instead
+
+### Side effects
+
+The operation consumes one guild channel slot and, when a parent is supplied, one slot in that category. It creates a [`CHANNEL_CREATE`](/http-api/guild-audit-logs/#audit-actions) guild audit log entry with the supplied reason, plus one [`CHANNEL_OVERWRITE_CREATE`](/http-api/guild-audit-logs/#audit-actions) entry for every stored overwrite, whether that overwrite was supplied or inherited.
+
+It emits [Channel Create](/gateway/events/#channel-create) with the complete channel object only to sessions that can view the new channel. Reaching either channel limit fails the request without consuming a slot.
+
+### Rate limit
+
+10 requests per minute for each authenticated user and guild ID, on the `guild:channel:create::guild_id` bucket.
+
+## Modify guild channel positions
+
+
+
+Applies a guild channel hierarchy update and returns 204 with an empty body. Requires [MANAGE_CHANNELS](/http-api/permissions/) at guild level. Emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) Gateway event for each entry that changes the order.
+
+### Limitations
+
+- Setting `lock_permissions` on an entry that changes the parent additionally requires [MANAGE_ROLES](/http-api/permissions/) in the moved channel.
+- The operation records no guild audit log entry, so an `X-Audit-Log-Reason` header has no effect.
+
+`MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner.
+
+The request takes a guild-wide lock for its duration. A request that arrives while another hierarchy update owns the guild is rejected with 423 [`GENERAL_ERROR`](/http-api/errors/) and a `Retry-After` header of two seconds. The lock expires on its own after 30 seconds.
+
+Fluxer resolves every channel, parent, and sibling named by any entry once before the first entry runs, so a request naming an unknown identifier anywhere applies nothing. After that check, each entry is committed as it is applied. A request that fails partway leaves the entries it already applied in place. Moving a category moves the channels inside it as one block, and that block is excluded from the sibling list an entry indexes into.
+
+Each entry flattens the complete guild hierarchy so that each root channel appears in position order, each category is immediately followed by its children, and each category's children put text and link channels before voice channels. Fluxer lifts the moved channel out of that list, together with its children when it is a category, and reinserts it at its destination. It then renumbers the result densely from 1 through the channel count.
+
+:::note[Renumbering can rewrite a channel no entry named]
+An entry that changes the flattened order renumbers the guild's complete hierarchy, so a channel the request never mentioned can end up at a new position. An entry whose flattened result matches the list it started from writes nothing.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The top-level body is an array of [channel position objects](#channel-position-object) of any length, including zero. Every channel, parent, and sibling an entry names must exist in this guild. A category cannot be given a parent, and a parent that is not a category is refused. A channel cannot be positioned relative to itself or to one of its own children. Moving a channel into a full category is refused with 400 `MAX_CATEGORY_CHANNELS`.
+
+:::caution[Inside a category, voice channels come last]
+Fluxer checks each entry's planned result before committing it. It refuses the request with `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS` when a voice channel would come to rest directly above a text or link sibling, or the reverse.
+:::
+
+The check inspects the moved channel alone, and only inside its current parent and its destination parent, so no other category can fail an entry. Channels at the guild root are exempt. Every applied entry still orders each category's children with text and link channels first, and a category that already violated the rule is rewritten into that order by the same renumbering.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Channel hierarchy was applied |
+| 40012 | [error response](/http-api/#error-response) | An entry is structurally invalid, the destination category is full, or the caller has no enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS`, is not a member of the guild, or set `lock_permissions` without sufficient authority in the moved channel, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+| 423 | [error response](/http-api/#error-response) | Another hierarchy update currently owns this guild, returning `GENERAL_ERROR` |
+
+1 A structural failure returns `INVALID_FORM_BODY` and names the field with `CHANNEL_NOT_FOUND`, `INVALID_CHANNEL_ID`, `INVALID_PARENT_CHANNEL`, `PARENT_MUST_BE_CATEGORY`, `CATEGORIES_CANNOT_HAVE_PARENTS`, `PRECEDING_CHANNEL_MUST_SHARE_PARENT`, `CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF`, or `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS`. A full destination category returns the top-level `MAX_CATEGORY_CHANNELS`
+
+2 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after `MANAGE_CHANNELS` has been confirmed, so a caller without the permission receives 403 instead
+
+### Side effects
+
+The operation changes the position and parent of each affected channel, and every entry that changes the flattened order renumbers the guild's complete hierarchy densely from 1. It emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) per applied entry with the resulting hierarchy filtered to what each recipient can view. It sends nothing for an entry that leaves the order unchanged, and nothing to a recipient whose filtered list is empty.
+
+An entry with `lock_permissions` that changes the parent rewrites the moved channel's overwrites from the destination category after that entry's reorder has already been dispatched. The rewrite has no Gateway Dispatch and no audit entry, so a client must re-read the channel to observe the copied overwrites.
+
+The rewrite requires more than [MANAGE_ROLES](/http-api/permissions/) in the moved channel. In that channel, the caller must also hold each deny bit the copy would remove and each allow bit it would add. Any bit the caller does not hold returns 403 `MISSING_PERMISSIONS` after the reorder has already been committed.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and guild ID, on the `guild:channel:positions::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx b/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx
new file mode 100644
index 000000000..3e5326af3
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx
@@ -0,0 +1,389 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild emojis
+description: Guild emoji objects, uploads, cloning, bulk creation, renaming, and deletion.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An emoji is a custom image that one guild owns. Members use it in messages and reactions. Its ID is a [snowflake](/snowflakes/) that is unique across every guild. [List guild emojis](#list-guild-emojis) is the only guild-scoped read, and [Get emoji metadata](/http-api/expressions/#get-emoji-metadata) resolves a single emoji without membership of its guild.
+
+Every route names a guild in its path. A guild that has [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) rejects the request with 403 `MISSING_ACCESS` before the operation runs, and [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) does the same for an account without the instance staff flag.
+
+A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is distinguishable from guild membership. On [Modify guild emoji](#modify-guild-emoji) a non-member receives the same 403 `MISSING_PERMISSIONS`, and a guild that does not exist returns 404 `UNKNOWN_EMOJI`.
+
+The instance phrase and URL blocklists screen a submitted emoji `name` before the operation runs, and a match returns 403 `CONTENT_BLOCKED`. A name of fewer than three characters is not scanned. Neither blocklist reads `image`. Fluxer checks its decoded bytes against the banned asset hash list when it stores them.
+
+## Guild emoji object
+
+The stored image is written once at creation and is never replaced, so `name` is the only mutable field.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the emoji |
+| name | string | The name of the emoji (2-32 characters) |
+| animated1 | boolean | Whether the stored image is animated |
+| user?2 | [partial user](/http-api/users/#partial-user-object) object | The account that uploaded the emoji |
+
+1 Detected from the submitted image at creation, and copied unchanged by [Clone guild emoji](#clone-guild-emoji)
+
+2 Present only on [List guild emojis](#list-guild-emojis), where it is populated for every caller and requires no permission
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "name": "party_parrot",
+ "animated": true
+}
+```
+
+## Emoji create object
+
+The image and name submitted for one new emoji.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The name of the emoji (2-32 characters, ASCII letters, digits, and underscore) |
+| image23 | base64 string | The image to store (at most the resolved `emoji_max_size` limit in decoded bytes, 524288 by default) |
+
+1 The value is trimmed, and both bounds apply to the trimmed value
+
+2 A data URL prefix is accepted and everything up to the first comma is removed before any bound applies
+
+3 The decoded ceiling is the resolved `emoji_max_size` [limit key](/http-api/instance/#limit-keys) in guild scope, 524288 bytes by default
+
+The remaining encoded portion is bounded to 699052 characters, the base64 expansion of the 524288 byte default, and a longer value returns the validation code `BASE64_LENGTH_INVALID` at the `image` path. A value that is not canonical base64 with correct padding returns `INVALID_BASE64_FORMAT`. An image over the resolved ceiling returns `IMAGE_SIZE_EXCEEDS_LIMIT` at the same path, and `maxSize` is the ceiling that applied.
+
+The 699052 character bound does not move with the limit key, so a resolved ceiling above 524288 bytes admits no more than 524289 decoded bytes.
+
+Accepted upload formats are PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG. Fluxer takes the format and the animation state from the decoded image. An animated AVIF is rejected.
+
+Pixel dimensions are not enforced. An accepted image is stored at its original dimensions and the [Media Proxy image asset contract](/media-proxy/routes/#image-asset-contract) selects the delivered size.
+
+### Example
+
+```json
+{
+ "name": "party_parrot",
+ "image": "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI="
+}
+```
+
+## Emoji bulk create response object
+
+The outcome of one bulk creation, holding one entry for every submitted item.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | array[[guild emoji](#guild-emoji-object) object] | The emojis this request created |
+| failed | array[[expression creation failure](#expression-creation-failure-object) object] | The items rejected after top-level request validation |
+
+Both arrays are present on every success response, and either can be empty. An item appears in exactly one of them, so the combined length equals the submitted item count.
+
+### Example
+
+```json
+{
+ "success": [{"id": "1501314428688998182", "name": "party_parrot", "animated": true}],
+ "failed": [{"name": "too_big", "error": "The image is too large."}]
+}
+```
+
+## Expression creation failure object
+
+One item a bulk creation rejected, identified only by the name the caller submitted.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The name echoed from the submitted item |
+| error2 | string | The failure message rendered for the item |
+
+1 The object has no index, so two submitted items sharing a name cannot be told apart
+
+2 Rendered in the locale of the authenticated account, so its value changes with the caller's locale
+
+There is no machine-readable code, so a client that needs to branch on the reason retries the item on its own. An item that fails because the guild is full renders the emoji limit message with the resolved limit. Every other failure with a registered [error code](/http-api/errors/) renders that code's own message. An undecodable or unaccepted image renders the generic `INVALID_FORM_BODY` message with no per-field validation code. A banned image hash renders the `CONTENT_BLOCKED` message. A failure with no registered code, such as an object storage fault, renders a fixed unknown-error message.
+
+## List guild emojis
+
+
+
+Returns every [guild emoji object](#guild-emoji-object) of the guild, each with `user`. Requires membership of the guild and no permission.
+
+Fluxer returns the complete collection in one response, and the operation has no pagination parameters. An emoji whose uploader cannot be resolved is omitted, so the array can be shorter than the number of stored emojis.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild emoji](#guild-emoji-object) object] | Emojis were returned |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, or the caller is not a member |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and `MISSING_PERMISSIONS` for a non-member
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user and guild ID, on the `guild:emojis:list::guild_id` bucket.
+
+## Create guild emoji
+
+
+
+Creates one emoji from submitted image data and returns its [guild emoji object](#guild-emoji-object) without `user`. Requires membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event.
+
+The admission checks run in a fixed order: the body schema, then guild existence and membership, then `CREATE_EXPRESSIONS`, then the slot limit, and only then the image. A guild at its slot limit therefore returns `MAX_EMOJIS` even when the image would also have been rejected.
+
+The slot limit is the operator-configured [max_guild_emojis](/http-api/instance/#limit-keys) value resolved against the guild's complete feature set, defaulting to 500. A guild with [UNLIMITED_EMOJI](/http-api/guilds/#guild-features) bypasses that configuration and receives a fixed ceiling of 999999.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The body is one [emoji create object](#emoji-create-object).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild emoji](#guild-emoji-object) object | Emoji was created |
+| 4001 | [error response](/http-api/#error-response) | Guild is at its emoji slot limit, or the image is undecodable, oversized, or in an unaccepted format |
+| 4032 | [error response](/http-api/#error-response) | Name is blocked, the guild is unavailable, the caller is not a member or lacks `CREATE_EXPRESSIONS`, or the image hash is banned |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MAX_EMOJIS` for the slot limit, with the resolved limit in its message variables, and `INVALID_FORM_BODY` for an image failure, with `BASE64_LENGTH_INVALID` for an oversized encoding, `IMAGE_SIZE_EXCEEDS_LIMIT` for decoded bytes over the resolved ceiling, or `INVALID_IMAGE_FORMAT` for an undecodable or unaccepted image at the `image` path
+
+2 The [error code](/http-api/errors/) is `CONTENT_BLOCKED` for a blocked name and for a banned image hash, `MISSING_ACCESS` for an unavailable guild, and `MISSING_PERMISSIONS` for a membership or permission failure
+
+An image the decoder cannot read returns `INVALID_IMAGE_FORMAT`. The name scan runs before the route is reached, so it precedes the rate limit bucket and the credential check. The image hash check runs at upload time, after every other admission step has passed.
+
+### Side effects
+
+The operation consumes one guild emoji slot, stores the metadata-stripped image, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's complete emoji collection to every session connected to the guild, and then writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. A failed creation leaves no emoji, audit entry, or Dispatch and consumes no slot. Fluxer logs a failure to write the audit entry and still returns 200, so the emoji exists and the Dispatch is emitted.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and guild ID, on the `guild:emoji:create::guild_id` bucket.
+
+## Bulk create guild emojis
+
+
+
+Attempts to create between 1 and 50 emojis in one request and returns an [emoji bulk create response object](#emoji-bulk-create-response-object). Requires membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Emits one [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event for the whole batch.
+
+Fluxer processes the items strictly in the submitted order and treats each one independently. Once the guild is full, every remaining item fails with the limit. A later item can still succeed after an earlier item failed for any other reason.
+
+An undecodable image, an unaccepted format, and a banned hash each fail one item and leave the rest of the batch to run.
+
+:::caution[200 does not mean every item was created]
+The operation returns 200 whenever the top-level request was valid, including when every item failed. Read `success` and `failed` for the per-item outcome. The emojis in `success` are durable and are not rolled back when a later item fails.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emojis | array[[emoji create object](#emoji-create-object)] | The items to create, attempted in the order supplied (1-50) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [emoji bulk create response](#emoji-bulk-create-response-object) object | Every validated item was attempted |
+| 4001 | [error response](/http-api/#error-response) | Top-level body, item count, item shape, item name, or encoded image bound is invalid |
+| 4032 | [error response](/http-api/#error-response) | An item name is blocked, the guild is unavailable, or the caller is not a member or lacks `CREATE_EXPRESSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 Boundary validation rejects the whole request, so an oversized encoded image in one item fails before any item is created
+
+2 The [error code](/http-api/errors/) is `CONTENT_BLOCKED` for a blocked item name, `MISSING_ACCESS` for an unavailable guild, and `MISSING_PERMISSIONS` for a membership or permission failure
+
+Every item name is scanned together before the route is reached, so one blocked name rejects the whole request and no item is created.
+
+### Side effects
+
+Each successful item consumes one guild emoji slot, stores its image, and writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. One [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's complete emoji collection is emitted after the batch when at least one item succeeded, and it is emitted before the audit entries are written. A failed item leaves no emoji, audit entry, or slot consumption.
+
+### Rate limit
+
+6 requests per minute for each authenticated user and guild ID, on the `guild:emoji:bulk_create::guild_id` bucket.
+
+## Clone guild emoji
+
+
+
+Copies an existing emoji into the target guild and returns the new [guild emoji object](#guild-emoji-object) without `user`. Requires membership of the target guild and [CREATE_EXPRESSIONS](/http-api/permissions/) there. Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event in the target guild.
+
+The name, animation state, and stored image bytes are copied server-side, so the caller does not re-upload the image and cannot override any copied value. Membership of the source guild is not required. The source guild must not have [CLONE_EMOJI_DISABLED](/http-api/guilds/#guild-features).
+
+An unknown source emoji is reported first, then a source guild that is missing or that disables cloning, then target membership and permission, and only then the target slot limit. [Get emoji metadata](/http-api/expressions/#get-emoji-metadata) reports whether a source permits cloning without attempting the operation.
+
+The copied bytes are not re-scanned against the banned asset hash list.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the target guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| source_emoji_id | snowflake | The ID of the emoji to copy, which can belong to any guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild emoji](#guild-emoji-object) object | Emoji was cloned |
+| 4001 | [error response](/http-api/#error-response) | Target guild is at its emoji slot limit |
+| 4032 | [error response](/http-api/#error-response) | Source guild is missing or disables cloning, the target guild is unavailable, or the caller is not a member of the target guild or lacks `CREATE_EXPRESSIONS` there |
+| 4043 | [error response](/http-api/#error-response) | Source emoji or target guild does not exist |
+
+1 The [error code](/http-api/errors/) is `MAX_EMOJIS`
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` when the source guild is missing or has `CLONE_EMOJI_DISABLED` and when the target guild is unavailable, and `MISSING_PERMISSIONS` for a target membership or permission failure
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_EMOJI` for the source emoji and `UNKNOWN_GUILD` for the target guild
+
+A source guild that no longer exists returns 403 `MISSING_ACCESS`, so a caller cannot tell it apart from a source guild that disables cloning.
+
+### Side effects
+
+The operation consumes one target guild emoji slot and creates a copy whose uploader is the caller. It emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the target guild's complete emoji collection and then writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source emoji and source guild are unchanged and receive no Dispatch.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and target guild ID, on the `guild:emoji:clone::guild_id` bucket.
+
+## Modify guild emoji
+
+
+
+Renames an emoji and returns its [guild emoji object](#guild-emoji-object) without `user`. Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event.
+
+### Limitations
+
+- The uploader can rename their own emoji with [CREATE_EXPRESSIONS](/http-api/permissions/).
+- Any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/).
+
+Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels).
+
+The emoji is resolved within the guild named in the path before any permission is evaluated. An emoji that belongs to another guild and an emoji in a guild that does not exist both return 404 `UNKNOWN_EMOJI`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| emoji_id | snowflake | The ID of the emoji |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | The new name of the emoji (2-32 characters, ASCII letters, digits, and underscore) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild emoji](#guild-emoji-object) object | Name was processed |
+| 4001 | [error response](/http-api/#error-response) | Path parameter or name is invalid |
+| 4032 | [error response](/http-api/#error-response) | Name is blocked, the guild is unavailable, or the caller is neither the uploader with `CREATE_EXPRESSIONS` nor a member holding `MANAGE_EXPRESSIONS` |
+| 404 | [error response](/http-api/#error-response) | Emoji does not exist in that guild and the request returns `UNKNOWN_EMOJI` |
+
+1 The [error code](/http-api/errors/) is `INVALID_FORM_BODY` with `INVALID_SNOWFLAKE_FORMAT` or `SNOWFLAKE_OUT_OF_RANGE` for a path parameter, `STRING_LENGTH_INVALID` for a name outside 2 to 32 characters after normalisation, and `INVALID_FORMAT` for a disallowed character
+
+2 The [error code](/http-api/errors/) is `CONTENT_BLOCKED` for a blocked name, `MISSING_ACCESS` for an unavailable guild, and `MISSING_PERMISSIONS` for a permission failure
+
+### Side effects
+
+The operation sets the requested name, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's complete emoji collection, and then writes an [`EMOJI_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. Both are produced even when the submitted name equals the current name, and the audit entry then records an empty change list.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:emoji:update::guild_id` bucket.
+
+## Delete guild emoji
+
+
+
+Deletes the emoji record and returns 204 with an empty body. Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event.
+
+### Limitations
+
+- The uploader can delete their own emoji with [CREATE_EXPRESSIONS](/http-api/permissions/).
+- Any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/).
+
+Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels). Fluxer resolves the guild, then the purge eligibility, then the emoji, and only then the permission, so a purging request in a guild without the required feature is refused before the emoji is looked up.
+
+:::caution[A purge cannot be undone through the API]
+Without `purge`, the emoji leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the stored object and its `webp` and `gif` CDN representations are queued for permanent deletion.
+:::
+
+:::note[A clone outlives a purge of its source]
+A later purge of the source emoji does not affect an existing clone in another guild.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| emoji_id | snowflake | The ID of the emoji |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| purge?1 | boolean | Whether to permanently delete the stored image (default false) |
+
+1 Read as true only when it is exactly `true`, `True`, or `1` after trimming. Every other value, including `TRUE` and `yes`, reads as false
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Emoji record was deleted |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable or cannot purge expression assets, or the caller is neither the uploader with `CREATE_EXPRESSIONS` nor a member holding `MANAGE_EXPRESSIONS` |
+| 4042 | [error response](/http-api/#error-response) | Guild or emoji does not exist |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and when `purge` is true and the guild lacks [EXPRESSION_PURGE_ALLOWED](/http-api/guilds/#guild-features), and `MISSING_PERMISSIONS` for a membership or permission failure
+
+2 The [error code](/http-api/errors/) is `UNKNOWN_GUILD` for the guild and `UNKNOWN_EMOJI` for the emoji
+
+### Side effects
+
+The operation removes the emoji from the guild, returns its slot, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's remaining emoji collection, and then writes an [`EMOJI_DELETE`](/http-api/guild-audit-logs/#audit-actions) audit entry. When `purge` is true, the stored object and its cached representations are queued for permanent deletion after the Dispatch and before the audit entry. Existing messages that reference the emoji are not rewritten, and no message Dispatch is produced.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and guild ID, on the `guild:emoji:delete::guild_id` bucket. The route also draws on the `guild:emoji:delete:daily::guild_id` bucket of 300 requests per day for each authenticated user and guild ID.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx b/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx
new file mode 100644
index 000000000..83877336e
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx
@@ -0,0 +1,227 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild member search
+description: Indexed guild member search filters, sorting, pagination, and join metadata.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Guild member search finds the members of a guild by name, role, and join information. One route answers it from a search index and returns a [guild member search result](#guild-member-search-result-object) object for each match.
+
+A guild an operator has marked unavailable returns 403 `MISSING_ACCESS` before the route runs.
+
+Results are eventually consistent with direct membership reads. Fluxer logs a failed index write and discards it.
+
+:::caution[A dropped removal is never repaired]
+A rebuild re-upserts every current membership and repairs a dropped create or update. It deletes no document, so an already indexed guild keeps returning a member who has left.
+:::
+
+## Join source types
+
+Fluxer records the reason a membership exists at the moment it creates that membership. The HTTP API defines no operation that rewrites the value.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | CREATOR | Member created the guild |
+| 1 | INSTANT_INVITE | Member joined through an ordinary invite |
+| 2 | VANITY_URL | Member joined through the guild vanity URL |
+| 3 | BOT_INVITE | Account was added through a bot invite |
+| 4 | ADMIN_FORCE_ADD | Instance administrator added the member directly |
+| 6 | DISCOVERY | Member joined through guild discovery |
+
+The value 5 is not assigned.
+
+## Guild member search result object
+
+A guild member search result is one matched membership as the search index stored it. It flattens the account fields the [guild member object](/http-api/guild-members/#guild-member-object) nests under `user`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | The identity of the index document, one per membership |
+| guild_id | snowflake | The ID of the guild the membership belongs to |
+| user_id | snowflake | The ID of the account the membership belongs to |
+| username | string | The username of the account |
+| discriminator2 | string | The four-digit discriminator of the account |
+| global_name | ?string | The global display name of the account, or null when none is set |
+| nickname3 | ?string | The nickname of the member in this guild, or null when none is set |
+| role_ids34 | array[snowflake] | The IDs of the roles assigned to the member |
+| joined_at5 | integer | The time at which the account became a member, in Unix seconds |
+| supplemental6 | [guild member search supplemental](#guild-member-search-supplemental-object) object | The join metadata only this search exposes |
+| is_bot | boolean | Whether the account is a bot |
+
+1 The guild ID and the user ID joined by an underscore, as `{guild_id}_{user_id}`. No other operation accepts it as an identifier
+
+2 Left-padded with zeroes to four digits
+
+3 The [guild member object](/http-api/guild-members/#guild-member-object) names these two fields `nick` and `roles`, so a client reusing a member renderer must map them
+
+4 The everyone role is never present
+
+5 The same unit as the `joined_at_gte` and `joined_at_lte` filters. The [guild member object](/http-api/guild-members/#guild-member-object) reports the same moment as an ISO 8601 timestamp
+
+6 Always present, with every field inside it null unless the caller holds [MANAGE_GUILD](/http-api/permissions/)
+
+### Example
+
+```json
+{
+ "id": "1489002177550843904_1489002177550843906",
+ "guild_id": "1489002177550843904",
+ "user_id": "1489002177550843906",
+ "username": "ada",
+ "discriminator": "0042",
+ "global_name": "Ada",
+ "nickname": null,
+ "role_ids": ["1489002177550843910"],
+ "joined_at": 1755158400,
+ "supplemental": {"join_source_type": 1, "source_invite_code": "aBc123", "inviter_id": "1489002177550843905"},
+ "is_bot": false
+}
+```
+
+:::note[Search results are partial member representations]
+The result has no `avatar`, `banner`, `accent_color`, `mute`, `deaf`, `communication_disabled_until`, `profile_flags`, or `mention_flags`. Read the complete membership through [Get guild member](/http-api/guild-members/#get-guild-member).
+:::
+
+## Guild member search supplemental object
+
+A guild member search supplemental object has the join provenance the [guild member object](/http-api/guild-members/#guild-member-object) never exposes. Only a caller that can already manage the guild reads it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| join_source_type?12 | ?integer | The [join source type](#join-source-types) recorded for the membership |
+| source_invite_code1 | ?string | The invite code the member joined through |
+| inviter_id1 | ?snowflake | The ID of the account that created the invite the member joined through |
+
+1 Null both when the caller lacks [MANAGE_GUILD](/http-api/permissions/) and when the membership recorded no value, so a null does not distinguish the two
+
+2 Always emitted, so treat an absent field and a null field alike
+
+## Guild member search response object
+
+One search returns this envelope. Every field is always present.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild the search ran against |
+| members | array[[guild member search result](#guild-member-search-result-object) object] | The matched page in the requested order |
+| page_result_count1 | integer | The number of results in `members` |
+| total_result_count2 | integer | The total number of indexed memberships matching the filters |
+| indexing3 | boolean | Whether the guild member index is being built |
+
+1 The length of `members`
+
+2 The matching document count the configured search backend reports
+
+3 A true value always comes with an empty `members` array and both counts at zero
+
+An Elasticsearch instance reports the true `total_result_count`. A Meilisearch instance reports an estimate that saturates at 10000. In a very large guild that value can be lower than the true number of matches.
+
+### Example
+
+```json
+{
+ "guild_id": "1489002177550843904",
+ "members": [],
+ "page_result_count": 0,
+ "total_result_count": 0,
+ "indexing": true
+}
+```
+
+## Search guild members
+
+
+
+Searches the guild member index. Returns a [guild member search response](#guild-member-search-response-object) object on success. Requires membership of the guild.
+
+### Limitations
+
+- The caller must also hold at least one of [MANAGE_GUILD](/http-api/permissions/), [MANAGE_ROLES](/http-api/permissions/), [MANAGE_NICKNAMES](/http-api/permissions/), [BAN_MEMBERS](/http-api/permissions/), [MODERATE_MEMBERS](/http-api/permissions/), or [KICK_MEMBERS](/http-api/permissions/).
+- Five of the six qualifying bits are [elevated permissions](/http-api/permissions/#elevated-permissions). In a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, a caller other than the guild owner who holds one of those five and has no enrolled authenticator receives 400 `TWO_FACTOR_REQUIRED`. `MANAGE_NICKNAMES` is not elevated, so a caller who qualifies through it alone never receives that code.
+- A non-member of an existing guild receives the same 403 `MISSING_PERMISSIONS` as a member holding none of the qualifying bits, so a caller cannot tell the two apart.
+
+A guild that does not exist returns 404 `UNKNOWN_GUILD`.
+
+[List guild members](/http-api/guild-members/#list-guild-members) needs only membership and returns the complete member object for everyone. The permissions above gate the filtering, the sorting, the deep paging, and the [supplemental](#guild-member-search-supplemental-object) join provenance.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+Every field is optional, and an omitted filter is not applied. The route reads an omitted, empty, or whitespace-only body as an empty object. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path. A request with no usable field returns the first page of every indexed membership.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| query?12 | string | The text matched against the indexed identity fields (max 100 characters) |
+| limit? | integer | The maximum number of results to return (1-100, default 25) |
+| offset? | integer | The number of matching results to skip (minimum 0, default 0) |
+| role_ids?3 | array[snowflake] | The role IDs a matched member must hold (max 10) |
+| joined_at_gte?4 | integer | Unix seconds, includes members who joined at or after this time |
+| joined_at_lte?4 | integer | Unix seconds, includes members who joined at or before this time |
+| join_source_type?5 | array[integer] | The [join source types](#join-source-types) to include (max 10) |
+| source_invite_code?25 | array[string] | The invite codes to include (max 10) |
+| is_bot? | boolean | Include only bot accounts when true, and only non-bot accounts when false |
+| user_created_at_gte?46 | integer | Unix seconds, includes accounts created at or after this time |
+| user_created_at_lte?46 | integer | Unix seconds, includes accounts created at or before this time |
+| sort_by?7 | string | The sort field, either `joinedAt` or `relevance` |
+| sort_order?7 | string | The sort direction, either `asc` or `desc` |
+
+1 Trimmed before use. An absent or whitespace-only value applies no text clause
+
+2 Scanned by content moderation like any other body string, so a value of at least three characters matching a blocklist returns 403 `CONTENT_BLOCKED`
+
+3 Every listed role must be held by a result, so several entries narrow the search
+
+4 Both ends are inclusive, and supplying only one leaves the other unbounded
+
+5 Applied only when the caller holds [MANAGE_GUILD](/http-api/permissions/), and discarded for any other caller. Several entries widen the search
+
+6 Derived from the account's [snowflake](/snowflakes/)
+
+7 The sort field is camel-cased `joinedAt`, where the result object names the same value `joined_at`
+
+[Input normalisation](/http-api/#input-normalisation) rewrites `""` to null, which this field rejects with 400 `INVALID_FORM_BODY`. `" "` is accepted.
+
+With no `sort_by`, the route sorts by join time and defaults `sort_order` to `desc`. Two members with the same join time are ordered by descending `id`. `relevance` ignores `sort_order`. A Meilisearch instance orders `relevance` by its own ranking rules. An Elasticsearch instance orders `relevance` by descending `id`.
+
+Text matching runs over the username, the discriminator, the global display name, the guild nickname, and the user ID, and a query can match the middle of a username as well as its start. Term matching and typo tolerance belong to the search backend. Filter matching is exact in every case.
+
+:::caution[A deep `offset` returns 200 with an empty page]
+Elasticsearch walks past its 10000 document window with an internal cursor and serves an arbitrary `offset`. Meilisearch caps the window at 10000 and returns an empty page beyond it.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild member search response](#guild-member-search-response-object) object | Search was evaluated, or the index is not ready |
+| 400 | [error response](/http-api/#error-response) | Filter, sort value, limit, or offset is invalid |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, a body string is blocked, or the caller is not a member or holds none of the required permissions |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked body string, and `MISSING_PERMISSIONS` otherwise
+
+An index that has never been built, or that predates the instance forced reindex point, returns 200 with an empty page and `indexing` set to true, and the request schedules the rebuild. The same empty body with `indexing` false covers a guild whose record disappeared between the membership check and the index read, an instance with no search backend configured, and an instance still initialising. This operation never returns `FEATURE_TEMPORARILY_DISABLED`.
+
+:::note[`indexing` false does not mean zero matches]
+True means the rebuild is scheduled and the page is empty for that reason alone. False covers a genuine zero-match search and every other index that could not answer.
+:::
+
+### Side effects
+
+The route enqueues a rebuild of the guild's member index when the guild has never been indexed or was last indexed before the instance forced reindex point.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the shared `guild:members::guild_id` bucket. [List guild members](/http-api/guild-members/#list-guild-members), [Get current guild member](/http-api/guild-members/#get-current-guild-member), [Get guild member](/http-api/guild-members/#get-guild-member), and [List guild bans](/http-api/guild-moderation/#list-guild-bans) consume the same bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-members.mdx b/fluxer_docs/src/content/docs/http-api/guild-members.mdx
new file mode 100644
index 000000000..f3f0198e7
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-members.mdx
@@ -0,0 +1,548 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild members
+description: Guild member objects, profiles, voice moderation, ownership, removal, and roles.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A guild member is an account that has joined a guild. The membership has a nickname, avatar, role set, and moderation state that apply in that guild alone. Indexed member queries live on [Guild member search](/http-api/guild-member-search/), and bans on [Guild moderation](/http-api/guild-moderation/).
+
+A guild that no Gateway process serves returns 404 `UNKNOWN_GUILD`. An existing guild that does not hold the caller as a member returns 403 `MISSING_PERMISSIONS`. Fluxer refuses a guild an operator has marked unavailable with 403 `MISSING_ACCESS` before the route runs. [Transfer guild ownership](#transfer-guild-ownership) is the one exception to the 404, and returns 403 `ACCESS_DENIED` for a guild whose record survives without a Gateway process.
+
+The two modify operations return the resulting membership, [Transfer guild ownership](#transfer-guild-ownership) returns the updated guild, and every other mutation returns 204 with an empty body.
+
+:::note[Fluxer reads the target before authorising the caller]
+This applies to the two modify operations, [Remove guild member](#remove-guild-member), [Add guild member role](#add-guild-member-role), and [Remove guild member role](#remove-guild-member-role). A request whose target is not a member returns 404 `UNKNOWN_MEMBER` whether or not the caller can see the guild.
+:::
+
+## Guild member object
+
+Every field except `user` describes state that belongs to the membership.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [partial user](/http-api/users/#partial-user-object) object | The account the membership belongs to |
+| nick | ?string | The nickname the account uses in this guild, or null when none is set |
+| avatar1 | ?string | The image hash of the guild-specific avatar, or null when none is set |
+| banner1 | ?string | The image hash of the guild-specific banner, or null when none is set |
+| accent_color | ?integer | The guild profile accent colour as packed 24-bit RGB, or null when none is set |
+| roles2 | array[snowflake] | The IDs of the roles assigned to the membership (max 250) |
+| joined_at | ISO8601 timestamp | The time the account became a member of this guild |
+| mute3 | boolean | Whether a moderator has muted the member in voice |
+| deaf4 | boolean | Whether a moderator has deafened the member in voice |
+| communication_disabled_until5 | ?ISO8601 timestamp | The time the communication timeout expires, or null when the member is not timed out |
+| profile_flags?6 | integer | The [guild member profile flags](#guild-member-profile-flags) set on the membership |
+| mention_flags?6 | integer | The [reply mention preference](/http-api/users/#reply-mention-preferences) that applies only inside this guild |
+
+1 An animated hash has the `a_` prefix. Both are reported as null while the membership is marked premium sanitised
+
+2 The everyone role is never present
+
+3 True while a moderator mute is stored on the membership and also while a communication timeout is active
+
+4 True while a moderator deafen is stored on the membership. A communication timeout does not affect it
+
+5 A timestamp in the past is retained until a later update supplies `communication_disabled_until`
+
+6 Omitted while the stored value is 0, so absence means no flag is set and, for `mention_flags`, that the account-level preference applies
+
+Premium sanitisation hides the stored avatar and banner hashes and keeps them on the membership. Fluxer applies it once the account has lost its premium entitlement, when the membership holds a guild avatar, banner, biography, or accent colour.
+
+A client compares `communication_disabled_until` against the current time, because a non-null value can name a moment that has already passed. A [communication timeout](/http-api/permissions/#communication-timeout) does not reduce the member's computed permission mask.
+
+### Example
+
+```json
+{
+ "user": {"id": "1489002177550843906", "username": "ada", "discriminator": "0042"},
+ "nick": "Ada L.",
+ "avatar": null,
+ "banner": null,
+ "accent_color": 3447003,
+ "roles": ["1489002177550843910"],
+ "joined_at": "2026-08-14T09:12:44.000Z",
+ "mute": false,
+ "deaf": false,
+ "communication_disabled_until": null
+}
+```
+
+:::note[List and direct reads can briefly differ]
+[Get current guild member](#get-current-guild-member), [Get guild member](#get-guild-member), and both modify operations recompute `mute` against the current time. In [List guild members](#list-guild-members) an expired timeout can leave `mute` true until that membership next changes.
+:::
+
+## Guild member profile flags
+
+A guild profile asset has three states. With no flag and no hash the membership inherits the account-level asset. A stored hash sets a guild-specific asset. The flag below suppresses inheritance and renders the default.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | AVATAR_UNSET | The guild avatar is explicitly unset, so the account avatar is not inherited |
+| 1 << 1 | BANNER_UNSET | The guild banner is explicitly unset, so the account banner is not inherited |
+
+The field is validated as an integer between 0 and 2147483647, so an undefined bit below bit 31 is stored and returned unchanged. Bit 31, and any value above 2147483647, are rejected.
+
+## Guild member update object
+
+The request body shared by [Modify current guild member](#modify-current-guild-member) and [Modify guild member](#modify-guild-member). An omitted field preserves its current value.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| nick?1 | ?string | The nickname to set in this guild (1-32 characters) |
+| roles?2 | array[snowflake] | The complete replacement role set (max 250) |
+| avatar?3 | ?base64 string | The guild avatar image data, or null to clear it |
+| banner?3 | ?base64 string | The guild banner image data, or null to clear it |
+| bio? | ?string | The guild profile biography (1-320 characters) |
+| pronouns? | ?string | The guild profile pronouns (1-40 characters) |
+| accent_color? | ?integer | The guild profile accent colour as packed 24-bit RGB (0-16777215) |
+| profile_flags? | ?integer | The [guild member profile flags](#guild-member-profile-flags) to store |
+| mention_flags?4 | ?integer | The [reply mention preference](/http-api/users/#reply-mention-preferences) to apply in this guild |
+| mute?5 | boolean | Whether the member is muted by a moderator in voice |
+| deaf?5 | boolean | Whether the member is deafened by a moderator in voice |
+| communication_disabled_until?6 | ?ISO8601 timestamp | The time at which the communication timeout expires |
+| timeout_reason?7 | ?string | The reason recorded alongside the timeout (1-512 characters) |
+| channel_id?8 | ?snowflake | The voice channel to move the member into, or null to disconnect them |
+| connection_id?8 | ?string | The single voice connection to move, defaulting to every connection (1-32 characters) |
+
+1 A blank string is normalised to null before the length bound runs, so an empty value clears the nickname
+
+2 A role ID absent from the supplied array is removed from the member
+
+3 Base64 image data with an optional data URL prefix, which is not counted
+
+4 One of the three [reply mention preference](/http-api/users/#reply-mention-preferences) enum values, where 0 restores the account-level preference
+
+5 Stored on the membership, so it applies whether or not the member holds a voice connection
+
+6 An ISO 8601 date and time. A blank string is normalised to null
+
+7 Trimmed, and never published in the audit log response
+
+8 `channel_id` moves or disconnects, and `connection_id` selects one of the member's voice connections
+
+A `roles` entry that does not resolve to an existing role of the guild is dropped from the replacement, so a request naming only unknown roles clears the member's role set.
+
+An `avatar` or `banner` base64 payload longer than 13981016 characters returns `BASE64_LENGTH_INVALID`, and a malformed one returns `INVALID_BASE64_FORMAT`. The decoded bytes are then checked against the instance-configured `avatar_max_size` [limit](/http-api/instance/#limit-keys), whose stock value is 10485760, and the same ceiling covers both fields. The decoded image must also pass the format allowlist and the animation rules of the asset policy for the field it sets. Pixel dimensions are not enforced. A value that is too large returns `IMAGE_SIZE_EXCEEDS_LIMIT`, and one whose format or animation is not allowed returns `INVALID_IMAGE_FORMAT`.
+
+Null and any `communication_disabled_until` that is not in the future both clear the timeout. A time more than 365.25 days ahead returns `TIMEOUT_CANNOT_EXCEED_365_DAYS`, and a value that passes the schema but is not a real instant returns `INVALID_TIMEOUT_VALUE`. `timeout_reason` has no effect unless `communication_disabled_until` is also supplied.
+
+Omitting `connection_id` applies the move or the disconnect to every voice connection the member holds in the guild. A `connection_id` that is not one of the member's own connections returns 400 `USER_NOT_IN_VOICE`.
+
+## List guild members
+
+
+
+Returns a page of [guild member objects](#guild-member-object) in ascending user ID order. Requires membership of the guild and no permission.
+
+This route consults no permission bit, including [VIEW_CHANNEL_MEMBERS](/http-api/permissions/).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit?1 | integer | The maximum number of members to return (1-1000, default 1) |
+| after?2 | snowflake | The lower bound on user ID, returning only members strictly greater than this value |
+
+1 A caller that wants a batch states `limit` explicitly, because the default returns one member
+
+2 The cursor is exclusive, and there is no descending or `before` form
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild member](#guild-member-object) object] | Members were returned |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, or the caller is not a member |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the `guild:members::guild_id` bucket.
+
+## Get current guild member
+
+
+
+Returns the authenticated account's own [guild member object](#guild-member-object). Requires membership of the guild and no permission.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild member](#guild-member-object) object | Membership was returned |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, or the caller is not a member |
+| 4041 | [error response](/http-api/#error-response) | Guild does not exist, or the membership record is absent |
+
+1 A missing guild returns `UNKNOWN_GUILD` and a missing membership record returns `UNKNOWN_MEMBER`
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the shared `guild:members::guild_id` bucket.
+
+## Get guild member
+
+
+
+Returns one member's [guild member object](#guild-member-object). Requires membership of the guild and no permission.
+
+This route resolves the guild for the caller before it reads the target, so a non-member never learns whether the target is one.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the target member |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild member](#guild-member-object) object | Membership was returned |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, or the caller is not a member |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD`, or the target is not a member, returning `UNKNOWN_MEMBER` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the shared `guild:members::guild_id` bucket.
+
+## Modify current guild member
+
+
+
+Modifies the authenticated account's own membership and returns the resulting [guild member object](#guild-member-object). Requires membership of the guild. Emits a [Guild Member Update](/gateway/events/#guild-member-update) Gateway event.
+
+### Limitations
+
+- Changing the nickname requires [CHANGE_NICKNAME](/http-api/permissions/).
+- Supplying `channel_id` requires [MOVE_MEMBERS](/http-api/permissions/), even for the caller's own membership.
+- Supplying `mute` requires [MUTE_MEMBERS](/http-api/permissions/) and supplying `deaf` requires [DEAFEN_MEMBERS](/http-api/permissions/).
+- Supplying `nick`, `avatar`, `banner`, `bio`, `pronouns`, `accent_color`, or `profile_flags` requires a verified email address, and a bot account is exempt. An unverified account is rejected with 403 `PROFILE_EMAIL_VERIFICATION_REQUIRED`.
+
+A caller without `CHANGE_NICKNAME` does not fail the request. Fluxer discards the nickname change and applies every other supplied field. A member who holds the permission but is currently timed out is rejected with 403 `COMMUNICATION_DISABLED`.
+
+A supplied `nick`, `bio`, or `pronouns` is scanned against the instance phrase, URL, and profile substring blocklists, and a match returns 403 `CONTENT_BLOCKED`.
+
+Guild avatar, banner, biography, and accent colour additionally require the instance-configured `feature_per_guild_profiles` [limit](/http-api/instance/#limit-keys) for the calling account. Fluxer discards any of those four fields supplied without that capability and applies every other field. Pronouns, `profile_flags`, and `mention_flags` are not gated that way.
+
+A `channel_id` destination must be a guild voice channel, and the caller must hold both [VIEW_CHANNEL](/http-api/permissions/) and [CONNECT](/http-api/permissions/) there. Supplying `channel_id` for a member with no live voice connection returns 400 `USER_NOT_IN_VOICE`. Supplying `mute` or `deaf` for such a member succeeds, stores the flags, and emits no voice Dispatch.
+
+Fluxer rejects a supplied `communication_disabled_until` with 403 `MISSING_PERMISSIONS` before any other timeout check. [Modify guild member](#modify-guild-member) addressed at the caller's own user ID is rejected the same way.
+
+:::caution[A profile change limit fails the whole request]
+Avatar, banner, biography, pronouns, and accent colour each have a limit of 25 changes per 30 minutes for each authenticated user and guild. Exhausting one returns 400 `INVALID_FORM_BODY` and applies no field at all.
+:::
+
+The avatar and banner limits are consumed whenever the field is present. The biography, pronouns, and accent colour limits are consumed only when the supplied value differs from the stored one. The field code is `AVATAR_CHANGED_TOO_MANY_TIMES`, `BANNER_CHANGED_TOO_MANY_TIMES`, `BIO_CHANGED_TOO_MANY_TIMES`, `PRONOUNS_CHANGED_TOO_MANY_TIMES`, or `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The body is a [guild member update object](#guild-member-update-object) without `roles`. A supplied `roles` field is discarded without an error, so role assignment goes through [Modify guild member](#modify-guild-member).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild member](#guild-member-object) object | Membership was processed |
+| 400 | [error response](/http-api/#error-response) | Image data or a profile change limit rejects the request |
+| 400 | [error response](/http-api/#error-response) | `channel_id` was supplied for a member with no live voice connection |
+| 400 | [error response](/http-api/#error-response) | Destination channel does not exist or is not a voice channel |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member, or the email address is unverified |
+| 403 | [error response](/http-api/#error-response) | Blocked content was supplied, the caller is timed out, or a required voice permission is absent |
+| 403 | [error response](/http-api/#error-response) | `communication_disabled_until` was supplied |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, or the membership record or account is absent |
+
+### Side effects
+
+The operation updates the membership, replaces any supplied guild avatar or banner, updates member search results, records a [`MEMBER_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, and emits [Guild Member Update](/gateway/events/#guild-member-update).
+
+The target's own sessions always receive the Dispatch. Other guild sessions receive it subject to [event filtering](/gateway/event-filtering/). The audit entry and Dispatch are produced even when the resulting membership is unchanged, and an entry whose change list is empty is published without a `changes` member.
+
+Supplying `channel_id` moves or disconnects every voice connection the member holds, or only the one named by `connection_id`. It records one [`MEMBER_MOVE`](/http-api/guild-audit-logs/#audit-actions) or [`MEMBER_DISCONNECT`](/http-api/guild-audit-logs/#audit-actions) audit entry for the whole request, whatever the number of connections it touched, and emits [Voice State Update](/gateway/events/#voice-state-update) to sessions that can view the affected channel. Supplying `mute` or `deaf` updates the membership, records no separate audit entry, and emits the same voice Dispatch once for each live connection.
+
+Every audit entry also emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). A request that fails after uploading a new avatar or banner leaves the previous asset in place.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:update::guild_id` bucket.
+
+## Modify guild member
+
+
+
+Modifies another member's guild state and returns the resulting [guild member object](#guild-member-object). Requires membership of the guild. Emits a [Guild Member Update](/gateway/events/#guild-member-update) Gateway event.
+
+### Limitations
+
+- Changing another member's nickname requires [MANAGE_NICKNAMES](/http-api/permissions/) and role hierarchy authority over the target.
+- Replacing `roles` requires [MANAGE_ROLES](/http-api/permissions/) and hierarchy authority over every role the replacement adds or removes, and the guild owner bypasses both checks.
+- Applying `communication_disabled_until` requires [MODERATE_MEMBERS](/http-api/permissions/) and hierarchy authority, and the target must not hold [ADMINISTRATOR](/http-api/permissions/).
+- Applying `mute` requires [MUTE_MEMBERS](/http-api/permissions/), applying `deaf` requires [DEAFEN_MEMBERS](/http-api/permissions/), and both require hierarchy authority over the target.
+- Moving the target requires [MOVE_MEMBERS](/http-api/permissions/) and hierarchy authority, plus [VIEW_CHANNEL](/http-api/permissions/) and [CONNECT](/http-api/permissions/) for the caller in the destination.
+
+A supplied `nick` is scanned against the instance phrase, URL, and profile substring blocklists, and a match returns 403 `CONTENT_BLOCKED`.
+
+Hierarchy authority over the target is not consulted for `roles`, so a caller who outranks every affected role can replace the role set of a member who outranks them. The everyone role in the array is rejected with the field code `INVALID_ROLE_ID`.
+
+The target's own permissions in the destination are never consulted, so a member can be moved into a channel they could not join themselves. Disconnecting sets `channel_id` to null and requires the same permission.
+
+Supplying `channel_id` for a target with no live voice connection, or a `connection_id` that is not one of the target's own, returns 400 `USER_NOT_IN_VOICE`. Supplying `mute` or `deaf` for such a target succeeds and stores the flags. A destination that does not exist returns the field code `CHANNEL_DOES_NOT_EXIST`, and one that is not a voice channel `CHANNEL_MUST_BE_VOICE`.
+
+`MANAGE_ROLES` and `MODERATE_MEMBERS` are [elevated permissions](/http-api/permissions/#elevated-permissions), so replacing `roles` or applying `communication_disabled_until` additionally requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner.
+
+A caller addressing their own user ID here falls under the self-targeted rules of [Modify current guild member](#modify-current-guild-member) for the guild profile fields, and the body remains the complete update object, so `roles` is accepted. Replacing their own role set still requires `MANAGE_ROLES` and hierarchy authority, and `communication_disabled_until` is still rejected with 403 `MISSING_PERMISSIONS`.
+
+:::note[Guild profile fields belong to their own account]
+`avatar`, `banner`, `bio`, `pronouns`, `accent_color`, `profile_flags`, and `mention_flags` describe the target's own profile, so a moderator cannot set them. Supplying one for another member leaves it unwritten, and the request still applies every field the caller may set.
+:::
+
+:::caution[A blocklisted `bio` or `pronouns` returns 403]
+The request fails with `CONTENT_BLOCKED` even though the field is never written.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the target member |
+
+### JSON body
+
+The body is a [guild member update object](#guild-member-update-object).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild member](#guild-member-object) object | Membership was processed |
+| 400 | [error response](/http-api/#error-response) | Role set or timeout value rejects the request |
+| 4001 | [error response](/http-api/#error-response) | Caller holds `MANAGE_ROLES` or `MODERATE_MEMBERS` without an enrolled authenticator in an elevated-MFA guild |
+| 400 | [error response](/http-api/#error-response) | `channel_id` was supplied for a target with no live voice connection |
+| 400 | [error response](/http-api/#error-response) | Destination channel does not exist or is not a voice channel |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member, or a required permission is absent |
+| 403 | [error response](/http-api/#error-response) | Blocked content was supplied, hierarchy authority is absent, or the target holds `ADMINISTRATOR` |
+| 403 | [error response](/http-api/#error-response) | Target is the caller and `communication_disabled_until` was supplied |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD`, or the target membership or account is absent, returning `UNKNOWN_MEMBER` |
+
+1 The missing-authenticator [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed, so a caller lacking the permission entirely receives 403 instead
+
+### Side effects
+
+The operation has the same membership, audit, search, [Guild Member Update](/gateway/events/#guild-member-update), and [Voice State Update](/gateway/events/#voice-state-update) effects as [Modify current guild member](#modify-current-guild-member). Applying a non-empty replacement role set to a temporary membership also makes the membership permanent. The target account always receives its own Guild Member Update, while every other guild session receives it subject to [event filtering](/gateway/event-filtering/).
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:update::guild_id` bucket.
+
+## Remove guild member
+
+
+
+Removes a member from the guild and returns 204 with an empty body. Requires membership, [KICK_MEMBERS](/http-api/permissions/), and role hierarchy authority over the target. Emits a [Guild Member Remove](/gateway/events/#guild-member-remove) Gateway event.
+
+`KICK_MEMBERS` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it additionally requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner.
+
+The caller and the guild owner cannot be removed through this operation, and both are reported as 404 `UNKNOWN_MEMBER`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the target member |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Member was removed |
+| 4001 | [error response](/http-api/#error-response) | Caller holds `KICK_MEMBERS` without an enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member, or `KICK_MEMBERS` is absent |
+| 403 | [error response](/http-api/#error-response) | Hierarchy authority over the target is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | Target is not a member, is the caller, or is the guild owner, each returning `UNKNOWN_MEMBER` |
+
+1 The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed
+
+### Side effects
+
+The operation retains the member's first join time, leave time, and any stored communication timeout for one year. Rejoining during that period resumes a timeout that is still in the future.
+
+It deletes the membership row and decreases the guild's member count, so the guild nickname, guild avatar hash, guild banner hash, biography, pronouns, accent colour, and role set are lost. The removed account's read states and guild settings are left untouched, and the guild is not removed from that account's guild folders.
+
+The operation records a [`MEMBER_KICK`](/http-api/guild-audit-logs/#audit-actions) audit entry with no change list and emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). Remaining guild sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove), subject to [event filtering](/gateway/event-filtering/). The removed account's sessions receive [Guild Delete](/gateway/events/#guild-delete).
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:remove::guild_id` bucket.
+
+## Transfer guild ownership
+
+
+
+Transfers guild ownership to another current member and returns the updated [guild object](/http-api/guilds/#guild-object). Requires the caller to be the current owner. Emits a [Guild Update](/gateway/events/#guild-update) Gateway event.
+
+### Limitations
+
+- No permission bit substitutes for ownership.
+- The target must be a current member.
+- The target must not be a bot account, and a bot target returns 400 `CANNOT_TRANSFER_OWNERSHIP_TO_BOT`.
+
+The operation requires sudo mode as described by the [sudo verification fields](/http-api/guilds/#sudo-verification-fields), which a bot credential satisfies without a proof. Fluxer verifies sudo mode before ownership, so a member who is not the owner still has to prove sudo mode before receiving 403 `MISSING_PERMISSIONS`. An account that supplies no usable proof receives 403 `SUDO_MODE_REQUIRED`, whose payload names the second factors the account holds.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT? | string | The existing sudo mode proof |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| new_owner_id | snowflake | The ID of the member to make the new owner |
+| password? | string | The current owner password, as defined by the [sudo verification fields](/http-api/guilds/#sudo-verification-fields) |
+| mfa_method? | string | The second factor to verify, either `totp` or `webauthn` |
+| mfa_code? | string | The authenticator code, required when the method is `totp` (1-32 characters) |
+| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | The assertion produced when the method is `webauthn` |
+| webauthn_challenge? | string | The challenge bound to the WebAuthn assertion |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild](/http-api/guilds/#guild-object) object | Ownership was transferred |
+| 400 | [error response](/http-api/#error-response) | Supplied password or authenticator code is wrong, or the target is a bot account |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, sudo mode is absent, or the caller is not the guild owner |
+| 403 | [error response](/http-api/#error-response) | Guild record exists while no Gateway process serves it, returning `ACCESS_DENIED` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD`, or the target is not a member, returning `UNKNOWN_MEMBER` |
+
+### Side effects
+
+The operation changes only the guild's `owner_id`. The outgoing owner keeps every role, permission overwrite, and membership they held.
+
+It records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry naming the previous and new owner in its change list, emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Update](/gateway/events/#guild-update) to every session that can see the guild. The audit log response has no `options` member for this entry.
+
+An account with an enrolled second factor also receives a reissued sudo proof. A bot credential and an account whose only proof was a password receive `X-Fluxer-Sudo-Mode-JWT` only when the request itself has one.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the shared `guild:update::guild_id` bucket.
+
+## Add guild member role
+
+
+
+Adds one role to a member and returns 204 with an empty body. Requires membership, [MANAGE_ROLES](/http-api/permissions/), and hierarchy authority over the role. Emits a [Guild Member Update](/gateway/events/#guild-member-update) Gateway event.
+
+### Limitations
+
+- The guild owner bypasses the permission and the hierarchy check.
+- The everyone role cannot be assigned and is rejected with the field code `INVALID_ROLE_ID`.
+
+`MANAGE_ROLES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it additionally requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. Every accepted request emits [Guild Member Update](/gateway/events/#guild-member-update), including one that changes nothing.
+
+The guild owner receives 404 `UNKNOWN_ROLE` for a role that does not exist in the guild. Any other caller receives 403 `MISSING_PERMISSIONS`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the target member |
+| role_id | snowflake | The ID of the role to add |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Role is present on the member |
+| 4001 | [error response](/http-api/#error-response) | Role is the everyone role, or the caller holds `MANAGE_ROLES` without an enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member, `MANAGE_ROLES` is absent, or hierarchy authority over the role is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | Target is not a member, returning `UNKNOWN_MEMBER` |
+| 404 | [error response](/http-api/#error-response) | Guild owner named a role that does not exist, returning `UNKNOWN_ROLE` |
+
+1 The missing-authenticator [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed
+
+### Side effects
+
+When the member does not already hold the role, the operation adds it and makes a temporary membership permanent. It then updates member search results, records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Member Update](/gateway/events/#guild-member-update), and it does all four whether or not the role set changed. A request naming a role the member already holds still writes an audit entry with an empty change list, published without a `changes` member.
+
+The audit log response names the role only through the change list.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:role:add::guild_id` bucket.
+
+## Remove guild member role
+
+
+
+Removes one role from a member and returns 204 with an empty body. Requires membership, [MANAGE_ROLES](/http-api/permissions/), and hierarchy authority over the role. Emits a [Guild Member Update](/gateway/events/#guild-member-update) Gateway event.
+
+### Limitations
+
+- The guild owner bypasses the permission and the hierarchy check.
+- The everyone role cannot be removed and is rejected with the field code `INVALID_ROLE_ID`.
+
+`MANAGE_ROLES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it additionally requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. Every accepted request emits [Guild Member Update](/gateway/events/#guild-member-update), including one that changes nothing. A role that does not exist is reported exactly as it is for [Add guild member role](#add-guild-member-role).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the target member |
+| role_id | snowflake | The ID of the role to remove |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Role is absent from the member |
+| 4001 | [error response](/http-api/#error-response) | Role is the everyone role, or the caller holds `MANAGE_ROLES` without an enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member, `MANAGE_ROLES` is absent, or hierarchy authority over the role is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | Target is not a member, returning `UNKNOWN_MEMBER` |
+| 404 | [error response](/http-api/#error-response) | Guild owner named a role that does not exist, returning `UNKNOWN_ROLE` |
+
+1 The missing-authenticator [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed
+
+### Side effects
+
+When the member holds the role, the operation removes it. It then updates member search results, records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Member Update](/gateway/events/#guild-member-update), and it does all four whether or not the role set changed. Removing a role does not change whether the membership is temporary.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:role:remove::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx b/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx
new file mode 100644
index 000000000..b62b14b1a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx
@@ -0,0 +1,208 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild moderation
+description: Guild bans, temporary bans, ban replacement, and the blocks a ban has.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A guild ban keeps one account out of one guild, and it outlives the membership it removes. Removing a member is a separate operation with its own permission, and [Remove guild member](/http-api/guild-members/#remove-guild-member) defines it.
+
+Every route here requires [BAN_MEMBERS](/http-api/permissions/), which is an [elevated permission](/http-api/permissions/#elevated-permissions). In a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, every caller except the guild owner also needs an enrolled multi-factor authenticator. A guild an operator has marked unavailable returns 403 `MISSING_ACCESS` before the route runs.
+
+[List guild bans](#list-guild-bans) is the only operation that reads a ban record, and there is no bulk ban operation.
+
+## Guild ban object
+
+A guild ban object names the banned account, the moderator, the reason, and any expiry. Creating a ban for a user who is already banned replaces the existing record.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [partial user](/http-api/users/#partial-user-object) object | The banned account |
+| reason1 | ?string | The stored ban reason, or null when none was recorded |
+| moderator_id | snowflake | The account that issued or most recently replaced the ban |
+| banned_at2 | ISO8601 timestamp | Time the ban was issued or most recently replaced |
+| expires_at3 | ?ISO8601 timestamp | Time a temporary ban stops applying, or null for a permanent ban |
+
+1 The body `reason` when that field is supplied, and the `X-Audit-Log-Reason` value when it is omitted or null
+
+2 Replacing a ban rewrites this value, so it records the most recent issuance
+
+3 An expired temporary ban is removed and never returned with a past `expires_at`
+
+### Example
+
+```json
+{
+ "user": {"id": "1489002177550843906", "username": "spammer", "discriminator": "0042"},
+ "reason": "Advertising",
+ "moderator_id": "1489002177550843905",
+ "banned_at": "2026-08-14T09:12:44.000Z",
+ "expires_at": null
+}
+```
+
+:::note[Fluxer never returns the ban address or email]
+A ban also stores the target's last known IP address and the lower-cased address of their account email. An address on the instance exemption list is stored as null.
+:::
+
+Accepting an [invite](/http-api/invites/) compares the joining account against the banned account identifier, the stored address, and the stored email. The address comparison uses a normalised decision key: the exact address for IPv4, the mapped IPv4 address for an IPv4-mapped IPv6 address, and the `/64` network for every other IPv6 address. An address match refuses the join with 403 `USER_IP_BANNED_FROM_GUILD`.
+
+Fluxer skips the address match when the joining address is on the instance exemption list, and also when the banned address is a single address and the joining address is reported as high mobile or carrier-grade NAT blast radius. The match runs when that report is unavailable or fails.
+
+The email comparison is exact and runs only when an invite is accepted. An email match refuses the join with 403 `USER_BANNED_FROM_GUILD`. Every other path that adds a member compares the account identifier and the address alone, and several compare nothing. [Grant OAuth2 consent](/http-api/oauth2/#grant-oauth2-consent) installing a bot, the admin membership operations, the stock community auto-join, and the premium entitlement guild join all add the member without consulting the ban list.
+
+[Remove guild ban](#remove-guild-ban) releases both blocks. Permanently deleting the banned account deletes every guild ban it holds, and that releases both blocks in every guild at once.
+
+## List guild bans
+
+
+
+Returns every [guild ban object](#guild-ban-object) of the guild. Requires [BAN_MEMBERS](/http-api/permissions/).
+
+A non-member cannot hold `BAN_MEMBERS`, so a caller outside the guild receives 403 `MISSING_PERMISSIONS`.
+
+The operation returns the complete collection in one response and has no limit or cursor parameters. The response excludes an expired temporary ban, and omits a ban whose account can no longer be resolved, so the array can be shorter than the number of stored bans.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild ban](#guild-ban-object) object] | Bans were returned |
+| 4001 | [error response](/http-api/#error-response) | Caller holds `BAN_MEMBERS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, or `BAN_MEMBERS` is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and `MISSING_PERMISSIONS` otherwise
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the shared `guild:members::guild_id` bucket.
+
+## Create or replace guild ban
+
+
+
+Creates a guild ban, or replaces an existing one, and returns 204 with an empty body. Requires [BAN_MEMBERS](/http-api/permissions/). Emits a [Guild Ban Add](/gateway/events/#guild-ban-add) Gateway event.
+
+### Limitations
+
+- The caller cannot ban themselves, and Fluxer reports a self-target as 404 `UNKNOWN_MEMBER`.
+- Banning a target who is currently a member also requires role hierarchy authority over that member. The guild owner holds that authority over everyone, and no other caller holds it over the owner.
+- A target who is not a member can still be banned, and the ban pre-empts a future join.
+
+Fluxer scans every string in a JSON request body against the instance phrase and URL blocklists, and that includes `reason`. A blocked value returns 403 `CONTENT_BLOCKED` before the rate limit bucket, the credential check, and the permission check.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the account to ban |
+
+### JSON body
+
+The body is optional, and so is every field in it. Fluxer reads an omitted or whitespace-only body as an empty object, so a bare request creates a permanent ban that deletes no messages and records no body reason. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| delete_message_seconds?1 | integer | Age of the target's recent guild messages to delete, in seconds (0-604800) |
+| delete_message_days?2 | integer | Legacy age of those messages, in whole days (0-7, default 0) |
+| reason?3 | ?string | Reason stored on the ban record (max 512 characters) |
+| ban_duration_seconds?4 | integer | Ban duration in seconds, either 0 or 60 through 63072000 |
+
+1 Zero and an omitted field delete nothing. The field takes precedence over `delete_message_days` whenever it is present, including when it is 0
+
+2 Multiplied by 86400 to produce the effective interval, and only when `delete_message_seconds` is absent
+
+3 Stripped of removed control and format code points and trimmed before its length is measured. A longer result fails with `STRING_LENGTH_INVALID` at the `reason` path
+
+4 Zero and an omitted field both create a permanent ban. Any value outside 60 through 63072000, which is two years, fails with `INVALID_FORMAT`
+
+`delete_message_days` defaults to 0, so a [`MEMBER_BAN_ADD`](/http-api/guild-audit-logs/#audit-actions) entry always records `options.delete_member_days`. That option is `0` for a ban that supplied only `delete_message_seconds`.
+
+An omitted or null `reason` falls back to the `X-Audit-Log-Reason` value, while a supplied value that normalises to the empty string is stored as null without falling back. The header remains the audit entry reason in every case.
+
+:::caution[Banning an already banned account succeeds]
+The second ban overwrites the stored reason, moderator, issue time, expiry, address, and email. Re-issuing can downgrade a permanent ban to a temporary one or extend a temporary one. There is no conditional form.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Ban was created or replaced |
+| 4001 | [error response](/http-api/#error-response) | Duration, deletion interval, or reason is invalid, or the caller holds `BAN_MEMBERS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, blocked content was supplied, `BAN_MEMBERS` is absent, or hierarchy authority over a member target is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+| 4043 | [error response](/http-api/#error-response) | Target account does not exist, or the target is the caller |
+
+1 The missing-authenticator [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`, returned only after the permission itself has been confirmed
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked string, and `MISSING_PERMISSIONS` otherwise
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_USER` for an account that does not exist and `UNKNOWN_MEMBER` for a caller that names itself, and the self-target check runs first
+
+### Side effects
+
+The operation stores the ban together with the target's last known IP address and account email. It records a [`MEMBER_BAN_ADD`](/http-api/guild-audit-logs/#audit-actions) audit entry whose change list has the stored [guild ban change fields](/http-api/guild-audit-logs/#guild-ban-change-fields), emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Ban Add](/gateway/events/#guild-ban-add) to the guild's sessions, subject to [event filtering](/gateway/event-filtering/). A positive deletion interval schedules permanent deletion of the target's newer guild messages.
+
+A target that is a current member also loses that membership. The guild member count decreases, the guild's sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove), the banned account's sessions receive [Guild Delete](/gateway/events/#guild-delete), and the membership is dropped from the member search index of an indexed guild. Fluxer records no `MEMBER_KICK` entry alongside the ban entry.
+
+A ban does not preserve an active communication timeout for a later rejoin, and [Remove guild member](/http-api/guild-members/#remove-guild-member) does. The banned account loses its guild nickname, guild avatar hash, guild banner hash, biography, pronouns, accent colour, and role set. Its read states and guild settings stay in place.
+
+From that point Fluxer refuses a join attempt by the banned account. The address and email matches described under the [guild ban object](#guild-ban-object) apply as well. A ban therefore restricts more than one account when an address or an email is shared.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:remove::guild_id` bucket.
+
+## Remove guild ban
+
+
+
+Removes an existing guild ban and returns 204 with an empty body. Requires [BAN_MEMBERS](/http-api/permissions/). Emits a [Guild Ban Remove](/gateway/events/#guild-ban-remove) Gateway event.
+
+Fluxer rejects a target that is not currently banned with 400 `INVALID_FORM_BODY`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| user_id | snowflake | The ID of the banned account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Ban was removed |
+| 4001 | [error response](/http-api/#error-response) | Target is not banned, or the caller holds `BAN_MEMBERS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, or `BAN_MEMBERS` is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 A target that is not banned returns `INVALID_FORM_BODY` with [`USER_IS_NOT_BANNED`](/http-api/errors/#validation-error-code-registry) against `user_id`. The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after the permission is confirmed
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and `MISSING_PERMISSIONS` otherwise
+
+### Side effects
+
+The operation deletes the ban together with the address and email blocks it had. It records a [`MEMBER_BAN_REMOVE`](/http-api/guild-audit-logs/#audit-actions) audit entry whose change list has the removed [guild ban change fields](/http-api/guild-audit-logs/#guild-ban-change-fields), emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Ban Remove](/gateway/events/#guild-ban-remove) to the guild's sessions, subject to [event filtering](/gateway/event-filtering/).
+
+It does not restore the removed membership, the guild profile fields and roles that membership had, or the deleted messages. The unbanned account can rejoin through an ordinary invite.
+
+An instance-wide IP ban is a separate mechanism this operation does not touch. Every request from an address it covers is still refused with 403 `GLOBAL_IP_BANNED` for a permanent ban and 403 `GLOBAL_IP_TEMPORARILY_BANNED` for a 24 hour one.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:member:remove::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx b/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx
new file mode 100644
index 000000000..9319e61d7
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx
@@ -0,0 +1,423 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guild stickers
+description: Guild sticker objects, uploads, cloning, bulk creation, metadata updates, and deletion.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A sticker is an image a guild stores for its members to send with a message. It belongs to exactly one guild. Its [snowflake](/snowflakes/) is unique across every guild. [Get sticker metadata](/http-api/expressions/#get-sticker-metadata) resolves a sticker without membership of its guild.
+
+Every route names a guild in its path. A guild that has [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) rejects the request with 403 `MISSING_ACCESS` before the operation runs. [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) does the same for an account without the instance staff flag. On [Clone guild sticker](#clone-guild-sticker) the gate covers the target guild and never the source guild.
+
+The instance-wide content filter screens every route with a JSON body before the route runs. A body string of at least 3 characters matching the phrase blocklist, or a URL matching the URL blocklist, returns 403 `CONTENT_BLOCKED`. The `image` member is exempt.
+
+A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is distinguishable from guild membership. [Modify guild sticker](#modify-guild-sticker) answers 404 `UNKNOWN_STICKER` for such a guild.
+
+There is no single-sticker read scoped to a guild. [List guild stickers](#list-guild-stickers) returns the whole collection in one response.
+
+## Guild sticker object
+
+A guild sticker object is the complete stored form of one sticker. The stored image is written once at creation and is never replaced. Only the name, description, and tags are mutable.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the sticker |
+| name | string | The name of the sticker (2-30 characters) |
+| description1 | string | The sticker description, or an empty string when it has none |
+| tags | array[string] | [Sticker tags](#sticker-tags) |
+| animated2 | boolean | Whether the stored image is animated |
+| user?3 | [partial user](/http-api/users/#partial-user-object) object | The account that uploaded the sticker |
+
+1 Never null on the wire. A sticker with no description has an empty string
+
+2 Detected from the submitted image at creation, and copied unchanged by [Clone guild sticker](#clone-guild-sticker)
+
+3 Present only on [List guild stickers](#list-guild-stickers), where it is populated for every caller and requires no permission
+
+A sticker is always a guild sticker, and `animated` alone describes its format. The object has no sticker type, format type, or availability field.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998183",
+ "name": "shipit",
+ "description": "Ship it",
+ "tags": ["ship", "deploy"],
+ "animated": false
+}
+```
+
+## Sticker tags
+
+Tags are an array of strings. A create or modify request accepts at most 10 entries. Fluxer trims each entry. The 1 to 30 character bound applies to the trimmed value. Entries are stored and returned in the submitted order.
+
+The array is optional on every request that accepts it and defaults to an empty array, so omitting it on [Modify guild sticker](#modify-guild-sticker) clears the stored tags.
+
+## Sticker create object
+
+The item shape shared by [Create guild sticker](#create-guild-sticker) and each entry of [Bulk create guild stickers](#bulk-create-guild-stickers).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The name of the sticker (2-30 characters) |
+| description?2 | ?string | The sticker description (1-500 characters), or null for none |
+| tags? | array[string] | [Sticker tags](#sticker-tags), defaulting to an empty array |
+| image3 | base64 string | The image data, with at most 524288 decoded bytes |
+
+1 The value is trimmed before the bound applies, as it is for `description` and each tag
+
+2 An omitted value and null are both stored as null and returned as an empty string
+
+3 A data URL prefix is accepted and everything up to the first comma is removed before any bound applies
+
+The remaining encoded portion is bounded to 699052 characters, which encodes a full 524288 byte image, and a longer value returns the validation code `BASE64_LENGTH_INVALID` at the `image` path. A value that is not canonical base64 with correct padding returns `INVALID_BASE64_FORMAT`.
+
+Decoded bytes are at most the operator-configured `sticker_max_size` [limit key](/http-api/instance/#limit-keys) value, resolved against the guild's complete feature set and defaulting to 524288. A larger image is rejected on the `image` path with `IMAGE_SIZE_EXCEEDS_LIMIT`, whose message names the resolved ceiling. An image over 524289 bytes exceeds the 699052 character bound and returns `BASE64_LENGTH_INVALID`, so a configured value above 524289 never applies.
+
+Accepted upload formats are PNG, JPEG, APNG, GIF, WebP, AVIF, and SVG. Fluxer takes the format from the decoded image. An animated AVIF is rejected. Fluxer detects animation state from the image, so this object has no animation field.
+
+Pixel dimensions and animation length are not enforced. An accepted image is stored at its original dimensions. The [Media Proxy image asset contract](/media-proxy/routes/#image-asset-contract) selects the delivered size.
+
+### Example
+
+```json
+{
+ "name": "shipit",
+ "description": "Ship it",
+ "tags": ["ship", "deploy"],
+ "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
+}
+```
+
+## Sticker bulk create response object
+
+The outcome of one [Bulk create guild stickers](#bulk-create-guild-stickers) call, split into the items that were created and the items that were rejected.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | array[[guild sticker](#guild-sticker-object) object] | The stickers that were created |
+| failed | array[[sticker creation failure](#sticker-creation-failure-object) object] | The items rejected after top-level request validation |
+
+Both arrays are present on every success response. Either can be empty. An item appears in exactly one of them, so the combined length equals the submitted item count.
+
+### Example
+
+```json
+{
+ "success": [],
+ "failed": [{"name": "shipit", "error": "The image is too large."}]
+}
+```
+
+## Sticker creation failure object
+
+One rejected item from a bulk create call, named and explained in display text.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | The requested sticker name, echoed from the submitted item |
+| error2 | string | The failure message rendered for the item |
+
+1 The object has no index, so two submitted items sharing a name cannot be told apart
+
+2 Rendered in the locale of the authenticated account, so its value changes with the caller's locale
+
+There is no machine-readable code, so a client that needs to branch on the reason retries the item on its own.
+
+## List guild stickers
+
+
+
+Returns every [guild sticker object](#guild-sticker-object) of the guild, each with `user`. Requires membership of the guild and no permission.
+
+One response has the complete collection. The operation is not paginated. A sticker whose uploader cannot be resolved is omitted, so the array can be shorter than the number of stored stickers.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild sticker](#guild-sticker-object) object] | Stickers were returned |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, or the caller is not a member |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and `MISSING_PERMISSIONS` for a non-member
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user and guild ID, on the `guild:sticker:list::guild_id` bucket.
+
+## Create guild sticker
+
+
+
+Creates one sticker from submitted image data and returns its [guild sticker object](#guild-sticker-object) without `user`. Requires membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Emits a [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event.
+
+The admission checks run in a fixed order. Fluxer scans the submitted name, description, and tags for prohibited content, then resolves guild existence and membership, then `CREATE_EXPRESSIONS`, then the slot limit, and only then the image. A guild at its slot limit therefore returns `MAX_STICKERS` even when the image would also have been rejected.
+
+The slot limit is the operator-configured [max_guild_stickers](/http-api/instance/#limit-keys) value resolved against the guild's complete feature set, defaulting to 500. A guild with [UNLIMITED_STICKERS](/http-api/guilds/#guild-features) bypasses that configuration and receives a fixed ceiling of 999999.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The body is one [sticker create object](#sticker-create-object).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild sticker](#guild-sticker-object) object | Sticker was created |
+| 4001 | [error response](/http-api/#error-response) | Guild is at its sticker slot limit, or the image is invalid |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member or lacks `CREATE_EXPRESSIONS`, or the submitted text or image is blocked |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MAX_STICKERS` for the slot limit, with the resolved limit in its message variables. An image failure is `INVALID_FORM_BODY` with `BASE64_LENGTH_INVALID` for an oversized encoding, `IMAGE_SIZE_EXCEEDS_LIMIT` for decoded bytes over the resolved ceiling, or `INVALID_IMAGE_FORMAT` for an undecodable or unaccepted image at the `image` path
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_PERMISSIONS` for a membership or permission failure, and `CONTENT_BLOCKED` when the name, description, or a tag matches a blocklist or the decoded image matches a banned file hash
+
+### Side effects
+
+The operation consumes one guild sticker slot, stores the metadata-stripped image, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the guild's complete sticker collection, and then writes a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. A failed creation leaves no sticker, audit entry, or Dispatch and consumes no slot.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and guild ID, on the `guild:sticker:create::guild_id` bucket.
+
+## Bulk create guild stickers
+
+
+
+Attempts to create between 1 and 50 stickers in one request and returns a [sticker bulk create response](#sticker-bulk-create-response-object) object. Emits one [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event for the whole batch.
+
+The caller needs membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Fluxer processes the items strictly in the submitted order and treats each one independently. An item fails when no guild sticker slot remains. A later item can still succeed after an earlier item failed for another reason.
+
+:::caution[A 200 does not mean every item succeeded]
+The operation returns 200 whenever the top-level request was valid, including when every item failed. Read `success` and `failed` for the per-item outcome. The stickers in `success` are durable and are not rolled back when a later item fails.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stickers | array[[sticker create object](#sticker-create-object)] | The items to create, in the order they are processed (1-50) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [sticker bulk create response](#sticker-bulk-create-response-object) object | Every validated item was attempted |
+| 4001 | [error response](/http-api/#error-response) | Top-level body, item count, item shape, item metadata, or encoded image bound is invalid |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not a member or lacks `CREATE_EXPRESSIONS`, or a submitted string is blocked |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 An oversized encoded image, an out-of-range name, or a tag outside its bound rejects the whole request before any item is created
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_PERMISSIONS` for a membership or permission failure, and `CONTENT_BLOCKED` for a blocked string
+
+A decoded image that is oversized, undecodable, in an unaccepted format, or matching a banned file hash fails one item.
+
+:::note[Two blocklist screens run with different floors]
+The global body screen runs before the batch starts and ignores strings shorter than 3 characters, so a blocked string there fails the whole request. The per-item scan applies the same blocklist with no floor, and only a shorter match becomes an item failure.
+:::
+
+### Side effects
+
+Each successful item consumes one guild sticker slot, stores its image, and writes the supplied audit reason into its [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) entry. A failed item leaves no sticker, audit entry, or slot consumption.
+
+Fluxer emits one [Guild Stickers Update](/gateway/events/#guild-stickers-update) after the batch when at least one item succeeded, with the guild's complete sticker collection. A batch in which no item succeeded emits nothing.
+
+### Rate limit
+
+6 requests per minute for each authenticated user and guild ID, on the `guild:sticker:bulk_create::guild_id` bucket.
+
+## Clone guild sticker
+
+
+
+Copies an existing sticker into the target guild and returns the new [guild sticker object](#guild-sticker-object) without `user`. Emits a [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event in the target guild.
+
+The caller needs membership of the target guild and [CREATE_EXPRESSIONS](/http-api/permissions/) there. Membership of the source guild is not required. The source guild must not have [CLONE_STICKER_DISABLED](/http-api/guilds/#guild-features).
+
+The name, description, tags, animation state, and stored image bytes are copied server-side, so the caller does not re-upload the image and cannot override any copied value.
+
+The availability gate on the target guild runs first. An unknown source sticker is then reported before a missing source guild, before target membership and permission, and before the target slot limit. A request naming an unknown source sticker against a target guild that does not exist returns 404 `UNKNOWN_STICKER`.
+
+[Get sticker metadata](/http-api/expressions/#get-sticker-metadata) reports whether a source permits cloning without attempting the operation.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the target guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| source_sticker_id | snowflake | The ID of the sticker to copy, which can belong to any guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild sticker](#guild-sticker-object) object | Sticker was cloned |
+| 4001 | [error response](/http-api/#error-response) | Target guild is at its sticker slot limit |
+| 4032 | [error response](/http-api/#error-response) | Source guild is missing or disables cloning, the target guild is unavailable, or the caller is not a member of the target guild or lacks `CREATE_EXPRESSIONS` there |
+| 4043 | [error response](/http-api/#error-response) | Source sticker or target guild does not exist |
+
+1 The [error code](/http-api/errors/) is `MAX_STICKERS`
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` when the source guild is missing or has `CLONE_STICKER_DISABLED` and when the target guild is unavailable, and `MISSING_PERMISSIONS` for a target membership or permission failure
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_STICKER` for the source sticker and `UNKNOWN_GUILD` for the target guild
+
+A source guild that no longer exists returns 403 `MISSING_ACCESS`, so a caller cannot tell it apart from a source guild that disables cloning.
+
+### Side effects
+
+The operation consumes one target guild sticker slot and creates a copy whose uploader is the caller. It emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the target guild's complete sticker collection and then writes a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source sticker and source guild are unchanged and receive no Dispatch.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and target guild ID, on the `guild:sticker:clone::guild_id` bucket.
+
+## Modify guild sticker
+
+
+
+Replaces sticker metadata and returns the [guild sticker object](#guild-sticker-object) without `user`. Emits a [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event.
+
+### Limitations
+
+- The uploader can modify their own sticker with [CREATE_EXPRESSIONS](/http-api/permissions/), and any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/).
+- Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels).
+- The stored image cannot be replaced, so an image change requires creating a new sticker.
+
+The sticker is resolved before any permission is evaluated. A sticker that belongs to another guild and a sticker in a guild that does not exist are both reported as an unknown sticker.
+
+:::caution[Every request writes `description` and `tags`]
+Only `name` is required. Omitting `description` clears the stored description and omitting `tags` clears the stored tags. A client changing one field submits the current value of the other two alongside it.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| sticker_id | snowflake | The ID of the sticker |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | The name of the sticker (2-30 characters) |
+| description?1 | ?string | The sticker description (1-500 characters), or null to clear it |
+| tags?2 | array[string] | [Sticker tags](#sticker-tags), defaulting to an empty array |
+
+1 An omitted value and null are both stored as null and returned as an empty string
+
+2 An omitted array is written as an empty array
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild sticker](#guild-sticker-object) object | Metadata was processed |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is neither the uploader with `CREATE_EXPRESSIONS` nor a member holding `MANAGE_EXPRESSIONS`, or the submitted text is blocked |
+| 404 | [error response](/http-api/#error-response) | Sticker does not exist in that guild and the request returns `UNKNOWN_STICKER` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_PERMISSIONS` for a membership or permission failure, and `CONTENT_BLOCKED` for a blocked name, description, or tag
+
+A non-member who names an existing sticker receives `MISSING_PERMISSIONS`.
+
+### Side effects
+
+The operation writes the supplied metadata, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the guild's complete sticker collection, and then records a [`STICKER_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. Both are produced even when the submitted values match the stored ones. The audit entry then has no `changes`.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild ID, on the `guild:sticker:update::guild_id` bucket.
+
+## Delete guild sticker
+
+
+
+Deletes the sticker record and returns 204 with an empty body. Emits a [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event.
+
+### Limitations
+
+- The uploader can delete their own sticker with [CREATE_EXPRESSIONS](/http-api/permissions/), and any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/).
+- Setting `purge` needs the guild to have [EXPRESSION_PURGE_ALLOWED](/http-api/guilds/#guild-features).
+
+Fluxer checks membership, then purge eligibility, then the sticker, and only then the permission. A non-member receives `MISSING_PERMISSIONS` whatever `purge` is set to, and a purging request in a guild without the required feature is refused before the sticker is looked up.
+
+:::caution[Purging the stored image cannot be undone]
+Without `purge`, the sticker leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the stored image and its `png`, `jpeg`, `apng`, `gif`, `webp`, `avif`, and `svg` CDN representations are queued for permanent deletion.
+:::
+
+:::note[A clone outlives a purge of its source]
+A later purge of the source sticker does not affect an existing clone in another guild.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| sticker_id | snowflake | The ID of the sticker |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| purge?1 | boolean | Whether to permanently delete the stored image (default false) |
+
+1 Read as true only when it is exactly `true`, `True`, or `1` after trimming. Every other value, including `TRUE` and `yes`, reads as false
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Sticker was deleted |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable or cannot purge expression assets, or the caller is neither the uploader with `CREATE_EXPRESSIONS` nor a member holding `MANAGE_EXPRESSIONS` |
+| 4042 | [error response](/http-api/#error-response) | Guild or sticker does not exist |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and when `purge` is true and the guild lacks [EXPRESSION_PURGE_ALLOWED](/http-api/guilds/#guild-features), and `MISSING_PERMISSIONS` for a membership or permission failure
+
+2 The [error code](/http-api/errors/) is `UNKNOWN_GUILD` for the guild and `UNKNOWN_STICKER` for the sticker
+
+### Side effects
+
+The operation removes the sticker from the guild, returns its slot, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the guild's remaining sticker collection, and then writes a [`STICKER_DELETE`](/http-api/guild-audit-logs/#audit-actions) audit entry.
+
+When `purge` is true, the stored image and each cached representation are deleted after the response, so the image can still be served for a short time after the 204. Messages that reference the sticker are not rewritten. No message Dispatch is produced.
+
+### Rate limit
+
+10 requests per 30 seconds for each authenticated user and guild ID, on the `guild:sticker:delete::guild_id` bucket. The route charges a second allowance of 300 requests per day for each authenticated user and guild ID, on the `guild:sticker:delete:daily::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/guilds.mdx b/fluxer_docs/src/content/docs/http-api/guilds.mdx
new file mode 100644
index 000000000..be791faa5
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/guilds.mdx
@@ -0,0 +1,964 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Guilds
+description: Guild objects, creation templates, configuration registries, and lifecycle operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A guild is a community with its own channels, roles, members, and configuration. Membership and ownership transfer live on [Guild members](/http-api/guild-members/), and channels on [Guild channels](/http-api/guild-channels/).
+
+[List current user guilds](#list-current-user-guilds) and [Get guild](#get-guild) declare the `guilds` [OAuth2 scope](/http-api/oauth2/#oauth2-scopes), and a bearer credential without that scope receives 403 `MISSING_OAUTH_SCOPE`. Every other route rejects a bearer credential with 403 `ACCESS_DENIED`.
+
+A guild that does not exist returns 404 `UNKNOWN_GUILD`, and an existing guild that does not hold the caller as a member returns 403 `MISSING_PERMISSIONS`, so any authenticated caller can tell a missing guild from one they are not in. A guild whose stored record exists while the main Gateway reports it unknown returns 403 `ACCESS_DENIED`.
+
+Fluxer screens a route whose path opens with `/guilds/{guild_id}` for [guild availability](#guild-features) before the operation runs. [Create guild](#create-guild), [List current user guilds](#list-current-user-guilds), [Leave guild](#leave-guild), and [Bulk delete current user's guild messages](#bulk-delete-current-users-guild-messages) do not have the guild in their first two path segments, so a forced-unavailable guild is still listed and can still be left.
+
+The instance-wide content filter screens the JSON body of every `POST`, `PUT`, and `PATCH` route here before the route runs. A string of at least 3 characters matching the phrase blocklist, or a URL matching the URL blocklist, returns 403 `CONTENT_BLOCKED`. The `icon`, `banner`, `splash`, `embed_splash`, `permissions`, `roles`, `channels`, `password`, `mfa_code`, `webauthn_response`, and `webauthn_challenge` members are exempt. The guild `name` is scanned a second time inside [Create guild](#create-guild) and [Modify guild](#modify-guild), without the 3 character floor.
+
+## Guild object
+
+A guild object contains the guild's configuration. The operation that returns it decides which embedded collections and caller-specific fields it has.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild (1-100 characters) |
+| icon1 | ?string | Guild icon hash |
+| banner2 | ?string | Guild banner hash |
+| banner_width3 | ?integer | Banner width in pixels |
+| banner_height3 | ?integer | Banner height in pixels |
+| splash4 | ?string | Invite splash hash |
+| splash_width3 | ?integer | Invite splash width in pixels |
+| splash_height3 | ?integer | Invite splash height in pixels |
+| splash_card_alignment | integer | [Splash card alignment](#splash-card-alignments) |
+| embed_splash4 | ?string | Embedded invite splash hash |
+| embed_splash_width3 | ?integer | Embedded invite splash width in pixels |
+| embed_splash_height3 | ?integer | Embedded invite splash height in pixels |
+| vanity_url_code | ?string | Custom invite code |
+| owner_id | snowflake | The ID of the guild owner |
+| system_channel_id | ?snowflake | Text channel that receives system messages |
+| system_channel_flags | integer | [System channel flags](#system-channel-flags) |
+| rules_channel_id5 | ?snowflake | The ID of the rules channel |
+| afk_channel_id | ?snowflake | Voice channel that inactive members are moved to |
+| afk_timeout | integer | AFK timeout in seconds (60-3600) |
+| features6 | array[string] | [Guild features](#guild-features) |
+| verification_level | integer | [Verification level](#verification-levels) |
+| mfa_level | integer | [MFA level](#mfa-levels) |
+| nsfw_level7 | integer | [NSFW level](#nsfw-levels) |
+| nsfw | boolean | Whether the guild is marked as adult content |
+| content_warning_level | integer | [Guild content warning level](#guild-content-warning-levels) |
+| content_warning_text8 | ?string | Guild content warning text (max 200 characters) |
+| explicit_content_filter | integer | [Guild explicit content filter level](#guild-explicit-content-filter-levels) |
+| default_message_notifications | integer | [Default message notification level](#default-message-notification-levels) |
+| disabled_operations9 | integer | [Disabled guild operations](#disabled-guild-operations) |
+| message_history_cutoff10 | ?ISO8601 timestamp | Earliest message visible to a member without `READ_MESSAGE_HISTORY` |
+| permissions?11 | decimal string | Caller permissions in the guild |
+| roles?12 | array[[guild role](/http-api/permissions/#guild-role-object) object] | Guild roles |
+| emojis?12 | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | Guild emojis |
+| stickers?12 | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | Guild stickers |
+| channels?13 | array[[channel](/http-api/channels/#channel-object) object] | Guild channels the caller can view |
+| member_count?14 | integer | Member count held by the main Gateway |
+| online_count?14 | integer | Online presence count held by the main Gateway |
+| approximate_member_count?15 | integer | Member count read from the main Gateway count cache |
+| approximate_presence_count?15 | integer | Online presence count read from the main Gateway count cache |
+
+1 An animated icon hash has the `a_` prefix, and that prefix is removed from the returned value while the guild lacks `ANIMATED_ICON`
+
+2 The field is null whenever the guild lacks `BANNER`, and the `a_` prefix is removed from the returned value while the guild lacks `ANIMATED_BANNER`
+
+3 The dimension is null whenever its own asset hash field is null
+
+4 The field is null whenever the guild lacks `INVITE_SPLASH`
+
+5 No operation on this page assigns the rules channel
+
+6 The set has each feature at most once, and its order is not stable
+
+7 The value is derived from `nsfw`, so it is 3 when `nsfw` is true and 0 otherwise
+
+8 A null value means the client renders the localised default warning text for the guild
+
+9 Written only through the [Admin API](/admin-api/guilds/)
+
+10 The cutoff is evaluated only for a member that lacks [READ_MESSAGE_HISTORY](/http-api/permissions/). A null value denies that member every message in the guild, and a set value exposes messages created at or after the timestamp
+
+11 Only [List current user guilds](#list-current-user-guilds) populates this field, and only when its permission lookup succeeds. The value is the complete mask the caller's roles grant
+
+12 Only [Get guild](#get-guild) populates this field
+
+13 Only [Get guild](#get-guild) populates this field, and the array is filtered to the channels the caller can currently view
+
+14 Only [Get guild](#get-guild) populates these two fields, and both are computed from the guild's live main Gateway state
+
+15 Only [List current user guilds](#list-current-user-guilds) populates these two fields, and only when `with_counts` is true. A guild whose counts are not currently held reports 0 for both
+
+:::note[An absent field means the operation omitted it]
+`roles`, `emojis`, `stickers`, `channels`, and every count field arrive only from the operations named in the footnotes above. A guild returned without `roles` can still hold roles.
+:::
+
+### Example
+
+```json
+{
+ "id": "1489002177550843904",
+ "name": "Ada's Workshop",
+ "icon": "a_9f2c1d4e",
+ "banner": null,
+ "splash": null,
+ "vanity_url_code": "workshop",
+ "owner_id": "1489002177550843905",
+ "system_channel_id": "1489002177550843907",
+ "system_channel_flags": 0,
+ "afk_channel_id": null,
+ "afk_timeout": 300,
+ "features": ["ANIMATED_ICON", "BANNER", "VANITY_URL"],
+ "verification_level": 1,
+ "mfa_level": 0,
+ "nsfw_level": 0,
+ "nsfw": false,
+ "content_warning_level": 0,
+ "content_warning_text": null,
+ "explicit_content_filter": 1,
+ "default_message_notifications": 1,
+ "disabled_operations": 0,
+ "message_history_cutoff": null
+}
+```
+
+## Partial guild object
+
+A partial guild object is the smaller guild shape that appears inside invite payloads. It has identity and presentation fields only, and the feature-gated asset rules of the complete [guild object](#guild-object) apply to it unchanged.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name of the guild |
+| icon1 | ?string | Guild icon hash |
+| banner2 | ?string | Guild banner hash |
+| banner_width3 | ?integer | Banner width in pixels |
+| banner_height3 | ?integer | Banner height in pixels |
+| splash4 | ?string | Invite splash hash |
+| splash_width3 | ?integer | Invite splash width in pixels |
+| splash_height3 | ?integer | Invite splash height in pixels |
+| splash_card_alignment | integer | [Splash card alignment](#splash-card-alignments) |
+| embed_splash4 | ?string | Embedded invite splash hash |
+| embed_splash_width3 | ?integer | Embedded invite splash width in pixels |
+| embed_splash_height3 | ?integer | Embedded invite splash height in pixels |
+| features5 | array[string] | [Guild features](#guild-features) |
+
+1 An animated icon hash has the `a_` prefix, and that prefix is removed from the returned value while the guild lacks `ANIMATED_ICON`
+
+2 The field is null whenever the guild lacks `BANNER`, and the `a_` prefix is removed from the returned value while the guild lacks `ANIMATED_BANNER`
+
+3 The dimension is null whenever its own asset hash field is null
+
+4 The field is null whenever the guild lacks `INVITE_SPLASH`
+
+5 The set has each feature at most once, and `vanity_url_code` is never part of this representation
+
+### Example
+
+```json
+{
+ "id": "1489002177550843904",
+ "name": "Ada's Workshop",
+ "icon": "a_9f2c1d4e",
+ "banner": null,
+ "splash": null,
+ "splash_card_alignment": 0,
+ "embed_splash": null,
+ "features": ["ANIMATED_ICON", "BANNER"]
+}
+```
+
+## Guild creation template object
+
+A creation template describes the roles and channels that [Create guild](#create-guild) creates. Fluxer remaps its template-local identifiers to newly allocated guild snowflakes during creation. Every template-local identifier is compared as its decimal string form, so the integer `1` and the string `"1"` name the same entity.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | Template name |
+| description?1 | ?string | Template description |
+| verification_level?2 | integer | [Verification level](#verification-levels), defaulting to 0 |
+| default_message_notifications?2 | integer | [Default message notification level](#default-message-notification-levels), defaulting to 0 |
+| explicit_content_filter?2 | integer | [Guild explicit content filter level](#guild-explicit-content-filter-levels), defaulting to 0 |
+| system_channel_id?3 | ?decimal string \| integer | Template-local ID of the text channel that becomes the system channel |
+| afk_timeout?2 | integer | AFK timeout in seconds, clamped to 60-3600 and defaulting to 300 |
+| system_channel_flags?4 | integer | [System channel flags](#system-channel-flags), defaulting to 0 |
+| roles5 | array[[guild template role](#guild-template-role-object) object] | Template roles (max 250) |
+| channels6 | array[[guild template channel](#guild-template-channel-object) object] | Template channels (max 500) |
+
+1 The field is read and discarded. The new guild takes its name from the `name` of the [Create guild](#create-guild) body
+
+2 The value is truncated to an integer and clamped into its registry range, and a missing value becomes the stated default. A non-numeric value fails validation and returns 400 `INVALID_FORM_BODY`
+
+3 An identifier that resolves to no text channel in the same template, and an absent or null value, all fall back to the template's first text channel. A template with no text channel receives a root text channel named `general`, which becomes the system channel
+
+4 Every bit outside the registry is discarded
+
+5 A template with more than 250 roles is rejected with 400 `GUILD_TEMPLATE_INVALID`. The ceiling counts the submitted array, and the everyone role is created on top of it, so 250 template roles that name no everyone role produce a guild holding 251
+
+6 A template with more than 500 channels is rejected with 400 `GUILD_TEMPLATE_INVALID`
+
+### Guild template role object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | decimal string \| integer | Template-local role ID |
+| name?2 | ?string | Role name, defaulting to the empty string |
+| permissions?3 | decimal string \| integer | Legacy role permission bitfield |
+| permissions_new?3 | decimal string \| integer | Preferred role permission bitfield |
+| color? | integer | Role colour, defaulting to 0 |
+| hoist? | boolean | Whether the role is displayed separately (default false) |
+| mentionable? | boolean | Whether anyone can mention the role (default false) |
+| unicode_emoji?4 | ?string | Role Unicode emoji |
+
+1 A duplicate identifier rejects creation with 400 `GUILD_TEMPLATE_INVALID`
+
+2 The role whose name is exactly `@everyone` supplies the permissions of the new guild's everyone role, and when no role has that name the role whose identifier is `0` supplies them. Every remaining role is created in array order at positions 1 upward
+
+3 `permissions_new` takes precedence over `permissions`, the value is masked to the permission bits Fluxer defines, and a value that is not a valid integer rejects creation with 400 `GUILD_TEMPLATE_INVALID`. An everyone role whose resolved mask is 0 receives the default permission set
+
+4 The value is stored on the created role. The everyone role is always created with no Unicode emoji
+
+### Guild template channel object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | decimal string \| integer | Template-local channel ID |
+| type2 | integer | Source channel type |
+| name? | ?string | Channel name, defaulting to the empty string |
+| topic? | ?string | Channel topic |
+| position | integer | The sort position of the channel |
+| parent_id?3 | ?decimal string \| integer | Template-local parent category ID |
+| bitrate?4 | ?integer | Voice bitrate, defaulting to 64000 for a voice channel |
+| user_limit?4 | ?integer | Voice occupancy limit, defaulting to 0 for a voice channel |
+| voice_connection_limit?4 | ?integer | Per-user voice connection limit, defaulting to 5 for a voice channel |
+| nsfw? | boolean | Whether the channel is age restricted (default false) |
+| rate_limit_per_user? | integer | Slowmode interval in seconds, defaulting to 0 |
+| permission_overwrites?5 | array[[guild template role permission overwrite](#guild-template-role-permission-overwrite-object) object] | Role permission overwrites |
+
+1 A duplicate identifier in the same template rejects creation with 400 `GUILD_TEMPLATE_INVALID`
+
+2 The value 0 creates a text channel, 2 a voice channel, and 4 a category, and for an import from the other platform 5 creates a text channel and 13 a voice channel. Fluxer skips every other value, so the channel is not created
+
+3 The identifier is applied only when it resolves to a category in the same template, and every other value leaves the channel at the guild root
+
+4 The three voice fields are stored only on a voice channel and are null on every other channel type
+
+5 An entry is applied only when its resolved `type` is 0 and its `id` resolves to a role in the same template, and the identifier `0` resolves to the everyone role. Fluxer skips every other entry, and a channel whose entries are all skipped is created with no overwrites
+
+### Guild template role permission overwrite object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | decimal string \| integer | Template-local role ID |
+| type1 | integer \| string | Overwrite type |
+| allow2 | decimal string \| integer | Allowed permission bitfield |
+| deny2 | decimal string \| integer | Denied permission bitfield |
+
+1 The string `role` resolves to 0 and the string `member` resolves to 1, and every other string is read as a number. Only a resolved value of 0 is applied
+
+2 The value is masked to the permission bits Fluxer defines, and a value that is not a valid integer rejects creation with 400 `GUILD_TEMPLATE_INVALID`
+
+## Verification levels
+
+A verification level gates member participation. Fluxer evaluates it when a member sends a message in the guild.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NONE | No verification requirement |
+| 1 | LOW1 | The account is claimed and its email address verified |
+| 2 | MEDIUM | The LOW requirement, and the account is at least 5 minutes old |
+| 3 | HIGH2 | The MEDIUM requirement, and the membership is at least 10 minutes old |
+| 4 | VERY_HIGH3 | The account has a verified phone number |
+
+1 A guild with `DISCOVERABLE` is evaluated at an effective minimum of LOW even when the stored value is NONE, and [Modify guild](#modify-guild) rejects lowering a discoverable guild below LOW with 400 `INVALID_FORM_BODY` and the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`
+
+2 The membership age requirement is skipped when the join timestamp cannot be read, so the level behaves as MEDIUM
+
+3 A verified phone number is the whole requirement at this level
+
+The guild owner, a bot, and any member holding at least one role bypass the check at every level.
+
+## MFA levels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NONE | MFA is not required for moderation actions |
+| 1 | ELEVATED1 | MFA is required for moderation actions |
+
+1 Only the guild owner can change this value, the owner account needs a second factor already configured, and the change requires sudo mode
+
+While the level is ELEVATED, a caller other than the guild owner exercises the nine [elevated permissions](/http-api/permissions/#elevated-permissions) only with an enrolled authenticator, and a bot inherits the enrolment state of its application owner. An operation that asserts an elevated permission the caller holds but cannot exercise returns 400 `TWO_FACTOR_REQUIRED` after the permission itself has been confirmed.
+
+## Splash card alignments
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | CENTER | Centre the splash card |
+| 1 | LEFT | Align the splash card to the left |
+| 2 | RIGHT | Align the splash card to the right |
+
+## Guild explicit content filter levels
+
+A guild explicit content filter level is the guild-wide filter a moderator selects. The account setting of the same name on the [user settings object](/http-api/users/settings/) is a different registry with different values.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | DISABLED | Do not apply the guild explicit content filter |
+| 1 | MEMBERS_WITHOUT_ROLES | Apply the filter to members without roles |
+| 2 | ALL_MEMBERS | Apply the filter to all members |
+
+## Default message notification levels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALL_MESSAGES | Notify for all messages by default |
+| 1 | ONLY_MENTIONS | Notify only for mentions by default |
+
+## NSFW levels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | SAFE | Guild is not age restricted |
+| 3 | AGE_RESTRICTED | Guild is age restricted |
+
+The registry has no other value. Fluxer derives the level from the guild's `nsfw` field and never writes it directly.
+
+## Guild content warning levels
+
+The guild is the last position in the resolution chain described by [channel content warning levels](/http-api/channels/#content-warning-levels).
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | INHERIT | No level is set at guild level, so a channel that inherits displays no content warning |
+| 1 | CONTENT_WARNING | A channel that inherits displays the guild content warning |
+
+## System channel flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | SUPPRESS_JOIN_NOTIFICATIONS | Suppress member join system messages |
+
+Every bit outside this registry is discarded when the field is written.
+
+## Disabled guild operations
+
+A set bit disables the named behaviour across the guild. Only the [Admin API](/admin-api/guilds/) writes the bitfield.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | PUSH_NOTIFICATIONS | Disable push notifications |
+| 1 << 1 | EVERYONE_MENTIONS | Disable mentions of the everyone role |
+| 1 << 2 | TYPING_EVENTS | Disable typing events |
+| 1 << 3 | INSTANT_INVITES | Disable instant invites |
+| 1 << 4 | SEND_MESSAGE | Disable message sending |
+| 1 << 5 | REACTIONS | Disable reactions |
+| 1 << 6 | MEMBER_LIST_UPDATES | Disable member list updates |
+
+## Guild features
+
+A feature is a capability or availability flag in the guild's `features` array.
+
+| Value | Description |
+| --- | --- |
+| ANIMATED_ICON | Guild can use an animated icon |
+| ANIMATED_BANNER | Guild can use an animated banner |
+| BANNER | Guild can use a banner |
+| CLONE_EMOJI_DISABLED1 | The one-click emoji clone shortcut is disabled for non-members |
+| CLONE_STICKER_DISABLED1 | The one-click sticker clone shortcut is disabled for non-members |
+| DETACHED_BANNER1 | Guild banner is independent from its splash |
+| INVITE_SPLASH | Guild can use invite splash assets |
+| INVITES_DISABLED1 | Guild invite use is disabled |
+| RAID_DETECTED | Raid detection is active and invites are restricted |
+| TEXT_CHANNEL_FLEXIBLE_NAMES1 | Text channel names accept the flexible naming policy |
+| HIDE_OWNER_CROWN1 | Guild owner crown is hidden |
+| MORE_EMOJI2 | Legacy increased emoji slot allowance |
+| MORE_STICKERS2 | Legacy increased sticker slot allowance |
+| UNLIMITED_EMOJI3 | Guild emoji slots are effectively unlimited |
+| UNLIMITED_STICKERS3 | Guild sticker slots are effectively unlimited |
+| EXPRESSION_PURGE_ALLOWED | Expression assets can be purged through delete operations |
+| VANITY_URL | Guild can own a custom invite code |
+| DISCOVERABLE | Guild is present in public discovery |
+| PARTNERED | Guild has partnered status |
+| VERIFIED | Guild has verified status |
+| VIP_VOICE | Guild has VIP voice capability |
+| VOICE_E2EE | Guild voice channels support end-to-end encryption |
+| UNAVAILABLE_FOR_EVERYONE4 | Guild is unavailable to every account |
+| UNAVAILABLE_FOR_EVERYONE_BUT_STAFF4 | Guild is unavailable to every account without the instance staff flag |
+| UNAVAILABLE_HIDDEN | Guild is hidden while it is forced unavailable |
+| VISIONARY | Guild has visionary status |
+| LARGE_GUILD_OVERRIDE2 | Guild is marked as a large guild |
+| VERY_LARGE_GUILD5 | Guild member capacity is raised |
+
+1 The feature is added or removed through [Modify guild](#modify-guild), as part of the complete array. Send every feature without this marker back to `features` exactly as the guild holds it
+
+2 The feature changes no HTTP API behaviour. An instance can name it in a filter of the ordered [limit configuration](/http-api/instance/#limit-keys), and the stock configuration names none of the three
+
+3 The expression operations raise the slot ceiling directly, outside the instance limit configuration
+
+4 An authenticated request whose path opens with `/guilds/{guild_id}` or `/channels/{channel_id}`, where that channel belongs to this guild, is rejected before the operation runs with 403 `MISSING_ACCESS`. `UNAVAILABLE_FOR_EVERYONE` applies to the guild owner exactly as it applies to every other member, while `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` exempts an account with the instance staff flag
+
+5 The feature raises the default `max_guild_members` limit from 1000000 to 10000000 before the ordered [limit configuration](/http-api/instance/#limit-keys) is consulted, so the raised ceiling applies even when no configured rule names the feature
+
+## Custom invite URL object
+
+A custom invite URL object reports the guild's current custom invite code and how many times the invite behind it has been used. [Get guild custom invite URL](#get-guild-custom-invite-url) returns it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | ?string | Current custom invite code |
+| uses1 | integer | Use count of the invite backing the current code |
+
+1 The value is 0 when the guild holds no code and when the backing invite can no longer be resolved
+
+### Example
+
+```json
+{
+ "code": "workshop",
+ "uses": 128
+}
+```
+
+## Custom invite URL selection object
+
+A custom invite URL selection object reports the code the guild holds after a write. [Modify guild custom invite URL](#modify-guild-custom-invite-url) returns it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | ?string | Custom invite code held by the guild after the request (2-32 characters) |
+
+## Sudo verification fields
+
+These fields prove [sudo mode](/http-api/users/mfa/#sudo-mode) when an operation requires it. A caller can also supply an existing proof through the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| password?1 | string | Current account password |
+| mfa_method?2 | string | MFA method, either `totp` or `webauthn` |
+| mfa_code? | string | Authenticator code when the method is `totp` (1-32 characters) |
+| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | Assertion when the method is `webauthn` |
+| webauthn_challenge? | string | Challenge bound to the WebAuthn assertion |
+
+1 The password is accepted only when the account has no second factor, and it returns the field code `INVALID_PASSWORD` when it does not match
+
+2 The MFA proof is accepted only when the account has a second factor, and it returns the field code `INVALID_MFA_CODE` on any failure
+
+A bot credential satisfies sudo mode without any proof. An account that has no password hash and no second factor also satisfies it without any proof. Every other account that supplies no usable proof receives 403 `SUDO_MODE_REQUIRED`, whose error object has `has_mfa` and a `methods` object reporting whether `totp` and `webauthn` are available.
+
+Fluxer issues a newly generated proof in the `X-Fluxer-Sudo-Mode-JWT` header of the success response, and only for an account that has a second factor. A proof supplied on the request is echoed back in that same header.
+
+## Create guild
+
+
+
+Creates a guild owned by the caller. Requires a user session credential. Returns a [guild object](#guild-object) on success. Emits [Guild Create](/gateway/events/#guild-create) and [User Settings Update](/gateway/events/#user-settings-update) Gateway events to the owner.
+
+### Limitations
+
+- A bot credential is rejected with 400 `BOTS_CANNOT_CREATE_GUILDS`.
+- An unclaimed account is rejected with 400 `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS`.
+- An account without a verified email address is rejected with 403 `GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED`.
+- A caller already at the configured guild limit is rejected with 400 `MAX_GUILDS`.
+- While the instance's single community policy is active, every caller is rejected with 400 `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS`.
+
+Fluxer decides the single community refusal first, so it precedes the email, bot, unclaimed, and guild limit failures.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name1 | string | Guild name (1-100 characters) |
+| icon?2 | ?base64 string | Guild icon |
+| empty_features?3 | boolean | Whether to create the guild with no features (default false) |
+| template? | [guild creation template object](#guild-creation-template-object) | Roles and channels to provision for the new guild |
+
+1 Fluxer trims the value before it measures the length bound
+
+2 The field accepts raw base64, or a data URL whose payload is everything after the first comma
+
+3 When false the guild is created holding `ANIMATED_ICON`, `ANIMATED_BANNER`, `BANNER`, and `INVITE_SPLASH`
+
+A `name` that satisfies the 1 to 100 character bound only before normalisation is rejected with the field code `STRING_LENGTH_INVALID`. The normalised name is scanned against the instance phrase and URL blocklists, and a match returns 403 `CONTENT_BLOCKED`.
+
+The `icon` base64 payload is bounded to 1 to 13981016 characters and is otherwise rejected with `BASE64_LENGTH_INVALID`, and a payload that is not valid base64 with `INVALID_BASE64_FORMAT`. The decoded image must fit within the instance `avatar_max_size` limit, which defaults to the 10 MiB ceiling, and a larger image is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG are accepted. An animated AVIF is rejected with `INVALID_IMAGE_FORMAT`. An image the media classifier marks as explicit is rejected with 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT`.
+
+Without a template the guild is created with a `Text Channels` category holding a text channel named `general`, a `Voice Channels` category holding a voice channel named `General`, and an everyone role with the default permission set. The `general` channel becomes the system channel.
+
+:::note[Embedded collections arrive on the Guild Create Dispatch]
+The creation response has none of them. Read [Get guild](#get-guild) afterwards to fetch roles, channels, emojis, stickers, and member counts over HTTP.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild object](#guild-object) | Guild was created |
+| 4001 | [error response](/http-api/#error-response) | Body, image, template, bot or unclaimed credential, configured guild limit, or single community policy rejects creation |
+| 4032 | [error response](/http-api/#error-response) | Email address is unverified, or the name is blocked |
+
+1 The [error code](/http-api/errors/) is `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS` while the single community policy is active, `BOTS_CANNOT_CREATE_GUILDS` for a bot credential, `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS` for an unclaimed account, `MAX_GUILDS` at the configured guild limit, `GUILD_TEMPLATE_INVALID` for a rejected template, `EXPLICIT_CONTENT_CANNOT_BE_SENT` for an explicit icon, and `INVALID_FORM_BODY` otherwise
+
+2 The [error code](/http-api/errors/) is `GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED` for an unverified email address and `CONTENT_BLOCKED` for a blocked name or body string
+
+### Side effects
+
+The operation creates the guild, its everyone role, its template or default channels, and the owner's membership. A supplied icon becomes the guild icon. Fluxer adds the guild to the owner's guild folder layout. The owner receives [Guild Create](/gateway/events/#guild-create) for the new guild and [User Settings Update](/gateway/events/#user-settings-update) with the changed folder layout. No audit log entry is recorded, and an `X-Audit-Log-Reason` header on this request is read and discarded.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `guild:create` bucket.
+
+## List current user guilds
+
+
+
+Returns an array of [guild objects](#guild-object), one for every guild the authenticated user is a member of. A bearer credential requires the `guilds` [OAuth2 scope](/http-api/oauth2/#oauth2-scopes).
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| before?1 | snowflake | Return guilds with a lower ID than this guild ID |
+| after?1 | snowflake | Return guilds with a higher ID than this guild ID |
+| limit? | integer | Maximum number returned (1-200, default 200) |
+| with_counts?2 | boolean | Include approximate member and presence counts (default false) |
+
+1 A page is always ordered by ascending guild ID whichever cursor is used, and `after` takes precedence when both cursors are supplied. A cursor naming a guild the caller is not a member of is ignored, so the page starts at the first guild
+
+2 A guild whose counts cannot be fetched is returned without them
+
+A membership whose guild record can no longer be resolved is dropped before the cursor and `limit` are applied, so the page still has up to `limit` guilds when further guilds remain. This route runs no availability screen, and a guild with `UNAVAILABLE_FOR_EVERYONE` is still listed.
+
+:::caution[`permissions` needs a limit of 100 or lower]
+A page of more than 100 guilds, or a failed lookup, omits `permissions` from every guild. The request still succeeds.
+:::
+
+A page within that ceiling can still omit `permissions` from an individual guild the lookup could not resolve.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild](#guild-object) object] | Guilds were returned |
+| 403 | [error response](/http-api/#error-response) | Bearer credential lacks the `guilds` scope and the request returns `MISSING_OAUTH_SCOPE` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `guild:list` bucket.
+
+## Get guild
+
+
+
+Returns the [guild object](#guild-object) with its roles, emojis, stickers, viewable channels, `member_count`, and `online_count`. Requires membership of the guild, and a bearer credential requires the `guilds` [OAuth2 scope](/http-api/oauth2/#oauth2-scopes).
+
+The response has no `permissions` field. Read [List current user guilds](#list-current-user-guilds) for the caller's mask.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild object](#guild-object) | Guild was returned |
+| 4031 | [error response](/http-api/#error-response) | Guild is unavailable, the bearer credential lacks the `guilds` scope, or the caller is not a member |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_OAUTH_SCOPE` for a missing scope, `MISSING_PERMISSIONS` for a non-member, and `ACCESS_DENIED` when the record exists while the Gateway reports the guild unknown
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and guild, on the `guild:read::guild_id` bucket.
+
+## Modify guild
+
+
+
+Modifies guild configuration and returns the updated [guild object](#guild-object). Requires membership and [MANAGE_GUILD](/http-api/permissions/). Emits a [Guild Update](/gateway/events/#guild-update) Gateway event to every session that can see the guild.
+
+`MANAGE_GUILD` is an [elevated permission](/http-api/permissions/#elevated-permissions). While the guild [MFA level](#mfa-levels) is elevated, a caller other than the owner also needs an enrolled authenticator, and receives 400 `TWO_FACTOR_REQUIRED` without one.
+
+Changing `mfa_level` to a different value additionally requires the guild owner, an owner account that already has a second factor, and sudo mode. A caller who is not the owner receives 403 `MISSING_PERMISSIONS` for that change even while holding `MANAGE_GUILD`. Sudo mode is verified before ownership, so a non-owner without a proof receives 403 `SUDO_MODE_REQUIRED` first.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof, read when the MFA level changes |
+
+### JSON body
+
+Every field is optional. An omitted field preserves its current value, and a field explicitly set to null clears it where the type allows null.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name?1 | string | Guild name (1-100 characters) |
+| icon?2 | ?base64 string | Guild icon, or null to clear it |
+| system_channel_id?3 | ?snowflake | System channel ID, or null to clear it |
+| system_channel_flags?4 | integer | [System channel flags](#system-channel-flags) |
+| afk_channel_id?5 | ?snowflake | AFK voice channel ID, or null to clear it |
+| afk_timeout? | integer | AFK timeout in seconds (60-3600) |
+| default_message_notifications? | integer | [Default message notification level](#default-message-notification-levels) |
+| verification_level?6 | integer | [Verification level](#verification-levels) |
+| mfa_level?7 | integer | [MFA level](#mfa-levels) |
+| nsfw_level?8 | integer | Legacy [NSFW level](#nsfw-levels), which is translated into `nsfw` and the content warning level |
+| nsfw?9 | boolean | Whether the guild is marked as adult content |
+| content_warning_level?10 | integer | [Guild content warning level](#guild-content-warning-levels) |
+| content_warning_text?11 | ?string | Content warning text (max 200 characters), or null for the localised default |
+| explicit_content_filter? | integer | [Guild explicit content filter level](#guild-explicit-content-filter-levels) |
+| banner?12 | ?base64 string | Guild banner, or null to clear it |
+| splash?13 | ?base64 string | Static invite splash, or null to clear it |
+| embed_splash?13 | ?base64 string | Static embedded invite splash, or null to clear it |
+| splash_card_alignment? | integer | [Splash card alignment](#splash-card-alignments) |
+| features?14 | array[string] | Complete desired [guild feature](#guild-features) set (max 100 entries) |
+| message_history_cutoff?15 | ?ISO8601 timestamp | Earliest history visible without [READ_MESSAGE_HISTORY](/http-api/permissions/), or null to deny historical access |
+| password? | string | [Sudo verification](#sudo-verification-fields) password |
+| mfa_method? | string | [Sudo verification](#sudo-verification-fields) method, either `totp` or `webauthn` |
+| mfa_code? | string | [Sudo verification](#sudo-verification-fields) authenticator code |
+| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | [Sudo verification](#sudo-verification-fields) assertion |
+| webauthn_challenge? | string | Challenge bound to the WebAuthn assertion |
+
+1 The value is normalised and trimmed before its length is measured, and the normalised name is scanned against the instance phrase and URL blocklists, so a match returns 403 `CONTENT_BLOCKED`
+
+2 The accepted encoding, byte ceiling, and format set are the ones listed by [Create guild](#create-guild), and an image the media classifier marks as explicit is rejected with 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT`. No guild feature gates an animated icon on write, but the `a_` prefix is stripped from the returned hash while the guild lacks `ANIMATED_ICON`
+
+3 The channel must exist in this guild and be a text channel, and is otherwise rejected with `SYSTEM_CHANNEL_MUST_BE_IN_GUILD` or `SYSTEM_CHANNEL_MUST_BE_TEXT`
+
+4 Every bit outside the registry is discarded
+
+5 The channel must exist in this guild and be a voice channel, and is otherwise rejected with `AFK_CHANNEL_MUST_BE_IN_GUILD` or `AFK_CHANNEL_MUST_BE_VOICE`
+
+6 A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`. That check runs before the MFA level check
+
+7 Sending the value the guild already holds needs neither ownership nor sudo mode, and an owner without a configured second factor is rejected with the field code `MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS`
+
+8 The only accepted values are 0 and 3, and `nsfw` takes precedence when both fields are supplied. Setting 3 through this field also raises the content warning level to CONTENT_WARNING when `content_warning_level` is absent from the same body and the guild currently sits at INHERIT
+
+9 The write also sets `nsfw_level` to 3 when true and 0 when false, and unlike `nsfw_level` it never changes the content warning level
+
+10 Any value other than 1 is stored as 0
+
+11 The value is trimmed, and a value that is empty after trimming is stored as null
+
+12 A non-null value requires `BANNER`, and an animated image additionally requires `ANIMATED_BANNER`, otherwise the request is rejected with the field codes `GUILD_BANNER_REQUIRES_FEATURE` or `ANIMATED_GUILD_BANNER_REQUIRES_FEATURE`
+
+13 A non-null value requires `INVITE_SPLASH` and is rejected with the field codes `INVITE_SPLASH_REQUIRES_FEATURE` or `EMBED_SPLASH_REQUIRES_FEATURE`. Both splash fields take static images only, so GIF and APNG are not accepted for either and an animated upload is rejected with the field code `INVALID_IMAGE_FORMAT`
+
+14 Adding a feature that is not user-toggleable is rejected with the field code `GUILD_FEATURE_NOT_TOGGLEABLE`, while omitting a feature that is not user-toggleable leaves it in place. The user-toggleable features are `INVITES_DISABLED`, `TEXT_CHANNEL_FLEXIBLE_NAMES`, `DETACHED_BANNER`, `CLONE_EMOJI_DISABLED`, `CLONE_STICKER_DISABLED`, and `HIDE_OWNER_CROWN`
+
+15 A timestamp earlier than the guild's creation time or in the future is rejected with `MESSAGE_HISTORY_CUTOFF_BEFORE_GUILD_CREATION` or `MESSAGE_HISTORY_CUTOFF_IN_FUTURE`
+
+:::caution[Omitting a user-toggleable feature removes it]
+Send the current array with the intended changes applied. A feature without the toggleable marker stays in place when the array omits it.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild object](#guild-object) | Guild was modified, or the supplied values matched current state |
+| 4001 | [error response](/http-api/#error-response) | Body, image, feature set, channel reference, timestamp, or sudo proof is invalid |
+| 4001 | [error response](/http-api/#error-response) | Caller cannot exercise `MANAGE_GUILD` |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, the name is blocked, `MANAGE_GUILD` or ownership is absent, or sudo mode is required |
+| 4043 | [error response](/http-api/#error-response) | Guild does not exist |
+
+1 The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED` for a caller who holds `MANAGE_GUILD` but cannot exercise it, `EXPLICIT_CONTENT_CANNOT_BE_SENT` for an explicit image, and `INVALID_FORM_BODY` otherwise
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked name or body string, `SUDO_MODE_REQUIRED` when an MFA level change is unproven, and `MISSING_PERMISSIONS` for a non-member, a missing `MANAGE_GUILD`, or a non-owner changing the MFA level
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_GUILD`
+
+### Side effects
+
+Every successful request emits [Guild Update](/gateway/events/#guild-update) to every session that can see the guild, and a request that writes no field still emits it.
+
+Only a change to an audited field records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the previous and new values, which in turn emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions that can read the audit log.
+
+Removing `TEXT_CHANNEL_FLEXIBLE_NAMES` renames every guild text channel whose stored name does not satisfy the strict naming policy and, when at least one channel is renamed, emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) Dispatch with every channel in the guild.
+
+Replacing an image queues the previous asset for deletion after the change succeeds. A failed replacement rolls back the newly uploaded asset and leaves the previous image unchanged.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and guild, on the `guild:update::guild_id` bucket.
+
+## Delete guild
+
+
+
+Permanently deletes the guild and returns 204 with an empty body. Requires the guild owner and sudo mode. Emits a [Guild Delete](/gateway/events/#guild-delete) Gateway event to every member session.
+
+Fluxer refuses a non-owner with 403 `MISSING_PERMISSIONS`. A bot can never own a guild, so a bot credential never satisfies the requirement.
+
+A guild protected by an active single community policy cannot be deleted and returns 400 `SINGLE_COMMUNITY_CANNOT_DELETE`. Fluxer decides that refusal before the ownership check and before sudo mode is verified, so a non-owner of such a guild receives the 400 rather than the 403.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof |
+
+### JSON body
+
+The body is the optional [sudo verification fields](#sudo-verification-fields). A request that already has a valid proof can omit it.
+
+:::danger[Deleting a guild destroys its messages and attachments]
+There is no grace period and no scheduled deadline. The guild, its memberships, roles, channels, messages, attachments, invites, webhooks, and discovery listing are destroyed as the request runs, and nothing in the HTTP API restores them.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Guild was deleted |
+| 4001 | [error response](/http-api/#error-response) | Body, sudo proof, or single community policy rejects the request |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, the caller is not the owner, or sudo mode is required |
+| 4043 | [error response](/http-api/#error-response) | Guild does not exist |
+
+1 The [error code](/http-api/errors/) is `SINGLE_COMMUNITY_CANNOT_DELETE` while the policy protects this guild, and `INVALID_FORM_BODY` otherwise
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `SUDO_MODE_REQUIRED` when sudo mode is unproven, and `MISSING_PERMISSIONS` for a caller who is not a member or not the owner
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_GUILD`
+
+### Side effects
+
+Every member session receives [Guild Delete](/gateway/events/#guild-delete) before any state is removed. The operation then removes every membership, deletes each member's guild settings, and removes the guild from each non-bot member's folder layout, which emits [User Settings Update](/gateway/events/#user-settings-update) to that member's sessions. It deletes every invite, webhook, message, attachment, and discovery record, and then the guild itself.
+
+No audit log entry is recorded, because the audit log is destroyed with the guild. An `X-Audit-Log-Reason` header on this request is read and discarded.
+
+### Rate limit
+
+10 requests per minute for each authenticated user and guild, on the `guild:delete::guild_id` bucket.
+
+## Leave guild
+
+
+
+Removes the authenticated account's membership and returns 204 with an empty body. Requires a current membership. Emits a [Guild Member Remove](/gateway/events/#guild-member-remove) Gateway event to the remaining guild sessions and a [Guild Delete](/gateway/events/#guild-delete) Gateway event to the leaving account's own sessions.
+
+A caller with no current membership receives 404 `UNKNOWN_MEMBER` whether or not the guild exists. The guild owner cannot leave and receives 400 `INVALID_FORM_BODY` with the field code `CANNOT_LEAVE_GUILD_AS_OWNER`. A guild protected by an active single community policy cannot be left and returns 400 `SINGLE_COMMUNITY_CANNOT_LEAVE`. Setting `delete_messages` requires sudo mode, which a bot credential satisfies implicitly.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| delete_messages?1 | boolean | Delete every message the caller authored in the guild before leaving (default false) |
+
+1 The deletion runs inside this request and completes before the membership is removed. The response is sent only after every matching message is gone
+
+Setting `delete_messages` also changes the failure order. Fluxer resolves the guild and proves sudo mode before it reads the membership, so a non-member of an existing guild receives 403 `MISSING_PERMISSIONS` rather than 404 `UNKNOWN_MEMBER`.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof, read only when `delete_messages` is true |
+
+### JSON body
+
+The optional body is the [sudo verification fields](#sudo-verification-fields). It is validated on every request and used only when `delete_messages` is true.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Membership was removed |
+| 4001 | [error response](/http-api/#error-response) | Query, body, sudo proof, guild ownership, or single community policy rejects the request |
+| 4032 | [error response](/http-api/#error-response) | Sudo mode is required, or the caller is not a member while `delete_messages` is true |
+| 4043 | [error response](/http-api/#error-response) | Caller holds no membership in the guild |
+
+1 The [error code](/http-api/errors/) is `SINGLE_COMMUNITY_CANNOT_LEAVE` while the policy protects this guild, and `INVALID_FORM_BODY` otherwise, with the field code `CANNOT_LEAVE_GUILD_AS_OWNER` on `guild_id` for the guild owner
+
+2 The [error code](/http-api/errors/) is `SUDO_MODE_REQUIRED` when `delete_messages` is true and sudo mode is unproven, and `MISSING_PERMISSIONS` for a non-member reached through the `delete_messages` path
+
+3 The [error code](/http-api/errors/) is `UNKNOWN_MEMBER`, and `UNKNOWN_GUILD` when `delete_messages` is true and the guild does not exist
+
+### Side effects
+
+The operation snapshots the membership's first join time, leave time, and any active communication timeout for one year, removes the membership, and decreases the guild's member count. For a non-bot account it also removes the guild from the account's folder layout, which emits [User Settings Update](/gateway/events/#user-settings-update) to that account's sessions.
+
+Remaining guild sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove) and the leaving account's sessions receive [Guild Delete](/gateway/events/#guild-delete).
+
+Fluxer leaves the guild's read states and per-guild settings in place. The membership row is destroyed with everything on it, so the guild nickname, guild avatar, guild banner, bio, pronouns, accent colour, and roles do not survive the leave. Rejoining within the retention window restores an unexpired communication timeout and nothing else. No audit log entry is recorded, and an `X-Audit-Log-Reason` header on this request is read and discarded.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and guild, on the `guild:leave::guild_id` bucket.
+
+## Bulk delete current user's guild messages
+
+
+
+Deletes every message the authenticated account has authored in the guild and returns 202 with an empty body. Requires membership and sudo mode, which a bot credential satisfies implicitly. The membership survives the request.
+
+The deletion runs inside this request, so the 202 is sent only after every matching message in every guild channel has been removed. Each processed batch emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) in its channel.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT? | string | Existing sudo mode proof |
+
+### JSON body
+
+The body is the optional [sudo verification fields](#sudo-verification-fields).
+
+:::danger[Deletion is irreversible and unbounded]
+Every message the caller authored in the guild is destroyed together with its attachments. There is no filter and no dry run, and the request runs for as long as the caller's authored history takes.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 202 | empty | Messages were deleted |
+| 400 | [error response](/http-api/#error-response) | Body or sudo proof is invalid |
+| 4031 | [error response](/http-api/#error-response) | Caller is not a member, or sudo mode is required |
+| 4042 | [error response](/http-api/#error-response) | Guild does not exist |
+
+1 The [error code](/http-api/errors/) is `MISSING_PERMISSIONS` for a caller who is not a member and `SUDO_MODE_REQUIRED` when sudo mode is unproven
+
+2 The [error code](/http-api/errors/) is `UNKNOWN_GUILD`
+
+### Side effects
+
+The operation deletes the caller's messages in this guild's channels in batches of at most 100 per channel. Each batch purges the messages' attachments and emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) with that batch's IDs to the channel. No audit log entry is recorded.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and guild, on the shared `guild:leave::guild_id` bucket.
+
+## Get guild custom invite URL
+
+
+
+Returns the guild's [custom invite URL object](#custom-invite-url-object). Requires membership and [MANAGE_GUILD](/http-api/permissions/).
+
+`MANAGE_GUILD` is an [elevated permission](/http-api/permissions/#elevated-permissions). While the guild [MFA level](#mfa-levels) is elevated, a caller other than the owner also needs an enrolled authenticator, and receives 400 `TWO_FACTOR_REQUIRED` without one.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [custom invite URL object](#custom-invite-url-object) | Custom invite URL state was returned |
+| 4001 | [error response](/http-api/#error-response) | Caller cannot exercise `MANAGE_GUILD` |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, or `MANAGE_GUILD` is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED`
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild and `MISSING_PERMISSIONS` for a non-member or a caller lacking `MANAGE_GUILD`
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and guild, on the `guild:vanity_url:get::guild_id` bucket.
+
+## Modify guild custom invite URL
+
+
+
+Sets or removes the guild's custom invite code and returns the [custom invite URL selection object](#custom-invite-url-selection-object). Requires membership and [MANAGE_GUILD](/http-api/permissions/). An effective change emits a [Guild Update](/gateway/events/#guild-update) Gateway event to every session that can see the guild.
+
+`MANAGE_GUILD` is an [elevated permission](/http-api/permissions/#elevated-permissions). While the guild [MFA level](#mfa-levels) is elevated, a caller other than the owner also needs an enrolled authenticator, and receives 400 `TWO_FACTOR_REQUIRED` without one.
+
+A non-null code additionally requires the `VANITY_URL` guild feature and is otherwise rejected with the field code `VANITY_URL_REQUIRES_FEATURE`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code?1 | ?string | Custom invite code to claim, or null to remove the current code |
+
+1 Fluxer lowercases the value, turns each whitespace run into one hyphen, and collapses repeated hyphens. The result must match `^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$` and be 2 to 32 characters
+
+A result that fails the pattern is rejected with the field code `VANITY_URL_INVALID_CHARACTERS`, and one outside the length bound with `VANITY_URL_CODE_LENGTH_INVALID`. A code containing the term `fluxer` is rejected with the field code `VANITY_URL_CODE_CANNOT_CONTAIN_FLUXER`. Sending the code the guild already holds returns that code unchanged, after the feature and reserved term checks and before the uniqueness check. A code that any existing invite already holds is otherwise rejected with the field code `VANITY_URL_CODE_ALREADY_TAKEN`.
+
+:::note[A custom invite code competes with ordinary invites]
+Selecting a code claims it in the global invite namespace, and replacing or removing the code releases it back.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [custom invite URL selection object](#custom-invite-url-selection-object) | Code was claimed, removed, or already matched |
+| 4001 | [error response](/http-api/#error-response) | Code, reserved term policy, feature requirement, or global uniqueness check rejects the request |
+| 4001 | [error response](/http-api/#error-response) | Caller cannot exercise `MANAGE_GUILD` |
+| 4032 | [error response](/http-api/#error-response) | Guild is unavailable, the code is blocked, or `MANAGE_GUILD` is absent |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The [error code](/http-api/errors/) is `TWO_FACTOR_REQUIRED` for a caller who holds `MANAGE_GUILD` but cannot exercise it, and `INVALID_FORM_BODY` otherwise
+
+2 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked code, and `MISSING_PERMISSIONS` for a non-member or a caller lacking `MANAGE_GUILD`
+
+### Side effects
+
+Supplying the current code, or removing a code when the guild has none, changes nothing, records no audit entry, and emits no Dispatch. Any other request deletes the invite backing the previous code. Where a code was supplied, it claims the new one. It records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the previous and new code, emits [Guild Update](/gateway/events/#guild-update) to every session that can see the guild, and emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions that can read the audit log.
+
+### Rate limit
+
+10 requests per minute for each authenticated user and guild, on the `guild:vanity_url:patch::guild_id` bucket.
+
diff --git a/fluxer_docs/src/content/docs/http-api/index.md b/fluxer_docs/src/content/docs/http-api/index.md
new file mode 100644
index 000000000..14224bb38
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/index.md
@@ -0,0 +1,312 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: HTTP API
+description: Request format, body representations, shared headers, cross-origin policy, and the error objects.
+---
+
+The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from `endpoints.api` in the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`.
+
+Every route is also mounted at the root, so a path resolves with or without the prefix. A client MUST use the `/v1` form. [Download stored object](/http-api/downloads/#download-stored-object) is the one exception, and it resolves at the root alone.
+
+Numeric limits such as attachment counts, expression counts, and profile field lengths are instance configuration. Each deployment publishes its current values in its [instance discovery](/http-api/instance/#limit-keys) document, so a client reads them at runtime.
+
+[API conventions](/conventions/) defines the wire notation and the omission and `null` semantics.
+
+## Request format
+
+A JSON request body has `Content-Type: application/json`. A response body is UTF-8 JSON with `Content-Type: application/json` unless the operation states another representation. Message operations and webhook execution accept multipart bodies, and the OAuth2 token operations accept form-encoded bodies.
+
+The API applies no generic byte limit to a request body. An operation that accepts an upload bounds that upload itself.
+
+Every request counts against one in-flight request ceiling for the whole instance. A request that arrives while the instance is at that ceiling returns 503 `SERVICE_UNAVAILABLE` with `Retry-After: 1` before the operation runs. The `/_health`, `/_healthz`, and `/_metrics` probe paths are exempt.
+
+A request to a path that matches no route returns 404 `NOT_FOUND`. A request whose path is registered but not for the request method is answered the same way, and the response has no `Allow` header. Routing is strict, so a trailing slash is significant.
+
+The `GET` registered for a path also serves `HEAD`. A path that registers no `GET` serves no `HEAD` either. A `HEAD` response has the status and headers that `GET` returns, with no body. The request still reports `HEAD` as its method, so the [same-host origin check](#cross-origin-requests) can refuse a `HEAD` that has no `Origin` where the identical `GET` succeeds.
+
+## Request body formats
+
+An operation documents its body under `JSON body`, `Form body`, or `Multipart body` and states any content-type restriction it enforces.
+
+A JSON body is parsed from the raw request text without inspecting `Content-Type`. The instance content filter scans a `POST`, `PUT`, or `PATCH` body that parses as JSON against the banned-phrase and banned-URL blocklists, whatever the header declares. It skips a body whose `Content-Type` contains `multipart/form-data` or `application/x-www-form-urlencoded`. A client MUST send the canonical media type.
+
+Form bodies accept `application/x-www-form-urlencoded` and `multipart/form-data` interchangeably. The three [OAuth2](/http-api/oauth2/) token operations are the only ones that take one. A field that occurs once is a string or file. Repeating the same field name produces an array in occurrence order, and a name ending in `[]` also collects its values into an array.
+
+[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) are the only operations that define a multipart body of their own. Each selects the multipart parser when the request `Content-Type` contains `multipart/form-data` and parses the body as JSON otherwise. The three OAuth2 token operations also accept `multipart/form-data`, but read it as an ordinary form body.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| payload_json?1 | string | JSON object with the operation's complete message request payload |
+| files[n]?2 | file | Direct attachment file at zero-based index `n` |
+
+1 An absent `payload_json` is read as an empty object. A value that is not a string, or does not parse as JSON, is rejected with `INVALID_JSON_IN_PAYLOAD_JSON`
+
+2 `n` is a run of decimal digits, at most 10000 and below the deployment's [`max_attachments_per_message`](/http-api/instance/#limit-keys) limit, which defaults to 10
+
+The legacy names `file` and `file` followed by an index are accepted as file fields as well, and a bare `file` takes the next free legacy index.
+
+Five field-name failures are rejected with their own code. An index outside either bound returns `FILE_INDEX_EXCEEDS_MAXIMUM`. Any other name beginning with `files[` returns `INVALID_FILE_FIELD_NAME`. Two file fields resolving to the same index return `DUPLICATE_FILE_INDEX`. More than one file supplied for one index returns `MULTIPLE_FILES_FOR_INDEX_NOT_ALLOWED`. Where the resolved limit is 0, any file field at all returns `ATTACHMENTS_NOT_ALLOWED_FOR_MESSAGE`.
+
+The `Content-Type` header supplies the multipart boundary. Each part's field name is in `Content-Disposition`. A body the multipart parser cannot read is rejected with `FAILED_TO_PARSE_MULTIPART_FORM_DATA`. A field name the operation does not recognise is ignored, and a `files[n]` part whose value is not a file is ignored once its index has been bounds-checked.
+
+A multipart message body MAY also have `content`, `nonce`, `tts`, `flags`, `favorite_meme_id`, and `sticker_ids` as plain form fields. Each overrides the member of the same name in the parsed `payload_json`, and `sticker_ids` collects every value it is given.
+
+The `attachments` array inside `payload_json` maps attachment metadata to files by matching each attachment `id` to `n` in `files[n]`. A metadata entry that has a `filename` but matches no supplied file is rejected with `NO_FILE_FOR_ATTACHMENT_METADATA`, and two metadata entries claiming the same file index are rejected with `DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED`. A supplied file that no metadata entry claims is still attached, taking its index as the attachment ID and its uploaded filename as the attachment filename. A metadata entry that has no `filename` and matches no supplied file is read as a pre-uploaded attachment reference.
+
+[Messages](/http-api/messages/) defines the attachment metadata and pre-uploaded attachment form, and [Attachment uploads](/topics/uploads/) defines the separate relay upload flow.
+
+:::caution[Multipart indices are attachment IDs]
+Each direct file's `files[n]` index is also the `id` in its attachment metadata entry. Fluxer reads `n` from the field name, so part order sets no identity. The indices need not begin at zero and need not be contiguous.
+:::
+
+## Input normalisation
+
+Field tables use the shared [wire table notation](/conventions/#wire-table-notation).
+
+:::note[Empty values are normalised before validation]
+An empty string becomes `null` at any depth, and an empty nested object becomes `null` below the root of the payload.
+:::
+
+The shared validator normalises the JSON body, a form body, the query string, path parameters, request headers, and cookies before their schemas run.
+
+A nested object whose members have all become `null` becomes `null` in turn. The root object itself is never collapsed this way. An empty request body is read as an empty object, so the caller sees the operation's own required-field failures. A body that is present but does not parse as JSON returns 400 `INVALID_FORM_BODY` with one element at path `body` and code `INVALID_FORMAT`.
+
+:::caution[Three operations bypass the shared validator]
+[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) read their own body and apply none of that normalisation.
+:::
+
+An empty string stays an empty string and an empty nested object stays an empty object in those three. The first two reject a JSON body that does not parse. On a JSON body all three collapse every schema failure to one validation entry, and each operation names that entry on its own page.
+
+## Authentication
+
+[Authentication](/authentication/) defines the accepted `Authorization` schemes, their exact token forms, and the OAuth2 scope registry. An operation that requires a credential states the scheme on its resource page. The [sudo verification object](/http-api/users/mfa/#sudo-verification-object) defines the sudo mode credential that guards sensitive account operations.
+
+An OAuth2 bearer access token is accepted only where a route opts in, and the resource page says so. Everywhere else a bearer credential is refused with 403 `ACCESS_DENIED`, and an account with a suspicious activity flag is refused with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+## Standard request headers
+
+These headers are accepted across resources. An operation-specific header is documented in that operation's request header table.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization? | string | The single credential for an authenticated request, in one of the [accepted schemes](/authentication/#authorization-schemes) |
+| Content-Type?1 | string | The media type of the request body, which selects the multipart parser when it contains `multipart/form-data` |
+| Accept-Language?2 | string | Selects the locale used for an error `message` |
+| X-Audit-Log-Reason?3 | string | Free-text reason recorded on the resulting audit log entry |
+| X-Fluxer-Client-Properties?4 | string | Base64-encoded JSON with the native client's `os`, read when an authentication session is created |
+| X-Fluxer-Sudo-Mode-JWT?5 | string | A sudo mode proof previously issued to the authenticated user |
+| X-Captcha-Token?6 | string | The CAPTCHA solution issued by the selected provider |
+| X-Captcha-Type?6 | string | Either `hcaptcha` or `turnstile`, selecting the provider that issued the token |
+| X-Request-ID?7 | string | A correlation identifier the client chooses, echoed unchanged in the response |
+| User-Agent? | string | The originating client description recorded on a new authentication session and on an Admin audit entry |
+| Origin?8 | string | The browser origin used for cross-origin negotiation and for the mutating same-host origin check |
+
+1 Only the multipart message operations read it to decide how the body is parsed. The instance content filter reads it separately, as described under [request body formats](#request-body-formats)
+
+2 The configured locale of the authenticated account takes precedence, so this header selects the locale only for an unauthenticated request or an account with no configured locale
+
+3 The value is read verbatim with no percent-decoding, then stripped of form feed and right-to-left override characters and trimmed. A blank or over-long value is treated as absent
+
+4 Read only for a native Fluxer `User-Agent`, at most 4096 characters, and only the `os` member is used
+
+5 A valid token replaces the sudo proof fields in the operation body and is echoed in the response header without extending its lifetime.
+
+6 Read only while the instance has a provider configured and the operation is gated. The handshake is defined in [CAPTCHA handling](/topics/captcha/)
+
+7 A supplied value is echoed back unchanged and unvalidated
+
+8 The exact use is defined in [cross-origin requests](#cross-origin-requests)
+
+A client MUST NOT send an `X-Request-ID` it is not willing to see in logs, because Fluxer echoes and records the value without validating it.
+
+An `X-Audit-Log-Reason` normalised to more than 512 characters is discarded, and the request still succeeds. Every request is normalised this way, but only an operation that supports an audit reason records the result.
+
+## Standard response headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Version?1 | string | The build version of the instance that served the request |
+| X-Request-ID?2 | string | The identifier assigned to the request |
+| X-Fluxer-Sudo-Mode-JWT?3 | string | The sudo mode token for the caller, present only on an operation that completes a sudo proof |
+| Content-Type? | string | The media type of the representation, absent from a response with no body |
+| Cache-Control?4 | string | The literal value `no-cache` unless the operation sets its own directive |
+| Access-Control-Allow-Origin?5 | string | The request `Origin` when it is a configured application origin, and the literal `*` on routes that set their own wildcard |
+| Access-Control-Expose-Headers?5 | string | The literal value `X-Fluxer-Version` |
+| Vary?5 | string | The literal value `Origin`, sent whenever the allowed origin was echoed |
+| Retry-After?6 | string | Whole seconds to wait, sent on a rate limit denial, a slowmode denial, a resource lock, and the in-flight ceiling 503 |
+| X-RateLimit-Limit?7 | string | Present on a route denial and on a successful bot or webhook request |
+| X-RateLimit-Remaining?7 | string | Present on a route denial and on a successful bot or webhook request |
+| X-RateLimit-Reset?7 | string | Unix timestamp in seconds |
+| X-RateLimit-Reset-After?7 | string | Seconds until the bucket resets |
+| X-RateLimit-Bucket?7 | string | A stable 16-character hash of the bucket name |
+| X-RateLimit-Scope?7 | string | Present on a rate limit denial, taking the value `user`, `shared`, or `global` |
+| X-RateLimit-Global?7 | string | Present on a global rate limit denial, taking the literal value `true` |
+
+1 The value is the literal `dev` when the build has no version stamp
+
+2 A generated UUID unless the request supplied its own, in which case that value is echoed back unchanged and unvalidated
+
+3 A token newly issued where the caller proved sudo mode afresh, and otherwise the incoming proof echoed back with no extension of its lifetime
+
+4 Absent from a response with no body
+
+5 `Access-Control-Expose-Headers` is sent on every response the instance CORS policy handles. `Access-Control-Allow-Origin` and `Vary` are sent only for an allowed origin
+
+6 The literal 1 on the in-flight ceiling 503, and otherwise the [rate limit header](/topics/rate-limits/#rate-limit-headers) contract
+
+7 The complete contract is defined in [Rate limits](/topics/rate-limits/#rate-limit-headers)
+
+An operation that sets its own `Cache-Control` keeps that value. A response whose `Content-Type` names a stylesheet, script, font, image, video, or audio representation receives `public, max-age=31536000`.
+
+
+
+## Rate limits
+
+Every route consumes its own rate limit bucket and is additionally evaluated against one global bucket unless that bucket is exempt. A denial returns 429 `RATE_LIMITED`. [Rate limits](/topics/rate-limits/) defines the bucket scoping rules, the global allowance, the 429 body, the scope registry, and the complete `X-RateLimit-*` header contract.
+
+:::note[Two 429 responses have no `X-RateLimit-*` header]
+A 429 `RESOURCE_LOCKED` response has `Retry-After: 1`, and a 429 `IP_AUTHORIZATION_RESEND_COOLDOWN` response has the remaining cooldown in whole seconds. A client that reads the bucket headers branches on `code`.
+:::
+
+A global denial has `Retry-After`, `X-RateLimit-Scope`, and `X-RateLimit-Global` alone.
+
+## Hosted-only routes
+
+A small set of routes exists only on the hosted Fluxer deployment. A self-hosted deployment answers one of them with 404 `NOT_FOUND`. [Deployment availability](/http-api/deployment-availability/) lists every hosted-only route and states how a client resolves the deployment kind before authenticating.
+
+## Cross-origin requests
+
+The CORS response policy is an allow-list of exactly two origins, the deployment's configured web application endpoint and its marketing endpoint. A request whose `Origin` matches one of them receives `Access-Control-Allow-Origin` set to that origin and `Vary: Origin`. Every other request, including one that sends no `Origin`, receives no `Access-Control-Allow-Origin` from that policy. Credentialed cross-origin requests are not enabled, so `Access-Control-Allow-Credentials` is never sent.
+
+Five paths are readable from any origin. `/v1/webhooks/{webhook_id}/{token}` and `/v1/webhooks/{webhook_id}/{token}/messages/{message_id}` have a second cross-origin policy that allows any origin. Four of the methods registered on them refuse the first-party web client outright, and that refusal is defined by [Origin refusal](/http-api/webhooks/#origin-refusal).
+
+[Get instance discovery](/http-api/instance/#get-instance-discovery) on `/.well-known/fluxer`, [Get OpenAPI document](/http-api/instance/#get-openapi-document) on `/v1/openapi.json`, and [Get client geolocation](/http-api/instance/#get-client-geolocation) on `/v1/ip` set `Access-Control-Allow-Origin: *` in the operation itself. The wildcard stands for any origin outside the allow-list, and for an allowed origin the policy replaces it with that exact origin and sends `Vary: Origin`.
+
+`Access-Control-Expose-Headers` is the single value `X-Fluxer-Version`. Every other Fluxer response header, the rate limit headers and `X-Request-ID` included, is hidden from cross-origin script.
+
+:::caution[Same-host mutations require a matching origin]
+A production deployment rejects a non-`GET` request whose `Host` is `web.fluxer.app` or `web.canary.fluxer.app` unless its `Origin` is exactly `https://` followed by that same host, returning 403 `INVALID_API_ORIGIN`. A request sent to the API host is unaffected.
+:::
+
+
+
+## Error response object
+
+A JSON error response identifies the stable error code and a human-readable message. An operation can add further top-level members with structured detail. [Errors](/http-api/errors/) documents the code registries, the HTTP status fallback mapping, and the localisation behaviour.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | Stable machine-readable [API error code](/http-api/errors/#api-error-code-registry) |
+| message2 | string | Human-readable description of this failure |
+| errors?3 | array[[validation error](#validation-error-object) object] | Request fields that failed validation |
+
+1 Every code is an uppercase symbolic name matching `[A-Z][A-Z0-9_]*`
+
+2 The wording varies by locale and defaults to the code itself when the failure supplies no message
+
+3 Present on an `INVALID_FORM_BODY` response, and on any other failure that has field detail
+
+### Example
+
+```json
+{
+ "code": "MISSING_PERMISSIONS",
+ "message": "Missing permissions"
+}
+```
+
+The body has no request identifier. A client correlates a failure through the `X-Request-ID` response header.
+
+:::note[Structured detail sits at the top level]
+`IP_AUTHORIZATION_REQUIRED` has `ip_authorization_required`, `ticket`, `email`, and `resend_available_in`. `SUDO_MODE_REQUIRED` has `has_mfa` and `methods`.
+:::
+
+`MISSING_OAUTH_SCOPE` has `required_scope`, and `RATE_LIMITED` has `retry_after` and `global`, where `global` is `true` only on a global bucket denial. Each operation that produces one of those members documents it. A client MUST treat any member it does not recognise as absent. Two operations returning the same `code` can have different extra members, so a client MUST read the members its own operation documents.
+
+## Validation error object
+
+Each entry identifies one failed input field. A 400 response whose top-level code is `INVALID_FORM_BODY` has the array in `errors`. [Errors](/http-api/errors/#validation-failure-codes) states which failures produce it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| path1 | string | The location of the request field that failed validation |
+| code?2 | string | Stable machine-readable validation code |
+| message3 | string | The human-readable description of this validation failure |
+
+1 A nested field is a dot-joined path such as `embeds.0.title`, and a failure with no field position uses the literal path `root`
+
+2 Present whenever the failure has an enumerated code from the [validation error code registry](/http-api/errors/#validation-error-code-registry)
+
+3 The localised message registered for the `code`, falling back to the code itself, and a fixed English string on a failure that has no code
+
+### Example
+
+```json
+{
+ "code": "INVALID_FORM_BODY",
+ "message": "Invalid form body",
+ "errors": [
+ {"path": "name", "code": "BASE_TYPE_REQUIRED", "message": "This field is required"}
+ ]
+}
+```
+
+Fluxer produces at most one entry for each distinct pair of `path` and `code`, so a field that fails several equivalent constraints appears once.
+
+## Resource pages
+
+| Page | Covers |
+| --- | --- |
+| [Errors](/http-api/errors/) | The error envelope, status fallback mapping, and the API and validation code registries |
+| [Deployment availability](/http-api/deployment-availability/) | Routes that exist only on the hosted deployment |
+| [Authentication operations](/http-api/authentication/) | Registration, login, MFA, WebAuthn, SSO, email verification, recovery, sessions, IP authorisation, desktop handoff |
+| [Gateway](/http-api/gateway/) | Bot discovery of the main Gateway endpoint and session start budget |
+| [Instance](/http-api/instance/) | Instance discovery, client geolocation, the limit key registry, the served OpenAPI document |
+| [OAuth2](/http-api/oauth2/) | Authorisation, consent, token exchange, introspection, revocation, granted authorisations |
+| [Applications](/http-api/applications/) | Application, bot account, client secret, and bot token management, plus public lookup |
+| [Connections](/http-api/connections/) | External account connection initiation, verification, visibility, and deletion |
+| [Users](/http-api/users/) | The user object and the shared user enumerations |
+| [Current user](/http-api/users/current-user/) | Current account retrieval, profile mutation, lifecycle, policy acceptance, authorised IP state |
+| [User settings](/http-api/users/settings/) | Account, notification, and privacy settings, guild folders, voice activity sharing |
+| [User settings Protobuf](/http-api/users/settings-protobuf/) | Every structured client preference message and enumeration |
+| [Email and password changes](/http-api/users/email-and-password/) | The ticketed credential replacement flows |
+| [Multi-factor authentication](/http-api/users/mfa/) | TOTP, backup codes, WebAuthn credentials, sudo verification |
+| [Phone verification](/http-api/users/phone-verification/) | Outbound and inbound phone verification |
+| [Relationships](/http-api/users/relationships/) | Friend requests, friendships, blocks, relationship nicknames |
+| [User notes](/http-api/users/notes/) | Private notes attached to user IDs |
+| [Private channels](/http-api/users/private-channels/) | Direct message and group DM discovery, creation, preload, pin state |
+| [User content collections](/http-api/users/content/) | Recent mentions, saved messages, asynchronous message deletion |
+| [Gift inventory](/http-api/users/gifts/) | Premium gift codes created by the current account |
+| [Data harvests](/http-api/users/data-harvest/) | Data harvest creation, status, and download |
+| [Read states](/http-api/read-states/) | Message watermark and mention count acknowledgements |
+| [Memes](/http-api/memes/) | The saved image, video, and audio collection and batch GIF URL resolution |
+| [Themes](/http-api/themes/) | Shareable custom CSS theme creation |
+| [Channels](/http-api/channels/) | Channel objects, private recipients, permission overwrites, slowmode, RTC regions |
+| [Calls](/http-api/calls/) | Call eligibility, region selection, ringing, and termination |
+| [Streams](/http-api/streams/) | Go Live stream keys, stream regions, preview image lifecycle |
+| [Entrance sounds](/http-api/entrance-sounds/) | The entrance sound collection, its per-scope selections, and playback |
+| [Messages](/http-api/messages/) | Messages, attachments, embeds, history, acknowledgements, pins, reactions, typing |
+| [Guilds](/http-api/guilds/) | Guild objects and lifecycle operations |
+| [Guild channels](/http-api/guild-channels/) | Listing, creation, hierarchy, permission inheritance, bulk positioning |
+| [Guild members](/http-api/guild-members/) | Member objects, profiles, voice moderation, ownership, removal, role assignment |
+| [Guild member search](/http-api/guild-member-search/) | Indexed search filters, sorting, pagination, supplemental join metadata |
+| [Guild moderation](/http-api/guild-moderation/) | Bans, temporary bans, ban replacement, and the blocks a ban has |
+| [Guild emojis](/http-api/guild-emojis/) | Guild emoji objects, uploads, metadata changes, deletion |
+| [Guild stickers](/http-api/guild-stickers/) | Guild sticker objects, uploads, metadata changes, deletion |
+| [Expressions](/http-api/expressions/) | Emoji and sticker metadata reads across both |
+| [Guild audit logs](/http-api/guild-audit-logs/) | Audit entries, typed targets, contexts, changes, filters, the audit reason contract |
+| [Roles and permissions](/http-api/permissions/) | Permission flags, computation order, role objects and lifecycle |
+| [Discovery](/http-api/discovery/) | Public guild listings, categories, the listing application lifecycle, joining without an invite |
+| [Invites](/http-api/invites/) | Invite lookup, creation, acceptance, deletion, code generation |
+| [Webhooks](/http-api/webhooks/) | Webhook management, message execution, GitHub, Slack, and Instatus callbacks |
+| [Search](/http-api/search/) | Authenticated global message search |
+| [Unfurl](/http-api/unfurl/) | Authenticated external URL metadata resolution |
+| [Billing](/http-api/billing/) | Stripe checkout, card preapproval, gift purchase, age verification, refunds, the Stripe webhook |
+| [Premium](/http-api/premium/) | Premium pricing, entitlement state, subscription self-service, billing portal handoff |
+| [Gifts](/http-api/gifts/) | Public gift code lookup and authenticated redemption |
+| [Donations](/http-api/donations/) | Donation currencies and intervals, checkout sessions, the donor management link |
+| [Reports](/http-api/reports/) | Authenticated safety reports and email-verified Digital Services Act notices |
diff --git a/fluxer_docs/src/content/docs/http-api/instance.mdx b/fluxer_docs/src/content/docs/http-api/instance.mdx
new file mode 100644
index 000000000..197e3d662
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/instance.mdx
@@ -0,0 +1,520 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Instance
+description: Instance discovery, client geolocation, the limit key registry, and the served OpenAPI document.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The Instance resource describes how one Fluxer deployment is set up. It publishes the discovery document, the client geolocation lookup, and the served OpenAPI document, and it owns the [limit key](#limit-keys) registry that every other page cites.
+
+[Get instance discovery](#get-instance-discovery) is the entry point of the API. A client that knows only a Fluxer origin reads `/.well-known/fluxer` first, and that one unauthenticated response has every field of the [instance discovery object](#instance-discovery-object). Those values include the API base URLs, the [main Gateway](/gateway/overview/) WebSocket URL, and the [Media Proxy](/media-proxy/overview/) base URL, and they override the [endpoint path defaults](#instance-endpoints-object) a deployment would otherwise serve from its canonical public origin.
+
+Every route here is unauthenticated. A credential changes no response body. One that resolves to an account moves the [rate limit bucket](/topics/rate-limits/) from the client IP address onto that account.
+
+Each of the three routes answers with `Access-Control-Allow-Origin: *`, replaced by the request `Origin` when the deployment-wide [cross-origin policy](/http-api/#cross-origin-requests) allows that origin. The [Admin Instance API](/admin-api/instance/) defines the operator side of the same configuration.
+
+## Instance discovery object
+
+The discovery document is the complete public description of one deployment. Every field is always present.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| api_code_version1 | integer | The version of the API server code this deployment runs |
+| endpoints | [instance endpoints](#instance-endpoints-object) object | Public service endpoints for this deployment |
+| captcha | [captcha configuration](#captcha-configuration-object) object | Public CAPTCHA configuration |
+| features | [instance features](#instance-features-object) object | Public deployment feature state |
+| gif | [GIF provider](#gif-provider-object) object | The active GIF provider and its attribution requirement |
+| sso | [SSO status](/http-api/authentication/#sso-status-object) object | Public single sign-on state |
+| registration | [registration policy](#registration-policy-object) object | Public registration policy |
+| community | [community policy](#community-policy-object) object | Community defaults and direct message policy |
+| services | [service availability](#service-availability-object) object | Resolved third-party integration availability |
+| limits | [limit configuration](#limit-configuration-object) object | Ordered limit rules and the trait names the deployment declares |
+| push | [push configuration](#push-configuration-object) object | Public Web Push identity |
+| app_public | [public application configuration](#public-application-configuration-object) object | Public client configuration |
+
+1 The value is a plain integer that increases when the API introduces a change a client is expected to notice, and the current value is `1`
+
+## Instance endpoints object
+
+Each value is an absolute URL supplied by the operator. A value can be a bare origin or an origin with a path prefix, because a deployment can serve several roles from one canonical public origin. A client accepts two endpoints that share an origin and MUST NOT infer a separate host, scheme, or port for any of them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| api1 | string | Base URL for authenticated API requests |
+| api_client1 | string | Base URL for client API requests |
+| api_public | string | Base URL for public API requests |
+| gateway2 | string | The [main Gateway](/gateway/overview/) WebSocket URL |
+| media | string | [Media Proxy](/media-proxy/overview/) base URL, also used as the attachment [upload relay](/media-proxy/upload-relay/) base |
+| static_cdn3 | string | Static asset base URL |
+| marketing | string | Marketing site base URL |
+| admin | string | Admin application base URL |
+| invite | string | Invite link base URL |
+| gift | string | Gift link base URL |
+| webapp | string | Web application base URL |
+
+1 Both fields are the same configured client API endpoint, while `api_public` is the separately configured public API endpoint
+
+2 The value is the configured Gateway endpoint and its scheme is `ws` or `wss` as the operator configured it
+
+3 A deployment that configures a separate static asset domain derives that value over `https` on the configured domain and without the port the other endpoints have
+
+Every value is the exact origin the deployment advertises, including its scheme and any explicit port, and a deliberately plain HTTP deployment publishes `http` and `ws` values here.
+
+The operator configures one canonical public origin with an exact `http` or `https` scheme, a host, and an optional explicit port. The web application is served at that origin itself, and the other endpoints default to a path under it:
+
+| Endpoint | Default path |
+| --- | --- |
+| HTTP API | `/api` |
+| Media Proxy | `/media` |
+| Admin application | `/admin` |
+| Marketing site | `/marketing` |
+| Invite links | `/invite` |
+| Gift links | `/gift` |
+| Gateway | `/gateway` |
+
+The Gateway scheme is `ws` or `wss`. A port that equals the default for its scheme is omitted.
+
+A deployment can configure a separate static asset domain, invite domain, and gift domain, and it can replace any single endpoint with an exact override.
+
+:::caution[Use a published endpoint exactly as published]
+A client MUST NOT upgrade a published `http` value to `https`, downgrade a published `https` value after a failure, discard a port, or assume a DNS suffix.
+:::
+
+### Example
+
+```json
+{
+ "api": "https://api.example.com",
+ "api_client": "https://api.example.com",
+ "api_public": "https://public.example.com",
+ "gateway": "wss://gateway.example.com",
+ "media": "https://media.example.com",
+ "static_cdn": "https://cdn.example.com",
+ "marketing": "https://example.com",
+ "admin": "https://admin.example.com",
+ "invite": "https://example.com/invite",
+ "gift": "https://example.com/gift",
+ "webapp": "https://app.example.com"
+}
+```
+
+## CAPTCHA configuration object
+
+Which CAPTCHA provider this deployment uses, and the site keys a client needs to render its challenge.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| provider1 | string | The selected [CAPTCHA provider](#captcha-providers) |
+| hcaptcha_site_key2 | ?string | The configured hCaptcha site key, or null when none is configured |
+| turnstile_site_key2 | ?string | The configured Turnstile site key, or null when none is configured |
+
+1 The value is `none` when the configured provider's site key or secret key is missing
+
+2 Only the key that matches `provider` is non-null, so both are null while `provider` is `none`
+
+### CAPTCHA providers
+
+| Value | Description |
+| --- | --- |
+| none | The deployment requires no CAPTCHA |
+| hcaptcha | hCaptcha challenges gate the operations that demand one |
+| turnstile | Cloudflare Turnstile challenges gate the operations that demand one |
+
+The complete challenge handshake, including the `X-Captcha-Token` and `X-Captcha-Type` request headers, is defined in [CAPTCHA handling](/topics/captcha/).
+
+## Instance features object
+
+Deployment-wide switches a client reads before it offers a feature, plus whether the deployment calls itself self-hosted.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| voice_enabled | boolean | Whether voice and video calling is enabled |
+| stripe_enabled | boolean | Whether Stripe [billing](/http-api/billing/) is enabled |
+| self_hosted | boolean | Whether this deployment identifies itself as self-hosted |
+| presigned_attachment_uploads | boolean | Whether a client can request presigned attachment upload URLs |
+| emails_enabled1 | boolean | Whether the deployment sends email |
+
+1 The value is true only when email is switched on and the transport is completely configured
+
+A deployment that reports `emails_enabled` as false sends no verification, password recovery, or IP authorisation message, and the flows that depend on one are unusable there. [Deployment availability](/http-api/deployment-availability/) states which routes a self-hosted deployment does not serve at all.
+
+## GIF provider object
+
+The GIF provider this deployment has bound, and whether a client shows its attribution mark.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| provider | string | Stable machine name of the active GIF provider |
+| display_name | string | Human-readable provider name a client shows |
+| attribution_required1 | boolean | Whether a client shows the provider attribution |
+
+1 A client that renders GIF results displays the provider's attribution mark alongside them when this value is true
+
+The fields describe the provider a deployment has bound even when [service availability](#service-availability-object) reports `gif_enabled` as false. A client reads `gif_enabled` to decide whether to offer the picker at all. A deployment that has bound no GIF provider still publishes the stock values `klipy`, `Klipy`, and false.
+
+## Registration policy object
+
+Who can register an account on this deployment.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mode | string | The [registration mode](#registration-modes) |
+| admin_registration_urls_enabled1 | boolean | Whether an administrator-issued registration URL code is accepted |
+
+1 When true, [Register an account](/http-api/authentication/#register-an-account) accepts `registration_url_code`, a valid code admits registration even while `mode` is `closed`, and the code's own approval requirement replaces the one the mode would impose
+
+### Registration modes
+
+| Value | Description |
+| --- | --- |
+| open | Anyone can register and receives a session immediately |
+| approval | Registration succeeds but the account waits for administrator approval before it can sign in |
+| closed | Public registration is refused |
+
+## Community policy object
+
+Whether this deployment runs as one community, and whether direct messages exist on it at all.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| single_community | boolean | Whether this deployment runs as one community that every account joins |
+| single_community_guild_id1 | ?snowflake | The stock community guild ID, or null |
+| direct_messages_disabled | boolean | Whether direct messages and friend requests are disabled for the whole deployment |
+
+1 The identifier is published only while `single_community` is true and a stock community has been chosen
+
+## Service availability object
+
+Each field reports whether one optional integration is available on this deployment.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| gif_enabled | boolean | Whether the GIF picker is available |
+| youtube_enabled | boolean | Whether YouTube link enrichment is available |
+| bluesky_enabled | boolean | Whether Bluesky [connections](/http-api/connections/) are available |
+
+## Push configuration object
+
+This deployment's public Web Push identity.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| public_vapid_key1 | ?string | The deployment's Web Push VAPID public key, or null |
+
+1 Null when the deployment has configured no Web Push identity
+
+## Limit configuration object
+
+The ordered rules a client evaluates to work out the limits that apply to an account or a guild.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| version | integer | The wire format version, always `2` |
+| traitDefinitions1 | array[string] | The trait names this deployment declares |
+| rules | array[[limit rule](#limit-rule-object) object] | The rules to evaluate |
+| defaultsHash2 | string | A hash of the release's built-in default limit values |
+
+1 A stock hosted configuration publishes the single name `premium`, which is computed from the account's premium state
+
+2 The value is computed from the release's default limit values alone, so an operator editing a rule leaves it unchanged. It changes when a release changes the defaults, which invalidates a resolution a client cached against the older ones
+
+Each rule publishes only the keys whose values differ from the release defaults. The defaults themselves are not published in this document, so a client MUST hold its own copy of them. A client MUST also rebuild every rule into a full limit map before it resolves anything, taking the default value of every [limit key](#limit-keys) and overlaying that rule's `overrides` on it.
+
+A client starts from the default value of every key. It evaluates the rules from least specific to most specific, where specificity is the total number of trait and guild feature names in a rule's filters. Two rules of equal specificity keep their published order. A rule matches when every trait it names is present in the evaluation context and every guild feature it names is present.
+
+A rule that names no trait and no guild feature replaces the current value for each key it has. A rule that names at least one raises the current value to its own value when that is higher. Because a rebuilt rule has every key, an unfiltered rule returns each key it did not override to the default. A matching filtered rule raises each key it did not override to at least the default.
+
+Two evaluation contexts exist. A user evaluation applies only keys whose scope is user or is both. A guild evaluation applies every key whose scope is both. It applies a user-scoped key only when the rule has no guild feature filter. It applies a guild-scoped key when the rule has a guild feature filter, or when the rule has no trait filter at all. The [limit key](#limit-keys) registry states the scope of each key.
+
+:::note[The operation applies its own authoritative bound]
+The document lets a client present the correct bounds before it attempts an operation. A request that exceeds the resolved value still fails with the operation's own documented error.
+:::
+
+:::caution[An unauthenticated reader resolves only the unfiltered outcome]
+The document publishes neither the requesting account's traits nor any guild feature set. A client supplies that context from its own authenticated state.
+:::
+
+`traitDefinitions` names the traits the deployment advertises. Nothing validates a rule's `traits` filter against it, so a rule can filter on a name the collection omits. A self-hosted deployment whose premium mode grants every account the stock limits publishes an empty collection and has no rule that filters on `premium`.
+
+## Limit rule object
+
+One rule of the limit configuration, with the filters that select it and the values it sets.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The stable rule identifier |
+| filters? | [limit filters](#limit-filters-object) object | The traits and guild features that select the rule, omitted when the rule always matches |
+| overrides1 | map[string, integer] | Integer values keyed by [limit key](#limit-keys) |
+
+1 An entry whose value is negative or is not a finite number is ignored when the rule is applied
+
+### Example
+
+```json
+{
+ "id": "premium",
+ "filters": {"traits": ["premium"]},
+ "overrides": {
+ "max_message_length": 4000,
+ "max_guild_emojis": 250
+ }
+}
+```
+
+## Limit filters object
+
+The traits and guild features a [limit rule](#limit-rule-object) requires before it matches.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| traits? | array[string] | Trait names that must all be present for the rule to match |
+| guildFeatures? | array[string] | Guild feature names that must all be present for the rule to match |
+
+An empty or omitted collection imposes no condition. A rule whose filters object has neither collection always matches, and it replaces each key's current value exactly as a rule that omits `filters` does.
+
+## Limit keys
+
+Each key names one limit. A key whose name begins with `feature_` is a feature gate whose value is `0` when the feature is unavailable and `1` when it is available. Every other key's value is a bound in the unit named by its description.
+
+| Value | Description |
+| --- | --- |
+| avatar_max_size | Maximum file size for avatar uploads in bytes, in user scope |
+| emoji_max_size | Maximum file size for emoji uploads in bytes, in guild scope |
+| feature_animated_avatar | Allow animated avatar uploads, in user scope |
+| feature_animated_banner | Allow animated banner uploads, in user scope |
+| feature_custom_discriminator | Allow custom discriminator selection, in user scope |
+| feature_custom_notification_sounds | Allow custom notification sounds, in user scope |
+| feature_early_access | Access to beta features, in user scope |
+| feature_global_expressions | Use expressions across all guilds, in user scope |
+| feature_higher_video_quality | Access to higher video streaming quality, in user scope |
+| feature_per_guild_profiles | Different profile per guild, in user scope |
+| feature_voice_entrance_sounds | Play a sound when joining voice, in user scope |
+| max_attachment_file_size | Maximum size of each attachment file in bytes, in user and guild scope |
+| max_attachments_per_message | Maximum number of file attachments per message, in user and guild scope |
+| max_bio_length | Maximum characters in a user biography, in user scope |
+| max_bookmarks | Maximum bookmarked messages, in user scope |
+| max_channels_per_category | Maximum channels per category, in guild scope |
+| max_custom_backgrounds | Maximum custom profile backgrounds, in user scope |
+| max_embeds_per_message | Maximum number of embeds per message, in user and guild scope |
+| max_favorite_meme_tags | Maximum meme tags to track, in user scope |
+| max_favorite_memes | Maximum favourited memes, in user scope |
+| max_group_dm_recipients | Maximum members per group direct message, in user scope |
+| max_group_dms_per_user | Maximum group direct messages a user can own, in user scope |
+| max_guild_channels | Maximum channels per guild, in guild scope |
+| max_guild_emojis | Maximum total emojis per guild, in guild scope |
+| max_guild_emojis_animated1 | Compatibility alias for older clients, in guild scope |
+| max_guild_emojis_animated_more1 | Compatibility alias for older clients, in guild scope |
+| max_guild_emojis_static1 | Compatibility alias for older clients, in guild scope |
+| max_guild_emojis_static_more1 | Compatibility alias for older clients, in guild scope |
+| max_guild_invites | Maximum active invites per guild, in guild scope |
+| max_guild_members | Maximum members per guild, in guild scope |
+| max_guild_roles | Maximum roles per guild, in guild scope |
+| max_guild_stickers | Maximum stickers per guild, in guild scope |
+| max_guild_stickers_more2 | Compatibility alias for older clients, in guild scope |
+| max_guilds | Maximum number of guilds a user can join, in user scope |
+| max_message_length | Maximum number of characters per message, in user and guild scope |
+| max_private_channels_per_user | Maximum direct message channels per user, in user scope |
+| max_reactions_per_message | Maximum distinct reactions per message, in guild scope |
+| max_relationships | Maximum friend and blocked relationships, in user scope |
+| max_users_per_message_reaction | Maximum users who can use the same reaction on a message, in guild scope |
+| max_voice_message_duration | Maximum voice message duration in seconds, in user and guild scope |
+| max_webhooks_per_channel | Maximum webhooks per channel, in guild scope |
+| max_webhooks_per_guild | Maximum webhooks per guild, in guild scope |
+| sticker_max_size | Maximum file size for sticker uploads in bytes, in guild scope |
+
+1 Emoji limits are enforced as one shared total against `max_guild_emojis`, and the four aliases exist only so an older client reads a plausible value
+
+2 Sticker limits are enforced as one shared total against `max_guild_stickers`
+
+Where a rule sets `max_guild_emojis`, `max_guild_stickers`, or one of the five compatibility aliases, it republishes that key in its own `overrides` even when the value matches the default.
+
+## Public application configuration object
+
+Branding, setup state, legal documents, and registration fields a client reads before an account signs in.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| branding | [public branding](#public-branding-object) object | Public instance identity |
+| setup | [public setup state](#public-setup-state-object) object | Initial configuration state |
+| legal | [public legal configuration](#public-legal-configuration-object) object | Registration legal document URLs |
+| registration | [public registration fields](#public-registration-fields-object) object | Registration field collection policy |
+
+## Public branding object
+
+The instance identity a client renders: its name, its images, and its theme colour. The image URLs customise browser metadata, install metadata, and link previews.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| product_name | string | The configured instance name a client displays |
+| icon_url | ?string | Full application icon URL, or null |
+| symbol_url | ?string | Compact application symbol URL, or null |
+| logo_url | ?string | Application logo URL, or null |
+| wordmark_url | ?string | Application wordmark URL, or null |
+| favicon_url | ?string | Browser favicon URL, or null |
+| theme_color | ?string | Browser theme colour, or null |
+
+## Public setup state object
+
+Whether the deployment has finished its initial configuration, and where an operator goes to continue it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| configured | boolean | Whether initial deployment configuration is complete |
+| admin_url1 | ?string | The admin application URL used to continue setup, or null |
+
+1 The value is the deployment's configured admin endpoint, and it is null when no admin endpoint is configured
+
+## Public legal configuration object
+
+Two public URLs a client links from its registration form.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| terms_url | ?string | Public terms of service URL, or null |
+| privacy_url | ?string | Public privacy policy URL, or null |
+
+## Public registration fields object
+
+Which extra fields public registration collects.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| collect_date_of_birth | boolean | Whether public registration collects and validates a date of birth |
+
+## Geolocation object
+
+The approximate location Fluxer resolved for the request, together with the regions where mature content is gated behind an age check or is unavailable.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| countryCode12 | ?string | The detected ISO 3166-1 alpha-2 country code, or null |
+| regionCode12 | ?string | The detected ISO 3166-2 subdivision code within the country, or null |
+| latitude13 | ?string | The approximate latitude, or null |
+| longitude13 | ?string | The approximate longitude, or null |
+| ageRestrictedGeos | array[[geo entry](#geo-entry-object) object] | Locations where mature content requires an age check |
+| ageBlockedGeos | array[[geo entry](#geo-entry-object) object] | Locations where mature content is unavailable |
+
+1 Every detected field is null when the deployment has configured no geolocation database, when the client address is not a valid IP address, when the database holds no record for it, or when the lookup fails. A record that resolves can still omit the subdivision code or the coordinate pair
+
+2 The value is upper case
+
+3 The value is a decimal string
+
+The two geo collections are fixed by the release, so they are identical in every response. A client resolves its own country and subdivision from the detected fields and then selects the matching entry itself.
+
+### Example
+
+```json
+{
+ "countryCode": "GB",
+ "regionCode": "ENG",
+ "latitude": "51.5074",
+ "longitude": "-0.1278",
+ "ageRestrictedGeos": [{"countryCode": "US", "regionCode": "TX"}],
+ "ageBlockedGeos": []
+}
+```
+
+## Geo entry object
+
+A geo entry names a whole country when its `regionCode` is null and names one subdivision otherwise.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| countryCode | string | The ISO 3166-1 alpha-2 country code |
+| regionCode | ?string | The ISO 3166-2 subdivision code within the country, or null when the entry covers the whole country |
+
+## Get instance discovery
+
+
+
+Returns the [instance discovery object](#instance-discovery-object) describing this deployment.
+
+The path has no `/v1` prefix.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [instance discovery](#instance-discovery-object) object | The discovery document was returned |
+
+### Rate limit
+
+60 requests per minute for each authenticated user, or for each client IP address when the request has no credential, on the `instance:info` bucket. [Get OpenAPI document](#get-openapi-document) draws on the same bucket, so the two operations share one allowance.
+
+## Get client geolocation
+
+
+
+Returns the [geolocation object](#geolocation-object) for the request's client address.
+
+The client address is the first hop of the deployment's configured client IP header, which defaults to `x-forwarded-for`. A lookup that resolves nothing still returns 200 with `countryCode`, `regionCode`, `latitude`, and `longitude` all null. A client MUST treat an all-null result as an unknown location.
+
+A resolved result is cached for ten minutes against the address. An IPv4 address is cached individually and an IPv6 address is cached by its `/64`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [geolocation](#geolocation-object) object | The location was resolved, wholly, partly, or not at all |
+
+### Rate limit
+
+30 requests per minute for each authenticated user, or for each client IP address when the request has no credential, on the `ip:geo_lookup` bucket.
+
+## Get OpenAPI document
+
+
+
+Returns the deployment's OpenAPI 3.1 document as JSON with `Content-Type: application/json; charset=utf-8`.
+
+The document has no `ETag`. It declares one server entry, the deployment's `api_client` value from the [instance endpoints object](#instance-endpoints-object) followed by `/v1`. Every path key is relative to that entry. The server entry is the only part that varies by deployment, and the rest is fixed when the document is generated.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | OpenAPI 3.1 document | The document was returned |
+
+### Rate limit
+
+60 requests per minute for each authenticated user, or for each client IP address when the request has no credential, on the `instance:info` bucket. The allowance is shared with [Get instance discovery](#get-instance-discovery).
diff --git a/fluxer_docs/src/content/docs/http-api/invites.mdx b/fluxer_docs/src/content/docs/http-api/invites.mdx
new file mode 100644
index 000000000..17a4e4657
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/invites.mdx
@@ -0,0 +1,428 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Invites
+description: Invite objects, invite metadata, custom invite URLs, and the invite operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+An invite is a code that admits an account into a guild or a group direct message.
+
+[Get invite](#get-invite) is the only route here that needs no credential. [Modify guild custom invite URL](/http-api/guilds/#modify-guild-custom-invite-url) configures a guild's custom invite URL, and [Discovery](/http-api/discovery/) defines joining an approved public guild with no invite.
+
+## Invite object
+
+An invite object describes one code and the target it admits into. The `type` selects the representation. A guild invite has the guild it admits into and that guild's online count. A group direct message invite exposes the group's current recipients through its partial channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | The unique code that identifies the invite |
+| type | integer | [Invite type](#invite-types) |
+| guild?1 | [partial guild](/http-api/guilds/#partial-guild-object) object | The guild the invite admits into |
+| channel?2 | [partial invite channel](#partial-invite-channel-object) object | The guild channel or group direct message the invite targets |
+| inviter3 | ?[partial user](/http-api/users/#partial-user-object) object | The account that created the invite, or null |
+| member_count?4 | integer | The member count of the guild, or the exact recipient count of the group direct message |
+| presence_count?4 | integer | The online member count of the guild |
+| expires_at5 | ?ISO8601 timestamp | The time at which the invite stops resolving, or null when it never expires |
+| temporary6 | boolean | Whether admission through this invite grants temporary membership |
+
+1 Present only when `type` is `0`, and it applies the same feature-gated asset stripping as the full [guild object](/http-api/guilds/#guild-object)
+
+2 Present when `type` is `0` or `1`. A guild invite with no stored target channel resolves the first guild text channel the default role can view
+
+3 Null when the record stores no creator, which is always the case for a custom invite URL
+
+4 A guild invite reports the counts the main Gateway currently holds, and both are `0` when it holds none
+
+5 Computed from the creation time plus the stored lifetime, and null whenever that lifetime is `0`
+
+6 Enforced only for a guild invite. A group direct message invite stores and reports the flag without ever reading it
+
+:::note[Branch on `type` to know which fields arrive]
+A guild invite always has `guild`, `channel`, `member_count`, and `presence_count`. A group direct message invite has `channel` and `member_count` only.
+:::
+
+### Example
+
+```json
+{
+ "code": "aB3dEf7h",
+ "type": 0,
+ "guild": {"id": "1489002177550843904", "name": "Example guild"},
+ "channel": {"id": "1489002177550843905", "name": "general", "type": 0},
+ "inviter": {"id": "1501314428688998182", "username": "example"},
+ "member_count": 412,
+ "presence_count": 87,
+ "expires_at": null,
+ "temporary": false
+}
+```
+
+## Invite types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | GUILD | An invite that admits an account into a guild |
+| 1 | GROUP_DM | An invite that admits an account into a group direct message |
+
+## Invite metadata object
+
+The metadata object extends the invite with its creation and usage state. Creation and listing operations return it when the caller has management access to the target, and [Get invite](#get-invite) never does.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| created_at | ISO8601 timestamp | The time at which the invite record was created |
+| uses1 | integer | The number of admissions completed through the invite |
+| max_uses2 | integer | The maximum admissions the invite permits, where `0` is unlimited |
+| max_age?3 | integer | The stored lifetime in seconds, where `0` never expires |
+
+1 The counter advances only when the admission actually adds the account, so accepting an invite for a target the account already belongs to leaves it unchanged
+
+2 The value is `0` for every custom invite URL and for any invite created without an explicit maximum
+
+3 Present only when `type` is `0`, because a group direct message invite reports its expiry solely through `expires_at`
+
+### Example
+
+```json
+{
+ "code": "aB3dEf7h",
+ "type": 0,
+ "created_at": "2026-08-04T11:22:19.000Z",
+ "uses": 3,
+ "max_uses": 25,
+ "max_age": 86400,
+ "temporary": false
+}
+```
+
+## Partial invite channel object
+
+The partial channel has only enough identity to describe the target before admission.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel |
+| name1 | ?string | The name of the channel, or null |
+| type | integer | [Channel type](/http-api/channels/#channel-types) |
+| recipients?2 | array[[partial invite recipient](#partial-invite-recipient-object) object] | The accounts currently in the group direct message |
+
+1 A guild channel always stores a name, and a group direct message that stores no name reports null
+
+2 Present only on a group direct message invite, and it lists every current recipient including the invite creator
+
+## Partial invite recipient object
+
+A partial recipient has only the username.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username | string | The username of the recipient |
+
+### Example
+
+```json
+{
+ "username": "example"
+}
+```
+
+## Custom invite URLs
+
+[Modify guild custom invite URL](/http-api/guilds/#modify-guild-custom-invite-url) creates and replaces a custom invite code and stores it as a guild invite record of type `0`. It has no fixed target channel, creator, maximum uses, or lifetime, so a resolved custom invite URL reports a null `inviter`, a null `expires_at`, `max_uses` of `0`, and `max_age` of `0`. Its channel is the first guild text channel in channel order that the default role can view.
+
+A custom invite code is stored lowercased, so [Get invite](#get-invite) and [Accept invite](#accept-invite) resolve it in any case through their lowercase retry. Clearing or replacing the code deletes the invite record outright, and a released code stops resolving immediately.
+
+[Accept invite](#accept-invite) records the join as a custom invite URL admission and attributes no inviter and no source invite code. [List channel invites](#list-channel-invites) and [List guild invites](#list-guild-invites) exclude custom invite URLs, and [Delete invite](#delete-invite) refuses them.
+
+## Get invite
+
+
+
+Reads one invite. Returns an [invite object](#invite-object) on success.
+
+An `Authorization` header that cannot be resolved is ignored, and the route returns the same representation to every caller. The read consumes no use.
+
+Fluxer retries a lookup that misses once against the lowercase form of the supplied code. A code allocated by [Create channel invite](#create-channel-invite) is drawn from a mixed-case alphanumeric alphabet and is matched exactly.
+
+:::note[Every admission gate runs at accept time]
+Resolving an invite proves that the record exists and that its target is describable. [Accept invite](#accept-invite) evaluates bans, disabled invites, exhausted uses, missing presence, and target capacity.
+:::
+
+:::note[Expiry removes the record from storage]
+A record's storage lifetime equals `max_age`. An exhausted record survives until an acceptance attempt or an explicit deletion removes it.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| invite_code1 | string | The invite code to resolve (1 through 256 characters) |
+
+1 Normalised before lookup by removing form feed and right-to-left override characters and trimming, and a value empty or above 256 characters afterwards returns the field code `STRING_LENGTH_INVALID`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [invite](#invite-object) object | Invite resolved |
+| 404 | [error response](/http-api/#error-response) | No record has the code, a guild invite names no guild, a group direct message invite names no channel or unresolvable recipient, or a guild invite with no stored target channel exposes no viewable channel, each returning `UNKNOWN_INVITE` |
+| 404 | [error response](/http-api/#error-response) | The stored target channel no longer exists and the request returns `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The named guild no longer exists and the request returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+100 requests per 10 seconds for each identity and invite code, on the `invite:read::invite_code` bucket, where the identity is the authenticated account when a credential resolves and otherwise the derived key for the client IP address.
+
+## Accept invite
+
+
+
+Accepts an invite and admits the authenticated account into its target. Returns an [invite object](#invite-object) on success.
+
+A guild admission emits [Guild Create](/gateway/events/#guild-create), [Guild Member Add](/gateway/events/#guild-member-add), [User Settings Update](/gateway/events/#user-settings-update), [User Guild Settings Update](/gateway/events/#user-guild-settings-update), and [Message Create](/gateway/events/#message-create) Gateway events. A group direct message admission emits [Channel Create](/gateway/events/#channel-create), [Channel Recipient Add](/gateway/events/#channel-recipient-add), and [Message Create](/gateway/events/#message-create) Gateway events.
+
+### Limitations
+
+- User-only, so a bot token is rejected with 403 `ACCESS_DENIED`.
+- The caller needs no permission.
+
+:::caution[Exhausting an invite deletes it]
+An acceptance attempt against an invite whose maximum uses are already spent deletes the record before the request fails with 404 `UNKNOWN_INVITE`, so the failing caller removes it. A custom invite URL is never deleted this way.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| invite_code | string | The invite code to accept (1 through 256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [invite](#invite-object) object | Account was admitted, or was already a member of the target |
+| 400 | [error response](/http-api/#error-response) | A temporary guild invite is accepted without an active presence and the request returns `TEMPORARY_INVITE_REQUIRES_PRESENCE` |
+| 400 | [error response](/http-api/#error-response) | An unclaimed account accepts a group direct message invite and the request returns `UNCLAIMED_ACCOUNT_CANNOT_JOIN_GROUP_DMS` |
+| 400 | [error response](/http-api/#error-response) | A type `1` invite names a private channel that is not a group direct message and the caller is not already a recipient and the request returns `INVALID_CHANNEL_TYPE` |
+| 400 | [error response](/http-api/#error-response) | The caller already holds the maximum number of guilds and the request returns `MAX_GUILDS` |
+| 400 | [error response](/http-api/#error-response) | The target is full and the request returns `MAX_GUILD_MEMBERS` or `MAX_GROUP_DM_RECIPIENTS` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The guild has the invites-disabled feature and the request returns `INVITES_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | Instant invites are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | The caller is banned by account or email address and the request returns `USER_BANNED_FROM_GUILD` |
+| 403 | [error response](/http-api/#error-response) | The caller is banned by network address and the request returns `USER_IP_BANNED_FROM_GUILD` |
+| 403 | [error response](/http-api/#error-response) | A deferred phone verification requirement becomes due and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 404 | [error response](/http-api/#error-response) | The named guild no longer exists and the request returns `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The target channel no longer exists and the request returns `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The code resolves to no record, the record is exhausted, or the invite names no reachable target, each returning `UNKNOWN_INVITE` |
+
+### Side effects
+
+An account already in the target receives the invite unchanged without consuming a use or emitting a Dispatch. Otherwise `uses` advances exactly once after admission completes, and the record is deleted when that advance spends its last use. A failed admission consumes no use. Fluxer builds the response body after the admission has committed, through the same target resolution [Get invite](#get-invite) performs. A guild invite whose stored target channel has since been deleted therefore admits the account and advances `uses` before the request returns 404 `UNKNOWN_CHANNEL`.
+
+A guild admission creates the membership, records whether the join used a custom invite URL or an instant invite, adds the guild to the caller's settings and folder layout, and marks the membership temporary when the invite is temporary.
+
+The joining account receives [Guild Create](/gateway/events/#guild-create). [Guild Member Add](/gateway/events/#guild-member-add) is guild-wide and reaches every session connected to the guild, subject to the [event filtering](/gateway/event-filtering/) gates, which suppress it for a passive session in a large guild. The joining account receives [User Settings Update](/gateway/events/#user-settings-update) when the join changes its stored settings, and [User Guild Settings Update](/gateway/events/#user-guild-settings-update) when its `default_hide_muted_channels` setting is enabled. Unless join notifications are suppressed or no system channel exists, the operation creates a join system message and delivers [Message Create](/gateway/events/#message-create).
+
+A group direct message admission adds the caller as a recipient and creates a recipient addition system message. The joining account receives [Channel Create](/gateway/events/#channel-create), the recipients the group already held receive [Channel Recipient Add](/gateway/events/#channel-recipient-add), and every recipient receives [Message Create](/gateway/events/#message-create).
+
+No admission path records a guild audit entry.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user, on the `invite:accept` bucket, which is not partitioned by invite code.
+
+## Delete invite
+
+
+
+Deletes an invite permanently and returns 204 with an empty body. Emits an [Invite Delete](/gateway/events/#invite-delete) Gateway event.
+
+### Limitations
+
+- A guild invite requires guild membership.
+- The invite creator deletes it without holding [MANAGE_GUILD](/http-api/permissions/), and any other member needs that permission.
+- A group direct message invite requires the caller to be a current recipient and the owner of the group.
+
+`MANAGE_GUILD` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller who is neither the guild owner nor enrolled in multi-factor authentication receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated.
+
+:::note[Non-membership and missing permission return the same 403]
+A non-member and a member without the permission both receive 403 `MISSING_PERMISSIONS`. A guild that no longer exists returns 404 `UNKNOWN_GUILD`.
+:::
+
+:::caution[Deletion is immediate and unrecoverable]
+The invite code can be allocated again later, and every outstanding link with that code stops resolving immediately.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| invite_code | string | The invite code to delete (1 through 256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Invite was deleted |
+| 400 | [error response](/http-api/#error-response) | The caller is not the creator and the guild requires elevated multi-factor authentication that the caller lacks, and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Caller presents a bearer credential and the request returns `ACCESS_DENIED`, or the caller is not a member of the guild, is neither the creator nor a holder of [MANAGE_GUILD](/http-api/permissions/), or does not own the group direct message, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | The named guild no longer exists and the request returns `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | No record has the code, the record is the guild's current custom invite URL, or a guild invite names no target, each returning `UNKNOWN_INVITE` |
+| 404 | [error response](/http-api/#error-response) | The group direct message no longer exists or the caller is not one of its recipients and the request returns `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+A guild invite records an [INVITE_DELETE audit action](/http-api/guild-audit-logs/#audit-actions) targeting the invite code, with the channel, creator, maximum uses, lifetime, and temporary flag as metadata, the removed record as the change set, and the supplied reason. Fluxer logs a failure to write that entry and still completes the request. The `X-Audit-Log-Reason` header is read but never recorded for a group direct message invite.
+
+Sessions holding `VIEW_AUDIT_LOG` receive [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). [Invite Delete](/gateway/events/#invite-delete) has only `code`, `channel_id`, and `guild_id`. The guild delivers it to the sessions holding `MANAGE_CHANNELS` on the channel named by `channel_id`, as [event filtering](/gateway/event-filtering/) states. A passive session in a guild with more than 250 members is suppressed after that filter. A group direct message invite creates no audit entry and delivers Invite Delete to every current recipient's sessions, where no passive gate applies.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and invite code, on the `invite:delete::invite_code` bucket.
+
+## Create channel invite
+
+
+
+Creates an invite for a channel, or returns an existing equivalent invite. Returns an [invite metadata object](#invite-metadata-object) on success. Emits an [Invite Create](/gateway/events/#invite-create) Gateway event when a new record is created.
+
+### Limitations
+
+- A guild channel requires guild membership, [VIEW_CHANNEL](/http-api/permissions/), and [CREATE_INSTANT_INVITE](/http-api/permissions/) in that exact channel.
+- An age-restricted guild channel additionally requires an age-verified account.
+- A private channel requires the caller to be a current recipient, and no ownership is required.
+
+`CREATE_INSTANT_INVITE` is read directly from the main Gateway, so this operation never requires elevated multi-factor authentication.
+
+:::note[Guild affiliation determines the invite type]
+A channel that belongs to a guild creates a guild invite. Any other channel the caller can resolve creates a type `1` invite, including a direct message and the personal notes channel. Only a group direct message admits an account afterwards.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild channel or private channel |
+
+### JSON body
+
+Every field is optional and nullable, and an omitted or null value takes the stated default.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| max_uses?1 | ?integer | The maximum number of admissions (0-100, default 0, where 0 is unlimited) |
+| max_age?1 | ?integer | The lifetime in seconds (0-604800, default 0, where 0 never expires) |
+| unique?2 | ?boolean | Whether to create a new invite even when an equivalent one already exists (default false) |
+| temporary?3 | ?boolean | Whether admission grants temporary membership (default false) |
+
+1 A negative value, a `max_uses` above 100, or a `max_age` above 604,800 seconds is rejected with 400 `INVALID_FORM_BODY` on that field
+
+2 When false or omitted the operation returns an existing invite whose creator, target channel, maximum uses, lifetime, and temporary flag all match the request exactly. A custom invite URL never matches
+
+3 Stored and never enforced on a private channel invite
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [invite metadata](#invite-metadata-object) object | Invite was created, or an equivalent existing invite was returned |
+| 400 | [error response](/http-api/#error-response) | The guild already holds its maximum number of invites and the request returns `MAX_INVITES` |
+| 403 | [error response](/http-api/#error-response) | Caller presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The guild is unavailable to the caller and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | Instant invites are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | The caller is not a guild member or lacks [VIEW_CHANNEL](/http-api/permissions/) or [CREATE_INSTANT_INVITE](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The channel is age restricted and the account is not age verified and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, is pending deletion, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild the channel belongs to no longer exists and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+Returning an existing equivalent invite changes nothing, records no audit entry, and emits no Dispatch.
+
+Creating an invite allocates a random eight-character code drawn from the mixed-case alphanumeric alphabet. A guild invite records an [INVITE_CREATE audit action](/http-api/guild-audit-logs/#audit-actions) targeting the invite code, with the channel, creator, maximum uses, lifetime, and temporary flag as metadata and the supplied reason, then delivers [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding `VIEW_AUDIT_LOG`. Fluxer logs a failure to write that entry and still completes the request. The `X-Audit-Log-Reason` header is read but never recorded for a private channel.
+
+The guild delivers [Invite Create](/gateway/events/#invite-create) to the sessions holding `MANAGE_CHANNELS` on the invite's channel, as [event filtering](/gateway/event-filtering/) states. The payload has no `channel_id` member, so that filter reads the channel from the nested `channel.id`. A passive session in a guild with more than 250 members is suppressed after that filter. A private channel invite records no audit entry and delivers Invite Create to every current recipient's sessions.
+
+The guild invite ceiling is the configured `max_guild_invites` value for the guild's feature set, which defaults to 1,000. It counts every guild invite across all channels, including the custom invite URL record, and Fluxer compares it before writing a new record.
+
+### Rate limit
+
+20 requests per minute for each authenticated user and channel ID, on the `invite:create::channel_id` bucket.
+
+## List channel invites
+
+
+
+Returns the standard invites of one channel as an array of [invite metadata objects](#invite-metadata-object), ordered by descending creation time.
+
+### Limitations
+
+- A guild channel requires guild membership, [VIEW_CHANNEL](/http-api/permissions/) in that channel, and [MANAGE_CHANNELS](/http-api/permissions/) in the guild.
+- An age-restricted guild channel additionally requires an age-verified account.
+- A group direct message requires the caller to be a current recipient and its owner.
+
+`MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller who is neither the guild owner nor enrolled in multi-factor authentication receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated.
+
+The operation is not paginated. Every matching record is returned in one response, bounded by the guild invite ceiling described by [Create channel invite](#create-channel-invite). The response excludes the custom invite URL record.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild channel or group direct message |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[invite metadata](#invite-metadata-object) object] | Invites were returned, and the array is empty when the channel holds none |
+| 400 | [error response](/http-api/#error-response) | The guild requires elevated multi-factor authentication that the caller lacks and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Caller presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The guild is unavailable to the caller and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The caller is not a guild member, lacks [VIEW_CHANNEL](/http-api/permissions/) or [MANAGE_CHANNELS](/http-api/permissions/), or does not own the group direct message and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The channel is age restricted and the account is not age verified and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, is pending deletion, the caller is not a recipient of the private channel, or the private channel is not a group direct message, each returning `UNKNOWN_CHANNEL`, or the guild the channel belongs to no longer exists and the request returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and channel ID, on the `invite:list::channel_id` bucket.
+
+## List guild invites
+
+
+
+Returns the standard invites of one guild across all of its channels as an array of [invite metadata objects](#invite-metadata-object), ordered by descending creation time. Requires guild membership and [MANAGE_GUILD](/http-api/permissions/).
+
+`MANAGE_GUILD` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller who is neither the guild owner nor enrolled in multi-factor authentication receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated.
+
+The operation is not paginated. Every matching record is returned in one response, bounded by the guild invite ceiling described by [Create channel invite](#create-channel-invite). The response excludes the custom invite URL record, so a client reads the current custom invite code through [Get guild custom invite URL](/http-api/guilds/#get-guild-custom-invite-url).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[invite metadata](#invite-metadata-object) object] | Invites were returned, and the array is empty when the guild holds none |
+| 400 | [error response](/http-api/#error-response) | The guild requires elevated multi-factor authentication that the caller lacks and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Caller presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The guild is unavailable to the caller and the request returns `MISSING_ACCESS` |
+| 403 | [error response](/http-api/#error-response) | The caller is not a member of the guild or lacks [MANAGE_GUILD](/http-api/permissions/) and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the `invite:list::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/memes.mdx b/fluxer_docs/src/content/docs/http-api/memes.mdx
new file mode 100644
index 000000000..a70524024
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/memes.mdx
@@ -0,0 +1,458 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Memes
+description: Saved favourite media and batch GIF URL resolution.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A meme is an image, video, or audio asset the current account has saved as a favourite. Every meme is a durable Fluxer copy held as an account-owned attachment, so it keeps working after the URL or message it came from disappears.
+
+The routes here are user-only and operate on the caller's own collection. Fluxer rejects a bot token with 403 `ACCESS_DENIED`. [Create message](/http-api/messages/#create-message) sends a stored meme through its `favorite_meme_id` field, and the [GIF provider API](/http-api/gifs/) searches the configured provider for a GIF to save.
+
+## Collection limits
+
+Fluxer caps a collection at the instance-configured `max_favorite_memes` [limit](/http-api/instance/#limit-keys), with a fallback of 50. Both creation operations check the count before they touch the channel, the message, or the source URL, so an account at its limit is refused with 400 `MAX_FAVORITE_MEMES` even when the rest of the request would have failed. The error body has `max_favorite_memes` reporting the ceiling reached.
+
+A meme has at most `max_favorite_meme_tags` tags, with a fallback of 10. A request that exceeds the allowance returns 400 `INVALID_FORM_BODY` with the validation code `MAX_FAVORITE_MEME_TAGS_EXCEEDED` at the `tags` path.
+
+Fluxer normalises and trims a tag before counting it, and the result must be 1 through 30 characters. A tag that normalises to the empty string fails with 400 `INVALID_FORM_BODY` and `STRING_LENGTH_INVALID`.
+
+## Deduplication
+
+A meme is identified by the content hash of its stored bytes. The same bytes reached through a different URL or a different message are still a duplicate. Saving an asset whose hash already exists in the account's collection returns 400 `INVALID_FORM_BODY` with the validation code `MEDIA_ALREADY_IN_FAVORITE_MEMES` at the `media` path. The existing meme is not returned and nothing is created.
+
+## Meme object
+
+One media asset owned by one account. Fluxer resolves every media field at creation, and only the name, alt text, and tags change afterwards.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the meme |
+| user_id | snowflake | The ID of the owning account |
+| name1 | string | The display name of the meme (1-100 characters) |
+| alt_text2 | ?string | The accessibility description of the media, or null when none is set |
+| tags | array[string] | The search tags stored on the meme, each 1-30 characters |
+| attachment_id3 | snowflake | The ID of the attachment holding the durable Fluxer copy |
+| filename | string | The filename of the stored copy |
+| content_type | string | The MIME type of the stored copy |
+| content_hash | ?string | The content hash used for [deduplication](#deduplication), or null when it is unavailable |
+| size | integer | The stored size of the copy in bytes |
+| width | ?integer | The width of the media in pixels, or null when inapplicable or unknown |
+| height | ?integer | The height of the media in pixels, or null when inapplicable or unknown |
+| duration | ?number | The duration of the media in seconds, or null when inapplicable or unknown |
+| url3 | string | The Fluxer attachment URL of the stored copy |
+| is_gifv4 | boolean | Whether the stored media is animated or a video converted from a GIF |
+| gif_slug5 | ?string | The provider-issued slug this meme was sourced from, or null |
+| gif_provider5 | ?string | The name of the GIF provider that issued `gif_slug`, or null |
+| media6 | ?map[string, [GIF media format](#gif-media-format-object) object] | The provider format map for a GIF-sourced meme, or null |
+| placeholder | ?string | The compact thumbhash placeholder recorded at save time, or null |
+
+1 Required by [Save meme from message](#save-meme-from-message). [Save meme from URL](#save-meme-from-url) derives it from `filename`, truncated to 100 characters, when it is omitted or null
+
+2 [Save meme from message](#save-meme-from-message) falls back to the selected attachment's own description when the body supplies no non-empty alt text
+
+3 `url` is the media endpoint followed by `attachments/{user_id}/{attachment_id}/{filename}`
+
+4 Set when the source attachment or embed was already animated, and when the Media Proxy reports the stored copy as animated
+
+5 Both fields are null together. `klipy` is the only value a new meme records, and `tenor` appears on legacy records alone
+
+6 Keyed by [GIF media format name](#gif-media-format-names). Persisted only by [Save meme from URL](#save-meme-from-url), and null on every message-sourced meme
+
+A message-sourced meme records a slug only when the selected embed is a `gifv` embed whose URL the configured provider recognises. An attachment selection never records one.
+
+The creation time is the timestamp embedded in `id`, as defined by [Snowflakes](/snowflakes/).
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "user_id": "1501314428688990000",
+ "name": "party_parrot.gif",
+ "alt_text": null,
+ "tags": ["parrot", "party"],
+ "attachment_id": "1501314428688998183",
+ "filename": "party_parrot.gif",
+ "content_type": "image/gif",
+ "size": 40213,
+ "width": 320,
+ "height": 240,
+ "is_gifv": true,
+ "gif_slug": "party-parrot-1234",
+ "gif_provider": "klipy",
+ "placeholder": "3PcNNYYONWQPFxrsUeUcRh4"
+}
+```
+
+## GIF media format object
+
+One descriptor describes one encoding of one provider GIF. A provider returns several for the same GIF so a client can pick the smallest encoding it can decode.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| src | string | The direct media URL of this format |
+| proxy_src | string | The [Media Proxy](/media-proxy/overview/) URL of this format |
+| width1 | integer | The width of this format in pixels |
+| height1 | integer | The height of this format in pixels |
+
+1 A non-negative value of at most 2147483647. A descriptor with an empty `src` or `proxy_src`, or a zero dimension, is unusable as a preview and is skipped
+
+A client submitting a format map to [Save meme from URL](#save-meme-from-url) supplies all four members of every descriptor, and a descriptor missing one fails validation with 400 `INVALID_FORM_BODY`.
+
+## GIF media format names
+
+| Value | Description |
+| --- | --- |
+| webm | Full-size WebM video |
+| mp4 | Full-size MP4 video |
+| webp | Full-size WebP image |
+| gif | Full-size GIF image |
+| mediumwebm | Medium WebM video |
+| mediummp4 | Medium MP4 video |
+| mediumwebp | Medium WebP image |
+| mediumgif | Medium GIF image |
+| tinywebm | Small WebM video |
+| tinymp4 | Small MP4 video |
+| tinywebp | Small WebP image |
+| tinygif | Small GIF image |
+| nanowebm | Smallest WebM video |
+| nanomp4 | Smallest MP4 video |
+| nanowebp | Smallest WebP image |
+| nanogif | Smallest GIF image |
+| loopedmp4 | Looping MP4 video |
+
+A map contains only the formats the provider returned, so no key is guaranteed to be present. A client walks a priority list until it finds a key the map has. A client that cannot decode the video keys SHOULD prefer `tinywebp`, `tinygif`, `mediumwebp`, `mediumgif`, `webp`, `gif`, `nanowebp`, and `nanogif`, in that order.
+
+## Resolved GIF entry object
+
+One entry describes one URL submitted to [Resolve GIF URLs](#resolve-gif-urls).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | The source GIF URL |
+| proxy_url | string | The signed [Media Proxy](/media-proxy/overview/) URL for the preview format |
+| width2 | integer | The width of the preview format in pixels |
+| height2 | integer | The height of the preview format in pixels |
+| media3 | map[string, [GIF media format](#gif-media-format-object) object] | The formats available for the URL, keyed by [GIF media format name](#gif-media-format-names) |
+| content_type4 | string | The MIME type of the media addressed by `proxy_url` |
+| placeholder | ?string | The compact thumbhash placeholder, or null when the Media Proxy emitted none |
+
+1 The provider's own canonical URL when the submitted URL resolved through the configured GIF provider, and the submitted URL otherwise
+
+2 Zero when the dimensions could not be determined
+
+3 Empty when the URL belongs to no registered GIF provider and no single direct format could be derived from it
+
+4 The empty string means the type is unknown, and a client treats that as `image/gif`
+
+A direct format is derived only for `video/webm`, `video/mp4`, `image/webp`, and `image/gif`, and only when both dimensions are positive.
+
+The preview format is the first usable descriptor in the order `tinywebm`, `tinymp4`, `mediumwebm`, `mediummp4`, `webm`, `mp4`, `loopedmp4`, `nanowebm`, `nanomp4`, `tinywebp`, `tinygif`, `mediumwebp`, `webp`, `mediumgif`, `gif`, `nanowebp`, `nanogif`. When no key in that list is usable, Fluxer takes the first usable descriptor in map order. When the map holds nothing usable, it takes the provider's own top-level media.
+
+## List memes
+
+
+
+Returns every [meme object](#meme-object) the current account owns, in descending meme ID order, so the most recently saved comes first.
+
+The account's [collection limit](#collection-limits) bounds how many entries the array holds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[meme](#meme-object) object] | Collection was returned, possibly as an empty array |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `favorite_meme:list` bucket.
+
+## Save meme from URL
+
+
+
+Fetches an absolute media URL, stores a durable Fluxer copy, and returns the created [meme object](#meme-object). Emits a [Favorite Meme Create](/gateway/events/#favorite-meme-create) Gateway event.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url | string | The absolute media URL to fetch and store |
+| name?1 | ?string | The display name of the meme (1-100 characters) |
+| alt_text? | ?string | The accessibility description of the media (0-500 characters) |
+| tags?2 | ?array[string] | The search tags to store, each 1-30 characters |
+| gif_slug?3 | ?string | The provider-issued slug or slug-id token for the GIF (1-300 characters) |
+| gif_provider?3 | ?string | The stable name of the provider that issued `gif_slug` (1-32 characters) |
+| media?4 | ?map[string, [GIF media format](#gif-media-format-object) object] | The provider format map captured by the client at save time |
+
+1 When omitted or null, the name is the stored `filename` truncated to 100 characters
+
+2 An omitted or null value is an empty tag list
+
+3 Honoured together only when `gif_provider` names the configured provider. Otherwise Fluxer extracts a slug from `url` with the configured provider
+
+4 Persisted only when the request resolved to a provider GIF and the map is non-empty, and discarded otherwise
+
+The stored filename is the last path segment of `url` after percent-decoding. Fluxer appends an extension inferred from the resolved content type when that segment has no dot, then normalises the result so whitespace becomes `_` and every character outside letters, digits, combining marks, `_`, `.`, and `-` is dropped. A URL path with no last segment yields `media.{extension}`. A segment that normalises to nothing, or to only dots and underscores, yields `unnamed`, and the extension is `bin` when the content type maps to none.
+
+The stored `gif_provider` is always the configured provider's own name, so new provider GIFs are sourced from KLIPY even when the request names another provider.
+
+Fluxer fetches the asset and resolves its metadata before it stores anything. A URL that resolves to no usable media fails with 400 `MEDIA_METADATA_ERROR`. When the request resolves to a provider GIF, the provider's canonical share URL is unfurled and the video content hash it reports replaces the fetched hash for [deduplication](#deduplication).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | [meme](#meme-object) object | Meme was created |
+| 400 | [error response](/http-api/#error-response) | Body fails validation with `INVALID_FORM_BODY`5 |
+| 400 | [error response](/http-api/#error-response) | The URL resolves to no usable media with `MEDIA_METADATA_ERROR` |
+| 400 | [error response](/http-api/#error-response) | The collection is at its limit with `MAX_FAVORITE_MEMES` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+5 Including `MAX_FAVORITE_MEME_TAGS_EXCEEDED` at the `tags` path and `MEDIA_ALREADY_IN_FAVORITE_MEMES` at the `media` path
+
+### Side effects
+
+The meme is created against a new attachment of its own holding the fetched bytes.
+
+The operation emits one [Favorite Meme Create](/gateway/events/#favorite-meme-create) with the created object to the caller's own sessions.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `favorite_meme:create:url` bucket.
+
+## Save meme from message
+
+
+
+Copies one attachment or one embed asset from a readable message into the caller's own collection and returns the created [meme object](#meme-object). Requires [VIEW_CHANNEL](/http-api/permissions/) in a guild channel. Emits a [Favorite Meme Create](/gateway/events/#favorite-meme-create) Gateway event.
+
+A guild caller without [READ_MESSAGE_HISTORY](/http-api/permissions/) can select media only from a message on or after the guild's message history cutoff. Attachments and embeds in the message's forwarded snapshots are selectable alongside its own.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel containing the message |
+| message_id | snowflake | The ID of the message containing the selected media |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | The display name of the meme (1-100 characters) |
+| attachment_id?1 | ?snowflake | The ID of an attachment in the message or one of its snapshots |
+| embed_index?12 | ?integer | The zero-based index of an embed in the message or one of its snapshots |
+| alt_text?3 | ?string | The accessibility description of the media (0-500 characters) |
+| tags? | ?array[string] | The search tags to store, each 1-30 characters |
+
+1 At least one of the two keys must be present, and a body with neither fails validation. Both may be present as null, which selects nothing explicitly
+
+2 `embed_index` takes precedence when both have a value, and an index outside the combined embed list fails with `EMBED_INDEX_OUT_OF_BOUNDS`
+
+3 Taken from the selected attachment's own description whenever the supplied value is omitted, null, empty, or whitespace only
+
+When neither key selects anything, Fluxer takes the message's first attachment. It takes the first usable embed when the message has no attachment, or when that attachment has no supported media type. An embed selection has no alt text of its own.
+
+Only an `image/*`, `video/*`, or `audio/*` asset can be saved. `attachment_id` is matched only when the message or one of its snapshots has at least one attachment, and an ID matching none of them fails with `ATTACHMENT_ID_NOT_FOUND_IN_MESSAGE` at the `attachment_id` path. When the message has no attachment at all, Fluxer ignores the ID and takes the first usable embed. Only the selected attachment is tried, so a message whose chosen attachment has an unsupported type falls through to its embeds. A selection that resolves to no supported media fails with `NO_VALID_MEDIA_IN_MESSAGE` at the `media` path.
+
+An embed selection uses the embed's image, video, or thumbnail, in that order. A URL the instance's own media endpoint serves is copied without a fetch. Any other URL is fetched over the network, and a fetch that resolves no metadata fails with 400 `MEDIA_METADATA_ERROR`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | [meme](#meme-object) object | Meme was created |
+| 400 | [error response](/http-api/#error-response) | Body or selection fails validation with `INVALID_FORM_BODY`4 |
+| 400 | [error response](/http-api/#error-response) | External media metadata cannot be resolved with `MEDIA_METADATA_ERROR` |
+| 400 | [error response](/http-api/#error-response) | The collection is at its limit with `MAX_FAVORITE_MEMES` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The caller is not a member of the guild or lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The channel requires age verification the account has not completed and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel is unavailable or the caller is not a recipient of it with `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The message is unavailable or unreadable with `UNKNOWN_MESSAGE` |
+
+4 The validation code is `ATTACHMENT_ID_NOT_FOUND_IN_MESSAGE`, `EMBED_INDEX_OUT_OF_BOUNDS`, `NO_VALID_MEDIA_IN_MESSAGE`, `MEDIA_ALREADY_IN_FAVORITE_MEMES`, or `MAX_FAVORITE_MEME_TAGS_EXCEEDED`
+
+:::note[Missing message history reads as an unknown message]
+A guild caller that can view the channel but lacks `READ_MESSAGE_HISTORY` receives 404 `UNKNOWN_MESSAGE` for a message before the guild's history cutoff, and for every message when the guild has no cutoff. This matches [Get message](/http-api/messages/#get-message).
+:::
+
+### Side effects
+
+Fluxer copies the selected asset into the account's own attachment space under a new attachment ID and creates the meme record against that attachment. Reading the source message renews its attachment decay deadline when attachment decay is enabled. The source message, its attachments, and its embeds are untouched.
+
+The operation emits one [Favorite Meme Create](/gateway/events/#favorite-meme-create) with the created object to the caller's own sessions.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `favorite_meme:create:message` bucket.
+
+## Get meme
+
+
+
+Returns one [meme object](#meme-object) owned by the current account.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| meme_id | snowflake | The ID of the meme |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [meme](#meme-object) object | Meme was returned |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 404 | [error response](/http-api/#error-response) | Meme is unknown or belongs to another account with `UNKNOWN_FAVORITE_MEME` |
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user, on the `favorite_meme:get` bucket.
+
+## Modify meme
+
+
+
+Changes the name, alt text, or tags of one meme owned by the current account and returns the updated [meme object](#meme-object). Emits a [Favorite Meme Update](/gateway/events/#favorite-meme-update) Gateway event.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| meme_id | snowflake | The ID of the meme |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | string | The replacement display name (1-100 characters) |
+| alt_text?1 | ?string | The replacement accessibility description (0-500 characters), or null to clear it |
+| tags?2 | ?array[string] | The replacement tags, each 1-30 characters |
+
+1 An omitted field leaves the stored value unchanged, and an explicit null clears it
+
+2 An omitted or null value leaves the stored tags unchanged
+
+:::caution[Supplying `tags` overwrites the complete tag list]
+An empty array clears every tag. Omit the field, or send null, to leave the stored tags untouched.
+:::
+
+Fluxer checks the tag allowance against the resulting list, so a request omitting `tags` re-checks the tags already stored. A meme that exceeds a since-lowered allowance therefore fails with 400 `INVALID_FORM_BODY` and `MAX_FAVORITE_MEME_TAGS_EXCEEDED` until the caller supplies a shorter list.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [meme](#meme-object) object | Meme was updated |
+| 400 | [error response](/http-api/#error-response) | Path, name, alt text, or tag length fails validation with `INVALID_FORM_BODY`3 |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 404 | [error response](/http-api/#error-response) | Meme is unknown or belongs to another account with `UNKNOWN_FAVORITE_MEME` |
+
+3 Including `MAX_FAVORITE_MEME_TAGS_EXCEEDED` at the `tags` path
+
+### Side effects
+
+The operation changes the supplied fields and leaves every omitted field unchanged. It emits one [Favorite Meme Update](/gateway/events/#favorite-meme-update) with the updated object to the caller's own sessions.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `favorite_meme:update` bucket.
+
+## Delete meme
+
+
+
+Deletes one meme owned by the current account and returns 204 with an empty body. Emits a [Favorite Meme Delete](/gateway/events/#favorite-meme-delete) Gateway event.
+
+An unknown meme, or one owned by another account, is a successful no-op.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| meme_id | snowflake | The ID of the meme |
+
+:::danger[Deleting a meme destroys its stored copy]
+Every meme owns its stored bytes, so deletion removes them permanently. The meme cannot be restored, and saving the same source again creates a new meme with a new ID and a new attachment ID.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Meme was deleted, or no owned meme existed |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Side effects
+
+The operation removes the meme record and permanently deletes its stored copy. A storage failure is logged and does not fail the request, so the record is removed either way.
+
+It emits one [Favorite Meme Delete](/gateway/events/#favorite-meme-delete) with `meme_id` to the caller's own sessions. A request naming a meme the account does not own emits nothing.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `favorite_meme:delete` bucket.
+
+## Resolve GIF URLs
+
+
+
+Resolves a batch of GIF URLs into [resolved GIF entry objects](#resolved-gif-entry-object) with signed [Media Proxy](/media-proxy/overview/) URLs and preview metadata.
+
+A client uses it to turn stored URL-only favourite GIFs into renderable picker entries without saving anything.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Accept-Language?1 | string | The preferred locale used for a GIF provider lookup |
+
+1 Resolved against the [supported locale registry](/topics/locales/#supported-locales), superseded by the account's own stored locale, and defaulting to `en-US`
+
+Fluxer derives a two-letter country from the requesting address by geolocation, with `US` as the fallback.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| urls | array[string] | The absolute GIF URLs to resolve (1-200) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| entries1 | array[[resolved GIF entry](#resolved-gif-entry-object) object] | One entry for each submitted URL |
+
+1 Exactly one entry per submitted URL, in the submitted order, so a client may index the result positionally against its request
+
+Fluxer resolves a URL in three stages. It offers the URL to the configured GIF provider, then reads it as direct external media, and then unfurls it as a page when the direct read produced no renderable image or video. A failure in any stage downgrades that URL's entry alone. A URL that none of the three stages resolves still returns an entry, with its signed proxy URL, an empty `media` map, and whatever the direct read reported. Dimensions are zero and `content_type` is the empty string when the direct read reported nothing.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Every URL was resolved |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Rate limit
+
+30 requests per minute for each authenticated user, on the `favorite_gif:resolve` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/messages.mdx b/fluxer_docs/src/content/docs/http-api/messages.mdx
new file mode 100644
index 000000000..cf4a8a056
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/messages.mdx
@@ -0,0 +1,2114 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Messages
+description: Message objects, attachments, history, acknowledgements, pins, reactions, and typing.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A message is one post in a channel, together with the text, attachments, embeds, stickers, and reactions stored with it. Channel metadata and membership are defined by the [Channels resource](/http-api/channels/).
+
+Two terms recur below. A text-bearing channel is a [channel type](/http-api/channels/#channel-types) that stores messages, which is every type except a guild category and a guild link channel. The message history cutoff is an instant a guild stores on itself, and a member without [READ_MESSAGE_HISTORY](/http-api/permissions/) reads nothing created before it.
+
+## Channel resolution
+
+Fluxer resolves the channel before it applies any per-route authorisation. A private channel returns 404 `UNKNOWN_CHANNEL` to a caller who is not a recipient. A guild channel returns 403 `MISSING_PERMISSIONS` to a non-member and to a member without [VIEW_CHANNEL](/http-api/permissions/). A guild an operator has marked unavailable returns 403 `MISSING_ACCESS` before the route runs, and a guild that no Gateway process serves returns 404 `UNKNOWN_GUILD`, or 403 `ACCESS_DENIED` when its stored record survives.
+
+The message read, create, modify, and delete routes, [Bulk delete messages](#bulk-delete-messages), [Bulk delete own messages](#bulk-delete-own-messages), and [List pinned messages](#list-pinned-messages) also enforce age verification on an effectively age restricted guild channel. They return 403 `NSFW_CONTENT_AGE_RESTRICTED` until the account satisfies it. The attachment routes, the pin routes, the reaction routes, and [Indicate typing](#indicate-typing) resolve the channel without that check.
+
+[Clear channel read state](#clear-channel-read-state) and [Acknowledge message](#acknowledge-message) resolve no channel. [Acknowledge pins](#acknowledge-pins) reads the channel without checking the caller's access. None of the three evaluates a permission.
+
+## Message object
+
+A message object is the full stored form of one post in a channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the message |
+| channel_id | snowflake | The ID of the channel |
+| author1 | [partial user](/http-api/users/#partial-user-object) object | The user credited with the message |
+| webhook_id? | snowflake | Originating webhook ID, present only for a webhook-authored message |
+| type | integer | [Message type](#message-types) |
+| flags | integer | [Message flags](#message-flags) |
+| content | string | The text of the message, empty when the message has only media |
+| timestamp | ISO8601 timestamp | Creation time derived from the message [snowflake](/snowflakes/) |
+| edited_timestamp | ?ISO8601 timestamp | Most recent edit time, or null when the message has never been edited |
+| pinned | boolean | Whether the message is pinned |
+| mention_everyone | boolean | Whether the message mentions everyone |
+| tts2 | boolean | Whether the message requested text-to-speech presentation |
+| mentions3 | array[[partial user](/http-api/users/#partial-user-object) object] | The users the message actively mentions |
+| mention_roles | array[snowflake] | The IDs of the roles the message actively mentions |
+| mention_channels? | array[[channel mention](#channel-mention-object) object] | The channels the message content links by ID |
+| users?3 | array[[partial user](/http-api/users/#partial-user-object) object] | Users referenced by non-notifying content, embed, and snapshot text |
+| embeds4 | array[[embed](#embed-object) object] | The previews resolved or supplied for the message |
+| attachments5 | array[[message attachment](#message-attachment-object) object] | The files attached to the message |
+| stickers | array[[sticker item](#sticker-item-object) object] | The stickers sent with the message |
+| nsfw_emojis? | array[snowflake] | IDs of the custom emojis in the message that are classified as explicit |
+| reactions? | array[[reaction](#reaction-object) object] | Ordinary reaction summaries |
+| message_reference? | [message reference](#message-reference-object) object | Reply or forward reference |
+| message_snapshots? | array[[message snapshot](#message-snapshot-object) object] | The immutable copies captured for a forward |
+| nonce?6 | string | Caller-supplied message nonce, echoed to the sender as a string of 1 through 32 characters |
+| call? | [message call](#message-call-object) object | Call state attached to a call message |
+| referenced_message?7 | ?[message](#message-object) object | Resolved referenced message without a nested `referenced_message` field |
+
+1 Synthesised for a webhook-authored message and for a stored author ID that no longer resolves
+
+2 Echoed from the create request, so a message read through any other operation always reports false
+
+3 A user who became an active mention appears in `mentions`, and a user who is only referenced by rendered text appears in `users` instead, so the two arrays never contain the same user
+
+4 Empty while `SUPPRESS_EMBEDS` is set on the message
+
+5 An attachment that a resolved embed owns is excluded from this array and is exposed through that embed's media object instead
+
+6 Echoed only in the create message response and its originating [Message Create](/gateway/events/#message-create) Gateway Dispatch, and never stored on the message
+
+7 The key is absent when the message has no reply reference, present and null when the reply target no longer resolves, and present with the message when it does. A client must tell absent apart from null by key presence
+
+A webhook-authored message has no stored author user. Fluxer builds its author from the webhook, with the webhook ID as `id`, the stored webhook name as `username`, the discriminator `0000`, the stored webhook avatar hash as `avatar`, and `bot` true. A message whose stored author ID no longer resolves to an account is served with a deleted-user placeholder. The placeholder has that ID, the discriminator `0000`, and no avatar.
+
+`mentions`, `mention_roles`, `embeds`, `attachments`, and `stickers` are always present and can be empty. Every optional field above is omitted entirely, with `referenced_message` as the one exception. A message whose author user and webhook are both absent is omitted from every read.
+
+:::note[A forward has immutable copies in `message_snapshots`]
+A reply reference resolves into `referenced_message`, and a forward has none. Fluxer captures the snapshots at forward time, and they never follow later edits to the source. [Message reference types](#message-reference-types) is the discriminator.
+:::
+
+### Example
+
+```json
+{
+ "id": "1501320000000000000",
+ "channel_id": "1501314428688998182",
+ "author": {
+ "id": "1501314428688990000",
+ "username": "ada",
+ "discriminator": "0001",
+ "avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90"
+ },
+ "type": 0,
+ "flags": 0,
+ "content": "shipping it",
+ "timestamp": "2026-08-31T09:14:02.000Z",
+ "edited_timestamp": null,
+ "pinned": false,
+ "mention_everyone": false,
+ "tts": false,
+ "mentions": [],
+ "mention_roles": [],
+ "embeds": [],
+ "attachments": [],
+ "stickers": []
+}
+```
+
+## Message types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | DEFAULT12 | Ordinary message |
+| 1 | RECIPIENT_ADD | Group direct message recipient-add system message |
+| 2 | RECIPIENT_REMOVE | Group direct message recipient-remove system message |
+| 3 | CALL | Private call system message |
+| 4 | CHANNEL_NAME_CHANGE | Group direct message name-change system message |
+| 5 | CHANNEL_ICON_CHANGE | Group direct message icon-change system message |
+| 6 | CHANNEL_PINNED_MESSAGE2 | Channel pin system message |
+| 7 | USER_JOIN2 | User-join system message |
+| 19 | REPLY12 | Message with a reply reference |
+
+1 Only this type can be modified, pinned, unpinned, or used as the target of a reply reference. Any other type rejects modification, pinning, and unpinning with 400 `CANNOT_MODIFY_SYSTEM_WEBHOOK`, and rejects being replied to with 400 `CANNOT_REPLY_TO_SYSTEM_MESSAGE`
+
+2 Only this type can be deleted, and any other type rejects deletion with 403 `MISSING_PERMISSIONS`
+
+## Message flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 2 | SUPPRESS_EMBEDS1 | Suppress rendering of embeds |
+| 1 << 12 | SUPPRESS_NOTIFICATIONS | Do not generate ordinary mention notifications |
+| 1 << 13 | VOICE_MESSAGE2 | Message has one voice recording attachment |
+
+1 The only flag a guild moderator can change on a message they did not author
+
+2 Setting this flag binds the message to the voice message contract described under [Create message](#create-message)
+
+These three bits are also the complete sendable set. A create masks the supplied value down to them, and a modify replaces only them and leaves every other stored bit untouched. A bit outside this table is discarded, and the request still succeeds.
+
+## Message attachment object
+
+An attachment is one file stored against a message and served from media delivery.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the attachment |
+| filename | string | The filename stored with the attachment |
+| title | ?string | Display title, or null when none is stored |
+| description | ?string | Alternative text or description, or null when none is stored |
+| content_type1 | string | The media type detected for the attachment |
+| content_hash | ?string | The hash of the attachment's stored bytes, or null when they were never hashed |
+| size | integer | The stored size of the attachment in bytes |
+| url2 | ?string | Public attachment URL, or null once the attachment has expired |
+| proxy_url2 | ?string | Media Proxy URL, or null once the attachment has expired |
+| width?3 | integer | Pixel width for visual media |
+| height?3 | integer | Pixel height for visual media |
+| placeholder? | string | Encoded media placeholder |
+| flags | integer | [Attachment flags](#attachment-flags) |
+| nsfw? | boolean | Whether media inspection marked the attachment explicit |
+| duration? | integer | Audio or video duration in seconds |
+| waveform? | string | Base64-encoded voice waveform |
+| expires_at4 | ?ISO8601 timestamp | Attachment URL expiry time, or null when the attachment has no recorded decay deadline |
+| expired?5 | boolean | Whether the attachment is expired |
+
+1 Always present. It falls back to a type guessed from the stored filename when the attachment has no recorded media type, and to `application/octet-stream` when the filename yields no guess
+
+2 Both fields are the same canonical media endpoint URL, and both become null together once the attachment has expired
+
+3 Omitted for an audio media type even when a stored dimension exists
+
+4 The key is always present. Its value is the recorded decay deadline, or null when the attachment has none
+
+5 Present and true only when the attachment has already expired, so an absent key must be read as false
+
+### Example
+
+```json
+{
+ "id": "1501320000000000001",
+ "filename": "diagram.png",
+ "title": null,
+ "description": "Sequence diagram",
+ "content_type": "image/png",
+ "content_hash": "9f86d081884c7d659a2feaa0c55ad015",
+ "size": 18422,
+ "url": "https://media.example.com/attachments/1501314428688998182/1501320000000000001/diagram.png",
+ "proxy_url": "https://media.example.com/attachments/1501314428688998182/1501320000000000001/diagram.png",
+ "width": 1280,
+ "height": 720,
+ "flags": 0,
+ "expires_at": null
+}
+```
+
+## Attachment flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 3 | IS_SPOILER | Attachment is presented as a spoiler |
+| 1 << 4 | CONTAINS_EXPLICIT_MEDIA | Attachment contains explicit media |
+| 1 << 5 | IS_ANIMATED | Attachment is animated |
+
+## Emoji object
+
+An emoji is either a custom guild emoji identified by ID or a standard Unicode emoji.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | snowflake | The ID of the custom emoji, absent for a Unicode emoji |
+| name1 | string | Emoji name or Unicode value |
+| animated? | boolean | Whether a custom emoji is animated |
+
+1 Holds the guild emoji name for a custom emoji and the literal Unicode sequence for a standard emoji
+
+## Reaction object
+
+A reaction object summarises one emoji group on a message. Fluxer has ordinary reactions only.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emoji | [emoji](#emoji-object) object | The emoji this group counts |
+| count1 | integer | The number of users who added this emoji |
+| me?2 | boolean | Whether the authenticated user has this reaction |
+
+1 Capped by the resolved `max_users_per_message_reaction` limit, which defaults to 1,000,000 users for each group
+
+2 Present and `true` only when the authenticated user has this reaction, and omitted entirely otherwise, so an absent key must be read as false
+
+A message has at most the resolved `max_reactions_per_message` distinct groups, which defaults to 30. Groups are ordered by the time each group's earliest reaction was added, then by emoji name, then by emoji ID.
+
+### Example
+
+```json
+{
+ "emoji": {
+ "id": "1501314428688991234",
+ "name": "party_parrot",
+ "animated": true
+ },
+ "count": 7,
+ "me": true
+}
+```
+
+## Message reference object
+
+A message reference names the message that a reply points at or that a forward was taken from.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the referenced channel |
+| message_id | snowflake | The ID of the referenced message |
+| guild_id? | snowflake | The ID of the referenced guild, present only for a guild message |
+| type | integer | [Message reference type](#message-reference-types) |
+
+## Message reference types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | DEFAULT1 | Reply reference |
+| 1 | FORWARD2 | Forward reference represented by snapshots |
+
+1 The default when a create message request supplies `message_reference` without a `type`
+
+2 Requires `channel_id` in the create message request, and the created message has `message_snapshots` and no `referenced_message`
+
+## Message reference input object
+
+A message reference input names the source message a create request replies to or forwards.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_id | snowflake | The ID of the referenced message |
+| channel_id?1 | snowflake | The ID of the referenced channel, required for a forward reference |
+| guild_id? | snowflake | The ID of the referenced guild when applicable |
+| type? | integer | [Message reference type](#message-reference-types), defaulting to `DEFAULT` |
+| attachment_ids?2 | array[snowflake] | At most 10 selected attachment IDs for a forward |
+| embed_indices?2 | array[integer] | At most 10 non-negative selected embed indices for a forward |
+
+1 A `FORWARD` reference without it fails validation, and a reply reference defaults to the channel the new message is being sent to
+
+2 Selects a subset of the source message's media to snapshot, and omitting both snapshots everything the source message has
+
+## Message snapshot object
+
+A snapshot is the flat immutable copy of one forwarded message. It has no message ID, channel ID, or author, so a client cannot trace a forward back to its source through this object.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| content? | string | Snapshotted text |
+| timestamp | ISO8601 timestamp | Original creation time |
+| edited_timestamp? | ISO8601 timestamp | Original edit time, omitted when the original message was never edited |
+| mentions?1 | array[snowflake] | Snapshotted mentioned user IDs |
+| mention_roles? | array[snowflake] | Snapshotted mentioned role IDs |
+| mention_channels? | array[[channel mention](#channel-mention-object) object] | Snapshotted mentioned channels |
+| embeds? | array[[embed](#embed-object) object] | Snapshotted embeds |
+| attachments?2 | array[[message attachment](#message-attachment-object) object] | Snapshotted attachments |
+| stickers? | array[[sticker item](#sticker-item-object) object] | Snapshotted stickers |
+| type | integer | Original [message type](#message-types) |
+| flags | integer | Original [message flags](#message-flags) |
+
+1 A client resolves each snapshotted ID against the enclosing message's `users` array
+
+2 Snapshot attachments are independent stored objects, so deleting the source message leaves them intact
+
+Every optional field above is omitted entirely.
+
+## Message call object
+
+A message call has the participants and end time of the call that a `CALL` message records.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| participants | array[snowflake] | Current participant user IDs |
+| ended_timestamp | ?ISO8601 timestamp | Call end time, or null while the call is still active |
+
+## Channel mention object
+
+A channel mention names one channel that the message content links by ID.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the mentioned channel |
+| name | string | Mentioned channel name |
+| type | integer | [Channel type](/http-api/channels/#channel-types) |
+
+## Sticker item object
+
+A sticker item is the trimmed sticker record on a message.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the sticker |
+| name | string | Sticker name |
+| animated | boolean | Whether the sticker is animated |
+| nsfw? | boolean | Whether the sticker is classified as explicit |
+
+## Embed object
+
+An embed is a rendered preview on a message, either resolved by the unfurler or supplied as rich input.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type | string | [Embed type](#embed-types) |
+| title? | string | Embed title |
+| description? | string | Embed description |
+| url? | string | Embed destination URL |
+| timestamp? | ISO8601 timestamp | Embed timestamp |
+| color? | integer | Embed colour |
+| author? | [embed author](#embed-author-object) object | Embed author |
+| provider?1 | [embed provider](#embed-provider-object) object | External provider |
+| thumbnail? | [embed media](#embed-media-object) object | Thumbnail image |
+| image? | [embed media](#embed-media-object) object | Main image |
+| video?1 | [embed media](#embed-media-object) object | Video media |
+| audio?1 | [embed media](#embed-media-object) object | Audio media |
+| footer? | [embed footer](#embed-footer-object) object | Embed footer |
+| fields? | array[[embed field](#embed-field-object) object] | Embed fields |
+| nsfw? | boolean | Whether the embed contains explicit media |
+| html?1 | string | Sanitised oEmbed markup for a trusted specialised renderer |
+| html_width?1 | integer | Preferred pixel width of the sanitised oEmbed markup |
+| html_height?1 | integer | Preferred pixel height of the sanitised oEmbed markup |
+| children?1 | array[[embed](#embed-object) object] | At most one nested unfurler-generated embed, which itself has no `children` field |
+
+1 Produced only by the unfurler when it resolves a link, and never accepted from a [rich embed input](#rich-embed-input-objects)
+
+Every optional field above is omitted entirely.
+
+### Example
+
+```json
+{
+ "type": "rich",
+ "title": "Release 2.4.0",
+ "description": "Voice reconnects are now instant.",
+ "url": "https://example.com/releases/2.4.0",
+ "color": 5793266,
+ "footer": {"text": "example.com"},
+ "fields": [{"name": "Platform", "value": "Desktop", "inline": true}]
+}
+```
+
+## Embed types
+
+| Value | Description |
+| --- | --- |
+| rich | An embed the unfurler assembled itself, and the type of every accepted rich embed input |
+| link | A generic link preview with page metadata and no resolved media |
+| image | An image preview resolved from a linked URL |
+| video | A video preview resolved from a linked URL |
+| audio | An audio preview resolved from a linked URL |
+| gifv | An animated looping video preview |
+| article | An article or encyclopaedia page preview |
+| bluesky | A Bluesky post preview |
+
+## Embed author object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Author name |
+| url? | string | Author link |
+| icon_url? | string | Author icon URL |
+| proxy_icon_url? | string | Proxied author icon URL |
+
+## Embed media object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url | string | Media URL |
+| proxy_url1 | string | Media Proxy URL |
+| width? | integer | Pixel width |
+| height? | integer | Pixel height |
+| duration? | integer | Duration in seconds |
+| description? | string | Media description |
+| content_type? | string | Media type |
+| content_hash? | string | Content hash |
+| placeholder? | string | Encoded media placeholder |
+| flags? | integer | Media flags using `CONTAINS_EXPLICIT_MEDIA` and `IS_ANIMATED` from [attachment flags](#attachment-flags) |
+
+1 Always derived from `url`, so every serialised media object has it
+
+## Embed footer object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text | string | Footer text |
+| icon_url? | string | Footer icon URL |
+| proxy_icon_url? | string | Proxied footer icon URL |
+
+## Embed field object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Field name |
+| value | string | Field value |
+| inline | boolean | Whether the field can render inline |
+
+## Embed provider object
+
+Fluxer populates a provider only from resolved embed metadata.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Provider name |
+| url? | string | Provider URL |
+
+## Allowed mentions object
+
+An allowed mentions object decides which mentions written in the message text become active mentions, meaning the ones that notify a user or role. Omitting the object lets every mention in the text become active, subject to the caller's permissions.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| parse?12 | array[string] | Mention categories from [allowed mention parse values](#allowed-mention-parse-values) |
+| users?2 | array[snowflake] | At most 100 user IDs permitted to become active mentions |
+| roles?2 | array[snowflake] | At most 100 role IDs permitted to become active mentions |
+| replied_user?3 | boolean | Whether a reply mentions the referenced message author (default true) |
+
+1 Defaults to every category when the object is absent, to the empty set when `users` or `roles` is supplied without it, and to the empty set when the object is present with none of its four fields
+
+2 A non-empty `parse` combined with a non-empty `users` or `roles` is rejected with 400 `PARSE_AND_USERS_OR_ROLES_CANNOT_BE_USED_TOGETHER`
+
+3 Ignored in a one-to-one direct message and in personal notes, and ignored when the referenced message has the same author as the new message
+
+:::caution[An empty object suppresses every mention]
+Sending `allowed_mentions` as `{}` resolves to an empty parse set, so no user mention, role mention, `@everyone`, or `@here` becomes active. Omitting the object does the opposite.
+:::
+
+`mention_everyone` is forced to false whenever the caller lacks `MENTION_EVERYONE`, without failing the request.
+
+## Allowed mention parse values
+
+| Value | Description |
+| --- | --- |
+| users | Parse user mentions |
+| roles | Parse role mentions |
+| everyone | Parse `@everyone` and `@here` mentions |
+
+## Message attachment input objects
+
+A new message can refer to a previously uploaded attachment or attach a direct multipart file. An edited message can retain an existing attachment. Attachment IDs in a direct multipart request identify the matching zero-based `files[N]` field.
+
+### Pre-uploaded attachment object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | integer \| integer string | Attachment position |
+| filename | string | Attachment filename |
+| upload_filename2 | string | Opaque upload key returned by [Request attachment upload URLs](#request-attachment-upload-urls) |
+| file_size1 | integer | Uploaded byte count |
+| content_type | string | Media type of 1 through 255 characters |
+| title? | ?string | Display title (1-1,024 characters) |
+| description? | ?string | Description (1-4,096 characters) |
+| duration?3 | ?integer | Voice media duration in seconds (0-2,147,483,647) |
+| waveform?3 | ?string | Base64 waveform of 1 through 4,096 characters |
+| flags? | integer | [Attachment flags](#attachment-flags), defaulting to `0` |
+
+1 Accepted either as a JSON number or as a decimal string
+
+2 The presence of this key is the discriminator that selects this variant over the [direct multipart metadata](#direct-multipart-attachment-metadata-object) variant
+
+3 Both are required when the message has the `VOICE_MESSAGE` flag. A `waveform` on an attachment whose resolved media type is not `audio/*` fails with 400 `VOICE_MESSAGES_ATTACHMENT_MUST_BE_AUDIO`
+
+Only `IS_SPOILER` and `CONTAINS_EXPLICIT_MEDIA` survive from a caller-supplied `flags` value. `IS_ANIMATED` is set by media inspection alone.
+
+### Direct multipart attachment metadata object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | integer \| integer string | Zero-based `files[N]` index |
+| filename | string | Display filename |
+| content_type? | string | Media type of 1 through 255 characters |
+| title? | ?string | Display title (1-1,024 characters) |
+| description? | ?string | Description (1-4,096 characters) |
+| flags? | integer | [Attachment flags](#attachment-flags), defaulting to `0` |
+| duration?2 | ?integer | Voice media duration in seconds (0-2,147,483,647) |
+| waveform?2 | ?string | Base64 waveform of 1 through 4,096 characters |
+
+1 An entry whose `id` matches no supplied file index and that has a `filename` fails with 400 `NO_FILE_FOR_ATTACHMENT_METADATA`. An entry whose `id` matches no supplied file and that has no `filename` is read as an [existing attachment reference](#existing-attachment-reference-object) instead
+
+2 Both are required when the message has the `VOICE_MESSAGE` flag
+
+Fluxer discards a key outside the table above and accepts the rest of the entry.
+
+### Existing attachment reference object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake \| integer | Existing attachment ID or attachment position |
+| title? | ?string | Replacement display title (1-1,024 characters) |
+| description? | ?string | Replacement description (1-4,096 characters) |
+| flags?1 | integer | [Attachment flags](#attachment-flags), accepted and discarded |
+| filename?12 | string | Display filename, accepted and discarded |
+| duration?12 | ?integer | Duration in seconds (0-2,147,483,647), accepted and discarded |
+| waveform?12 | ?string | Base64 waveform of 1 through 4,096 characters, accepted and discarded |
+
+1 A retained attachment is rebuilt from its stored record, and only `title` and `description` are written back onto it
+
+2 Rejected with 400 `CANNOT_EDIT_ATTACHMENT_METADATA` when the caller is a guild moderator editing a message they did not author, because that caller can supply only `id`, `title`, `description`, and `flags`
+
+## Rich embed input objects
+
+A rich embed input is an embed a client supplies on a message, and Fluxer always stores it with the `rich` [embed type](#embed-types). The number of embeds on one message is bounded by the resolved `max_embeds_per_message` limit, which defaults to 10, and exceeding it fails with 400 `TOO_MANY_EMBEDS`.
+
+### Rich embed object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url? | ?string | Embed destination URL (1-2,048 characters) |
+| title? | ?string | Title with at most 256 characters |
+| color? | ?integer | Embed colour |
+| timestamp? | ?ISO8601 timestamp | Embed timestamp |
+| description? | ?string | Description (1-4,096 characters, empty string treated as absent) |
+| author?1 | ?[embed author input](#embed-author-input-object) object | Author metadata |
+| image?1 | ?[embed media input](#embed-media-input-object) object | Main image metadata |
+| thumbnail?1 | ?[embed media input](#embed-media-input-object) object | Thumbnail metadata |
+| footer?1 | ?[embed footer input](#embed-footer-input-object) object | Footer metadata |
+| fields? | ?array[[embed field input](#embed-field-input-object) object] | At most 25 embed fields |
+
+1 A nested object that omits its own required field is discarded. An author without `name`, a media object without `url`, and a footer without `text` are read as if the field had not been sent
+
+### Embed author input object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Author name (1-256 characters) |
+| url? | ?string | Author link (1-2,048 characters) |
+| icon_url? | ?string | Author icon URL (1-2,048 characters) |
+
+### Embed media input object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url1 | string | Media URL or attachment reference |
+| description? | ?string | Media description (1-4,096 characters) |
+
+1 Either an `http` or `https` URL, or an `attachment://` reference naming a `png`, `jpg`, `jpeg`, `webp`, or `gif` attachment on the same message, and from 1 through 2,048 characters in either form. Only `image` and `thumbnail` accept the `attachment://` form
+
+An `attachment://` reference on a request that has no attachment fails with 400 `CANNOT_REFERENCE_ATTACHMENTS_WITHOUT_ATTACHMENTS`. A reference to a filename that no attachment on the request supplies fails with 400 `REFERENCED_ATTACHMENT_NOT_FOUND`, and a reference to an attachment whose extension is not a supported image extension fails with 400 `ATTACHMENT_MUST_BE_IMAGE`.
+
+### Embed footer input object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text | string | Footer text (1-2,048 characters) |
+| icon_url? | ?string | Footer icon URL (1-2,048 characters) |
+
+### Embed field input object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Field name (1-256 characters) |
+| value | string | Field value with at most 1,024 characters |
+| inline? | boolean | Whether the field can render inline, defaulting to `false` |
+
+## Attachment upload objects
+
+Fluxer issues an upload URL for an attachment before any message can reference it. [Request attachment upload URLs](#request-attachment-upload-urls) plans the upload. A plan whose declared size exceeds 10 MiB is a multipart plan, and [Complete attachment upload](#complete-attachment-upload) finalises it.
+
+An `upload_url` is either a presigned object storage URL or a Fluxer [upload relay](/media-proxy/upload-relay/) URL, and the instance decides which per request from the caller's country. A caller whose IP is unknown, or whose country is not on the instance's direct-upload list, is handed a relay URL. A client MUST send the URL exactly as issued and MUST NOT assume either shape.
+
+Only a message that the same identity creates in the same channel can reference the key an upload plan yields.
+
+### Attachment upload request item object
+
+One declared upload inside a [Request attachment upload URLs](#request-attachment-upload-urls) body.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | integer | Caller-selected attachment position (0-2,147,483,647) |
+| filename | string | Filename to upload |
+| file_size1 | integer | Declared byte count (0-9,007,199,254,740,991) |
+| content_type2 | string | Media type of 1 through 255 characters the client uploads |
+
+1 A decimal string is coerced to the integer
+
+2 The issued capability and the stored attachment both use the media type derived from `filename`
+
+### Singlepart attachment upload object
+
+Returned when the declared `file_size` is at most 10 MiB.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | Caller-selected attachment position echoed from the request |
+| filename | string | Filename echoed from the request |
+| upload_filename1 | string | Opaque upload key of 1 through 4,096 characters |
+| file_size | integer | Declared byte count echoed from the request |
+| content_type | string | Media type derived from `filename` |
+| upload_mode | string | Always `singlepart` |
+| upload_url2 | string | Presigned URL that accepts the complete file in one `PUT` |
+
+1 This value is the `upload_filename` of a [pre-uploaded attachment](#pre-uploaded-attachment-object) in a later [Create message](#create-message) or [Modify message](#modify-message) request
+
+2 The capability is bound to the derived media type and to exactly `file_size` bytes, so a `PUT` of any other length is rejected
+
+### Multipart attachment upload object
+
+Returned when the declared `file_size` exceeds 10 MiB.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | Caller-selected attachment position echoed from the request |
+| filename | string | Filename echoed from the request |
+| upload_filename | string | Opaque upload key of 1 through 4,096 characters |
+| file_size | integer | Declared byte count echoed from the request |
+| content_type | string | Media type derived from `filename` |
+| upload_mode | string | Always `multipart` |
+| upload_id | string | Object storage multipart upload identifier of 1 through 1,024 characters |
+| part_size1 | integer | Byte size of every part except the last |
+| parts | array[[attachment upload part](#attachment-upload-part-object) object] | Presigned part capabilities in ascending part number (1-10,000 entries) |
+
+1 The plan targets 20 parts, so `part_size` is one twentieth of `file_size` rounded up to a whole mebibyte, and it is never smaller than 10 MiB. The last part is the remainder
+
+### Attachment upload part object
+
+One presigned part of a multipart upload plan.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| part_number | integer | One-based part number |
+| upload_url1 | string | Presigned URL that accepts exactly this part |
+
+1 Each capability is bound to the exact byte count of its part, so a `PUT` of any other length is rejected
+
+### Multipart upload completion item object
+
+One planned upload named in a [Complete attachment upload](#complete-attachment-upload) body.
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| upload_filename | string | Opaque upload key returned when the upload was planned |
+| upload_id | string | Multipart upload identifier returned when the upload was planned |
+
+## Channel pin object
+
+A channel pin pairs one pinned message with the time it was pinned.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message1 | [message](#message-object) object | The message the pin points at |
+| pinned_at | ISO8601 timestamp | Time at which the message was pinned |
+
+1 The pinned message omits `referenced_message` and `reactions`, so a client that needs either must read the message through [Get message](#get-message)
+
+## Channel pins page object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| items1 | array[[channel pin](#channel-pin-object) object] | Current page of pins |
+| has_more | boolean | Whether an older page exists |
+
+1 Ordered by descending pin time
+
+The page has no cursor field. The next request repeats the operation with `before` set to the `pinned_at` of the last returned item. Continue while `has_more` is true. `has_more` is false only when the channel has no further visible pins.
+
+## Reaction users page object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| items | array[[partial user](/http-api/users/#partial-user-object) object] | Users in ascending cursor order |
+| has_more | boolean | Whether another page exists |
+| next_after1 | ?snowflake | User ID cursor for the next page |
+
+1 Null when no further page exists, and otherwise the ID of the last user in `items`
+
+## Bulk message request object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| limit | integer | Number of messages to return (1-50) |
+| before? | snowflake | Return messages before this message ID |
+| after? | snowflake | Return messages after this message ID |
+| around? | snowflake | Return a window centred on this message ID |
+
+## Bulk message channel object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| messages | array[[message](#message-object) object] | Reverse-chronological message window |
+
+## List channel messages
+
+
+
+Returns a reverse-chronological array of [message](#message-object) objects. Requires [VIEW_CHANNEL](/http-api/permissions/).
+
+### Limitations
+
+- Reading history in a guild channel requires [READ_MESSAGE_HISTORY](/http-api/permissions/).
+- A guild caller without `READ_MESSAGE_HISTORY` sees only messages created on or after the guild's message history cutoff.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Number of messages to return (1-100, default 50) |
+| before?1 | snowflake | Return messages before this message ID |
+| after?1 | snowflake | Return messages after this message ID |
+| around? | snowflake | Return a window centred on this message ID, overriding `before` and `after` |
+
+1 Combining the two selects the page before `before`, then discards entries at or below `after`
+
+:::caution[A page can hold fewer than `limit` items]
+Fluxer selects the page first, then removes messages outside the caller's message history cutoff and messages with neither a stored author nor a stored webhook. A page can therefore hold fewer than `limit` items.
+:::
+
+:::note[Missing history is an empty page]
+A caller without `READ_MESSAGE_HISTORY` in a guild channel with no history cutoff receives 200 with an empty array. An unreachable cursor also returns an empty window.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[message](#message-object) object] | Message window was returned |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS`, or the caller lacks age verification for the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient of the private channel |
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:messages:read::channel_id` bucket.
+
+## List messages from multiple channels
+
+
+
+Returns a bulk message response object holding bounded message windows from several channels.
+
+### Limitations
+
+- This is a user-only operation, and a bot credential is refused with 403 `ACCESS_DENIED`.
+- Fluxer resolves and authorises each requested channel independently, under exactly the same boundary as [List channel messages](#list-channel-messages).
+- An inaccessible channel fails the whole request, so the response has no window for any channel.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| requests1 | array[[bulk message request](#bulk-message-request-object) object] | From 1 through 25 channel requests |
+
+1 The `limit` values across all entries must sum to at most 250, and exceeding that sum fails the whole request
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channels1 | array[[bulk message channel](#bulk-message-channel-object) object] | Results in request order |
+
+1 Contains exactly one entry for each requested channel, in the order the entries were sent, and a duplicated channel ID therefore produces a duplicated result entry
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | All requested windows were returned |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL` for a requested channel and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks age verification for a requested channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | A requested channel does not exist or the caller is not a recipient of the private channel |
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `channel:messages:bulk_read` bucket.
+
+## Get message
+
+
+
+Returns one [message](#message-object) object. Requires [VIEW_CHANNEL](/http-api/permissions/).
+
+### Limitations
+
+- In a guild channel without [READ_MESSAGE_HISTORY](/http-api/permissions/), a message created before the guild's message history cutoff is reported as 404 `UNKNOWN_MESSAGE` rather than 403.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](#message-object) object | Message was returned |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS`, or the caller lacks age verification for the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:read::channel_id` bucket.
+
+## Request attachment upload URLs
+
+
+
+Plans from 1 through 10 attachment uploads. Returns a [singlepart](#singlepart-attachment-upload-object) or [multipart](#multipart-attachment-upload-object) upload object for each declaration.
+
+### Limitations
+
+- The channel must support messages, and one that does not fails with 400 `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`.
+- A guild channel requires [SEND_MESSAGES](/http-api/permissions/) and [ATTACH_FILES](/http-api/permissions/), and a timed-out member is refused with 403 `COMMUNICATION_DISABLED`.
+
+Fluxer checks each declared `file_size` separately against the attachment size limit it resolves for the caller and the guild context. A declaration above that limit returns 400 `FILE_SIZE_TOO_LARGE` with the resolved ceiling. The resolved ceiling defaults to 26214400 bytes, the 25 MiB non-premium allowance, and 524288000 bytes, the 500 MiB premium allowance. A bot credential is additionally clamped to 52428800 bytes, the 50 MiB bot ceiling, even when the resolved limit is higher.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the target message channel |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| attachments | array[[attachment upload request item](#attachment-upload-request-item-object) object] | Attachment upload declarations (1-10 entries) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| attachments1 | array[[singlepart](#singlepart-attachment-upload-object) or [multipart](#multipart-attachment-upload-object) attachment upload object] | Upload plan for each declaration |
+
+1 Returned in the same order as the request, and each entry echoes the caller's `id` so a client can pair a plan with its declaration. Read `upload_mode` to select the variant
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Upload plans were issued |
+| 400 | [error response](/http-api/#error-response) | Body, attachment declaration, or count is invalid and the request returns `INVALID_FORM_BODY` |
+| 400 | [error response](/http-api/#error-response) | The channel does not support messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 400 | [error response](/http-api/#error-response) | A declared size exceeds the resolved limit and the request returns `FILE_SIZE_TOO_LARGE` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `SEND_MESSAGES` or `ATTACH_FILES` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | Presigned attachment uploads are switched off for the instance and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or is unavailable and the request returns `UNKNOWN_CHANNEL`, or the authenticated account does not exist and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+The operation records one pending upload for each declaration against the caller and the channel, and opens a storage multipart upload for each multipart plan. It creates no message.
+
+An issued `upload_filename` becomes an attachment only when a later [Create message](#create-message) or [Modify message](#modify-message) request references it from a [pre-uploaded attachment](#pre-uploaded-attachment-object) entry. A singlepart plan needs no completion step, so a message can reference its key as soon as the `PUT` succeeds.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:attachment:upload::channel_id` bucket.
+
+## Complete attachment upload
+
+
+
+Finalises from 1 through 10 multipart uploads after every part has been sent. Returns the finalised upload keys.
+
+### Limitations
+
+- Each upload must have been planned as multipart by the same authenticated identity for the same channel.
+
+The request has no part list and no entity tags.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel bound to the uploads |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| uploads | array[[multipart upload completion item](#multipart-upload-completion-item-object) object] | Uploads to finalise (1-10 entries) |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| uploads | array[object] | One entry for each finalised upload, each with `upload_filename` |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Every multipart upload was finalised |
+| 400 | [error response](/http-api/#error-response) | Body is invalid, or an upload is not a pending multipart upload of this caller in this channel and the request returns `INVALID_FORM_BODY` with the code `UPLOADED_ATTACHMENT_NOT_FOUND` on the path `uploads.{index}.upload_filename` |
+| 400 | [error response](/http-api/#error-response) | No part was uploaded and the request returns `INVALID_FORM_BODY` with the code `NO_UPLOADED_PARTS_TO_FINALIZE` on the path `parts` |
+| 400 | [error response](/http-api/#error-response) | The uploaded bytes exceed the resolved limit and the request returns `FILE_SIZE_TOO_LARGE` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `SEND_MESSAGES` or `ATTACH_FILES` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | Presigned attachment uploads are switched off for the instance and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or is unavailable and the request returns `UNKNOWN_CHANNEL`, or the authenticated account does not exist and the request returns `UNKNOWN_USER` |
+
+:::caution[A failed completion aborts the upload]
+Fluxer aborts the multipart upload and discards its parts when no part was uploaded, when the uploaded bytes exceed the resolved limit, or when the storage completion fails. Plan the upload again rather than retrying the completion.
+:::
+
+:::caution[An unowned upload is reported as invalid input]
+An upload key the identity does not own, that belongs to another channel, that was planned as singlepart, or that a message already consumed all report 400 `UPLOADED_ATTACHMENT_NOT_FOUND`. A caller cannot tell the four apart, so it cannot probe another identity's upload state.
+:::
+
+### Side effects
+
+The operation finalises each multipart upload and records its completion time and completion IP. It creates no message, and the upload key stays available until a message consumes it.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:attachment:upload::channel_id` bucket, which is shared with [Request attachment upload URLs](#request-attachment-upload-urls).
+
+## Create message
+
+
+
+Creates a message from a JSON body or from multipart form data. Returns the created [message](#message-object) object. Emits a [Message Create](/gateway/events/#message-create) Gateway event.
+
+### Limitations
+
+- The channel must be a text-bearing type.
+- A guild channel requires [VIEW_CHANNEL](/http-api/permissions/) and [SEND_MESSAGES](/http-api/permissions/), an untimed-out membership, and any membership verification the guild requires.
+- A timed-out member is refused with 403 `COMMUNICATION_DISABLED`.
+- Embeds require [EMBED_LINKS](/http-api/permissions/), attachments require [ATTACH_FILES](/http-api/permissions/), a favourite meme requires both, and active everyone mentions require [MENTION_EVERYONE](/http-api/permissions/).
+- Slowmode applies to a non-bot caller unless they hold [BYPASS_SLOWMODE](/http-api/permissions/).
+- A one-to-one direct message additionally applies the recipient's direct message policy and relationship state, and a denial returns 400 `CANNOT_SEND_MESSAGES_TO_USER`.
+- An unclaimed account can send only to its own personal notes channel, and a send to any other channel is refused with 400 `UNCLAIMED_ACCOUNT_CANNOT_SEND_MESSAGES` before the channel is resolved.
+- A non-bot caller must have started a session, and one that has not is refused with 400 `MUST_START_SESSION_BEFORE_SENDING`.
+
+The request body is read as a multipart form when `Content-Type` contains `multipart/form-data`, and as JSON otherwise. An empty JSON body is read as `{}`. A JSON body that is neither valid JSON nor valid against the schema fails with 400 `INVALID_MESSAGE_DATA` on the path `message_data`, which has no per-field detail. A multipart body whose merged payload fails the same schema instead fails with a per-field 400 `INVALID_FORM_BODY` naming each offending path.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the target channel |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| nonce?1 | string \| integer | Caller-generated identifier as a string of 1 through 32 characters or a non-negative safe integer |
+| content?2 | ?string | Message text |
+| embeds?6 | array[[rich embed](#rich-embed-object) object] | Rich embed inputs |
+| attachments?7 | array[[pre-uploaded attachment](#pre-uploaded-attachment-object) object] | Attachment references |
+| message_reference? | ?[message reference input](#message-reference-input-object) object | Reply or forward reference |
+| allowed_mentions? | ?[allowed mentions](#allowed-mentions-object) object | Active mention policy |
+| flags?3 | integer | [Message flags](#message-flags), defaulting to `0` |
+| favorite_meme_id?4 | ?snowflake | Favorite meme to attach |
+| sticker_ids? | ?array[snowflake] | At most 3 sticker IDs |
+| tts?5 | boolean | Whether to request text-to-speech presentation |
+
+1 Echoed back on the created message and on its [Message Create](/gateway/events/#message-create) Dispatch so a client can match the result to its optimistic entry. An integer is converted to its decimal string form
+
+2 The schema imposes no length bound. Exceeding the effective one returns 400 `INVALID_FORM_BODY` with `CONTENT_EXCEEDS_MAX_LENGTH` on the path `content`
+
+3 Fluxer keeps only the bits in [message flags](#message-flags) and silently drops every other bit
+
+4 The stored meme object is copied into a new attachment on the message, so the message has its own independent copy. A meme the caller does not own returns 400 `INVALID_FORM_BODY` with the code `FAVORITE_MEME_NOT_FOUND`
+
+5 Fluxer forces the value to false when the caller lacks [SEND_TTS_MESSAGES](/http-api/permissions/) in a guild, and the request still succeeds
+
+6 Bounded by the resolved `max_embeds_per_message` limit, which defaults to 10. Exceeding it fails with 400 `TOO_MANY_EMBEDS` on the path `embeds`
+
+7 Bounded by the resolved `max_attachments_per_message` limit, which defaults to 10. Exceeding it fails with 400 `TOO_MANY_FILES` on the path `attachments`
+
+The smallest body that works is one line of text:
+
+```json
+{
+ "content": "hello"
+}
+```
+
+Fluxer resolves the effective `max_message_length` for the author and guild context, and it defaults to 2,000 characters. A bot or webhook author takes whichever is larger, the resolved value or 4,000 characters.
+
+A message must have at least one of content with visible characters, an embed, an attachment, a favourite meme, or a sticker, and an otherwise empty request fails with 400 `CANNOT_SEND_EMPTY_MESSAGE`. `SUPPRESS_NOTIFICATIONS` changes notification generation, and the message still has its mention data.
+
+:::caution[A reused nonce replays the first result]
+A nonce is remembered for the authenticated identity for five minutes, so a client that retries a send after a timeout MUST reuse the same nonce to avoid a duplicate.
+:::
+
+Reusing a nonce in the same channel inside that window returns the message the first request created. Reusing it in a different channel fails with 404 `UNKNOWN_MESSAGE`. The replay check runs after authorisation and body validation, so a retry that is otherwise invalid still fails, and a retry after the window has passed creates a second message.
+
+A reply applies the caller's message history cutoff to its target, and a forward applies none to its source. A reply whose target is a system message fails with 400 `CANNOT_REPLY_TO_SYSTEM_MESSAGE`, and a reference that resolves to no message fails with 404 `UNKNOWN_MESSAGE`.
+
+A `FORWARD` reference has `channel_id`. One that does not is refused by body validation before the operation runs, with 400 `INVALID_MESSAGE_DATA` on a JSON body and a per-field 400 `INVALID_FORM_BODY` on a multipart body.
+
+A forward request has no `content`, `embeds`, `attachments`, or `sticker_ids`, and one that does fails with 400 `FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT`. A `guild_id` that disagrees with the source channel's guild fails with 400 `GUILD_ID_MUST_MATCH_REFERENCED_MESSAGE`. Reading the source channel requires [VIEW_CHANNEL](/http-api/permissions/) when that channel belongs to a guild.
+
+Snapshots with embeds require `EMBED_LINKS` in the destination guild channel, and snapshots with attachments require `ATTACH_FILES`, each returning 403 `MISSING_PERMISSIONS` when absent.
+
+### Multipart body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| payload_json? | string | JSON encoding of the create message body, defaulting to `{}` when the field is absent |
+| files[N]?12 | binary | Direct attachment file at zero-based index `N` |
+| content? | string | Overrides `content` in `payload_json` |
+| nonce? | string | Overrides `nonce` in `payload_json` |
+| tts? | string | Overrides `tts` in `payload_json`, parsed from `true` or `false` |
+| flags? | string | Overrides `flags` in `payload_json`, parsed as a decimal integer |
+| favorite_meme_id? | string | Overrides `favorite_meme_id` in `payload_json` |
+| sticker_ids? | string | Overrides `sticker_ids` in `payload_json`, repeated once per sticker |
+
+1 The legacy names `file` and `file` are accepted and take the same indices. Any field name not listed above is ignored
+
+2 The index runs from 0 through the resolved `max_attachments_per_message` limit minus one
+
+A body that cannot be parsed as a multipart form fails with 400 `FAILED_TO_PARSE_MULTIPART_FORM_DATA`, and a `payload_json` field that is not a JSON string fails with 400 `INVALID_JSON_IN_PAYLOAD_JSON`. A field name beginning with `files[` that does not match `files[]` fails with 400 `INVALID_FILE_FIELD_NAME`.
+
+| File field condition | Error |
+| --- | --- |
+| Index below 0 or above 10000 | 400 `FILE_INDEX_EXCEEDS_MAXIMUM` reporting a `maxIndex` of 10000 |
+| Resolved attachment limit is 0 | 400 `ATTACHMENTS_NOT_ALLOWED_FOR_MESSAGE` |
+| Index at or above a non-zero resolved limit | 400 `FILE_INDEX_EXCEEDS_MAXIMUM` reporting that limit minus one |
+| Index repeated across fields | 400 `DUPLICATE_FILE_INDEX` |
+| More than one file under one index | 400 `MULTIPLE_FILES_FOR_INDEX_NOT_ALLOWED` |
+| More files than the resolved limit | 400 `TOO_MANY_FILES` |
+
+The `attachments` array inside `payload_json` uses [direct multipart attachment metadata](#direct-multipart-attachment-metadata-object), and an entry with `upload_filename` is instead read as a [pre-uploaded attachment](#pre-uploaded-attachment-object). An entry whose `id` matches a supplied file index supplies that file's metadata, and two entries claiming the same file fail with 400 `DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED`.
+
+Fluxer permits index gaps and attaches the files in ascending index order. A supplied file that no metadata entry claims is attached with its own multipart filename and no title or description, so omitting `attachments` entirely attaches every supplied file that way.
+
+A message with `VOICE_MESSAGE` set has exactly one attachment and nothing else. Each rule fails with its own 400 code.
+
+Any count other than one attachment returns `VOICE_MESSAGES_REQUIRE_SINGLE_ATTACHMENT`. A missing `waveform` returns `VOICE_MESSAGES_ATTACHMENT_WAVEFORM_REQUIRED` and a missing `duration` returns `VOICE_MESSAGES_ATTACHMENT_DURATION_REQUIRED`. Content, embeds, stickers, and a favourite meme return `VOICE_MESSAGES_CANNOT_HAVE_CONTENT`, `VOICE_MESSAGES_CANNOT_HAVE_EMBEDS`, `VOICE_MESSAGES_CANNOT_HAVE_STICKERS`, and `VOICE_MESSAGES_CANNOT_HAVE_FAVORITE_MEMES`. A duration above the resolved `max_voice_message_duration` limit, which defaults to 1200 seconds, returns `VOICE_MESSAGES_DURATION_EXCEEDS_LIMIT`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](#message-object) object | Message was created |
+| 400 | [error response](/http-api/#error-response) | Body, multipart form, content, reference, mention, attachment, embed, sticker, or voice message contract is invalid, the recipient's direct message policy denies the send and the request returns `CANNOT_SEND_MESSAGES_TO_USER`, or slowmode denies the send and the request returns `SLOWMODE_RATE_LIMITED` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks a required permission and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | Is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | Is age restricted from the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 403 | [error response](/http-api/#error-response) | Message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel, referenced message, sticker, or guild does not exist or is unavailable, or the supplied nonce was last used in a different channel and the request returns `UNKNOWN_MESSAGE` |
+
+:::caution[Slowmode denials are 400, not 429]
+Slowmode uses no rate limit bucket. The denial returns 400 `SLOWMODE_RATE_LIMITED` with a `Retry-After` header in whole seconds and a top-level `retry_after` member in decimal seconds.
+:::
+
+`retry_after` is never more than one second below the header. The response has no bucket headers and no `X-RateLimit-Scope`, so a client can tell it apart from a route or [global limit](/topics/rate-limits/) denial.
+
+### Side effects
+
+The operation creates the message, attaches every referenced upload and direct multipart file, applies attachment decay deadlines, copies forwarded media, updates the channel's last message, and makes the message searchable.
+
+It advances the author's read state unless the author is a bot, reopens a direct message for any recipient who had closed it, and resolves eligible uncached URLs into embeds.
+
+It emits [Message Create](/gateway/events/#message-create) to every session that can see the channel. Mention and reply notifications follow the resolved allowed mentions policy.
+
+It creates no audit log entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:create::channel_id` bucket.
+
+## Modify message
+
+
+
+Modifies a message. Returns the updated [message](#message-object) object. Emits a [Message Update](/gateway/events/#message-update) Gateway event.
+
+### Limitations
+
+- The target must be a `DEFAULT` or `REPLY` message, and any other type fails with 400 `CANNOT_MODIFY_SYSTEM_WEBHOOK`.
+- A message that has [message snapshots](#message-snapshot-object) cannot be edited by its author and fails with 400 `MESSAGES_WITH_SNAPSHOTS_CANNOT_BE_EDITED`. A non-author moderator holding `MANAGE_MESSAGES` can still toggle `SUPPRESS_EMBEDS` and change existing attachment metadata on a forwarded message.
+- The author can modify every supported field, and a timed-out author is refused with 403 `COMMUNICATION_DISABLED`.
+- A caller who is not the author can act only in a guild channel, must hold [MANAGE_MESSAGES](/http-api/permissions/), and can change only `SUPPRESS_EMBEDS` and the metadata of attachments that already exist. A non-author edit that does not satisfy all three conditions fails with 403 `CANNOT_EDIT_OTHER_USER_MESSAGE`.
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild. A caller who does not hold the bit at all is treated as any other non-author and receives 403 `CANNOT_EDIT_OTHER_USER_MESSAGE`.
+- Embeds require [EMBED_LINKS](/http-api/permissions/) and adding an upload requires [ATTACH_FILES](/http-api/permissions/).
+- The route takes a per-message write lock for the duration of the edit. A request that cannot take the lock fails with 429 `RESOURCE_LOCKED` and a `Retry-After` of 1.
+
+The body is read exactly as it is for [Create message](#create-message), so a `multipart/form-data` request is accepted with the same `payload_json` and `files[N]` fields. A file that is not uploaded directly must first be planned through [Request attachment upload URLs](#request-attachment-upload-urls) and then referenced by its `upload_filename`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message to modify |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| content? | ?string | Replacement text under the same effective limits as message creation |
+| embeds?1 | array[[rich embed](#rich-embed-object) object] | Replacement embed collection |
+| allowed_mentions?2 | ?[allowed mentions](#allowed-mentions-object) object | Replacement active mention policy |
+| flags?3 | integer | Replacement [message flags](#message-flags) |
+| attachments?1 | array[[pre-uploaded attachment](#pre-uploaded-attachment-object) object \| [existing attachment reference](#existing-attachment-reference-object) object] | Complete retained and added attachment collection |
+
+1 Replaces the stored collection outright, so an existing embed or attachment that the array omits is removed. Both collections are bounded by the same resolved limits as message creation
+
+2 Defaults to the stored reply mention state when omitted, so an edit that does not name a policy neither adds nor removes the reply mention of the referenced author
+
+3 Only the bits in [message flags](#message-flags) are retained, and the message keeps every other stored bit unchanged
+
+A request that supplies `message_snapshots` is rejected with 403 `MISSING_PERMISSIONS`, so snapshot metadata cannot be edited through this route.
+
+An omitted field preserves its stored value, and the message's `edited_timestamp` advances only when `content` changes. The request itself must supply at least one of content with visible characters, an embed, an attachment, or `flags`, and one that supplies none of them fails with 400 `CANNOT_SEND_EMPTY_MESSAGE`.
+
+:::caution[Supplying attachments replaces the whole collection]
+To retain an attachment, list its existing ID again, because any attachment left out is dropped. The same array adds new attachments as [pre-uploaded attachment](#pre-uploaded-attachment-object) entries. The same rule applies to `embeds`.
+:::
+
+An `id` that names no attachment on the message is skipped, and the edit still succeeds.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](#message-object) object | Message was modified |
+| 400 | [error response](/http-api/#error-response) | Body, message type, attachment, embed, or resulting payload is invalid, or the author is editing a message that has snapshots and the request returns `MESSAGES_WITH_SNAPSHOTS_CANNOT_BE_EDITED` |
+| 403 | [error response](/http-api/#error-response) | Caller is not the author and cannot moderate the message and the request returns `CANNOT_EDIT_OTHER_USER_MESSAGE` |
+| 403 | [error response](/http-api/#error-response) | The caller supplied `message_snapshots` or lacks `EMBED_LINKS` or `ATTACH_FILES` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | The caller is age restricted from the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 403 | [error response](/http-api/#error-response) | Message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist |
+
+### Side effects
+
+The operation updates the message, advances its edit timestamp when `content` changed, attaches every newly referenced upload, and drops each attachment the request did not retain. It recalculates mentions when `content`, `allowed_mentions`, or `embeds` changed, updates search results when the channel is indexed, and resolves newly added eligible URLs into embeds.
+
+It emits [Message Update](/gateway/events/#message-update) to every session that can see the channel. It creates no audit log entry, even for a moderator edit.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:update::channel_id` bucket.
+
+## Clear channel read state
+
+
+
+Deletes the authenticated identity's read state entry for one channel. Returns 204 with an empty body on success.
+
+### Limitations
+
+- Fluxer does not resolve the channel, so the operation mutates only the caller's own read state.
+- The operation is idempotent, and a channel with no stored entry still returns 204.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel whose read state is deleted |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Read state was absent or deleted |
+| 403 | [error response](/http-api/#error-response) | Credential type or account state denies the request and it returns `ACCESS_DENIED` or `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Side effects
+
+The operation deletes the caller's stored read-through marker, mention count, and pin acknowledgement for the channel, and invalidates the caller's cached unread badge count. It emits no Gateway Dispatch.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and channel ID, on the `channel:read_state:delete::channel_id` bucket.
+
+## Delete message
+
+
+
+Deletes one message. Returns 204 with an empty body on success. Emits a [Message Delete](/gateway/events/#message-delete) Gateway event.
+
+### Limitations
+
+- The target must be a deletable [message type](#message-types).
+- The author can delete their own message anywhere.
+- Deleting another user's guild message requires both [SEND_MESSAGES](/http-api/permissions/) and [MANAGE_MESSAGES](/http-api/permissions/), and no caller can delete another user's private channel message.
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild.
+- The operation reads no audit reason header.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message to delete |
+
+:::danger[Deleting a message destroys its attachments too]
+The message, its reactions, and its attachments are removed permanently and disappear from search. There is no grace period or restore operation.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message was deleted |
+| 403 | [error response](/http-api/#error-response) | Caller lacks authorship or the required guild permissions, or the message type is not deletable, each returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks age verification for the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 403 | [error response](/http-api/#error-response) | Message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist |
+
+### Side effects
+
+The operation permanently deletes the message, its reactions, and its attachments and removes it from search.
+
+It emits [Message Delete](/gateway/events/#message-delete) to every session that can see the channel. When the message was pinned, it also removes the channel pin entry and emits [Channel Pins Update](/gateway/events/#channel-pins-update) with the channel's unchanged last-pin timestamp.
+
+Every deletion in a guild channel writes one `MESSAGE_DELETE` guild audit log entry with no reason, including deletion of the caller's own message.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:delete::channel_id` bucket.
+
+## Delete message attachment
+
+
+
+Deletes one attachment from the caller's own message. Returns 204 with an empty body on success. Emits a [Message Update](/gateway/events/#message-update) Gateway event.
+
+### Limitations
+
+- Only the message author can use this operation, and a guild moderator must use [Modify message](#modify-message) instead.
+- This route cannot address an attachment that a resolved embed owns.
+- When the attachment was the message's last remaining payload, the [Delete message](#delete-message) effects apply.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the owning message |
+| attachment_id | snowflake | The ID of the attachment to delete |
+
+:::caution[Removing the last payload deletes the message]
+When the attachment is the only attachment and the message would otherwise be empty, Fluxer deletes the whole message. The response is 204 in both cases, so the emitted Gateway Dispatch is the only signal that distinguishes them.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Attachment or resulting empty message was deleted |
+| 403 | [error response](/http-api/#error-response) | Caller is not the message author and the request returns `MISSING_PERMISSIONS`, or message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message has no such addressable attachment |
+
+### Side effects
+
+The operation permanently deletes the stored attachment object, purges it from media delivery, removes it from the message, and advances the message's edit timestamp. It then emits [Message Update](/gateway/events/#message-update) to every session that can see the channel. When the removal leaves the message with no payload, the complete [Delete message](#delete-message) effect set applies instead, including its Dispatches and guild audit log entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:delete::channel_id` bucket.
+
+## Bulk delete messages
+
+
+
+Deletes from 1 through 100 messages in one guild channel. Returns 204 with an empty body on success. Emits one [Message Delete Bulk](/gateway/events/#message-delete-bulk) Gateway event.
+
+### Limitations
+
+- The channel must belong to a guild, and a private channel fails with 400 `CANNOT_EXECUTE_ON_DM`.
+- The caller requires [MANAGE_MESSAGES](/http-api/permissions/).
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild.
+- The operation applies no age boundary, so a message of any age can be selected.
+- The operation reads no audit reason header.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild channel |
+
+### JSON body
+
+At least one of the two fields below must be present. Supplying neither fails with 400 `INVALID_FORM_BODY` on the path `message_ids`, and supplying both uses `message_ids` and ignores `messages`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_ids?1 | array[snowflake] | Message IDs to delete, at most 100 entries |
+| messages?1 | array[snowflake] | Alias for `message_ids` |
+
+1 An empty array fails with 400 `MESSAGE_IDS_CANNOT_BE_EMPTY` on the path `message_ids`. More than 100 entries is rejected by body validation with 400 `INVALID_FORM_BODY` before the operation runs
+
+:::danger[Bulk deletion removes attachments and search entries]
+Every selected message, with its reactions and attachments, is permanently deleted and removed from search.
+:::
+
+An ID that names no message in the channel is skipped, so a partially matching request still deletes every matching message. When no ID matches, the request returns 204 without deleting anything, emitting a Dispatch, or writing an audit log entry.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Messages were deleted |
+| 400 | [error response](/http-api/#error-response) | Body or entry count is invalid, the selection is empty and the request returns `MESSAGE_IDS_CANNOT_BE_EMPTY`, or the channel is a private channel and the request returns `CANNOT_EXECUTE_ON_DM` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_MESSAGES` and the request returns `MISSING_PERMISSIONS`, or the caller lacks age verification for the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist |
+
+### Side effects
+
+The operation permanently removes every selected message, attachment, and reaction and removes each message from search.
+
+When at least one ID matched, it emits one [Message Delete Bulk](/gateway/events/#message-delete-bulk) with every requested ID to sessions that can see the channel, including an ID that matched nothing. It emits no [Channel Pins Update](/gateway/events/#channel-pins-update) and no individual [Message Delete](/gateway/events/#message-delete).
+
+It writes one `MESSAGE_BULK_DELETE` guild audit log entry with the matched count and no reason.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:bulk_delete::channel_id` bucket.
+
+## Purge personal notes
+
+
+
+Deletes every message in the authenticated user's personal notes channel. Returns the number that were removed. Emits a [Message Delete Bulk](/gateway/events/#message-delete-bulk) Gateway event.
+
+### Limitations
+
+- The channel must be the caller's own `DM_PERSONAL_NOTES` channel, and any other channel fails with 400 `INVALID_CHANNEL_TYPE`.
+
+The deletion runs inside the request in pages of 100 messages, so a channel with a very large history holds the request open until the last message is gone.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the personal notes channel |
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| deleted_count | integer | Number of messages removed by this request |
+
+:::danger[Purging personal notes cannot be undone]
+The operation deletes every message it finds, with no age boundary and no confirmation step. There is no restore step once it has started.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Every personal note was deleted |
+| 403 | [error response](/http-api/#error-response) | Credential type or account state denies the request and it returns `ACCESS_DENIED` or `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller cannot resolve it |
+
+### Side effects
+
+The operation permanently deletes every personal note, its attachments, and its reactions and removes each from search. The caller's sessions receive one [Message Delete Bulk](/gateway/events/#message-delete-bulk) Dispatch for each page of at most 100 messages.
+
+The operation emits no [Channel Pins Update](/gateway/events/#channel-pins-update) and no individual [Message Delete](/gateway/events/#message-delete), and it writes no audit log entry.
+
+### Rate limit
+
+2 requests per minute for each authenticated user and channel ID, on the `channel:message:purge::channel_id` bucket.
+
+## Bulk delete own messages
+
+
+
+Deletes every message the caller authored in one channel. Returns 202 with an empty body. Emits a [Message Delete Bulk](/gateway/events/#message-delete-bulk) Gateway event.
+
+### Limitations
+
+- User session and bot credentials are accepted, and a bot credential satisfies the sudo requirement without any proof.
+- The caller must be able to resolve the channel and must be in [sudo mode](/http-api/users/mfa/#sudo-mode), established by the `X-Fluxer-Sudo-Mode-JWT` header or by supplying a proof in the body.
+- A caller who satisfies none of those receives 403 `SUDO_MODE_REQUIRED` with `has_mfa` and the available `methods`.
+- An account that holds no verifiable credential at all, meaning it is not a bot, has no enrolled authenticator, and has no stored password, satisfies the sudo requirement with an empty body.
+
+The deletion runs inside the request despite the 202 status, so every matching message is gone by the time the response is returned.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel whose caller-authored messages are deleted |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Sudo-Mode-JWT?1 | string | Existing sudo mode proof |
+
+1 A valid header satisfies the sudo requirement on its own, and the body fields below are then unnecessary. The accepted proof is echoed back in the same response header
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| password?1 | string | Account password proof |
+| mfa_method?2 | string | MFA method, one of `totp` or `webauthn` |
+| mfa_code? | string | Authenticator code (1-32 characters), supplied with the `totp` method |
+| webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | WebAuthn assertion for sudo verification |
+| webauthn_challenge? | string | Challenge bound to the sudo mode assertion |
+
+1 Accepted only when the account has no enrolled authenticator, and an incorrect password fails with 400 `INVALID_PASSWORD`. An account with neither a stored password nor an enrolled authenticator never reaches this check
+
+2 Accepted only when the account has an enrolled authenticator. A failed verification returns 400 `INVALID_MFA_CODE`
+
+:::danger[Every message the caller authored is destroyed]
+The deletion has no age boundary and no selection. It permanently removes every message the caller has authored in the channel, together with its attachments and reactions, and removes each from search. There is no cancellation and no restore.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 202 | empty | Every caller-authored message in the channel was deleted |
+| 403 | [error response](/http-api/#error-response) | Caller is not in sudo mode and the request returns `SUDO_MODE_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | The caller cannot view the guild channel and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller is age restricted from the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or is unavailable |
+
+### Side effects
+
+Each matching message and its attachments are permanently deleted and removed from search. Deletions emit batched [Message Delete Bulk](/gateway/events/#message-delete-bulk) Dispatches.
+
+The operation emits no [Channel Pins Update](/gateway/events/#channel-pins-update) and no individual [Message Delete](/gateway/events/#message-delete), and it writes no guild audit log entry.
+
+### Rate limit
+
+10 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:bulk_delete::channel_id` bucket.
+
+## List pinned messages
+
+
+
+Returns a [channel pins page](#channel-pins-page-object) object holding one page of pinned messages and their pin times.
+
+### Limitations
+
+- The caller must be able to view the channel and must satisfy the channel's age verification requirement.
+- The channel must be a text-bearing type.
+- A guild caller without [READ_MESSAGE_HISTORY](/http-api/permissions/) sees only pins whose message falls on or after the guild's message history cutoff, and receives an empty page when the guild has no cutoff.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel whose pins are returned |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Number of pins to return (1-50, default 50) |
+| before? | ISO8601 timestamp | Return pins pinned before this timestamp, defaulting to the current time |
+
+:::caution[Pin pagination is timestamp-only]
+The cursor has no message ID, so a client that pages with the `pinned_at` of the last item can see that item again when another pin shares its exact timestamp. A client must deduplicate pages by message ID.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel pins page](#channel-pins-page-object) object | Pin page was returned |
+| 400 | [error response](/http-api/#error-response) | Path or cursor is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS`, or the caller lacks age verification for the channel and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient of the private channel |
+
+Acknowledging pins is a separate operation, [Acknowledge pins](#acknowledge-pins).
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:pins::channel_id` bucket.
+
+## Pin message
+
+
+
+Pins a `DEFAULT` or `REPLY` message. Returns 204 with an empty body on success. Emits [Message Update](/gateway/events/#message-update), [Channel Pins Update](/gateway/events/#channel-pins-update), and [Message Create](/gateway/events/#message-create) Gateway events.
+
+### Limitations
+
+- A guild caller requires [PIN_MESSAGES](/http-api/permissions/) and channel access.
+- A guild caller without [READ_MESSAGE_HISTORY](/http-api/permissions/) can pin only a message on or after the guild's message history cutoff.
+- A one-to-one direct message caller must satisfy the recipient's direct message policy, and one who may not send fails with 400 `CANNOT_SEND_MESSAGES_TO_USER`.
+- A group direct message and the personal notes channel apply no send policy.
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message to pin |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message was already pinned or was pinned |
+| 400 | [error response](/http-api/#error-response) | Path is invalid and the request returns `INVALID_FORM_BODY` |
+| 400 | [error response](/http-api/#error-response) | The channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 400 | [error response](/http-api/#error-response) | The target is a system message and the request returns `CANNOT_MODIFY_SYSTEM_WEBHOOK` |
+| 400 | [error response](/http-api/#error-response) | The private channel's send policy denies the caller and the request returns `CANNOT_SEND_MESSAGES_TO_USER` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` or `PIN_MESSAGES` and the request returns `MISSING_PERMISSIONS`, or message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+### Side effects
+
+Pinning a message that is not already pinned sets its pin timestamp, advances the channel's last-pin timestamp, and stores a new `CHANNEL_PINNED_MESSAGE` system message authored by the caller and referencing the pinned message.
+
+It emits [Message Update](/gateway/events/#message-update) for the target, [Channel Pins Update](/gateway/events/#channel-pins-update) for the channel, and [Message Create](/gateway/events/#message-create) for the system message, each to every session that can see the channel.
+
+A guild pin also writes one `MESSAGE_PIN` guild audit log entry with no reason. Pinning an already pinned message changes nothing and emits no Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:pins::channel_id` bucket.
+
+## Unpin message
+
+
+
+Unpins a message. Returns 204 with an empty body on success. Emits [Message Update](/gateway/events/#message-update) and [Channel Pins Update](/gateway/events/#channel-pins-update) Gateway events.
+
+### Limitations
+
+- Authorisation is exactly the same as [Pin message](#pin-message).
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message to unpin |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message was already unpinned or was unpinned |
+| 400 | [error response](/http-api/#error-response) | Path is invalid and the request returns `INVALID_FORM_BODY` |
+| 400 | [error response](/http-api/#error-response) | The channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 400 | [error response](/http-api/#error-response) | The target is a system message and the request returns `CANNOT_MODIFY_SYSTEM_WEBHOOK` |
+| 400 | [error response](/http-api/#error-response) | The private channel's send policy denies the caller and the request returns `CANNOT_SEND_MESSAGES_TO_USER` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` or `PIN_MESSAGES` and the request returns `MISSING_PERMISSIONS`, or message sending is temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+### Side effects
+
+Unpinning a currently pinned message clears its pin timestamp and emits [Message Update](/gateway/events/#message-update) and [Channel Pins Update](/gateway/events/#channel-pins-update) to every session that can see the channel. A guild unpin also writes one `MESSAGE_UNPIN` guild audit log entry with no reason.
+
+It creates no system message. The `CHANNEL_PINNED_MESSAGE` system message the original pin created stays in the channel, and the channel's last-pin timestamp is unchanged.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:pins::channel_id` bucket.
+
+## Acknowledge pins
+
+
+
+Records the channel's current pin timestamp in the authenticated identity's read state. Returns 204 with an empty body. Emits a [Channel Pins ACK](/gateway/events/#channel-pins-ack) Gateway event.
+
+The request has no body. Fluxer acknowledges the channel's own stored last-pin timestamp.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel whose current pin state is acknowledged |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Pin state was absent or acknowledged |
+| 403 | [error response](/http-api/#error-response) | Credential type or account state denies the request and it returns `ACCESS_DENIED` or `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+:::note[The channel is read without an access check]
+A channel the caller cannot see is acknowledged like any other whenever it stores a last-pin timestamp. Fluxer writes nothing when the channel does not exist or has never held a pin.
+:::
+
+### Side effects
+
+When the channel stores a last-pin timestamp, Fluxer writes that exact instant to the caller's read state and emits one [Channel Pins ACK](/gateway/events/#channel-pins-ack) with the channel ID and that timestamp to the caller's own sessions. The write replaces the stored value. When the channel stores no last-pin timestamp, Fluxer writes nothing and emits nothing.
+
+When the Dispatch fails, the request returns 502, 503, or 504 after the read-state write has landed. A client treats those statuses as indeterminate and reconciles from a later read.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:pins::channel_id` bucket, which is shared with [List pinned messages](#list-pinned-messages), [Pin message](#pin-message), and [Unpin message](#unpin-message).
+
+## Reaction emoji path value
+
+The `emoji` path value is URI-encoded and is 1 through 64 characters. A custom emoji uses `name:id`, where `id` is the trailing decimal custom emoji snowflake and `name` is everything before the final colon. Any other decoded value is read as a Unicode emoji.
+
+Fluxer validates the emoji in full only when a request would create a new reaction group on the message. At that point a Unicode value must be exactly one valid emoji, and one that is not fails with 400 `NOT_A_VALID_UNICODE_EMOJI`. A custom emoji must exist, and one that does not fails with 400 `CUSTOM_EMOJI_NOT_FOUND`. Every other reaction operation parses the value without validating it, so removing a reaction with a nonsensical emoji succeeds silently.
+
+## List reaction users unpaged
+
+
+
+Returns a bare array of [partial user](/http-api/users/#partial-user-object) objects for one ordinary reaction.
+
+### Limitations
+
+- Authorisation, query parameters, and ordering are exactly those of [List reaction users](#list-reaction-users), and the array is the same page that operation returns in its `items` field.
+
+The pagination signal is in two response headers. `X-Has-More` is `true` or `false`. `X-Next-After` is the ID of the last user in the array, and it is absent when no further page exists. [List reaction users](#list-reaction-users) returns the same users with both values in the body.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Number of users to return (1-100, default 25) |
+| after? | snowflake | Return users after this user ID cursor |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[partial user](/http-api/users/#partial-user-object) object] | Reaction users were returned |
+| 400 | [error response](/http-api/#error-response) | Path, emoji, or pagination input is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+An emoji with no reaction group on the message returns 200 with an empty array.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## List reaction users
+
+
+
+Returns a [reaction users page](#reaction-users-page-object) object holding the users who added one ordinary reaction.
+
+### Limitations
+
+- The caller must be able to view the channel, and the channel must be a text-bearing type.
+- A guild caller without [READ_MESSAGE_HISTORY](/http-api/permissions/) can read only a message on or after the guild's message history cutoff.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit? | integer | Number of users to return (1-100, default 25) |
+| after? | snowflake | Return users after this user ID cursor |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [reaction users page](#reaction-users-page-object) object | Reaction users were returned |
+| 400 | [error response](/http-api/#error-response) | Path, emoji, or pagination input is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+An emoji with no reaction group on the message returns 200 with an empty `items` array, `has_more` false, and a null `next_after`.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Add own reaction
+
+
+
+Adds the authenticated identity's ordinary reaction. Returns 204 with an empty body. Emits a [Message Reaction Add](/gateway/events/#message-reaction-add) Gateway event.
+
+### Limitations
+
+- The caller must be able to view the text-bearing channel, must not be timed out, and must reach the message under the message history cutoff.
+- A non-bot caller must have a verified email, and one that does not receives 403 `REACTION_EMAIL_VERIFICATION_REQUIRED`.
+- Creating a new emoji reaction group in a guild requires [ADD_REACTIONS](/http-api/permissions/), while adding to an existing group does not.
+- A custom emoji whose source guild is not the channel's guild requires the `feature_global_expressions` entitlement, and a caller without it receives 400 `CUSTOM_EMOJIS_REQUIRE_PREMIUM_OUTSIDE_SOURCE`.
+- In a guild channel a custom emoji additionally requires [USE_EXTERNAL_EMOJIS](/http-api/permissions/).
+- A non-bot caller must have started a session, and one that has not receives 400 `MUST_START_SESSION_BEFORE_SENDING`.
+- An unclaimed account can react only in its personal notes channel, and elsewhere receives 400 `UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS`.
+- The operation is idempotent, and this route applies no direct message send policy.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id?1 | string | Originating Gateway session ID (1-64 characters) |
+
+1 Echoed in the resulting Dispatch so the originating session can correlate its own optimistic update, and it never affects authorisation or the stored reaction
+
+:::note[Permission is checked only for a new group]
+`ADD_REACTIONS`, Unicode emoji validation, custom emoji existence, and the external emoji checks apply only when the request would open a new group. Joining an existing group is authorised by channel access alone.
+:::
+
+A message has at most the resolved `max_reactions_per_message` distinct reaction groups, which defaults to 30, and one group at most the resolved `max_users_per_message_reaction` users, which defaults to 1,000,000. Both ceilings are reported as 400 `MAX_REACTIONS`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Reaction already existed or was added |
+| 400 | [error response](/http-api/#error-response) | Path, emoji, or session is invalid, the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 400 | [error response](/http-api/#error-response) | The account has not started a session and the request returns `MUST_START_SESSION_BEFORE_SENDING` |
+| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_ADD_REACTIONS` |
+| 400 | [error response](/http-api/#error-response) | The emoji is not a valid single Unicode emoji and the request returns `NOT_A_VALID_UNICODE_EMOJI` |
+| 400 | [error response](/http-api/#error-response) | The custom emoji is unknown and the request returns `CUSTOM_EMOJI_NOT_FOUND` |
+| 400 | [error response](/http-api/#error-response) | The external emoji requires the entitlement and the request returns `CUSTOM_EMOJIS_REQUIRE_PREMIUM_OUTSIDE_SOURCE` |
+| 400 | [error response](/http-api/#error-response) | The message has reached a reaction ceiling and the request returns `MAX_REACTIONS` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL`, `ADD_REACTIONS`, or `USE_EXTERNAL_EMOJIS` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The caller is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | The caller has an unverified email and the request returns `REACTION_EMAIL_VERIFICATION_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Reactions are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel or message does not exist, or the message is outside the caller's message history cutoff |
+
+### Side effects
+
+Adding a reaction the caller does not already hold creates it and emits [Message Reaction Add](/gateway/events/#message-reaction-add) to every session that can see the channel, with `session_id` for originating session correlation. Adding a reaction the caller already has changes nothing and emits no Dispatch. It writes no audit log entry.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Remove own reaction
+
+
+
+Removes the authenticated identity's own ordinary reaction. Returns 204 with an empty body. Emits a [Message Reaction Remove](/gateway/events/#message-reaction-remove) Gateway event.
+
+### Limitations
+
+- The caller must be able to view the text-bearing channel and reach the message under the message history cutoff.
+- A removal re-runs none of the timeout, email verification, and emoji validity checks.
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id?1 | string | Originating Gateway session ID (1-64 characters) |
+
+1 Echoed in the resulting Dispatch so the originating session can correlate its own optimistic update, and it never affects authorisation
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Reaction was absent or removed |
+| 400 | [error response](/http-api/#error-response) | Path, emoji, or session input is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS`, or reactions are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the message is outside the caller's message history cutoff |
+
+A message that does not exist returns 204 rather than 404.
+
+### Side effects
+
+Fluxer deletes the reaction and emits [Message Reaction Remove](/gateway/events/#message-reaction-remove) to every session that can see the channel, with `session_id` for originating session correlation. The Dispatch is emitted whether or not the caller held the reaction.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Remove another user's reaction
+
+
+
+Removes one named user's ordinary reaction. Returns 204 with an empty body. Emits a [Message Reaction Remove](/gateway/events/#message-reaction-remove) Gateway event.
+
+### Limitations
+
+- The caller must be able to view the text-bearing channel and reach the message under the message history cutoff.
+- The message author may remove any user's reaction from their own message in any channel.
+- Any other caller must be in a guild channel and must hold [MANAGE_MESSAGES](/http-api/permissions/), so a non-author in a private channel is refused with 403 `MISSING_PERMISSIONS`.
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild.
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+| target_id1 | snowflake | The ID of the user whose reaction is removed |
+
+1 Naming the authenticated user skips the moderation check entirely, so `MANAGE_MESSAGES` is not required in that case
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| session_id?1 | string | Originating Gateway session ID (1-64 characters) |
+
+1 Echoed in the resulting Dispatch so the originating session can correlate its own optimistic update, and it never affects authorisation
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Target reaction was absent or removed |
+| 400 | [error response](/http-api/#error-response) | Path, emoji, or session input is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller is not the message author and lacks `VIEW_CHANNEL` or `MANAGE_MESSAGES`, or is not the author in a private channel, each returning `MISSING_PERMISSIONS`, or reactions are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the message is outside the caller's message history cutoff |
+
+A message that does not exist returns 204 before the authorship and permission checks. A request naming a target user with no such reaction also returns 204.
+
+### Side effects
+
+Fluxer deletes the target user's reaction and emits [Message Reaction Remove](/gateway/events/#message-reaction-remove) with the target user ID to every session that can see the channel, plus `session_id` when the request supplied one. The Dispatch is emitted whether or not the target held the reaction. It creates no audit log entry.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Remove all reactions for emoji
+
+
+
+Removes every ordinary reaction for one emoji. Returns 204 with an empty body. Emits a [Message Reaction Remove Emoji](/gateway/events/#message-reaction-remove-emoji) Gateway event.
+
+### Limitations
+
+- The caller must be able to view the text-bearing channel and reach the message under the message history cutoff.
+- The message author may clear the emoji on their own message in any channel.
+- Any other caller must be in a guild channel and must hold [MANAGE_MESSAGES](/http-api/permissions/).
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild.
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+| emoji | string | URI-encoded [reaction emoji path value](#reaction-emoji-path-value) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Emoji reactions were absent or removed |
+| 400 | [error response](/http-api/#error-response) | Path or emoji is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller is not the message author and lacks `VIEW_CHANNEL` or `MANAGE_MESSAGES`, or is not the author in a private channel, each returning `MISSING_PERMISSIONS`, or reactions are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the message is outside the caller's message history cutoff |
+
+### Side effects
+
+Fluxer deletes every reaction for the emoji and emits one [Message Reaction Remove Emoji](/gateway/events/#message-reaction-remove-emoji) to every session that can see the channel. Fluxer emits no [Message Reaction Remove](/gateway/events/#message-reaction-remove) for the individual users. The Dispatch is emitted whether or not the message had a group for the emoji. It creates no audit log entry.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Remove all reactions
+
+
+
+Removes every ordinary reaction from a message. Returns 204 with an empty body. Emits a [Message Reaction Remove All](/gateway/events/#message-reaction-remove-all) Gateway event.
+
+### Limitations
+
+- The caller must be able to view the text-bearing channel and reach the message under the message history cutoff.
+- The message author may clear their own message in any channel.
+- Any other caller must be in a guild channel and must hold [MANAGE_MESSAGES](/http-api/permissions/).
+- `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller holding the bit without an enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild.
+- The operation is idempotent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel |
+| message_id | snowflake | The ID of the message |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Reactions were absent or removed |
+| 400 | [error response](/http-api/#error-response) | Path parameters are invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller is not the message author and lacks `VIEW_CHANNEL` or `MANAGE_MESSAGES`, or is not the author in a private channel, each returning `MISSING_PERMISSIONS`, or reactions are temporarily disabled for the guild and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist, or the message is outside the caller's message history cutoff |
+
+### Side effects
+
+Fluxer deletes every reaction on the message and emits one [Message Reaction Remove All](/gateway/events/#message-reaction-remove-all) to every session that can see the channel. Fluxer emits no event for the individual groups or users. The Dispatch is emitted whether or not the message had any reaction. It creates no audit log entry.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user and channel ID, on the `channel:reactions::channel_id` bucket.
+
+## Indicate typing
+
+
+
+Publishes typing activity in a text-bearing channel. Returns 204 with an empty body. Emits a [Typing Start](/gateway/events/#typing-start) Gateway event.
+
+### Limitations
+
+- The caller must hold [SEND_MESSAGES](/http-api/permissions/) in a guild channel and must not be timed out.
+- A private channel caller needs only recipient access, because this route applies no direct message send policy.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel in which typing is indicated |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Typing operation was accepted |
+| 400 | [error response](/http-api/#error-response) | Path parameter is invalid, or the channel has no messages and the request returns `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL` |
+| 403 | [error response](/http-api/#error-response) | Caller lacks `SEND_MESSAGES` and the request returns `MISSING_PERMISSIONS`, or the caller is timed out and the request returns `COMMUNICATION_DISABLED` |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or the caller is not a recipient of the private channel |
+
+### Side effects
+
+The operation emits [Typing Start](/gateway/events/#typing-start) to every session that can see the channel, with the channel ID, the caller's user ID, and a Unix timestamp in seconds. It creates no durable state, so no read operation reports a typing indicator and the indicator expires on the client. When the guild has typing events disabled, the response is still 204 and no Dispatch is emitted.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and channel ID, on the `channel:typing::channel_id` bucket.
+
+## Acknowledge message
+
+
+
+Advances the authenticated user's read state through one message. Returns 204 with an empty body. Emits a [Message ACK](/gateway/events/#message-ack) Gateway event.
+
+### Limitations
+
+- This is a user-only operation, and a bot credential is refused with 403 `ACCESS_DENIED`.
+- Fluxer resolves neither the channel nor the message, so the operation writes the supplied identifiers straight into the caller's own read state and mutates nothing else.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the channel |
+| message_id1 | snowflake | Message ID through which the channel is acknowledged |
+
+1 Neither identifier needs to name something that exists, because both are stored as read-state keys and watermarks
+
+### JSON body
+
+An empty body is read as `{}`, and `{}` is the minimal valid form.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mention_count?1 | integer | Mention count to store for the channel (0-2,147,483,647, default `0`) |
+| manual?2 | boolean | Whether the acknowledgement was explicitly initiated by the user (default false) |
+
+1 Fluxer stores the value verbatim. A client that acknowledges partway through a channel must send the number of mentions that remain above the watermark, and one that omits the field resets the stored count to `0`
+
+2 Selects the watermark rule described below
+
+:::note[Manual acknowledgement can move the marker backwards]
+Without `manual`, the marker advances to the larger of its current value and `message_id`. With `manual` true, Fluxer sets it to exactly `message_id`, which is how a user marks a channel unread from an earlier point.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message was acknowledged |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot and the request returns `ACCESS_DENIED` |
+
+### Side effects
+
+The operation stores the new read-through marker and the supplied mention count for the caller and channel. It invalidates the caller's cached unread badge count, clears the channel's delivered notifications through the acknowledged watermark, and emits [Message ACK](/gateway/events/#message-ack) to the caller's own sessions only. No message state is modified.
+
+A non-manual acknowledgement whose `message_id` is below the stored watermark leaves the stored watermark and mention count untouched. The Dispatch reports the stored values.
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and channel ID, on the `channel:message:ack::channel_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/oauth2.mdx b/fluxer_docs/src/content/docs/http-api/oauth2.mdx
new file mode 100644
index 000000000..3ffed101c
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/oauth2.mdx
@@ -0,0 +1,775 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: OAuth2
+description: OAuth2 authorisation, token, identity, introspection, and revocation operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+OAuth2 is how a user grants an application access to their account. The same consent step can install the application's bot into a guild or group direct message. Application records, client secrets, and bot credentials belong to the [Applications resource](/http-api/applications/).
+
+## Authorisation code grant
+
+The grant begins at [Authorise application](#authorise-application). That route redirects the browser to the Fluxer consent interface, unless the caller asked for a silent authorisation. [Grant OAuth2 consent](#grant-oauth2-consent) records the decision, issues one authorisation code, and returns the callback URL that has it. The client presents that code to [Exchange OAuth2 token](#exchange-oauth2-token) with its client credentials and receives an access and refresh token pair.
+
+Proof Key for Code Exchange, or PKCE, supports the `S256` and `plain` challenge methods, and Fluxer binds the challenge to the code when it issues the code. A `code_challenge` supplied without a `code_challenge_method` is treated as `plain`. An exchange for a code that has a challenge supplies a matching `code_verifier`, and an exchange for a code that has none ignores any verifier it is given.
+
+## Credentials and lifetimes
+
+Fluxer generates an authorisation code, a client secret, an access token, and a refresh token as 32 random bytes encoded as base64url, and a bot token is the application ID, a full stop, then such a secret. Retrying an interrupted exchange with the same inputs is rejected as an invalid grant, and the client restarts the grant.
+
+An authorisation code expires 10 minutes after it is issued, an access token after 7 days, and a refresh token after 30 days. Every refresh exchange issues a replacement with a new 30 day life, so a grant ends 30 days after its last refresh.
+
+## Route authentication
+
+Every route with a delegated identity is user-only unless the operation says otherwise. Fluxer rejects a bot token and an OAuth2 bearer with 403 `ACCESS_DENIED`, and accepts an account with an outstanding required action. The token, introspection, and revocation routes authenticate the client application instead, so their [rate limit](/topics/rate-limits/) is keyed by client IP address.
+
+## OAuth2 string normalisation
+
+Fluxer trims every bounded OAuth2 string field before it checks its length. Each such field accepts 1 to 256 UTF-16 code units, `redirect_uri` included.
+
+Fluxer validates, stores, compares, forwards, and returns the normalised value. The `permissions` field and the [Grant OAuth2 consent](#grant-oauth2-consent) `response_type` field are read verbatim and have no length bound of their own. HTTP Basic client credentials do not pass through this request field normalisation. The PKCE `code_challenge` and `code_verifier` are normalised too, so a client MUST NOT rely on surrounding whitespace surviving.
+
+## OAuth2 scopes
+
+A scope names one thing the application may read or do on the user's behalf.
+
+| Value | Description |
+| --- | --- |
+| identify1 | Read the authorised user's account identity |
+| email2 | Include the authorised user's email address and email verification state |
+| guilds3 | Read the guilds the authorised user is a member of |
+| connections4 | Read the authorised user's [connections](/http-api/connections/) |
+| bot5 | Install the application's bot account into a selected guild or group direct message |
+
+1 Grants [Get OAuth2 user information](#get-oauth2-user-information) and the reduced bearer representation from [Get current user](/http-api/users/current-user/#get-current-user), and is also required before [Get current OAuth2 authorisation](#get-current-oauth2-authorisation) includes a user object
+
+2 Has no observable effect unless identify is granted as well, because every route that exposes the address also requires identify
+
+3 Grants [List current user guilds](/http-api/guilds/#list-current-user-guilds), [Get guild](/http-api/guilds/#get-guild), and [List guild roles](/http-api/permissions/#list-guild-roles)
+
+4 Grants [List connections](/http-api/connections/#list-connections)
+
+5 Installs a bot account and grants no bearer access of its own, so an application whose grants are bot alone is never presented as an authorisation by [List OAuth2 authorisations](#list-oauth2-authorisations)
+
+An unrecognised scope value is rejected when the authorisation request is completed. Fluxer splits a scope string on runs of whitespace and `+` characters, and discards empty segments. A repeated scope value is granted once.
+
+:::note[Scope ordering differs by response]
+Scope order has no semantic meaning, so a client MUST compare scopes as a set.
+:::
+
+The `scope` string of an [OAuth2 token](#oauth2-token-object) and of an [OAuth2 introspection](#oauth2-introspection-object) is sorted into the registry order above. The `scopes` array of a [current OAuth2 authorisation](#current-oauth2-authorisation-object) and of an [OAuth2 authorisation](#oauth2-authorisation-object) has the stored scopes in no defined order, with unrecognised values removed.
+
+## OAuth2 error response object
+
+The failure envelope the OAuth2 routes return.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| error | string | The [OAuth2 error code](#oauth2-error-codes) naming the failure |
+| error_description1 | string | The human-readable description of the failure |
+
+1 Always present, and some descriptions are a bare API error code identifier
+
+These two members are the whole body, so the routine that reads the ordinary [error response](/http-api/#error-response) cannot parse it. Fluxer returns every OAuth2 failure with status 400. A client MUST key on `error` and never on `error_description`.
+
+The consent, token, introspection, and revocation routes return this object for a grant, client authentication, scope, redirect, response type, or permission mask failure. Every other failure, including request validation, authentication, authorisation, and rate limiting, uses the ordinary [error response](/http-api/#error-response) with an [API error code](/http-api/errors/).
+
+:::note[Two error envelopes share status 400]
+The same route can return either one. [Authorise application](#authorise-application) returns neither, because it reports an OAuth2 failure as a query parameter on a redirect.
+:::
+
+### Example
+
+```json
+{
+ "error": "invalid_grant",
+ "error_description": "Authorization code is invalid or expired"
+}
+```
+
+### OAuth2 error codes
+
+| Value | Description |
+| --- | --- |
+| invalid_request2 | The request itself is malformed |
+| invalid_client3 | Client authentication failed |
+| invalid_grant4 | The presented grant is not usable |
+| invalid_scope | The requested scope contains a value that is not in the [scope registry](#oauth2-scopes) |
+
+2 The redirect URI is missing, unregistered, or unparseable, the response type is not `code` on a request that asks for a non-bot scope, the requested permission mask is not a non-negative integer literal, or client credentials are supplied in both an `Authorization` header and the form
+
+3 The client application is unknown, supplied no client secret, or supplied a client secret that does not match the stored hash
+
+4 The authorisation code or refresh token is unknown, expired, already used, bound to another application, or bound to another redirect URI, or the authorisation code has a PKCE challenge the presented verifier does not prove
+
+## OAuth2 token object
+
+The access and refresh token pair a successful token exchange returns.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| access_token | string | The bearer token the application presents in the `Authorization` header |
+| token_type | string | The token type, always `Bearer` |
+| expires_in1 | integer | The access token lifetime in seconds, always 604800 |
+| refresh_token2 | string | The refresh token that replaces this pair when it is exchanged |
+| scope | string | The granted scopes, space separated in registry order |
+
+1 The configured access token lifetime, restated in every successful response
+
+2 Every grant Fluxer issues is bound to a user, so a successful exchange always returns a refresh token
+
+### Example
+
+```json
+{
+ "access_token": "M2xQb1RkY0hqTmZLc1B2WndBcUx1RWc2WXRJbk9iUmE",
+ "token_type": "Bearer",
+ "expires_in": 604800,
+ "refresh_token": "VGhpc0lzQUZha2VSZWZyZXNoVG9rZW5Gb3JEb2NzMDE",
+ "scope": "identify email"
+}
+```
+
+## OAuth2 user information object
+
+The account identity an application reads with the `identify` scope.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sub | snowflake | The ID of the subject user, equal to `id` |
+| id | snowflake | The ID of the account |
+| username | string | The username of the account |
+| discriminator | string | The four-digit discriminator tag |
+| global_name | ?string | The display name of the account, or null when unset |
+| avatar1 | ?string | The avatar hash of the account, or null when it has none |
+| email?2 | ?string | The email address of the account, null unless the `email` scope is granted and an address is set |
+| verified?3 | ?boolean | Whether the address has been verified |
+| flags?4 | integer | The [public user flags](/http-api/users/#public-user-flags) on the account |
+| avatar_color5 | ?integer | The default avatar colour |
+| bot5 | boolean | Whether the account is a bot |
+| system5 | boolean | Whether the account is a system user |
+
+1 An animated avatar hash has the `a_` prefix, and the prefix is removed while the account has no animated avatar entitlement
+
+2 Always emitted
+
+3 Emitted only when the `email` scope is granted and the account has an address. An unverified address reports false
+
+4 Declared optional by the response schema and populated on every response
+
+5 Emitted on every response although the response schema does not declare it, so a client MUST NOT depend on it
+
+## OAuth2 introspection object
+
+The liveness and scope report Fluxer returns for one presented token.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| active | boolean | Whether the presented token is live and belongs to the authenticated client |
+| scope? | string | The granted scopes, space separated in registry order |
+| client_id? | snowflake | The ID of the application the token was issued to |
+| username?1 | string | A reserved member that Fluxer never populates |
+| token_type? | string | The kind of token presented, `Bearer` for an access token and `refresh_token` for a refresh token |
+| exp?2 | integer | The access token expiry time in Unix seconds |
+| iat? | integer | The issue time in Unix seconds |
+| sub?3 | snowflake | The ID of the subject user |
+
+1 Absent from every response
+
+2 Absent for a refresh token, which still expires 30 days after it is issued
+
+3 Every token Fluxer issues is bound to a user, so an active result always has a subject
+
+Apart from `username` and `exp`, every optional field above is present when `active` is true and absent when it is false. An active access token has `exp` and an active refresh token does not.
+
+:::note[Several conditions produce the same inactive body]
+The body has only `active` set to false, and names no reason.
+:::
+
+Fluxer reports an unknown token, an expired token, and a token issued to another application as inactive. Introspection never resolves the account behind the token, so an active result reports on the token alone.
+
+## Current OAuth2 authorisation object
+
+The application, scopes, and expiry behind the access token the caller presented.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application | [OAuth2 application summary](#oauth2-application-summary-object) object | The application the access token was issued to |
+| scopes1 | array[string] | The granted scopes, with unrecognised values removed |
+| expires | ISO8601 timestamp | The expiry time of the access token |
+| user?2 | [OAuth2 authorised user](#oauth2-authorised-user-object) object | The account the access token was issued for |
+
+1 Emitted in no defined order, and `bot` is retained here even though it grants no delegated read
+
+2 Present only when the token grants `identify` and the account still exists, so a token restricted to `bot` never has one
+
+## OAuth2 application summary object
+
+The reduced application record in a [current OAuth2 authorisation](#current-oauth2-authorisation-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The name of the application |
+| icon1 | ?string | The icon hash, always null on this object |
+| description1 | ?string | The description, always null on this object |
+| bot_public | boolean | Whether any eligible user can install the bot |
+| bot_require_code_grant | boolean | Whether bot installation requires an OAuth2 code grant |
+| flags2 | integer | The application flags, always 0 |
+
+1 Null even when the application's bot has an avatar or biography
+
+2 No application flag bit is defined
+
+## OAuth2 authorised user object
+
+The account a [current OAuth2 authorisation](#current-oauth2-authorisation-object) was issued for.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the account |
+| username | string | The username of the account |
+| discriminator | string | The four-digit discriminator tag |
+| global_name | ?string | The display name of the account, or null when unset |
+| avatar1 | ?string | The avatar hash of the account, or null when it has none |
+| avatar_color | ?integer | The default avatar colour |
+| bot?2 | boolean | Whether the account is a bot |
+| system?2 | boolean | Whether the account is a system user |
+| flags | integer | The [public user flags](/http-api/users/#public-user-flags) on the account |
+| email?3 | ?string | The email address of the account, null unless the `email` scope is granted and an address is set |
+| verified?4 | ?boolean | Whether the address has been verified |
+| sub5 | snowflake | The ID of the subject user, equal to `id` |
+
+1 An animated avatar hash has the `a_` prefix, and the prefix is removed while the account has no animated avatar entitlement
+
+2 Emitted on every response, and false when the account is neither
+
+3 Always emitted
+
+4 Emitted only when the `email` scope is granted and the account has an address
+
+5 Emitted on every response although the response schema does not declare it, so a client MUST NOT depend on it
+
+## OAuth2 authorisation object
+
+One application the current user has authorised, aggregated across its live refresh tokens.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application | [authorised application](#authorised-application-object) object | The application the user has granted access to |
+| scopes1 | array[string] | The scopes aggregated across the user's live refresh tokens for the application |
+| authorized_at2 | ISO8601 timestamp | The earliest creation time among the refresh tokens that contributed to this entry |
+
+1 The refresh token that first contributes an application contributes only its recognised scopes other than `bot`. Every further refresh token for the same application contributes its stored scope set unfiltered, so `bot` and an unrecognised value can appear once more than one refresh token contributes
+
+2 Lowered by every refresh token that contributes to the entry, including one that has only `bot`
+
+An authorisation appears only while the application holds a live refresh grant with a recognised scope other than `bot`.
+
+## Authorised application object
+
+The application named by an [OAuth2 authorisation](#oauth2-authorisation-object) entry.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the application |
+| name | string | The name of the application |
+| icon1 | ?string | The avatar hash of the application's bot account |
+| description2 | ?string | The description, always null on this object |
+| bot_public | boolean | Whether any eligible user can install the bot |
+
+1 Null when the application has no bot account or that account has no avatar. The stored hash is reported unfiltered, so an animated hash retains its `a_` prefix
+
+2 An application has no independent description
+
+## OAuth2 consent response object
+
+The callback URL [Grant OAuth2 consent](#grant-oauth2-consent) returns after it records the decision.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| redirect_to1 | string | The absolute URL with the authorisation result |
+
+1 The URL has the `code` query parameter, and the normalised caller `state` when one was supplied
+
+Fluxer builds the URL from the registered redirect URI the request resolved to, which is the Fluxer application endpoint when a bot-only authorisation supplied none. The code records the URL serialisation of that resolved URI, and the client later presents that exact serialisation to [Exchange OAuth2 token](#exchange-oauth2-token).
+
+## Authorise application
+
+
+
+Starts an authorisation request. A query that passes request validation always answers with a redirect, and authentication is optional.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| response_type? | string | The OAuth2 response type, `code` when supplied |
+| client_id | snowflake | The ID of the application being authorised |
+| redirect_uri?1 | string | The absolute redirect URI registered by the application (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| scope | string | The requested [OAuth2 scopes](#oauth2-scopes), space separated (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| state?4 | string | The caller state (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| prompt? | string | The prompt mode, either `consent` or `none` |
+| guild_id?2 | snowflake | The guild pre-selected for the `bot` scope |
+| channel_id?2 | snowflake | The group direct message pre-selected for the `bot` scope |
+| permissions?3 | string | The bot permission integer literal |
+| disable_guild_select? | string | Whether the consent interface suppresses guild selection, as the literal `true` or `false` |
+| code_challenge? | string | The PKCE challenge bound to the issued code (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| code_challenge_method? | string | The PKCE method, either `S256` or `plain`, defaulting to `plain` when a challenge is supplied without it |
+
+1 The value must parse as an absolute URL with a scheme and a host, and must match a registered redirect URI before a code is issued
+
+2 Both values are forwarded to the consent interface unchanged, and their mutual exclusion is enforced by [Grant OAuth2 consent](#grant-oauth2-consent)
+
+3 The value is forwarded to the consent interface unchanged and is parsed as an integer only by [Grant OAuth2 consent](#grant-oauth2-consent)
+
+4 The value is returned unchanged after normalisation, on both the success redirect and the error redirect
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 302 | empty | The consent interface, a successful client callback, or an OAuth2 error callback was selected |
+
+The 302 `Location` header is the selected redirect.
+
+Without `prompt=none`, the redirect targets the Fluxer consent interface and forwards every supplied parameter, so no application, scope, or redirect validation happens yet.
+
+With `prompt=none`, a request that resolves no user redirects with `error=login_required`. Every other silent failure redirects with `error=consent_required`. That covers every failure to issue a code, including an unknown application, an unrecognised scope, a missing or unregistered redirect URI, and a private bot the caller does not own. It also covers a requested non-bot scope the user has not already granted to this application. A silent request that asks only for `bot` skips the prior-grant test.
+
+An error redirect has `error` and the normalised `state` when the caller supplied one. It has no `error_description`, and never the more specific [OAuth2 error code](#oauth2-error-codes) the underlying failure raised.
+
+:::caution[An unregistered redirect URI never receives the error]
+The error redirect targets the supplied `redirect_uri` only when the named application has registered that exact string, so this route cannot bounce a browser to an arbitrary origin.
+:::
+
+Fluxer compares the two strings byte for byte, so a differing scheme, port, trailing slash, query, or fragment does not match. Every other case redirects to the Fluxer application endpoint. That covers an omitted redirect URI, an unknown application, and an application with no registered URI at all.
+
+### Side effects
+
+A successful `prompt=none` request issues one authorisation code bound to the application, user, resolved redirect URI, granted scopes, and PKCE challenge. The code expires after 10 minutes. Nothing else changes. Only [Grant OAuth2 consent](#grant-oauth2-consent) installs a bot.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, or for each client IP address when the request has no credential, on the `oauth:authorize` bucket.
+
+## Grant OAuth2 consent
+
+
+
+Completes an authorisation request. Returns an [OAuth2 consent response](#oauth2-consent-response-object) object with the callback URL.
+
+Installing a bot into a guild requires [MANAGE_GUILD or ADMINISTRATOR](/http-api/permissions/) in that guild. Installing it into a group direct message requires the caller to be a recipient of that channel.
+
+A guild installation emits [Guild Create](/gateway/events/#guild-create), [Guild Member Add](/gateway/events/#guild-member-add), and, unless the guild suppresses join notifications, [Message Create](/gateway/events/#message-create). When a non-zero permission mask is requested it also emits [Guild Role Create](/gateway/events/#guild-role-create) and [Guild Member Update](/gateway/events/#guild-member-update). A group direct message installation emits [Channel Create](/gateway/events/#channel-create) to the bot, [Channel Recipient Add](/gateway/events/#channel-recipient-add) to the existing recipients, and [Message Create](/gateway/events/#message-create).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| response_type?1 | string | The OAuth2 response type |
+| client_id | snowflake | The ID of the application being authorised |
+| redirect_uri?2 | string | The absolute redirect URI registered by the application (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| scope | string | The requested [OAuth2 scopes](#oauth2-scopes), space separated (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| state? | string | The caller state appended to the callback URL (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| permissions?3 | string | The bot permission integer literal requested for the installed bot |
+| guild_id?4 | snowflake | The guild the bot is installed into |
+| channel_id?4 | snowflake | The group direct message the bot is installed into |
+| code_challenge? | string | The PKCE challenge bound to the issued code (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| code_challenge_method? | string | The PKCE method, either `S256` or `plain`, defaulting to `plain` when a challenge is supplied without it |
+
+1 On a request that asks for any scope other than `bot`, an omitted value defaults to `code` and a supplied value must be `code`. On a request that asks for the `bot` scope alone the field is ignored
+
+2 Required unless the request asks for the `bot` scope alone, and it must match a registered redirect URI. A bot-only request still requires it when the application sets `bot_require_code_grant`
+
+3 The value must parse as a non-negative integer literal. Every bit outside the defined [permission](/http-api/permissions/) mask is cleared before use, and a caller without ADMINISTRATOR cannot request a retained bit it does not itself hold
+
+4 The two fields are mutually exclusive
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [OAuth2 consent response](#oauth2-consent-response-object) object | Consent completed |
+| 4005 | [error response](/http-api/#error-response) | The body fails validation, or the bot installation target is not usable |
+| 4006 | [OAuth2 error](#oauth2-error-response-object) object | The application, scope, redirect URI, response type, or permission mask is rejected |
+| 4037 | [error response](/http-api/#error-response) | The credential or the caller's permissions do not allow the installation |
+| 404 | [error response](/http-api/#error-response) | The selected channel does not exist (`UNKNOWN_CHANNEL`), or the selected guild does not exist (`UNKNOWN_GUILD`) |
+
+5 Both installation targets are supplied, the selected channel is not a group direct message (`INVALID_CHANNEL_TYPE`), the application has no bot (`NOT_A_BOT_APPLICATION`), the bot is already a member of the selected guild (`BOT_ALREADY_IN_GUILD`), the guild has reached its member limit (`MAX_GUILD_MEMBERS`) or its role limit (`MAX_GUILD_ROLES`), or the group direct message has reached its recipient limit (`MAX_GROUP_DM_RECIPIENTS`)
+
+6 The application is unknown, a scope is unrecognised, the redirect URI is missing or unregistered, the response type is not `code` on a request that asks for a non-bot scope, or the permission mask is not a non-negative integer literal
+
+7 The credential is a bot token or an OAuth2 bearer (`ACCESS_DENIED`), the bot is private and the caller does not own the application (`BOT_IS_PRIVATE`), the caller holds neither MANAGE_GUILD nor ADMINISTRATOR in the selected guild, or requests a permission bit it does not itself hold (`MISSING_PERMISSIONS`), or the caller is not a recipient of the selected channel (`MISSING_ACCESS`)
+
+### Side effects
+
+Consent issues one authorisation code bound to the application, user, resolved redirect URI, granted scopes, and PKCE challenge, and returns the callback URL that has it.
+
+With the `bot` scope and a guild target, consent adds the bot to the guild with the bot-invite join source and the caller recorded as inviter, bypassing both the bot account's own guild count limit and the guild ban list. The guild member limit still applies. When a non-zero permission mask is requested, it also creates one role with exactly those permissions, names the role after the application, and assigns it to the bot.
+
+That role is created at position 1 with no colour and is neither hoisted nor mentionable. It consumes a guild role slot and is not removed when the bot leaves. The installation records bot add, role create, and member role update [audit log](/http-api/guild-audit-logs/) entries.
+
+With the `bot` scope and a group direct message target, it adds the bot as a recipient and stores the recipient-add system message.
+
+When any part of the installation fails, Fluxer cancels the newly issued authorisation code, so the caller never receives a usable code.
+
+:::caution[The installed role has the filtered mask]
+The installation either succeeds with exactly that mask or fails with a missing-permissions error.
+:::
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `oauth:authorize` bucket.
+
+## Exchange OAuth2 token
+
+
+
+Exchanges an authorisation code or a refresh token. Returns an [OAuth2 token](#oauth2-token-object) object on success.
+
+The request body is `application/x-www-form-urlencoded` or `multipart/form-data`. The client application authenticates with HTTP Basic or with the `client_id` and `client_secret` form fields. No user credential is accepted.
+
+:::caution[Refresh rotates the token pair]
+A refresh exchange consumes the presented refresh token, so a client that loses the response MUST restart the authorisation code grant.
+:::
+
+Earlier access tokens for the same user and application survive a refresh and expire on their own schedule.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization?1 | string | The HTTP Basic client credentials |
+| Content-Type | string | The request media type, either `application/x-www-form-urlencoded` or `multipart/form-data` |
+
+1 This route resolves the client from the form first and falls back to the header, so supplying both is accepted. The introspection and revocation routes reject that combination as `invalid_request`
+
+### Form body
+
+#### Authorisation code fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| grant_type | string | The grant type, always `authorization_code` on this variant |
+| code | string | The authorisation code returned by the callback (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| redirect_uri1 | string | The redirect URI the authorisation request resolved to (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| client_id?2 | snowflake | The client ID, which takes precedence over the HTTP Basic value |
+| client_secret?2 | string | The client secret, which takes precedence over the HTTP Basic value (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| code_verifier?3 | string | The PKCE verifier proving the challenge stored on the code (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+
+1 Compared as an exact string against the URI recorded on the code, which the [OAuth2 consent response](#oauth2-consent-response-object) describes. A trailing slash, host casing, or percent-encoding difference is rejected as an invalid grant
+
+2 Each credential field is resolved on its own, so a form value is used when it is supplied and the HTTP Basic value is used otherwise, and a form `client_id` can be combined with an HTTP Basic client secret. A request that resolves no client ID or no client secret is rejected as `invalid_client`
+
+3 Required when the authorisation code has a PKCE challenge. For the `S256` method it is hashed with SHA-256 and base64url-encoded before comparison with the challenge stored on the code, and for `plain` it is compared unchanged
+
+When the code is PKCE-bound, an omitted or incorrect verifier is rejected as `invalid_grant` in the OAuth2 error response.
+
+#### Refresh token fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| grant_type | string | The grant type, always `refresh_token` on this variant |
+| refresh_token | string | The refresh token to exchange (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| client_id?1 | snowflake | The client ID, which takes precedence over the HTTP Basic value |
+| client_secret?1 | string | The client secret, which takes precedence over the HTTP Basic value (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+
+1 Each credential field is resolved on its own, so a form value is used when it is supplied and the HTTP Basic value is used otherwise, and a form `client_id` can be combined with an HTTP Basic client secret. A request that resolves no client ID or no client secret is rejected as `invalid_client`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [OAuth2 token](#oauth2-token-object) object | A token pair was issued |
+| 400 | [error response](/http-api/#error-response) | The form fails validation, including an unsupported `grant_type` |
+| 4002 | [OAuth2 error](#oauth2-error-response-object) object | Client authentication or the presented grant fails |
+
+2 Client authentication, the authorisation code, the refresh token, the redirect URI, or a required PKCE verifier is missing or does not prove the grant
+
+An authorisation code is single-use. Fluxer consumes it before it issues the token pair. A replayed code is rejected as an invalid grant. Deleting an account purges every OAuth2 access and refresh token it holds, so a refresh exchange for a deleted account is rejected as an invalid grant as well. An authorisation code issued before the deletion still exchanges until it expires.
+
+Fluxer verifies client authentication before it examines the grant, so an unknown application, an absent client secret, and a wrong client secret are all reported as `invalid_client`.
+
+:::note[No client credentials grant]
+Only `authorization_code` and `refresh_token` exist, and Fluxer issues no token that is unbound to a user.
+:::
+
+Any other `grant_type`, `client_credentials` included, fails request validation with the ordinary [error response](/http-api/#error-response).
+
+### Side effects
+
+A successful exchange issues one access token that expires after 7 days and one refresh token that expires after 30 days. An authorisation code exchange consumes the code, and a refresh exchange consumes the presented refresh token. Nothing else changes and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+120 requests per minute for each client IP address, on the `oauth:token` bucket.
+
+## Get OAuth2 user information
+
+
+
+Returns the [OAuth2 user information](#oauth2-user-information-object) object for the account the token was issued to. Requires the `identify` [scope](#oauth2-scopes).
+
+An OAuth2 bearer access token is required. The `email` scope populates the address fields.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization | string | The OAuth2 bearer access token |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [OAuth2 user information](#oauth2-user-information-object) object | User information was returned |
+| 4011 | [error response](/http-api/#error-response) | The bearer token is missing, unusable, or expired |
+| 403 | [error response](/http-api/#error-response) | The token lacks `identify` (`MISSING_OAUTH_SCOPE`) |
+
+1 The credential is missing, is not an OAuth2 bearer access token, or has expired (`UNAUTHORIZED`), or the bearer no longer resolves to a user and an application (`INVALID_TOKEN`)
+
+A missing scope failure has the required scope as a top-level `required_scope` member of the [error response](/http-api/#error-response), so a client can request the correct authorisation without parsing the message text.
+
+### Rate limit
+
+120 requests per minute for each pair of authenticated user and bearer application, on the `oauth:introspect` bucket.
+
+## Introspect OAuth2 token
+
+
+
+Returns an [OAuth2 introspection](#oauth2-introspection-object) object describing one access or refresh token.
+
+The request body is `application/x-www-form-urlencoded` or `multipart/form-data`. The client application authenticates with HTTP Basic or with the `client_id` and `client_secret` form fields. No user credential is accepted.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization? | string | The HTTP Basic client credentials |
+| Content-Type | string | The request media type, either `application/x-www-form-urlencoded` or `multipart/form-data` |
+
+### Form body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The access or refresh token to inspect (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| client_id?1 | snowflake | The client ID, supplied when HTTP Basic is not used |
+| client_secret?1 | string | The client secret, supplied when HTTP Basic is not used (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+
+1 Supplying either field together with a well-formed HTTP Basic `Authorization` header is rejected as `invalid_request`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [OAuth2 introspection](#oauth2-introspection-object) object | Introspection completed, including a token reported as inactive |
+| 400 | [error response](/http-api/#error-response) | The form fails validation |
+| 4002 | [OAuth2 error](#oauth2-error-response-object) object | Client authentication failed |
+
+2 Client credentials are supplied in both the header and the form (`invalid_request`), no client secret is supplied, the client is unknown, or the client secret is wrong (`invalid_client`)
+
+Fluxer ignores an `Authorization` header that is not well-formed HTTP Basic and reads the client credentials from the form.
+
+Only a token issued to the authenticated client is reported as active.
+
+### Rate limit
+
+120 requests per minute for each client IP address, on the `oauth:introspect` bucket.
+
+## Revoke OAuth2 token
+
+
+
+Revokes a presented access or refresh token.
+
+The request body is `application/x-www-form-urlencoded` or `multipart/form-data`. The client application authenticates with HTTP Basic or with the `client_id` and `client_secret` form fields. No user credential is accepted.
+
+:::caution[Revocation clears the whole grant]
+Revoking a token deletes every access token and every refresh token its user holds for the authenticated application.
+:::
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization? | string | The HTTP Basic client credentials |
+| Content-Type | string | The request media type, either `application/x-www-form-urlencoded` or `multipart/form-data` |
+
+### Form body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | The access or refresh token to revoke (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+| token_type_hint?1 | string | The kind of token presented, either `access_token` or `refresh_token` |
+| client_id?2 | snowflake | The client ID, supplied when HTTP Basic is not used |
+| client_secret?2 | string | The client secret, supplied when HTTP Basic is not used (1-256 UTF-16 code units after [normalisation](#oauth2-string-normalisation)) |
+
+1 Only `refresh_token` matches the presented value against refresh tokens, and access tokens are still tried when no refresh token matches. An absent hint and an `access_token` hint match access tokens only
+
+2 Supplying either field together with a well-formed HTTP Basic `Authorization` header is rejected as `invalid_request`
+
+A client that presents a refresh token MUST send `token_type_hint=refresh_token`, or the revocation takes no effect.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | empty | The revocation request was processed, including a token that matched nothing |
+| 400 | [error response](/http-api/#error-response) | The form fails validation |
+| 4003 | [OAuth2 error](#oauth2-error-response-object) object | Client authentication failed |
+
+3 Client credentials are supplied in both the header and the form (`invalid_request`), no client secret is supplied, the client is unknown, or the client secret is wrong (`invalid_client`)
+
+Fluxer ignores an `Authorization` header that is not well-formed HTTP Basic and reads the client credentials from the form.
+
+An unknown token and a token issued to another application both return 200, so the response never reveals whether a token exists.
+
+### Side effects
+
+On a match, Fluxer deletes the user's complete token set for the authenticated application. Those tokens stop authenticating requests at once. A token that matched nothing mutates no state.
+
+No Gateway Dispatch is emitted, so a client holding a revoked token learns of the revocation from its next rejected request.
+
+### Rate limit
+
+120 requests per minute for each client IP address, on the `oauth:introspect` bucket.
+
+## Get current OAuth2 authorisation
+
+
+
+Returns the [current OAuth2 authorisation](#current-oauth2-authorisation-object) object for the presented access token. Requires no scope.
+
+An OAuth2 bearer access token is required, and the token must belong to a non-bot account. The response has a user object only when the token holds `identify`.
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Authorization | string | The OAuth2 bearer access token |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [current OAuth2 authorisation](#current-oauth2-authorisation-object) object | The authorisation was returned |
+| 4011 | [error response](/http-api/#error-response) | The bearer token is missing, unusable, or expired |
+| 403 | [error response](/http-api/#error-response) | The token belongs to a bot account (`ACCESS_DENIED`) |
+
+1 The credential is missing, is not an OAuth2 bearer access token, or has expired (`UNAUTHORIZED`), the bearer has no user identity (`UNAUTHORIZED`), or it names an application that no longer exists (`INVALID_TOKEN`)
+
+### Rate limit
+
+120 requests per minute for each pair of authenticated user and bearer application, on the `oauth:introspect` bucket.
+
+## List OAuth2 authorisations
+
+
+
+Returns the [OAuth2 authorisation](#oauth2-authorisation-object) objects the current user has granted to applications.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[OAuth2 authorisation](#oauth2-authorisation-object) object] | The authorisations were returned |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or an OAuth2 bearer (`ACCESS_DENIED`) |
+
+An application appears in the listing only while it holds a live refresh grant with a recognised scope other than `bot`. A deleted application is omitted. The listing is unpaginated.
+
+### Rate limit
+
+60 requests per minute for each authenticated user, on the `oauth_dev:clients:list` bucket.
+
+## Revoke OAuth2 authorisation
+
+
+
+Revokes the current user's authorisation for one application.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| applicationId | snowflake | The ID of the application whose authorisation is revoked |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The authorisation was revoked, including when no grant remained |
+| 400 | [error response](/http-api/#error-response) | The application ID is not a valid snowflake |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or an OAuth2 bearer (`ACCESS_DENIED`) |
+| 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) |
+
+Fluxer resolves the application before it deletes any token, so a 404 leaves the grant untouched.
+
+:::caution[Revocation cannot be undone]
+The user completes the [authorisation code grant](#authorise-application) again before the application can act on the account, and the application receives no notice that its access has ended.
+:::
+
+### Side effects
+
+Revocation deletes every access token and refresh token the current user holds for the named application. Those tokens stop authenticating requests at once.
+
+A bot the application installed stays in every guild and group direct message it joined. No Gateway Dispatch is emitted.
+
+### Rate limit
+
+120 requests per minute for each authenticated user, on the `oauth:introspect` bucket.
+
+## Bulk revoke OAuth2 authorisations
+
+
+
+Revokes the current user's authorisations for several applications.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| application_ids1 | array[snowflake] | The IDs of the applications to revoke (1-100) |
+
+1 Repeated IDs are collapsed before any revocation is performed
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The authorisations were revoked |
+| 400 | [error response](/http-api/#error-response) | The body fails validation, including an empty or oversized ID array |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or an OAuth2 bearer (`ACCESS_DENIED`) |
+| 404 | [error response](/http-api/#error-response) | One of the named applications does not exist (`UNKNOWN_APPLICATION`) |
+
+Fluxer resolves every named application before it deletes any token. One unknown application leaves every named application untouched.
+
+### Side effects
+
+Revocation deletes every access token and refresh token the current user holds for each named application. Those tokens stop authenticating requests at once. Bot memberships are unaffected and no Gateway Dispatch is emitted.
+
+### Rate limit
+
+120 requests per minute for each authenticated user, on the `oauth:introspect` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/permissions.mdx b/fluxer_docs/src/content/docs/http-api/permissions.mdx
new file mode 100644
index 000000000..7a74b1bd8
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/permissions.mdx
@@ -0,0 +1,541 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Roles and permissions
+description: The guild permission bitfield, permission computation, role hierarchy, and role operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A permission grants a member one ability in a guild, such as sending a message or banning another member. A role has a set of them as an unsigned 64-bit mask, and a [permission overwrite](/http-api/channels/#permission-overwrite-object) stored on a channel adjusts that set for that channel.
+
+Only [List guild roles](#list-guild-roles) accepts an OAuth2 bearer credential, and every other route here rejects one with 403 `ACCESS_DENIED`. A guild with [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) rejects an authenticated request with 403 `MISSING_ACCESS`, and [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) does the same for an account without the instance staff flag.
+
+Fluxer resolves the guild before any role. A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is visible to any authenticated caller. A route that names a role asserts `MANAGE_ROLES` before it reads the role. A caller without the permission receives 403 `MISSING_PERMISSIONS` even for a role that does not exist.
+
+## Permissions
+
+Every bit Fluxer defines appears below.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | CREATE_INSTANT_INVITE | Create an invite to a guild channel |
+| 1 << 1 | KICK_MEMBERS | Remove a member from the guild |
+| 1 << 2 | BAN_MEMBERS | Create and revoke a guild ban |
+| 1 << 3 | ADMINISTRATOR1 | Hold every permission and bypass every channel permission overwrite |
+| 1 << 4 | MANAGE_CHANNELS | Create, modify, reorder, and delete a guild channel |
+| 1 << 5 | MANAGE_GUILD | Modify guild settings and manage guild-level resources such as invites, the vanity URL, and discovery |
+| 1 << 6 | ADD_REACTIONS | Add a reaction to a message in a guild channel |
+| 1 << 7 | VIEW_AUDIT_LOG | Read the guild audit log |
+| 1 << 8 | PRIORITY_SPEAKER | Use priority speaker in a guild voice channel, evaluated by the client |
+| 1 << 9 | STREAM2 | Publish a screenshare track on a guild voice connection |
+| 1 << 10 | VIEW_CHANNEL3 | View a guild channel |
+| 1 << 11 | SEND_MESSAGES | Send a message in a guild text or voice channel |
+| 1 << 12 | SEND_TTS_MESSAGES | Send a message that requests text-to-speech playback |
+| 1 << 13 | MANAGE_MESSAGES4 | Delete another member's message, bulk delete messages, remove another member's reaction, and edit the flags and attachments of another member's message |
+| 1 << 14 | EMBED_LINKS | Have a link in the caller's own message expanded into an embed |
+| 1 << 15 | ATTACH_FILES | Attach a file to a message |
+| 1 << 16 | READ_MESSAGE_HISTORY | Read messages that were sent before the current access began |
+| 1 << 17 | MENTION_EVERYONE | Use the everyone and here mentions in a guild channel |
+| 1 << 18 | USE_EXTERNAL_EMOJIS | Use an emoji owned by another guild in a message or a reaction |
+| 1 << 20 | CONNECT | Connect to a guild voice channel |
+| 1 << 21 | SPEAK | Transmit audio in a guild voice channel |
+| 1 << 22 | MUTE_MEMBERS | Apply and clear a moderator mute in a guild voice channel |
+| 1 << 23 | DEAFEN_MEMBERS | Apply and clear a moderator deafen in a guild voice channel |
+| 1 << 24 | MOVE_MEMBERS | Move a member between guild voice channels or disconnect a member |
+| 1 << 25 | USE_VAD | Use voice activity detection rather than push-to-talk in a guild voice channel, evaluated by the client |
+| 1 << 26 | CHANGE_NICKNAME | Change the caller's own nickname in the guild |
+| 1 << 27 | MANAGE_NICKNAMES | Change another member's nickname in the guild |
+| 1 << 28 | MANAGE_ROLES5 | Create, modify, position, and delete a role, assign a role to a member, and set a channel permission overwrite |
+| 1 << 29 | MANAGE_WEBHOOKS | Create, modify, and delete a webhook on a guild channel |
+| 1 << 30 | MANAGE_EXPRESSIONS6 | Modify and delete an emoji or sticker created by another member |
+| 1 << 37 | USE_EXTERNAL_STICKERS | Use a sticker owned by another guild |
+| 1 << 40 | MODERATE_MEMBERS | Apply and clear a member communication timeout |
+| 1 << 43 | CREATE_EXPRESSIONS6 | Create an emoji or sticker, and modify or delete the ones the caller created |
+| 1 << 51 | PIN_MESSAGES | Pin and unpin a message in a guild channel |
+| 1 << 52 | BYPASS_SLOWMODE | Send a message without waiting for the channel slowmode interval |
+| 1 << 53 | UPDATE_RTC_REGION | Change the voice region of a guild voice channel |
+| 1 << 54 | VIEW_CHANNEL_MEMBERS7 | View the member list of a guild channel |
+
+1 A member who holds the bit through any assigned role receives the complete 64-bit mask, and Fluxer applies no channel overwrite afterwards
+
+2 The same bit gates the camera track, so a member without it publishes neither screenshare nor video
+
+3 Fluxer also treats a category as visible when at least one channel inside it is visible
+
+4 Deleting or editing the caller's own message does not require the bit
+
+5 A role mutation also needs [role hierarchy](#role-hierarchy) authority over the target role
+
+6 Modifying or deleting an expression created by the caller requires `CREATE_EXPRESSIONS`, and the same operation on an expression created by another member requires `MANAGE_EXPRESSIONS`
+
+7 The bit is [feature-gated on write](#feature-gated-permission-bits), so a request that does not declare the feature cannot change it
+
+Bits 19, 31 through 36, 38, 39, 41, 42, 44 through 50, and 55 through 63 are unassigned.
+
+:::note[`STREAM` authorises a track on the existing connection]
+The track is a screenshare or a camera. Fluxer opens no second connection for it, and losing the bit mutes the published tracks while the voice connection stays up.
+:::
+
+:::note[Permission masks are decimal strings on the wire]
+Every permission response field uses the decimal representation of an unsigned 64-bit integer inside a JSON string. A permission request field accepts that decimal string or a JSON number that is a safe integer, and the value is a non-negative integer no greater than 9223372036854775807.
+:::
+
+A value that is not a run of decimal digits is rejected with the validation code `INVALID_INTEGER_FORMAT`, and one above the maximum with `INTEGER_OUT_OF_INT64_RANGE`. A JSON number cannot represent the full 64-bit range, so a client MUST parse and combine masks with unsigned 64-bit integer arithmetic and MUST NOT compare them with string equality.
+
+Fluxer discards a bit it does not define. A role mutation intersects the requested mask with the set of defined bits before authorisation. An unassigned bit never appears in a response. A channel permission overwrite uses the same mask and the same defined-bit set, and Fluxer does not filter bits by channel type, so an overwrite can name a permission its channel does not use.
+
+### Feature-gated permission bits
+
+Fluxer gates some bits on a client feature declaration. A client sends the feature set in the `X-Fluxer-Features` request header as a comma-separated list.
+
+One bit is gated today. `VIEW_CHANNEL_MEMBERS` requires the feature name `view_channel_members_permission`.
+
+On a role or overwrite write that does not declare the matching feature, Fluxer keeps the gated bit at its stored value, so the request can neither set nor clear it. Every other bit in the same mask is written normally.
+
+Fluxer trims and lowercases each name in the header before matching it. A name that is empty, longer than 64 characters, or containing a character outside `a-z`, `0-9`, and `_` is ignored. Fluxer reads at most 64 names from one header. An absent or empty header declares no features.
+
+A [Create guild role](#create-guild-role) request that supplies `permissions` without declaring the feature always creates the role with `VIEW_CHANNEL_MEMBERS` cleared. A request that omits `permissions` copies the everyone role mask without applying the gate.
+
+## Elevated permissions
+
+`KICK_MEMBERS`, `BAN_MEMBERS`, `ADMINISTRATOR`, `MANAGE_CHANNELS`, `MANAGE_GUILD`, `MANAGE_MESSAGES`, `MANAGE_ROLES`, `MANAGE_WEBHOOKS`, and `MODERATE_MEMBERS` are elevated permissions. In a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, only the guild owner exercises them without an enrolled authenticator.
+
+An operation that asserts an elevated permission the caller holds but cannot exercise returns 400 `TWO_FACTOR_REQUIRED`. The check runs after Fluxer has confirmed the permission itself, so a caller who lacks the permission outright receives 403 `MISSING_PERMISSIONS` instead.
+
+[List guild roles](#list-guild-roles) asserts no permission, so it never produces `TWO_FACTOR_REQUIRED`.
+
+:::note[The reported mask keeps every elevated bit]
+Elevation gates the operation. A `permissions` field reports what the caller's roles grant, so a client hiding an action must compare the guild [MFA level](/http-api/guilds/#mfa-levels) and its own enrolment state.
+:::
+
+## Permission computation
+
+Fluxer computes a permission set for one member in one guild, and optionally in one channel of that guild. The result is the complete effective mask for that member at that moment.
+
+The guild owner receives the complete 64-bit mask. A user who is not a member receives an empty mask.
+
+Otherwise Fluxer starts from the permissions of the everyone role, whose [snowflake](/snowflakes/) equals the guild snowflake, then adds the permissions of every role assigned to the member with a bitwise union. If the result contains `ADMINISTRATOR` at that point, Fluxer returns the complete 64-bit mask and runs no further step.
+
+When no channel was named, the union is the final result. When a channel was named, Fluxer applies that channel's stored [permission overwrites](/http-api/channels/#permission-overwrite-object) to the union in three steps:
+
+1. The everyone role overwrite, by removing its denied bits and then adding its allowed bits.
+2. The overwrites targeting roles assigned to the member, accumulated into one union of allowed bits and one union of denied bits and applied as a single step in the same order. An allow on any one of the member's roles defeats a deny on another.
+3. The member overwrite, again denied bits first and allowed bits second.
+
+A named channel that does not exist in the guild leaves the guild-level union unchanged.
+
+:::caution[`ADMINISTRATOR` grants every bit and skips overwrites]
+No deny overwrite in any channel restricts a member who holds the bit. Removing `ADMINISTRATOR` from every role they hold is the only remedy.
+:::
+
+A guild channel is visible when its channel-scoped mask contains `VIEW_CHANNEL`, or when the member holds temporary access through an in-flight voice or call transition. A category is also visible when the mask of at least one child channel contains `VIEW_CHANNEL`. Temporary access applies only to the channel it was granted on.
+
+## Communication timeout
+
+A communication timeout stops a member from communicating in a guild until it expires. It does not change the computed mask. A member whose [communication_disabled_until](/http-api/guild-members/#guild-member-object) is in the future keeps every permission their roles grant, and Fluxer applies the restriction as a separate precondition on each communicating operation.
+
+Sending a message, editing the caller's own message, adding a reaction, requesting an attachment upload, starting a typing indicator, and changing the caller's own nickname each fail with 403 `COMMUNICATION_DISABLED` while the timeout is in the future. A timeout has no effect in a private channel.
+
+The order of the timeout precondition and the permission check varies by operation, so a caller who is both timed out and short of the permission can receive either `COMMUNICATION_DISABLED` or `MISSING_PERMISSIONS`.
+
+A client that gates its interface on the computed mask MUST also compare `communication_disabled_until` against the current time, because the mask still reports `SEND_MESSAGES` for a timed-out member.
+
+## Role hierarchy
+
+Roles are ranked by `position`, and the highest position ranks first. Two roles sharing a position are ranked by snowflake, and the lower snowflake ranks first. A member's rank is the rank of their highest assigned role, and a member with no assigned role ranks below every role.
+
+A caller manages a role when the caller's rank is strictly above the role's rank. The guild owner manages every role. The everyone role sits at position 0, so any caller holding a role above it outranks it and can modify it. It is never a valid target for a position or hoist position mutation.
+
+Fluxer enforces hierarchy independently of `MANAGE_ROLES`. A caller who holds the permission without outranking the target role receives 403 `MISSING_PERMISSIONS`. A caller whose only `MANAGE_ROLES` comes from the everyone role has no assigned role, so they can manage no role.
+
+:::caution[A non-owner cannot grant a permission they lack]
+A requested bit outside the caller's own effective guild permissions fails the whole request with `MISSING_PERMISSIONS`. Fluxer runs that check after it resolves the unassigned and feature-gated bits.
+:::
+
+## Guild role object
+
+Roles are named permission masks that a guild assigns to any number of its members.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the role |
+| name | string | The name of the role (1-100 characters) |
+| color | integer | The colour of the role as an RGB integer, which every operation on this resource writes in the range 0 through 16777215 |
+| position1 | integer | The rank of the role in the permission hierarchy, where a larger value ranks higher |
+| hoist_position2 | ?integer | The rank of the role among the separately displayed member list groups |
+| permissions | decimal string | The [permission](#permissions) bitfield the role grants |
+| hoist | boolean | Whether members holding the role are displayed as their own member list group |
+| mentionable | boolean | Whether a member without `MENTION_EVERYONE` can mention the role |
+| unicode_emoji?3 | ?string | The Unicode emoji shown beside the role name |
+
+1 The everyone role is always at position 0, and every other role occupies a dense position from 1 through the number of remaining roles after any hierarchy mutation
+
+2 A null value means the role has no explicit member list position, and the role is then ordered by its hierarchy `position` instead
+
+3 The field is absent from every role these operations return and from every role in a role Gateway Dispatch
+
+The everyone role has the same snowflake as its guild and is named `@everyone`. It defines the base permission set for every member, and it cannot be deleted, reordered, or given a hoist position. Its colour and its permissions remain mutable.
+
+### Example
+
+```json
+{
+ "id": "1489002177550843904",
+ "name": "Moderator",
+ "color": 3447003,
+ "position": 4,
+ "hoist_position": 1,
+ "permissions": "1099511637006",
+ "hoist": true,
+ "mentionable": false
+}
+```
+
+## Role position object
+
+Each entry names one role and the rank the caller requests for it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the role |
+| position?1 | integer | The rank the caller requests for the role, where a larger value ranks higher |
+
+1 Fluxer ranks a role whose entry omits the field by its stored `position` in the same sort, so a role that requested a position above that value still displaces it
+
+### Example
+
+```json
+[
+ {"id": "1489002177550843904", "position": 3},
+ {"id": "1489002177550843905", "position": 2}
+]
+```
+
+## Role hoist position object
+
+Each entry names one role and the member list position it receives.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the role |
+| hoist_position | integer | The member list position to give the role |
+
+### Example
+
+```json
+[
+ {"id": "1489002177550843904", "hoist_position": 1}
+]
+```
+
+## List guild roles
+
+
+
+Returns every [guild role object](#guild-role-object) in the guild, in descending `position` order, with a tie resolved by ascending role ID.
+
+### Limitations
+
+- The caller must be a member of the guild, and no permission is required.
+- A bearer credential requires the [guilds](/http-api/oauth2/#oauth2-scopes) OAuth2 scope.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[guild role](#guild-role-object) object] | Roles were returned |
+| 403 | [error response](/http-api/#error-response) | Bearer credential lacks the `guilds` scope and returns `MISSING_OAUTH_SCOPE`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member and returns `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated identity and guild ID, on the `guild:role:list::guild_id` bucket.
+
+## Create guild role
+
+
+
+Creates a role and returns its [guild role object](#guild-role-object). Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Create](/gateway/events/#guild-role-create) Gateway event.
+
+### Limitations
+
+- A caller who is not the guild owner cannot grant a permission absent from their own effective guild permissions.
+
+[Role hierarchy](#role-hierarchy) is not evaluated. The new role receives hierarchy position 1 and ranks below every existing role at that position. No other role is renumbered. The role is created without a hoist position or Unicode emoji and with `hoist` and `mentionable` set to false.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Features? | string | A comma-separated [feature declaration](#feature-gated-permission-bits) |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | The name of the role (1-100 characters) |
+| color? | integer | The colour of the role as a 24-bit RGB integer (0-16777215, default 0) |
+| permissions?1 | decimal string \| integer | The [permission](#permissions) bitfield the role grants |
+
+1 An omitted field copies the current permissions of the everyone role unchanged. A supplied value is intersected with the set of defined bits, resolved against the caller's [feature declaration](#feature-gated-permission-bits), then checked against the caller's own permissions
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild role object](#guild-role-object) | Role was created |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise `MANAGE_ROLES` because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED`, or the guild role limit is reached and the request returns `MAX_GUILD_ROLES` |
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member, lacks `MANAGE_ROLES`, or requested a permission they do not hold, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The operation consumes one guild role slot and creates a [ROLE_CREATE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry with the supplied reason. It emits [Guild Role Create](/gateway/events/#guild-role-create) to every session that can see the guild, including the acting session. A request that reaches the configured role limit fails with `MAX_GUILD_ROLES` and consumes no slot. The response body has the limit in a top-level `max_guild_roles` member. The default limit is 250 roles for each guild and counts the everyone role.
+
+### Rate limit
+
+10 requests per minute for each authenticated identity and guild ID, on the `guild:role:create::guild_id` bucket.
+
+## Modify guild role
+
+
+
+Modifies one role and returns its [guild role object](#guild-role-object). Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Update](/gateway/events/#guild-role-update) Gateway event.
+
+### Limitations
+
+- A caller who is not the guild owner additionally requires [hierarchy authority](#role-hierarchy) over the target role and cannot grant a permission absent from their own effective guild permissions.
+
+The everyone role is a valid target.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| role_id | snowflake | The ID of the role |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Fluxer-Features? | string | A comma-separated [feature declaration](#feature-gated-permission-bits) |
+
+### JSON body
+
+Every field is optional, and an omitted field leaves the stored value unchanged. `position` is not accepted here, because only [Modify guild role positions](#modify-guild-role-positions) writes the hierarchy.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name?1 | string | The name of the role (1-100 characters) |
+| color? | integer | The colour of the role as a 24-bit RGB integer (0-16777215) |
+| permissions?2 | decimal string \| integer | The complete replacement [permission](#permissions) bitfield |
+| hoist?1 | boolean | Whether members holding the role are displayed as their own member list group |
+| hoist_position?1 | ?integer | The member list position to give the role, or null to clear it |
+| mentionable?1 | boolean | Whether a member without `MENTION_EVERYONE` can mention the role |
+
+1 The field is accepted and then ignored when the target is the everyone role, which cannot be renamed, hoisted, given a member list position, or made mentionable
+
+2 The supplied value becomes the complete stored mask. Fluxer intersects it with the set of defined bits and resolves it against the caller's [feature declaration](#feature-gated-permission-bits) before checking the caller's authority
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [guild role object](#guild-role-object) | Role was modified, or every supplied value already matched the stored value |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise `MANAGE_ROLES` because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member, lacks `MANAGE_ROLES`, does not outrank the target role, or requested a permission they do not hold, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD`, or the role does not exist and returns `UNKNOWN_ROLE` |
+
+### Side effects
+
+The operation creates a [ROLE_UPDATE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry with the supplied reason and emits [Guild Role Update](/gateway/events/#guild-role-update) to every session that can see the guild. A permission change adds a [permissions_diff](/http-api/guild-audit-logs/#guild-role-change-fields) change entry recording the exact added and removed permission names. A request whose values all match the current role still emits the Dispatch and still records the entry, with no change rows in it.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated identity and guild ID, on the `guild:role:update::guild_id` bucket.
+
+## Modify guild role positions
+
+
+
+Reorders the role hierarchy and returns 204 with an empty body. Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) Gateway event.
+
+### Limitations
+
+- The caller needs [hierarchy authority](#role-hierarchy) over every role whose entry supplies a position differing from that role's stored `position`.
+- An entry that omits the field or restates the stored value is accepted without that check.
+
+Fluxer holds a guild-wide lock while the operation runs, so a concurrent position write on the same guild returns 423 `GENERAL_ERROR`.
+
+Every role the caller can manage is sorted by its requested position in descending order, with a role that supplied no position keeping its stored position as the sort key and any remaining tie resolved by the current order. Fluxer then puts those roles back into the same set of slots they held before, so a role the caller cannot manage never moves. Every role other than the everyone role is then renumbered to a dense position. The highest-ranked role receives the number of roles other than the everyone role, the lowest-ranked role receives 1, and the everyone role stays at 0.
+
+:::note[A requested position is an ordering key]
+The dense renumbering that follows the sort decides the stored value, so it can differ from the one requested. A client reads the resulting `position` from the [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) payload.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The top-level body is an array of [role position objects](#role-position-object). The everyone role cannot appear in the array and is rejected with the validation code `CANNOT_REORDER_EVERYONE_ROLE`, and a role that does not exist in the guild is rejected with `INVALID_ROLE_ID`. Both checks run over the whole array before the hierarchy check and before any write.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Positions were applied|
+| 400 | [error response](/http-api/#error-response) | An entry names the everyone role or a role that does not exist, or the caller cannot exercise `MANAGE_ROLES` because of the guild MFA level|
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member, lacks `MANAGE_ROLES`, or supplied a position differing from the stored `position` of a role they do not outrank, each returning `MISSING_PERMISSIONS`|
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD`|
+| 423 | [error response](/http-api/#error-response) | Another position write holds the guild lock, returning `GENERAL_ERROR` with `Retry-After: 2` |
+
+### Side effects
+
+The operation creates one [ROLE_UPDATE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry for each role whose position changed and emits one [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) with those roles to every session that can see the guild. A request that produces no positional change creates no audit entry and emits no Dispatch. Dense renumbering can change the numeric `position` of an unnamed role without changing its rank, and such a role is included in the Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated identity and guild ID, on the `guild:role:positions::guild_id` bucket.
+
+## Modify role hoist positions
+
+
+
+Sets the member list position of one or more roles and returns 204 with an empty body. Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) Gateway event.
+
+### Limitations
+
+- The caller needs [hierarchy authority](#role-hierarchy) over every named role.
+- The everyone role cannot receive a hoist position.
+
+A hoist position orders the separately displayed member list groups and is independent of the permission hierarchy. A role with no hoist position is ordered by its hierarchy `position` instead. The operation holds a guild-wide lock, so a concurrent hoist position write on the same guild returns 423 `GENERAL_ERROR`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+### JSON body
+
+The top-level body is an array of [role hoist position objects](#role-hoist-position-object). Every entry supplies a hoist position, because this operation cannot clear one. The everyone role cannot appear in the array and is rejected with the validation code `CANNOT_SET_HOIST_FOR_EVERYONE_ROLE`, and a role that does not exist in the guild is rejected with `INVALID_ROLE_ID`. Every check runs over the whole array before any write.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Hoist positions were applied|
+| 400 | [error response](/http-api/#error-response) | An entry names the everyone role or a role that does not exist, or the caller cannot exercise `MANAGE_ROLES` because of the guild MFA level|
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member, lacks `MANAGE_ROLES`, or does not outrank a named role, each returning `MISSING_PERMISSIONS`|
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD`|
+| 423 | [error response](/http-api/#error-response) | Another hoist position write holds the guild lock, returning `GENERAL_ERROR` with `Retry-After: 2` |
+
+### Side effects
+
+The operation creates one [ROLE_UPDATE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry for each named role whose hoist position changed and emits one [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) with the changed roles to every session that can see the guild. Reasserting the current positions changes nothing, creates no audit entry, and emits no Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated identity and guild ID, on the `guild:role:hoist_positions::guild_id` bucket.
+
+## Reset role hoist positions
+
+
+
+Clears the member list position of every role in the guild and returns 204 with an empty body. Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) Gateway event.
+
+### Limitations
+
+- [Role hierarchy](#role-hierarchy) is not evaluated. The permission alone clears the hoist position of every role in the guild, including roles the caller does not outrank.
+
+The operation takes the same guild-wide lock as [Modify role hoist positions](#modify-role-hoist-positions).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+
+The request has no body.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Hoist positions were cleared, or no role had one|
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise `MANAGE_ROLES` because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED`|
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member or lacks `MANAGE_ROLES`, each returning `MISSING_PERMISSIONS`|
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD`|
+| 423 | [error response](/http-api/#error-response) | Another hoist position write holds the guild lock, returning `GENERAL_ERROR` with `Retry-After: 2` |
+
+### Side effects
+
+The operation creates one [ROLE_UPDATE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry for each role whose hoist position is cleared and emits one [Guild Role Update Bulk](/gateway/events/#guild-role-update-bulk) with those roles to every session that can see the guild. When no role held a hoist position, no audit entry is created and no Dispatch is emitted.
+
+### Rate limit
+
+10 requests per minute for each authenticated identity and guild ID, on the `guild:role:hoist_positions_reset::guild_id` bucket.
+
+## Delete guild role
+
+
+
+Deletes one role and returns 204 with an empty body. Requires [MANAGE_ROLES](#permissions), which is an [elevated permission](#elevated-permissions). Emits a [Guild Role Delete](/gateway/events/#guild-role-delete) Gateway event.
+
+### Limitations
+
+- A caller who is not the guild owner additionally requires [hierarchy authority](#role-hierarchy) over the target role.
+- The everyone role cannot be deleted.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild |
+| role_id | snowflake | The ID of the role |
+
+The request has no body.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Role was deleted |
+| 400 | [error response](/http-api/#error-response) | The caller cannot exercise `MANAGE_ROLES` because of the guild MFA level and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Bearer credential is used and returns `ACCESS_DENIED`, the guild is unavailable and returns `MISSING_ACCESS`, or the caller is not a member, lacks `MANAGE_ROLES`, or does not outrank the target role, each returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist and returns `UNKNOWN_GUILD`, or the role does not exist or is the everyone role, each returning `UNKNOWN_ROLE` |
+
+:::caution[Deletion is irreversible and rewrites every affected member]
+A deleted role cannot be restored, and its permissions, colour, and hoist state go with it. An overwrite naming it no longer affects any computed mask. Every member who held the role loses it in the same operation, so anyone relying on it for channel access loses that access immediately.
+:::
+
+### Side effects
+
+The operation removes the role from every member that held it, removes the role record, frees the guild role slot it occupied, and creates a [ROLE_DELETE](/http-api/guild-audit-logs/#audit-actions) guild audit log entry with the supplied audit reason. It emits [Guild Role Delete](/gateway/events/#guild-role-delete) to every session that can see the guild. Subsequent member reads and [Guild Member Update](/gateway/events/#guild-member-update) payloads reflect the removed role.
+
+### Rate limit
+
+10 requests per minute for each authenticated identity and guild ID, on the `guild:role:delete::guild_id` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/premium.mdx b/fluxer_docs/src/content/docs/http-api/premium.mdx
new file mode 100644
index 000000000..454d6dfc8
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/premium.mdx
@@ -0,0 +1,795 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Premium
+description: Premium pricing, entitlement state, mirrored billing data, and subscription self-service.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Premium is the paid tier of a Fluxer account. A recurring subscription, a redeemed gift, or a lifetime Visionary purchase pays for it. The routes here read that entitlement and run the subscription self-service operations, while [Billing](/http-api/billing/) owns purchase creation, refunds and the payment provider webhook.
+
+Every route except [get price IDs](#get-price-ids) is user-only. A [self-hosted deployment](/http-api/deployment-availability/) serves only [get premium state](#get-premium-state) and [set premium perks disabled](#set-premium-perks-disabled).
+
+| Object | Purpose |
+| --- | --- |
+| [Price IDs](#price-ids-object) | The recurring and gift prices resolved for one country |
+| [Current subscription price](#current-subscription-price-object) | The price the account is billed against, and the current list price |
+| [Pending subscription change](#pending-subscription-change-object) | A billing cycle change scheduled for the end of the period |
+| [Premium state](#premium-state-object) | The single read behind a billing screen |
+| [Actual premium state](#actual-premium-state-object) | The entitlement backed by billing alone |
+| [Effective premium state](#effective-premium-state-object) | The entitlement every premium feature check reads |
+| [Premium billing state](#premium-billing-state-object) | Mirrored payment provider data for the account |
+| [Billing subscription](#billing-subscription-object) | One mirrored subscription |
+| [Billing invoice](#billing-invoice-object) | One mirrored invoice |
+| [Billing payment method](#billing-payment-method-object) | One mirrored payment method |
+| [Premium pricing state](#premium-pricing-state-object) | Both resolved checkout catalogues |
+
+:::note[Billing history is mirrored from the payment provider]
+[Get premium state](#get-premium-state) returns the mirrored invoices, payment methods and subscription fields. [Create customer portal](#create-customer-portal) is the externally hosted interface for changing any of them.
+:::
+
+## Premium types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NONE | No premium entitlement |
+| 1 | SUBSCRIPTION | Recurring or gift-funded premium entitlement |
+| 2 | LIFETIME | Lifetime Visionary entitlement, which has a sequence number and community access |
+
+## Billing cycles
+
+| Value | Description |
+| --- | --- |
+| monthly | The recurring price bills every month |
+| yearly | The recurring price bills every year |
+
+## Display currencies
+
+Every amount field is expressed in the minor unit of its own currency field. Two amounts in one object can have different currencies, so read each amount against the currency field named in that field's description.
+
+| Value | Description |
+| --- | --- |
+| USD | United States dollar |
+| EUR | Euro |
+| BRL | Brazilian real |
+| INR | Indian rupee |
+| PLN | Polish zloty |
+| TRY | Turkish lira |
+
+## Price IDs object
+
+The four checkout prices resolved for one country: monthly and yearly recurring, and one-month and one-year gift.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| monthly?1 | ?string | The payment provider price ID of the monthly recurring plan |
+| yearly?1 | ?string | The payment provider price ID of the yearly recurring plan |
+| gift_1_month?1 | ?string | The payment provider price ID of the one-month gift |
+| gift_1_year?1 | ?string | The payment provider price ID of the one-year gift |
+| monthly_amount_minor?2 | ?integer | The monthly recurring amount, in the minor unit of `currency` |
+| yearly_amount_minor?2 | ?integer | The yearly recurring amount, in the minor unit of `currency` |
+| gift_1_month_amount_minor?2 | ?integer | The one-month gift amount, in the minor unit of `gift_currency` |
+| gift_1_year_amount_minor?2 | ?integer | The one-year gift amount, in the minor unit of `gift_currency` |
+| currency | string | [Display currency](#display-currencies) of the recurring prices |
+| gift_currency | string | [Display currency](#display-currencies) of the gift prices |
+
+1 Fluxer resolves the recurring pair and the gift pair separately. Each pair takes the first currency for which both of its prices are configured, so the two pairs can resolve to different currencies
+
+2 Null when the amount cannot be resolved. [Get price IDs](#get-price-ids) reads it from the payment provider through a one-hour cache, while the copies inside [get premium state](#get-premium-state) read the mirrored price rows
+
+### Example
+
+```json
+{
+ "monthly": "price_1QaMonthlyUsd",
+ "yearly": "price_1QaYearlyUsd",
+ "gift_1_month": "price_1QaGift1MonthUsd",
+ "gift_1_year": "price_1QaGift1YearUsd",
+ "monthly_amount_minor": 499,
+ "yearly_amount_minor": 4999,
+ "gift_1_month_amount_minor": 499,
+ "gift_1_year_amount_minor": 4999,
+ "currency": "USD",
+ "gift_currency": "USD"
+}
+```
+
+## Current subscription price object
+
+The price the account is billed against, and the current list price for the same billing cycle and currency.
+
+[Get current subscription price](#get-current-subscription-price) reads the payment provider, and the `billing.current_subscription_price` copy inside [get premium state](#get-premium-state) reads the local mirror. The complete value is null when no subscription, no billing cycle, or no amount resolves for the account.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| price_id1 | string | The price the account is currently billed against |
+| amount_minor | integer | The amount actually charged, in the minor unit of `currency` |
+| currency2 | string | [Display currency](#display-currencies) of the charged amount |
+| billing_cycle | string | [Billing cycle](#billing-cycles) of the active subscription |
+| is_grandfathered3 | boolean | Whether the account is billed against a price other than the current list price |
+| list_amount_minor4 | ?integer | The current list amount, in the minor unit of `currency` |
+| list_price_id4 | ?string | The current list price ID for the same billing cycle and currency |
+
+1 The mirror-backed copy reports an empty string when neither the mirrored price row nor the mirrored subscription records a price identifier
+
+2 The subscription's own currency uppercased, so an unlisted currency is reported verbatim. The mirror-backed copy falls back to `USD` when neither source records a currency
+
+3 True only when a list price is configured for the same billing cycle and currency and its identifier differs from `price_id`, so an unconfigured currency reports false
+
+4 Both are null when the deployment configures no list price for the subscription's billing cycle and currency. `list_amount_minor` is also null on its own when that list price is configured but its amount cannot be read
+
+### Example
+
+```json
+{
+ "price_id": "price_1QaLegacyMonthlyUsd",
+ "amount_minor": 399,
+ "currency": "USD",
+ "billing_cycle": "monthly",
+ "is_grandfathered": true,
+ "list_amount_minor": 499,
+ "list_price_id": "price_1QaMonthlyUsd"
+}
+```
+
+## Pending subscription change object
+
+A billing cycle change that [change subscription billing cycle](#change-subscription-billing-cycle) scheduled for the end of the current period. [Premium billing state](#premium-billing-state-object) has it as its only live member, read from the payment provider on every request. No route returns it on its own.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| schedule_id | string | The payment provider subscription schedule ID that has the change |
+| current_billing_cycle1 | ?string | The current [billing cycle](#billing-cycles) |
+| target_billing_cycle | string | [Billing cycle](#billing-cycles) that takes effect at `effective_at` |
+| effective_at | ISO8601 timestamp | The start of the scheduled future phase |
+| current_price_id | ?string | The price ID of the active subscription item, or null when it cannot be resolved |
+| target_price_id | ?string | The price ID of the scheduled future phase, or null when it cannot be resolved |
+| currency | ?string | [Display currency](#display-currencies) of the target price, or null when it is unresolved or unsupported |
+| initial_amount_minor2 | ?integer | The amount of the first invoice after the change, in the minor unit of `currency` |
+| recurring_amount_minor | ?integer | The amount of each later invoice, in the minor unit of `currency` |
+| credit_amount_minor3 | ?integer | The credit applied to the first invoice, in the minor unit of `currency` |
+
+1 Resolved from the live subscription item, and falls back to the billing cycle stored on the account when the live price cannot be resolved
+
+2 The recurring amount plus any scheduled invoice adjustment, clamped at zero, and null when the target amount cannot be resolved
+
+3 Null unless the scheduled adjustment is a credit. A monthly to yearly change credits the smaller of the two amounts, each multiplied by the subscription item quantity, and no credit is scheduled during a trial
+
+The complete value is null in each of these cases.
+
+| Condition | Detail |
+| --- | --- |
+| No payment provider | The deployment configures none |
+| No subscription | The account has no stored subscription identifier |
+| No usable schedule | The subscription has no schedule, or the schedule is neither active nor waiting to start |
+| No future phase | The schedule has no phase starting after now |
+| No target cycle | Neither the future phase price nor the schedule metadata resolves it |
+| No change | The resolved target billing cycle equals the current one |
+| Read failed | The payment provider read failed, which is logged and absorbed |
+
+### Example
+
+```json
+{
+ "schedule_id": "sub_sched_1QaBcDeFgHiJ",
+ "current_billing_cycle": "monthly",
+ "target_billing_cycle": "yearly",
+ "effective_at": "2026-09-28T08:15:00.000Z",
+ "current_price_id": "price_1QaMonthlyUsd",
+ "target_price_id": "price_1QaYearlyUsd",
+ "currency": "USD",
+ "initial_amount_minor": 4500,
+ "recurring_amount_minor": 4999,
+ "credit_amount_minor": 499
+}
+```
+
+## Premium state object
+
+The account's entitlement, the premium checks that actually apply to it, and the billing and pricing data behind both. [Get premium state](#get-premium-state) and [set premium perks disabled](#set-premium-perks-disabled) both return it.
+
+A client gates a premium feature on `effective.is_premium`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| actual | [actual premium state](#actual-premium-state-object) object | The entitlement backed by billing, before local perk disabling |
+| effective | [effective premium state](#effective-premium-state-object) object | The entitlement every premium feature check reads |
+| billing | [premium billing state](#premium-billing-state-object) object | The mirrored payment provider data for the account |
+| pricing | [premium pricing state](#premium-pricing-state-object) object | The resolved checkout prices for the requested country |
+
+## Actual premium state object
+
+The entitlement the account holds from billing alone. Nothing here reacts to the perks-disabled flag, to an operator override, or to the instance premium mode.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| premium_type1 | ?integer | [Premium type](#premium-types) |
+| premium_since | ?ISO8601 timestamp | The time premium access first started, or null when it never has |
+| premium_until2 | ?ISO8601 timestamp | The time premium access ends, or null for a lifetime entitlement |
+| premium_will_cancel | boolean | Whether the subscription is set to cancel at the end of the current period |
+| premium_billing_cycle | ?string | [Billing cycle](#billing-cycles), or null when no recurring cycle is known |
+| premium_lifetime_sequence3 | ?integer | The Visionary sequence number, or null without lifetime entitlement |
+| premium_grace_ends_at | ?ISO8601 timestamp | The time the post-cancellation grace access ends, or null when no grace is active |
+| has_active_paid_premium4 | boolean | Whether paid premium access is currently active before local disabling |
+| is_visionary | boolean | Whether the entitlement is lifetime Visionary access |
+| has_ever_purchased | boolean | Whether the account has ever completed a premium purchase |
+
+1 An account that holds no stored premium type reports `0`
+
+2 The later of the subscription period end and any stacked gift extension, so redeeming a gift on an active subscription moves this value
+
+3 Allocated once when lifetime entitlement is first granted, and retained afterwards
+
+4 Still true throughout the post-expiry grace window, which runs to `premium_grace_ends_at` when one is recorded and otherwise to exactly 3 days after `premium_until`
+
+### Example
+
+```json
+{
+ "premium_type": 1,
+ "premium_since": "2026-02-14T09:00:00.000Z",
+ "premium_until": "2026-09-14T09:00:00.000Z",
+ "premium_will_cancel": false,
+ "premium_billing_cycle": "monthly",
+ "premium_lifetime_sequence": null,
+ "premium_grace_ends_at": null,
+ "has_active_paid_premium": true,
+ "is_visionary": false,
+ "has_ever_purchased": true
+}
+```
+
+## Effective premium state object
+
+The effective state decides whether premium features are available. It differs from the actual state when the account has disabled its own perks, when an operator override grants access, and when a self-hosted deployment uses the `everyone` [premium mode](/admin-api/instance/#premium-modes).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| is_premium1 | boolean | Whether premium access checks are active |
+| premium_type2 | ?integer | The effective [premium type](#premium-types) |
+| premium_since2 | ?ISO8601 timestamp | The effective time premium access started |
+| premium_until3 | ?ISO8601 timestamp | The time premium access ends, including any stacked gift extension, or null for a lifetime entitlement |
+| premium_will_cancel3 | boolean | Whether the subscription is set to cancel at the end of the current period |
+| premium_billing_cycle3 | ?string | [Billing cycle](#billing-cycles) |
+| premium_lifetime_sequence3 | ?integer | The Visionary sequence number, or null without lifetime entitlement |
+| premium_grace_ends_at3 | ?ISO8601 timestamp | The time the post-cancellation grace access ends, or null when no grace is active |
+| premium_enabled_override4 | boolean | Whether an operator premium override applies to the account |
+| premium_purchase_disabled4 | boolean | Whether the account has the purchase-disabled flag |
+| premium_perks_disabled4 | boolean | Whether the account has the perks-disabled flag |
+| self_hosted5 | boolean | Whether the deployment is self-hosted |
+| bot6 | boolean | Whether the credential is a bot account with premium-equivalent service access |
+
+1 Decided in a fixed order. A self-hosted deployment in the `everyone` [premium mode](/admin-api/instance/#premium-modes) reports true, then a bot account reports true, then `premium_perks_disabled` reports false, then `premium_enabled_override` reports true, and otherwise the value follows `actual.has_active_paid_premium`
+
+2 The only two fields the effective state gates. They report the account value while `is_premium` is true, and `0` and null respectively while it is false
+
+3 Copied from the [actual premium state](#actual-premium-state-object) unchanged, so the value does not react to `is_premium`
+
+4 Reflects the corresponding [premium flag](/admin-api/users/#premium-flags) on the account
+
+5 `is_premium` follows this only while the instance [premium mode](/admin-api/instance/#premium-modes) is `everyone`
+
+6 Always false through this API, because both operations that return this object reject a bot credential with 403 `ACCESS_DENIED`
+
+## Premium billing state object
+
+The account's billing data behind a premium screen.
+
+`stripe_customer_id`, `current_subscription_price`, `subscription`, `invoices` and `payment_methods` are read from the local mirror of the payment provider, and Fluxer repairs the payment method mirror while building the response. `refund_eligibility` is computed from the mirrored invoices. `pending_subscription_change` is read live from the payment provider on every request.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stripe_customer_id | ?string | The payment provider customer ID, or null when none was ever provisioned |
+| current_subscription_price | ?[current subscription price](#current-subscription-price-object) object | The price the account is billed against |
+| pending_subscription_change | ?[pending subscription change](#pending-subscription-change-object) object | The scheduled billing cycle change |
+| subscription | ?[billing subscription](#billing-subscription-object) object | The most relevant mirrored subscription |
+| invoices1 | array[[billing invoice](#billing-invoice-object)] | The most recent invoices, newest first |
+| invoices_has_more2 | boolean | Whether the account has invoices beyond the returned page |
+| payment_methods3 | array[[billing payment method](#billing-payment-method-object)] | The stored payment methods |
+| refund_eligibility4 | [refund eligibility](/http-api/billing/#refund-eligibility-object) object | The self-service refund state for the account |
+
+1 At most 12 entries, ordered by provider creation time descending. Fluxer gathers invoices across every payment provider customer the account owns and deduplicates them by provider identifier before the cut
+
+2 True when the account has more invoices than the 12 returned. No route pages past the first 12, so use [create customer portal](#create-customer-portal) for the rest
+
+3 The default payment method sorts first, then the remainder by provider creation time descending
+
+4 Computed from the mirrored invoices, and it skips an invoice that already has a pending, succeeded or action-required refund. It can therefore differ from [get refund eligibility](/http-api/billing/#get-refund-eligibility)
+
+Fluxer selects the subscription by preferring an `active` or `trialing` mirror, then a `past_due`, `unpaid` or `incomplete` one, then anything else. Ties break on the latest current period end.
+
+## Billing subscription object
+
+One mirrored payment provider subscription. Fluxer passes every member through, so `status`, `plan_interval` and `plan_currency` are the provider's own values.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The payment provider subscription ID |
+| status | ?string | The provider subscription status |
+| current_period_start | ?ISO8601 timestamp | The start of the current billing period |
+| current_period_end | ?ISO8601 timestamp | The end of the current billing period |
+| cancel_at_period_end | boolean | Whether the subscription cancels at the period end |
+| cancel_at | ?ISO8601 timestamp | The scheduled cancellation time |
+| canceled_at | ?ISO8601 timestamp | The time the cancellation was requested |
+| plan_interval1 | ?string | The provider recurring interval of the primary price |
+| plan_amount_minor | ?integer | The primary price amount, in the minor unit of `plan_currency` |
+| plan_currency | ?string | The provider currency of the primary price, as the provider spells it |
+| default_payment_method_id | ?string | The payment method the subscription bills |
+
+1 The provider interval, `month` or `year`, which differs from the [billing cycle](#billing-cycles) values used elsewhere on this page
+
+### Example
+
+```json
+{
+ "id": "sub_1QaBcDeFgHiJkLmN",
+ "status": "active",
+ "current_period_start": "2026-08-14T09:00:00.000Z",
+ "current_period_end": "2026-09-14T09:00:00.000Z",
+ "cancel_at_period_end": false,
+ "cancel_at": null,
+ "canceled_at": null,
+ "plan_interval": "month",
+ "plan_amount_minor": 499,
+ "plan_currency": "usd",
+ "default_payment_method_id": "pm_1QaBcDeFgHiJkLmN"
+}
+```
+
+## Billing invoice object
+
+One mirrored payment provider invoice. `hosted_invoice_url` and `invoice_pdf` are the only links to the invoice document.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The payment provider invoice ID |
+| number | ?string | The human-readable invoice number |
+| amount_due1 | integer | The amount due, in the minor unit of `currency` |
+| amount_paid1 | integer | The amount paid, in the minor unit of `currency` |
+| currency2 | string | The provider currency of the invoice, as the provider spells it |
+| status | ?string | The provider invoice status |
+| created_at | ?ISO8601 timestamp | The provider creation time |
+| paid_at | ?ISO8601 timestamp | The settlement time |
+| billing_reason | ?string | The provider reason the invoice was created |
+| subscription_id | ?string | The subscription that produced the invoice, or null for a one-off purchase |
+| hosted_invoice_url | ?string | The externally hosted invoice page |
+| invoice_pdf | ?string | The externally hosted invoice PDF |
+
+1 Reported as `0` when the mirror records no amount
+
+2 Falls back to `usd` when the mirror records no currency
+
+### Example
+
+```json
+{
+ "id": "in_1QaBcDeFgHiJkLmN",
+ "number": "A1B2C3D4-0007",
+ "amount_due": 499,
+ "amount_paid": 499,
+ "currency": "usd",
+ "status": "paid",
+ "created_at": "2026-08-14T09:00:00.000Z",
+ "paid_at": "2026-08-14T09:00:04.000Z",
+ "billing_reason": "subscription_cycle",
+ "subscription_id": "sub_1QaBcDeFgHiJkLmN",
+ "hosted_invoice_url": "https://invoice.example.com/i/acct_1/live_1",
+ "invoice_pdf": "https://invoice.example.com/i/acct_1/live_1.pdf"
+}
+```
+
+## Billing payment method object
+
+One mirrored stored payment method. A non-card method reports every card member as null and still reports `type`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The payment provider payment method ID |
+| type | ?string | The provider payment method type |
+| card_brand | ?string | The card brand, or null when the method is not a card |
+| card_last4 | ?string | The last four digits of the card, or null when the method is not a card |
+| card_exp_month | ?integer | The expiry month of the card, or null when the method is not a card |
+| card_exp_year | ?integer | The expiry year of the card, or null when the method is not a card |
+| is_default1 | boolean | Whether the method is the customer's default |
+
+1 Recomputed against the default of the owning payment provider customer during the repair, so an account owning more than one customer can report more than one default
+
+### Example
+
+```json
+{
+ "id": "pm_1QaBcDeFgHiJkLmN",
+ "type": "card",
+ "card_brand": "visa",
+ "card_last4": "4242",
+ "card_exp_month": 11,
+ "card_exp_year": 2029,
+ "is_default": true
+}
+```
+
+## Premium pricing state object
+
+The checkout catalogue resolved for the request. A client can render prices without a second call to [get price IDs](#get-price-ids). Fluxer resolves both catalogues on every request, so one response has both a localised price and its USD or EUR equivalent.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| country_code1 | ?string | The country used to resolve the prices |
+| localized2 | ?[price IDs](#price-ids-object) object | The localised catalogue, or null when it cannot be resolved |
+| base2 | ?[price IDs](#price-ids-object) object | The base USD or EUR catalogue, or null when it cannot be resolved |
+
+1 The `country_code` query value uppercased, or null when it was omitted
+
+2 Null unless the deployment configures a complete recurring pair and a complete gift pair for the currency preferences that apply, so one missing gift price nulls the whole catalogue. The amounts come from the mirrored price rows
+
+Fluxer reports an unresolvable catalogue as null here, and the request still succeeds. [Get price IDs](#get-price-ids) answers 400 `STRIPE_ERROR` for the same condition.
+
+## Get price IDs
+
+
+
+Returns the [price IDs](#price-ids-object) object for a country and pricing mode. A supplied credential only keys the rate limit, so an unrecognised credential falls back to the client IP address.
+
+The deployment must configure a complete recurring pair and a complete gift pair for at least one currency that the requested country prefers. Fluxer answers 400 `STRIPE_ERROR` when it does not.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| country_code?1 | string | The two-letter country used to select the regional price catalogue |
+| pricing_mode?2 | string | Either `localized` or `base` (default `localized`) |
+
+1 Fluxer trims surrounding whitespace and requires exactly two characters, and any other length fails validation with `STRING_LENGTH_EXACT`. Omitting it resolves the default currency preference order
+
+2 `base` restricts resolution to the deployment's base currency preferences, which is how a client reads USD or EUR pricing for a country that has its own localised catalogue
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [price IDs](#price-ids-object) object | The catalogue was resolved |
+| 400 | [error response](/http-api/#error-response) | No configured currency satisfies the request and the request returns `STRIPE_ERROR` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user or client IP address, on the `stripe:price:ids` bucket.
+
+## Get premium state
+
+
+
+Returns the [premium state](#premium-state-object) object for the authenticated account.
+
+The route answers from mirrored billing data, so it works when the payment provider is unconfigured. When the provider is configured, the operation also repairs missing payment method mirror rows for each of the account's customers before it builds the response. A repair failure is logged and absorbed.
+
+:::note[One read serves the whole billing screen]
+`billing.current_subscription_price` and `billing.refund_eligibility` cover the same ground as [get current subscription price](#get-current-subscription-price) and [get refund eligibility](/http-api/billing/#get-refund-eligibility), and `billing.pending_subscription_change` has no route of its own.
+:::
+
+:::note[The paired values are computed differently]
+This route derives `current_subscription_price` from the mirrored subscription and `refund_eligibility` from the mirrored invoices, while the two dedicated routes read the payment provider. The values can disagree while the mirror is behind.
+:::
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| country_code?1 | string | The two-letter country used to resolve `pricing` |
+
+1 Fluxer trims surrounding whitespace and requires exactly two characters. Omitting it resolves `pricing` against the default currency preference order and reports `pricing.country_code` as null
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [premium state](#premium-state-object) object | The state was returned |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+The read can write to the payment provider and to the local billing mirror while repairing payment method state, including aligning the customer default payment method with the subscription default payment method. It does not change entitlement.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `stripe:premium:state` bucket.
+
+## Get current subscription price
+
+
+
+Returns the [current subscription price](#current-subscription-price-object) object for the authenticated account, or null when no price can be resolved.
+
+The route reads the subscription from the payment provider, so it returns null when the deployment configures no payment provider and when the account has no stored subscription identifier. A provider read that fails is logged and reported as null.
+
+Fluxer caches the resolved value for 5 minutes for each subscription. It reads `list_amount_minor` through a second cache that holds each list price amount for 1 hour, so a list price change made in the payment provider can take an hour to appear. `is_grandfathered` compares price identifiers and does not read that cache.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | ?[current subscription price](#current-subscription-price-object) object | The current price or null was returned |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `stripe:subscription:current_price` bucket.
+
+## Set premium perks disabled
+
+
+
+Turns the account's own premium perks off or on without changing billing state, and returns the resulting [premium state](#premium-state-object) object. Emits a [User Update](/gateway/events/#user-update) Gateway event when the requested value differs from the stored one.
+
+The response is built exactly as [get premium state](#get-premium-state) builds it, except that it always resolves pricing without a country code.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| disabled | boolean | Whether premium perks are disabled for the account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [premium state](#premium-state-object) object | The current state was returned, whether or not the flag changed |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+When the requested value differs from the current one, the operation updates the account and sends [User Update](/gateway/events/#user-update) to every session the account owns. Repeating the current value leaves the flag untouched. The returned `actual` state never changes. Building the response runs the same payment method repair as [get premium state](#get-premium-state), and either call can write to the payment provider and to the local billing mirror.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `stripe:premium:perks_disabled` bucket.
+
+## Create customer portal
+
+
+
+Creates a billing portal session for the authenticated account and returns a [redirect URL](/http-api/billing/#redirect-url-object) object. The returned URL is the only handle on the session.
+
+An account that has never had a payment provider customer provisioned receives 400 `STRIPE_NO_PURCHASE_HISTORY`. A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, and a provider call that fails or answers without a URL receives 400 `STRIPE_ERROR`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [redirect URL](/http-api/billing/#redirect-url-object) object | The portal session was created |
+| 400 | [error response](/http-api/#error-response) | The account has no payment provider customer, or the provider is not configured or rejected the request |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+The operation creates an externally hosted billing portal session that returns to the Fluxer premium callback when the user closes it. Nothing is written to Fluxer state, and any change the user makes in the portal reaches Fluxer through [receive Stripe webhook](/http-api/billing/#receive-stripe-webhook).
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:customer_portal` bucket.
+
+## End premium grace period
+
+
+
+Ends an active post-cancellation grace period immediately. Emits a [User Update](/gateway/events/#user-update) Gateway event when a grace period was actually ended.
+
+The operation is idempotent and reports success without changing anything when no grace deadline is recorded, when the account holds a lifetime entitlement, and when the account's premium end has not yet passed.
+
+The window this operation ends runs to `premium_grace_ends_at`. [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) records that deadline when a subscription is deleted for an account whose stored premium type is subscription.
+
+| Condition | Recorded deadline |
+| --- | --- |
+| The subscription ran to its recorded premium end | The subscription end plus 3 days |
+| The subscription ended early | The subscription end itself, which leaves no window for this route to close |
+| The early cancellation is already recorded | Nothing |
+
+A background reconciliation of the account against the payment provider records the same early cancellation deadline when it finds a terminal subscription that ended before the recorded premium end. `has_active_paid_premium` treats a missing deadline as exactly 3 days after `premium_until`, but this route requires the recorded deadline.
+
+:::caution[The downgrade is immediate]
+Premium features become unavailable before the recorded grace deadline arrives. The grace period cannot be restarted through this API.
+:::
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The grace period was ended, or no applicable grace state existed |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+For an active grace period on a non-lifetime account, the operation clears the premium type, premium start, premium end, gift extension end, cancellation flag, billing cycle and grace deadline together. Every account session receives [User Update](/gateway/events/#user-update). A call with no applicable grace state changes nothing. Fluxer contacts no payment provider.
+
+### Rate limit
+
+3 requests per minute for each authenticated user, on the `stripe:premium:grace:end` bucket.
+
+## Cancel subscription
+
+
+
+Marks the active recurring subscription to cancel at the end of the current period and returns 204 with an empty body. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, an account with no stored subscription receives 400 `STRIPE_NO_ACTIVE_SUBSCRIPTION`, and a subscription already set to cancel receives 400 `STRIPE_SUBSCRIPTION_ALREADY_CANCELING`. Every other failure, including a subscription that has a schedule but no future period end, is reported as 400 `STRIPE_ERROR`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The subscription was marked to cancel at period end |
+| 4001 | [error response](/http-api/#error-response) | The subscription cannot be marked to cancel |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+1 The account has no stored subscription, the subscription is already cancelling, the subscription has a schedule but no future period end, the payment provider is not configured, or the payment provider rejected the request
+
+### Side effects
+
+Fluxer sets the subscription to cancel at the current period end, sets the account's cancellation flag, and refreshes the mirrored subscription row. Every account session receives [User Update](/gateway/events/#user-update). The ordinary path writes no premium end, so the recorded end is whatever the account already has.
+
+A subscription that has a schedule discards its pending billing cycle change and ends in cancellation at the current period end, and Fluxer then rewrites the premium end from the refreshed subscription. Premium access continues through the recorded end, and [receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) later applies the final downgrade.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:subscription:cancel` bucket.
+
+## Reactivate subscription
+
+
+
+Removes period end cancellation from a subscription that is set to cancel and returns 204 with an empty body. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, an account with no stored subscription receives 400 `STRIPE_NO_SUBSCRIPTION`, and a subscription that is not set to cancel receives 400 `STRIPE_SUBSCRIPTION_NOT_CANCELING`. Every other failure is reported as 400 `STRIPE_ERROR`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The subscription was reactivated |
+| 4001 | [error response](/http-api/#error-response) | The subscription cannot be reactivated |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+1 The account has no stored subscription, the subscription is not set to cancel, the payment provider is not configured, or the payment provider rejected the request
+
+### Side effects
+
+Fluxer releases any subscription schedule, which discards a pending billing cycle change, then clears period end cancellation. The account's cancellation flag becomes false. Every account session receives [User Update](/gateway/events/#user-update).
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:subscription:reactivate` bucket.
+
+## Change subscription billing cycle
+
+
+
+Moves the active subscription between the monthly and yearly billing cycles, either immediately or at the end of the current period, and returns 204 with an empty body. Emits a [User Update](/gateway/events/#user-update) Gateway event when the account state changes.
+
+A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, an account with no stored subscription receives 400 `STRIPE_NO_ACTIVE_SUBSCRIPTION`, and a deployment with no configured price for the target cycle in the subscription's currency receives 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`. A subscription with no recurring primary item, an unsupported recurring interval, a period end change with no future period end, and any other provider failure are all reported as 400 `STRIPE_ERROR`.
+
+The operation reports success without changing anything when the subscription already bills on the requested cycle. When a future schedule phase already has the target price it sends nothing further to the payment provider and writes only the account's cancellation flag, premium end and stored payment provider customer identifier.
+
+:::caution[An immediate change invoices at once]
+An immediate change swaps the subscription item to the target price and always invoices the proration, so the account is charged or credited. An incomplete payment fails the request without leaving the subscription in the `incomplete` status.
+:::
+
+:::note[A period end change can credit the first invoice]
+A monthly to yearly change credits the first invoice of the new phase with the smaller of the two amounts. A trial adds no credit, and a yearly to monthly change has none.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| billing_cycle | string | The target [billing cycle](#billing-cycles), either `monthly` or `yearly` |
+| effective_at? | string | Either `now` or `period_end` (default `now`) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The change was applied, scheduled, or already in effect |
+| 4001 | [error response](/http-api/#error-response) | The billing cycle cannot be changed |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+1 The account has no stored subscription, the subscription has no usable recurring item or future period end, no price is configured for the target cycle and currency, the payment provider is not configured, or the payment provider rejected the request
+
+### Side effects
+
+An immediate change clears pending cancellation, replaces the subscription price, and updates the billing cycle and premium end. A period end change clears pending cancellation and schedules the target cycle to begin at the current period end without proration. A monthly to yearly change adds the applicable credit to the scheduled phase.
+
+A scheduled change becomes visible as `billing.pending_subscription_change` in [get premium state](#get-premium-state). Every account session receives [User Update](/gateway/events/#user-update). Requesting the already active cycle changes nothing.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:subscription:change` bucket.
+
+## Cancel pending subscription change
+
+
+
+Cancels a scheduled billing cycle change and returns 204 with an empty body. Emits a [User Update](/gateway/events/#user-update) Gateway event when a scheduled change was actually cancelled.
+
+A deployment that configures no payment provider receives 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, an account with no stored subscription receives 400 `STRIPE_NO_ACTIVE_SUBSCRIPTION`, and a provider failure receives 400 `STRIPE_ERROR`.
+
+The operation is idempotent and reports success without changing anything when the subscription has no schedule and when the schedule has no pending billing cycle change.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The pending change was cancelled, or none existed |
+| 400 | [error response](/http-api/#error-response) | The account has no stored subscription, or the provider is not configured or rejected the request |
+| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+Fluxer releases the subscription schedule, which removes the scheduled cycle change. The account's cancellation flag becomes false, and Fluxer refreshes its premium end. Every account session receives [User Update](/gateway/events/#user-update). Afterwards `billing.pending_subscription_change` in [get premium state](#get-premium-state) is null.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:subscription:change` bucket, shared with [change subscription billing cycle](#change-subscription-billing-cycle).
+
+## Rejoin Visionary guild
+
+
+
+Adds the authenticated account back to the configured Visionary community guild and returns 204 with an empty body.
+
+The route requires a lifetime entitlement, so an account whose premium type is not lifetime receives 403 `MISSING_ACCESS`.
+
+A join emits [Guild Create](/gateway/events/#guild-create), [Guild Member Add](/gateway/events/#guild-member-add) and, when the guild has a system channel and does not suppress join notifications, [Message Create](/gateway/events/#message-create). The role grant that follows emits [Guild Member Update](/gateway/events/#guild-member-update). An account that already belongs to the guild produces that Dispatch alone.
+
+The operation is idempotent. For an account that already belongs to the guild, Fluxer reasserts the Visionary role and changes nothing else. The join bypasses the guild ban check and the join risk gate, so a banned account is added anyway.
+
+Both size ceilings still apply. An account already at its maximum number of guilds receives 400 `MAX_GUILDS`, and a Visionary guild at its member ceiling receives 400 `MAX_GUILD_MEMBERS`. Neither check runs for an account that already belongs to the guild.
+
+A deployment that configures no Visionary guild or no Visionary role returns 400 `STRIPE_ERROR`, and a configured Visionary guild that does not exist returns 404 `UNKNOWN_GUILD`. A role grant that fails is logged and absorbed, so the response is still 204 and the account keeps its membership without the role.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The account joined the guild or already belonged to it |
+| 4001 | [error response](/http-api/#error-response) | The deployment is not configured, or a size ceiling is reached |
+| 403 | [error response](/http-api/#error-response) | The account holds no lifetime entitlement and the request returns `MISSING_ACCESS` |
+| 404 | [error response](/http-api/#error-response) | The configured Visionary guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+1 The deployment configures no Visionary guild or no Visionary role and returns `STRIPE_ERROR`, the account is at its guild ceiling and the request returns `MAX_GUILDS`, or the guild is at its member ceiling and the request returns `MAX_GUILD_MEMBERS`
+
+### Side effects
+
+A new membership sends [Guild Create](/gateway/events/#guild-create) with complete guild state to the joining account and [Guild Member Add](/gateway/events/#guild-member-add) to the guild. It also creates a join system message and sends [Message Create](/gateway/events/#message-create) unless the guild has no system channel or suppresses join notifications.
+
+A new or existing member then receives the Visionary role, which records a `MEMBER_ROLE_UPDATE` guild audit log entry attributed to the system user and emits [Guild Member Update](/gateway/events/#guild-member-update). An account already holding the role causes no role change. Billing state is unchanged.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `stripe:visionary:rejoin` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/read-states.mdx b/fluxer_docs/src/content/docs/http-api/read-states.mdx
new file mode 100644
index 000000000..2e11d992e
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/read-states.mdx
@@ -0,0 +1,191 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Read states
+description: Current account message read states and the batch acknowledgement operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A read state records how far the current account has read in one channel. It holds the highest message read through, the mention count, and the last acknowledged pin timestamp. The [Messages resource](/http-api/messages/) defines single-channel acknowledgement, read state deletion, and pin acknowledgement.
+
+Both routes on this page are user-only. Fluxer rejects a bot or OAuth2 credential with 403 `ACCESS_DENIED`.
+
+## Read state object
+
+An account holds at most one read state per channel, keyed by the account and the channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the channel the read state belongs to |
+| mention_count1 | integer | The number of mentions stored for the channel (0-2,147,483,647) |
+| last_message_id | ?snowflake | The highest message ID the account has read through, or null when the entry stores no watermark |
+| last_pin_timestamp2 | ?ISO8601 timestamp | The time of the last acknowledged pin, or null when pins have never been acknowledged |
+| version?3 | string | The read state version as a canonical decimal unsigned 64-bit string |
+
+1 The value the last acknowledgement stored, incremented by the server as new mentions arrive. It is never recomputed from message history
+
+2 Neither operation on this page writes it, so an acknowledged pin timestamp survives every message acknowledgement
+
+3 Present on every entry the API returns. The value is always `0`
+
+An entry is created by an acknowledgement, by [Acknowledge pins](/http-api/messages/#acknowledge-pins), and by the server when a mention arrives in a channel the account has no entry for. A pin acknowledgement writes only `last_pin_timestamp`, and the entry it creates reports `last_message_id` as null. An entry the server creates for a mention starts from the channel's own baseline watermark, which is the snowflake of the channel ID itself, so every message already in the channel stays unread.
+
+[Clear channel read state](/http-api/messages/#clear-channel-read-state) deletes the whole entry, so it drops the watermark and the mention count along with the pin timestamp.
+
+:::note[The aggregate is delivered through the Gateway]
+There is no HTTP read for the read state aggregate. The complete entry array arrives once as `read_states` in the main Gateway [Ready](/gateway/events/#ready) payload.
+:::
+
+Only an acknowledgement produces a later Dispatch. A message acknowledgement emits [Message ACK](/gateway/events/#message-ack), and a pin acknowledgement emits [Channel Pins ACK](/gateway/events/#channel-pins-ack). A server-side mention increment and [Clear channel read state](/http-api/messages/#clear-channel-read-state) both change the stored entry and emit nothing, so the Dispatch stream is not a complete change feed. A client that needs the authoritative aggregate reconciles from a new Ready, or from the entries [Acknowledge read states](#acknowledge-read-states) returns.
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "mention_count": 3,
+ "last_message_id": "1501320000000000000",
+ "last_pin_timestamp": null,
+ "version": "0"
+}
+```
+
+## Read state acknowledgement object
+
+One entry in the body of [Acknowledge read states](#acknowledge-read-states). It names the channel whose entry changes, the message the account has read through, and the mention count that survives the read.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the channel whose read watermark changes |
+| message_id2 | snowflake | The ID of the message watermark to acknowledge |
+| mention_count?3 | integer | The number of mentions to store for the channel (0-2,147,483,647, default `0`) |
+| manual?4 | boolean | Whether the stored watermark becomes exactly `message_id` |
+
+1 Fluxer never resolves the channel. It stores the value as the read state key with no check that the channel exists or that the account can see it
+
+2 Fluxer does not resolve the message either. An entry equal to the stored watermark still produces a write, so it resets the stored mention count
+
+3 Fluxer stores the submitted value verbatim, so an acknowledgement leaving mentions above the watermark must send how many remain
+
+4 With `manual` false an entry strictly below the stored watermark is discarded whole
+
+:::caution[A manual acknowledgement can rewind the watermark]
+A lower `message_id` marks later messages unread again. Fluxer replaces the stored mention count at the same time, so a rewinding acknowledgement that omits `mention_count` clears it.
+:::
+
+### Example
+
+```json
+{
+ "channel_id": "1501314428688998182",
+ "message_id": "1501320000000000000",
+ "mention_count": 0
+}
+```
+
+## Bulk read state acknowledgement object
+
+One entry in the body of [Mark channels as read](#mark-channels-as-read). It names the channel and the message to read through.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the channel whose read watermark changes |
+| message_id1 | snowflake | The ID of the message watermark to acknowledge |
+
+1 Neither identifier is resolved, so an entry may name a channel or a message that does not exist
+
+## Acknowledge read states
+
+
+
+Applies 1 through 100 [read state acknowledgement objects](#read-state-acknowledgement-object) to the current account's aggregate and returns the resulting [read state](#read-state-object) entries. User-only. Emits one [Message ACK](/gateway/events/#message-ack) Gateway event to the caller's own sessions for each submitted entry.
+
+The route resolves no channel and evaluates no permission. The write mutates only the caller's own read states.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| read_states1 | array[[read state acknowledgement](#read-state-acknowledgement-object) object] | The acknowledgements to apply (1-100) |
+
+1 A list of fewer than 1 or more than 100 entries fails with 400 `INVALID_FORM_BODY`
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| read_states1 | array[[read state](#read-state-object) object] | The authoritative entries after the write |
+
+1 One entry for each submitted acknowledgement, in the submitted order. An entry whose watermark did not move reports the values already stored
+
+:::note[A plain list takes the batched path]
+When no entry sets `manual` and no entry has a `mention_count` above `0`, Fluxer applies the whole list through the batched path [Mark channels as read](#mark-channels-as-read) uses.
+:::
+
+A list with one manual entry or one positive mention count takes the entry-by-entry path in submitted order, and a later entry naming the same channel is evaluated against the earlier entry's result.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Acknowledgements were processed and the resulting entries returned |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED`, or the account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Side effects
+
+Each entry updates its channel watermark according to the entry's `manual` value and stores the submitted mention count. The write invalidates the account's unread badge total.
+
+One [Message ACK](/gateway/events/#message-ack) goes to the caller's own sessions for every submitted entry, including one that leaves the watermark and mention count unchanged. The Dispatch has the resulting `message_id`, `mention_count`, and `version`, so a non-manual entry below the stored watermark reports the higher stored value. The entry-by-entry path also has the submitted `manual` value, and the batched path has no `manual` at all.
+
+Fluxer logs a failed Dispatch or a failed unread badge invalidation on either path and does not fail the request. A client that misses a Dispatch reconciles from a later read.
+
+Every entry also clears delivered notifications for its channel through the submitted `message_id`, so a discarded or rewound entry clears a different range than the entry stores.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `read_state:ack_bulk` bucket, shared with [Mark channels as read](#mark-channels-as-read).
+
+## Mark channels as read
+
+
+
+Applies 1 through 100 [bulk read state acknowledgement objects](#bulk-read-state-acknowledgement-object) and returns 204 with an empty body. User-only. Emits one [Message ACK](/gateway/events/#message-ack) Gateway event to the caller's own sessions for each submitted entry.
+
+Every entry advances its channel watermark monotonically and sets the channel's stored mention count to `0`. An entry whose `message_id` is strictly below the stored watermark is skipped and leaves the stored entry untouched. Like [Acknowledge read states](#acknowledge-read-states), the operation resolves no channel and evaluates no permission.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| read_states1 | array[[bulk read state acknowledgement](#bulk-read-state-acknowledgement-object) object] | The channel and message pairs to acknowledge (1-100) |
+
+1 Two entries naming the same channel are both evaluated against the value stored before the request
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Acknowledgements were processed |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED`, or the account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+
+### Side effects
+
+An entry equal to the stored watermark still produces a write, and two entries naming the same channel produce two writes. The unread badge total is invalidated once for the whole request.
+
+One [Message ACK](/gateway/events/#message-ack) goes to the caller's own sessions for every submitted entry, including a skipped one. Every entry clears delivered notifications for its channel through the submitted `message_id`. The Dispatch has no `manual`, and a skipped entry reports the stored watermark and mention count.
+
+Fluxer logs a failed Dispatch or a failed unread badge invalidation and does not fail the request, so a client tolerates a missing Dispatch for an entry the write applied.
+
+:::note[A failed request can leave some entries written]
+A request that fails partway leaves the entries already written in place. A retry is safe, because a repeated monotonic acknowledgement is idempotent.
+:::
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `read_state:ack_bulk` bucket, shared with [Acknowledge read states](#acknowledge-read-states).
diff --git a/fluxer_docs/src/content/docs/http-api/reports.mdx b/fluxer_docs/src/content/docs/http-api/reports.mdx
new file mode 100644
index 000000000..e8b148334
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/reports.mdx
@@ -0,0 +1,509 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Reports
+description: Safety report submission, the category registries, and the DSA notice flow.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A safety report tells moderators that a message, a user, or a guild broke the rules, and any user account can file one. A Digital Services Act notice needs no account, only a verified email address. The [Admin Reports API](/admin-api/reports/) covers reading, triaging, and closing a stored report.
+
+| Operation | Credential | Target |
+| --- | --- | --- |
+| [Report message](#report-message) | User session | One message the reporter can read |
+| [Report user](#report-user) | User session | One account, optionally with a guild for context |
+| [Report guild](#report-guild) | User session | One guild the reporter can reach |
+| [Send DSA report email](#send-dsa-report-email) | None | An email address to verify |
+| [Verify DSA report email](#verify-dsa-report-email) | None | A verification code, exchanged for a ticket |
+| [Create DSA report](#create-dsa-report) | A ticket | A message, user, or guild |
+
+Fluxer discards a body member that no schema here defines, so a misspelt member is dropped without an error. A body that is not parseable JSON returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_FORMAT` at the `body` path.
+
+:::caution[A submitted report is durable evidence]
+Fluxer copies the reported content when the report is submitted. A message report also copies the surrounding conversation and the bytes of every attachment in it. Deleting the message or leaving the guild afterwards does not remove that snapshot.
+:::
+
+## Access rules
+
+[Report message](#report-message), [Report user](#report-user), and [Report guild](#report-guild) accept a user session token only. A bot token and an OAuth2 bearer credential are both rejected with 403 `ACCESS_DENIED`. The account must also be claimed and email-verified. An account holding no password and no SSO identity is rejected with 400 `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS`, and one whose address is unverified with 403 `REPORT_EMAIL_VERIFICATION_REQUIRED`. An account with the report ban flag is rejected with 403 `REPORT_BANNED`.
+
+The three Digital Services Act routes require no credential. Fluxer still resolves a valid credential presented anyway, so the request draws on that account's rate limit allowance.
+
+Every route bucket is keyed by the authenticated user ID when a credential resolves and by client IP address otherwise. Report creation applies further policy allowances keyed by the reporter.
+
+## Content screening
+
+The instance-wide content filter screens the JSON body of [Report message](#report-message), [Report user](#report-user), [Report guild](#report-guild), and [Create DSA report](#create-dsa-report) before the route runs. A string of at least 3 characters matching the instance phrase blocklist, or a URL matching the instance URL blocklist, is rejected with 403 `CONTENT_BLOCKED`. The `ticket`, `channel_id`, `message_id`, `user_id`, and `guild_id` members are exempt, and so is any value shorter than 3 characters.
+
+:::note[The screen precedes the bucket and the credential check]
+A blocked body returns `CONTENT_BLOCKED` rather than `RATE_LIMITED` or `UNAUTHORIZED`. [Send DSA report email](#send-dsa-report-email) and [Verify DSA report email](#verify-dsa-report-email) are exempt by path, so a submitted address is never scanned.
+:::
+
+## Submission side effects
+
+Every accepted submission writes one report and returns a [report object](#report-object). Submission emits no Gateway Dispatch.
+
+## Report object
+
+The receipt Fluxer returns when it records a submission.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| report_id | snowflake | The [snowflake](/snowflakes/) identifying this report |
+| status1 | string | [Report status](#report-status-values) |
+| reported_at2 | ISO8601 timestamp | Time the report row was written |
+
+1 Always `pending`
+
+2 A Digital Services Act notice has its filing time, even when its verification code was issued much earlier
+
+### Example
+
+```json
+{
+ "report_id": "1501314428688998182",
+ "status": "pending",
+ "reported_at": "2026-08-04T11:22:19.000Z"
+}
+```
+
+## Success object
+
+The fixed acknowledgement returned by [Send DSA report email](#send-dsa-report-email).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ok | boolean | Always true, because a failure is reported as an error status instead |
+
+### Example
+
+```json
+{
+ "ok": true
+}
+```
+
+## Ticket object
+
+The single credential [Create DSA report](#create-dsa-report) accepts. Fluxer binds the ticket to the verified address it was issued for, and that address becomes the reporter identity on the resulting notice.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket1 | string | The opaque credential [Create DSA report](#create-dsa-report) consumes |
+
+1 64 lowercase hexadecimal characters encoding 32 random bytes, valid for one hour from verification, and consumed by the first [Create DSA report](#create-dsa-report) that presents it
+
+### Example
+
+```json
+{
+ "ticket": "6b1f0a92c73d4e58a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f6071"
+}
+```
+
+## Report status values
+
+| Value | Description |
+| --- | --- |
+| pending | The report has been recorded and no moderator decision exists yet |
+| resolved | A moderator has closed the report |
+| unknown | The stored status is neither of the two values above |
+
+Only `pending` and `resolved` are ever stored. A client MUST still tolerate a status it does not recognise.
+
+## Message report categories
+
+A message report and the `message` variant of a Digital Services Act notice both draw `category` from this registry. A value outside it returns 400 `INVALID_FORM_BODY`.
+
+| Value | Description |
+| --- | --- |
+| harassment | Content that harasses, bullies, or intimidates users |
+| hate_speech | Content promoting hatred against protected groups |
+| violent_content | Content depicting or promoting violence |
+| spam | Unsolicited bulk messages or promotional content |
+| nsfw_violation | Adult content posted outside age-restricted channels |
+| illegal_activity | Content promoting or facilitating illegal activities |
+| doxxing | Content revealing private personal information |
+| self_harm | Content promoting self-harm or suicide |
+| child_safety | Content that endangers minors or depicts child abuse |
+| malicious_links | Links to malware, phishing, or other malicious sites |
+| impersonation | Content falsely claiming to be another person or entity |
+| other | Other violations not covered by specific categories |
+
+## User report categories
+
+A user report and the `user` variant of a Digital Services Act notice both draw `category` from this registry. A value outside it returns 400 `INVALID_FORM_BODY`.
+
+| Value | Description |
+| --- | --- |
+| harassment | User engages in harassment, bullying, or intimidation |
+| hate_speech | User promotes hatred against protected groups |
+| spam_account | Account used for spamming or bulk messaging |
+| impersonation | User falsely claims to be another person or entity |
+| underage_user | User appears to be under the minimum required age |
+| inappropriate_profile | Profile contains inappropriate or offensive content |
+| other | Other violations not covered by specific categories |
+
+## Guild report categories
+
+A guild report and the `guild` variant of a Digital Services Act notice both draw `category` from this registry. A value outside it returns 400 `INVALID_FORM_BODY`.
+
+| Value | Description |
+| --- | --- |
+| harassment | Guild facilitates harassment, bullying, or intimidation |
+| hate_speech | Guild promotes hatred against protected groups |
+| extremist_community | Guild promotes extremist or terrorist ideologies |
+| illegal_activity | Guild promotes or facilitates illegal activities |
+| child_safety | Guild endangers minors or hosts child abuse content |
+| raid_coordination | Guild coordinates attacks on other communities |
+| spam | Guild used for spamming or bulk messaging |
+| malware_distribution | Guild distributes malware or malicious software |
+| other | Other violations not covered by specific categories |
+
+## EU country codes
+
+No instance configuration changes this registry. A value outside it returns 400 `INVALID_FORM_BODY`.
+
+| Value | Description |
+| --- | --- |
+| AT | Austria |
+| BE | Belgium |
+| BG | Bulgaria |
+| HR | Croatia |
+| CY | Cyprus |
+| CZ | Czechia |
+| DK | Denmark |
+| EE | Estonia |
+| FI | Finland |
+| FR | France |
+| DE | Germany |
+| GR | Greece |
+| HU | Hungary |
+| IE | Ireland |
+| IT | Italy |
+| LV | Latvia |
+| LT | Lithuania |
+| LU | Luxembourg |
+| MT | Malta |
+| NL | Netherlands |
+| PL | Poland |
+| PT | Portugal |
+| RO | Romania |
+| SK | Slovakia |
+| SI | Slovenia |
+| ES | Spain |
+| SE | Sweden |
+
+## Report message
+
+
+
+Creates and returns a [report object](#report-object) for a message the reporter can currently read.
+
+### Limitations
+
+- The reporter must be able to open the containing channel and read the message.
+- A reporter who authored the message receives 400 `CANNOT_REPORT_OWN_MESSAGE`.
+- A channel that does not exist and a direct message the reporter is not a recipient of both return 404 `UNKNOWN_CHANNEL`.
+- A non-member and a member without [VIEW_CHANNEL](/http-api/permissions/) both return 403 `MISSING_PERMISSIONS`.
+- An age-restricted guild channel returns 403 `NSFW_CONTENT_AGE_RESTRICTED` for an account that is not age verified.
+- A message not stored under `channel_id`, a message with no author, and any message a reporter without [READ_MESSAGE_HISTORY](/http-api/permissions/) cannot reach all return 404 `UNKNOWN_MESSAGE`.
+- An author whose account no longer exists returns 404 `UNKNOWN_USER`.
+
+:::note[No cutoff means no history at all]
+A reporter without `READ_MESSAGE_HISTORY` can report only a message at or after the guild's message history cutoff. A guild that sets no cutoff withholds the whole channel from that reporter.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the channel containing the reported message |
+| message_id | snowflake | The ID of the reported message |
+| category | string | [Message report category](#message-report-categories) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [report](#report-object) object | Report was created |
+| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS`, or the reporter is the message author and the request returns `CANNOT_REPORT_OWN_MESSAGE` |
+| 403 | [error response](/http-api/#error-response) | A body string is blocked and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or OAuth2 bearer and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | Its email is unverified and the request returns `REPORT_EMAIL_VERIFICATION_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | The account is report banned and the request returns `REPORT_BANNED` |
+| 403 | [error response](/http-api/#error-response) | The reporter is not a guild member or lacks `VIEW_CHANNEL` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The channel is age restricted and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | The channel, the message, or the message author is not visible to the reporter |
+| 4091 | [error response](/http-api/#error-response) | The reporter already reported this message, returning `CONFLICT` |
+
+1 The reporter, the channel, and the message form a permanent submission key. A second report of the same message by the same reporter is refused however much time has passed and whichever category it names
+
+### Side effects
+
+The operation writes one report with a frozen evidence snapshot. The snapshot records the reporter's account ID and email address, the message author's identity and avatar hash, the channel and guild identity, and the guild and channel NSFW and content warning state.
+
+The snapshot also records a conversation window of up to 25 messages before the reported message, the reported message itself, and up to 25 messages after it. Each context message records its author identity, content, timestamps, type, flags, mentions, embeds, and sticker items, and each attachment is copied byte for byte. A context message whose author no longer exists is dropped from the window, and an attachment whose copy fails is dropped without failing the request. A reporter without `READ_MESSAGE_HISTORY` receives a window trimmed to the guild's message history cutoff.
+
+### Rate limit
+
+10 requests per hour, on the `report:create` bucket, which is shared with [Report user](#report-user) and [Report guild](#report-guild).
+
+Four further allowances apply, each counted over one hour. One reporter creates at most 5 reports across all report types and at most 3 message reports in one channel, and one message receives at most 20 reports across all reporters. When the reported message is in a guild, one reporter creates at most 4 message reports in that guild. A duplicate submission returns 409 `CONFLICT` before any of the four is consumed.
+
+## Report user
+
+
+
+Creates and returns a [report object](#report-object) for another user, optionally noting the guild in which the conduct occurred.
+
+### Limitations
+
+- A reporter naming their own account receives 400 `CANNOT_REPORT_YOURSELF`.
+- A `user_id` that resolves to no account returns 404 `UNKNOWN_USER`.
+- A `guild_id` that resolves to no guild returns 404 `UNKNOWN_GUILD`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the reported user |
+| category | string | [User report category](#user-report-categories) |
+| guild_id? | snowflake | The ID of the guild in which the reported conduct occurred |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [report](#report-object) object | Report was created |
+| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS`, or the reporter is the reported user and the request returns `CANNOT_REPORT_YOURSELF` |
+| 403 | [error response](/http-api/#error-response) | A body string is blocked and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or OAuth2 bearer and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | Its email is unverified and the request returns `REPORT_EMAIL_VERIFICATION_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | The account is report banned and the request returns `REPORT_BANNED` |
+| 404 | [error response](/http-api/#error-response) | The reported user does not exist and the request returns `UNKNOWN_USER` |
+| 404 | [error response](/http-api/#error-response) | The supplied `guild_id` does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The operation writes one report recording the reporter's account ID and the email address on that account, the reported user's ID and avatar hash, and, when `guild_id` is supplied, the guild's ID, name, icon hash, NSFW flag, and content warning state. No message evidence is captured and no media bytes are copied.
+
+Every accepted request writes a new report, and nothing deduplicates a repeat submission.
+
+### Rate limit
+
+10 requests per hour, on the `report:create` bucket, which is shared with [Report message](#report-message) and [Report guild](#report-guild).
+
+One reporter creates at most 5 reports each hour across all report types.
+
+## Report guild
+
+
+
+Creates and returns a [report object](#report-object) for a guild.
+
+### Limitations
+
+- A `guild_id` that resolves to no guild returns 404 `UNKNOWN_GUILD`.
+- The guild owner reporting their own guild returns 400 `CANNOT_REPORT_OWN_GUILD`.
+
+Fluxer establishes access to the guild in one of three ways, tried in that order. A current member needs nothing further. Any reporter can report a guild with the `DISCOVERABLE` [guild feature](/http-api/guilds/#guild-features). Any other reporter supplies `invite_code` for an invite pointing at that exact guild. A reporter satisfying none of the three returns 403 `CANNOT_REPORT_GUILD`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the reported guild |
+| category | string | [Guild report category](#guild-report-categories) |
+| invite_code?1 | string | [Invite](/http-api/invites/) code proving access to the guild (1-64 characters) |
+
+1 Read only after the member check and the `DISCOVERABLE` check both fail
+
+This route reads the bare code and accepts no invite URL. A code that resolves to no invite, or to an invite for another guild, returns 403 `CANNOT_REPORT_GUILD` rather than a 404.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [report](#report-object) object | Report was created |
+| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS`, or the reporter owns the guild and the request returns `CANNOT_REPORT_OWN_GUILD` |
+| 403 | [error response](/http-api/#error-response) | A body string is blocked and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | The credential is a bot token or OAuth2 bearer and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | Its email is unverified and the request returns `REPORT_EMAIL_VERIFICATION_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | The account is report banned and the request returns `REPORT_BANNED` |
+| 403 | [error response](/http-api/#error-response) | Access to the guild cannot be established and the request returns `CANNOT_REPORT_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The reported guild does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The operation writes one report recording the reporter's account ID and the email address on that account, the guild's ID, name, and icon hash, the guild's NSFW flag and content warning state, and the supplied invite code when one was sent. No message evidence is captured and no media bytes are copied.
+
+Every accepted request writes a new report, and nothing deduplicates a repeat submission.
+
+### Rate limit
+
+10 requests per hour, on the `report:create` bucket, which is shared with [Report message](#report-message) and [Report user](#report-user).
+
+One reporter creates at most 5 reports each hour across all report types.
+
+## Send DSA report email
+
+
+
+Issues a Digital Services Act verification code for an email address and returns a [success object](#success-object).
+
+The address must be deliverable. A domain publishing no MX record and no A or AAAA record returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_EMAIL_ADDRESS` on `email`. A DNS lookup that fails for a transient reason is treated as deliverable. A domain result is cached for 30 minutes when it resolves and 5 minutes when it does not.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email1 | string | The address that receives the verification code (1-254 characters) |
+
+1 Trimmed and lowercased before use, so two spellings that differ only in case address the same verification record
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [success](#success-object) object | The code was generated and handed to the email provider |
+| 400 | [error response](/http-api/#error-response) | The address failed validation or its domain publishes no usable mail DNS record, and the request returns `INVALID_FORM_BODY` |
+
+### Side effects
+
+The operation stores one verification record for the normalised address. The record expires 10 minutes after issue. It is keyed by address alone, so sending again for the same address replaces the previous code and restarts the 10 minute window. The code is nine characters in `XXXX-XXXX` form drawn from uppercase letters and digits.
+
+Fluxer attempts delivery inside the request and discards the outcome. The operation creates no ticket and emits no Gateway Dispatch.
+
+:::caution[A 200 covers every delivery outcome]
+The response is 200 when the provider accepts the message, when it refuses the message, when the address was previously recorded as a hard bounce, and when the instance has email delivery switched off entirely.
+:::
+
+### Rate limit
+
+5 requests per hour, on the `dsa:report:email:send` bucket.
+
+## Verify DSA report email
+
+
+
+Consumes a verification code and returns the [ticket object](#ticket-object) that [Create DSA report](#create-dsa-report) requires.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email1 | string | The address that received the verification code (1-254 characters) |
+| code | string | The nine-character code in `XXXX-XXXX` form, using uppercase letters and digits |
+
+1 Trimmed and lowercased before the verification record is looked up, exactly as [Send DSA report email](#send-dsa-report-email) normalises it
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [ticket](#ticket-object) object | The code matched the unexpired verification record for the address |
+| 4001 | [error response](/http-api/#error-response) | The address or code failed validation, or no unexpired record matches, returning `INVALID_DSA_VERIFICATION_CODE` |
+
+1 An address with no verification record, an expired record, and a wrong code all produce the same error code. A malformed address or code fails schema validation and returns 400 `INVALID_FORM_BODY`.
+
+### Side effects
+
+The operation deletes the verification record and writes one ticket that expires one hour later. Fluxer deletes the record only on success, so a wrong guess leaves the outstanding code usable until it expires. The operation emits no Gateway Dispatch.
+
+### Rate limit
+
+10 requests per hour, on the `dsa:report:email:verify` bucket.
+
+## Create DSA report
+
+
+
+Consumes a ticket, files the notice, and returns a [report object](#report-object).
+
+`report_type` selects which extra fields the body has, and `category` comes from the registry that matches it.
+
+A ticket that is unknown or past its one hour expiry returns 400 `INVALID_DSA_TICKET`. The ticket is consumed when the notice is written, so a notice that is rejected or rate limited leaves it usable. Nothing deduplicates a repeat notice.
+
+### JSON body
+
+#### Common fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The ticket returned by [Verify DSA report email](#verify-dsa-report-email) (1-128 characters) |
+| report_type | string | The discriminator selecting the variant, one of `message`, `user`, or `guild` |
+| category | string | The category matching `report_type`, drawn from the [message](#message-report-categories), [user](#user-report-categories), or [guild](#guild-report-categories) registry |
+| additional_info? | string | The free-text context supplied by the reporter (0-1000 characters) |
+| reporter_full_legal_name | string | The full legal name of the person filing the notice (1-160 characters) |
+| reporter_country_of_residence | string | [EU country code](#eu-country-codes) of the reporter's residence |
+| reporter_fluxer_tag?1 | string | The reporter's own tag in `username#1234` form (3-37 characters) |
+
+1 Validated for shape and then discarded, so Fluxer never resolves it against an account and no moderator sees it
+
+#### Message fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_link1 | string | The link to the reported message (1-2048 characters) |
+| reported_user_tag?2 | string | The message author's tag in `username#1234` form (3-37 characters) |
+
+1 Parsed as an absolute URL whose path is `/channels/<guild_id>/<channel_id>/<message_id>`. Fluxer ignores the origin and the guild segment, and reads only the channel and message segments
+
+2 When supplied it resolves to exactly the account that authored the linked message. A mismatch returns 400 `INVALID_DSA_REPORT_TARGET`, and a tag that resolves to no account returns 404 `UNKNOWN_USER`
+
+A value that is not an absolute URL, whose path holds fewer than four segments, whose first segment is not `channels`, or whose third or fourth segment is not a decimal snowflake, returns 404 `UNKNOWN_MESSAGE`.
+
+:::caution[This variant applies no visibility check]
+The guild need not be discoverable, the channel need not be readable by the default role, and the evidence window is captured with no permission filtering. A notice can reference a message no unauthenticated party could otherwise read.
+:::
+
+#### User fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id?1 | snowflake | The ID of the reported user |
+| user_tag?1 | string | The reported user's tag in `username#1234` form (3-37 characters) |
+
+1 Supply at least one of the two. A body that supplies neither returns 400 `INVALID_FORM_BODY`, and an ID or tag that resolves to no account returns 404 `UNKNOWN_USER`
+
+When both are supplied they must resolve to the same account. A mismatch returns 400 `INVALID_DSA_REPORT_TARGET`.
+
+#### Guild fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the reported guild |
+| invite_code?1 | string | [Invite](/http-api/invites/) code or invite URL for the reported guild (1-64 characters) |
+
+1 This variant checks no access to the guild, so the code is never required. When supplied it is reduced to its final `/` separated segment, and a complete invite URL is accepted
+
+An unknown code returns 404 `UNKNOWN_INVITE`. A code that resolves to an invite of another type, or to an invite for another guild, returns 400 `INVALID_DSA_REPORT_TARGET`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [report](#report-object) object | The notice was recorded |
+| 400 | [error response](/http-api/#error-response) | The ticket is unknown or expired and the request returns `INVALID_DSA_TICKET`, or the target could not be resolved to the reported subject and the request returns `INVALID_DSA_REPORT_TARGET` |
+| 403 | [error response](/http-api/#error-response) | A body string is blocked by content moderation and the request returns `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | The referenced channel, message, user, guild, or invite does not exist |
+
+### Side effects
+
+The operation resolves the target, deletes the ticket, and writes one report under a freshly generated [snowflake](/snowflakes/). A message notice captures the same conversation window as [Report message](#report-message), including the byte copy of every attachment, and applies no permission filtering to it. A user notice records the reported user's ID and avatar hash and nothing else about the target. A guild notice records the guild's ID, name, icon hash, NSFW flag, content warning state, and the sanitised invite code when one was supplied.
+
+Every notice also records `additional_info`, the reporter's legal name, country of residence, and the verified address behind the ticket. A notice has no account ID even when the request presented a credential, and it discards `reporter_fluxer_tag`.
+
+### Rate limit
+
+5 requests per hour, on the `dsa:report:create` bucket. The verified address behind the ticket files at most 5 reports each hour.
diff --git a/fluxer_docs/src/content/docs/http-api/search.mdx b/fluxer_docs/src/content/docs/http-api/search.mdx
new file mode 100644
index 000000000..a58295213
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/search.mdx
@@ -0,0 +1,299 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Search
+description: Global message search, its scope resolution, filter vocabulary, and result envelope.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Message search matches indexed messages against a text query and a filter set. One request covers a single channel, one guild, the caller's private channels, or several of those at once. The [Guild member search resource](/http-api/guild-member-search/) defines guild member search, and the [Discovery resource](/http-api/discovery/) defines discoverable guild search.
+
+Fluxer resolves the requested scope to the set of channels the caller may search, then runs the query over that set. A private channel search covers the authenticated account alone, and the set holds a guild channel only while the caller can read its history.
+
+An instance backs message search with either Elasticsearch or Meilisearch. Term matching and typo tolerance belong to whichever it runs, so an instance can differ in how forgiving a match is. Filter matching is exact in every case.
+
+## Message search result object
+
+The body a completed search returns, holding one page of matching messages.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| messages1 | array[[message](/http-api/messages/#message-object) object] | The matching messages on the requested page |
+| channels2 | array[[channel](/http-api/channels/#channel-object) object] | The channels the returned messages belong to |
+| total3 | integer | The number of messages matching the query |
+| hits_per_page | integer | The page size that was applied, echoed from the request |
+| page | integer | The page number that was applied, echoed from the request |
+| cursor?4 | array[string] | The opaque cursor the backend produced, which no backend accepts back |
+
+1 Each entry is an ordinary message object with the `referenced_message` field removed, so a reply is returned without its resolved parent even though the reply's own `message_reference` is preserved
+
+2 Contains one entry for each distinct channel represented in `messages`, and never contains a channel with no returned message
+
+3 Counted over the whole resolved scope. A single channel context without [READ_MESSAGE_HISTORY](/http-api/permissions/) reports the number actually returned
+
+4 Present only when the backend produced a cursor. A single channel context, a reconciled offset page, and a Meilisearch instance each return none
+
+[Search messages](#search-messages) never honours a supplied cursor, so page through a result set with `page` alone.
+
+The operation drops a hit whose message no longer exists. On an offset page the operation re-walks the result set so that `total` and the returned page both count only live messages. A request that supplied a `cursor` skips that reconciliation and only removes the stale hits, so the page can be shorter than `hits_per_page` while further pages exist.
+
+### Example
+
+```json
+{
+ "messages": [],
+ "channels": [],
+ "total": 0,
+ "hits_per_page": 25,
+ "page": 1
+}
+```
+
+## Search indexing object
+
+The other 200 body. Fluxer returns it when a channel the search needs has never been indexed, or when that index predates the instance forced reindex point.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| indexing | boolean | Whether an index the search needed is not queryable yet, always true on this body |
+
+Tell the two bodies apart by the presence of `indexing`, which the result object never has. The request enqueues the missing indexing work, and a later identical request returns ordinary results once that work completes.
+
+### Example
+
+```json
+{
+ "indexing": true
+}
+```
+
+:::caution[One stale channel withholds a whole multi-guild search]
+The `all_guilds`, `all`, and `open_dms_and_all_guilds` scopes check the index of every guild channel the caller can read, so a request narrowed to particular channels still receives the search indexing object when any other readable guild channel is unindexed.
+:::
+
+## Search scopes
+
+| Value | Description |
+| --- | --- |
+| current | The single guild named by context_guild_id, or the single channel named by context_channel_id when no guild context is supplied |
+| open_dms1 | Every direct message and group direct message channel the caller currently has open |
+| all_dms | Every direct message and group direct message channel the caller has ever been a recipient of |
+| all_guilds | Every guild the caller is currently a member of |
+| all | Every guild the caller is currently a member of together with the all_dms channel set |
+| open_dms_and_all_guilds | Every guild the caller is currently a member of together with the open_dms channel set |
+
+1 A private channel is open when the caller has not closed it, and a channel named by `context_channel_id` is added to the open set for this request even when it is closed
+
+A bot uses `current`. Supplying any other value with a bot token returns 400 `INVALID_FORM_BODY` on the path `scope` with the field code [BOT_SEARCH_SCOPE_UNAVAILABLE](/http-api/errors/).
+
+## Search author types
+
+| Value | Description |
+| --- | --- |
+| user | Authored by an ordinary user account |
+| bot | Authored by a bot account |
+| webhook1 | Authored by a webhook |
+
+1 A webhook message is indexed as webhook even when the executing application is a bot
+
+## Search content types
+
+| Value | Description |
+| --- | --- |
+| image | Has an uploaded attachment whose media type begins with `image/` |
+| sound | Has an uploaded attachment whose media type begins with `audio/` |
+| video | Has an uploaded attachment whose media type begins with `video/` |
+| file | Has at least one uploaded attachment of any media type |
+| sticker | Has at least one sticker |
+| embed1 | Has at least one generated preview or supplied rich embed |
+| link2 | Has an HTTP or HTTPS URL in its own text |
+| poll3 | Has a poll |
+| snapshot4 | Is a forward and therefore has at least one message snapshot |
+
+1 Derived from the message's own embed list, so an uploaded attachment never satisfies it
+
+2 Derived from the message text alone, so a message that only has a link inside an embed does not match
+
+3 `has` never matches on this value, and `exclude_has` with this value excludes nothing
+
+4 A forward also merges the content, embeds, attachments, stickers, and mentions of its snapshots into its own indexed document, so the other attributes can be satisfied by forwarded content
+
+## Search embed types
+
+| Value | Description |
+| --- | --- |
+| image1 | An image preview embed |
+| video1 | A video preview embed |
+| sound2 | A value the filter accepts that matches no document |
+| article1 | An article or webpage preview embed |
+
+1 Compared against the embed's own type string, which is one of `rich`, `link`, `article`, `image`, `video`, `audio`, `gifv`, or `bluesky`
+
+2 An audio preview is indexed as `audio`. Use the `sound` [content type](#search-content-types) to find uploaded audio attachments
+
+The four values above are the only ones this filter accepts, so a rich, link, gifv, or bluesky embed cannot be selected or excluded by embed type.
+
+## Search sort fields
+
+| Value | Description |
+| --- | --- |
+| timestamp | Order by creation time, tie-broken by descending message ID |
+| relevance1 | Order by whatever the configured search backend produces when no explicit sort field is submitted |
+
+1 `sort_order` is not applied to this value
+
+A Meilisearch instance orders `relevance` by its own ranking rules. An Elasticsearch instance applies its descending message ID tiebreak as the only sort key, so `relevance` there is equivalent to `timestamp` with a `sort_order` of `desc`.
+
+## Search sort orders
+
+| Value | Description |
+| --- | --- |
+| asc | Oldest first |
+| desc | Newest first |
+
+## Search messages
+
+
+
+Searches indexed messages in the resolved scope. Returns a [message search result](#message-search-result-object) object on success, or a [search indexing](#search-indexing-object) object when an index the search needs is not queryable yet.
+
+### Limitations
+
+- A bot is restricted to the `current` scope.
+- The caller must be a recipient of every private channel that contributes to the search.
+- A guild scope includes a guild channel only when the caller holds both [VIEW_CHANNEL](/http-api/permissions/) and [READ_MESSAGE_HISTORY](/http-api/permissions/) in it.
+- A single channel context requires guild membership and [VIEW_CHANNEL](/http-api/permissions/) alone. Without `READ_MESSAGE_HISTORY` the guild's message history cutoff applies to each returned message.
+- An age-restricted guild or single channel context requires an account old enough for age-restricted content, and otherwise returns 403 `NSFW_CONTENT_AGE_RESTRICTED`.
+- An instance with no search backend configured returns 403 `FEATURE_TEMPORARILY_DISABLED`.
+
+Every scope that touches a guild channel calls the main Gateway. A Gateway call that times out returns 504 `GATEWAY_TIMEOUT`, a reply that cannot be interpreted returns 502 `BAD_GATEWAY`, and a Gateway that is overloaded or unreachable returns 503 `SERVICE_UNAVAILABLE`.
+
+### JSON body
+
+Every field is optional, though the default `current` scope requires a context. An omitted filter is not applied.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope?1 | string | [Search scope](#search-scopes) to resolve (default `current`) |
+| context_channel_id?2 | snowflake | The ID of the channel that supplies the channel context |
+| context_guild_id?2 | snowflake | The ID of the guild that supplies the guild context |
+| channel_ids?3 | array[snowflake] | The channel IDs to narrow the resolved scope to (at most 500) |
+| channel_id?4 | array[snowflake] | An alias of `channel_ids` (at most 500) |
+| hits_per_page? | integer | The number of messages on each page (1-25, default 25) |
+| page?5 | integer | The page number (1-9007199254740991, default 1) |
+| cursor?6 | array[string] | The opaque cursor returned by a previous page, accepted and then discarded |
+| min_id?7 | snowflake | The lowest message ID to match, applied exclusively |
+| max_id?7 | snowflake | The highest message ID to match, applied exclusively |
+| content?8 | string | The free-text query (1-1024 characters) |
+| contents?9 | array[string] | The alternative free-text queries (at most 100, 1-1024 characters each) |
+| exact_phrases?10 | array[string] | The phrases that must all match (at most 10, 1-1024 characters each) |
+| exclude_channel_id? | array[snowflake] | The channel IDs, any of which excludes a message (at most 500) |
+| author_id?11 | array[snowflake] | The author user IDs, any of which can match (at most 100) |
+| exclude_author_id? | array[snowflake] | The author user IDs, any of which excludes a message (at most 100) |
+| author_type?11 | array[string] | [Author types](#search-author-types), any of which can match (at most 20) |
+| exclude_author_type? | array[string] | [Author types](#search-author-types), any of which excludes a message (at most 20) |
+| mentions?12 | array[snowflake] | The user IDs, all of which must be mentioned (at most 100) |
+| exclude_mentions? | array[snowflake] | The user IDs, any of which excludes a message (at most 100) |
+| mention_everyone? | boolean | Whether a matching message has an everyone mention |
+| pinned? | boolean | Whether a matching message is pinned |
+| has?12 | array[string] | [Content types](#search-content-types), all of which must be present (at most 20) |
+| exclude_has? | array[string] | [Content types](#search-content-types), none of which can be present (at most 20) |
+| embed_type?12 | array[string] | [Embed types](#search-embed-types), all of which must be present (at most 20) |
+| exclude_embed_type? | array[string] | [Embed types](#search-embed-types), any of which excludes a message (at most 20) |
+| embed_provider?1213 | array[string] | The embed provider names, all of which must be present (at most 50, 1-256 characters each) |
+| exclude_embed_provider?13 | array[string] | The embed provider names, any of which excludes a message (at most 50, 1-256 characters each) |
+| link_hostname?1213 | array[string] | The hostnames, all of which must be present (at most 100, 1-255 characters each) |
+| exclude_link_hostname?13 | array[string] | The hostnames, any of which excludes a message (at most 100, 1-255 characters each) |
+| attachment_filename?1213 | array[string] | The filenames, all of which must be present (at most 100, 1-1024 characters each) |
+| exclude_attachment_filename?13 | array[string] | The filenames, any of which excludes a message (at most 100, 1-1024 characters each) |
+| attachment_extension?1214 | array[string] | The extensions, all of which must be present (at most 50, 1-32 characters each) |
+| exclude_attachment_extension?14 | array[string] | The extensions, any of which excludes a message (at most 50, 1-32 characters each) |
+| sort_by? | string | [Search sort field](#search-sort-fields) (default `timestamp`) |
+| sort_order? | string | [Search sort order](#search-sort-orders) (default `desc`) |
+| include_nsfw?15 | boolean | Whether age-restricted channels can contribute results (default false) |
+
+1 A bot that supplies any value other than `current` is rejected with 400 `INVALID_FORM_BODY` on the path `scope`
+
+2 Consulted only by the `current` scope, where a guild context wins over a channel context, and by the private channel scopes, where it adds one closed channel back
+
+3 Every listed channel must already be in the resolved scope. A `current` channel context ignores it entirely
+
+4 Read only when `channel_ids` is absent, and then it narrows the resolved scope exactly as `channel_ids` does
+
+5 Only `page` reaches a page beyond the first
+
+6 The route never honours it. Supplying one serves the first page again while the response still echoes the requested `page`
+
+7 Both bounds are exclusive, so `min_id` matches messages strictly newer than it and `max_id` matches messages strictly older than it
+
+8 Matched against the message text and the collected embed text. The value is trimmed and stripped of control and formatting characters before its length is checked
+
+9 Supply the text query in exactly one of `content` and `contents`
+
+10 Every phrase must match, and a phrase matches only when its terms appear adjacently and in order in the message text or the collected embed text
+
+11 A message matches when it satisfies any one of the listed values
+
+12 A message matches only when it satisfies every listed value, so supplying two values requires both
+
+13 Compared exactly and case-sensitively, so a partial value never matches. A link hostname uses the URL host of a link in the message text or of an embed URL
+
+14 Indexed in lowercase, without a leading dot, and only for the final dot-separated segment of a filename when that segment is 1 through 10 characters
+
+15 Honoured only for an account whose recorded date of birth places it at 18 years or older, and otherwise treated as false. A bot always passes
+
+A `current` guild context rejects a requested channel outside that guild with 400 and the field code [ALL_CHANNELS_MUST_BELONG_TO_GUILD](/http-api/errors/). It rejects a channel inside the guild that the caller cannot read with 403 `MISSING_PERMISSIONS`. Every other scope rejects a requested channel its resolved set does not already contain with 403 `MISSING_PERMISSIONS`. Use `exclude_channel_id` to remove a channel.
+
+Fluxer compares `attachment_extension` without lowercasing it, so an uppercase value never matches. `include_nsfw` gates which channels a resolved scope keeps, and a single channel context ignores it.
+
+:::caution[One stale hit re-reads the whole result set]
+The operation walks the set from the first page to skip stale hits, so a deep offset page costs proportionally more and returns no cursor.
+:::
+
+:::caution[Elasticsearch has a 10000 document result window]
+A plain text query pages past it. A page beyond that window fails with 500 `INTERNAL_SERVER_ERROR` when `contents` or `exact_phrases` is supplied.
+:::
+
+The two backends read `min_id` and `max_id` differently. An Elasticsearch instance compares the message ID, while a Meilisearch instance compares the whole creation second the bound encodes, so a message created in the same second as the bound is excluded there. An Elasticsearch instance requires at least one `contents` entry to match and replaces `content` with that group. A Meilisearch instance joins the group, `content`, and the quoted phrases into one query string, and requires no entry in particular. Supplying `exact_phrases` without `contents` narrows `content` to the message text alone on Elasticsearch.
+
+The `current` scope requires one of `context_guild_id` and `context_channel_id`. Supplying neither returns 400 with the field code [CONTEXT_CHANNEL_OR_GUILD_ID_REQUIRED](/http-api/errors/) on the path `context`.
+
+Fluxer resolves a supplied `context_guild_id` before anything else. A guild that cannot be resolved returns 404 `UNKNOWN_GUILD`, and a guild the caller is not a member of returns 403 `MISSING_PERMISSIONS`. A guild context searches that guild, restricted to `channel_ids` when supplied and to every channel of the guild otherwise, and every channel in the resulting set must belong to that guild. A channel the caller cannot both view and read history in is dropped when the set came from the guild, and rejected with 403 `MISSING_PERMISSIONS` when the caller named it in `channel_ids`. An age-gated channel that `include_nsfw` does not admit is removed from the set before the permission check runs, so it never causes that refusal.
+
+A guild whose NSFW level is age restricted refuses a caller who is not old enough with 403 `NSFW_CONTENT_AGE_RESTRICTED`. An old-enough caller who did not set `include_nsfw` receives an empty result from such a guild. The request still succeeds.
+
+A channel context searches only that channel and ignores `channel_ids`, `cursor`, and `include_nsfw`. When the caller cannot read the channel's history and the guild defines no message history cutoff, the search returns an empty result.
+
+The `all_guilds`, `all`, and `open_dms_and_all_guilds` scopes include only guild channels the caller can currently view and read history in. The `open_dms` and `open_dms_and_all_guilds` scopes include the caller's open private channels. The `all_dms` and `all` scopes include every private channel available to the caller unless `channel_ids` narrows the set. A scope that resolves to no channel returns an empty result with a `total` of `0`.
+
+Searchable text is the message content plus the collected embed text, which covers embed titles, descriptions, URLs, author names, provider names, footer text, and field names and values, including nested embeds.
+
+Content moderation scans every string in the body whose field name does not end in `_id` or `_ids`, so a value of at least three characters that matches the instance phrase blocklist or has a blocked URL returns 403 `CONTENT_BLOCKED`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message search result](#message-search-result-object) object \| [search indexing](#search-indexing-object) object | Search completed, or an index it needed is not queryable yet |
+| 400 | [error response](/http-api/#error-response) | A bot supplied a scope other than `current`, the `current` scope has no context, or a requested channel does not belong to the context guild, each returning `INVALID_FORM_BODY` |
+| 403 | [error response](/http-api/#error-response) | Caller presents an OAuth2 bearer credential, or the guild of the context channel cannot be resolved while a stored guild record still exists, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | A body string is blocked by content moderation and the request returns `CONTENT_BLOCKED` |
+| 403 | [error response](/http-api/#error-response) | No search backend is configured for the instance and the request returns `FEATURE_TEMPORARILY_DISABLED` |
+| 403 | [error response](/http-api/#error-response) | A requested channel is outside the resolved scope, the caller is not a member of the context guild, or the caller cannot view the context channel, each returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The context guild or the context channel is age restricted and the caller is not old enough and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Context guild cannot be resolved, or the guild of the context channel cannot be resolved and no stored guild record exists, each returning `UNKNOWN_GUILD` |
+| 404 | [error response](/http-api/#error-response) | The context channel does not exist or is a private channel the caller is not a recipient of and the request returns `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The caller's own account record is missing and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+When a channel in the resolved scope has never been indexed or its index is stale, the operation enqueues an indexing job for it and returns the [search indexing](#search-indexing-object) object.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `search:messages` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/streams.mdx b/fluxer_docs/src/content/docs/http-api/streams.mdx
new file mode 100644
index 000000000..3d33ae9f4
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/streams.mdx
@@ -0,0 +1,324 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Streams
+description: Go Live stream keys, stream region selection, and the stream preview image lifecycle.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A Go Live stream is a screen share on a member's existing voice connection. A stream key names one stream. The operations here record a region preference and manage the stream's JPEG preview image.
+
+Every route on this page is user-only. Bot and OAuth2 credentials are rejected.
+
+## Stream lifecycle
+
+Starting and stopping a stream is a main Gateway operation. Going live publishes a screen share track on the LiveKit participant the member already holds, and the publisher advertises it by setting `self_stream` on that connection with [Voice State Update](/gateway/commands/#voice-state-update). Fluxer opens no second connection and issues no new grant. The [Voice overview](/voice/) states the full model.
+
+## Stream key
+
+A stream key travels as one path segment of 1 through 256 characters. It has exactly three colon-separated segments. The form is `{guild_id}:{channel_id}:{connection_id}` for a guild voice stream and `dm:{channel_id}:{connection_id}` for a private call stream.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope1 | string | The literal `dm` for a private call, or the decimal guild ID for a guild voice channel |
+| channel_id2 | string | The decimal ID of the voice channel or private channel the stream runs in |
+| connection_id3 | string | The `connection_id` of the publisher's existing voice connection |
+
+1 Any other value fails the format check, including an empty segment
+
+2 The segment is canonical decimal digits, `[0-9]+` with no leading zero. A non-canonical form never matches
+
+3 The publisher's ordinary voice connection, which is also the one in its voice state and in its media participant identity
+
+A key with a different segment count, a non-numeric channel segment, an empty connection segment, or a scope segment that is neither `dm` nor a decimal ID returns 400 `INVALID_STREAM_KEY_FORMAT`.
+
+[Modify stream region](#modify-stream-region), [Get stream preview](#get-stream-preview), and [Delete stream preview](#delete-stream-preview) resolve the channel from the channel segment of the key. [Upload stream preview](#upload-stream-preview) and [Create stream preview upload URL](#create-stream-preview-upload-url) instead resolve it from the `channel_id` in their request body and compare that value against the key afterwards.
+
+The scope segment is checked against the resolved channel. A guild-scoped key whose channel turns out to be private, a `dm` key whose channel turns out to belong to a guild, and a guild-scoped key whose guild segment is not the resolved channel's guild each return 400 `STREAM_KEY_SCOPE_MISMATCH`.
+
+The route then compares the channel segment against the resolved channel ID, and a mismatch returns 400 `STREAM_KEY_CHANNEL_MISMATCH`. On the two routes that have a body `channel_id`, a caller MUST send the same value in the body and in the key.
+
+That comparison is the last check of the read boundary. Channel lookup, the scope check, the channel type check, and the `CONNECT` check all run first, so a body `channel_id` naming a channel the caller cannot see answers 404 `UNKNOWN_CHANNEL` or 403.
+
+## Access rules
+
+Two boundaries apply across this page.
+
+Read access matches [Get channel](/http-api/channels/#get-channel) for the [resolved channel](#stream-key). A guild channel additionally requires the [CONNECT](/http-api/permissions/) permission. The caller does not have to own the stream, so any member who can join the channel can read its preview.
+
+Mutation access adds two requirements to read access. A guild channel also requires the [STREAM](/http-api/permissions/) permission. The caller must hold a voice state in exactly the resolved channel and the connection identifier the key names. Fluxer rejects a caller that holds no such voice state with 403 `ACCESS_DENIED`, even when it owns the channel.
+
+:::caution[A preview can exist with no live stream]
+A caller can record a region and upload a preview for any voice connection it holds, whether or not it is streaming.
+:::
+
+A guild channel resolves before its guild does. A channel row that outlives its guild returns 404 `UNKNOWN_GUILD`. A guild record that exists but whose membership state cannot be resolved returns 403 `ACCESS_DENIED`, and a caller that is not a member or that lacks [VIEW_CHANNEL](/http-api/permissions/) returns 403 `MISSING_PERMISSIONS`. An unsatisfied age restriction on a guild text, voice, or link channel returns 403 `NSFW_CONTENT_AGE_RESTRICTED`.
+
+A private channel whose recipient set does not contain the caller returns 404 `UNKNOWN_CHANNEL`. An absent channel returns the same code, so recipient membership is never distinguishable from private channel existence.
+
+Every route checks the channel type after the scope check. A guild key names a guild voice channel and a `dm` key names a direct message or a group direct message. Any other resolved channel returns 400 `INVALID_CHANNEL_TYPE`.
+
+## Stream preview upload object
+
+A short-lived URL that accepts one stream's preview image. [Create stream preview upload URL](#create-stream-preview-upload-url) returns it, and the client uploads the image with a single `PUT` against `upload_url`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| upload_url1 | string | The capability URL that accepts the preview bytes with one `PUT` request |
+| method | string | Always `PUT` |
+| content_type2 | string | The media type of the upload request |
+| expires_at3 | ISO8601 timestamp | The time the capability expires |
+| expires_in3 | integer | The lifetime of the capability in seconds |
+| max_bytes | integer | The maximum accepted byte count, always `1000000` |
+
+1 Either a presigned object storage URL or an [upload relay](/media-proxy/upload-relay/) URL with the signed capability in its `t` query parameter
+
+2 The request's `content_type` is returned verbatim, original casing included, and an absent field yields `image/jpeg`
+
+3 A presigned object storage URL lasts one day. A relay URL lasts the relay token lifetime the instance is configured with
+
+A client MUST send the URL exactly as returned and MUST NOT rebuild or reorder it. Rebuilding drops the signed capability in `t`, and a relay URL that arrives without `t` returns 401.
+
+The capability keeps accepting `PUT` requests until it expires. Nothing records that it has been redeemed, and each request overwrites the object the previous one wrote.
+
+### Example
+
+```json
+{
+ "upload_url": "https://cdn.example.com/stream-previews/1501314428688998182?t=fabricated-token",
+ "method": "PUT",
+ "content_type": "image/jpeg",
+ "expires_at": "2026-08-31T12:00:00.000Z",
+ "expires_in": 86400,
+ "max_bytes": 1000000
+}
+```
+
+## Modify stream region
+
+
+
+Records the preferred media region for a stream and returns 204 with an empty body. The caller must satisfy the [mutation access](#access-rules) boundary.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stream_key | string | [Stream key](#stream-key) whose region is set, of 1 through 256 characters |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| region?1 | string | The RTC region ID of 1 through 64 characters |
+
+1 An omitted field stores no region for this stream
+
+A region identifier is the `id` of an [RTC region object](/http-api/channels/#rtc-region-object), as returned by [List RTC regions](/http-api/channels/#list-rtc-regions).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Preference was recorded |
+| 400 | [error response](/http-api/#error-response) | Stream key is malformed and the request returns `INVALID_STREAM_KEY_FORMAT` |
+| 400 | [error response](/http-api/#error-response) | Its scope disagrees with the resolved channel and the request returns `STREAM_KEY_SCOPE_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | Its channel segment is not the canonical form of the resolved channel ID and the request returns `STREAM_KEY_CHANNEL_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The resolved channel is not a guild voice channel or a private channel and the request returns `INVALID_CHANNEL_TYPE` |
+| 403 | [error response](/http-api/#error-response) | Caller holds no matching voice state or has an unresolvable guild membership state, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL`, `CONNECT`, or `STREAM` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction of a guild channel is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel named by the stream key does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The call has no effect on where the stream is placed. Fluxer does not validate the region against the caller's accessible region list. The operation emits no Gateway Dispatch.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and stream key, on the `channel:stream:update::stream_key` bucket.
+
+## Get stream preview
+
+
+
+Returns the stored preview bytes for a stream. The caller must satisfy the [read access](#access-rules) boundary.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stream_key | string | [Stream key](#stream-key) whose preview is returned, of 1 through 256 characters |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | preview bytes | A stored preview exists |
+| 400 | [error response](/http-api/#error-response) | Stream key is malformed and the request returns `INVALID_STREAM_KEY_FORMAT`, its scope disagrees with the resolved channel and the request returns `STREAM_KEY_SCOPE_MISMATCH`, the resolved channel is not a guild voice channel or a private channel and the request returns `INVALID_CHANNEL_TYPE`, or its channel segment is not the canonical form of the resolved channel ID and the request returns `STREAM_KEY_CHANNEL_MISMATCH` |
+| 403 | [error response](/http-api/#error-response) | Caller has an unresolvable guild membership state and the request returns `ACCESS_DENIED`, the caller lacks `VIEW_CHANNEL` or `CONNECT` and the request returns `MISSING_PERMISSIONS`, or the resolved age restriction of a guild channel is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | empty | No preview is stored for this stream key |
+| 404 | [error response](/http-api/#error-response) | Channel named by the stream key does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+The 200 also has `Cache-Control: no-store, private` and `Pragma: no-cache`.
+
+A stored preview expires one day after it was written, and a read after that point answers an empty 404. A stored object that is missing, temporarily unreadable, or larger than 1000000 bytes answers an empty 404 too.
+
+:::note[A missing preview answers 404 with no body]
+Every other 404 on this route has an [error response](/http-api/#error-response) body, so a client MUST read an empty 404 as "no preview yet".
+:::
+
+The response `Content-Type` is the media type recorded when the preview was written, and it defaults to `image/jpeg` when none was recorded. [Upload stream preview](#upload-stream-preview) accepts a declared media type naming JPEG without inspecting the bytes, and the capability path of [Create stream preview upload URL](#create-stream-preview-upload-url) inspects nothing. A client therefore MUST NOT treat this header as proof of the payload format and MUST decode defensively.
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user and stream key, on the `channel:stream:preview:get::stream_key` bucket.
+
+## Upload stream preview
+
+
+
+Replaces the stream's preview with a JPEG sent inline as base64 and returns 204 with an empty body. The caller must satisfy the [mutation access](#access-rules) boundary.
+
+When the image should travel out of band, use [Create stream preview upload URL](#create-stream-preview-upload-url).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stream_key | string | [Stream key](#stream-key) that receives the preview, of 1 through 256 characters |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the channel the stream is active in |
+| thumbnail2 | base64 string | The base64-encoded JPEG of 1 through 2000000 characters |
+| content_type?3 | string | The declared media type of 1 through 64 characters |
+
+1 The channel the route resolves and permission-checks. A value that is not the channel segment of the [stream key](#stream-key) returns 400 `STREAM_KEY_CHANNEL_MISMATCH`
+
+2 Decoded bytes are at most 1000000. Fluxer trims the value before checking its length
+
+3 A value containing `jpeg` or `jpg` in any case is accepted without inspecting the bytes. Any other value, and an absent field, is accepted only when the decoded bytes begin with `FF D8` and end with `FF D9`
+
+Fluxer stores the accepted `content_type` verbatim and returns it from [Get stream preview](#get-stream-preview). An absent field stores the preview as `image/jpeg`.
+
+A `thumbnail` that is not canonical base64 returns 400 `INVALID_STREAM_THUMBNAIL_PAYLOAD`. Fluxer re-encodes the decoded bytes and rejects the value when the result is not what the client sent. The 2000000 character ceiling admits 1500000 decoded bytes, so an oversized JPEG reaches the byte check and returns `FILE_SIZE_TOO_LARGE`. The format check runs before the size check. An oversized payload that is not a JPEG returns `PREVIEW_MUST_BE_JPEG`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Preview was accepted |
+| 400 | [error response](/http-api/#error-response) | Stream key is malformed and the request returns `INVALID_STREAM_KEY_FORMAT` |
+| 400 | [error response](/http-api/#error-response) | The key scope disagrees with the resolved channel and the request returns `STREAM_KEY_SCOPE_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The body channel disagrees with the key and the request returns `STREAM_KEY_CHANNEL_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The resolved channel is not a guild voice channel or a private channel and the request returns `INVALID_CHANNEL_TYPE` |
+| 400 | [error response](/http-api/#error-response) | The payload is not canonical base64 and the request returns `INVALID_STREAM_THUMBNAIL_PAYLOAD` |
+| 400 | [error response](/http-api/#error-response) | The payload is not a JPEG and the request returns `PREVIEW_MUST_BE_JPEG` |
+| 400 | [error response](/http-api/#error-response) | It exceeds 1000000 bytes and the request returns `FILE_SIZE_TOO_LARGE` |
+| 403 | [error response](/http-api/#error-response) | Caller holds no matching voice state or has an unresolvable guild membership state, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL`, `CONNECT`, or `STREAM` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction of a guild channel is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel named by the body `channel_id` does not exist, or the caller is not a recipient of that private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The decoded bytes replace whatever preview the stream key already had, and the preview metadata expires one day after the write. Fluxer emits no Gateway Dispatch, so a viewer observes the new image on its next [Get stream preview](#get-stream-preview) read.
+
+Fluxer absorbs a transient object storage failure. The request still answers 204 and the previous preview remains readable, so a publisher that needs the image present re-uploads on its normal interval.
+
+A transient failure of the metadata write also answers 204. The next read returns the new bytes under the `Content-Type` and the expiry recorded by the previous write. When no metadata existed at all, the object is stored and no read finds it.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and stream key, on the `channel:stream:preview:post::stream_key` bucket.
+
+## Create stream preview upload URL
+
+
+
+Returns a [stream preview upload object](#stream-preview-upload-object) granting a bounded `PUT` upload of the JPEG preview. The caller must satisfy the [mutation access](#access-rules) boundary.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stream_key | string | [Stream key](#stream-key) that receives the preview, of 1 through 256 characters |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The ID of the channel the stream is active in |
+| content_type?2 | string | The declared media type of 1 through 64 characters |
+
+1 The channel the route resolves and permission-checks. A value that is not the channel segment of the [stream key](#stream-key) returns 400 `STREAM_KEY_CHANNEL_MISMATCH`
+
+2 Only a value containing `jpeg` or `jpg` in any case is accepted, an absent field means `image/jpeg`, and any other value returns 400 `PREVIEW_MUST_BE_JPEG`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [stream preview upload](#stream-preview-upload-object) object | Upload capability was issued |
+| 400 | [error response](/http-api/#error-response) | Stream key is malformed and the request returns `INVALID_STREAM_KEY_FORMAT` |
+| 400 | [error response](/http-api/#error-response) | The key scope disagrees with the resolved channel and the request returns `STREAM_KEY_SCOPE_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The body channel disagrees with the key and the request returns `STREAM_KEY_CHANNEL_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The resolved channel is not a guild voice channel or a private channel and the request returns `INVALID_CHANNEL_TYPE` |
+| 400 | [error response](/http-api/#error-response) | The declared media type is not JPEG and the request returns `PREVIEW_MUST_BE_JPEG` |
+| 403 | [error response](/http-api/#error-response) | Caller holds no matching voice state or has an unresolvable guild membership state, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL`, `CONNECT`, or `STREAM` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction of a guild channel is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel named by the body `channel_id` does not exist, or the caller is not a recipient of that private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The operation issues one bounded upload capability and immediately points the stream key's preview metadata at the object that capability writes to. There is no separate completion call. The preview becomes readable as soon as the `PUT` lands, and until then a [Get stream preview](#get-stream-preview) read answers an empty 404 or still returns the previous image.
+
+A client MUST send the returned `content_type` with the `PUT` and MUST NOT exceed `max_bytes`. The preview metadata expires one day after this call, so a capability used later than that writes an object no read can find. Fluxer emits no Gateway Dispatch.
+
+:::note[Neither URL shape inspects the bytes]
+A relay URL refuses a declared length above `max_bytes` with 413, and a presigned object storage URL enforces nothing beyond the signed media type. An object above `max_bytes` still stores, and [Get stream preview](#get-stream-preview) then answers an empty 404.
+:::
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and stream key, on the `channel:stream:preview:upload_url::stream_key` bucket.
+
+## Delete stream preview
+
+
+
+Deletes the stored preview for a stream and returns 204 with an empty body. Deleting an absent preview also returns 204. The caller must satisfy the [mutation access](#access-rules) boundary.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| stream_key | string | [Stream key](#stream-key) whose preview is deleted, of 1 through 256 characters |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Preview was deleted, or none was stored |
+| 400 | [error response](/http-api/#error-response) | Stream key is malformed and the request returns `INVALID_STREAM_KEY_FORMAT` |
+| 400 | [error response](/http-api/#error-response) | Its scope disagrees with the resolved channel and the request returns `STREAM_KEY_SCOPE_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | Its channel segment is not the canonical form of the resolved channel ID and the request returns `STREAM_KEY_CHANNEL_MISMATCH` |
+| 400 | [error response](/http-api/#error-response) | The resolved channel is not a guild voice channel or a private channel and the request returns `INVALID_CHANNEL_TYPE` |
+| 403 | [error response](/http-api/#error-response) | Caller holds no matching voice state or has an unresolvable guild membership state, each returning `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL`, `CONNECT`, or `STREAM` and the request returns `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The resolved age restriction of a guild channel is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | Channel named by the stream key does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+Fluxer drops the preview metadata first and removes the stored object second, so the preview stops being readable even when a transient storage failure leaves the object in place. Fluxer emits no Gateway Dispatch, and a viewer observes the removal on its next [Get stream preview](#get-stream-preview) read.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and stream key, on the `channel:stream:preview:delete::stream_key` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/themes.mdx b/fluxer_docs/src/content/docs/http-api/themes.mdx
new file mode 100644
index 000000000..5eb54def0
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/themes.mdx
@@ -0,0 +1,104 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Themes
+description: Theme identifiers, custom CSS storage, document bounds, and content screening.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A theme is a custom CSS stylesheet stored under a short code, and anyone holding the code can fetch it. The HTTP API exposes one operation, which stores a theme. The [Media Proxy](/media-proxy/overview/) serves the stored stylesheet back through [Get theme CSS](/media-proxy/routes/#get-theme-css).
+
+The single route here is user-only. A bot token and an OAuth2 bearer credential are both rejected with 403 `ACCESS_DENIED`.
+
+## Theme identifier
+
+A theme identifier is 16 lowercase hexadecimal characters matching `^[a-f0-9]{16}$`. Fluxer draws eight cryptographically random bytes for each request and renders them as hex, so storing identical CSS twice produces two independent themes under two independent identifiers.
+
+### Example
+
+```json
+{
+ "id": "3f0a91c7d2b84e65"
+}
+```
+
+## Document bounds
+
+The UTF-8 encoding of a submitted document cannot exceed 8388608 bytes, and the document is at least one character long. Both bounds are fixed constants of the instance.
+
+| Bound | Value |
+| --- | --- |
+| Encoded CSS12 | 8388608 bytes |
+| Submitted CSS3 | at least 1 character |
+
+1 The 8 MiB ceiling is inclusive, so a document whose UTF-8 encoding is exactly 8388608 bytes is stored and one byte more is rejected with 400 `FILE_SIZE_TOO_LARGE`
+
+2 Measured over the UTF-8 encoding, so a document of multi-byte characters can be shorter than 8388608 characters and still be rejected
+
+3 Measured in characters of the submitted string
+
+An account holds no theme quota. The route bucket is the only bound on how many themes an account creates, and no route here consults an [instance limit key](/http-api/instance/#limit-keys).
+
+## Content screening
+
+The instance-wide content filter screens the submitted document before the route runs. It checks a `css` value of at least 3 characters against the instance phrase blocklist and the instance URL blocklist. A match on either returns 403 `CONTENT_BLOCKED`. A shorter value is never screened.
+
+Fluxer scans a body under every content type except `multipart/form-data` and `application/x-www-form-urlencoded`. A body that does not parse as JSON is skipped as well. The route parses the body from the raw request text and reads no content type, so a JSON document sent under either form content type is stored without passing the blocklists.
+
+The screen precedes the rate limit bucket, the credential check, and the request schema, so a blocked document returns `CONTENT_BLOCKED` even when the request has no credential.
+
+The screen is the only inspection the document receives. Fluxer stores the CSS verbatim and never parses, validates, minifies, or rewrites it.
+
+## Create theme
+
+
+
+Stores a custom CSS document and returns 201 with the created [theme identifier](#theme-identifier). User-only, and no permission is required.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| css12 | string | The CSS text to store and share (at least 1 character) |
+
+1 Stored verbatim, bounded by the 8388608-byte UTF-8 ceiling described in [Document bounds](#document-bounds)
+
+2 An empty string is rejected with the validation code `INVALID_FORMAT`
+
+:::note[Malformed and unusable bodies fail at different paths]
+An absent body, a whitespace-only body, and a present but empty, null, or non-string `css` all return 400 `INVALID_FORM_BODY` with `INVALID_FORMAT` at the `css` path. A body that does not parse as JSON returns the same status and code at the `body` path.
+:::
+
+A body that parses as JSON but is not an object, such as a string, a number, an array, or `null`, returns the same code at the path `root`.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | The [theme identifier](#theme-identifier) |
+
+The object has the identifier and nothing else.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 201 | response body | Document was stored |
+| 400 | [error response](/http-api/#error-response) | The encoded document exceeds the size ceiling and the request returns `FILE_SIZE_TOO_LARGE` |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED` |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 403 | [error response](/http-api/#error-response) | The document is blocked by content moderation and the request returns `CONTENT_BLOCKED` |
+
+### Side effects
+
+Fluxer stores the UTF-8 encoding of the submitted document with the content type `text/css; charset=utf-8`. The identifier addresses the document by the time the caller receives it.
+
+Nothing Fluxer stores links a theme to the account that created it. The [Media Proxy](/media-proxy/overview/) then serves the stored document from `/themes/{id}.css`, and that route accepts no credential.
+
+:::danger[Theme CSS is public and permanent]
+The identifier is the only thing guarding a stored document, and the read route is unauthenticated. The HTTP API replaces or deletes no stored theme. A caller MUST NOT put a credential, a token, or any other secret in a submitted document.
+:::
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `theme:share:create` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/unfurl.mdx b/fluxer_docs/src/content/docs/http-api/unfurl.mdx
new file mode 100644
index 000000000..13cc6532d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/unfurl.mdx
@@ -0,0 +1,121 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Unfurl
+description: On-demand URL resolution, its site resolvers, network policy, and cache behaviour.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Unfurl resolves one URL into the preview a message would show for it. That preview is an array of [embed](/http-api/messages/#embed-object) objects, the same objects the ordinary message path attaches.
+
+The single route here is user-only. Fluxer rejects a bot token and an OAuth2 bearer credential with 403 `ACCESS_DENIED`.
+
+## Site resolvers
+
+Fluxer walks a fixed chain of resolvers in the order below. A resolver runs only when the URL matches it, and the first resolver that produces at least one embed wins. Fluxer skips a resolver that produces nothing and a resolver whose own fetch fails, then tries the next match. An unavailable provider therefore degrades to the generic resolver.
+
+| Resolver | Matched URL | Source |
+| --- | --- | --- |
+| Hacker News1 | `news.ycombinator.com` with a path beginning `/item` | The Hacker News item API |
+| Klipy23 | `klipy.com` and `www.klipy.com` | The Klipy media API |
+| Tenor | `tenor.com` | The page markup and its JSON-LD block |
+| xkcd | `xkcd.com` | The comic page markup |
+| YouTube24 | `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com`, `youtube-nocookie.com`, `www.youtube-nocookie.com`, and `youtu.be` | The YouTube Data API |
+| Wikipedia5 | `wikipedia.org`, `www.wikipedia.org`, and the language hosts `en`, `de`, `fr`, `es`, `it`, `ja`, `ru`, and `zh`, with a path beginning `/wiki/` | The Wikipedia REST page summary API |
+| Bluesky6 | `bsky.app` | The Bluesky XRPC API |
+| FxTwitter7 | `fxtwitter.com`, `fixupx.com`, `twittpr.com`, `xfixup.com`, and any subdomain of those | The FxTwitter status API |
+| Generic8 | Every URL | The URL itself |
+
+1 The item identifier is read from the `id` query parameter, and a URL that has none produces no embed
+
+2 The Klipy and YouTube resolvers rewrite the URL to its canonical provider form before anything is fetched, and the generic resolver sees that rewritten URL
+
+3 Requires the instance to have a Klipy API key configured, and produces nothing without one
+
+4 The URL has a video identifier of 6 through 15 characters drawn from `A-Za-z0-9_-`, and the instance needs a YouTube Data API key configured
+
+5 A host whose language subdomain is outside the supported set does not match, and the article title is taken from the path after `/wiki/`
+
+6 Resolves a post URL and a profile URL. Any other path on the host produces no embed
+
+7 The mirror domains only. `twitter.com` and `x.com` do not match this resolver and fall through to the generic one
+
+8 Builds a direct media embed when the response is an image, video, or audio asset. A final response whose status is not 200 produces no embed
+
+The generic resolver otherwise decodes the document and assembles the embed from its ActivityPub representation, its oEmbed document, and its Open Graph and Twitter card metadata. For a MediaWiki page it also reads the article extract.
+
+A resolved embed has at most one nested embed in `children`, and a nested embed has no `children` of its own.
+
+## Network policy
+
+Fluxer applies its network policy before every fetch and again to every redirect target. It refuses a URL that is empty, exceeds 8192 characters, contains a control character, uses a scheme other than HTTP or HTTPS, or has user information. Fluxer also refuses a URL whose port is `0` or whose host is not a valid public hostname. A host that resolves to a loopback, private, link-local, or otherwise reserved address is refused, and so is a host that resolves to no address at all.
+
+Every HTTP request has a timeout of at most 10 seconds. The FxTwitter timeout is 8 seconds, and a Hacker News, Wikipedia, Bluesky, ActivityPub, oEmbed, MediaWiki extract, or media metadata call has 5 seconds. Every fetch identifies itself as `Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)`.
+
+Fluxer abandons a fetch that follows more than five redirects, that revisits a URL already followed in the same chain, or whose response body exceeds the byte budget of that fetch. That budget is 8388608 bytes for a document fetch and lower for a provider API call.
+
+:::note[The whole resolution has a 12-second deadline]
+Each redirect hop is a fresh request with its own bound, so one fetch that follows redirects can run for a multiple of the per-request timeout.
+:::
+
+A refused or failed fetch does not by itself fail the operation. When Fluxer produces no embed, the response is 200 with an empty array.
+
+## Cache behaviour
+
+Two requests share a cached result only when they have the same URL and the same media scanning mode, and the instance's provider API keys did not change between them.
+
+Fluxer holds a result with at least one embed for the instance's configured entry lifetime, and a result with none for 60 seconds.
+
+A message whose URL is already cached is created with the embed attached. A URL that is not cached is resolved after the message is created, and the message is then updated with the result.
+
+## Resolve URL embeds
+
+
+
+Resolves the supplied URL. Returns an array of [embed](/http-api/messages/#embed-object) objects, empty when no resolver produces one. User-only.
+
+No permission applies.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url123 | string | Absolute HTTP or HTTPS URL (1-2048 characters) |
+
+1 Trimmed before its length is measured
+
+2 A normalised value outside 1 through 2048 characters has the validation code `URL_LENGTH_INVALID`, and every other rejection at this boundary has `INVALID_URL_FORMAT`
+
+3 The value begins with `http://` or `https://`. Fluxer rejects a value that has embedded credentials, omits a host, uses a protocol-relative form, or ends its host with a trailing dot
+
+Outside a development instance, Fluxer also rejects a URL that omits a top-level domain.
+
+The route always asks for the explicit media classifier, so a media object the classifier marks has `CONTAINS_EXPLICIT_MEDIA` in its [flags](/http-api/messages/#attachment-flags). The Klipy resolver is the one exception. It reads its thumbnail and video metadata with the classifier disabled regardless of what the request asked for, and Klipy media never has that flag.
+
+The message path asks the same way, except in a channel that permits explicit media, where the classifier is disabled for every resolver. The same URL can resolve to differently flagged media there.
+
+The message path also extracts its candidate URLs from message text. It drops anything inside a code span or code fence and anything wrapped in angle brackets. It skips a Fluxer invite, the invite host, the gift host, the web application host, the marketing host under `/channels/` and `/theme/`, and the instance's configured `unfurl_ignored_hosts`. It then unfurls at most the first five surviving URLs. One message has at most 10 embeds, and Fluxer scans every resolved URL and media URL for banned content before it attaches an embed. A scan that blocks the content deletes the message.
+
+:::note[This route applies none of the message-path filtering]
+It resolves precisely the URL in the request body and returns the complete resolver output, with no URL extraction, no embed cap, and no banned-content scan.
+:::
+
+The request never reads a cached result, so Fluxer resolves the URL live every time.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[embed](/http-api/messages/#embed-object) object] | Resolution completed, possibly with no embed |
+| 403 | [error response](/http-api/#error-response) | Caller is a bot or presents a bearer credential and the request returns `ACCESS_DENIED`, or the account has an outstanding required action and the request returns `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| 502 | [error response](/http-api/#error-response) | The resolution service reports a failure, or answers with a payload the API cannot read, and the request returns `BAD_GATEWAY` |
+| 503 | [error response](/http-api/#error-response) | No resolution service is answering, or the service rejects the request at its concurrency limit, and the request returns `SERVICE_UNAVAILABLE` |
+| 504 | [error response](/http-api/#error-response) | The resolution service does not answer within its 12-second deadline and the request returns `GATEWAY_TIMEOUT` |
+
+### Side effects
+
+The operation fetches the supplied URL, can call a provider API for a matched site, and fetches resolved media assets so the classifier can read them. Fluxer writes the result to the unfurl cache, where a later message operation can reuse it.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `unfurl:debug` bucket. Fluxer charges the bucket before it checks the credential, and keys it on the caller's address when no valid credential is present. An unauthenticated caller therefore consumes it and receives 429 `RATE_LIMITED` rather than 401 once it is exhausted.
diff --git a/fluxer_docs/src/content/docs/http-api/users.mdx b/fluxer_docs/src/content/docs/http-api/users.mdx
new file mode 100644
index 000000000..cbf82063d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users.mdx
@@ -0,0 +1,688 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Users
+description: User objects, public profiles, account tags, and the settings record.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A user is a Fluxer account. Another account sees the [partial user object](#partial-user-object), and the owner sees the [user object](#user-object). Account mutation lives on [Current user](/http-api/users/current-user/).
+
+Every route on this page takes a user session token or a bot token. An OAuth2 bearer credential is rejected with 403 `ACCESS_DENIED`.
+
+| Object | Purpose |
+| --- | --- |
+| [Partial user](#partial-user-object) | Public account representation embedded everywhere |
+| [User](#user-object) | Private owner representation |
+| [User settings](#user-settings-object) | Account-wide settings record |
+| [Full user profile](#full-user-profile-object) | Profile read for one target account |
+
+## Partial user object
+
+The public account representation embedded by messages, relationships, guild members, and applications. It is the only representation returned to a caller other than the account owner.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The [snowflake](/snowflakes/) that identifies the account |
+| username | string | The account username (1-32 ASCII letters, digits, or underscores) |
+| discriminator | string | The account discriminator, four decimal digits with leading zeroes |
+| global_name | ?string | The display name of the account, or null when none is set |
+| avatar1 | ?string | The image hash of the account avatar, or null when none is set |
+| avatar_color | ?integer | The packed 24-bit RGB colour derived from the stored avatar, or null |
+| bot? | boolean | Whether the account is a bot, omitted when false |
+| system? | boolean | Whether the account is a Fluxer system account, omitted when false |
+| flags | integer | [Public user flags](#public-user-flags) |
+| mention_flags? | integer | [Reply mention preference](#reply-mention-preferences), omitted when the account has no preference |
+
+1 An animated avatar hash has the `a_` prefix, and the prefix is removed while the account has no animated avatar entitlement
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "username": "aria",
+ "discriminator": "0042",
+ "global_name": "Aria",
+ "avatar": "a_2f1c9d7be4a350c8",
+ "avatar_color": 5793266,
+ "flags": 8
+}
+```
+
+:::note[A deleted account keeps a renderable shape]
+A completed deletion returns the username `DeletedUser`, the discriminator `0000`, the global name `Deleted User`, null `avatar` and `avatar_color`, `flags` of `0`, and no `mention_flags`. The `bot` field keeps its account value.
+:::
+
+:::note[Substituted values appear only when deletion completes]
+A system account never receives the deleted representation. An account only scheduled for deletion keeps its ordinary representation throughout the grace period.
+:::
+
+## Public user flags
+
+`flags` holds the publicly visible subset of the account's stored flags. `STAFF` is also absent from an account with the staff-hidden flag.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | STAFF | Account is a staff member |
+| 1 << 2 | PARTNER | Account is a partner |
+| 1 << 3 | BUG_HUNTER | Account is a bug hunter |
+| 1 << 4 | FRIENDLY_BOT | Bot accepts friend requests from users |
+| 1 << 5 | FRIENDLY_BOT_MANUAL_APPROVAL | Bot requires manual approval for friend requests |
+| 1 << 6 | SPAMMER | Account is flagged as a spammer |
+
+## Reply mention preferences
+
+The account-wide preference applied when another account replies to one of this account's messages. `mention_flags` is exactly one of the three values below, and it is omitted from a serialised user while the stored value is `NO_PREFERENCE`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NO_PREFERENCE | Respect the sender's intent on each reply, with no warning when the mention is toggled |
+| 1 | PREFER_MENTION | Replies mention this account by default, and the sender is warned when they disable the mention |
+| 2 | PREFER_NO_MENTION | Replies omit the mention by default, and the sender is warned when they enable it |
+
+A guild member has the same enumeration in its own `mention_flags`, where `NO_PREFERENCE` inherits the account-wide value.
+
+## User object
+
+The private representation of the current account, returned by [Get current user](/http-api/users/current-user/#get-current-user) and by every operation that mutates the account. It extends the [partial user object](#partial-user-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The [snowflake](/snowflakes/) that identifies the account |
+| username | string | The account username (1-32 ASCII letters, digits, or underscores) |
+| discriminator | string | The account discriminator, four decimal digits with leading zeroes |
+| global_name | ?string | The display name of the account, or null when none is set |
+| avatar1 | ?string | The image hash of the account avatar, or null when none is set |
+| avatar_color | ?integer | The packed 24-bit RGB colour derived from the stored avatar, or null |
+| bot? | boolean | Whether the account is a bot, omitted when false |
+| system? | boolean | Whether the account is a Fluxer system account, omitted when false |
+| flags | integer | [Public user flags](#public-user-flags) |
+| mention_flags? | integer | [Reply mention preference](#reply-mention-preferences), omitted when the account has no preference |
+| is_staff | boolean | Whether the account has the staff flag, including when that flag is hidden from `flags` |
+| acls23 | array[string] | The Admin access control entries held by the account |
+| traits34 | array[string] | The account traits, in sorted order |
+| email5 | ?string | The account email address, or null when none exists |
+| email_bounced?3 | boolean | Whether the mail provider marked the current email as bounced |
+| phone6 | ?string | Always null |
+| has_verified_phone | boolean | Whether phone verification is complete |
+| bio | ?string | The profile biography, or null when none is set |
+| pronouns | ?string | The profile pronouns, or null when none are set |
+| accent_color | ?integer | The profile accent colour as packed 24-bit RGB, or null when none is set |
+| timezone?7 | ?string | The IANA timezone identifier stored for the account, or null when none is set |
+| timezone_privacy_flags?7 | integer | [Profile field privacy flags](#profile-field-privacy-flags) applied to the profile timezone |
+| banner8 | ?string | The image hash of the profile banner, or null when none is set or the entitlement is missing |
+| banner_color | ?integer | The packed 24-bit RGB colour derived from the stored banner, or null |
+| mfa_enabled39 | boolean | Whether any authenticator is configured |
+| authenticator_types?39 | array[integer] | [Authenticator types](#authenticator-types) configured for the account |
+| verified5 | boolean | Whether the account email is verified |
+| premium_type10 | ?integer | [Premium type](#premium-types) |
+| premium_since310 | ?ISO8601 timestamp | The time premium was first activated, or null |
+| premium_until311 | ?ISO8601 timestamp | The end of current premium access, or null when no premium period is stored |
+| premium_will_cancel3 | boolean | Whether subscription premium will cancel at the billing boundary |
+| premium_billing_cycle3 | ?string | The premium billing cycle, or null |
+| premium_lifetime_sequence3 | ?integer | The lifetime premium sequence, or null |
+| premium_grace_ends_at12 | ?ISO8601 timestamp | The end of the post-cancellation grace interval, or null when the account is not in grace |
+| premium_discriminator13 | boolean | Whether the current discriminator was selected under a premium entitlement |
+| premium_badge_hidden3 | boolean | Whether the premium badge is withheld from the public profile |
+| premium_badge_masked3 | boolean | Whether a lifetime badge is presented as an ordinary subscription badge |
+| premium_badge_timestamp_hidden3 | boolean | Whether the premium activation time is withheld from the public profile |
+| premium_badge_sequence_hidden3 | boolean | Whether the lifetime sequence is withheld from the public profile |
+| premium_purchase_disabled3 | boolean | Whether premium purchasing is disabled for the account |
+| premium_enabled_override3 | boolean | Whether an administrative override grants premium entitlements |
+| premium_perks_disabled3 | boolean | Whether premium entitlements are suspended for the account |
+| force_inbound_phone_verification?14 | boolean | The debugging switch that forces the inbound phone verification flow |
+| password_last_changed_at3 | ?ISO8601 timestamp | The time of the most recent password change, or null |
+| last_voice_activity_sharing_change_at15 | ?ISO8601 timestamp | The time of the most recent bulk voice activity sharing change, or null |
+| required_actions316 | array[string] | The ordered [required actions](#required-actions) the account must complete before unrestricted use |
+| nsfw_allowed317 | boolean | Whether the account can access age-restricted content |
+| has_dismissed_premium_onboarding318 | boolean | Whether premium onboarding was dismissed |
+| has_ever_purchased3 | boolean | Whether the account has completed a purchase |
+| has_unread_gift_inventory319 | boolean | Whether the gift inventory has unread items |
+| unread_gift_inventory_count319 | integer | The number of unread gift inventory items |
+| pending_bulk_message_deletion3 | ?[pending bulk message deletion](#pending-bulk-message-deletion-object) object | The message deletion the account scheduled for itself, or null when none is pending |
+| age_verified_adult? | boolean | Whether adult age verification is complete, omitted when false |
+| terms_agreed_at | ?ISO8601 timestamp | The time of the most recent terms acceptance, or null |
+| privacy_agreed_at | ?ISO8601 timestamp | The time of the most recent privacy policy acceptance, or null |
+
+1 An animated avatar hash has the `a_` prefix, and the prefix is removed while the account has no animated avatar entitlement
+
+2 The array is empty for an account with no Admin access, and it is replaced wholesale by [Set user ACLs](/admin-api/users/#set-user-acls). It is independent of `is_staff`
+
+3 The field is neutralised for an OAuth2 bearer credential as described below
+
+4 The value `premium` is present exactly when premium entitlements are currently active, regardless of the stored trait set, and every other stored trait is returned sorted
+
+5 For an OAuth2 bearer credential without the `email` [scope](/http-api/oauth2/#oauth2-scopes), `email` is `null`
+
+6 Phone numbers are no longer stored on the account record. The field is retained so an older client keeps parsing the response
+
+7 The pair is present only while the account has the staff flag, and it is absent for every other account
+
+8 The banner hash is withheld entirely while the account lacks the animated banner entitlement, which gates every profile banner
+
+9 `mfa_enabled` is true exactly when at least one authenticator is configured. An account with no authenticator omits `authenticator_types`
+
+10 The value is forced to `0` and `premium_since` is forced to `null` while premium entitlements are not currently active
+
+11 The value is the later of the subscription end and any stacked gift extension, and it is reported even when premium entitlements are no longer active
+
+12 While the value is set, premium entitlements remain active until it passes, and it replaces the default grace interval that otherwise follows `premium_until`
+
+13 The flag is set when the account changes its username or discriminator while holding non-lifetime premium, and it marks the discriminator for reselection when that premium access ends
+
+14 The field is declared but never populated, so it is absent from every response
+
+15 The value drives the 24-hour cooldown on changing the default voice activity sharing state
+
+16 The array is empty when the account has no stored suspicious activity flag, when it has no email address, or when its contact is exempt from required actions
+
+17 A bot account is always permitted, and a user account is permitted only when its recorded date of birth places it at 18 years or older
+
+18 The value is false while premium entitlements are not currently active, even when a dismissal was recorded earlier
+
+19 Both values derive from the difference between the stored gift inventory sequence and the sequence the account has acknowledged, and they report `false` and `0` when no gift has ever been recorded
+
+:::note[A bearer read returns zero values]
+`acls`, `traits`, and `required_actions` become empty arrays. `email_bounced` and `authenticator_types` are dropped. Every field marked 3 above that is a boolean becomes `false`, every timestamp or nullable field becomes `null`, and `unread_gift_inventory_count` becomes `0`.
+:::
+
+:::caution[An outstanding required action blocks ordinary routes]
+While `required_actions` is non-empty, the ordinary login requirement returns 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. The error body's `data` member has `suspicious_activity_flags`, rebuilt from the surviving list.
+:::
+
+Recovery routes stay open to a restricted account. They are [Get current user](/http-api/users/current-user/#get-current-user), [Modify current user](/http-api/users/current-user/#modify-current-user), [Get current user settings](/http-api/users/settings/#get-current-user-settings), every [email change](/http-api/users/email-and-password/) and [phone verification](/http-api/users/phone-verification/) route, [Resend email verification](/http-api/authentication/#resend-email-verification), session listing and termination, and the OAuth2 routes.
+
+A requirement disappears from the list as soon as it is met. An Admin operation applies no suspicious activity gate.
+
+## Pending bulk message deletion object
+
+A scheduled deletion of every message the account has sent. The field is this object only while the deletion requested through [Request bulk message deletion](/http-api/users/content/#request-bulk-message-deletion) is still waiting to run.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scheduled_at | ISO8601 timestamp | The time at which the deletion runs |
+| channel_count1 | integer | The number of channels holding a message that will be deleted |
+| message_count1 | integer | The number of messages that will be deleted |
+
+1 Both counts are computed when the deletion is scheduled and are not recomputed while it waits
+
+## Authenticator types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | TOTP | Time-based one-time password authenticator |
+| 2 | WEBAUTHN | WebAuthn authenticator |
+
+The value `1` is unassigned. Any stored authenticator value outside this registry is excluded from `authenticator_types`.
+
+## Premium types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | NONE | No premium subscription |
+| 1 | SUBSCRIPTION | Active premium subscription |
+| 2 | LIFETIME | Lifetime premium subscription |
+
+## Required actions
+
+Each entry is an exact string in the `required_actions` array of the [user object](#user-object), ordered by the registry order below. Fluxer removes a requirement the account already satisfies, and removes a requirement implied by a stricter retained one.
+
+| Value | Description |
+| --- | --- |
+| REQUIRE_VERIFIED_EMAIL | The account needs a verified email address |
+| REQUIRE_REVERIFIED_EMAIL | The account needs to verify its email address again |
+| REQUIRE_VERIFIED_PHONE | The account needs a verified phone number |
+| REQUIRE_REVERIFIED_PHONE | The account needs to verify its phone number again |
+| REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE | The account needs either a verified email address or a verified phone number |
+| REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE | The account needs to verify its email address again or hold a verified phone number |
+| REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE | The account needs a verified email address or needs to verify its phone number again |
+| REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE | The account needs to verify either its email address or its phone number again |
+| REQUIRE_INBOUND_PHONE_VERIFICATION1 | The account needs to complete phone verification by sending an SMS to the instance's inbound number |
+
+1 The entry appears only while a phone requirement is also outstanding. A stored inbound requirement with no other phone clause produces an ordinary verified phone requirement
+
+## Profile field privacy flags
+
+A field whose flags value is `0` is visible to nobody, including on the owner's own profile read.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | EVERYONE | Anyone permitted to view the full profile can see the field |
+| 1 << 1 | FRIENDS | Friends can see the field |
+| 1 << 2 | MUTUAL_GUILDS | Members of a mutual guild can see the field |
+
+## User settings object
+
+The complete account-wide settings record, with one field per setting. `synced_preferences` holds a base64-encoded Protobuf snapshot defined by the [user settings Protobuf reference](/http-api/users/settings-protobuf/). Fluxer stores that snapshot for clients and never interprets it.
+
+Update semantics belong to the [user settings update object](/http-api/users/settings/#user-settings-update-object). Both settings operations return this object, and so do [Ready](/gateway/events/#ready-object) and [User Settings Update](/gateway/events/#user-settings-update). Every field below is always present.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status | string | [Presence status](/http-api/users/settings/#presence-status-values) |
+| status_resets_at | ?ISO8601 timestamp | The time at which the scheduled status reset applies, or null |
+| status_resets_to | ?string | [Presence status](/http-api/users/settings/#presence-status-values) applied by the scheduled reset, or null |
+| theme | string | [Theme value](/http-api/users/settings/#theme-values) |
+| locale | string | The interface [locale](/topics/locales/#supported-locales) |
+| restricted_guilds1 | array[snowflake] | The guilds where member direct messages are restricted |
+| bot_restricted_guilds1 | array[snowflake] | The guilds where bot direct messages are restricted |
+| default_guilds_restricted | boolean | Whether newly joined guilds restrict member direct messages |
+| bot_default_guilds_restricted | boolean | Whether newly joined guilds restrict bot direct messages |
+| inline_attachment_media | boolean | Whether attachment media is rendered inline |
+| inline_embed_media | boolean | Whether embed media is rendered inline |
+| gif_auto_play | boolean | Whether GIF media plays automatically |
+| render_embeds | boolean | Whether message embeds are rendered |
+| render_reactions | boolean | Whether message reactions are rendered |
+| animate_emoji | boolean | Whether custom emoji animate |
+| animate_stickers | integer | [Sticker animation setting](#sticker-animation-settings) |
+| render_spoilers | integer | [Spoiler rendering setting](#spoiler-rendering-settings) |
+| message_display_compact | boolean | Whether messages use compact presentation |
+| friend_source_flags | integer | [Friend source flags](#friend-source-flags) |
+| incoming_call_flags | integer | [Incoming call flags](#incoming-call-flags) |
+| group_dm_add_permission_flags | integer | [Group DM add permission flags](#group-dm-add-permission-flags) |
+| guild_folders2 | array[[guild folder](#guild-folder-object) object] | The user-owned guild sidebar layout |
+| custom_status | ?[custom status](#custom-status-object) object | The current custom status, or null when none is set |
+| afk_timeout | integer | The idle time in seconds before the account is presented as away |
+| time_format | integer | [Time format setting](#time-format-settings) |
+| developer_mode | boolean | Whether developer mode is enabled |
+| trusted_domains3 | array[string] | The external link domains the account trusts |
+| default_hide_muted_channels | boolean | Whether newly joined guilds hide muted channels |
+| sensitive_content_friend_dm_filter | integer | [Sensitive media filter](#sensitive-media-filters) for friend direct messages |
+| sensitive_content_non_friend_dm_filter | integer | [Sensitive media filter](#sensitive-media-filters) for non-friend direct messages |
+| sensitive_content_guild_filter | integer | [Guild sensitive media filter](#guild-sensitive-media-filters) |
+| suppress_unprivileged_self_mentions4 | boolean | Whether direct and reply mentions from unprivileged users are suppressed |
+| suppress_unprivileged_self_mentions_bypass_user_ids45 | array[snowflake] | The users exempt from mention suppression |
+| staff_dm_access_user_ids45 | array[snowflake] | The users granted staff direct message access |
+| synced_preferences6 | string | The base64-encoded [synced preferences](/http-api/users/settings-protobuf/) message |
+| profile_privacy | integer | [Profile privacy level](#profile-privacy-levels) |
+| default_share_voice_activity7 | boolean | Whether a newly accepted friend relationship starts with voice activity sharing on |
+
+1 Duplicate identifiers are removed when the array is written
+
+2 An account with an empty stored layout that still holds a legacy guild ordering receives one uncategorised folder holding that ordering. An account with neither receives an empty array
+
+3 The single entry `*` trusts every domain, and it cannot be combined with a concrete domain
+
+4 The field is writable only by a staff account and is reset to its zero value on every settings update from an ordinary account
+
+5 Duplicate identifiers are removed and the array is returned in ascending identifier order
+
+6 The message is `fluxer.user.preferences.v1.SyncedPreferences`. The empty string means nothing has been synced yet. The decoded message cannot exceed 262144 bytes, the 256 KiB ceiling
+
+7 The field is read-only here and is mutated by [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing)
+
+## Custom status object
+
+A custom status is the text and emoji stored on the account's [user settings](#user-settings-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text | ?string | The status text (1-128 characters), or null when none is set |
+| expires_at?1 | ISO8601 timestamp | The time at which the status expires |
+| emoji_id?1 | snowflake | The ID of the custom emoji shown beside the text |
+| emoji_name2 | ?string | The custom emoji name resolved from `emoji_id`, the single Unicode emoji supplied directly, or null |
+| emoji_animated | boolean | Whether the selected custom emoji is animated |
+
+1 The field is omitted when the status has no expiry or no custom emoji
+
+2 A payload with `emoji_id` has its `emoji_name` replaced by the name of that emoji
+
+### Example
+
+```json
+{
+ "text": "shipping",
+ "expires_at": "2026-03-04T18:00:00.000Z",
+ "emoji_id": "1501314428688998182",
+ "emoji_name": "party_parrot",
+ "emoji_animated": true
+}
+```
+
+## Guild folder object
+
+One entry of the saved, user-owned guild sidebar layout. Joining a guild prepends its ID to the uncategorised folder. Leaving or losing a guild removes its ID from every folder and drops any non-uncategorised folder the removal leaves empty, whether or not it has a name.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | ?integer | The identifier of the folder |
+| name | ?string | The name of the folder, or null when none is set |
+| color2 | ?integer | The folder colour as packed 24-bit RGB |
+| flags | integer | [Guild folder flags](#guild-folder-flags) |
+| icon3 | string | [Guild folder icon](#guild-folder-icons) |
+| guild_ids4 | array[snowflake] | The IDs of the guilds in the folder, in sidebar order |
+
+1 The value `-1` identifies the uncategorised folder, which holds guilds not placed in a named folder. A stored layout that omits it has it prepended when the layout is written
+
+2 A folder written without a colour is stored with `0`. The value is null on an uncategorised folder synthesised from a legacy guild ordering or created by a guild join
+
+3 A folder written without an icon is stored with `folder`
+
+4 Writing the layout keeps one occurrence of a guild ID that appears in more than one folder, preferring a named folder over the uncategorised one
+
+### Example
+
+```json
+{
+ "id": -1,
+ "name": null,
+ "color": null,
+ "flags": 0,
+ "icon": "folder",
+ "guild_ids": ["1501314428688998182", "1501314428688998183"]
+}
+```
+
+## Guild folder flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | SHOW_ICON_WHEN_COLLAPSED | Show the selected icon instead of guild previews while collapsed |
+
+## Guild folder icons
+
+| Value | Description |
+| --- | --- |
+| folder | Folder icon, applied when no icon is supplied |
+| star | Star icon |
+| heart | Heart icon |
+| bookmark | Bookmark icon |
+| game_controller | Game controller icon |
+| shield | Shield icon |
+| music_note | Music note icon |
+
+## Sticker animation settings
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALWAYS_ANIMATE | Always animate stickers |
+| 1 | ANIMATE_ON_INTERACTION | Animate stickers during interaction |
+| 2 | NEVER_ANIMATE | Never animate stickers |
+
+## Spoiler rendering settings
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALWAYS | Always reveal spoiler content |
+| 1 | ON_CLICK | Reveal spoiler content after interaction |
+| 2 | IF_MODERATOR | Reveal spoiler content for moderators |
+
+## Time format settings
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | AUTO | Select the time format from the locale |
+| 1 | TWELVE_HOUR | Use a 12-hour clock |
+| 2 | TWENTY_FOUR_HOUR | Use a 24-hour clock |
+
+## Sensitive media filters
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | SHOW | Show sensitive media |
+| 1 | BLUR | Blur sensitive media until revealed |
+| 2 | BLOCK | Hide sensitive media |
+
+## Guild sensitive media filters
+
+Guild channels accept only these two values.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | SHOW | Show sensitive media |
+| 1 | BLUR | Blur sensitive media until revealed |
+
+## Profile privacy levels
+
+A bot account is never subject to profile privacy, and a caller reading its own profile is never restricted.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALL_GUILDS | Friends and members of any mutual guild can view the full profile |
+| 1 | SMALL_GUILDS_ONLY | Friends and members of a mutual guild with at most 200 members can view the full profile |
+| 2 | FRIENDS_ONLY | Only friends can view the full profile |
+
+## Friend source flags
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | MUTUAL_FRIENDS | Accept requests from users with mutual friends |
+| 1 << 1 | MUTUAL_GUILDS | Accept requests from users in mutual guilds |
+| 1 << 2 | NO_RELATION | Accept requests without an existing relation |
+
+## Incoming call flags
+
+`SILENT_EVERYONE` applies to calls the other flags already permit. When conflicting policy flags are supplied, `FRIENDS_ONLY` takes precedence over `NOBODY`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | FRIENDS_OF_FRIENDS | Permit calls from friends of friends |
+| 1 << 1 | GUILD_MEMBERS | Permit calls from guild members |
+| 1 << 2 | EVERYONE | Permit calls from everyone |
+| 1 << 3 | FRIENDS_ONLY | Permit calls only from friends |
+| 1 << 4 | NOBODY | Reject all incoming calls |
+| 1 << 5 | SILENT_EVERYONE | Receive permitted calls silently |
+
+## Group DM add permission flags
+
+When conflicting policy flags are supplied, `FRIENDS_ONLY` takes precedence over `NOBODY`, which takes precedence over `EVERYONE`.
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 << 0 | FRIENDS_OF_FRIENDS | Permit friends of friends to add the user |
+| 1 << 1 | GUILD_MEMBERS | Permit guild members to add the user |
+| 1 << 2 | EVERYONE | Permit everyone to add the user |
+| 1 << 3 | FRIENDS_ONLY | Permit only friends to add the user |
+| 1 << 4 | NOBODY | Prevent every user from adding the user |
+
+## User profile object
+
+The account-wide profile customisation inside a [full user profile object](#full-user-profile-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| bio1 | ?string | The account-wide biography, or null when none is set |
+| pronouns1 | ?string | The account-wide pronouns, or null when none are set |
+| banner2 | ?string | The image hash of the profile banner, or null when none is set |
+| banner_color | ?integer | The packed 24-bit RGB colour derived from the stored banner, or null |
+| accent_color | ?integer | The profile accent colour as packed 24-bit RGB, or null when none is set |
+
+1 The value is forced to `null` when profile privacy restricts the viewer, which is reported by `profile_limited`
+
+2 The banner hash is withheld while the account lacks the animated banner entitlement, which gates every profile banner. This withholding is independent of profile privacy
+
+## Guild member profile object
+
+A guild member profile is the per-guild profile customisation of the target account, in the guild named by the `guild_id` query parameter.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| bio1 | ?string | The guild-specific biography, or null when none is set |
+| pronouns1 | ?string | The guild-specific pronouns, or null when none are set |
+| banner2 | ?string | The image hash of the guild-specific banner, or null when none is set |
+| accent_color | ?integer | The guild profile accent colour as packed 24-bit RGB, or null when none is set |
+
+1 The value is forced to `null` when profile privacy restricts the viewer
+
+2 The value is `null` while the member's premium-dependent guild customisation is sanitised. An animated banner hash has the `a_` prefix
+
+## Mutual guild object
+
+One guild that both the caller and the target account belong to.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| nick | ?string | The nickname the target account uses in the guild, or null when none is set |
+
+## Full user profile object
+
+The profile read returned by [Get user profile](#get-user-profile) for one target account.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [partial user](#partial-user-object) object | The public representation of the target account |
+| user_profile | [user profile](#user-profile-object) object | The account-wide profile customisation |
+| guild_member?1 | [guild member](/http-api/guild-members/#guild-member-object) object | The membership the target account holds in the named guild |
+| guild_member_profile?1 | [guild member profile](#guild-member-profile-object) object | The per-guild profile customisation of the target account |
+| premium_type?2 | integer | The [premium type](#premium-types) visible to the caller |
+| premium_since?2 | ISO8601 timestamp | The premium activation time visible to the caller |
+| premium_lifetime_sequence?2 | integer | The lifetime premium sequence visible to the caller |
+| mutual_friends?3 | array[[partial user](#partial-user-object) object] | The mutual friends in descending account identifier order |
+| mutual_guilds?3 | array[[mutual guild](#mutual-guild-object) object] | The guilds the caller and the target are both members of |
+| connected_accounts4 | array[[connection](/http-api/connections/#connection-object) object] | The verified connections the caller is permitted to see |
+| timezone_offset5 | ?integer | The offset of the target profile timezone in minutes from UTC, or null |
+| profile_limited?6 | boolean | Whether profile privacy removed restricted fields |
+
+1 The pair requires the `guild_id` query parameter and requires both the caller and the target to be members of that guild
+
+2 All three are omitted while the profile is restricted or the target hides the premium badge, `premium_since` is additionally omitted while the target hides the activation time, and `premium_lifetime_sequence` while it hides the sequence
+
+3 The collection is omitted unless its query parameter is true, and always when the target is the caller
+
+4 The array is empty when the profile is restricted. It otherwise holds only verified connections whose [profile field privacy flags](#profile-field-privacy-flags) admit the caller
+
+5 The value is null unless the profile is unrestricted, the target has the staff flag, the target has stored a timezone, and its [profile field privacy flags](#profile-field-privacy-flags) admit the caller
+
+6 The field is present only on a restricted read
+
+A lifetime target that masks its badge reports `premium_type` of `1` rather than `2`.
+
+## Get user
+
+
+
+Returns a [partial user](#partial-user-object) object for any account. Requires no permission and no relationship with the target.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+:::note[A deleted target still resolves]
+A target whose deletion has completed returns 200 with the substituted [deleted account representation](#partial-user-object), so historical references stay renderable. Only an identifier that was never allocated returns 404 `UNKNOWN_USER`.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [partial user](#partial-user-object) object | User was returned |
+| 404 | [error response](/http-api/#error-response) | Identifier was never allocated and the request returns `UNKNOWN_USER` |
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and target user ID, on the `user:read::user_id` bucket.
+
+## Get user profile
+
+
+
+Returns a [full user profile](#full-user-profile-object) object for one target account.
+
+### Limitations
+
+- A caller reading another account needs one of four relationships with the target: friendship, a pending friend request in either direction, a shared guild, or a shared group direct message channel.
+- Reading a bot target is always permitted.
+- Reading the caller's own profile bypasses both the access check and profile privacy.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| target_id | snowflake | The ID of the target account |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id?1 | snowflake | The guild whose member and guild profile objects are included |
+| with_mutual_friends?2 | boolean | Whether to include mutual friends (default false) |
+| with_mutual_guilds?2 | boolean | Whether to include mutual guilds (default false) |
+
+1 A guild that either the caller or the target is not a member of omits `guild_member` and `guild_member_profile`. The read still returns 200
+
+2 The value is trimmed and read as true only for the exact strings `true`, `True`, and `1`. Every other value, including absence, is false
+
+:::note[Profile privacy is a field filter]
+A read that passes the access check can still be restricted. A restricted read nulls `bio` and `pronouns` in both profile objects, empties `connected_accounts`, nulls `timezone_offset`, and sets `profile_limited` to true. It also removes the three premium fields from the body.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [full user profile](#full-user-profile-object) object | Profile was returned |
+| 403 | [error response](/http-api/#error-response) | Caller has no relationship permitting profile access |
+| 404 | [error response](/http-api/#error-response) | Target user does not exist |
+
+### Side effects
+
+When the target's premium period and grace interval have both elapsed, this read clears the stored premium state on the target account before returning it. The cleared fields are the premium type, activation time, end time, gift extension, cancellation flag, billing cycle, and grace end. That write emits no Gateway Dispatch. The background sweep that performs the same clearing emits [User Update](/gateway/events/#user-update). The profile is still returned when the clearing write fails.
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and target user ID, on the `user:profile::target_id` bucket.
+
+## Check user tag
+
+
+
+Reports whether a username and discriminator pair is unavailable.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username1 | string | The username after trimming (1-32 ASCII letters, digits, or underscores) |
+| discriminator2 | string | The discriminator (1-4 decimal digits) |
+
+1 The value cannot be `everyone` or `here` and cannot contain `fluxer` or `system message`, all compared case-insensitively
+
+2 The value is parsed as a decimal integer before the lookup, so `7`, `07`, and `0007` describe the same tag
+
+Fluxer reports `false` for the caller's own current tag without a lookup. That comparison reads the username case-insensitively and the discriminator as an integer.
+
+A reserved username fails validation with `USERNAME_RESERVED_VALUE` or `USERNAME_CANNOT_CONTAIN_RESERVED_TERMS`, and no `taken` value comes back for it.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| taken | boolean | Whether the normalised tag is unavailable |
+
+:::caution[`true` also comes from two transient conditions]
+A tag claimed by a registration still in progress reads as taken for up to 30 seconds. A lookup that waits more than 10 seconds on a busy username also reads as taken.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | Tag availability was returned |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `user:check_tag` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/content.mdx b/fluxer_docs/src/content/docs/http-api/users/content.mdx
new file mode 100644
index 000000000..ddbed2660
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/content.mdx
@@ -0,0 +1,423 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: User content collections
+description: Recent mentions, saved messages, and deletion of the caller's own messages.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Recent mentions and saved messages are private lists that only the account owning them can read. Two further routes delete the messages the caller authored, one filtered and immediate, the other unfiltered and delayed by a day. Data harvests live on [Data harvests](/http-api/users/data-harvest/).
+
+Every route here is user-only. Fluxer rejects a bot or OAuth2 bearer credential with 403 `ACCESS_DENIED`, and an account that has an outstanding required action with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+Every route except [Delete current user's messages](#delete-current-users-messages) reaches the main Gateway, and a Gateway failure returns 502 `BAD_GATEWAY`, 503 `SERVICE_UNAVAILABLE`, or 504 `GATEWAY_TIMEOUT`. [Save message](#save-message) resolves the channel before it writes, so a failure there stores nothing. The request still returns 204 when a [Saved Message Create](/gateway/events/#saved-message-create), [Saved Message Delete](/gateway/events/#saved-message-delete), or [Recent Mention Delete](/gateway/events/#recent-mention-delete) Dispatch fails to publish after the write.
+
+:::caution[The schedule is written before the Dispatch]
+[Request bulk message deletion](#request-bulk-message-deletion) and [Cancel bulk message deletion](#cancel-bulk-message-deletion) publish [User Update](/gateway/events/#user-update) after the account row is written. A Gateway failure returns 502, 503, or 504 with the stored schedule already changed.
+:::
+
+## Saved message object
+
+A saved entry is a private bookmark pairing one message with the channel it was saved from. Saving a message notifies nobody, including its author.
+
+An entry stores no copy of the message. Fluxer resolves the message against current permissions on every read, so one entry can have a message on one read and none on the next.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | snowflake | The ID of the saved entry |
+| channel_id2 | snowflake | The channel the message was saved from |
+| message_id | snowflake | The ID of the saved message |
+| status | string | The [saved message status](#saved-message-statuses) of the entry |
+| message3 | ?[message](/http-api/messages/#message-object) object | The message as the caller can currently see it, or null |
+
+1 Always equal to `message_id`, because one message can be saved at most once per account
+
+2 The channel supplied when the message was saved, and it is not re-derived on read
+
+3 Null exactly when `status` is `missing_permissions`, and otherwise the message resolved for the caller at read time. A message that still exists and needs `READ_MESSAGE_HISTORY` the caller no longer holds is also null.
+
+## Saved message statuses
+
+| Value | Description |
+| --- | --- |
+| available | The message resolved and is included in the entry |
+| missing_permissions1 | The caller can no longer reach the channel or read the message, so `message` is null |
+
+1 Reported when resolving the channel fails with `MISSING_PERMISSIONS`, `UNKNOWN_CHANNEL`, `UNKNOWN_GUILD`, `ACCESS_DENIED`, or `NSFW_CONTENT_AGE_RESTRICTED`. Also reported when the channel resolves and a stored message needs `READ_MESSAGE_HISTORY` the caller does not hold.
+
+## Bulk message deletion scopes
+
+| Value | Description |
+| --- | --- |
+| selected | The supplied context toggles and guild filter choose what is deleted |
+| inaccessible_only1 | The deletion covers only guilds the caller has left and group DMs the caller is no longer a member of |
+
+1 One-to-one direct messages are never eligible under this scope
+
+## Guild filter modes
+
+| Value | Description |
+| --- | --- |
+| exclude | The deletion covers every eligible guild except `excluded_guild_ids` |
+| include_only | The deletion covers only `included_guild_ids` |
+
+## List recent mentions
+
+
+
+Lists the caller's recent mention history. Returns an array of [message](/http-api/messages/#message-object) objects the caller can still read, in descending message ID order.
+
+Fluxer resolves each retained entry against current permissions. A message that has been deleted, or whose channel the caller can no longer reach, is omitted without being removed from history. The filters and `limit` apply before that resolution, so a full page can return fewer messages than `limit`.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit?1 | integer | The maximum number of mentions read from history (1-100, default 25) |
+| roles?23 | boolean | Whether entries recorded as role mentions are included (default true) |
+| everyone?234 | boolean | Whether entries recorded as everyone mentions are included (default true) |
+| guilds?25 | boolean | Whether entries in a guild channel are included (default true) |
+| before? | snowflake | The upper bound on entry message ID, exclusive |
+
+1 A value outside the range fails with `VALUE_MUST_BE_INTEGER_IN_RANGE` at the `limit` path. The value is read with a leading-integer parse, so `25abc` is accepted as `25`
+
+2 Only the exact strings `true`, `True`, and `1` are read as true. Every other value, `TRUE` and `yes` included, is read as false
+
+3 The filter excludes every entry with the named mention kind, so a message that arrived through both a direct and a role mention is excluded while `roles` is false
+
+4 An `@here` mention is recorded as an everyone mention. It is recorded only for an account holding a live Gateway session on the guild when the mention was fanned out
+
+5 Every recorded entry has a guild, so setting this to false always returns an empty array
+
+:::note[Only guild channel mentions are recorded]
+A direct message never produces an entry. Fluxer records nothing for the message author, for a bot account, or for a member who cannot view the channel.
+:::
+
+The response has no cursor metadata, so `before` and `limit` are the only pagination controls.
+
+Fluxer drops a direct mention at message creation when the mentioned account has blocked the author, so it never reaches the history. An `@everyone` or `@here` mention is not recorded while `suppress_everyone` is enabled for the guild, and a role mention is not recorded while `suppress_roles` is enabled. A message that also has a direct mention is still recorded. Changing those [guild notification settings](/http-api/users/settings/#user-guild-settings-object) later does not retroactively add or remove history.
+
+:::caution[One bad entry can fail the whole listing]
+Fluxer omits an entry whose channel the caller cannot reach. An entry that needs age verification, or one whose membership state does not resolve, returns 403 for the whole request. Narrow the window with `before` to page around it.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[message](/http-api/messages/#message-object) object] | Readable mentions were returned |
+| 403 | [error response](/http-api/#error-response) | A retained entry sits in a guild whose membership state cannot be resolved, returning `ACCESS_DENIED`, or in an age-restricted channel the account has not verified for, returning `NSFW_CONTENT_AGE_RESTRICTED` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:mentions:read` bucket.
+
+## Mark recent mentions read
+
+
+
+Removes entries from the caller's recent mention history. Returns 204 with an empty body. Emits one [Recent Mention Delete](/gateway/events/#recent-mention-delete) Gateway event for each entry it removes.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_ids1 | array[snowflake] | The recent mention message IDs to remove (1-100 entries) |
+
+1 An ID absent from the history is an idempotent no-op that emits no Dispatch. A duplicate ID is not deduplicated, so an ID naming a real entry twice emits the Dispatch twice
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Every supplied ID was processed |
+
+:::note[Removing an entry leaves the message unread]
+The mention count on the entry's channel does not change. [Read states](/http-api/read-states/) defines acknowledgement.
+:::
+
+### Side effects
+
+Fluxer looks up every supplied ID first and deletes only the entries that exist. [Recent Mention Delete](/gateway/events/#recent-mention-delete) reaches the caller's own sessions once for every entry found, so a request naming only unknown IDs writes nothing.
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `user:mentions:delete` bucket, which is shared with [Delete recent mention](#delete-recent-mention).
+
+## Delete recent mention
+
+
+
+Removes one entry from the caller's recent mention history. Returns 204 with an empty body. Emits a [Recent Mention Delete](/gateway/events/#recent-mention-delete) Gateway event when an entry is removed.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_id | snowflake | The message ID of the recent mention entry |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The entry was removed, or no entry named that message |
+
+### Side effects
+
+A matching entry is deleted and [Recent Mention Delete](/gateway/events/#recent-mention-delete) reaches the caller's own sessions. When no entry existed the request writes nothing. The message stays in its channel, and the caller's channel mention count and unread badge are unchanged.
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `user:mentions:delete` bucket, which is shared with [Mark recent mentions read](#mark-recent-mentions-read).
+
+## List saved messages
+
+
+
+Lists the caller's saved message collection. Returns an array of [saved message](#saved-message-object) objects in descending message ID order.
+
+The response is a bare array with no cursor metadata, so `limit` is the only control and an account holding more than 100 entries cannot page past the first 100. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| limit?12 | integer | The maximum number of entries read from the collection (1-100, default 25) |
+
+1 A value outside the range fails with `VALUE_MUST_BE_INTEGER_IN_RANGE` at the `limit` path
+
+2 Applied to the stored entries before each one is resolved
+
+:::caution[Reading the collection deletes entries]
+Fluxer permanently deletes an entry whenever its message no longer exists. An entry whose message the caller cannot read is kept and reported as `missing_permissions`.
+:::
+
+A guild configuring [message_history_cutoff](/http-api/guilds/#guild-object) still serves a message posted at or after the cutoff to a member without `READ_MESSAGE_HISTORY`, so the entry stays `available`. A message posted before the cutoff is reported as `missing_permissions`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[saved message](#saved-message-object) object] | Saved entries were returned |
+| 403 | [error response](/http-api/#error-response) | A stored entry sits in a guild whose membership state cannot be resolved, returning `ACCESS_DENIED`, or in an age-restricted channel, returning `NSFW_CONTENT_AGE_RESTRICTED` |
+
+### Side effects
+
+The cleanup deletion emits no [Saved Message Delete](/gateway/events/#saved-message-delete).
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:saved_messages:read` bucket.
+
+## Save message
+
+
+
+Adds a message to the caller's private saved message collection. Returns 204 with an empty body. Emits a [Saved Message Create](/gateway/events/#saved-message-create) Gateway event.
+
+The caller needs access to the channel and to the message.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id1 | snowflake | The channel containing the message |
+| message_id | snowflake | The ID of the message to save |
+
+1 The saved entry retains this channel ID verbatim
+
+### Collection limit
+
+An account holds at most the instance-configured `max_bookmarks` [limit](/http-api/instance/#limit-keys) entries. Fluxer resolves the account's effective value on every save, and the default is 50. Reaching the ceiling fails with 400 `MAX_BOOKMARKS`, whose body has `max_bookmarks` reporting the ceiling.
+
+The check runs before Fluxer touches the channel or the message, so re-saving an already stored message is refused at the ceiling too.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message is present in the saved collection |
+| 400 | [error response](/http-api/#error-response) | The collection is at its ceiling and the request returns `MAX_BOOKMARKS` |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `VIEW_CHANNEL` or guild membership, returning `MISSING_PERMISSIONS`, the membership state cannot be resolved, returning `ACCESS_DENIED`, or the channel needs age verification, returning `NSFW_CONTENT_AGE_RESTRICTED` |
+| 404 | [error response](/http-api/#error-response) | The channel returns `UNKNOWN_CHANNEL`, its guild returns `UNKNOWN_GUILD`, the message returns `UNKNOWN_MESSAGE`, or the account returns `UNKNOWN_USER` |
+
+### Side effects
+
+Saving an already stored message rewrites the entry. [Saved Message Create](/gateway/events/#saved-message-create) reaches the caller's own sessions on every accepted save, a repeat save included, with the complete message object as the caller can currently see it.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:saved_messages:write` bucket, which is shared with [Unsave message](#unsave-message).
+
+## Unsave message
+
+
+
+Removes a message from the caller's private saved message collection. Returns 204 with an empty body. Emits a [Saved Message Delete](/gateway/events/#saved-message-delete) Gateway event.
+
+No current access to the channel is required.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_id | snowflake | The message ID of the saved entry |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Message is absent from the saved collection |
+
+### Side effects
+
+Fluxer issues the delete unconditionally, and [Saved Message Delete](/gateway/events/#saved-message-delete) reaches the caller's own sessions on every accepted request, including one naming a message the account never saved. The saved entry is removed and the message stays in its channel.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:saved_messages:write` bucket, which is shared with [Save message](#save-message).
+
+## Delete current user's messages
+
+
+
+Deletes the messages the caller authored that the supplied filter selects. Requires [sudo mode](/http-api/users/mfa/#sudo-mode). Returns 202 with an empty body before deletion begins.
+
+Deletion emits batched [Message Delete Bulk](/gateway/events/#message-delete-bulk) Dispatches as it progresses, and finally a [Message Create](/gateway/events/#message-create) for the completion direct message.
+
+:::danger[Deletion is permanent]
+Every message the filter selects is deleted from its channel for every recipient, and its attachments are permanently removed. No operation restores them. Obtain explicit user confirmation before submitting, and never present the 202 as confirmation that deletion has finished.
+:::
+
+### JSON body
+
+The caller proves sudo mode either with the five sudo fields below or with an existing proof in the request. An existing proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope? | string | The [deletion scope](#bulk-message-deletion-scopes), default `selected` |
+| include_dms? | boolean | Whether one-to-one DMs the caller still has open are included (default true) |
+| include_dms_closed?1 | boolean | Whether one-to-one DMs the caller has closed are included (default true) |
+| include_group_dms?2 | boolean | Whether group DMs the caller is still a member of are included (default true) |
+| include_guilds?2 | boolean | Whether channels in guilds the caller is still a member of are included (default true) |
+| guild_filter_mode?3 | string | The [guild filter mode](#guild-filter-modes), default `exclude` |
+| excluded_guild_ids? | array[snowflake] | The guilds left untouched in `exclude` mode (max 500 IDs, default empty) |
+| included_guild_ids? | array[snowflake] | The only guilds targeted in `include_only` mode (max 500 IDs, default empty) |
+| start_date?4 | ?ISO8601 timestamp | The inclusive lower bound on message time, or null for no lower bound |
+| end_date?4 | ?ISO8601 timestamp | The exclusive upper bound on message time, or null for no upper bound |
+| password?5 | string | The account password (8-256 characters) |
+| mfa_method?5 | string | The sudo MFA method, either `totp` or `webauthn` |
+| mfa_code?5 | string | The sudo authenticator code, or an unconsumed backup code (1-32 characters) |
+| webauthn_response?5 | [WebAuthn assertion](/http-api/users/mfa/#webauthn-assertion-object) object | The sudo WebAuthn assertion |
+| webauthn_challenge?5 | string | The sudo WebAuthn challenge (1-256 characters) |
+
+1 Independent of `include_dms`, so setting `include_dms` false and `include_dms_closed` true targets closed direct messages only
+
+2 Under the `selected` scope a group DM is eligible only while the caller is still a recipient, and a guild channel only while the caller is still a member
+
+3 Evaluated only while `include_guilds` is true and `scope` is `selected`
+
+4 Supplying both bounds requires `start_date` strictly earlier than `end_date`, and an equal pair fails validation on `end_date`
+
+5 The five sudo fields are the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). Which combination is accepted depends on the account's configured authenticators
+
+The `selected` scope requires at least one of the four context toggles to be true, and a request that disables all four fails validation on `include_dms`. The `inaccessible_only` scope ignores the toggles and the guild filter.
+
+Neither scope includes the caller's personal notes channel, so this operation cannot delete a personal note.
+
+[Request filtered data harvest](/http-api/users/data-harvest/#request-filtered-data-harvest) accepts the same filter shape and applies it to the messages an archive contains. That operation requires no sudo mode.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 202 | empty | Deletion was accepted for asynchronous processing |
+| 400 | [error response](/http-api/#error-response) | The date range or context selection is invalid, or a supplied sudo proof is wrong and the request returns `INVALID_PASSWORD`, `PASSWORD_NOT_SET`, or `INVALID_MFA_CODE` |
+| 403 | [error response](/http-api/#error-response) | No accepted sudo proof is present and the request returns `SUDO_MODE_REQUIRED` |
+| 500 | [error response](/http-api/#error-response) | Deletion could not be enqueued |
+
+### Side effects
+
+An account holding an MFA authenticator that proved sudo mode with MFA receives a fresh proof in the response header. A request that already had a valid proof gets that same token echoed back without an extended lifetime.
+
+Fluxer enqueues the work with at most 5 attempts. As deletion progresses, each affected channel emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) in batches of at most 100 message IDs, and the deleted messages' attachments are permanently removed.
+
+On completion the system account sends the caller a direct message in the account locale reporting the total deleted message count and the number of channels touched. That message arrives through the ordinary [Message Create](/gateway/events/#message-create) Dispatch.
+
+### Rate limit
+
+5 requests per 30 minutes for each authenticated user, on the `user:messages:bulk_delete_mine_filtered` bucket.
+
+## Request bulk message deletion
+
+
+
+Schedules deletion of every message the caller has ever sent. Returns 204 with an empty body. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+The request requires [sudo mode](/http-api/users/mfa/#sudo-mode). [Delete current user's messages](#delete-current-users-messages) is the immediate counterpart and takes a filter. This form takes none and waits a day, so the caller can change their mind.
+
+Calling the operation again replaces the pending schedule. The account holds at most one pending deletion, reported by the [pending bulk message deletion object](/http-api/users/#pending-bulk-message-deletion-object) on the [user object](/http-api/users/#user-object).
+
+:::danger[Deletion is permanent once it runs]
+The schedule targets every message the caller authored before the scheduled moment and removes their attachments. Unlike the filtered form it applies no channel eligibility rule, so it also deletes the caller's personal notes. [Cancel bulk message deletion](#cancel-bulk-message-deletion) is the only way to stop it.
+:::
+
+### JSON body
+
+The body is the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). It has no other field, and an existing proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The deletion was scheduled |
+| 400 | [error response](/http-api/#error-response) | A supplied sudo proof is wrong and the request returns `INVALID_PASSWORD`, `PASSWORD_NOT_SET`, or `INVALID_MFA_CODE` |
+| 403 | [error response](/http-api/#error-response) | No accepted sudo proof is present and the request returns `SUDO_MODE_REQUIRED` |
+| 500 | [error response](/http-api/#error-response) | The deletion could not be scheduled |
+
+The 204 has `X-Fluxer-Sudo-Mode-JWT` when sudo verification issued or reused a token.
+
+### Side effects
+
+Any pending deletion for the account is removed from the queue first. The account then records a scheduled moment exactly one day in the future together with the number of messages and channels the deletion would affect. Those counts are computed once at scheduling time and are not recomputed while the deletion waits.
+
+The updated counts and schedule are published to the caller through [User Update](/gateway/events/#user-update). No message is deleted by this request, so no channel receives a Dispatch until the scheduled work runs.
+
+:::caution[Nothing announces that the deletion finished]
+When the scheduled work runs it clears the three stored fields directly and emits no [User Update](/gateway/events/#user-update).
+:::
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:messages:bulk_delete` bucket, which is shared with [Cancel bulk message deletion](#cancel-bulk-message-deletion).
+
+## Cancel bulk message deletion
+
+
+
+Cancels a pending bulk message deletion. Returns 200 on success. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+No sudo verification applies. An account with no pending deletion still returns 200 and still emits the Dispatch. Once the scheduled work has started there is nothing left to cancel, and the deleted messages are not restored.
+
+### Response body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| success | boolean | Whether the cancellation was applied, always true |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | response body | The pending deletion, if any, was cancelled |
+| 500 | [error response](/http-api/#error-response) | The cancellation could not be applied |
+
+### Side effects
+
+The scheduled moment and both stored counts are cleared from the account, the queued work is removed, and [User Update](/gateway/events/#user-update) reaches the caller with `pending_bulk_message_deletion` set to null.
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:messages:bulk_delete` bucket, which is shared with [Request bulk message deletion](#request-bulk-message-deletion).
diff --git a/fluxer_docs/src/content/docs/http-api/users/current-user.mdx b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx
new file mode 100644
index 000000000..a43e4c269
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx
@@ -0,0 +1,333 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Current user
+description: Reading and mutating the authenticated account, plus its lifecycle operations.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The current user is the account that presented the credential, represented as a [user object](/http-api/users/#user-object). The routes here read that account, change it, and run its lifecycle operations.
+
+Every route except [Get current user](#get-current-user) is user-only. Those routes reject a bot or OAuth2 bearer credential with 403 `ACCESS_DENIED`. They also reject an account that has an outstanding [required action](/http-api/users/#required-actions) with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+## Sudo verification
+
+Sudo verification asks the account to prove itself again inside the request. Security-sensitive fields and destructive lifecycle operations require it. Proof comes in one of three forms.
+
+| Account state | Accepted proof |
+| --- | --- |
+| No authenticator configured | The current `password` |
+| An authenticator configured | `mfa_method` of `totp` with `mfa_code`, or `mfa_method` of `webauthn` with `webauthn_response` and `webauthn_challenge` |
+| No password credential and no authenticator | None, the requirement is already satisfied |
+
+An already valid sudo token replaces any of those forms. Once an account configures an authenticator its `password` stops working here.
+
+A sudo token is an HS256 JSON Web Token valid for five minutes. It has the account ID as its subject and is rejected for any other account. A successful verification returns the token in the `X-Fluxer-Sudo-Mode-JWT` response header. Fluxer mints a fresh token only when an authenticator satisfied the requirement. A request presenting an already valid token gets that same token back without an extended lifetime.
+
+A request that does not satisfy the requirement returns 403 [`SUDO_MODE_REQUIRED`](/http-api/errors/), whose body has `has_mfa` and `methods` as top-level members. An invalid authenticator code, backup code, or WebAuthn assertion returns [`INVALID_MFA_CODE`](/http-api/errors/) on the `mfa_code` path. An incorrect password returns [`INVALID_PASSWORD`](/http-api/errors/) on the `password` path.
+
+A request presents an existing token in the `X-Fluxer-Sudo-Mode-JWT` request header. Retain the response header value and send it back on each later operation in the same sudo window.
+
+## Get current user
+
+
+
+Returns the current [user](/http-api/users/#user-object) object.
+
+### Limitations
+
+- An OAuth2 bearer credential must hold the `identify` [scope](/http-api/oauth2/#oauth2-scopes), and one without it is rejected with 403 [`MISSING_OAUTH_SCOPE`](/http-api/errors/), whose body has `required_scope`.
+- A bearer receives the neutralised representation, and `email` only when it also holds the `email` scope.
+
+This route alone admits an account with an outstanding [required action](/http-api/users/#required-actions), so a client can read and poll the actions it has to complete.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | Current account was returned |
+| 403 | [error response](/http-api/#error-response) | Bearer credential lacks the `identify` scope and the request returns `MISSING_OAUTH_SCOPE` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:settings:get` bucket, which is shared with [Get current user settings](/http-api/users/settings/#get-current-user-settings).
+
+## Modify current user
+
+
+
+Modifies the current account and returns the resulting [user](/http-api/users/#user-object) object.
+
+### Limitations
+
+- Changing the username or discriminator to a different value, supplying `new_password`, or supplying `email_token` requires [sudo verification](#sudo-verification).
+- A field supplied with the value the account already holds is not a change and does not raise that requirement.
+- An unclaimed account never raises it either.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username?1 | string | Username after trimming (1-32 ASCII letters, digits, or underscores) |
+| discriminator?2 | string \| integer | Discriminator parsed as an integer (1-4 decimal digits) |
+| global_name?3 | ?string | Display name after sanitisation (1-32 characters), or null to clear |
+| email?4 | string | Always rejected, because an address is applied through `email_token` |
+| new_password?5 | string | New password (8-256 characters) |
+| password? | string | Current password (8-256 characters), used as [sudo verification](#sudo-verification) proof |
+| avatar?6 | ?string | Base64-encoded avatar image, or null to clear |
+| banner?67 | ?string | Base64-encoded banner image, or null to clear |
+| bio?8 | ?string | Biography (1-320 characters), or null to clear |
+| pronouns?9 | ?string | Pronouns (1-40 characters), or null to clear |
+| accent_color? | ?integer | Packed 24-bit RGB colour (0-16777215), or null to clear |
+| timezone?1011 | ?string | Supported IANA timezone identifier (1-128 characters), or null to clear |
+| timezone_privacy_flags?10 | integer | [Profile field privacy flags](/http-api/users/#profile-field-privacy-flags) applied to the profile timezone |
+| premium_badge_hidden? | boolean | Whether to withhold the premium badge from the public profile |
+| premium_badge_masked? | boolean | Whether to present a lifetime badge as an ordinary subscription badge |
+| premium_badge_timestamp_hidden? | boolean | Whether to withhold the premium activation time |
+| premium_badge_sequence_hidden? | boolean | Whether to withhold the lifetime premium sequence |
+| premium_enabled_override?12 | boolean | Whether a staff override grants premium entitlements |
+| has_dismissed_premium_onboarding?13 | boolean | Whether the premium onboarding flow has been dismissed |
+| has_unread_gift_inventory?14 | boolean | Whether the gift inventory still holds unread items |
+| mention_flags? | integer | [Reply mention preference](/http-api/users/#reply-mention-preferences), one of `0`, `1`, or `2` |
+| email_token?15 | string | Email token (1-256 characters) issued by [verify new email](/http-api/users/email-and-password/#verify-new-email) |
+| mfa_method? | string | [Sudo verification](#sudo-verification) method, either `totp` or `webauthn` |
+| mfa_code? | string | [Sudo verification](#sudo-verification) authenticator or backup code (1-32 characters) |
+| webauthn_response? | [WebAuthn assertion](/http-api/users/mfa/#webauthn-assertion-object) object | [Sudo verification](#sudo-verification) WebAuthn assertion |
+| webauthn_challenge? | string | [Sudo verification](#sudo-verification) WebAuthn challenge (1-256 characters) |
+
+1 The value cannot be `everyone` or `here` and cannot contain `fluxer` or `system message`, on every instance including a self-hosted one
+
+2 Supplying the field without a username requires the custom discriminator entitlement and is otherwise rejected with `CHANGING_DISCRIMINATOR_REQUIRES_PREMIUM`. A pair already assigned to another account is rejected with `TAG_ALREADY_TAKEN`
+
+3 Sanitisation strips standalone surrogates, invisible characters, and variation selectors and collapses whitespace before the length is measured
+
+4 Any string value returns [`EMAIL_MUST_BE_CHANGED_VIA_TOKEN`](/http-api/errors/) on the `email` path, joined by `INVALID_EMAIL_FORMAT` when the string is not an address
+
+5 A claimed account also supplies `password`, and a body that omits it is rejected with `PASSWORD_NOT_SET`. A breached password is rejected with [`PASSWORD_IS_TOO_COMMON`](/http-api/errors/)
+
+6 The value may have a `data:...;base64,` prefix. The remaining payload runs 1 to 13981016 characters
+
+7 Every non-null value requires the animated banner entitlement, which gates every profile banner, and is otherwise rejected with [`BANNERS_REQUIRE_PREMIUM`](/http-api/errors/)
+
+8 The resolved `max_bio_length` instance limit applies on top of the schema bound, and a longer value is rejected with `CONTENT_EXCEEDS_MAX_LENGTH`
+
+9 The value is matched against the instance profile substring blocklist like `username`, `global_name`, and `bio`
+
+10 The pair is staff-only. A request from an account without the [`STAFF`](/http-api/users/#public-user-flags) flag has both fields dropped before the update runs
+
+11 An unrecognised identifier is rejected with `INVALID_TIMEZONE_IDENTIFIER`. Setting a first timezone without `timezone_privacy_flags` sets those flags to `EVERYONE`
+
+12 The field requires the [`STAFF`](/http-api/users/#public-user-flags) flag, and a caller without it is rejected with 403 [`MISSING_ACCESS`](/http-api/errors/)
+
+13 Only `true` has an effect, recording the current time as the dismissal moment. `false` is accepted and does nothing
+
+14 Only `false` has an effect, advancing the gift inventory read cursor to the current sequence. `true` is accepted and does nothing
+
+15 Supplying the field applies the verified address the token stands for, and the token is deleted once the update lands
+
+To skip the body proof fields, send the sudo token in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+A body that has no `email_token` and nothing beyond `mfa_method`, `mfa_code`, `webauthn_response`, and `webauthn_challenge` returns the current account unchanged. That check runs before the required action barrier, so it succeeds even while an action is outstanding. A body that has only `password` is an update with nothing to change, and it still emits [User Update](/gateway/events/#user-update).
+
+Fluxer rejects an account with an outstanding required action, with one exemption. A body that has `email_token` and nothing beyond the sudo verification fields is accepted. Any other defined field removes the exemption.
+
+A claimed account needs a verified email before changing the username, discriminator, display name, avatar, banner, biography, pronouns, accent colour, timezone, timezone privacy, or any premium badge field. A request without one returns 403 [`PROFILE_EMAIL_VERIFICATION_REQUIRED`](/http-api/errors/).
+
+An account is unclaimed while it holds no password credential, is not a bot, and does not have the SSO trait. Such an account may set only `new_password`, `has_dismissed_premium_onboarding`, and `has_unread_gift_inventory`. Any other field is rejected with [`UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN`](/http-api/errors/) on the path of the first offending field. The allow-list covers the profile payload only, so `email_token` and the sudo fields stay available as the way such an account claims itself.
+
+### Image validation
+
+Decoded avatar and banner content stays within the resolved `avatar_max_size` limit, which defaults to 10485760 bytes, and a larger payload is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. Accepted formats are PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JPEG XL, and SVG. Anything else, animated AVIF included, is rejected with `INVALID_IMAGE_FORMAT`, and so is content the Media Proxy cannot identify.
+
+Content the explicit media classifier marks is rejected with 400 [`EXPLICIT_CONTENT_CANNOT_BE_SENT`](/http-api/errors/), whose body has the classifier score in a top-level `probability` member. An animated avatar requires the animated avatar entitlement and is otherwise rejected with `ANIMATED_AVATARS_REQUIRE_PREMIUM`. A successfully decoded avatar or non-null banner derives its dominant colour and returns it later as `avatar_color` or `banner_color`.
+
+### Content blocklists
+
+The username, display name, biography, and pronouns are matched against the instance profile substring blocklist, and a match returns 403 [`CONTENT_BLOCKED`](/http-api/errors/). An account with the `STAFF` flag is exempt. The biography and pronouns are additionally scanned against the phrase and URL blocklists.
+
+The global content filter applied to every POST, PUT, and PATCH JSON body runs the same pair of blocklists over the whole body before the handler. It reads `username`, `global_name`, `email`, `timezone`, and `discriminator` when supplied as a string, alongside the biography and pronouns. It skips `avatar`, `banner`, `password`, `new_password`, `email_token`, `mfa_code`, `webauthn_response`, `webauthn_challenge`, and every field whose name ends in `_flags`, and it ignores any value shorter than 3 characters. A match returns the same 403 `CONTENT_BLOCKED`, and no flag exempts an account from it.
+
+### Secondary controls
+
+Each control is separate from the route bucket. A denial returns 400 `INVALID_FORM_BODY` with the matching validation code rather than 429.
+
+| Change | Allowance | Validation code |
+| --- | --- | --- |
+| Resulting username or discriminator | 5 per 3 hours | `USERNAME_CHANGED_TOO_MANY_TIMES` |
+| Biography to a different value | 25 per 30 minutes | `BIO_CHANGED_TOO_MANY_TIMES` |
+| Pronouns to a different value | 25 per 30 minutes | `PRONOUNS_CHANGED_TOO_MANY_TIMES` |
+| Accent colour to a different value | 25 per 30 minutes | `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES` |
+| Any non-null avatar | 25 per 30 minutes | `AVATAR_CHANGED_TOO_MANY_TIMES` |
+| Any banner value past the entitlement check | 25 per 30 minutes | `BANNER_CHANGED_TOO_MANY_TIMES` |
+
+Fluxer consumes the avatar control before it detects identical content, and clearing the avatar consumes nothing. A clearing request consumes the banner control, and so does a request whose value is unchanged.
+
+:::note[Omission and null are distinct]
+An omitted field leaves its stored value untouched, and an explicit `null` clears a nullable field. `has_dismissed_premium_onboarding` acts only on `true` and `has_unread_gift_inventory` only on `false`, so neither can be reversed through this route.
+:::
+
+:::caution[A password change rotates the session]
+Supplying `new_password` on a claimed account deletes every other authentication session and every outstanding password reset token, then replaces the session that issued the request. The replacement arrives as [Auth Session Change](/gateway/events/#auth-session-change).
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | Account was returned after applying every permitted field |
+| 400 | [error response](/http-api/#error-response) | Body, image, tag, password, entitlement, secondary control, or sudo proof is invalid, or the image returns `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
+| 403 | [error response](/http-api/#error-response) | Email verification, sudo verification, or a staff-only field is required, or content is blocked |
+
+### Side effects
+
+A successful change emits [User Update](/gateway/events/#user-update) to the account. When a [partial user object](/http-api/users/#partial-user-object) field changes, other accounts observe the new value. Changing the username, discriminator, or display name also reindexes the account's guild member records in every guild whose member index exists. A body that takes the early return changes nothing and emits no Dispatch. Any other accepted body emits the Dispatch even when no stored value changed.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:update` bucket, which is shared with [Accept current policies](#accept-current-policies) and [Reset current premium state](#reset-current-premium-state).
+
+## Forget authorised IPs
+
+
+
+Deletes every authorised IP record for the current account. Requires [sudo verification](#sudo-verification). Returns 204 with an empty body and emits no Gateway Dispatch.
+
+A later sign-in from any address then needs fresh IP authorisation.
+
+### JSON body
+
+The body is the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). It may be an empty object when the request presents an accepted sudo token.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Authorised IP records were cleared |
+| 403 | [error response](/http-api/#error-response) | Sudo verification is required and the request returns `SUDO_MODE_REQUIRED` |
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:authorized_ips:forget` bucket.
+
+## Disable current account
+
+
+
+Disables the current account while preserving its data. Requires [sudo verification](#sudo-verification).
+
+The operation mints no sudo token and returns no `X-Fluxer-Sudo-Mode-JWT` header.
+
+### JSON body
+
+The body is the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). It may be an empty object when the request presents an accepted sudo token.
+
+:::caution[A later request with the same credential returns 401]
+Disabling deletes every authentication session the account owns, including the one that issued the request. Signing in again clears the disabled flag, unless the flag came from a temporary suspension.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Account was disabled |
+| 403 | [error response](/http-api/#error-response) | Sudo verification is required and the request returns `SUDO_MODE_REQUIRED` |
+| 500 | [error response](/http-api/#error-response) | The account could not be disabled or its sessions could not be deleted |
+
+### Side effects
+
+Fluxer marks the account disabled and deletes every authentication session it owns. [User Update](/gateway/events/#user-update) is published only after that deletion. No client of the account is still connected to receive it. The disabled flag is not part of the public flag bitfield, so other accounts observe no change.
+
+### Rate limit
+
+5 requests per hour for each authenticated user, on the `user:account:disable` bucket.
+
+## Schedule current account deletion
+
+
+
+Schedules deletion of the current account and deletes every authentication session it holds. Requires [sudo verification](#sudo-verification).
+
+### Limitations
+
+- An account that owns at least one guild is rejected with 400 [`USER_OWNS_GUILDS`](/http-api/errors/).
+
+### JSON body
+
+The body is the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). It may be an empty object when the request presents an accepted sudo token.
+
+:::caution[Signing in before the deadline cancels the deletion]
+The request records a deletion deadline set 336 hours ahead by default and marks the account self-deleted with the reason `USER_REQUESTED`. Erasure after the deadline is irreversible.
+:::
+
+:::note[Transfer guild ownership first]
+Fluxer checks guild ownership before it accepts the deletion, so an owner of any guild transfers or deletes each guild first.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Account deletion was scheduled |
+| 400 | [error response](/http-api/#error-response) | The caller owns at least one guild and the request returns `USER_OWNS_GUILDS` |
+
+### Side effects
+
+Fluxer marks the account for deletion at the configured deadline and enqueues it for erasure at that time. When the account has an email address, Fluxer sends a scheduled deletion email in the account locale. Every authentication session is deleted, and [User Update](/gateway/events/#user-update) is published only after that deletion. The self-deleted flag is not part of the public flag bitfield, so other accounts observe no change until erasure runs.
+
+### Rate limit
+
+5 requests per hour for each authenticated user, on the `user:account:delete` bucket.
+
+## Accept current policies
+
+
+
+Records acceptance of the current terms of service and privacy policy. Returns the updated [user](/http-api/users/#user-object) object.
+
+### JSON body
+
+The body is an empty object. An absent or empty body is read as an empty object, and any supplied property is discarded. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with `INVALID_FORMAT` at the `body` path. A body that parses as valid JSON but is not an object is rejected with 400.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | Policy acceptance was recorded |
+| 400 | [error response](/http-api/#error-response) | The body is not valid JSON, or it parses as JSON that is not an object |
+
+### Side effects
+
+Fluxer sets both `terms_agreed_at` and `privacy_agreed_at` to the request time and emits [User Update](/gateway/events/#user-update) to the account. Neither timestamp belongs to the [partial user object](/http-api/users/#partial-user-object), so no other account observes the change. Repeating the operation replaces both timestamps.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:update` bucket, which is shared with [Modify current user](#modify-current-user) and [Reset current premium state](#reset-current-premium-state).
+
+## Reset current premium state
+
+
+
+Clears every premium field on the current account and returns 204 with an empty body. Requires the [`STAFF`](/http-api/users/#public-user-flags) flag.
+
+A caller without that flag is rejected with 403 [`MISSING_ACCESS`](/http-api/errors/).
+
+:::note[The payment provider is untouched]
+Nothing is cancelled or refunded in the payment provider, which stays authoritative for its own objects.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Premium state was cleared |
+| 403 | [error response](/http-api/#error-response) | Caller does not have the `STAFF` flag and the request returns `MISSING_ACCESS` |
+
+### Side effects
+
+The operation clears `premium_type`, `premium_since`, `premium_until`, `premium_gift_extension_ends_at`, `premium_will_cancel`, `premium_billing_cycle`, `premium_grace_ends_at`, and `premium_lifetime_sequence`, plus the stored provider customer and subscription identifiers, the purchase and first refund markers, both gift inventory sequences, and the staff premium override flag. It emits [User Update](/gateway/events/#user-update) to the account.
+
+Other accounts observe a change only when the account held an animated avatar, whose served avatar hash loses its animation prefix with the entitlement.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:update` bucket, which is shared with [Modify current user](#modify-current-user) and [Accept current policies](#accept-current-policies).
diff --git a/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx b/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx
new file mode 100644
index 000000000..58a38aeaa
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx
@@ -0,0 +1,355 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Data harvests
+description: Compiling the account's data into a downloadable ZIP archive.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A data harvest is a ZIP archive of the current account's data. Fluxer compiles it asynchronously, reports progress while it runs, and issues a temporary download URL once the archive is written.
+
+Every route except [Download data harvest archive](#download-data-harvest-archive) is user-only. Fluxer rejects a bot or OAuth2 bearer credential with 403 `ACCESS_DENIED`, and an account that has suspicious activity flags with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. No route here requires [sudo mode](/http-api/users/mfa/#sudo-mode). [Delete current user's messages](/http-api/users/content/#delete-current-users-messages) takes the same filter shape, destroys the messages, and requires it.
+
+:::caution[A completed archive has a seven day download deadline]
+A completed harvest records a deadline exactly seven days after the archive was written. After it, [Get data harvest download URL](#get-data-harvest-download-url) returns 400 `HARVEST_EXPIRED` and [Download data harvest archive](#download-data-harvest-archive) returns 404, while the record keeps reporting `completed`. Nothing extends or restores it.
+:::
+
+## Harvest status values
+
+The status is derived from the recorded timestamps in a fixed order, testing `failed_at` first, then `completed_at`, then `started_at`.
+
+| Value | Description |
+| --- | --- |
+| pending | No processing attempt has started |
+| processing | An attempt has started and has neither completed nor failed |
+| completed | The archive was written and is ready for download |
+| failed | The latest attempt recorded a failure |
+
+Fluxer retries the underlying job. A retry clears `failed_at` and `error_message`, sets `started_at` again, resets the reported progress to 0, and reports `processing` while it runs. A retry that succeeds records `completed_at`, `file_size`, and a download deadline, and reports `completed`.
+
+## Harvest creation object
+
+The acknowledgement returned by both creation operations.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| harvest_id | snowflake | The ID of the created harvest |
+| status1 | string | The [harvest status](#harvest-status-values) at creation |
+| created_at2 | ISO8601 timestamp | When the harvest was requested |
+
+1 Always `pending`
+
+2 Recorded at the same moment the [snowflake](/snowflakes/) is generated, so ordering harvests by ID is the same as ordering them by `created_at`
+
+## Harvest status object
+
+The full state of one harvest. It extends the [harvest creation](#harvest-creation-object) object with progress and completion fields.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| harvest_id | snowflake | The ID of the harvest |
+| status | string | The derived [harvest status](#harvest-status-values) |
+| created_at | ISO8601 timestamp | When the harvest was requested |
+| started_at1 | ?ISO8601 timestamp | When the current processing attempt began, or null while the harvest is pending |
+| completed_at | ?ISO8601 timestamp | When the archive was written, or null when it has not been |
+| failed_at2 | ?ISO8601 timestamp | When the latest attempt recorded a failure, or null when it has not |
+| file_size3 | ?string | The archive size in bytes as a decimal string, or null before the archive was written |
+| progress_percent4 | number | Progress from 0 through 100 |
+| progress_step5 | ?string | The [harvest progress step](#harvest-progress-steps) for the current stage |
+| error_message6 | ?string | The latest attempt's failure description, or null when it has not failed |
+| download_url_expires_at7 | ?ISO8601 timestamp | When the archive's download deadline elapses, or null before completion |
+| expires_at8 | ?ISO8601 timestamp | When the archive's download deadline elapses, or null before completion |
+
+1 A new processing attempt replaces this timestamp, resets `progress_percent` to 0, and sets `progress_step` to `Starting harvest`
+
+2 Cleared when a new attempt starts, so it is non-null only when the latest attempt failed
+
+3 A decimal string because the value is 64-bit
+
+4 Every recorded value is a whole number. It never decreases inside a single attempt, and completion sets it to exactly 100
+
+5 Every record is created with the step `Queued`, so this member is never null
+
+6 The string form of the underlying failure. It is diagnostic and is not stable for programmatic matching
+
+7 Recorded when the archive is written, exactly seven days after that moment. Past it Fluxer issues no new download URL
+
+8 A second name for `download_url_expires_at`. The stored value is the same
+
+## Harvest progress steps
+
+A short English label describing the current stage. Every value is fixed except the harvested-message label, which includes the message count.
+
+| Value | Description |
+| --- | --- |
+| Queued | Recorded when the harvest record is created, with progress 0 |
+| Starting harvest | Recorded when a processing attempt begins, with progress reset to 0 |
+| Harvesting messages | Message collection has started, with progress 5 |
+| Harvested N messages1 | Message collection has finished, where N is the exact decimal count collected |
+| Collecting user metadata | Account data is being collected, with progress 60 |
+| Downloading attachments and creating archive | The ZIP archive is being written, with progress 65 |
+| Completed | The archive is ready, with progress 100 |
+
+1 Skipped entirely when the harvest collected no message. Its progress is 5 plus the whole part of N divided by 200, capped at 55
+
+## Harvest download object
+
+One issued download URL and its stated expiry.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| download_url1 | string | The temporary archive download URL (1-2048 characters) |
+| expires_at2 | ISO8601 timestamp | Seven days after this response was produced |
+
+1 Resolves to a single ZIP object served as `application/zip`
+
+2 Exactly seven days after issuance, even when the archive's own deadline falls earlier
+
+Each response mints a URL, and a later call invalidates no earlier one. A URL pointing at [Download data harvest archive](#download-data-harvest-archive) still stops resolving at the archive's own deadline, so its stated expiry can outlast the point at which it stops working.
+
+:::note[An issued URL has its own seven day validity]
+A presigned object storage URL, the default form, is never rechecked against the harvest record, so one issued shortly before the deadline keeps working past it. The signed token form rechecks the deadline.
+:::
+
+## Harvest message filter object
+
+The filter chooses which messages a harvest collects. Every non-message section of the archive is included whatever the filter says.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scope?1 | string | Which set of contexts the harvest targets, either `selected` or `inaccessible_only` (default `selected`) |
+| include_dms? | boolean | Whether to include one-to-one direct messages the caller still has open (default true) |
+| include_dms_closed?2 | boolean | Whether to include one-to-one direct messages the caller has closed (default true) |
+| include_group_dms? | boolean | Whether to include group DMs the caller is still a member of (default true) |
+| include_guilds? | boolean | Whether to include text channels in guilds the caller is a member of (default true) |
+| guild_filter_mode?3 | string | How the guild list is read, either `exclude` or `include_only` (default `exclude`) |
+| excluded_guild_ids? | array[snowflake] | The guilds to leave out in `exclude` mode (max 500, default empty) |
+| included_guild_ids? | array[snowflake] | The only guilds to include in `include_only` mode (max 500, default empty) |
+| start_date?4 | ?ISO8601 timestamp | Inclusive lower bound on message timestamps, or null for no lower bound |
+| end_date?4 | ?ISO8601 timestamp | Exclusive upper bound on message timestamps, or null for no upper bound |
+
+1 The `selected` scope requires at least one of the four include fields to be true, and a body disabling all four is rejected at path `include_dms`
+
+2 Independent of `include_dms`, so setting `include_dms` false and `include_dms_closed` true targets closed conversations only
+
+3 The guild lists are read only when `include_guilds` is true and the scope is `selected`, and the list that does not match the mode is ignored
+
+4 Compared against the creation time embedded in the message [snowflake](/snowflakes/), so editing an old message does not move it into a later window
+
+Under the `selected` scope a one-to-one direct message is collected when the conversation is still open and `include_dms` is true, or when it has been closed and `include_dms_closed` is true. A group DM is collected only while the caller is still a recipient and `include_group_dms` is true. A guild channel is collected only while the caller is still a member, `include_guilds` is true, and the guild is admitted by the configured list mode.
+
+The `inaccessible_only` scope selects messages in guilds the caller has left or been removed from and in group DMs the caller has left. It never selects one-to-one direct messages, and it ignores the four toggles, the guild filter mode, and both guild lists.
+
+Both scopes exclude the caller's personal notes channel, and both exclude a message whose channel no longer resolves or is neither a direct message, a group DM, nor a guild channel.
+
+Supplying both date bounds requires `start_date` strictly earlier than `end_date`. An equal or inverted pair is rejected at path `end_date`.
+
+:::caution[A filtered archive still has every non-message section]
+The archive still contains the account document, the payment history document, the owned application document, the security document, and the account's avatar and banner. No read response returns the applied filter, so a caller keeps its own record of what it submitted.
+:::
+
+## Request data harvest
+
+
+
+Creates an unfiltered harvest of the account. Returns a [harvest creation](#harvest-creation-object) object on success.
+
+The operation takes no request body and always returns before the archive exists, so the caller polls [Get data harvest status](#get-data-harvest-status) or [Get latest data harvest](#get-latest-data-harvest) with the returned ID.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [harvest creation](#harvest-creation-object) object | The harvest request was accepted |
+| 404 | [error response](/http-api/#error-response) | The authenticated account no longer resolves and the request returns `UNKNOWN_USER` |
+| 500 | [error response](/http-api/#error-response) | The harvest record could not be written or the job could not be queued |
+
+### Side effects
+
+A harvest record is created with a new [snowflake](/snowflakes/), the request time as `created_at`, progress 0, the step `Queued`, and every other timestamp null. Creating a harvest removes no earlier record and enforces no ceiling on how many an account holds, so the route bucket is the only bound.
+
+Fluxer prepares the archive asynchronously. It contains `user.json` with the account document, one `channels/{channel_id}/messages.json` file for each channel that contributed a message, `payments/payment_history.json`, `integrations/oauth.json` listing the [applications](/http-api/applications/) the account owns rather than the ones it authorised, and `account/security.json`. The account avatar and banner are added under `assets/user/` when present. Messages inside each channel file are ordered oldest first. Attachment metadata appears with its message and has the attachment ID, filename, size, content type, CDN URL, and pixel dimensions. The attachment files themselves are never included.
+
+At most 100,000 authored messages are collected. The collector skips a message it cannot read, and the harvest still succeeds. When the archive completes, Fluxer sends one email containing a download URL if the account has an email address and the instance has email delivery enabled. That URL expires seven days after it was minted.
+
+### Rate limit
+
+5 requests per 30 minutes for each authenticated user, on the `user:data:harvest` bucket shared with [Request filtered data harvest](#request-filtered-data-harvest).
+
+## Request filtered data harvest
+
+
+
+Creates a harvest whose message collection is constrained by a filter. Returns a [harvest creation](#harvest-creation-object) object on success.
+
+### JSON body
+
+The body is a [harvest message filter](#harvest-message-filter-object) object. Every field is optional, so an empty object is accepted and every default admits its context. An empty request body is read as an empty object.
+
+:::note[A filtered archive tests channel eligibility for every message]
+Even with every default, a filtered archive drops messages in the caller's personal notes channel, in group DMs and guilds the caller has left, and in a channel that no longer resolves. [Request data harvest](#request-data-harvest) collects those.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [harvest creation](#harvest-creation-object) object | The filtered harvest request was accepted |
+| 400 | [error response](/http-api/#error-response) | A filter field failed validation, the `selected` scope enabled no context, a guild list exceeded 500 entries, or the date bounds were not strictly increasing |
+| 404 | [error response](/http-api/#error-response) | The authenticated account no longer resolves and the request returns `UNKNOWN_USER` |
+| 500 | [error response](/http-api/#error-response) | The harvest record could not be written or the job could not be queued |
+
+### Side effects
+
+The side effects match [Request data harvest](#request-data-harvest), except that only messages selected by the filter are collected.
+
+### Rate limit
+
+5 requests per 30 minutes for each authenticated user, on the `user:data:harvest` bucket shared with [Request data harvest](#request-data-harvest).
+
+## Get latest data harvest
+
+
+
+Returns the account's most recently created [harvest status](#harvest-status-object) object.
+
+The most recent harvest is the one with the highest ID, and because IDs are [Snowflakes](/snowflakes/) that ordering is creation order. The result is the newest record whatever its state, so a completed archive is not preferred over a newer failed one.
+
+:::note[An account with no harvest receives 200 and `null`]
+The body is the literal JSON value `null`, so decode it before treating the response as an object.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [harvest status](#harvest-status-object) object \| null | The latest harvest state was returned, or null when none exists |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:harvest:latest` bucket.
+
+## Get data harvest status
+
+
+
+Returns one [harvest status](#harvest-status-object) object owned by the current account.
+
+A harvest belonging to another account returns 404 `UNKNOWN_HARVEST`.
+
+Fluxer removes no harvest record, so an ID that once resolved keeps resolving. A 404 means the ID was never issued to this account, and another account's harvest is never distinguishable from one that never existed.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| harvestId | snowflake | The ID of the harvest |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [harvest status](#harvest-status-object) object | The harvest state was returned |
+| 404 | [error response](/http-api/#error-response) | The harvest is unknown or belongs to another account, returning `UNKNOWN_HARVEST` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:harvest:status` bucket.
+
+## Get data harvest download URL
+
+
+
+Issues a temporary download URL for one completed harvest. Returns a [harvest download](#harvest-download-object) object on success.
+
+Fluxer evaluates four rejection cases in a fixed order, each with its own code. An unknown or foreign harvest is 404 `UNKNOWN_HARVEST`. A harvest whose latest attempt failed is 400 `HARVEST_FAILED`. A harvest with no recorded completion or no stored archive is 400 `HARVEST_NOT_READY`. A harvest past its download deadline is 400 `HARVEST_EXPIRED`.
+
+:::danger[Each request issues a new bearer URL]
+A URL is created for each call, its stated expiry is seven days later, and a later call revokes nothing. Treat every issued URL as a secret.
+:::
+
+The URL takes one of two forms. When the instance has presigned harvest downloads enabled, which is the default, it is an object storage presigned URL. Otherwise it points at [Download data harvest archive](#download-data-harvest-archive) on this API with a signed `token` query parameter.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| harvestId | snowflake | The ID of the harvest |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [harvest download](#harvest-download-object) object | A download URL was issued |
+| 400 | [error response](/http-api/#error-response) | The harvest returns `HARVEST_NOT_READY`, `HARVEST_FAILED`, or `HARVEST_EXPIRED` |
+| 404 | [error response](/http-api/#error-response) | The harvest is unknown or belongs to another account, returning `UNKNOWN_HARVEST` |
+| 500 | [error response](/http-api/#error-response) | A download URL could not be issued |
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:harvest:download` bucket.
+
+## Download data harvest archive
+
+
+
+Streams the archive of one completed harvest.
+
+The signed `token` query parameter issued by [Get data harvest download URL](#get-data-harvest-download-url) is the whole authorisation, so the link stays usable from the harvest completion email and from a plain browser.
+
+The operation is active only while the instance has presigned harvest downloads disabled. That setting is enabled by default, so a default deployment answers every request here as 404 without inspecting the token.
+
+The token binds the account, the harvest ID, the stored object key, and an expiry.
+
+:::note[Every rejection looks identical]
+A missing, malformed, forged, or expired token, a token for another harvest, a harvest that never completed or recorded a failure, a harvest past its deadline, and a stale object key all return 404 with the plain text body `Not Found`.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| harvestId | snowflake | The ID of the harvest |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token | string | Signed download token issued with the download URL |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Range? | string | Standard byte range, answered with 206 and a `Content-Range` header |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | archive bytes | The complete archive was streamed |
+| 206 | archive bytes | The requested byte range was streamed |
+| 404 | `Not Found` | The token is missing, invalid, or expired, the harvest is not downloadable, or the instance issues presigned URLs instead |
+| 500 | [error response](/http-api/#error-response) | Object storage could not be read |
+
+### Response headers
+
+The 200 has `Content-Type: application/zip`, `Content-Disposition`1, `Content-Length`, `Accept-Ranges: bytes`, and `Cache-Control: private, no-store`. The 206 has the 200 headers plus `Content-Range`. The 404 has `Content-Type: text/plain`.
+
+1 `Content-Disposition` is always `attachment` and names the file `fluxer-data-{harvestId}.zip`
+
+### Side effects
+
+The archive is read and streamed. The harvest is unchanged and the token is not consumed, so it stays usable until its own expiry.
+
+### Rate limit
+
+60 requests per minute for each client IP address, or for the authenticated account when the request has a resolvable credential, on the `user:harvest:download_file` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx
new file mode 100644
index 000000000..3a64b6503
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx
@@ -0,0 +1,674 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Email and password changes
+description: Ticketed replacement of the account email address and password.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Changing the email address or the password on an account takes several steps. Fluxer opens a ticket, emails a code, and every later step presents that ticket together with the codes and proofs it has collected. An account whose address the mail provider rejected uses [bounced email recovery](#bounced-email-recovery) instead.
+
+Every operation needs a non-bot user session. The email change routes admit a session with suspicious account state. Every password change route rejects that state with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+## Ticket and code contract
+
+A ticket identifier is a version 4 UUID. Fluxer issues it when the flow starts, and every later step sends it as a plain string of 1 to 256 characters. Each ticket belongs to one account, and a step presented by another account fails with `INVALID_OR_EXPIRED_TICKET`. A completed ticket rejects every further step with `TICKET_ALREADY_COMPLETED`. Starting a flow again mints a fresh ticket and leaves an outstanding one usable.
+
+A verification code is eight characters drawn from the uppercase Latin alphabet and the decimal digits, written as two four-character groups separated by a hyphen. A code lives for 10 minutes from the moment it is sent. Fluxer trims a submitted value and the comparison is otherwise exact, so a value with any other stray character fails with `INVALID_VERIFICATION_CODE`.
+
+:::caution[A lowercase code never matches]
+An issued code has only uppercase letters, digits, and one hyphen. A client that accepts free-form input uppercases the value and preserves the separator before submitting it.
+:::
+
+Fluxer returns a proof, a version 4 UUID, from every step that establishes a fact about the ticket. The email change flow issues `original_proof` for the original address and requires it on [Request new email](#request-new-email) and [Verify new email](#verify-new-email). The password change flow issues `verification_proof` for the emailed code and requires it to complete the change.
+
+An email token is a version 4 UUID standing for one verified new address. It lives 30 minutes, belongs to the account it was issued to, and is deleted once consumed. A token presented by another account fails with `INVALID_EMAIL_TOKEN`, and one past its lifetime is deleted and fails with `EMAIL_TOKEN_EXPIRED`.
+
+Fluxer refuses a resend for 30 seconds after the previous send of the same code, and that refusal is HTTP 429 with a `Retry-After` computed from the exact moment the next send becomes available. It is independent of the route bucket and of the longer email-send controls.
+
+:::danger[Tickets, codes, proofs, and email tokens are credentials]
+Each of these values can advance a security-sensitive account change. Keep them out of logs, analytics, URLs, and messages to unintended recipients. They are opaque, so preserve them exactly and derive nothing from their contents.
+:::
+
+:::note[Ticket failures are validation entries]
+Every ticket, code, address, proof, token, and password failure named on this page arrives as HTTP 400 whose top-level code is `INVALID_FORM_BODY`. The named value is the `code` of one [validation error](/http-api/#validation-error-object) entry, and that entry's `path` is the field it belongs to.
+:::
+
+A ticket failure uses the path `ticket`, a code failure `code`, and an address failure `new_email` or `email`. A proof failure uses `original_proof` or `verification_proof`, an email token failure uses `email_token`, and a password failure uses `password` or `new_password`. The account-level codes `ACCESS_DENIED`, `ACCOUNT_SUSPICIOUS_ACTIVITY`, `SUDO_MODE_REQUIRED`, and `RATE_LIMITED` arrive as the top-level `code`.
+
+## Email send controls
+
+Each send-bearing operation passes through its own route bucket and, separately, through a longer email-send control keyed by the account. Exhausting either produces HTTP 429 even when the other has room.
+
+| Control | Allowance | Operations |
+| --- | --- | --- |
+| Original address | 3 sends per 15 minutes | [Start email change](#start-email-change) and [Resend original email code](#resend-original-email-code) |
+| New address | 5 sends per 15 minutes | [Request new email](#request-new-email), [Resend new email code](#resend-new-email-code), and both bounced recovery sends |
+| Password change start | 3 sends per 15 minutes | [Start password change](#start-password-change) |
+| Password change resend | 3 sends per 15 minutes | [Resend password change code](#resend-password-change-code) |
+
+## Email change ticket states
+
+The email change ticket moves through three states. A step legal in one state is rejected in every other with the transition code named below.
+
+| Current state | Operation | Next state |
+| --- | --- | --- |
+| Pending original | [Resend original email code](#resend-original-email-code) | Pending original |
+| Pending original | [Verify original email](#verify-original-email) | Pending new |
+| Pending new | [Request new email](#request-new-email) | Pending new |
+| Pending new | [Resend new email code](#resend-new-email-code) | Pending new |
+| Pending new | [Verify new email](#verify-new-email) | Completed |
+
+A ticket created without original email verification starts in the pending-new state and receives its `original_proof` directly from [Start email change](#start-email-change). Requesting or verifying a new address while the ticket is still pending original fails with `ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST`.
+
+Verifying the original stage on a ticket that never required it fails with `ORIGINAL_VERIFICATION_NOT_REQUIRED`. Resending the original code fails with `ORIGINAL_EMAIL_ALREADY_VERIFIED` on a cleared ticket and on a ticket that never required the stage, so the response does not tell the two apart. Verifying the original address again on a cleared ticket returns the same `original_proof` unchanged.
+
+Resending the new address code before an address has been requested fails with `NO_NEW_EMAIL_REQUESTED`. A pending-original ticket returns the same code.
+
+[Request new email](#request-new-email) and [Resend new email code](#resend-new-email-code) share one 30-second cooldown. Requesting a different address cannot bypass it.
+
+Reaching the completed state issues an email token and leaves the account email unchanged. [Apply email change](#apply-email-change) writes the new address.
+
+## Email change start object
+
+The state of a freshly created email change ticket.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier every later step of this flow sends |
+| require_original1 | boolean | Whether the original address needs verification before a new address can be requested |
+| original_email2 | ?string | The address currently on the account, or null when the account has none |
+| original_proof3 | ?string | The proof for the original address stage, or null while that stage is unverified |
+| original_code_expires_at4 | ?ISO8601 timestamp | The moment the code sent to the original address expires, 10 minutes after it was sent |
+| resend_available_at4 | ?ISO8601 timestamp | The earliest moment the original address code can be resent, 30 seconds after the last send |
+
+1 True only when the account holds an email address and that address is verified
+
+2 Recorded on the ticket at creation, so a later change to the account email does not move it. [Request new email](#request-new-email) compares against this recorded value
+
+3 Populated exactly when `require_original` is false. [Request new email](#request-new-email) and [Verify new email](#verify-new-email) require it
+
+4 Null whenever `require_original` is false
+
+### Example
+
+```json
+{
+ "ticket": "6f1a4c9e-3d52-4a77-9b0e-2c8f5d41ab30",
+ "require_original": true,
+ "original_email": "old@example.com",
+ "original_proof": null,
+ "original_code_expires_at": "2026-03-04T18:10:00.000Z",
+ "resend_available_at": "2026-03-04T18:00:30.000Z"
+}
+```
+
+## New email request object
+
+The state of a ticket with a candidate address bound to it and a code in flight to that address.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket1 | string | The identifier every later step of this flow sends |
+| new_email2 | string | The address now bound to the ticket |
+| new_code_expires_at | ISO8601 timestamp | The moment the code sent to the new address expires, 10 minutes after it was sent |
+| resend_available_at3 | ?ISO8601 timestamp | The earliest moment the new address code can be resent, 30 seconds after the last send |
+
+1 Echoes the ticket the request named, except on [Request replacement email for bounced address](#request-replacement-email-for-bounced-address), where it identifies the ticket that operation created
+
+2 The trimmed form of the submitted address, which is what a later [Verify new email](#verify-new-email) writes
+
+3 Always populated
+
+## Original email verification object
+
+The proof that a ticket has cleared its original address stage.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| original_proof1 | string | The proof [Request new email](#request-new-email) and [Verify new email](#verify-new-email) require |
+
+1 Stable for the lifetime of the ticket. Verifying the original address again returns the same value
+
+## New email verification object
+
+The email token a completed email change ticket hands back.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email_token1 | string | The token [Apply email change](#apply-email-change) consumes to write the address |
+
+1 The token lives 30 minutes and stands for the exact address the ticket verified
+
+## Password change start object
+
+The state of a freshly created password change ticket.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier every later step of this flow sends |
+| code_expires_at | ISO8601 timestamp | The moment the emailed code expires, 10 minutes after it was sent |
+| resend_available_at1 | ?ISO8601 timestamp | The earliest moment the code can be resent, 30 seconds after the last send |
+
+1 Always populated
+
+## Password verification object
+
+The proof that the emailed code was accepted.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| verification_proof1 | string | The proof [Complete password change](#complete-password-change) consumes |
+
+1 Stable for the lifetime of the ticket. Verifying the same code again returns the same value
+
+## Password change completion object
+
+The replacement session [Complete password change](#complete-password-change) hands back after the password is written.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| token1 | string | The authentication token for the replacement session |
+| auth_session_id_hash | string | The base64url-encoded hash of the replacement authentication session |
+
+1 The same operation destroys the token the client authenticated with, so adopt this value before issuing another request
+
+## Start email change
+
+
+
+Creates an email change ticket. Returns an [email change start](#email-change-start-object) object on success.
+
+Only an account that holds an email address or is unclaimed can start an email change. Every other account fails with `MUST_HAVE_EMAIL_TO_CHANGE_IT`.
+
+Original address verification applies only when the account holds a verified email address. A bounce clears the verified state. An account that has done nothing since the bounce gets a ticket that skips the original stage. [Apply email change](#apply-email-change) marks the address verified again without clearing the bounced marker, so an account that has applied a change since the bounce gets a ticket that does require the stage.
+
+Sending an original address code consumes the original address control.
+
+### JSON body
+
+The body can be omitted, and any supplied body is an object with no fields. Fluxer strips unknown keys before it handles the request.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [email change start](#email-change-start-object) object | The ticket was created |
+
+### Side effects
+
+When original address verification applies, Fluxer creates the ticket in the pending-original state and sends one verification email to the account's current address. A ticket that skips the stage is created pending-new with its `original_proof` already issued. No account field changes.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:start` bucket.
+
+## Resend original email code
+
+
+
+Sends a fresh verification code to the account's original address for an active ticket. Returns 204 with an empty body.
+
+A ticket that is no longer pending-original fails with `ORIGINAL_EMAIL_ALREADY_VERIFIED`. A ticket recording no original address fails with `NO_ORIGINAL_EMAIL_ON_RECORD`.
+
+The send consumes the original address control, and the ticket enforces its own 30-second cooldown.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start email change](#start-email-change) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A replacement original address code was sent |
+
+### Side effects
+
+Fluxer replaces the ticket's original address code, send time, and code expiry, which invalidates the previous code. One verification email goes to the original address.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:resend_original` bucket.
+
+## Verify original email
+
+
+
+Verifies the original address stage and advances the ticket to pending-new. Returns an [original email verification](#original-email-verification-object) object on success.
+
+Running the operation against a ticket that already cleared the stage returns the same proof and changes nothing. A ticket that never required the stage fails with `ORIGINAL_VERIFICATION_NOT_REQUIRED`. A ticket with no issued code fails with `VERIFICATION_CODE_NOT_ISSUED`, a code past its 10-minute lifetime with `VERIFICATION_CODE_EXPIRED`, and a mismatch with `INVALID_VERIFICATION_CODE`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start email change](#start-email-change) (1-256 characters) |
+| code | string | The code sent to the original address (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [original email verification](#original-email-verification-object) object | The original address is verified |
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:email_change:verify_original` bucket.
+
+## Request new email
+
+
+
+Binds a new address to an active ticket and sends it a verification code. Returns a [new email request](#new-email-request-object) object on success.
+
+A ticket that has not cleared original address verification fails with `ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST`. An `original_proof` that does not match the ticket fails with `INVALID_PROOF_TOKEN`.
+
+Fluxer trims the address and then checks it in order. An empty address fails with `EMAIL_IS_REQUIRED`, an address equal to the ticket's recorded original with `NEW_EMAIL_MUST_BE_DIFFERENT`, a domain that fails DNS eligibility with `INVALID_EMAIL_ADDRESS`, and an address another account already owns with `EMAIL_ALREADY_IN_USE`. Ownership is checked again when the resulting email token is applied, so passing here reserves nothing. Calling the operation again with a different address replaces the pending address on the same ticket.
+
+The send consumes the new address control, and the ticket enforces its own 30-second cooldown.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start email change](#start-email-change) (1-256 characters) |
+| new_email1 | string | The address to bind to the ticket (1-254 characters) |
+| original_proof | string | The proof issued by [Start email change](#start-email-change) or [Verify original email](#verify-original-email) (1-256 characters) |
+| new_password?2 | string | The password to check against the breached-password corpus (8-256 characters) |
+
+1 A syntactically invalid address fails with `INVALID_EMAIL_FORMAT`, one outside the length bound with `EMAIL_LENGTH_INVALID`, and a local part outside the permitted ASCII set with `INVALID_EMAIL_LOCAL_PART`
+
+2 The value is only checked against the breached-password corpus, which reports a match as `PASSWORD_IS_TOO_COMMON`. Nothing on this page writes it
+
+:::note[`new_password` is checked here and never written]
+An unclaimed account learns that its intended password is unusable before a code is sent. The account then sets that password by passing `new_password` alongside `email_token` to [Modify current user](/http-api/users/current-user/#modify-current-user).
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [new email request](#new-email-request-object) object | The address was bound and a code was sent |
+
+### Side effects
+
+The ticket stores the requested address, a fresh code, its send time, and its expiry, and stays pending-new. One verification email goes to the requested address. The account's own email address is untouched at this stage.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:request_new` bucket.
+
+## Resend new email code
+
+
+
+Sends a fresh verification code to the address already bound to an active ticket. Returns 204 with an empty body.
+
+A ticket with no requested address fails with `NO_NEW_EMAIL_REQUESTED`.
+
+The send consumes the new address control, and the ticket enforces its own 30-second cooldown.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start email change](#start-email-change) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A replacement new address code was sent |
+
+### Side effects
+
+Fluxer replaces the ticket's new address code, send time, and code expiry, which invalidates the previous code. One verification email goes to the requested address.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:resend_new` bucket.
+
+## Verify new email
+
+
+
+Verifies the requested address and completes the ticket. Returns a [new email verification](#new-email-verification-object) object on success.
+
+An `original_proof` that does not match the ticket fails with `INVALID_PROOF_TOKEN`. A ticket with no requested address or no issued code fails with `VERIFICATION_CODE_NOT_ISSUED`, a code past its 10-minute lifetime with `VERIFICATION_CODE_EXPIRED`, and a mismatch with `INVALID_VERIFICATION_CODE`.
+
+:::caution[The email token cannot be recovered]
+This operation moves the ticket to the completed state, so a second call fails with `TICKET_ALREADY_COMPLETED`.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start email change](#start-email-change) (1-256 characters) |
+| code | string | The code sent to the new address (1-256 characters) |
+| original_proof | string | The proof issued by [Start email change](#start-email-change) or [Verify original email](#verify-original-email) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [new email verification](#new-email-verification-object) object | The new address is verified |
+
+### Side effects
+
+The ticket moves to the completed state and a 30-minute email token is issued for the verified address. The account's email address is still unchanged.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:email_change:verify_new` bucket.
+
+## Apply email change
+
+
+
+Consumes an email token and writes its address to the account. Returns the updated [user](/http-api/users/#user-object) object on success. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+A claimed account proves [sudo mode](/http-api/users/mfa/#sudo-mode) before the route examines the token. The route accepts suspicious account state.
+
+A token that belongs to another account fails with `INVALID_EMAIL_TOKEN`, and one past its 30-minute lifetime fails with `EMAIL_TOKEN_EXPIRED`. An expired token is deleted when rejected. Fluxer checks the address against existing account ownership again here, and an address another account has taken since fails with `EMAIL_ALREADY_IN_USE` without consuming the token.
+
+An unclaimed account is exempt from sudo mode and applies the change with its session alone.
+
+[Modify current user](/http-api/users/current-user/#modify-current-user) accepts the same token alongside an unrelated profile edit. The token is single use, so a retry after success fails with `INVALID_EMAIL_TOKEN`.
+
+### JSON body
+
+The body extends the [sudo verification object](/http-api/users/mfa/#sudo-verification-object) with `email_token`, and the five sudo fields it merges in are repeated below. An existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| email_token | string | The token returned by [Verify new email](#verify-new-email) (1-256 characters) |
+| password?1 | string | The account password (8-256 characters) |
+| mfa_method?2 | string | The sudo MFA method, either `totp` or `webauthn` |
+| mfa_code?3 | string | The sudo authenticator code or an unconsumed backup code (1-32 characters) |
+| webauthn_response?4 | [WebAuthn assertion](/http-api/users/mfa/#webauthn-assertion-object) object | The assertion produced for the supplied challenge |
+| webauthn_challenge?4 | string | The challenge returned by [create sudo WebAuthn authentication options](/http-api/users/mfa/#create-sudo-webauthn-authentication-options) (1-256 characters) |
+
+1 Considered only while the account holds no MFA authenticator, and ignored once TOTP or a WebAuthn credential exists
+
+2 Required when the account holds any MFA authenticator, unless the request already presents an accepted sudo proof
+
+3 Required when `mfa_method` is `totp`. The value is a current authenticator code or an unconsumed backup code
+
+4 Both fields are required together when `mfa_method` is `webauthn`
+
+The body has no profile field and no `new_password` field, and Fluxer strips unknown keys before it handles the request.
+
+:::caution[An unclaimed account never sends `password` here]
+Sudo mode does not apply to it, so Fluxer reads the field as an ordinary account update. Such an account can update only `new_password`, `has_dismissed_premium_onboarding`, and `has_unread_gift_inventory`. The request fails with `UNCLAIMED_ACCOUNTS_CAN_ONLY_SET_EMAIL_VIA_TOKEN` on the path `password`.
+:::
+
+An unclaimed account that also wants a first password makes a single [Modify current user](/http-api/users/current-user/#modify-current-user) call with `email_token` and `new_password`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | The verified email change was applied |
+| 403 | [error response](/http-api/#error-response) | Sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` with the [sudo mode methods object](/http-api/users/mfa/#sudo-mode-methods-object) |
+
+The 200 has `X-Fluxer-Sudo-Mode-JWT` when sudo verification issued a proof.
+
+:::note[The password is untouched]
+This operation replaces the email address only. Nothing on the path revokes a credential, so authentication sessions, OAuth2 access tokens, and OAuth2 refresh tokens all stay valid. [Complete password change](#complete-password-change) destroys sessions.
+:::
+
+### Side effects
+
+Fluxer replaces the account email, marks it verified, and removes every email-clearable suspicious activity flag, which can empty `required_actions` and restore ordinary access. Fluxer evaluates the new address against the instance contact policy separately, and that check can add suspicious activity flags. The bounced marker is not cleared, so an account that has it recovers through [bounced email recovery](#bounced-email-recovery).
+
+For an ordinary change from an existing address, Fluxer sends a revert email to the original address so its holder can use [Revert an email change](/http-api/authentication/#revert-an-email-change). Fluxer deletes the email token, records the contact change in the account's contact change log, and emits [User Update](/gateway/events/#user-update). No public member field changes, so no [Guild Member Update](/gateway/events/#guild-member-update) follows.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:email_change:apply` bucket.
+
+## Bounced email recovery
+
+An account whose stored address the mail provider rejected has the bounced marker, reported as `email_bounced` on the [user object](/http-api/users/#user-object). The bounce also clears the verified email state and imposes a required action. The three operations below bind a replacement address and finish without the sudo-gated apply step.
+
+Every one of them requires the bounced marker. An account without it is rejected with 403 `ACCESS_DENIED`, and an account with no address at all is rejected with `MUST_HAVE_EMAIL_TO_CHANGE_IT`.
+
+The flow also requires the account to hold no verified address, which is the state a bounce leaves. [Apply email change](#apply-email-change) marks an address verified without clearing the bounced marker. An account that took the ordinary flow after its bounce has both states, gets `ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST` here, and changes its address through the ordinary flow.
+
+[Ticket and code contract](#ticket-and-code-contract) governs its codes, cooldown, and new address send control. The verification step writes the address and clears the marker in one call.
+
+## Request replacement email for bounced address
+
+
+
+Starts the recovery flow. Returns a [new email request](#new-email-request-object) object on success.
+
+This operation creates a ticket and binds the replacement address in the same call. The route accepts suspicious account state. Fluxer checks the address exactly as [Request new email](#request-new-email) checks it, so it passes DNS eligibility, belongs to no other account, and differs from the bounced address.
+
+The send consumes the new address control.
+
+:::caution[A failed attempt still sends mail]
+When the account does hold a verified address the request fails with `ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST`. The failure lands only after Fluxer has created the ticket, sent one code to the address the account currently holds, and counted one send against the original address control.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| new_email | string | The address to replace the bounced one (1-254 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [new email request](#new-email-request-object) object | The replacement address was bound and a code was sent |
+| 403 | [error response](/http-api/#error-response) | The account is not marked bounced and the request returns `ACCESS_DENIED` |
+
+### Side effects
+
+A ticket is created pending-new with the replacement address bound to it, and one verification email goes to that address. No account field changes. Retain the returned `ticket`, because the two remaining steps take no other identifier.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:bounced:request_new` bucket.
+
+## Resend replacement email code
+
+
+
+Sends a fresh verification code to the replacement address bound to an active recovery ticket. Returns 204 with an empty body.
+
+A ticket with no replacement address fails with `NO_NEW_EMAIL_REQUESTED`.
+
+The send consumes the new address control, and the ticket enforces its own 30-second cooldown.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Request replacement email for bounced address](#request-replacement-email-for-bounced-address) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A replacement code was sent |
+| 403 | [error response](/http-api/#error-response) | The account is not marked bounced and the request returns `ACCESS_DENIED` |
+
+### Side effects
+
+Fluxer replaces the ticket's code, send time, and code expiry, which invalidates the previous code. One verification email goes to the replacement address.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:email_change:bounced:resend_new` bucket.
+
+## Verify replacement email for bounced address
+
+
+
+Verifies the replacement address and writes it to the account in the same call. Returns the updated [user](/http-api/users/#user-object) object on success. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+No sudo verification and no email token step applies.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Request replacement email for bounced address](#request-replacement-email-for-bounced-address) (1-256 characters) |
+| code | string | The code sent to the replacement address (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | The replacement address was verified and written |
+| 403 | [error response](/http-api/#error-response) | The account is not marked bounced and the request returns `ACCESS_DENIED` |
+
+### Side effects
+
+Fluxer replaces the account email, marks it verified, clears the bounced marker, and removes every email-clearable suspicious activity flag, which can empty `required_actions` and restore ordinary access. It marks the ticket completed and deletes its email token immediately. The contact change is recorded in the account's contact change log.
+
+No revert email is sent.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:email_change:bounced:verify_new` bucket.
+
+## Start password change
+
+
+
+Creates a password change ticket and sends a verification code to the account email. Returns a [password change start](#password-change-start-object) object on success.
+
+An account holding no email address fails with `MUST_HAVE_EMAIL_TO_CHANGE_IT`.
+
+The send consumes the password change start control.
+
+### JSON body
+
+The body can be omitted, and any supplied body is an object with no fields. Fluxer strips unknown keys before it handles the request.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [password change start](#password-change-start-object) object | The ticket was created and a code was sent |
+
+### Side effects
+
+A ticket is created in its pending state and one verification email goes to the account address. No account field changes.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:password_change:start` bucket.
+
+## Resend password change code
+
+
+
+Sends a fresh verification code for an active password change ticket. Requires the account to still hold an email address. Returns 204 with an empty body.
+
+The send consumes the password change resend control, and the ticket enforces its own 30-second cooldown.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start password change](#start-password-change) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A replacement code was sent |
+
+### Side effects
+
+Fluxer replaces the ticket's code, send time, and code expiry, which invalidates the previous code. One verification email goes to the account address.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:password_change:resend` bucket.
+
+## Verify password change code
+
+
+
+Verifies the emailed code. Returns a [password verification](#password-verification-object) object on success.
+
+Running the operation against an already verified ticket returns the same proof. A ticket with no issued code fails with `VERIFICATION_CODE_NOT_ISSUED`, a code past its 10-minute lifetime with `VERIFICATION_CODE_EXPIRED`, and a mismatch with `INVALID_VERIFICATION_CODE`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start password change](#start-password-change) (1-256 characters) |
+| code | string | The code sent to the account address (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [password verification](#password-verification-object) object | The code was accepted |
+
+### Side effects
+
+The ticket moves to its verified state and stores a verification proof. The account password is still unchanged.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:password_change:verify` bucket.
+
+## Complete password change
+
+
+
+Consumes a verified ticket and replaces the account password. Returns a [password change completion](#password-change-completion-object) object on success. Emits an [Auth Session Change](/gateway/events/#auth-session-change) Gateway event with the replacement token.
+
+No sudo verification applies. A ticket outside its verified state or a proof that does not match fails with `INVALID_OR_EXPIRED_TICKET` or `INVALID_PROOF_TOKEN`. A replacement password in the public breached-password corpus is rejected with `PASSWORD_IS_TOO_COMMON`. The ticket is marked completed once the password is written, so a retry fails with `TICKET_ALREADY_COMPLETED`.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start password change](#start-password-change) (1-256 characters) |
+| verification_proof | string | The proof issued by [Verify password change code](#verify-password-change-code) (1-256 characters) |
+| new_password | string | The password to write to the account (8-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [password change completion](#password-change-completion-object) object | The password was replaced |
+
+:::danger[Every existing session is destroyed]
+Completing a password change deletes every authentication session for the account, including the one that authenticated the request, and issues the single replacement this operation returns. Switch to the returned `token` immediately.
+:::
+
+### Side effects
+
+The password and its change timestamp are replaced and the ticket is marked completed. Every outstanding password reset token is deleted. Fluxer deletes and terminates every other authentication session on the Gateway first, then emits [Auth Session Change](/gateway/events/#auth-session-change), then deletes and terminates the caller's own session. The returned replacement is then the account's only session. OAuth2 access tokens and refresh tokens are not revoked.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:password_change:complete` bucket.
+
diff --git a/fluxer_docs/src/content/docs/http-api/users/gifts.mdx b/fluxer_docs/src/content/docs/http-api/users/gifts.mdx
new file mode 100644
index 000000000..86c50b7a9
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/gifts.mdx
@@ -0,0 +1,98 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Gift inventory
+description: Premium gift codes the current account created, and their redemption state.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The gift inventory lists the premium gift codes the current account created, with the redemption state of each. The [Gifts resource](/http-api/gifts/) has public gift lookup and redemption, and the [Billing resource](/http-api/billing/) has gift purchase.
+
+A self-hosted deployment answers this route with 404 `NOT_FOUND`, as [deployment availability](/http-api/deployment-availability/) describes.
+
+A code enters this inventory only through a completed gift checkout with the payment provider. The Admin API records a code it issues against the system account, so that code never appears in any account's inventory.
+
+:::note[A received gift never appears in the inventory]
+Only the account that created a code sees it here.
+:::
+
+:::note[Listing gifts does not clear the unread marker]
+A newly issued code raises `has_unread_gift_inventory` on the [user object](/http-api/users/#user-object). A client clears it by sending `has_unread_gift_inventory` as `false` to [Modify current user](/http-api/users/current-user/#modify-current-user).
+:::
+
+## Gift inventory object
+
+One gift code created by the current account, with its redemption state. A completed checkout yields at most one code even when the payment provider retries its notification.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | The code a recipient presents to [Redeem gift](/http-api/gifts/#redeem-gift) |
+| duration_type2 | string | The [gift duration unit](/http-api/gifts/#gift-duration-units) that `duration_quantity` is measured in |
+| duration_quantity3 | integer | The number of `duration_type` units the code grants |
+| created_at4 | ISO8601 timestamp | The time the code was created |
+| created_by5 | [partial user](/http-api/users/#partial-user-object) object | The account that created the code |
+| redeemed_at?6 | ?ISO8601 timestamp | The time the code was redeemed, or null while it is unredeemed |
+| redeemed_by?67 | ?[partial user](/http-api/users/#partial-user-object) object | The account that redeemed the code, or null while it is unredeemed |
+
+1 Exactly 32 characters drawn from the uppercase letters, the lowercase letters, and the digits, and unique across every code ever issued
+
+2 One of `days`, `weeks`, `months`, or `years`. A purchased code has only `months` or `years`, because a term dividing evenly into years is normalised to years
+
+3 A purchased code always has `1`, because the only gift products are one month and one year. The value `0` denotes a lifetime grant, and no current operation issues one
+
+4 A code issued while repairing a lost checkout notification has the checkout time, so the value can predate the moment the code appeared
+
+5 Always the current account
+
+6 Declared optional by the response schema and always emitted, so an unredeemed code has an explicit null in each
+
+7 Fluxer substitutes a placeholder partial named `DeletedUser` for a redeemer whose account no longer resolves, so null here always means the code is unredeemed
+
+The object adds the creation time and the redemption identity to what the public [gift object](/http-api/gifts/#gift-object) reports, and has no `redeemed` boolean.
+
+Redeeming a lifetime-quantity code converts the account to lifetime premium and cancels any active recurring subscription.
+
+:::danger[A code is a bearer credential]
+Fluxer records no recipient at issue time, so [redemption](/http-api/gifts/#redeem-gift) binds a code to whichever eligible account presents it first. The inventory returns the full code, and no operation unredeems it. Treat every unredeemed code as a secret.
+:::
+
+### Example
+
+```json
+{
+ "code": "aB3xQ7kR2mW9tL5vN8pY1cD4fH6jS0zE",
+ "duration_type": "months",
+ "duration_quantity": 1,
+ "created_at": "2026-02-11T12:04:33.000Z",
+ "created_by": {"id": "1501314428688998182", "username": "aria", "discriminator": "0042"},
+ "redeemed_at": null,
+ "redeemed_by": null
+}
+```
+
+## List current user gifts
+
+
+
+Lists the gift codes the current account created, newest first. Returns an array of [gift inventory](#gift-inventory-object) objects on success.
+
+The listing is not paginated, so it accepts no query parameter and returns no cursor.
+
+An unredeemed code is listed indefinitely. A redeemed code is listed only while its redemption is less than seven days old, so an account whose codes were all redeemed longer ago receives an empty array.
+
+A revoked code is omitted at every age. Fluxer revokes a code when a dispute or refund reaches the purchase while the code is still unredeemed, and no route reports the reversal to the buyer. A code already redeemed when the reversal arrived is not revoked, so it stays listed under the seven-day rule while Fluxer recomputes the redeemer's entitlement from their remaining redeemed gifts.
+
+A redeemed code that has dropped out of the listing still exists, still resolves through the [Gifts resource](/http-api/gifts/), and is still permanently spent. A revoked code stops resolving through that resource with 404 `UNKNOWN_GIFT_CODE`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[gift inventory](#gift-inventory-object) object] | The inventory was returned |
+| 404 | [error response](/http-api/#error-response) | The deployment is self-hosted and the request returns `NOT_FOUND` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `gifts:list` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/mfa.mdx b/fluxer_docs/src/content/docs/http-api/users/mfa.mdx
new file mode 100644
index 000000000..056f8951f
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/mfa.mdx
@@ -0,0 +1,902 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Multi-factor authentication
+description: TOTP, backup codes, WebAuthn credentials, and sudo mode.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Multi-factor authentication asks for a second proof of identity after the account password. Fluxer accepts a TOTP authenticator app and WebAuthn credentials such as a passkey. A one-use backup code stands in for a TOTP code, and [sudo mode](#sudo-mode) reuses these factors to guard every security-sensitive operation across the API.
+
+Every route here requires a non-bot user session. Fluxer rejects an account in suspicious activity state with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`, including on the two sudo routes.
+
+:::caution[A challenge is one-use and context-bound]
+Fluxer consumes a backup code, a WebAuthn registration challenge, and a WebAuthn sudo challenge on first use. A challenge is bound to the issuing user and operation context and expires after five minutes, so a registration challenge cannot be redeemed as a sudo assertion.
+:::
+
+## TOTP parameters
+
+Fluxer validates a TOTP code as a six-digit HMAC-SHA-1 one-time password over a 30-second time step counted from the Unix epoch. It accepts one time step of clock skew on either side. A submitted code is exactly six decimal digits, and Fluxer rejects any other value. The shared secret is Base32, and Fluxer ignores whitespace, hyphens, and trailing padding when it decodes one.
+
+The client generates the secret, presents it to the user, and submits it with a code derived from it when calling [Enable TOTP MFA](#enable-totp-mfa). Fluxer never generates or returns a TOTP secret.
+
+:::caution[A TOTP code can pass twice]
+Fluxer rejects a second presentation of an accepted code for 30 seconds. The skew keeps a code valid up to 90 seconds, so it passes again once those 30 seconds lapse. Wait for the next time step.
+:::
+
+## Backup code contract
+
+Regenerating backup codes deletes the stored set and then issues exactly 10 replacements, so any code the user had already saved stops working.
+
+Enabling TOTP issues 10 codes without deleting anything first. An account that already generated a set through [List MFA backup codes](#list-mfa-backup-codes) while holding no TOTP secret keeps those codes alongside the 10 the enable response returns. Every one stays redeemable. Disabling TOTP deletes every backup code without issuing replacements.
+
+[List MFA backup codes](#list-mfa-backup-codes) reads the current set back at any time and reports which codes are already consumed. Consuming a code is irreversible. An account that cannot prove [sudo mode](#sudo-mode) reads the same set through the [backup codes challenge](#backup-codes-challenge).
+
+A backup code has exactly three entry points: the `mfa_code` field of the [sudo verification object](#sudo-verification-object) with `mfa_method` set to `totp`, the `code` field of [Disable TOTP MFA](#disable-totp-mfa), and the `code` field of [complete login with TOTP](/http-api/authentication/#complete-login-with-totp). Every other code field rejects it. All three accept a current authenticator code or an unconsumed backup code. [Enable TOTP MFA](#enable-totp-mfa) validates only against the secret being enrolled.
+
+Every entry point requires the account to hold a TOTP secret. An account whose only authenticator is a WebAuthn credential can still regenerate backup codes, but holds no secret, so none of the three entry points accepts one.
+
+:::caution[An uppercase backup code with spaces still matches]
+Fluxer lowercases the submitted value and each stored code, then removes every character outside `a-z` and `0-9`. `abcdefgh` and `AbCd EfGh` both match the issued code `abcd-efgh`.
+:::
+
+:::danger[Authenticator secrets are credentials]
+Keep the TOTP secret and every backup code out of logs, analytics, URLs, and other surfaces visible to third parties. A regenerated set replaces the previous one immediately, so let the user store the new set before discarding the response.
+:::
+
+## Backup codes challenge
+
+An account that lost its saved backup codes reads the set back through an emailed challenge. [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) opens a ticket and emails a code. [Verify MFA backup codes challenge code](#verify-mfa-backup-codes-challenge-code) exchanges that code for the current set and a proof. [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) then presents the ticket and the proof to replace the set. [Resend MFA backup codes challenge code](#resend-mfa-backup-codes-challenge-code) sends a fresh code at any point before verification.
+
+[Sudo mode](#sudo-mode) applies to none of the four, so a user who no longer has the authenticator app still reaches the codes through the email address. The account needs a verified email address and TOTP enabled. Only [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) checks the address, and [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) checks TOTP a second time.
+
+A ticket is a version 4 UUID and lives 30 minutes from its last write. Starting the challenge, resending the code, and verifying the code each write the ticket. Regeneration reads it and writes nothing, so the window runs from the verification that preceded it. A ticket Fluxer no longer holds, or one opened by another account, fails with `INVALID_OR_EXPIRED_TICKET`.
+
+A verification code is eight characters drawn from the uppercase Latin alphabet and the decimal digits, written as two four-character groups separated by a hyphen. A code lives 10 minutes from the moment it is sent, and each send replaces the code the previous send issued.
+
+:::note[A lowercase code with no hyphen still matches]
+Fluxer trims the submitted value, uppercases it, and removes every hyphen before it compares. A wrong value fails with `INVALID_VERIFICATION_CODE` on the path `code`.
+:::
+
+Verification issues a proof, a version 4 UUID, and clears the code from the ticket. Fluxer compares a submitted proof in constant time and rejects a mismatch with `INVALID_PROOF_TOKEN`. A ticket holding no proof at all fails with `INVALID_OR_EXPIRED_TICKET` on the path `verification_proof`.
+
+Every ticket, code, and proof failure named here arrives as HTTP 400 whose top-level code is `INVALID_FORM_BODY`. The named value is the `code` of one [validation error](/http-api/#validation-error-object) entry, and that entry's `path` is the field it belongs to.
+
+Each operation passes through its own route bucket and, separately, through one of the controls below. Exhausting either produces HTTP 429 even when the other has room.
+
+| Control | Allowance | Operation |
+| --- | --- | --- |
+| Challenge start | 3 sends per 15 minutes for each account | [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) |
+| Challenge resend | 3 sends per 15 minutes for each account | [Resend MFA backup codes challenge code](#resend-mfa-backup-codes-challenge-code) |
+| Code verification | 5 attempts per 15 minutes for each ticket | [Verify MFA backup codes challenge code](#verify-mfa-backup-codes-challenge-code) |
+| Regeneration | 5 attempts per 15 minutes for each ticket | [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) |
+
+Fluxer refuses a resend for 30 seconds after the previous send on the same ticket, and that refusal is HTTP 429 with a `Retry-After` computed from the exact moment the next send becomes available. It is independent of the route bucket and of the two 15-minute send controls.
+
+## Sudo mode
+
+Sudo mode is a short-lived proof that the human in front of the session is still the account holder. An operation that requires it accepts a valid `X-Fluxer-Sudo-Mode-JWT` request header or the [sudo verification object](#sudo-verification-object) fields inside the JSON body.
+
+The sudo token is an HS256 JSON Web Token with a lifetime of exactly five minutes. Its subject is the account [snowflake](/snowflakes/), and verification rejects a token whose subject is any other account. The token is opaque, and it is not bound to the session that obtained it. Fluxer generates one only when the account has at least one MFA authenticator and the request proved identity with MFA.
+
+:::caution[The echoed header repeats the request value]
+When Fluxer generates no fresh token, it echoes the request's `X-Fluxer-Sudo-Mode-JWT` value back unchanged and never re-signs it, so the window never slides. Fluxer echoes the value even when it fails verification.
+:::
+
+The accepted proof depends on the authenticators the account has configured.
+
+| Account state | Accepted proof |
+| --- | --- |
+| No authenticator | `password` |
+| Any authenticator | `mfa_method` with its matching material, because `password` is no longer accepted |
+| Neither an authenticator nor a password credential | Nothing, and eligible sudo operations pass |
+
+The `totp` method reads `mfa_code` as a current authenticator code or an unconsumed backup code and requires the account to hold a TOTP secret. The `webauthn` method reads `webauthn_response` and `webauthn_challenge` together and requires a registered credential.
+
+A request that has no accepted proof fails with 403 `SUDO_MODE_REQUIRED`. Its body has the [sudo mode methods object](#sudo-mode-methods-object) members, and a client can present the correct challenge. A proof that is present but wrong fails instead with 400 `INVALID_FORM_BODY` and a [validation error](/http-api/#validation-error-object) entry. A mismatched password produces path `password` with code `INVALID_PASSWORD`. A rejected TOTP code, backup code, or WebAuthn assertion produces path `mfa_code` with code `INVALID_MFA_CODE`, so a client cannot tell which of the three was rejected.
+
+The `totp` method also consumes a per-account allowance of 10 multi-factor attempts in 15 minutes, shared by every sudo-gated operation on every resource. Fluxer charges the allowance before it checks the code, so a wrong code and a correct code both draw on it. A correct code resets the counter to zero. While it is exhausted, a correct code returns the same `INVALID_MFA_CODE` entry as a wrong one. The `webauthn` method draws on no allowance. The login MFA allowances on [HTTP authentication](/http-api/authentication/) are counted separately.
+
+Fluxer returns an issued or echoed token in the `X-Fluxer-Sudo-Mode-JWT` response header. A client retains it and presents it through the same header on every later sudo-gated operation.
+
+## Sudo mode methods object
+
+In a `SUDO_MODE_REQUIRED` error response, `has_mfa` and `methods` are at the top level of the [error response](/http-api/#error-response) object.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| has_mfa | boolean | Whether the account has the TOTP or the WebAuthn authenticator type |
+| methods | [sudo mode method availability](#sudo-mode-method-availability-object) object | Authenticators the account can present |
+
+## Sudo mode method availability object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| totp1 | boolean | Whether the account has a TOTP secret and the TOTP authenticator type |
+| webauthn | boolean | Whether the account has the WebAuthn authenticator type |
+
+1 Both conditions are required, so an account holding a stored secret without the [authenticator type](/http-api/users/#authenticator-types) reports `false`
+
+## Sudo verification object
+
+Operations that require sudo mode merge these fields into their own JSON body. Every field is optional at the boundary, because the accepted combination depends on the account state described in [sudo mode](#sudo-mode).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| password?1 | string | Account password (8-256 characters) |
+| mfa_method?2 | string | MFA method, either `totp` or `webauthn` |
+| mfa_code?3 | string | Authenticator code or unconsumed backup code (1-32 characters) |
+| webauthn_response?4 | [WebAuthn assertion](#webauthn-assertion-object) object | Assertion produced for the supplied challenge |
+| webauthn_challenge?4 | string | Challenge returned by [create sudo WebAuthn authentication options](#create-sudo-webauthn-authentication-options) (1-256 characters) |
+
+1 Considered only while the account has no MFA authenticator, and ignored once TOTP or a WebAuthn credential exists
+
+2 Required when the account has any MFA authenticator, unless a valid sudo token is already present
+
+3 Required when `mfa_method` is `totp`, with a current authenticator code or an unconsumed backup code
+
+4 Both fields are required together when `mfa_method` is `webauthn`
+
+## MFA backup codes object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| backup_codes | array[[MFA backup code](#mfa-backup-code-object) object] | The account's current backup codes |
+
+### Example
+
+```json
+{
+ "backup_codes": [
+ {"code": "a3f2-9kd7", "consumed": false},
+ {"code": "b81c-4nq0", "consumed": true}
+ ]
+}
+```
+
+## MFA backup code object
+
+A backup code is two four-character groups separated by a hyphen. Each group is drawn from the lowercase Latin alphabet and the decimal digits.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code | string | One-use backup code |
+| consumed | boolean | Whether the code has already been consumed |
+
+## MFA backup codes challenge object
+
+The state of a freshly created backup codes challenge ticket.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier every later step of this flow sends |
+| code_expires_at | ISO8601 timestamp | The moment the emailed code expires, 10 minutes after it was sent |
+| resend_available_at | ISO8601 timestamp | The earliest moment the code can be resent, 30 seconds after the last send |
+
+## MFA backup codes verification object
+
+The backup codes and the proof a verified challenge hands back.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| backup_codes | array[[MFA backup code](#mfa-backup-code-object) object] | The account's current backup codes |
+| verification_proof1 | string | The proof [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) consumes |
+
+1 A resend clears the stored proof and the next verification issues a different value
+
+## WebAuthn credential object
+
+An account holds at most 10 WebAuthn credentials. Fluxer checks the limit twice, once before [create WebAuthn registration options](#create-webauthn-registration-options) issues a challenge and again before [Register WebAuthn credential](#register-webauthn-credential) stores the result. It refuses parallel enrolment flows at whichever step first sees the tenth credential.
+
+Fluxer verifies every assertion against the relying party identifier and the allowed origins configured for the instance. The credential's signature counter strictly increases, unless both the stored and reported counters are zero, which is how an authenticator without a counter appears.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Base64url credential ID |
+| name | string | User-assigned credential name (1-100 characters) |
+| created_at | ISO8601 timestamp | The time [Register WebAuthn credential](#register-webauthn-credential) stored the credential |
+| last_used_at1 | ?ISO8601 timestamp | Most recent successful authentication |
+
+1 Null until the credential completes a login, MFA, or sudo assertion for the first time
+
+The credential's public key, signature counter, and reported transports are never returned.
+
+## WebAuthn credential descriptor object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Base64url credential ID |
+| type | string | Credential type, always `public-key` |
+| transports?1 | array[string] | Authenticator transports from the [WebAuthn authenticator transport values](#webauthn-authenticator-transports) |
+
+1 Present only when the authenticator reported its transports during registration
+
+## WebAuthn authenticator transports
+
+| Value | Description |
+| --- | --- |
+| ble | Bluetooth Low Energy |
+| cable | Cloud-assisted Bluetooth Low Energy |
+| hybrid | Hybrid transport |
+| internal | Platform authenticator |
+| nfc | Near-field communication |
+| smart-card | Smart card |
+| usb | USB authenticator |
+
+## WebAuthn assertion object
+
+The browser WebAuthn `PublicKeyCredential` serialisation. Its field names are camelCase, because the boundary accepts the exact structure the WebAuthn client API produces.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Base64url credential ID |
+| rawId | string | Base64url raw credential ID |
+| response | [WebAuthn assertion response](#webauthn-assertion-response-object) object | Authenticator assertion response |
+| authenticatorAttachment? | string | Authenticator attachment, either `cross-platform` or `platform` |
+| clientExtensionResults | [WebAuthn client extension results](#webauthn-client-extension-results-object) object | Client extension outputs |
+| type | string | Credential type, always `public-key` |
+
+The boundary does not validate the shape of this object, so no validation entry is ever reported against `webauthn_response` itself. Fluxer forwards it to WebAuthn verification exactly as supplied, and a malformed assertion fails that verification and surfaces as the `mfa_code` entry described in [sudo mode](#sudo-mode).
+
+## WebAuthn assertion response object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| clientDataJSON | string | Base64url client data JSON |
+| authenticatorData | string | Base64url authenticator data |
+| signature | string | Base64url assertion signature |
+| userHandle?1 | string | Base64url user handle |
+
+1 Present only when the assertion came from a discoverable credential
+
+## WebAuthn registration response object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Base64url credential ID |
+| rawId | string | Base64url raw credential ID |
+| response | [WebAuthn attestation response](#webauthn-attestation-response-object) object | Authenticator attestation response |
+| authenticatorAttachment? | string | Authenticator attachment, either `cross-platform` or `platform` |
+| clientExtensionResults | [WebAuthn client extension results](#webauthn-client-extension-results-object) object | Client extension outputs |
+| type | string | Credential type, always `public-key` |
+
+The boundary does not validate this shape either. An unusable attestation returns `INVALID_WEBAUTHN_CREDENTIAL`, and no validation entry names a field of this object.
+
+## WebAuthn attestation response object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| clientDataJSON | string | Base64url client data JSON |
+| attestationObject | string | Base64url attestation object |
+| authenticatorData? | string | Base64url authenticator data |
+| transports?1 | array[string] | Authenticator transports from the [WebAuthn authenticator transport values](#webauthn-authenticator-transports) |
+| publicKeyAlgorithm? | integer | COSE public key algorithm identifier |
+| publicKey? | string | Base64url credential public key |
+
+1 Retained with the credential and returned later in `allowCredentials` and `excludeCredentials`
+
+## WebAuthn client extension results object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| appid? | boolean | Whether the AppID extension was used |
+| credProps? | [WebAuthn credential properties](#webauthn-credential-properties-object) object | Credential properties output |
+| hmacCreateSecret? | boolean | Whether the authenticator created an HMAC secret |
+
+Fluxer accepts additional extension result fields.
+
+## WebAuthn credential properties object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| rk? | boolean | Whether the created credential is discoverable |
+
+Fluxer accepts additional credential property fields.
+
+## WebAuthn authentication options object
+
+Fluxer returns this object from every operation that issues a WebAuthn authentication challenge: sudo verification here, and the two login option operations on [HTTP authentication](/http-api/authentication/).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| challenge1 | string | Base64url one-use challenge |
+| timeout2 | integer | Authenticator operation timeout in milliseconds |
+| rpId | string | Relying party identifier configured for the instance |
+| allowCredentials?3 | array[[WebAuthn credential descriptor](#webauthn-credential-descriptor-object) object] | Credentials accepted for this operation |
+| userVerification4 | string | User verification requirement, one of `discouraged`, `preferred`, or `required` |
+
+1 Expires five minutes after issue and is bound to the exact context that issued it, so a sudo challenge cannot be redeemed as a login assertion
+
+2 Every operation emits the fixed value `60000`, a standard PublicKeyCredential request option
+
+3 Sudo options list every credential currently registered to the account. The field is absent only on the discoverable login route
+
+4 Sudo options and MFA login completion request `discouraged`. Discoverable login requests and verifies `required`
+
+Fluxer emits no other PublicKeyCredential request options member, so `hints` and `extensions` never appear on this object.
+
+## WebAuthn registration options object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| rp | [WebAuthn relying party](#webauthn-relying-party-object) object | Relying party identity |
+| user | [WebAuthn registration user](#webauthn-registration-user-object) object | Account identity bound to the credential |
+| challenge | string | Base64url one-use challenge |
+| pubKeyCredParams1 | array[[WebAuthn public key parameter](#webauthn-public-key-parameter-object) object] | Accepted public key algorithms |
+| timeout2 | integer | Authenticator operation timeout in milliseconds |
+| excludeCredentials3 | array[[WebAuthn credential descriptor](#webauthn-credential-descriptor-object) object] | Existing credentials that cannot be registered again |
+| authenticatorSelection4 | [WebAuthn authenticator selection](#webauthn-authenticator-selection-object) object | Authenticator selection requirements |
+| attestation5 | string | Attestation conveyance, always `none` |
+| extensions6 | [WebAuthn client extension inputs](#webauthn-client-extension-inputs-object) object | Requested client extensions |
+| hints7 | array[string] | Authenticator hints |
+
+1 Always the three COSE identifiers `-8`, `-7`, and `-257`, in that order, standing for EdDSA, ECDSA with SHA-256, and RSASSA-PKCS1-v1_5 with SHA-256
+
+2 Every registration emits the fixed value `60000`
+
+3 Holds every credential already registered to the account, so an authenticator cannot enrol the same credential twice. It is an empty array when the account holds none
+
+4 Fluxer requests a preferred discoverable credential and preferred user verification, and requires neither, so `requireResidentKey` is `false`
+
+5 Fluxer requests `none`, so no attestation statement is retained
+
+6 Always present with `credProps` set to `true`, the only extension Fluxer requests
+
+7 Always an empty array
+
+## WebAuthn relying party object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Relying party display name configured for the instance |
+| id | string | Relying party identifier configured for the instance |
+
+## WebAuthn registration user object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | WebAuthn user handle, the decimal account [snowflake](/snowflakes/) encoded as base64url |
+| name | string | Account username |
+| displayName1 | string | Account display label |
+
+1 Fluxer supplies the account username in both `name` and `displayName`
+
+## WebAuthn public key parameter object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| alg | integer | COSE algorithm identifier |
+| type | string | Credential type, always `public-key` |
+
+## WebAuthn authenticator selection object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| authenticatorAttachment? | string | Authenticator attachment, either `cross-platform` or `platform` |
+| requireResidentKey? | boolean | Whether a discoverable credential is required |
+| residentKey? | string | Discoverable credential preference, one of `discouraged`, `preferred`, or `required` |
+| userVerification? | string | User verification preference, one of `discouraged`, `preferred`, or `required` |
+
+## WebAuthn client extension inputs object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| appid? | string | AppID extension value |
+| credProps?1 | boolean | Whether credential properties are requested |
+| hmacCreateSecret? | boolean | Whether HMAC secret creation is requested |
+| minPinLength? | boolean | Whether minimum PIN length is requested |
+
+1 The only member Fluxer ever sets, always `true` on [WebAuthn registration options](#webauthn-registration-options-object)
+
+## Sudo MFA methods object
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| totp | boolean | Whether the account has a TOTP secret and the TOTP authenticator type, as in the [sudo mode method availability](#sudo-mode-method-availability-object) object |
+| webauthn | boolean | Whether the account has the WebAuthn authenticator type |
+| has_mfa | boolean | Whether the account has the TOTP or the WebAuthn authenticator type, as in the [sudo mode methods](#sudo-mode-methods-object) object |
+
+## Enable TOTP MFA
+
+
+
+Enables TOTP for the current account and returns an [MFA backup codes](#mfa-backup-codes-object) object holding 10 new codes. [Sudo mode](#sudo-mode) is required. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+Fluxer checks sudo mode first, so an account that already holds a WebAuthn credential proves it with that credential.
+
+### Limitations
+
+- The account needs a verified email address and is otherwise refused with 403 `MFA_EMAIL_VERIFICATION_REQUIRED`.
+- An account that already has TOTP enabled is refused with 400 `TWO_FA_NOT_ENABLED`.
+
+### JSON body
+
+The body extends the [sudo verification object](#sudo-verification-object) with the fields below, and an existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| secret1 | string | Base32 TOTP secret (1-256 characters) |
+| code2 | string | Current TOTP code generated from the submitted secret (1-32 characters) |
+
+1 Generated by the client and stored verbatim on the account
+
+2 A backup code is rejected here
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [MFA backup codes](#mfa-backup-codes-object) object | TOTP was enabled and 10 backup codes were issued |
+| 400 | [error response](/http-api/#error-response) | The TOTP code did not match the submitted secret, returning `INVALID_CODE` on the path `code`, or TOTP is already enabled and the request returns `TWO_FA_NOT_ENABLED` |
+| 403 | [error response](/http-api/#error-response) | The account email is unverified and the request returns `MFA_EMAIL_VERIFICATION_REQUIRED`, or sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+
+### Side effects
+
+TOTP is enabled and 10 backup codes are issued. Any backup code the account already held stays in place, so the response has only the 10 new codes. The resulting authenticator types are also applied to every bot account owned by the user. [User Update](/gateway/events/#user-update) reaches the user's sessions and each owned bot whose authenticator types changed.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:mfa:totp:enable` bucket.
+
+## Disable TOTP MFA
+
+
+
+Disables TOTP for the current account and returns 204 with an empty body. [Sudo mode](#sudo-mode) is required in addition to this route's `code` field. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+The account needs TOTP already enabled and is otherwise refused with 400 `TWO_FACTOR_REQUIRED`. A verified email is not required, so an account whose address later became unverified can still remove its authenticator.
+
+### JSON body
+
+The body extends the [sudo verification object](#sudo-verification-object) with the field below, and an existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | Current TOTP code or an unconsumed backup code (1-32 characters) |
+
+1 A wrong value returns `INVALID_CODE` on the path `code`
+
+:::caution[Backup codes do not survive TOTP removal]
+Disabling TOTP invalidates every backup code on the account, including the code that proved this request. An account that keeps a WebAuthn credential is left with none, and needs [List MFA backup codes](#list-mfa-backup-codes) with `regenerate` set to true to issue a new set.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | TOTP was disabled |
+| 400 | [error response](/http-api/#error-response) | The code did not match, or TOTP is not enabled and the request returns `TWO_FACTOR_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | Sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+
+### Side effects
+
+TOTP is disabled and every backup code stops working. The TOTP authenticator type is removed, along with the unassigned legacy authenticator value `1` if the account still had it. The resulting authenticator types are also applied to every bot account owned by the user. [User Update](/gateway/events/#user-update) reaches the user's sessions and each owned bot whose authenticator types changed.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:mfa:totp:disable` bucket.
+
+## List MFA backup codes
+
+
+
+Returns an [MFA backup codes](#mfa-backup-codes-object) object, replacing the set first when `regenerate` is true. [Sudo mode](#sudo-mode) is required.
+
+Neither a verified email nor an authenticator is required, so an account with no MFA at all can prove sudo mode with its password and use this operation.
+
+A backup code presented as `mfa_code` to satisfy sudo mode is consumed. With `regenerate` false it comes back with `consumed` set to true. With `regenerate` true it is deleted with the rest of the previous set and does not appear.
+
+:::caution[Regeneration replaces the previous set immediately]
+Every code from the previous set is invalid as soon as an operation with `regenerate` set to true returns. The deletion and the insertion are separate writes, so the account holds no backup code at all between them.
+:::
+
+### JSON body
+
+The body extends the [sudo verification object](#sudo-verification-object) with the field below, and an existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| regenerate1 | boolean | Whether to discard the current set and issue 10 replacements |
+
+1 The field is required. Passing `false` reads the current set without changing it
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [MFA backup codes](#mfa-backup-codes-object) object | The current or replacement codes were returned |
+| 403 | [error response](/http-api/#error-response) | Sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:mfa:backup_codes` bucket.
+
+## Start MFA backup codes challenge
+
+
+
+Creates a backup codes challenge ticket and sends a verification code to the account email address. Returns an [MFA backup codes challenge](#mfa-backup-codes-challenge-object) object on success.
+
+Sudo mode is not required, and the route reads no sudo verification field.
+
+The send consumes the challenge start control.
+
+### Limitations
+
+- The account needs a verified email address and is otherwise refused with 403 `MFA_EMAIL_VERIFICATION_REQUIRED`.
+- An account holding no email address is refused with 400 `INVALID_FORM_BODY` and the validation code `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS` on the path `email`.
+- The account needs TOTP enabled and is otherwise refused with 400 `TWO_FACTOR_REQUIRED`.
+
+### JSON body
+
+The body can be omitted, and any supplied body is an object with no fields. Fluxer strips unknown keys before it handles the request.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [MFA backup codes challenge](#mfa-backup-codes-challenge-object) object | The ticket was created and a code was sent |
+| 400 | [error response](/http-api/#error-response) | TOTP is not enabled and the request returns `TWO_FACTOR_REQUIRED`, or the account holds no address |
+| 403 | [error response](/http-api/#error-response) | The account email is unverified and the request returns `MFA_EMAIL_VERIFICATION_REQUIRED` |
+
+### Side effects
+
+Fluxer stores a ticket holding the code and its expiry, and one verification email goes to the account address. No account field changes and no backup code is issued.
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:mfa:backup_codes_challenge:start` bucket.
+
+## Resend MFA backup codes challenge code
+
+
+
+Sends a fresh verification code for an active backup codes challenge ticket. Returns 204 with an empty body.
+
+The route reads the ticket and the account address only. An account holding no email address is refused with 400 `INVALID_FORM_BODY` and the validation code `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS` on the path `email`.
+
+The send consumes the challenge resend control, and the ticket enforces its own 30-second cooldown.
+
+:::caution[A resend undoes a completed verification]
+Fluxer clears the ticket's stored proof on every resend. A client that already holds a proof needs [Verify MFA backup codes challenge code](#verify-mfa-backup-codes-challenge-code) again before [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) accepts it.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | A replacement code was sent |
+| 400 | [error response](/http-api/#error-response) | The ticket is unknown and the request returns `INVALID_OR_EXPIRED_TICKET` |
+
+### Side effects
+
+Fluxer replaces the ticket's code, send time, and code expiry, which invalidates the previous code. The stored proof is cleared and one verification email goes to the account address.
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:mfa:backup_codes_challenge:resend` bucket.
+
+## Verify MFA backup codes challenge code
+
+
+
+Verifies the emailed code and returns an [MFA backup codes verification](#mfa-backup-codes-verification-object) object holding the current codes and a proof.
+
+Verification consumes the code. A ticket holding no code fails with `VERIFICATION_CODE_NOT_ISSUED`, which is what a second verification of the same ticket returns. A code past its 10-minute lifetime fails with `VERIFICATION_CODE_EXPIRED`. A mismatch fails with `INVALID_VERIFICATION_CODE`.
+
+Fluxer charges the per-ticket verification allowance before it reads the code, so a wrong code and a correct code both draw on it.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) (1-256 characters) |
+| code | string | The code sent to the account address (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [MFA backup codes verification](#mfa-backup-codes-verification-object) object | The code was accepted |
+| 400 | [error response](/http-api/#error-response) | The ticket returns `INVALID_OR_EXPIRED_TICKET`, or the code was not issued, has expired, or did not match |
+
+### Side effects
+
+The ticket stores a fresh proof and its code is cleared. The response holds every backup code on the account, including the consumed ones. No backup code is issued, consumed, or deleted.
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:mfa:backup_codes_challenge:verify` bucket.
+
+## Regenerate MFA backup codes
+
+
+
+Replaces the account's backup codes with 10 new ones and returns an [MFA backup codes](#mfa-backup-codes-object) object. Sudo mode is not required.
+
+The ticket and its proof are the only authorisation. The account needs TOTP enabled and is otherwise refused with 400 `TWO_FACTOR_REQUIRED`.
+
+A ticket holding no proof returns `INVALID_OR_EXPIRED_TICKET` on the path `verification_proof`, and a proof that does not match returns `INVALID_PROOF_TOKEN`.
+
+:::caution[Regeneration leaves the ticket usable]
+The operation reads the proof and writes nothing back to the ticket. The same pair replaces the set again on every call until the ticket expires, 30 minutes after its verification.
+:::
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ticket | string | The identifier returned by [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) (1-256 characters) |
+| verification_proof | string | The proof issued by [Verify MFA backup codes challenge code](#verify-mfa-backup-codes-challenge-code) (1-256 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [MFA backup codes](#mfa-backup-codes-object) object | The previous set was deleted and 10 replacements were issued |
+| 400 | [error response](/http-api/#error-response) | TOTP is not enabled and the request returns `TWO_FACTOR_REQUIRED`, or the ticket or the proof was rejected |
+
+### Side effects
+
+Every code from the previous set is deleted and 10 replacements are written. The deletion and the insertion are separate writes, so the account holds no backup code at all between them. The authenticator types are unchanged and Fluxer emits no Gateway event.
+
+### Rate limit
+
+6 requests per minute for each authenticated user, on the `user:mfa:backup_codes_challenge:regenerate` bucket.
+
+## List WebAuthn credentials
+
+
+
+Returns an array of [WebAuthn credential](#webauthn-credential-object) objects registered to the current account, or an empty array when none exist. Sudo mode is not required.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[WebAuthn credential](#webauthn-credential-object) object] | Credentials were returned |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `mfa:webauthn:list` bucket.
+
+## Create WebAuthn registration options
+
+
+
+Creates a one-use registration challenge and returns a [WebAuthn registration options](#webauthn-registration-options-object) object. [Sudo mode](#sudo-mode) is required.
+
+This operation sets no `X-Fluxer-Sudo-Mode-JWT` response header.
+
+### Limitations
+
+- The account needs a verified email address.
+- An account already holding 10 credentials receives 400 `WEBAUTHN_CREDENTIAL_LIMIT_REACHED` and no challenge.
+
+### JSON body
+
+The body is a [sudo verification object](#sudo-verification-object). An existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [WebAuthn registration options](#webauthn-registration-options-object) object | Challenge and registration options were issued |
+| 400 | [error response](/http-api/#error-response) | 10 credentials are already registered and the request returns `WEBAUTHN_CREDENTIAL_LIMIT_REACHED` |
+| 403 | [error response](/http-api/#error-response) | The account email is unverified and the request returns `MFA_EMAIL_VERIFICATION_REQUIRED`, or sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+
+### Side effects
+
+Fluxer issues a registration challenge for the current user. It expires after five minutes and can be redeemed once.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `mfa:webauthn:registration_options` bucket.
+
+## Register WebAuthn credential
+
+
+
+Consumes a registration challenge, adds one WebAuthn credential to the current account, and returns 204 with an empty body. Emits a [WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) Gateway event, and a [User Update](/gateway/events/#user-update) event when this is the account's first WebAuthn credential.
+
+The account needs a verified email and fewer than 10 registered credentials. This route accepts no sudo verification fields of its own. Fluxer verifies the attestation against the instance relying party identifier and its allowed origins, and does not require user verification.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| response | [WebAuthn registration response](#webauthn-registration-response-object) object | Authenticator response for the registration challenge |
+| challenge | string | One-use registration challenge (1-1024 characters) |
+| name | string | User-assigned credential name (1-100 characters) |
+
+:::caution[A failed registration cannot reuse its challenge]
+Fluxer consumes the registration challenge before it re-checks the credential limit and before it verifies the attestation. Request fresh registration options after any failure.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Credential was registered |
+| 400 | [error response](/http-api/#error-response) | The challenge or attestation failed and the request returns `INVALID_WEBAUTHN_CREDENTIAL`, the public key returns `INVALID_WEBAUTHN_PUBLIC_KEY_FORMAT`, the signature counter returns `INVALID_WEBAUTHN_CREDENTIAL_COUNTER`, or the limit returns `WEBAUTHN_CREDENTIAL_LIMIT_REACHED` |
+| 403 | [error response](/http-api/#error-response) | The account email is unverified and the request returns `MFA_EMAIL_VERIFICATION_REQUIRED` |
+
+### Side effects
+
+The credential is added to the account. When it is the account's first WebAuthn credential, the WebAuthn authenticator type is also applied to every bot account owned by the user.
+
+[WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) reaches the user's sessions with the complete current credential summaries. When the authenticator types changed, [User Update](/gateway/events/#user-update) also reaches the user and each affected owned bot.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `mfa:webauthn:register` bucket.
+
+## Rename WebAuthn credential
+
+
+
+Changes the user-assigned name of one WebAuthn credential owned by the current account and returns 204 with an empty body. [Sudo mode](#sudo-mode) is required. Emits a [WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) Gateway event.
+
+A verified email is not required.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| credential_id | string | Base64url credential ID registered to the current account (1-2048 characters) |
+
+### JSON body
+
+The body extends the [sudo verification object](#sudo-verification-object) with the field below, and an existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Replacement credential name (1-100 characters) |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Credential was renamed |
+| 403 | [error response](/http-api/#error-response) | Sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | The credential is not registered to the current account, returning `UNKNOWN_WEBAUTHN_CREDENTIAL` |
+
+### Side effects
+
+The credential name is replaced and no authenticator type changes. [WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) reaches the current user's sessions with the complete current credential summaries.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `mfa:webauthn:update` bucket.
+
+## Delete WebAuthn credential
+
+
+
+Deletes one WebAuthn credential owned by the current account and returns 204 with an empty body. [Sudo mode](#sudo-mode) is required. Emits a [WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) Gateway event, and a [User Update](/gateway/events/#user-update) event when this was the account's final WebAuthn credential.
+
+A verified email is not required.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| credential_id | string | Base64url credential ID registered to the current account (1-2048 characters) |
+
+### JSON body
+
+The body is a [sudo verification object](#sudo-verification-object). An existing sudo proof travels in the `X-Fluxer-Sudo-Mode-JWT` request header.
+
+:::caution[Removing the final credential removes an authenticator]
+Deleting the account's last WebAuthn credential removes the WebAuthn authenticator type. Without TOTP the account then has no MFA authenticator, and sudo mode falls back to the account password.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Credential was deleted |
+| 403 | [error response](/http-api/#error-response) | Sudo mode was not proven and the request returns `SUDO_MODE_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | The credential is not registered to the current account, returning `UNKNOWN_WEBAUTHN_CREDENTIAL` |
+
+### Side effects
+
+The credential is deleted. When it was the final one, the WebAuthn authenticator type is also removed from every bot account owned by the user.
+
+[WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) reaches the user's sessions with the remaining credential summaries. When the authenticator types changed, [User Update](/gateway/events/#user-update) also reaches the user and each affected owned bot.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `mfa:webauthn:delete` bucket.
+
+## Get sudo MFA methods
+
+
+
+Returns a [sudo MFA methods](#sudo-mfa-methods-object) object describing which sudo challenges the current account can answer. Sudo mode is not required.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [sudo MFA methods](#sudo-mfa-methods-object) object | Available methods were returned |
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `sudo:mfa:methods` bucket.
+
+## Create sudo WebAuthn authentication options
+
+
+
+Creates a one-use sudo challenge and returns a [WebAuthn authentication options](#webauthn-authentication-options-object) object listing every credential registered to the current account.
+
+The account needs at least one registered WebAuthn credential. Sudo mode is not required to obtain the challenge.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [WebAuthn authentication options](#webauthn-authentication-options-object) object | Challenge and options were issued |
+| 400 | [error response](/http-api/#error-response) | No WebAuthn credential is registered and the request returns `NO_PASSKEYS_REGISTERED` |
+
+### Side effects
+
+Fluxer issues a sudo challenge for the current user. It expires after five minutes and can be redeemed once by submitting it as `webauthn_challenge` alongside `webauthn_response` in a [sudo verification object](#sudo-verification-object).
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `sudo:webauthn:options` bucket.
+
diff --git a/fluxer_docs/src/content/docs/http-api/users/notes.mdx b/fluxer_docs/src/content/docs/http-api/users/notes.mdx
new file mode 100644
index 000000000..3e9aa6b1b
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/notes.mdx
@@ -0,0 +1,143 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: User notes
+description: Private note text the caller stores against another account.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A note is short private text the caller stores about another account.
+
+Every route here requires a user session. A bot or OAuth2 bearer credential is refused with 403 `ACCESS_DENIED`. An account with an outstanding required action is refused with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+:::note[A note is private to its author]
+The target account never receives it in an HTTP response or [Gateway Dispatch](/gateway/events/). A note has no relationship requirement, so the caller can hold one for an account that is neither a friend nor blocked.
+:::
+
+## User note object
+
+A single note the caller holds for one target account.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| note1 | string | The note text the caller stored for the target account |
+
+1 Never null and never the empty string. A stored note is always 1 to 256 characters after normalisation, and every clearing form deletes the record
+
+## User notes record
+
+Every note the caller has stored. The record is a `map[snowflake, string]`. Each property name is the [snowflake](/snowflakes/) of one target account, and its value is that target's note text.
+
+The Gateway delivers the same map as the `notes` field of the [READY](/gateway/events/#ready-object) payload, so a connected client already holds the record before it calls the list operation.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| {target_id}1 | string | The stored note text for the account named by the property |
+
+1 A target with no note has no property, so an account with no notes receives an empty object
+
+### Example
+
+```json
+{
+ "1501314428688998182": "met at the Berlin meetup",
+ "1501314428688998183": "always posts the good links"
+}
+```
+
+## List user notes
+
+
+
+Returns the caller's complete [user notes record](#user-notes-record). The operation is not paged, so every stored note comes back in one response.
+
+A note has no creation or modification timestamp.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user notes record](#user-notes-record) | Notes were returned, possibly as an empty object |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `user:notes:read` bucket, shared with [Get user note](#get-user-note).
+
+## Get user note
+
+
+
+Returns the caller's [user note](#user-note-object) object for one target account.
+
+A target with no stored note returns 404 `UNKNOWN_USER`. The same status covers an account that exists without a note and an ID that names no account at all.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| target_id1 | snowflake | The ID of the account the note describes |
+
+1 Fluxer removes leading and trailing whitespace before it reads the value. A leading zero, a sign, a decimal point, or any other non-digit returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_SNOWFLAKE_FORMAT`, and a value above 9223372036854775807 returns `SNOWFLAKE_OUT_OF_RANGE`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user note](#user-note-object) object | The note was returned |
+| 404 | [error response](/http-api/#error-response) | No note is stored for the target ID and the request returns `UNKNOWN_USER` |
+
+### Rate limit
+
+60 requests per 10 seconds for each authenticated user, on the `user:notes:read` bucket, shared with [List user notes](#list-user-notes).
+
+## Set user note
+
+
+
+Creates, replaces, or deletes the caller's note for one target account. Returns 204 with an empty body. Emits a [User Note Update](/gateway/events/#user-note-update) Gateway event.
+
+The supplied text becomes the complete stored note. An unresolved target ID returns 404 `UNKNOWN_USER`, even when the request would only delete a note. The caller can store a note for any account that exists, including a bot account, its own account, and an account it holds no relationship with.
+
+:::caution[An omitted, null, or empty note deletes it]
+An empty string is [read as `null`](/http-api/#input-normalisation), so the three forms are indistinguishable. A value that normalises to the empty string, such as one made only of whitespace, fails validation with `STRING_LENGTH_INVALID`. There is no separate delete route.
+:::
+
+A `note` of at least 3 characters matching the instance phrase blocklist, or with a URL matching the URL blocklist, is rejected with 403 `CONTENT_BLOCKED`. That screen runs ahead of the route's own credential and body checks, so a blocked value is refused with `CONTENT_BLOCKED` even when the request would also fail one of those checks.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| target_id1 | snowflake | The ID of the account the note describes |
+
+1 Fluxer removes leading and trailing whitespace before it reads the value. A leading zero, a sign, a decimal point, or any other non-digit returns `INVALID_SNOWFLAKE_FORMAT`, and a value above 9223372036854775807 returns `SNOWFLAKE_OUT_OF_RANGE`
+
+### JSON body
+
+Fluxer reads an omitted body as an empty object and deletes the stored note.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| note?1 | ?string | The complete replacement note text, or null to delete the stored note (1-256 characters) |
+
+1 Fluxer trims the value and the bound applies to the result, so a value that trims to empty returns `STRING_LENGTH_INVALID` at the `note` path
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The note was set or deleted |
+| 403 | [error response](/http-api/#error-response) | The note is blocked by content moderation and the request returns `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | The target account does not exist and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+Supplying text creates or replaces the note, and a request that supplies none deletes it. [User Note Update](/gateway/events/#user-note-update) reaches the caller's own sessions for every accepted request, including one that supplies the current value and one that deletes an absent note. Its payload has the target ID in `id` and the note text in `note`. A deletion has the empty string.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:notes:write` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx
new file mode 100644
index 000000000..52571a39d
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx
@@ -0,0 +1,363 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Phone verification
+description: Proving control of a phone number over outbound SMS or an inbound challenge, and setting a deferred requirement aside.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+Phone verification proves that the current account controls a phone number. The account either receives a one-time code over outbound SMS, or texts an issued challenge code to a Fluxer number. Success sets the account's verified phone state, which the phone [required actions](/http-api/users/#required-actions) and the `VERY_HIGH` guild [verification level](/http-api/guilds/#verification-levels) consult.
+
+Every route here needs a non-bot user session, and each one admits a session with suspicious account state.
+
+## Eligibility
+
+[Send phone verification](#send-phone-verification) and [Verify phone code](#verify-phone-code) admit only an account that satisfies at least one of four conditions.
+
+- The account already holds a verified phone.
+- A TOTP [authenticator](/http-api/users/#authenticator-types) is enrolled on the account.
+- The stored suspicious activity bitfield is non-zero.
+- The account belongs to at least one guild whose [verification level](/http-api/guilds/#verification-levels) is `VERY_HIGH`.
+
+An account satisfying none of them is refused with 403 `PHONE_ADD_NOT_ELIGIBLE`.
+
+Both operations run the check before they examine the submitted number. A refused account receives no message and consumes no challenge. [Start inbound phone challenge](#start-inbound-phone-challenge) skips the check entirely and issues a challenge for any non-bot session.
+
+:::note[Eligibility reads the stored bitfield]
+The [required actions](/http-api/users/#required-actions) array is filtered: empty for an account with no email address, empty for a contact address holding the required-actions exemption, and stripped of requirements the account already satisfies. An account with no visible required actions can still be eligible.
+:::
+
+## Deferred phone requirement
+
+An instance can defer a new account's phone requirement instead of imposing it at registration. The [deferred phone gate](/admin-api/instance/#deferred-phone-gate-object) policy sets the window after registration in which a qualifying join makes the requirement due, and the member count above which a guild is a qualifying one. A guild with the `DISCOVERABLE` [feature](/http-api/guilds/#guild-features) qualifies at any member count.
+
+A deferred requirement is absent from the [required actions](/http-api/users/#required-actions) array and restricts nothing. An attempt to join a qualifying guild inside the window makes it due, and [Accept invite](/http-api/invites/#accept-invite) refuses that attempt with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. The account then owes `REQUIRE_VERIFIED_PHONE` or `REQUIRE_REVERIFIED_PHONE`, and every ordinary user route refuses it with the same code.
+
+[Get phone gate escape preview](#get-phone-gate-escape-preview) and [Run phone gate escape](#run-phone-gate-escape) set that requirement aside again in place of verifying a number. Both admit an account the rest of the API refuses. The escape is available only while all six of these hold.
+
+- The instance has the deferred phone gate enabled.
+- The account holds no verified phone.
+- The account owes `REQUIRE_VERIFIED_PHONE` or `REQUIRE_REVERIFIED_PHONE`.
+- The account does not owe `REQUIRE_INBOUND_PHONE_VERIFICATION`.
+- The requirement became due through a qualifying join and has not been deferred again.
+- The account has at least one visible required action.
+
+## Number representation
+
+The `phone` field is a number already in canonical E.164 form. Fluxer strips control and formatting characters, trims the value, and then matches it against `^\+[1-9]\d{1,14}$`. A value that fails returns 400 `INVALID_FORM_BODY` with one [validation error](/http-api/#validation-error-object) entry whose `path` is `phone` and whose `code` is `PHONE_NUMBER_INVALID_FORMAT`.
+
+A number whose leading characters appear on the instance's banned prefix list is refused with 400 `INVALID_PHONE_NUMBER`. The list holds a built-in set of country prefixes plus operator additions, and one entry blocks every number that starts with it.
+
+:::note[Only the verified state is exposed]
+A successful verification sets a boolean on the account. The `phone` field of the [user object](/http-api/users/#user-object) is always null, and no route returns the submitted number.
+:::
+
+:::danger[Numbers and codes are confidential]
+Keep phone numbers, verification codes, and challenge codes out of logs, analytics, URLs, and messages to unintended recipients.
+:::
+
+## Phone verification channel values
+
+| Value | Description |
+| --- | --- |
+| sms1 | Fluxer asks the SMS provider to deliver a one-time code to the submitted number |
+| inbound_challenge2 | The user texts the issued challenge code to the returned Fluxer number |
+
+1 Requesting this value is equivalent to omitting the field
+
+2 Requesting this value always yields an inbound challenge, and neither the lookup provider nor the outbound SMS provider is contacted
+
+## Provider lookup verdicts
+
+[Verify phone code](#verify-phone-code) resolves the destination through the configured lookup provider before it checks the code. [Send phone verification](#send-phone-verification) does the same before it sends a code, except on an attempt that policy routes inbound before the lookup, where no lookup runs. A lookup result is cached for 7 days and reused for later attempts against the same number. The rules below run in order, and the first match decides.
+
+| Order | Condition | Verdict |
+| --- | --- | --- |
+| 1 | The lookup could not be performed1 | Refused with `INVALID_PHONE_NUMBER` |
+| 2 | The provider reports the number as invalid | Refused with `INVALID_PHONE_NUMBER` |
+| 3 | The line type is `fixedVoip` or `nonFixedVoip` | Inbound, reason `voip` |
+| 4 | The number starts with `+1` and its numbering plan area is Canadian | Inbound, reason `canadian` |
+| 5 | The line type is absent or `unknown` | Inbound, reason `unknown_line_type` |
+| 6 | The line type is `landline`, `tollFree`, `premium`, `sharedCost`, `uan`, `voicemail`, or `pager` | Refused with `INVALID_PHONE_NUMBER` |
+| 7 | The line type is anything other than `mobile` or `personal`2 | Refused with `INVALID_PHONE_NUMBER` |
+| 8 | The SMS pumping risk score reaches the threshold for the reported country3 | Refused with `INVALID_PHONE_NUMBER` |
+| 9 | No rule above matched | Accepted for outbound SMS |
+
+1 An instance whose lookup provider is unavailable or unconfigured refuses every number
+
+2 `mobile` and `personal` are the only accepted line types
+
+3 The threshold is 100 for `US` and `CA`, 70 for `GB`, `DE`, `FR`, `IT`, `ES`, `NL`, `SE`, `NO`, `DK`, `FI`, `AU`, `NZ`, `JP`, `KR`, `CH`, `AT`, `BE`, `IE`, and `PT`, and 35 for every other country and for a lookup reporting no country
+
+Only [Send phone verification](#send-phone-verification) converts an inbound verdict into a challenge. [Verify phone code](#verify-phone-code) refuses the same three verdicts with 400 `INVALID_PHONE_NUMBER`.
+
+## Inbound challenge reason values
+
+A reason explains why the attempt could not complete over outbound SMS.
+
+| Value | Description |
+| --- | --- |
+| voip | Provider lookup classified the destination as a VoIP line |
+| canadian1 | The destination is a Canadian numbering plan area |
+| unknown_line_type | Provider lookup reported no line type or an unknown line type |
+| expensive_destination2 | The configured inbound-required prefix policy matches the destination |
+| account_forced3 | The account has the flag that forces inbound verification |
+| behavioural_risk | Phone attempt risk controls forced this attempt onto the inbound channel |
+
+1 Determined from the numbering plan area of a `+1` number, and evaluated after the VoIP rule, so a Canadian VoIP number reports `voip` instead
+
+2 Also reported when the caller explicitly requests the `inbound_challenge` channel. The prefix policy itself applies only while the account holds no verified phone
+
+3 Reported in preference to `expensive_destination` when both would apply
+
+## SMS delivery object
+
+Confirmation that the SMS provider accepted a one-time code for delivery. [Send phone verification](#send-phone-verification) returns it on an outbound result, and [Verify phone code](#verify-phone-code) then takes the number itself.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The delivery channel, always the literal `sms` |
+
+## Inbound challenge object
+
+The instructions for texting a code to Fluxer, returned by [Send phone verification](#send-phone-verification) when policy routed the attempt inbound. `channel` tells the two response shapes apart.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel | string | The delivery channel, always the literal `inbound_challenge` |
+| challenge_code1 | string | The code the user texts to `our_number` |
+| our_number | string | The Fluxer E.164 number that receives the code |
+| expires_at2 | ISO8601 timestamp | The moment the challenge stops being redeemable |
+| reason | string | The [inbound challenge reason](#inbound-challenge-reason-values) that forced this channel |
+
+1 Six decimal digits
+
+2 15 minutes after the challenge was issued
+
+### Example
+
+```json
+{
+ "channel": "inbound_challenge",
+ "challenge_code": "418207",
+ "our_number": "+15550000000",
+ "expires_at": "2026-03-04T18:15:00.000Z",
+ "reason": "voip"
+}
+```
+
+## Direct inbound challenge object
+
+The code and the Fluxer number for a caller that asked for an inbound challenge directly. [Start inbound phone challenge](#start-inbound-phone-challenge) returns it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| challenge_code1 | string | The code the user texts to `our_number` |
+| our_number | string | The Fluxer E.164 number that receives the code |
+| expires_at2 | ISO8601 timestamp | The moment the challenge stops being redeemable |
+
+1 Six decimal digits
+
+2 15 minutes after the challenge was issued
+
+## Phone verification result object
+
+Returned by [Verify phone code](#verify-phone-code) on a successful verification.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| verified | boolean | Always the literal `true` |
+
+## Phone gate escape guild object
+
+One guild in a [phone gate escape preview](#phone-gate-escape-preview-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the guild |
+| name | string | The name the guild is listed under |
+
+## Phone gate escape preview object
+
+What [Run phone gate escape](#run-phone-gate-escape) would do for the current account.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| available | boolean | Whether the escape is open to this account right now |
+| guilds | array[[phone gate escape guild](#phone-gate-escape-guild-object) object] | The qualifying guilds the escape leaves, empty while `available` is false |
+| owned_guilds | array[[phone gate escape guild](#phone-gate-escape-guild-object) object] | The qualifying guilds the account owns, which the escape keeps |
+
+## Send phone verification
+
+
+
+Starts verification of the submitted number. Requires an account satisfying [eligibility](#eligibility). Returns an [SMS delivery](#sms-delivery-object) object or an [inbound challenge](#inbound-challenge-object) object on success.
+
+Several independent policies can force the inbound channel, and each one overrides a requested `sms`. Fluxer routes the attempt inbound before it contacts the lookup and SMS providers in three cases. The caller asks for `inbound_challenge`. The account has the flag that forces inbound verification. The configured inbound-required prefix policy matches the number while the account holds no verified phone.
+
+An attempt that survives that step passes through phone attempt risk controls keyed by account, by client IP address, and by /24 network. A hard block returns 429 `PHONE_RATE_LIMIT_EXCEEDED` with a `Retry-After` of 86400. A captcha decision returns 400 `CAPTCHA_REQUIRED`, and this route reads no [CAPTCHA](/topics/captcha/) solution header, so the attempt cannot be retried with a solution. An inbound decision returns a challenge with the `behavioural_risk` reason.
+
+An earlier attempt against the same account or number can record a provider cooldown, and a later request under that cooldown returns 429 `PHONE_RATE_LIMIT_EXCEEDED`. The destination is then resolved through [provider lookup](#provider-lookup-verdicts). A number that has already completed verification twice is refused with 400 `PHONE_ALREADY_USED`.
+
+Two further controls bound outbound delivery: 3 sends per 6 hours for each account, and 3 sends per 5 days for each number. Either denial returns 429 `PHONE_RATE_LIMIT_EXCEEDED`. The provider can also refuse the send: an invalid destination returns 400 `INVALID_PHONE_NUMBER`, a throttle returns 429 `PHONE_RATE_LIMIT_EXCEEDED`, and every other refusal, an unreachable provider included, returns 400 `SMS_VERIFICATION_UNAVAILABLE`. An attempt routed inbound on an instance where the inbound flow or its receiving number is unconfigured returns the same code.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| phone | string | The number in canonical E.164 form, matching `^\+[1-9]\d{1,14}$` |
+| channel?1 | string | The preferred [verification channel](#phone-verification-channel-values) |
+
+1 Omitting the field requests outbound SMS. Requesting `inbound_challenge` always yields a challenge reported with the `expensive_destination` reason, unless the account flag independently forces the channel and the reason becomes `account_forced`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [SMS delivery](#sms-delivery-object) object \| [inbound challenge](#inbound-challenge-object) object | Verification was started on the channel named by the response |
+| 400 | [error response](/http-api/#error-response) | The number is refused with `INVALID_PHONE_NUMBER`, was verified twice already and returns `PHONE_ALREADY_USED`, risk controls demand `CAPTCHA_REQUIRED`, or the send could not start and returns `SMS_VERIFICATION_UNAVAILABLE` |
+| 403 | [error response](/http-api/#error-response) | The account is not eligible and the request returns `PHONE_ADD_NOT_ELIGIBLE` |
+| 4291 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Risk controls, a provider throttle, or an account or per-number send control denies the request, which returns `PHONE_RATE_LIMIT_EXCEEDED` |
+
+1 A phone denial uses the rate limit envelope with `code` set to `PHONE_RATE_LIMIT_EXCEEDED` rather than the `RATE_LIMITED` an ordinary route denial returns
+
+On the 429, `X-RateLimit-Scope` reports `shared` when the per-number control or a number-scoped provider throttle produced the denial.
+
+### Side effects
+
+On an outbound result, Fluxer asks the configured SMS provider to deliver a one-time code. On an inbound result, it creates a one-use challenge for the current account.
+
+Fluxer records an attempt against the phone attempt risk counters only once it reaches [provider lookup](#provider-lookup-verdicts).
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `phone:send_verification` bucket.
+
+## Start inbound phone challenge
+
+
+
+Issues an inbound challenge without submitting or examining a phone number. Returns a [direct inbound challenge](#direct-inbound-challenge-object) object on success.
+
+The [eligibility](#eligibility) check does not apply here. The challenge code is six decimal digits, lives for 15 minutes, and can be redeemed once. Requesting a further challenge does not revoke an earlier one, so several codes issued to the same account can be live at the same time until each expires or is redeemed.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [direct inbound challenge](#direct-inbound-challenge-object) object | A challenge was issued |
+| 400 | [error response](/http-api/#error-response) | The instance has no receiving number for the inbound challenge and the request returns `SMS_VERIFICATION_UNAVAILABLE` |
+
+### Side effects
+
+Fluxer creates a one-use challenge for the current account. The challenge completes out of band when the provider delivers the matching inbound message, and the HTTP response has no part of it. On completion, Fluxer sets the verified phone state, clears the phone requirements from the stored suspicious activity bitfield, and dispatches [User Update](/gateway/events/#user-update). When the sending number has already completed verification twice, Fluxer abandons the completion and produces no observable error.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `phone:send_verification` bucket, shared with [Send phone verification](#send-phone-verification).
+
+## Verify phone code
+
+
+
+Verifies an outbound SMS code. Requires an account satisfying [eligibility](#eligibility). Returns a [phone verification result](#phone-verification-result-object) object on success. Emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+The number is resolved through [provider lookup](#provider-lookup-verdicts) again before the code is checked, so a number that has become ineligible since the code was sent is refused.
+
+The configured SMS provider checks the number and the code together, so the provider decides the code lifetime and the attempt allowance. A rejected code returns 400 `INVALID_PHONE_VERIFICATION_CODE`. A provider throttle returns 429 `PHONE_RATE_LIMIT_EXCEEDED` and records a cooldown that also denies [Send phone verification](#send-phone-verification) until it expires. An unreachable provider, or one answering with a server error, returns 400 `SMS_VERIFICATION_UNAVAILABLE`. An account record that has been deleted, or can no longer be read, fails with 400 `PHONE_VERIFICATION_REQUIRED` after the code has been accepted.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| phone | string | The number in canonical E.164 form, matching `^\+[1-9]\d{1,14}$` |
+| code1 | string | The code the outbound SMS delivered (1-32 characters) |
+
+1 Stripped of control and formatting characters and trimmed before the length check, and the normalised value is what the provider checks
+
+:::caution[A third verification consumes the code before refusing]
+A number can complete verification twice, and each success refreshes that record for a further 31 days. A third attempt is refused with 400 `PHONE_ALREADY_USED`, but only after the provider has accepted the code.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [phone verification result](#phone-verification-result-object) object | The number is now verified for the account |
+| 400 | [error response](/http-api/#error-response) | The number returns `INVALID_PHONE_NUMBER`, the code returns `INVALID_PHONE_VERIFICATION_CODE`, the provider returns `SMS_VERIFICATION_UNAVAILABLE`, the number returns `PHONE_ALREADY_USED`, or the account returns `PHONE_VERIFICATION_REQUIRED` |
+| 403 | [error response](/http-api/#error-response) | The account is not eligible and the request returns `PHONE_ADD_NOT_ELIGIBLE` |
+| 4291 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A provider throttle denies the request, which returns `PHONE_RATE_LIMIT_EXCEEDED` |
+
+1 A phone denial uses the rate limit envelope with `code` set to `PHONE_RATE_LIMIT_EXCEEDED` rather than the `RATE_LIMITED` an ordinary route denial returns
+
+On the 429, `X-RateLimit-Scope` reports `shared` when a number-scoped provider throttle produced the denial.
+
+### Side effects
+
+Fluxer marks the account as holding a verified phone. It then clears every phone requirement from the stored suspicious activity bitfield: `REQUIRE_VERIFIED_PHONE`, `REQUIRE_REVERIFIED_PHONE`, the four combined email-or-phone requirements, `REQUIRE_INBOUND_PHONE_VERIFICATION`, and the marker deferring a phone requirement until the account joins a qualifying community guild. Email-only requirements remain, so an account that also owes email verification stays restricted.
+
+Fluxer clears the spammer flag when present and dispatches [User Update](/gateway/events/#user-update). When the spammer flag was cleared, Fluxer also dispatches [Guild Member Update](/gateway/events/#guild-member-update) for the account in every guild it belongs to, before the HTTP response returns.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `phone:verify_code` bucket.
+
+## Get phone gate escape preview
+
+
+
+Returns a [phone gate escape preview](#phone-gate-escape-preview-object) object for the current account. For an account outside the state [Deferred phone requirement](#deferred-phone-requirement) describes, `available` is false and both arrays are empty.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [phone gate escape preview](#phone-gate-escape-preview-object) object | The preview was returned |
+
+### Rate limit
+
+20 requests per minute for each authenticated user, on the `user:phone_gate_escape:preview` bucket.
+
+## Run phone gate escape
+
+
+
+Leaves the qualifying guilds the account does not own and defers the phone requirement again. Returns the [user](/http-api/users/#user-object) object.
+
+### Limitations
+
+- The account is in the state [Deferred phone requirement](#deferred-phone-requirement) defines, and one outside it is rejected with 400 [`PHONE_GATE_ESCAPE_UNAVAILABLE`](/http-api/errors/).
+- One request leaves at most 25 guilds.
+
+### JSON body
+
+The body is an empty object. An absent or empty body is read as an empty object, and any supplied property is discarded. A body that is not valid JSON returns 400 `INVALID_FORM_BODY` with `INVALID_FORMAT` at the `body` path. A body that parses as valid JSON but is not an object is rejected with 400.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | The escape ran, wholly or up to the per-request limit |
+| 400 | [error response](/http-api/#error-response) | The escape is unavailable and the request returns `PHONE_GATE_ESCAPE_UNAVAILABLE` |
+
+### Side effects
+
+Fluxer leaves each qualifying guild the account does not own, at most 25 in one request, with the same effects as [Leave guild](/http-api/guilds/#leave-guild). A qualifying guild the account owns is kept and does not block the escape.
+
+A request that leaves the whole set then defers the requirement again and dispatches [User Update](/gateway/events/#user-update). A request that stopped at the limit writes no account field, dispatches nothing, and returns the account with the requirement still due. Repeat the request until the returned `required_actions` has no phone entry.
+
+### Rate limit
+
+5 requests per hour for each authenticated user, on the `user:phone_gate_escape:execute` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/private-channels.mdx b/fluxer_docs/src/content/docs/http-api/users/private-channels.mdx
new file mode 100644
index 000000000..00edf6181
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/private-channels.mdx
@@ -0,0 +1,282 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Private channels
+description: Direct message and group DM discovery, creation, preload, and pin order.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A private channel is a conversation outside any guild. It is either a direct message between two accounts or a group DM with its own participant set. Its fields come from the [channel object](/http-api/channels/#channel-object).
+
+The routes here take a user session or a bot token. An OAuth2 bearer credential receives 403 `ACCESS_DENIED`, and an account with an outstanding required action receives 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+:::note[Open state is per participant]
+A direct message channel exists once, and each participant holds its own open state. Closing one leaves the channel and its messages in place, so reopening the pair resolves the stored channel.
+:::
+
+## Preloaded messages object
+
+The response of both preload operations. It has no fixed field set, and each property name is the [snowflake](/snowflakes/) of one requested channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| {channel_id}12 | ?[message](/http-api/messages/#message-object) object | The latest message the caller can read in the channel, or null |
+
+1 A requested channel that is neither a direct message nor a group DM has no property at all. A channel that cannot be resolved, or whose latest message is missing, is present with the value null
+
+2 A repeated channel ID collapses into one property, so the object holds at most one entry for each distinct ID
+
+## Group DM unaddable recipient object
+
+One recipient that could not be added to a group DM, and the reason. [Create private channel](#create-private-channel) returns an array of these as `unaddable_recipients` at the top level of a `GROUP_DM_RECIPIENTS_NOT_ADDABLE` [error response](/http-api/#error-response).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The recipient that could not be added |
+| reason | string | The [unaddable reason](#group-dm-unaddable-reasons) the recipient was rejected under |
+
+## Group DM unaddable reasons
+
+| Value | Name | Description |
+| --- | --- | --- |
+| unknown_user | Unknown user | The recipient does not resolve |
+| not_friends | Not friends | The caller is an ordinary account and holds no friendship with the recipient |
+| blocked1 | Blocked | The caller is a bot account and shares no guild with the recipient |
+| group_dm_add_disabled | Group DM add disabled | The recipient's [group DM add permission flags](/http-api/users/#group-dm-add-permission-flags) reject the caller |
+
+1 Reported only on the bot path. The ordinary path reports a missing friendship as `not_friends`
+
+## List private channels
+
+
+
+Returns the caller's open direct message and group DM [channel](/http-api/channels/#channel-object) objects.
+
+The route returns only the channels the caller currently has open, so a closed direct message is absent even though the channel and its history still exist. The response has no ordering guarantee and is unpaginated. The caller's personal notes channel is never returned.
+
+Each returned channel has its recipients with the caller removed, so a direct message holds exactly one recipient and a group DM one fewer than its participant count. A group DM whose only participant is the caller omits the `recipients` key entirely.
+
+An open entry whose channel no longer lists the caller among its recipients is omitted, so leaving a group DM needs no separate cleanup call.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[channel](/http-api/channels/#channel-object) object] | Open private channels were returned |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:channels` bucket, shared with [Create private channel](#create-private-channel), [Pin private channel](#pin-private-channel), and [Unpin private channel](#unpin-private-channel).
+
+## Create private channel
+
+
+
+Opens a direct message with one account, or creates a group DM. Returns a [channel](/http-api/channels/#channel-object) object on success. Emits [Channel Create](/gateway/events/#channel-create) and [Message Create](/gateway/events/#message-create) Gateway events.
+
+One body field selects the channel type. `recipient_id` opens a direct message. `recipients` always creates a group DM whatever its length, so a single-element array creates a two-participant group DM, and an empty array creates a group DM whose only participant is the caller.
+
+A request supplying `recipients` additionally consumes the `user:group_dm:create` bucket and passes the CAPTCHA check below, whatever the resulting participant count.
+
+Instance policy can disable private channel creation entirely, in which case every request returns 400 `DIRECT_MESSAGES_DISABLED` after the body has been validated and the group DM bucket and CAPTCHA have been applied.
+
+An unclaimed account holds no password and did not arrive through SSO. Fluxer rejects an unclaimed caller with 400 `UNCLAIMED_ACCOUNT_CANNOT_JOIN_GROUP_DMS` when `recipients` is supplied, and with 400 `UNCLAIMED_ACCOUNT_CANNOT_SEND_DIRECT_MESSAGES` otherwise. An ordinary caller then needs a verified email address, failing which the request returns 403 `DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED`. A bot caller is exempt from the email requirement and is never unclaimed.
+
+### Direct message admission
+
+A request naming the caller as its own target returns 400 `CANNOT_DM_YOURSELF` as the field code on `recipient_id` under `INVALID_FORM_BODY`. An unresolved target returns 404 `UNKNOWN_USER`.
+
+Fluxer reads no block state, no friendship, and no shared guild on this route. A target that resolves is admitted, so the caller opens a direct message with an account that has blocked them. `CANNOT_SEND_MESSAGES_TO_USER` is reachable from [Create message](/http-api/messages/#create-message) alone, which applies the recipient's direct message policy on every send.
+
+:::caution[Blocking removes no channel from the blocked account]
+Blocking refuses no creation and closes no channel. The blocked account still opens the pair's direct message through this route and receives 200. Fluxer refuses the send alone, with 400 `CANNOT_SEND_MESSAGES_TO_USER`.
+:::
+
+:::caution[A SPAMMER caller receives a channel nobody sees]
+An ordinary caller with the [SPAMMER](/http-api/users/#public-user-flags) flag never opens a shared direct message. An unresolved target returns 404 `UNKNOWN_USER`, and any other returns 200 with a channel that exists only for the caller. No Dispatch reaches the target.
+:::
+
+### Group DM admission
+
+The participant set, meaning the supplied recipients plus the caller, stays within the instance `max_group_dm_recipients` limit resolved for the caller, which defaults to 50. Exceeding it returns 400 `MAX_GROUP_DM_RECIPIENTS` with the applied ceiling in the top-level `max_recipients` member. `recipients` is capped at 49 elements before the limit is read, so a raised instance limit does not raise the effective ceiling.
+
+A duplicated ID within `recipients` returns 400 `DUPLICATE_RECIPIENTS_NOT_ALLOWED`, and the caller's own ID within it returns 400 `CANNOT_ADD_YOURSELF_TO_GROUP_DM`. Both are field codes on `recipients` under `INVALID_FORM_BODY`.
+
+Every participant, the caller included, holds fewer open group DMs than the instance `max_group_dms_per_user` limit resolved for that account, which defaults to 150. A participant at the ceiling makes the request return 400 `MAX_GROUP_DMS` with the applied ceiling in the top-level `max_group_dms` member.
+
+Fluxer then evaluates each other recipient independently and collects every failure, using the [unaddable reasons](#group-dm-unaddable-reasons) above. One failing recipient rejects the whole request with 400 `GROUP_DM_RECIPIENTS_NOT_ADDABLE`. The response has the rejected entries in the top-level `unaddable_recipients` member and the accepted user IDs in `addable_recipients`, so the caller can retry with a smaller set.
+
+An ordinary caller never adds a recipient across a block in either direction, because blocking deletes the friendship both ways and a friend request crosses no block.
+
+:::note[Group DM admission reads no block state]
+Fluxer holds a bot caller to no friendship, so a recipient that has blocked the bot is added when the two share a guild and the recipient's [group DM add permission flags](/http-api/users/#group-dm-add-permission-flags) admit it.
+:::
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The CAPTCHA proof |
+| X-Captcha-Type?2 | string | The CAPTCHA provider, either `hcaptcha` or `turnstile` |
+
+1 Read only on a request supplying `recipients`, where a missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`, as does a proof for a provider the instance holds no secret key for. [CAPTCHA](/topics/captcha/) states when an instance skips verification
+
+2 The value `hcaptcha` or `turnstile` selects that provider whether or not the instance configured it. The instance's configured provider is used when the header is absent or has any other value
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| recipient_id?1 | snowflake | The sole recipient of a direct message |
+| recipients?1 | array[snowflake] | The other participants of a group DM, excluding the caller (max 49) |
+
+1 Exactly one of the two is supplied. Supplying both or neither fails validation with the entry path `root`
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [channel](/http-api/channels/#channel-object) object | The direct message was opened or the group DM was created |
+| 400 | [error response](/http-api/#error-response) | CAPTCHA, account eligibility, the recipient set, relationship policy, a group DM limit, or instance policy rejects creation |
+| 403 | [error response](/http-api/#error-response) | An ordinary caller's email address is unverified and the request returns `DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | The caller or the direct message recipient does not resolve |
+
+### Side effects
+
+A newly created direct message opens for the caller only and emits [Channel Create](/gateway/events/#channel-create) to the caller. The recipient's side stays closed until they open it themselves, or until the pair becomes friends through [Accept request or block user](/http-api/users/relationships/#accept-request-or-block-user), which opens both sides. Reopening an existing direct message emits `CHANNEL_CREATE` to the caller unconditionally.
+
+Creating a group DM makes the caller its owner, adds the complete participant set, and emits [Channel Create](/gateway/events/#channel-create) to every participant. It creates one [RECIPIENT_ADD](/http-api/messages/#message-types) system message authored by the caller for each other recipient and emits [Message Create](/gateway/events/#message-create) for each to every participant. Initial creation emits no [Channel Recipient Add](/gateway/events/#channel-recipient-add), which is reserved for a recipient added later. A group DM created from an empty `recipients` array produces no system message.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:channels` bucket, shared with [List private channels](#list-private-channels), [Pin private channel](#pin-private-channel), and [Unpin private channel](#unpin-private-channel).
+
+A request supplying `recipients` additionally draws on the `user:group_dm:create` bucket of 10 requests per hour for each authenticated user, which is exempt from the global HTTP limit.
+
+## Preload private channel messages
+
+
+
+Returns the [preloaded messages](#preloaded-messages-object) object holding the latest readable message of each requested private channel.
+
+Fluxer resolves each requested channel independently and reports an unknown channel, a missing permission, or a missing message as null for that channel. The request still answers 200. It applies the same read authorisation as ordinary message history. A channel the caller cannot see is indistinguishable from one whose history is empty.
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channels1 | array[snowflake] | The private channels to preload (max 100) |
+
+1 Required. An empty array is accepted and returns an empty object, and more than 100 elements fails body validation before any channel is read
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [preloaded messages](#preloaded-messages-object) object | The requested channels were resolved |
+
+### Side effects
+
+This read acknowledges no message and does not change the caller's read state. The caller's own user ID is the ID of their personal notes channel, and resolving it creates that channel row when the account holds none. The notes channel is neither a direct message nor a group DM, so the response has no property for it.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:preload_messages` bucket, shared with [Preload private channel messages by alias](#preload-private-channel-messages-by-alias).
+
+## Preload private channel messages by alias
+
+
+
+An alias of [Preload private channel messages](#preload-private-channel-messages). The request body, response, authorisation, and side effects are identical, including the personal notes channel creation described there.
+
+### JSON body
+
+The body is the same as [Preload private channel messages](#preload-private-channel-messages).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [preloaded messages](#preloaded-messages-object) object | The requested channels were resolved |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:preload_messages` bucket. The two paths share one allowance.
+
+## Pin private channel
+
+
+
+Adds a direct message or group DM to the caller's pinned order. Returns 204 with an empty body. Emits a [User Pinned DMs Update](/gateway/events/#user-pinned-dms-update) Gateway event.
+
+Fluxer resolves the channel with the caller's ordinary channel authorisation first. A private channel the caller is not a recipient of returns 404 `UNKNOWN_CHANNEL`, and a channel that does not exist returns the same. The channel then has to be a direct message or a group DM, so the caller's personal notes channel and a visible guild channel are rejected with 400 `CHANNEL_MUST_BE_DM_OR_GROUP_DM`, a field code on `channel_id` under `INVALID_FORM_BODY`.
+
+A guild channel the caller cannot see fails resolution first and returns 403 `MISSING_PERMISSIONS`. A channel row that outlived its guild returns 404 `UNKNOWN_GUILD`. A guild record that still exists while the caller's membership state cannot be resolved returns 403 `ACCESS_DENIED`.
+
+A newly pinned channel is appended after every channel already pinned. A channel later closed, left, or deleted stays in the pinned set, and only [Unpin private channel](#unpin-private-channel) or deletion of the caller's account removes an entry.
+
+:::caution[Unpinning needs the channel to resolve]
+Unpinning resolves the channel first, so an entry whose channel the caller can no longer resolve stays in the pinned set.
+:::
+
+:::note[Pinning is idempotent and the Dispatch is unconditional]
+Pinning an already pinned channel leaves the order untouched, yet the operation still returns 204 and emits [User Pinned DMs Update](/gateway/events/#user-pinned-dms-update) with the unchanged order.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the direct message or group DM |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The channel is present in the pinned set |
+| 400 | [error response](/http-api/#error-response) | The channel is neither a direct message nor a group DM and the request returns `CHANNEL_MUST_BE_DM_OR_GROUP_DM` |
+| 403 | [error response](/http-api/#error-response) | A guild channel is not visible and the request returns `MISSING_PERMISSIONS`, or the guild exists while membership cannot be resolved and the request returns `ACCESS_DENIED` |
+| 404 | [error response](/http-api/#error-response) | The channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL`, or a guild channel outlived its guild and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The channel is appended to the caller's pinned order, and [User Pinned DMs Update](/gateway/events/#user-pinned-dms-update) reaches only the caller with the complete resulting order as an array of channel ID strings. The channel itself is not modified, and no other participant can observe the caller's pinned order.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:channels` bucket, shared with [List private channels](#list-private-channels), [Create private channel](#create-private-channel), and [Unpin private channel](#unpin-private-channel).
+
+## Unpin private channel
+
+
+
+Removes a direct message or group DM from the caller's pinned order. Returns 204 with an empty body. Emits a [User Pinned DMs Update](/gateway/events/#user-pinned-dms-update) Gateway event.
+
+Fluxer resolves and type checks the channel exactly as in [Pin private channel](#pin-private-channel), so a channel that no longer resolves cannot be unpinned through this route. Unpinning preserves the relative order of every channel that remains, and a later pin is appended to the end. Unpinning a channel that is not pinned still returns 204 and emits the same Dispatch.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the direct message or group DM |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The channel is absent from the pinned set |
+| 400 | [error response](/http-api/#error-response) | The channel is neither a direct message nor a group DM and the request returns `CHANNEL_MUST_BE_DM_OR_GROUP_DM` |
+| 403 | [error response](/http-api/#error-response) | A guild channel is not visible and the request returns `MISSING_PERMISSIONS`, or the guild exists while membership cannot be resolved and the request returns `ACCESS_DENIED` |
+| 404 | [error response](/http-api/#error-response) | The channel does not exist or the caller is not a recipient, each returning `UNKNOWN_CHANNEL`, or a guild channel outlived its guild and the request returns `UNKNOWN_GUILD` |
+
+### Side effects
+
+The channel is removed from the caller's pinned order, and [User Pinned DMs Update](/gateway/events/#user-pinned-dms-update) reaches only the caller with the complete resulting order as an array of channel ID strings. The channel itself is not closed or deleted, and its recipients receive no Dispatch.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:channels` bucket, shared with [List private channels](#list-private-channels), [Create private channel](#create-private-channel), and [Pin private channel](#pin-private-channel).
diff --git a/fluxer_docs/src/content/docs/http-api/users/relationships.mdx b/fluxer_docs/src/content/docs/http-api/users/relationships.mdx
new file mode 100644
index 000000000..ae34f40ee
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/relationships.mdx
@@ -0,0 +1,385 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Relationships
+description: Friendships, blocks, and pending friend requests in both directions.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A relationship is the caller's stored record of one friendship, block, or pending friend request. Each account holds its own record, so one friendship is two records. The private note the caller keeps against another account lives on [User notes](/http-api/users/notes/).
+
+The routes here are user-only. A bot or OAuth2 bearer credential receives 403 `ACCESS_DENIED`, and an account with an outstanding required action receives 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+:::note[Relationship records are directional]
+A friendship is a FRIEND record on both accounts. A pending request is `OUTGOING_REQUEST` on the sender and `INCOMING_REQUEST` on the recipient. A block exists only on the account that created it, so the blocked account observes nothing.
+:::
+
+A deleted account has the deleted flag with no pending deletion timestamp. An account inside a scheduled deletion window has a timestamp, so it stays a valid friend request target.
+
+## Relationship object
+
+A relationship record belongs to one account and names one other account. Every field describes the owning account's side. The caller reads its own record on every operation except the friendly bot auto-acceptance of [Send friend request by user ID](#send-friend-request-by-user-id), which returns the bot's record.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | snowflake | The ID of the related account |
+| type | integer | The [relationship type](#relationship-types) |
+| user | [partial user](/http-api/users/#partial-user-object) object | The related account |
+| since?2 | ISO8601 timestamp | The time at which the record's current type was established |
+| nickname3 | ?string | The caller-private nickname for the related account, or null when none is set |
+| share_voice_activity4 | boolean | Whether the caller shares voice activity with the related account |
+| friend_shares_voice_activity5 | boolean | Whether the related account shares voice activity with the caller |
+
+1 A relationship has no identifier of its own, so this is always the related account's ID
+
+2 Omitted when the stored record has no timestamp. A nickname write and a sharing rewrite both preserve it, so it does not track the last write
+
+3 Only a FRIEND record has one, and [Modify relationship nickname](#modify-relationship-nickname) is its only writer. A type change rewrites the record, so a nickname does not survive one
+
+4 A FRIEND record is written with the owning account's default when the friendship is created. Every non-FRIEND record is written with true
+
+5 Only [List relationships](#list-relationships) resolves the reciprocal record. Every other operation reports true, and so does [List relationships](#list-relationships) for a record with no reciprocal of its type
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "type": 1,
+ "user": {"id": "1501314428688998182", "username": "aria", "discriminator": "0042"},
+ "since": "2026-01-14T09:31:00.000Z",
+ "nickname": "climbing partner",
+ "share_voice_activity": true,
+ "friend_shares_voice_activity": false
+}
+```
+
+## Relationship types
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 1 | FRIEND | The two accounts are friends |
+| 2 | BLOCKED | The caller has blocked the related account |
+| 3 | INCOMING_REQUEST | The related account has sent the caller a friend request |
+| 4 | OUTGOING_REQUEST | The caller has sent the related account a friend request |
+
+## Bulk ignore result object
+
+A bulk ignore result reports how many incoming friend requests one call removed.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ignored_count | integer | The number of incoming friend requests that were removed |
+
+## Bulk ignore filters
+
+| Value | Name | Description |
+| --- | --- | --- |
+| all | All | Matches every incoming friend request |
+| new_accounts1 | New accounts | Matches an incoming friend request whose sender account is younger than `max_account_age_seconds` |
+
+1 The age comparison applies only when `max_account_age_seconds` is supplied, so this filter matches every incoming request when that member is absent
+
+## Relationship limit
+
+The `max_relationships` limit governs the combined number of friendship, block, and pending request records one account holds. Fluxer resolves it from the account's traits and premium state, so the value can be higher for a premium account. The fallback is 1000 when the deployment resolves no rule. The registry entry is listed under [limit keys](/http-api/instance/#limit-keys).
+
+Fluxer refuses the write once the stored record count reaches the resolved value. It checks the caller and the target independently, and a request fails when either has reached that value.
+
+A refusal returns 400 `MAX_FRIENDS` with the applied value in a top-level `max_relationships` member. Fluxer skips the limit for a bot account on either side, for a block, and under [staff forced acceptance](#send-friend-request-by-user-id).
+
+## List relationships
+
+
+
+Returns every [relationship](#relationship-object) object the caller holds, covering friendships, blocks, and pending requests in both directions.
+
+The operation is not paged, and the whole set comes back in one response. The order is not dependable. `friend_shares_voice_activity` is meaningful only in this response, and every other operation reports it as true.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[relationship](#relationship-object) object] | Relationships were returned |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:relationships:list` bucket.
+
+## Send friend request by tag
+
+
+
+Sends a friend request to the account identified by username and discriminator. Returns a [relationship](#relationship-object) object on success.
+
+### Limitations
+
+- An unresolved tag returns 400 `NO_USERS_WITH_FLUXERTAG_EXIST`.
+- A resolved deleted account returns 400 `FRIEND_REQUEST_BLOCKED`.
+- An existing friendship returns 400 `ALREADY_FRIENDS`.
+
+Fluxer resolves the tag to exactly one account before any relationship rule runs. Every remaining admission rule is the one described under [Send friend request by user ID](#send-friend-request-by-user-id).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username1 | string | The username of the target account |
+| discriminator2 | string \| integer | The discriminator of the target account |
+
+1 Trimmed before validation, then 1 to 32 characters of ASCII letters, digits, and underscores. It cannot be `everyone` or `here`, or contain `fluxer` or `system message`, in any casing
+
+2 An integer and a decimal string of 1 to 4 digits are both accepted, and `0007` and `7` resolve the same account
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [relationship](#relationship-object) object | A pending request, an existing relationship, or an accepted friendship was returned |
+| 400 | [error response](/http-api/#error-response) | The tag is unresolved, the caller or target is ineligible, target or block policy rejects the request, deployment policy disables the operation, or the [relationship limit](#relationship-limit) is reached |
+| 403 | [error response](/http-api/#error-response) | The caller's email address is unverified and the request returns `FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | The caller or resolved target no longer exists, or the pending request being accepted has been withdrawn |
+
+### Side effects
+
+The side effects are those of [Send friend request by user ID](#send-friend-request-by-user-id). Neither route opens a direct message channel.
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:friend_request:send` bucket, shared with [Send friend request by user ID](#send-friend-request-by-user-id).
+
+## Send friend request by user ID
+
+
+
+Sends a friend request to the selected account. Returns a [relationship](#relationship-object) object on success.
+
+Admission is ordered. The deployment direct message policy runs first, so an ordinary caller receives 400 `DIRECT_MESSAGES_DISABLED` while that policy disables direct messages. A caller with the `STAFF` flag skips that first evaluation, whether or not it requests forced acceptance. When the target has already sent a request, a staff caller's send turns into an acceptance and still receives 400 `DIRECT_MESSAGES_DISABLED`. An unresolved caller then returns 404 `UNKNOWN_USER`.
+
+The caller's own ID returns 400 `CANNOT_SEND_FRIEND_REQUEST_TO_SELF`. An unresolved target returns 404 `UNKNOWN_USER`. A deleted target returns 400 `FRIEND_REQUEST_BLOCKED`.
+
+Fluxer consults the current relationship state next. A pending request from the target is accepted. An existing friendship or outgoing request is returned unchanged. The rules that follow apply to a genuinely new request alone, so they never refuse an existing friend.
+
+The remaining rules reject an unclaimed caller with 400 `UNCLAIMED_ACCOUNT_CANNOT_SEND_FRIEND_REQUESTS` and an unverified email with 403 `FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED`. A bot target is rejected with 400 `FRIEND_REQUEST_BLOCKED` unless it has `FRIENDLY_BOT`. A target with the internal app store reviewer flag is rejected with the same code.
+
+A target the caller has blocked returns 400 `CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER`. A target that has blocked the caller returns 400 `FRIEND_REQUEST_BLOCKED`. Fluxer reads the target's [friend source flags](/http-api/users/#friend-source-flags) next1, and a target accepting requests only from mutual friends or mutual guild members rejects an unrelated caller with 400 `FRIEND_REQUEST_BLOCKED`. The [relationship limit](#relationship-limit) applies last, independently for both accounts.
+
+1 The friend source evaluation is skipped when the target has no stored settings, which admits the request as though the target permitted requests from anyone
+
+:::caution[A flagged caller receives an indistinguishable success]
+A caller with the `SPAMMER` [public user flag](/http-api/users/#public-user-flags), or one whose send trips the deployment's direct contact spam policy, gets a one-sided request. Fluxer writes only the caller's `OUTGOING_REQUEST` record, and [Relationship Add](/gateway/events/#relationship-add) reaches the caller alone. The response is an ordinary `OUTGOING_REQUEST` object.
+:::
+
+That path also skips the rules that follow it: the target's block of the caller, the target's [friend source flags](/http-api/users/#friend-source-flags), the app store reviewer rule, the [relationship limit](#relationship-limit), and friendly bot auto-acceptance. The caller's own block of the target is still enforced and still returns 400 `CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER`. The policy path can also set the `SPAMMER` flag on the caller, which makes every later send one-sided.
+
+:::note[The two suppression entries enter at different points]
+A caller that already has the flag enters before the pending request lookup, so it can hold both an `INCOMING_REQUEST` and an `OUTGOING_REQUEST` record for one account. A caller suppressed during the call enters after the lookup and accepts the pending request normally.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+The body can be omitted, in which case it is treated as an empty object.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| staff_force_accept?1 | boolean | Whether the friendship is created immediately without a pending request |
+
+1 Honoured only when the caller has the `STAFF` flag, and a caller without it is treated as though the member were absent. The stored flag governs, so hidden staff status still qualifies
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [relationship](#relationship-object) object | A pending request, an existing relationship, or an accepted friendship was returned |
+| 400 | [error response](/http-api/#error-response) | The caller or target is ineligible, target or block policy rejects the request, deployment policy disables the operation, or the [relationship limit](#relationship-limit) is reached |
+| 403 | [error response](/http-api/#error-response) | The caller's email address is unverified and the request returns `FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED` |
+| 404 | [error response](/http-api/#error-response) | The caller, the target account, or the pending request being accepted does not exist |
+
+### Side effects
+
+A new request writes matching `OUTGOING_REQUEST` and `INCOMING_REQUEST` records and emits [Relationship Add](/gateway/events/#relationship-add) to both accounts. Both records are written with `share_voice_activity` set to true.
+
+When the target has already sent the caller a request, the operation accepts that request. Acceptance replaces both pending records with FRIEND records and emits [Relationship Update](/gateway/events/#relationship-update) to both accounts, each side taking its own sharing default. No acceptance path on this route opens a direct message channel, unlike [Accept request or block user](#accept-request-or-block-user).
+
+A bot target that has `FRIENDLY_BOT` without `FRIENDLY_BOT_MANUAL_APPROVAL` accepts in the same call, so the caller observes [Relationship Add](/gateway/events/#relationship-add) followed by [Relationship Update](/gateway/events/#relationship-update). Both flags are read only on a bot account. The response body of that call is the bot's own record, and its `id` and `user` name the caller and its `share_voice_activity` is the bot's default. The Dispatch the caller receives has the caller's record.
+
+Staff forced acceptance replaces every existing record between the two accounts, in either direction, with a friendship, and emits [Relationship Add](/gateway/events/#relationship-add) to both. A replaced record emits at most one [Relationship Remove](/gateway/events/#relationship-remove) to each account first. When the two accounts already hold a mutual friendship and nothing else, the existing record comes back unchanged. Forced acceptance still enforces the self rule, both unresolved account rules, the deleted account rule on both parties, the unclaimed account rule, and the email verification rule. It enforces no relationship rule, so it overrides a block in either direction, the target's [friend source flags](/http-api/users/#friend-source-flags), the bot target rule, and the [relationship limit](#relationship-limit).
+
+### Rate limit
+
+10 requests per minute for each authenticated user, on the `user:friend_request:send` bucket, shared with [Send friend request by tag](#send-friend-request-by-tag).
+
+## Accept request or block user
+
+
+
+Accepts a pending incoming friend request, or blocks the selected account when the body selects BLOCKED. Returns a [relationship](#relationship-object) object on success.
+
+Acceptance evaluates the deployment direct message policy first and returns 400 `DIRECT_MESSAGES_DISABLED` while that policy disables direct messages, with no staff exemption. An unresolved caller or requester returns 404 `UNKNOWN_USER`. A deleted account on either side returns 400 `FRIEND_REQUEST_BLOCKED`. An unclaimed caller returns 400 `UNCLAIMED_ACCOUNT_CANNOT_ACCEPT_FRIEND_REQUESTS`. A missing pending incoming request returns 404 `UNKNOWN_USER`, and the [relationship limit](#relationship-limit) then applies to both accounts. Acceptance has no email verification requirement.
+
+Blocking returns 404 `UNKNOWN_USER` for an unresolved target and 400 `CANNOT_BLOCK_SYSTEM_USER` for the Fluxer system account. Those are its only admission rules, so blocking runs regardless of the deployment direct message policy, the unclaimed account rule, the email verification rule, and the [relationship limit](#relationship-limit). It applies no self-target rule.
+
+:::caution[The type member selects the operation]
+Only the exact value BLOCKED selects blocking. Every other accepted [relationship type](#relationship-types), an omitted `type`, and an omitted body all select acceptance of a pending incoming request. Sending FRIEND does not force a friendship where no request exists.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the target account |
+
+### JSON body
+
+The body can be omitted, in which case it is treated as an empty object and acceptance is selected.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| type?1 | integer | The [relationship type](#relationship-types) selecting the operation |
+
+1 One of the four enumerated types when present. Only BLOCKED selects blocking, and every other accepted value selects acceptance
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [relationship](#relationship-object) object | The friendship or the block was returned |
+| 400 | [error response](/http-api/#error-response) | An account is ineligible, the block target is the system account, deployment policy disables acceptance, or the [relationship limit](#relationship-limit) is reached |
+| 404 | [error response](/http-api/#error-response) | The target account does not exist, or no pending incoming request exists |
+
+### Side effects
+
+Acceptance replaces the pending records with FRIEND records on both accounts and emits [Relationship Update](/gateway/events/#relationship-update) to each. It then opens the pair's direct message channel for both accounts, creating the channel when none exists, and emits [Channel Create](/gateway/events/#channel-create) to each account whose open state changed. No other relationship operation opens one.
+
+Blocking replaces the caller's existing friendship or pending request with a BLOCKED record. Removing a friendship or an outgoing request also removes the target's reciprocal record and emits [Relationship Remove](/gateway/events/#relationship-remove) to the target. Removing an incoming request touches the caller's record alone. [Relationship Add](/gateway/events/#relationship-add) then reaches the caller for the block. Blocking an already blocked target changes nothing. Blocking never closes or deletes the pair's direct message channel.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `user:friend_request:accept` bucket.
+
+## Remove relationship
+
+
+
+Removes the caller's current friendship, pending friend request in either direction, or block involving the selected account. Returns 204 with an empty body on success.
+
+The route reads no request body. The caller's current record determines what is removed. A caller holding no record of any type receives 404 `UNKNOWN_USER`. Removal has no deployment policy, email verification, unclaimed account, or limit rule, so a relationship stays removable while every creation path is closed.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the related account |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | The relationship was removed |
+| 404 | [error response](/http-api/#error-response) | No relationship of any type exists with the target |
+
+### Side effects
+
+Removing a friendship or an outgoing request removes both accounts' records and emits [Relationship Remove](/gateway/events/#relationship-remove) to each account.
+
+A block is stored on one account only, so removing one deletes the caller's record and emits [Relationship Remove](/gateway/events/#relationship-remove) to the caller alone.
+
+:::caution[Rejecting an incoming request leaves the sender's record]
+Removing an `INCOMING_REQUEST` record deletes only the caller's record and notifies only the caller. The sender keeps its `OUTGOING_REQUEST` record and cannot tell the request was rejected. [Ignore pending requests in bulk](#ignore-pending-requests-in-bulk) removes both records instead.
+:::
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:relationship:delete` bucket.
+
+## Modify relationship nickname
+
+
+
+Sets or clears the caller's private nickname for a friend. Returns the updated [relationship](#relationship-object) object. Emits a [Relationship Update](/gateway/events/#relationship-update) Gateway event.
+
+Only a FRIEND record accepts a nickname, and every other relationship state returns 404 `UNKNOWN_USER`.
+
+:::note[The nickname is private to the caller]
+Fluxer writes it only to the caller's record. The friend never reads it in a response and never receives it in a Dispatch.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | snowflake | The ID of the friend |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| nickname1 | ?string | The nickname to store, or null to clear it |
+
+1 The member is required, so an empty body is rejected. The value is trimmed, stripped of the form feed and right-to-left override characters, and then at most 256 characters
+
+A literal empty string clears the nickname, the same as null. A value that is only whitespace normalises to an empty string and is stored as one.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [relationship](#relationship-object) object | The updated relationship was returned |
+| 404 | [error response](/http-api/#error-response) | No FRIEND record exists with the target |
+
+### Side effects
+
+The record changes only for the caller, and the sharing preference keeps its stored value. A stored `since` value is preserved, and a record that has none is stamped with the current time. [Relationship Update](/gateway/events/#relationship-update) reaches the caller for every accepted request, including one that submits the value already stored.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:relationship:update` bucket.
+
+## Ignore pending requests in bulk
+
+
+
+Removes matching incoming friend requests. Returns a [bulk ignore result](#bulk-ignore-result-object) object on success.
+
+Matching applies only to the caller's `INCOMING_REQUEST` records. Fluxer derives a sender's account age from the timestamp embedded in its [snowflake](/snowflakes/) and compares it against `max_account_age_seconds` as an exclusive upper bound, so a sender exactly that old is not matched. One call removes every matching request.
+
+:::caution[An unbounded new_accounts filter removes everything]
+The age comparison applies only when `max_account_age_seconds` is supplied. Selecting `new_accounts` without it removes every incoming friend request, exactly as `all` does.
+:::
+
+### JSON body
+
+The body can be omitted, in which case it is treated as an empty object and the `all` filter applies.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| filter? | string | The [bulk ignore filter](#bulk-ignore-filters) to apply (default `all`) |
+| max_account_age_seconds?1 | integer | The exclusive maximum sender account age in seconds |
+
+1 A positive integer, read only by the `new_accounts` filter
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [bulk ignore result](#bulk-ignore-result-object) object | Matching requests were removed |
+
+### Side effects
+
+Each matched request removes the caller's `INCOMING_REQUEST` record and the sender's `OUTGOING_REQUEST` record, and emits [Relationship Remove](/gateway/events/#relationship-remove) to the caller and to the sender. A call that matches nothing changes nothing.
+
+Fluxer processes senders one at a time, and the operation is not atomic across them. A failure part of the way through keeps the removals already made, and the call returns no count.
+
+### Rate limit
+
+5 requests per minute for each authenticated user, on the `user:friend_request:bulk_ignore` bucket.
diff --git a/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md b/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md
new file mode 100644
index 000000000..ffd23d172
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md
@@ -0,0 +1,852 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: User settings Protobuf
+description: The client preference snapshot in the user settings object.
+---
+
+A synced preferences snapshot holds the client settings an account shares between its devices. It travels as one base64-encoded `fluxer.user.preferences.v1.SyncedPreferences` message in the `synced_preferences` field of the [user settings object](/http-api/users/#user-settings-object). Fluxer stores it and interprets no field on this page.
+
+## Reading and writing the snapshot
+
+Decode the string before reading any preference, and encode a valid `SyncedPreferences` message when writing one. [Modify current user settings](/http-api/users/settings/#modify-current-user-settings) takes the complete snapshot every time.
+
+The empty string in a response means nothing is stored. Sending null clears the stored snapshot, and so does sending the empty string. A stored snapshot reaches the account's other sessions through [User Settings Update](/gateway/events/#user-settings-update).
+
+Fluxer decodes every submitted snapshot and re-encodes it in canonical form before storing it, so a read can return a different string from the one submitted. Known fields are emitted in ascending field number order, and an unrecognised field number is preserved and re-emitted after them. Enums here are open, and an unassigned numeric value survives the round trip. When every known field holds its zero value and no unrecognised field is present, the snapshot encodes to zero bytes and is stored as the empty string.
+
+A submission may use either the standard or the URL-safe base64 alphabet, with or without padding. Fluxer always returns the standard alphabet with padding.
+
+## How to read the type column
+
+Types here are the declared Protobuf types. A field whose declared type is an enumeration appears as `int32`, its wire representation, and the Description column links the enumeration.
+
+A field without the optional marker always decodes and holds its Protobuf zero value: false for a bool, 0 for a numeric type, the empty string, an empty repeated field, or an empty map. A field with the optional marker tracks presence and is absent until a value is stored. Every singular message field tracks presence the same way, so a preference group is absent from the snapshot until a client writes to it.
+
+Fluxer applies no bound to any individual field of the message, so every length, range, and enumeration membership below describes what the first-party client writes and reads.
+
+## Rejection grounds
+
+Fluxer reports every entry in the resulting `errors` array against the path `synced_preferences`. It checks the encoded string first.
+
+| Condition | Status and code | Element codes |
+| --- | --- | --- |
+| Encoded string longer than 349528 characters | 400 `INVALID_FORM_BODY` | `CONTENT_EXCEEDS_MAX_LENGTH` and `INVALID_FORMAT` |
+| Encoded string outside the base64 alphabet | 400 `INVALID_FORM_BODY` | `INVALID_FORMAT` |
+| Decoded message above 262144 bytes | 400 `INVALID_FORM_BODY` | `TOO_LARGE` |
+| Bytes that do not decode as `SyncedPreferences` | 400 `INVALID_FORM_BODY` | `INVALID_FORMAT` |
+
+An over-length string draws two entries for the one path.
+
+## Synced preferences object
+
+The `SyncedPreferences` message is the root of the snapshot. Every field is a preference group defined in its own section, except `sanitize_urls` and `save_camera_uploads_to_device`, which are bools. Field numbers are allocated in blocks: 1 to 3, 20 to 25, 40 to 45, 60 to 63, 80 to 82, and 100 to 113. Never derive a field number from a field's position in this table.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| accessibility? | [accessibility settings](#accessibility-settings-object) object | Accessibility, display, motion, media, and interaction preferences |
+| accessibility_overrides? | [accessibility overrides](#accessibility-overrides-object) object | Dirty flags for three media settings, with no live consumer |
+| textual_preview? | [textual preview settings](#textual-preview-settings-object) object | Textual preview wrapping preferences |
+| emoji_picker? | [emoji picker state](#emoji-picker-state-object) object | Emoji picker usage, favourites, and collapsed categories |
+| sticker_picker? | [sticker picker state](#sticker-picker-state-object) object | Sticker picker usage, favourites, and collapsed categories |
+| memes_picker? | [memes picker state](#memes-picker-state-object) object | Meme picker usage, favourites, and collapsed categories |
+| emoji? | [emoji state](#emoji-state-object) object | Emoji skin tone preference |
+| emoji_sticker_layout? | [emoji and sticker layout settings](#emoji-and-sticker-layout-settings-object) object | Emoji and sticker picker layout preferences |
+| favorite_gifs?1 | [favourite GIF settings](#favourite-gif-settings-object) object | Saved favourite GIF entries |
+| favorites? | [favourites state](#favourites-state-object) object | Favourite channel and category layout |
+| recent_mentions? | [recent mentions settings](#recent-mentions-settings-object) object | Recent mention inclusion filters |
+| sidebar? | [sidebar preferences](#sidebar-preferences-object) object | Direct message sidebar state |
+| member_list? | [member list state](#member-list-state-object) object | Member list visibility state |
+| unread_channels? | [unread channels state](#unread-channels-state-object) object | Collapsed unread channel state |
+| mention_frecency? | [mention frecency state](#mention-frecency-state-object) object | Per-guild mention frequency and recency state |
+| nagbars? | [nagbar dismissals](#nagbar-dismissals-object) object | Dismissed account and guild notice state |
+| dismissed_upsells? | [dismissed upsells](#dismissed-upsells-object) object | Dismissed upsell state |
+| guild_nsfw_agreements? | [guild mature content agreements](#guild-mature-content-agreements-object) object | Accepted mature guild, category, and channel notices |
+| whats_new? | [what's new state](#whats-new-state-object) object | Last dismissed update entry |
+| privacy? | [privacy preferences](#privacy-preferences-object) object | Stream preview, activity, and attachment privacy preferences |
+| local_spam_overrides? | [local user spam overrides](#local-user-spam-overrides-object) object | Local user spam classification overrides |
+| sanitize_urls2 | bool | Whether tracking parameters are stripped from the URLs in an outgoing message |
+| sound? | [sound settings](#sound-settings-object) object | Sound enablement, volume, and per-sound overrides |
+| spellcheck? | [spellcheck settings](#spellcheck-settings-object) object | Spellcheck language, dictionary, and engine preferences |
+| search_engines? | [search engine settings](#search-engine-settings-object) object | Text, reverse image, and translation provider choices |
+| permission_layout? | [permission layout settings](#permission-layout-settings-object) object | Permission editor layout preferences |
+| guild_member_layout? | [guild member layout settings](#guild-member-layout-settings-object) object | Guild member view layout preference |
+| guild_folders?3 | [guild folder expanded state](#guild-folder-expanded-state-object) object | Expanded guild folder identifiers |
+| hidden_guild_buttons? | [hidden guild list buttons](#hidden-guild-list-buttons-object) object | Hidden guild list button state |
+| keyboard_mode_intro? | [keyboard mode intro state](#keyboard-mode-intro-state-object) object | Keyboard mode introduction state |
+| input_monitoring? | [input monitoring prompts state](#input-monitoring-prompts-state-object) object | Input monitoring prompt state |
+| voice_prompts? | [voice prompts state](#voice-prompts-state-object) object | Suppressed voice confirmation prompt state |
+| sudo_prompt? | [sudo prompt state](#sudo-prompt-state-object) object | Last used sudo verification method |
+| keybinds? | [keybind settings](#keybind-settings-object) object | Custom keybinds and transmit mode preferences |
+| chat_input? | [chat input settings](#chat-input-settings-object) object | Chat composer behaviour preferences |
+| save_camera_uploads_to_device?4 | bool | Whether a camera upload is also written to the device |
+
+1 The entries live inside the snapshot, and the account [memes](/http-api/memes/) collection holds none of them
+
+2 The only bare scalar on the root message, so it has no presence and reads as false until a client stores true. It applies to the content a client is about to send, and the first-party client leaves the URLs inside inline code and code blocks unchanged
+
+3 Holds only which folders are expanded. The folder layout itself is `guild_folders` on the [user settings object](/http-api/users/#user-settings-object)
+
+4 The only optional scalar on the root message, so it tracks presence. No live surface reads or writes it
+
+## Accessibility settings object
+
+The `accessibility` field has display, motion, message, media, voice, and interaction presentation preferences. Almost every field is optional, so distinguish an absent field from a stored zero value before applying a default.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| saturation_factor?1 | double | Colour saturation multiplier |
+| always_underline_links | bool | Whether links are always underlined |
+| enable_text_selection? | bool | Whether message text can be selected |
+| show_message_send_button? | bool | Whether the message composer shows a send button |
+| show_textarea_focus_ring? | bool | Whether the message composer shows a focus ring |
+| hide_keyboard_hints | bool | Whether keyboard shortcut hints are hidden |
+| escape_exits_keyboard_mode? | bool | Whether Escape exits keyboard navigation mode |
+| sync_reduced_motion_with_system? | bool | Whether reduced motion follows the operating system preference |
+| reduced_motion_override?2 | bool | Explicit reduced motion preference |
+| message_group_spacing?3 | double | Spacing between message groups |
+| message_gutter?3 | double | Horizontal message gutter size |
+| font_size?3 | double | Message font size |
+| show_user_avatars_in_compact_mode? | bool | Whether compact messages show user avatars |
+| mobile_sticker_animation_overridden4 | bool | Whether the mobile sticker animation value applies on a mobile layout |
+| mobile_gif_autoplay_overridden4 | bool | Whether the mobile GIF autoplay value applies on a mobile layout |
+| mobile_animate_emoji_overridden4 | bool | Whether the mobile emoji animation value applies on a mobile layout |
+| mobile_sticker_animation_value?5 | int32 | Mobile [sticker animation setting](/http-api/users/#sticker-animation-settings) |
+| mobile_gif_autoplay_value? | bool | Mobile GIF autoplay value |
+| mobile_animate_emoji_value? | bool | Mobile emoji animation value |
+| auto_send_klipy_gifs | bool | Whether selecting a GIF sends it immediately |
+| show_gif_button? | bool | Whether the composer shows the GIF button |
+| show_memes_button? | bool | Whether the composer shows the memes button |
+| show_stickers_button? | bool | Whether the composer shows the sticker button |
+| show_emoji_button? | bool | Whether the composer shows the emoji button |
+| show_media_favorite_button? | bool | Whether media actions show the favourite button |
+| show_media_download_button? | bool | Whether media actions show the download button |
+| show_media_delete_button? | bool | Whether media actions show the delete button |
+| show_suppress_embeds_button? | bool | Whether message actions show the suppress embeds button |
+| show_gif_indicator? | bool | Whether GIF media shows a GIF indicator |
+| show_attachment_expiry_indicator? | bool | Whether attachments show their expiry indicator |
+| use_browser_locale_for_time_format? | bool | Whether time formatting uses the browser locale |
+| channel_typing_indicator_mode | int32 | [Channel typing indicator mode](#channel-typing-indicator-modes) |
+| show_selected_channel_typing_indicator? | bool | Whether the typing indicator is shown for the selected channel |
+| show_message_action_bar? | bool | Whether messages show the action bar |
+| show_message_action_bar_quick_reactions? | bool | Whether the message action bar shows quick reactions |
+| show_message_action_bar_shift_expand? | bool | Whether holding Shift expands the message action bar |
+| show_message_action_bar_only_more_button? | bool | Whether the message action bar shows only the more button |
+| show_default_emojis_in_autocomplete? | bool | Whether autocomplete includes standard emoji |
+| show_custom_emojis_in_autocomplete? | bool | Whether autocomplete includes custom emoji |
+| show_stickers_in_autocomplete? | bool | Whether autocomplete includes stickers |
+| show_memes_in_autocomplete? | bool | Whether autocomplete includes memes |
+| voice_channel_join_requires_double_click? | bool | Whether joining a voice channel requires a double click |
+| custom_theme_css?6 | string | Custom theme CSS |
+| show_favorites? | bool | Whether the favourites section is shown |
+| zoom_level?7 | double | Application zoom level |
+| dm_message_preview_mode | int32 | [Direct message preview mode](#direct-message-preview-modes) |
+| enable_tts_command? | bool | Whether the text-to-speech command is enabled |
+| tts_rate?8 | double | Text-to-speech playback rate |
+| show_faded_unread_on_muted_channels? | bool | Whether muted channels retain a faded unread indicator |
+| show_context_menu_shortcuts? | bool | Whether context menus show keyboard shortcuts |
+| confirm_before_starting_calls? | bool | Whether starting a call requires confirmation |
+| hdr_display_mode | int32 | [HDR display mode](#hdr-display-modes) |
+| preserve_edit_draft? | bool | Whether cancelling a message edit preserves its draft |
+| stay_interactive_when_unfocused? | bool | Whether animation and interaction remain active while the application is unfocused |
+| confirm_before_joining_voice_channels? | bool | Whether joining a voice channel requires confirmation |
+| screen_reader_announce_new_messages? | bool | Whether a screen reader announces new messages |
+| first_click_pass_through_when_unfocused? | bool | Whether the first click also activates its control when the application is unfocused |
+| compact_message_group_spacing?3 | double | Spacing between compact message groups |
+| scroll_to_bottom_on_message_send? | bool | Whether sending a message scrolls the channel to the bottom |
+| dim_strikethrough_text? | bool | Whether strikethrough text is visually dimmed |
+| sequential_file_send? | bool | Whether multiple selected files are sent one message at a time |
+| mobile_splash_zoom_animation? | bool | Whether the mobile splash screen plays its zoom animation |
+
+1 A multiplier applied to interface colour, where 1 leaves colour unchanged
+
+2 Read only while `sync_reduced_motion_with_system` is false
+
+3 A length in CSS pixels. The first-party client defaults to 16 for `message_group_spacing`, `message_gutter`, and `font_size`, and to 0 for `compact_message_group_spacing`
+
+4 The paired `mobile_*_value` field is read only while this flag is true, and only on a mobile layout. While the flag is false the first-party client falls back to the account-level setting for emoji animation, and to a fixed mobile default for GIF autoplay and sticker animation
+
+5 The same enumeration as `animate_stickers` on the [user settings object](/http-api/users/#user-settings-object). This field is the mobile-local replacement
+
+6 The complete CSS of the account's synced custom theme, stored inline. A client that has opted out of syncing its theme applies a local one instead and re-emits this value unchanged, so it does not clobber the devices that do sync
+
+7 A multiplier, where 1 is unscaled. The first-party client keeps its zoom level in browser storage and neither reads nor writes this field
+
+8 A multiplier, where 1 is the unmodified speaking rate
+
+Field numbers 42 and 43 are reserved, together with the names `attachment_media_dimension_size` and `embed_media_dimension_size`. Neither number can be reused.
+
+### Channel typing indicator modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | CHANNEL_TYPING_INDICATOR_MODE_UNSPECIFIED | No explicit mode is selected |
+| 1 | CHANNEL_TYPING_INDICATOR_MODE_AVATARS | Show typing users as avatars |
+| 2 | CHANNEL_TYPING_INDICATOR_MODE_INDICATOR_ONLY | Show a typing indicator without avatars |
+| 3 | CHANNEL_TYPING_INDICATOR_MODE_HIDDEN | Hide the typing indicator |
+
+### Direct message preview modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | DM_MESSAGE_PREVIEW_MODE_UNSPECIFIED | No explicit mode is selected |
+| 1 | DM_MESSAGE_PREVIEW_MODE_ALL | Show previews for all direct messages |
+| 2 | DM_MESSAGE_PREVIEW_MODE_UNREAD_ONLY | Show previews only for unread direct messages |
+| 3 | DM_MESSAGE_PREVIEW_MODE_NONE | Hide direct message previews |
+
+### HDR display modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | HDR_DISPLAY_MODE_UNSPECIFIED | No explicit mode is selected |
+| 1 | HDR_DISPLAY_MODE_FULL | Display HDR media without constraining its range |
+| 2 | HDR_DISPLAY_MODE_STANDARD | Display HDR media using the standard presentation |
+
+## Accessibility overrides object
+
+The `accessibility_overrides` field holds three dirty flags that no live surface reads or writes. The settings they name also appear in [accessibility settings](#accessibility-settings-object) as a `mobile_*_overridden` flag with a matching `mobile_*_value`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| gif_autoplay_dirty | bool | GIF autoplay dirty flag |
+| animate_emoji_dirty | bool | Emoji animation dirty flag |
+| animate_stickers_dirty | bool | Sticker animation dirty flag |
+
+## Textual preview settings object
+
+The `textual_preview` field controls line wrapping in the inline preview of a textual attachment.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| wrap_text | bool | Whether long lines wrap in a textual preview |
+
+## Usage stat object
+
+The usage stat object records picker usage for one expression or one meme. It is the map value of the `usage` field in every picker state group, so the emoji, sticker, and meme pickers all rank their entries from the same shape.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| count | uint32 | Number of recorded uses |
+| last_used_ms1 | int64 | Unix timestamp in milliseconds of the most recent use |
+
+1 Milliseconds since 1970-01-01T00:00:00Z
+
+## Emoji picker state object
+
+The `emoji_picker` field records emoji usage, favourites, and collapsed picker categories.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| usage12 | map[string, [usage stat](#usage-stat-object) object] | Usage keyed by emoji usage key |
+| favorite_emoji_ids1 | array[string] | Favourite emoji usage keys in display order |
+| collapsed_category_ids | array[string] | Collapsed emoji category identifiers |
+
+1 The first-party client writes `unicode:` for a standard emoji and `custom::` for a custom emoji
+
+2 The first-party client drops a key matching neither form when it reads the snapshot, and applies no such filter to `favorite_emoji_ids`
+
+## Sticker picker state object
+
+The `sticker_picker` field records sticker usage, favourites, and collapsed picker categories.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| usage | map[string, [usage stat](#usage-stat-object) object] | Usage keyed by sticker identifier |
+| favorite_sticker_ids | array[string] | Favourite sticker identifiers in display order |
+| collapsed_category_ids | array[string] | Collapsed sticker category identifiers |
+
+## Memes picker state object
+
+The `memes_picker` field records meme usage, favourites, and collapsed picker categories. The memes themselves belong to the account [memes](/http-api/memes/) collection and are not stored here.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| usage | map[string, [usage stat](#usage-stat-object) object] | Usage keyed by meme identifier |
+| favorite_meme_ids | array[string] | Favourite meme identifiers in display order |
+| collapsed_category_ids | array[string] | Collapsed meme category identifiers |
+
+## Emoji state object
+
+The `emoji` field stores the selected emoji skin tone.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| skin_tone1 | string | Selected Unicode skin tone modifier |
+
+1 One of the five Unicode skin tone modifiers, U+1F3FB through U+1F3FF. The empty string selects the default tone
+
+## Emoji and sticker layout settings object
+
+The `emoji_sticker_layout` field controls how the emoji and sticker pickers lay their contents out.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emoji_layout | int32 | [Emoji picker layout](#emoji-picker-layouts) |
+| sticker_view_mode | int32 | [Sticker picker view mode](#sticker-picker-view-modes) |
+
+### Emoji picker layouts
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | EMOJI_PICKER_LAYOUT_UNSPECIFIED | No explicit layout is selected |
+| 1 | EMOJI_PICKER_LAYOUT_LIST | Display emoji in a list layout |
+| 2 | EMOJI_PICKER_LAYOUT_GRID | Display emoji in a grid layout |
+
+### Sticker picker view modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | STICKER_PICKER_VIEW_MODE_UNSPECIFIED | No explicit view mode is selected |
+| 1 | STICKER_PICKER_VIEW_MODE_COZY | Display stickers with the roomy layout |
+| 2 | STICKER_PICKER_VIEW_MODE_COMPACT | Display stickers with the compact layout |
+
+## Favourite GIF settings object
+
+The `favorite_gifs` field stores the GIFs the account has favourited, together with the metadata needed to render one without another lookup. Favouriting a GIF writes an entry here or writes a meme to the account [memes](/http-api/memes/) collection, never both, and `save_as_saved_media` selects which.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| entries1 | array[[favourite GIF entry](#favourite-gif-entry-object) object] | Favourite GIFs in display order |
+| save_as_saved_media2 | bool | Whether favouriting a GIF saves a meme to the account memes collection |
+| seen_first_time_prompt | bool | Whether the first-time favourite prompt has been shown |
+
+1 The first-party client treats `url` as the identity of an entry and refuses to append a second entry with a `url` already present
+
+2 While this is true the first-party client leaves `entries` untouched and favourites through [Save meme from message](/http-api/memes/#save-meme-from-message) instead
+
+## Favourite GIF entry object
+
+One entry describes one favourited GIF. Its fields mirror the [resolved GIF entry object](/http-api/memes/#resolved-gif-entry-object) returned by [Resolve GIF URLs](/http-api/memes/#resolve-gif-urls), so a client can store a resolved entry without reshaping it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| url | string | Source URL of the GIF |
+| proxy_url | string | [Media Proxy](/media-proxy/overview/) URL for the preview format |
+| width1 | uint32 | Width of the preview format in pixels |
+| height1 | uint32 | Height of the preview format in pixels |
+| media | map[string, [favourite GIF media format](#favourite-gif-media-format-object) object] | Formats keyed by [GIF media format name](/http-api/memes/#gif-media-format-names) |
+| content_type2 | string | Media type of the media addressed by `proxy_url` |
+| placeholder3 | string | Compact thumbhash placeholder |
+
+1 Zero when the dimensions could not be determined
+
+2 The empty string means the type is unknown, which a client reads as `image/gif`
+
+3 The empty string means the Media Proxy emitted none
+
+## Favourite GIF media format object
+
+One descriptor addresses one encoding of one favourited GIF. It mirrors the [GIF media format object](/http-api/memes/#gif-media-format-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| src | string | Direct media URL of this format |
+| proxy_src | string | [Media Proxy](/media-proxy/overview/) URL of this format |
+| width | uint32 | Width of this format in pixels |
+| height | uint32 | Height of this format in pixels |
+
+## Favourites state object
+
+The `favorites` field stores favourite channels, the categories they are grouped into, and the presentation state of that grouping. These categories are private to the favourites view.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channels | array[[favourite channel](#favourite-channel-object) object] | Favourite channels |
+| categories | array[[favourite category](#favourite-category-object) object] | Favourite categories |
+| collapsed_category_ids | array[string] | Collapsed favourite category identifiers |
+| hide_muted_channels | bool | Whether muted channels are hidden from favourites |
+| muted | bool | Whether the favourites collection is muted |
+
+## Favourite channel object
+
+One entry marks one channel as a favourite. Channel and guild identifiers here are strings holding the decimal form of a [snowflake](/snowflakes/). Nothing removes an entry when the account loses access to the channel, so an entry can reference a channel the account can no longer resolve.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | string | Channel ID |
+| guild_id | string | Guild ID |
+| parent_id?1 | string | Favourite category the channel is grouped under |
+| position2 | int32 | Position across the whole favourites list |
+| nickname? | string | User-assigned channel nickname |
+
+1 The `id` of a [favourite category](#favourite-category-object), not a guild category channel. An absent value groups the channel outside every category
+
+2 A single ordering over every favourite channel. The first-party client renumbers the whole list from 0 whenever an entry is removed or moved, and appends a new entry at the current entry count without renumbering
+
+## Favourite category object
+
+One entry names one grouping in the favourites view. The client chooses the identifier, so it is not a [snowflake](/snowflakes/) and Fluxer never resolves it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string | Category identifier |
+| name | string | Category name |
+| position1 | int32 | Category position |
+
+1 Renumbered from 0 across every category whenever one is removed or moved
+
+## Recent mentions settings object
+
+The `recent_mentions` field controls which mentions appear in the recent mentions view. The three filters combine, and `include_guilds` selects by channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| include_everyone?1 | bool | Whether to include `@everyone` mentions |
+| include_roles?1 | bool | Whether to include role mentions |
+| include_guilds?12 | bool | Whether to include mentions in a guild channel |
+
+1 The first-party client defaults every filter to true and omits the field while it holds that default, so a client that reads an absent value as the Protobuf zero filters out mentions the account expects to see
+
+2 False keeps direct message mentions and drops every mention whose channel belongs to a guild
+
+## Sidebar preferences object
+
+The `sidebar` field stores direct message sidebar presentation state.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| inline_dms_collapsed | bool | Whether the inline direct message section is collapsed |
+| show_collapsed_unread_dms_badge?1 | bool | Whether the collapsed section shows an unread direct message badge |
+| show_incoming_friend_request_badge?1 | bool | Whether the sidebar shows an incoming friend request badge |
+
+1 The first-party client defaults it to true and omits the field while it holds that default, so a client that reads an absent value as the Protobuf zero hides a badge the account expects to see
+
+## Member list state object
+
+The `member_list` field stores whether the member list panel is open.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| members_open? | bool | Whether the member list is open |
+
+## Unread channels state object
+
+The `unread_channels` field stores which channels are collapsed in the unread view.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| collapsed_channel_ids | array[string] | Collapsed channel IDs in decimal form |
+
+## Mention frecency state object
+
+The `mention_frecency` field records how often and how recently the account mentioned each user, one record set per guild, so a client can rank mention autocomplete without a server call. It nests two messages, `MentionFrecencyState.Scope` and `MentionFrecencyState.Entry`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| scopes | array[[mention frecency scope](#mention-frecency-scope-object) object] | Per-guild mention records |
+
+## Mention frecency scope object
+
+One scope holds every mention record for one guild. It is declared as `MentionFrecencyState.Scope`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | Guild ID in decimal form |
+| entries | array[[mention frecency entry](#mention-frecency-entry-object) object] | User mention records for the guild |
+
+## Mention frecency entry object
+
+One entry counts mentions of one user inside one scope. It is declared as `MentionFrecencyState.Entry`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | string | Mentioned user ID in decimal form |
+| count | uint32 | Number of recorded mentions |
+| last_at_ms1 | int64 | Unix timestamp in milliseconds of the most recent mention |
+
+1 Milliseconds since 1970-01-01T00:00:00Z
+
+## Nagbar dismissals object
+
+The `nagbars` field stores dismissed account-wide notices as bools and dismissed per-target notices as maps. Field number 13 is not assigned.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| ios_install | bool | Whether the iOS installation notice is dismissed |
+| pwa_install | bool | Whether the progressive web application installation notice is dismissed |
+| push_notification | bool | Whether the push notification notice is dismissed |
+| desktop_notification | bool | Whether the desktop notification notice is dismissed |
+| premium_grace_period | bool | Whether the premium grace period notice is dismissed |
+| premium_expired | bool | Whether the premium expiry notice is dismissed |
+| premium_onboarding | bool | Whether the premium onboarding notice is dismissed |
+| premium_trial_expiring | bool | Whether the premium trial expiry notice is dismissed |
+| gift_inventory | bool | Whether the gift inventory notice is dismissed |
+| desktop_download | bool | Whether the desktop download notice is dismissed |
+| guild_membership_cta | bool | Whether the guild membership call to action notice is dismissed |
+| visionary_mfa1 | bool | Whether the lifetime premium MFA notice is dismissed |
+| legacy_phone_unlink | bool | Whether the legacy phone unlink notice is dismissed |
+| pending_bulk_deletion | map[string, bool] | Dismissal state keyed by pending bulk deletion identifier |
+| invites_disabled2 | map[string, bool] | Dismissal state keyed by guild ID for disabled-invite notices |
+| guild_mfa_requirement2 | map[string, bool] | Dismissal state keyed by guild ID for MFA requirement notices |
+
+1 The notice this dismisses is shown only to an account holding lifetime premium with no MFA authenticator enrolled
+
+2 Keyed by the decimal form of a guild [snowflake](/snowflakes/). A missing key reads as not dismissed
+
+## Dismissed upsells object
+
+The `dismissed_upsells` field stores dismissed upsells.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| picker_premium | bool | Whether the premium picker upsell is dismissed |
+
+## Guild mature content agreements object
+
+The `guild_nsfw_agreements` field stores mature content acknowledgements. Acceptance is recorded per target, so accepting for a guild does not record acceptance for its categories or channels.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| agreed_channel_ids | array[string] | Channel IDs whose mature content notice was accepted |
+| agreed_guild_ids | array[string] | Guild IDs whose mature content notice was accepted |
+| agreed_category_ids | array[string] | Category IDs whose mature content notice was accepted |
+
+## What's new state object
+
+The `whats_new` field stores the most recently dismissed update entry.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| last_dismissed_entry_id? | string | Most recently dismissed update entry ID |
+
+## Privacy preferences object
+
+The `privacy` field stores client privacy behaviour. Fluxer reads none of it, and none of it changes the privacy fields of the [user settings object](/http-api/users/#user-settings-object), which govern what Fluxer itself discloses about the account.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| disable_stream_previews1 | bool | Whether stream previews are disabled |
+| show_active_now? | bool | Whether the active now view is shown |
+| preupload_message_attachments? | bool | Whether message attachments are uploaded before the message is sent |
+
+1 The only field in this group whose effect reaches past the account's own view. While it is true the first-party client uploads no stream preview frame, so no other viewer receives one
+
+## Local user spam overrides object
+
+The `local_spam_overrides` field stores client-local classifications that override the [SPAMMER public user flag](/http-api/users/#public-user-flags) in presentation only. Fluxer neither reads these lists nor changes any flag because of them.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| spammer_user_ids | array[string] | User IDs presented locally as spammers |
+| not_spammer_user_ids1 | array[string] | User IDs presented locally as not being spammers |
+
+1 This list wins over `spammer_user_ids` and over the `SPAMMER` flag, so a user ID present in both lists is presented as not a spammer
+
+## Sound settings object
+
+The `sound` field controls sound playback, master volume, and per-sound overrides.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| all_sounds_disabled | bool | Whether every application sound is disabled |
+| master_volume?1 | double | Master sound volume |
+| disabled_sounds | map[string, bool] | Disabled state keyed by [sound identifier](#sound-identifiers) |
+| sound_overrides2 | map[string, double] | Volume override keyed by [sound identifier](#sound-identifiers) |
+
+1 A percentage. The first-party client clamps it to the range 0 through 200, treats 100 as the default, and omits the field entirely while it holds the default
+
+2 A percentage on the same scale, and the complete volume for that one sound
+
+### Sound identifiers
+
+Both maps are keyed by an arbitrary string, and Fluxer stores and returns any key unchanged. The first-party client uses the identifiers below, and a key outside this set has no defined playback effect.
+
+| Value | Description |
+| --- | --- |
+| deaf | Deafening sound |
+| undeaf | Undeafening sound |
+| mute | Muting sound |
+| unmute | Unmuting sound |
+| message | Message notification sound |
+| direct-message | Direct message notification sound |
+| same-channel-message | Notification sound for the channel already in view |
+| incoming-ring | Incoming call sound |
+| user-join | Voice participant join sound |
+| user-leave | Voice participant leave sound |
+| user-move | Voice participant move sound |
+| viewer-join | Stream viewer join sound |
+| viewer-leave | Stream viewer leave sound |
+| voice-disconnect | Voice disconnection sound |
+| camera-on | Camera enabled sound |
+| camera-off | Camera disabled sound |
+| screen-share-start | Screen share start sound |
+| screen-share-stop | Screen share stop sound |
+
+## Spellcheck settings object
+
+The `spellcheck` field controls spellchecking, the selected languages, and the personal dictionary.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| enabled? | bool | Whether spellchecking is enabled |
+| languages1 | array[string] | Selected spellcheck languages |
+| personal_dictionary1 | array[string] | Words in the personal dictionary |
+| auto_detect? | bool | Whether spellcheck languages are detected automatically |
+| engine?2 | string | Selected spellcheck engine identifier |
+
+1 The first-party client drops an empty entry and a duplicate entry when it reads the snapshot
+
+2 The first-party client recognises `auto`, `system`, and `hunspell`, and ignores any other value
+
+## Search engine settings object
+
+The `search_engines` field stores the selected external providers. Every identifier names a provider the client defines, and Fluxer neither validates nor resolves one.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text_search_engine_id? | string | Text search engine identifier |
+| reverse_image_search_engine_id? | string | Reverse image search engine identifier |
+| translation_provider_id? | string | Translation provider identifier |
+
+## Permission layout settings object
+
+The `permission_layout` field controls the layout of the [permission](/http-api/permissions/) editor.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| layout | int32 | [Permission layout mode](#permission-layout-modes) |
+| grid | int32 | [Permission grid mode](#permission-grid-modes) |
+
+### Permission layout modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | PERMISSION_LAYOUT_MODE_UNSPECIFIED | No explicit layout is selected |
+| 1 | PERMISSION_LAYOUT_MODE_COMFY | Use the roomy permission layout |
+| 2 | PERMISSION_LAYOUT_MODE_DENSE | Use the dense permission layout |
+
+### Permission grid modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | PERMISSION_GRID_MODE_UNSPECIFIED | No explicit grid mode is selected |
+| 1 | PERMISSION_GRID_MODE_SINGLE | Show one permission category at a time |
+| 2 | PERMISSION_GRID_MODE_GRID | Show permission categories in a grid |
+
+## Guild member layout settings object
+
+The `guild_member_layout` field controls how the guild member view presents its members.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| mode | int32 | [Guild member view mode](#guild-member-view-modes) |
+
+### Guild member view modes
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | GUILD_MEMBER_VIEW_MODE_UNSPECIFIED | No explicit view mode is selected |
+| 1 | GUILD_MEMBER_VIEW_MODE_TABLE | Display guild members in a table |
+| 2 | GUILD_MEMBER_VIEW_MODE_GRID | Display guild members in a grid |
+
+## Guild folder expanded state object
+
+The `guild_folders` field stores which guild folders are expanded in the sidebar. The folders themselves are `guild_folders` on the [user settings object](/http-api/users/#user-settings-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| expanded_folder_ids1 | array[fixed64] | Expanded guild folder identifiers |
+
+1 Each value is the `id` of a [guild folder object](/http-api/users/#guild-folder-object). An identifier for a folder the layout no longer holds stays in the array and has no effect
+
+## Hidden guild list buttons object
+
+The `hidden_guild_buttons` field stores which guild list controls are hidden.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| download_button | bool | Whether the download button is hidden |
+| help_button | bool | Whether the help button is hidden |
+
+## Keyboard mode intro state object
+
+The `keyboard_mode_intro` field stores whether the keyboard navigation introduction has been seen.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| seen | bool | Whether the keyboard navigation introduction was seen |
+
+## Input monitoring prompts state object
+
+The `input_monitoring` field stores whether the input monitoring prompt has been seen. No live surface reads or writes it.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| seen_cta | bool | Whether the input monitoring call to action was seen |
+
+## Voice prompts state object
+
+The `voice_prompts` field stores which voice confirmation prompts the account has suppressed.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| skip_hide_own_camera_confirm | bool | Whether hiding the caller's own camera skips confirmation |
+| skip_hide_own_screenshare_confirm | bool | Whether hiding the caller's own screen share skips confirmation |
+
+## Sudo prompt state object
+
+The `sudo_prompt` field stores the verification method the account used most recently, so a client can preselect it the next time [sudo mode](/http-api/users/mfa/#sudo-mode) is required.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| last_used_mfa_method?1 | int32 | [MFA method](#mfa-methods) most recently used for sudo verification |
+
+1 The two assigned values correspond to `totp` and `webauthn` in `mfa_method` of the [sudo verification object](/http-api/users/mfa/#sudo-verification-object)
+
+### MFA methods
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | MFA_METHOD_UNSPECIFIED | No method is recorded |
+| 1 | MFA_METHOD_TOTP | Time-based one-time password |
+| 3 | MFA_METHOD_WEBAUTHN | WebAuthn credential |
+
+The value 2 is not assigned.
+
+## Keybind settings object
+
+The `keybinds` field stores custom keybinds and voice transmit behaviour.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| custom_keybinds | array[[custom keybind](#custom-keybind-object) object] | Custom keybinds |
+| transmit_mode1 | string | Voice transmit mode |
+| push_to_talk_release_delay_ms?2 | uint32 | Push-to-talk release delay in milliseconds |
+
+1 The first-party client recognises `voice_activity` and `voice_push_to_talk`, and falls back to `voice_activity` for the empty string and for any other value
+
+2 Read only while `transmit_mode` is `voice_push_to_talk`. The first-party client clamps it to the range 20 through 2000, treats 20 as the default, and omits the field entirely while it holds the default
+
+## Custom keybind object
+
+One entry binds one input combination to one action. The entry has its own `enabled` flag and its combination has another. The first-party client gates a custom keybind on the entry's flag alone, and reads `enabled` on a combination only for a built-in default binding.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | Keybind identifier |
+| action?2 | string | Action performed by the keybind |
+| combo? | [keybind combo](#keybind-combo-object) object | Input combination |
+| enabled | bool | Whether the keybind is enabled |
+
+1 Chosen by the client. The first-party client generates a replacement when it reads an entry whose `id` is the empty string
+
+2 An action name the client defines. The first-party client keeps an entry whose action it does not recognise and clears `action`
+
+## Keybind combo object
+
+One combination describes the key, modifiers, and buttons that trigger a keybind. The modifier flags are independent of each other, and `ctrl_or_meta` is a modifier of its own.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key | string | Key value |
+| code? | string | Physical key code |
+| ctrl_or_meta1 | bool | Whether Control or Meta is required according to the platform |
+| ctrl | bool | Whether Control is required |
+| alt | bool | Whether Alt is required |
+| shift | bool | Whether Shift is required |
+| meta | bool | Whether Meta is required |
+| global? | bool | Whether the combination is registered globally |
+| enabled?2 | bool | Whether the combination is active |
+| modifier_only | bool | Whether the combination contains only modifier keys |
+| both_sides | bool | Whether either side of a paired modifier is accepted |
+| mouse_button? | uint32 | Mouse button number |
+| gamepad_button? | uint32 | Gamepad button number |
+
+1 Resolves to Meta on macOS and to Control on every other platform, so one stored combination expresses the platform-native accelerator
+
+2 Distinct from `enabled` on the [custom keybind](#custom-keybind-object) that owns the combination, and with the opposite default, because an absent value here reads as active
+
+## Chat input settings object
+
+The `chat_input` field controls message composer behaviour.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| convert_emoticons? | bool | Whether emoticons are converted to emoji |
diff --git a/fluxer_docs/src/content/docs/http-api/users/settings.mdx b/fluxer_docs/src/content/docs/http-api/users/settings.mdx
new file mode 100644
index 000000000..fe4fae612
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/users/settings.mdx
@@ -0,0 +1,496 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: User settings
+description: Account-wide settings, guild folders, notification settings, and voice activity sharing.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+User settings are the stored preferences of one account. Fluxer keeps them in two records: the account-wide [user settings object](/http-api/users/#user-settings-object), and a [user guild settings object](#user-guild-settings-object) for each guild and for private channels.
+
+Every route here is user-only and rejects a bot or OAuth2 bearer credential with 403 `ACCESS_DENIED`. Every route except [Get current user settings](#get-current-user-settings) also rejects an account with an outstanding required action, with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`.
+
+## Presence status values
+
+| Value | Description |
+| --- | --- |
+| online | User is presented as online |
+| dnd | User is presented as do not disturb |
+| idle | User is presented as idle |
+| invisible1 | User is presented as offline to everyone else |
+
+1 The account stays connected and keeps receiving Gateway traffic while this value is stored
+
+:::note[The settings boundary accepts only a chosen status]
+Gateway presence also uses offline, which describes an account with no active session. The settings boundary rejects offline in both `status` and `status_resets_to`, and invisible is the stored form of appearing offline while connected.
+:::
+
+## Theme values
+
+| Value | Description |
+| --- | --- |
+| dark | Dark theme |
+| dark_legacy | Previous generation of the dark theme |
+| coal | Coal theme |
+| light | Light theme |
+| system | Theme follows the operating system |
+
+## Notification levels
+
+| Value | Name | Description |
+| --- | --- | --- |
+| 0 | ALL_MESSAGES | Notify for every message |
+| 1 | ONLY_MENTIONS | Notify only for mentions |
+| 2 | NO_MESSAGES | Do not notify for messages |
+| 3 | INHERIT | Inherit the parent setting |
+
+## Guild mute configuration object
+
+Mute expiry for a guild, a private channel group, or a channel override.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| end_time1 | ?ISO8601 timestamp | The time at which the temporary mute stops applying, or null for no expiry |
+| selected_time_window2 | integer | The opaque mute duration the client selected |
+
+1 An `end_time` in the past no longer mutes notifications but can remain present in the response
+
+2 The value is returned unchanged and takes no part in notification delivery. An absent value is returned as 0
+
+## Guild mute configuration input object
+
+The input form of the [guild mute configuration object](#guild-mute-configuration-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| end_time?1 | ?ISO8601 timestamp \| integer | The time at which the temporary mute stops applying, or null for no expiry |
+| selected_time_window | integer | The opaque mute duration the client selected, stored verbatim |
+
+1 The value is an ISO 8601 timestamp string or Unix milliseconds. A string that is not a valid timestamp is rejected with `INVALID_ISO_TIMESTAMP`
+
+## Channel notification override object
+
+Notification settings stored for one channel.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| collapsed1 | boolean | Whether the channel category is collapsed |
+| message_notifications | integer | [Notification level](#notification-levels) |
+| muted | boolean | Whether channel notifications are muted |
+| mute_config | ?[guild mute configuration](#guild-mute-configuration-object) object | The mute configuration of the channel, or null when none is stored |
+| unread_badges12 | ?integer | [Notification level](#notification-levels) for unread badges, or null to inherit |
+
+1 The value controls client presentation and does not affect notification delivery
+
+2 Every response has the member, with null standing in for an unset level
+
+## Channel notification override input object
+
+The input form of the [channel notification override object](#channel-notification-override-object).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| collapsed | boolean | Whether the channel category is collapsed |
+| message_notifications | integer | [Notification level](#notification-levels) |
+| muted | boolean | Whether channel notifications are muted |
+| mute_config? | ?[guild mute configuration input](#guild-mute-configuration-input-object) object | The mute configuration of the channel, or null to clear it |
+| unread_badges? | ?integer | [Notification level](#notification-levels) for unread badges, or null to inherit |
+
+## User guild settings object
+
+Notification settings for one guild, or for every private channel when `guild_id` is null.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id1 | ?snowflake | The ID of the guild, or null for direct message and group DM settings |
+| message_notifications | integer | The default [notification level](#notification-levels) |
+| muted | boolean | Whether the guild or private channel group is muted |
+| mute_config | ?[guild mute configuration](#guild-mute-configuration-object) object | The mute configuration, or null when none is stored |
+| mobile_push | boolean | Whether notification delivery is enabled for the guild |
+| suppress_everyone | boolean | Whether `@everyone` mentions are suppressed |
+| suppress_roles | boolean | Whether role mentions are suppressed |
+| hide_muted_channels3 | boolean | Whether muted channels are hidden |
+| channel_overrides2 | ?map[snowflake, [channel notification override](#channel-notification-override-object) object] | The per-channel overrides, or null when none are set |
+| unread_badges35 | ?integer | The default [notification level](#notification-levels) for unread badges, or null to follow `message_notifications` |
+| version4 | integer | The monotonic revision of this settings object |
+
+1 The private channel settings are always serialised with `guild_id` null
+
+2 An override map holding no entry is serialised as null
+
+3 The value controls client presentation and does not affect notification delivery
+
+4 The value increases on every stored write, including one that changes no other member, so a client can discard a stale [User Guild Settings Update](/gateway/events/#user-guild-settings-update)
+
+5 Every response has the member, with null standing in for an unset level
+
+### Example
+
+```json
+{
+ "guild_id": null,
+ "message_notifications": 1,
+ "muted": false,
+ "mute_config": null,
+ "mobile_push": true,
+ "suppress_everyone": false,
+ "suppress_roles": false,
+ "hide_muted_channels": false,
+ "channel_overrides": null,
+ "unread_badges": null,
+ "version": 4
+}
+```
+
+## Guild settings update fields
+
+Both notification settings operations accept exactly these fields. Every field is optional, and an omitted field leaves the stored value unchanged.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| message_notifications? | integer | The default [notification level](#notification-levels), INHERIT for new settings |
+| muted? | boolean | Whether the guild is muted, default false for new settings |
+| mute_config? | ?[guild mute configuration input](#guild-mute-configuration-input-object) object | The mute configuration, or null to clear it |
+| mobile_push? | boolean | Whether notification delivery is enabled, default true for new settings |
+| suppress_everyone? | boolean | Whether `@everyone` mentions are suppressed, default false for new settings |
+| suppress_roles? | boolean | Whether role mentions are suppressed, default false for new settings |
+| hide_muted_channels? | boolean | Whether muted channels are hidden, default false for new settings |
+| channel_overrides?1 | ?map[snowflake, [channel notification override input](#channel-notification-override-input-object) object] | The complete per-channel override map, or null to clear it |
+| unread_badges? | ?integer | The default [notification level](#notification-levels) for unread badges, or null to follow `message_notifications` |
+
+1 The supplied map becomes the complete override set, and an empty map clears every override
+
+:::note[Which of these fields the server reads]
+Fluxer reads `suppress_everyone` and `suppress_roles` when it counts the mentions in a guild message. Push notification delivery reads `muted`, `mute_config`, `mobile_push`, `message_notifications`, `suppress_everyone`, and `suppress_roles`, and the `muted` and `message_notifications` members of a channel override. Every other field is stored and returned unchanged.
+:::
+
+A direct mention of the account always counts. An `@everyone` or `@here` mention counts only while `suppress_everyone` is false, and a role mention only while `suppress_roles` is false. A mention that counts increments `mention_count` on the channel's [read state](/http-api/read-states/#read-state-object) and adds an entry to the [recent mention history](/http-api/users/content/#list-recent-mentions). A guild message whose every mention is suppressed records nothing.
+
+Neither field is read outside a guild. A direct message and a group DM collect only direct mentions, and every one of them counts whatever the private channel settings hold.
+
+## User settings update object
+
+The input form of the [user settings object](/http-api/users/#user-settings-object). Every field is optional, and an omitted field leaves the stored value unchanged. A field present with a value overwrites the stored value in full, including an array or object member.
+
+The three staff-only fields are the exception. Every update resets `suppress_unprivileged_self_mentions`, `suppress_unprivileged_self_mentions_bypass_user_ids`, and `staff_dm_access_user_ids` to false, empty, and empty for an ordinary account, including an update that names none of them.
+
+Two further members are accepted and ignored. `guild_positions` is an array of at most 200 guild snowflakes, and nothing reads it. `default_share_voice_activity` belongs to [Modify voice activity sharing](#modify-voice-activity-sharing).
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| status? | string | [Presence status](#presence-status-values) |
+| status_resets_at?1 | ?ISO8601 timestamp \| integer | The moment at which the scheduled reset applies, or null to clear it |
+| status_resets_to? | ?string | [Presence status](#presence-status-values) applied by the scheduled reset, or null to clear it |
+| theme? | string | [Theme value](#theme-values) |
+| locale?2 | string | Interface [locale](/topics/locales/#supported-locales) |
+| restricted_guilds?3 | array[snowflake] | The guilds where member direct messages are restricted (max 200) |
+| bot_restricted_guilds?3 | array[snowflake] | The guilds where bot direct messages are restricted (max 200) |
+| default_guilds_restricted? | boolean | Whether newly joined guilds restrict member direct messages |
+| bot_default_guilds_restricted? | boolean | Whether newly joined guilds restrict bot direct messages |
+| inline_attachment_media? | boolean | Whether attachment media is rendered inline |
+| inline_embed_media? | boolean | Whether embed media is rendered inline |
+| gif_auto_play? | boolean | Whether GIF media plays automatically |
+| render_embeds? | boolean | Whether message embeds are rendered |
+| render_reactions? | boolean | Whether message reactions are rendered |
+| animate_emoji? | boolean | Whether custom emoji animate |
+| animate_stickers? | integer | [Sticker animation setting](/http-api/users/#sticker-animation-settings) |
+| render_spoilers? | integer | [Spoiler rendering setting](/http-api/users/#spoiler-rendering-settings) |
+| message_display_compact? | boolean | Whether messages use compact presentation |
+| flags?4 | integer | [Friend source flags](/http-api/users/#friend-source-flags) |
+| friend_source_flags?4 | integer | [Friend source flags](/http-api/users/#friend-source-flags) |
+| incoming_call_flags?5 | integer | [Incoming call flags](/http-api/users/#incoming-call-flags) |
+| group_dm_add_permission_flags?6 | integer | [Group DM add permission flags](/http-api/users/#group-dm-add-permission-flags) |
+| guild_folders?7 | array[[guild folder input](#guild-folder-input-object) object] | The complete guild folder layout (max 100) |
+| custom_status?8 | ?[custom status input](#custom-status-input-object) object | The custom status, or null to clear it |
+| afk_timeout? | integer | The idle seconds before the account is presented as away (60-600) |
+| time_format? | integer | [Time format setting](/http-api/users/#time-format-settings) |
+| developer_mode? | boolean | Whether developer mode is enabled |
+| trusted_domains?9 | array[string] | The trusted external link domains (max 1000, each 1-253 characters) |
+| default_hide_muted_channels? | boolean | Whether newly joined guilds hide muted channels |
+| sensitive_content_friend_dm_filter?10 | integer | [Sensitive media filter](/http-api/users/#sensitive-media-filters) for friend direct messages |
+| sensitive_content_non_friend_dm_filter?11 | integer | [Sensitive media filter](/http-api/users/#sensitive-media-filters) for non-friend direct messages |
+| sensitive_content_guild_filter?11 | integer | [Guild sensitive media filter](/http-api/users/#guild-sensitive-media-filters) |
+| suppress_unprivileged_self_mentions?12 | boolean | Whether direct and reply mentions from unprivileged users are suppressed |
+| suppress_unprivileged_self_mentions_bypass_user_ids?12 | ?array[snowflake] | The users exempt from mention suppression |
+| staff_dm_access_user_ids?12 | ?array[snowflake] | The users granted staff direct message access |
+| profile_privacy? | integer | [Profile privacy level](/http-api/users/#profile-privacy-levels) |
+| synced_preferences?13 | ?string | The base64-encoded `fluxer.user.preferences.v1.SyncedPreferences` snapshot, or null to clear it |
+
+1 A string matches ISO 8601 and an integer is Unix milliseconds between 0 and 8640000000000000. Anything else is rejected with `INVALID_ISO_TIMESTAMP`
+
+2 The value is an exact entry of the [supported locale registry](/topics/locales/#supported-locales), never a negotiated nearest match. A change also applies to email localisation
+
+3 Duplicate identifiers are removed before the bound is applied, and an empty array clears the stored set
+
+4 `flags` is a legacy alias for `friend_source_flags`, which wins when both are supplied. `friend_source_flags` is bounded to 0 through 2147483647 while `flags` accepts any integer
+
+5 The stored value is collapsed before it is written. `FRIENDS_ONLY` wins over `NOBODY`, and either collapse preserves `SILENT_EVERYONE`
+
+6 `FRIENDS_ONLY`, then `NOBODY`, then `EVERYONE` are tested in that order against the already collapsed value, so `EVERYONE` applies only when neither of the other two is supplied
+
+7 The array replaces the layout in full
+
+8 The object replaces the stored custom status in full, so an omitted member is cleared. [Input normalisation](/http-api/#input-normalisation) turns an all-empty object into null, which clears the status
+
+9 The single entry `*` trusts every domain and cannot be combined with a concrete domain, which fails against `trusted_domains` with `INVALID_TRUSTED_DOMAINS`. An empty array clears the set
+
+10 An account that is not a verified adult can set only blur or block. Any other value fails against that field with `AGE_RESTRICTED`
+
+11 An account that is not a verified adult cannot modify this field, and supplying it fails against that field with `AGE_RESTRICTED`
+
+12 The field is staff-only
+
+13 The value runs to at most 349528 characters of base64 and decodes to at most 262144 bytes, the 256 KiB ceiling. A longer string fails at the boundary, an oversized decode fails with `TOO_LARGE`, and an undecodable value fails with `INVALID_FORMAT`
+
+Fluxer decodes an accepted `synced_preferences` value, re-encodes it canonically, and stores the result, so a snapshot with only zero values is stored as cleared and read back as the empty string.
+
+:::caution[A rebuilt folder layout drops unresolved references]
+The submitted array replaces the layout in full and is retained as sent, so a client that rebuilds it from only the items it can render loses the rest. Send unresolved references back and omit them only from the rendered view.
+:::
+
+:::note[Fluxer edits the layout on its own]
+Joining a guild prepends it to the uncategorised folder unless some folder already lists it. Leaving or losing a guild removes its ID from every folder and drops any non-uncategorised folder left empty. Each edit emits [User Settings Update](/gateway/events/#user-settings-update).
+:::
+
+Fluxer prepends an empty uncategorised folder to a submitted layout that omits one. A guild ID listed in more than one folder is kept only in the highest-ranked folder. A name is the primary key, so a named folder outranks an unnamed one whatever its identity, and an ordinary folder outranks the uncategorised folder only when both are named or both unnamed. The earliest folder in the submitted order wins a tie.
+
+## Guild folder input object
+
+One folder of a submitted guild folder layout.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | integer | The identifier of the folder |
+| name? | ?string | The name of the folder (0-100 characters) |
+| color?2 | ?integer | The packed 24-bit RGB colour of the folder (0-16777215) |
+| flags?2 | integer | [Guild folder flags](/http-api/users/#guild-folder-flags) |
+| icon?2 | string | [Guild folder icon](/http-api/users/#guild-folder-icons) |
+| guild_ids3 | array[snowflake] | The guild IDs in display order (max 200) |
+
+1 The value is at least `-1`, and `-1` identifies the uncategorised folder
+
+2 An omitted `color` is stored as `0`, an omitted `flags` as `0`, and an omitted `icon` as `folder`. A `color` below 0 fails with `COLOR_VALUE_TOO_LOW` and one above 16777215 with `COLOR_VALUE_TOO_HIGH`
+
+3 Duplicate identifiers within one folder are removed before the bound is applied
+
+## Custom status input object
+
+A custom status as submitted with a settings update.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text?1 | ?string | The status text (1-128 characters) |
+| expires_at?2 | ?ISO8601 timestamp \| integer | The expiry as a timestamp string or Unix milliseconds, or null for no expiry |
+| emoji_id?3 | ?snowflake | The custom emoji to display |
+| emoji_name?3 | ?string | The Unicode emoji to display (1-32 characters) |
+
+1 Fluxer measures the length after [input normalisation](/http-api/#input-normalisation), which turns an empty string into null. Blocklisted text or a blocklisted link is rejected with 403 `CONTENT_BLOCKED`
+
+2 The value is in the future, and a value at or before the current time is rejected
+
+3 `emoji_name` is ignored, and not validated, when `emoji_id` has a value. An `emoji_id` of null leaves `emoji_name` in place, and it is then exactly one Unicode emoji
+
+A custom emoji ID that does not resolve fails with `CUSTOM_EMOJI_NOT_FOUND` against `custom_status.emoji_id`. Fluxer drops the resolved emoji for an account without the global expression entitlement, so the request succeeds with text alone.
+
+:::caution[The stored expiry is not swept]
+An `expires_at` in the past does not clear the stored custom status and emits no Dispatch. The next Gateway session omits the expired status, and only an update naming `custom_status` replaces the stored record. Compare `expires_at` against the current time before rendering.
+:::
+
+## Initial settings
+
+A new account starts with these values. Every field not listed starts empty, so there is no custom status and `synced_preferences` is the empty string.
+
+| Setting | Initial value |
+| --- | --- |
+| Theme | The theme chosen at registration, or `system` |
+| Status | `online` |
+| Rendering | Animated custom emoji, always-animated stickers, GIF autoplay, embeds and reactions rendered, spoilers on click, inline attachment and embed media |
+| Incoming calls and group DM additions | Friends only |
+| Guild DM restrictions | Unrestricted for both members and bots |
+| Guild folders | One empty uncategorised folder |
+| AFK timeout | 600 seconds |
+| Time format | Automatic |
+| Trusted domains | None |
+| Profile privacy | Visible to all guild members |
+| Voice activity sharing | Enabled |
+| Friend sources | Mutual friends and mutual guilds, plus no relation for a verified adult |
+| Sensitive media filters | Show for a verified adult and blur otherwise on friend DMs, block on non-friend DMs, blur in guilds |
+| Developer mode | Enabled in development, disabled elsewhere |
+
+## Get current user settings
+
+
+
+Returns the [user settings](/http-api/users/#user-settings-object) object.
+
+An account with an outstanding required action can still call this route. An account with no stored settings record returns 404 `UNKNOWN_USER`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user settings](/http-api/users/#user-settings-object) object | Settings were returned |
+| 404 | [error response](/http-api/#error-response) | Settings do not exist and the request returns `UNKNOWN_USER` |
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user, on the `user:settings:get` bucket shared with [Get current user](/http-api/users/current-user/#get-current-user).
+
+## Modify current user settings
+
+
+
+Modifies the account-wide settings and returns the complete [user settings](/http-api/users/#user-settings-object) object. Emits a [User Settings Update](/gateway/events/#user-settings-update) Gateway event.
+
+An account with no stored settings record returns 404 `UNKNOWN_USER`. A locale change additionally emits a [User Update](/gateway/events/#user-update) Gateway event.
+
+An unresolvable custom status emoji returns 400 `INVALID_FORM_BODY` with `CUSTOM_EMOJI_NOT_FOUND` against `custom_status.emoji_id`. The trusted domain, age restriction, and synced preferences decisions return 400 `VALIDATION_ERROR` with their own codes in `errors`.
+
+### JSON body
+
+The body uses the [user settings update object](#user-settings-update-object).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user settings](/http-api/users/#user-settings-object) object | Settings were applied and returned |
+| 400 | [error response](/http-api/#error-response) | A field value, the custom status emoji, the trusted domain combination, an age-restricted filter, or the synced preferences snapshot is invalid |
+| 403 | [error response](/http-api/#error-response) | Custom status text is blocked and the request returns `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Settings do not exist and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+Fluxer emits [User Settings Update](/gateway/events/#user-settings-update) to the caller's own sessions. A value-identical patch still performs the write and produces the Dispatch. A locale change also applies to email and notification localisation and emits [User Update](/gateway/events/#user-update). The user object has no locale member.
+
+Every [User Settings Update](/gateway/events/#user-settings-update) republishes the caller's current [Presence Update](/gateway/events/#presence-update) to eligible sessions, so a custom status change becomes visible without a further request.
+
+A status of invisible forces every one of the caller's sessions to invisible. A later status of online, idle, or dnd releases them, and it does so only when at least one session is currently invisible.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `user:settings:update` bucket shared with [Modify voice activity sharing](#modify-voice-activity-sharing).
+
+## Modify voice activity sharing
+
+
+
+Changes the default voice activity sharing value, applies it to the caller's side of every existing friendship, and returns the caller's [user](/http-api/users/#user-object) object. Emits [Relationship Update](/gateway/events/#relationship-update) and [User Update](/gateway/events/#user-update) Gateway events, and a [User Settings Update](/gateway/events/#user-settings-update) Gateway event only when the stored default changes.
+
+A missing account or settings record returns 404 `UNKNOWN_USER`.
+
+This operation owns `default_share_voice_activity`, which is read-only in the [user settings object](/http-api/users/#user-settings-object).
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| share_voice_activity | boolean | Whether voice activity is shared, stored as the new default and applied to the caller's side of every friendship |
+
+:::caution[The cooldown is evaluated before the value comparison]
+A 24-hour cooldown recorded on the account is checked before the request is compared against the stored value, so re-sending the current value inside the window fails with `VOICE_ACTIVITY_SHARING_ON_COOLDOWN` on `share_voice_activity`. The remaining delay appears only inside the localised message.
+:::
+
+Outside the window the cooldown restarts on every accepted request, including one that sets the value it already holds. The [user object](/http-api/users/#user-object) has that moment as `last_voice_activity_sharing_change_at`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user](/http-api/users/#user-object) object | Sharing state was returned after applying the request |
+| 400 | [error response](/http-api/#error-response) | The 24-hour cooldown remains active and the request returns `INVALID_FORM_BODY` |
+| 404 | [error response](/http-api/#error-response) | The account or its settings do not exist and the request returns `UNKNOWN_USER` |
+
+### Side effects
+
+An accepted request writes `default_share_voice_activity` and then walks every friendship. A friendship whose caller-side value already equals the submitted value is skipped, and its stored `version` does not advance. Every friendship, skipped or rewritten, produces one [Relationship Update](/gateway/events/#relationship-update) to the caller and one to the friend, so re-sending the current value still fans the full set out. The friend's Dispatch is emitted only when the reciprocal friendship exists.
+
+It then records the change time in `last_voice_activity_sharing_change_at` and emits [User Update](/gateway/events/#user-update) to the caller.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user, on the `user:settings:update` bucket shared with [Modify current user settings](#modify-current-user-settings).
+
+## Modify DM notification settings
+
+
+
+Modifies and returns the [user guild settings](#user-guild-settings-object) object that governs direct messages and group DMs. Emits a [User Guild Settings Update](/gateway/events/#user-guild-settings-update) Gateway event on every success.
+
+Fluxer creates the settings with the documented defaults when they are absent.
+
+### JSON body
+
+The body uses the [guild settings update fields](#guild-settings-update-fields).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user guild settings](#user-guild-settings-object) object | DM settings were returned after applying the request |
+
+### Side effects
+
+The private channel settings are returned with `guild_id` null. Fluxer performs the write and emits the Dispatch to the caller's own sessions even when the request changes nothing, and `version` advances on that write. No other account receives a Dispatch.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:guild_settings:update` bucket shared with [Modify guild notification settings](#modify-guild-notification-settings).
+
+## Modify guild notification settings
+
+
+
+Modifies and returns the [user guild settings](#user-guild-settings-object) object for the supplied guild ID. Emits a [User Guild Settings Update](/gateway/events/#user-guild-settings-update) Gateway event on every success.
+
+Fluxer creates the settings with the documented defaults when they are absent.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The guild settings key |
+
+:::note[No membership check runs here]
+The operation neither confirms nor denies that the guild exists. A settings object written for a guild the caller has never joined is stored and returned like any other.
+:::
+
+:::note[`guild_id` of 0 is the DM alias]
+`PATCH /v1/users/@me/guilds/0/settings` writes the same private channel record as the `@me` route and returns `guild_id` null.
+:::
+
+### JSON body
+
+The body uses the [guild settings update fields](#guild-settings-update-fields).
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [user guild settings](#user-guild-settings-object) object | Guild settings were returned after applying the request |
+
+### Side effects
+
+Fluxer performs the write and emits the Dispatch to the caller's own sessions even when the request changes nothing, and `version` advances on that write. No guild member other than the caller receives a Dispatch.
+
+### Rate limit
+
+30 requests per 10 seconds for each authenticated user, on the `user:guild_settings:update` bucket shared with [Modify DM notification settings](#modify-dm-notification-settings).
diff --git a/fluxer_docs/src/content/docs/http-api/webhooks.mdx b/fluxer_docs/src/content/docs/http-api/webhooks.mdx
new file mode 100644
index 000000000..7b0bab990
--- /dev/null
+++ b/fluxer_docs/src/content/docs/http-api/webhooks.mdx
@@ -0,0 +1,1508 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Webhooks
+description: Webhook objects, management, token-authenticated execution, and the third-party callbacks.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A webhook posts messages into one guild channel under a name and avatar of its own.
+
+Management routes take a user session token or a bot token. Every other webhook operation uses the webhook ID and secret token in its own path as the complete credential and ignores the `Authorization` header.
+
+The [Messages resource](/http-api/messages/) defines the message, rich embed input, and message reference input objects.
+
+## Authorisation
+
+A management operation requires [MANAGE_WEBHOOKS](/http-api/permissions/) at guild level and again in the webhook's channel. `MANAGE_WEBHOOKS` is an [elevated permission](/http-api/permissions/#elevated-permissions). In a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, a caller who is neither the guild owner nor enrolled in multi-factor authentication receives 400 `TWO_FACTOR_REQUIRED`. Fluxer confirms the permission before that check, so a caller who lacks it outright receives 403 `MISSING_PERMISSIONS`.
+
+Fluxer resolves the guild before every management operation. A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`.
+
+[List guild webhooks](#list-guild-webhooks), [List channel webhooks](#list-channel-webhooks), and [Create webhook](#create-webhook) name a guild or a channel in their paths, so the availability gate applies. A guild with [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) refuses an authenticated request with 403 `MISSING_ACCESS` before the operation runs, and [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) does the same for an account without the instance staff flag. The three remaining management routes name only a webhook ID and are not gated.
+
+Fluxer scans a submitted webhook `name` against the instance phrase and URL blocklists, and a match returns 403 `CONTENT_BLOCKED`. The same lists scan the resolved content and embed text of a created or edited message, and a match returns the same code. The `avatar` member is exempt from that scan, and Fluxer checks its decoded bytes against the banned asset hash list when it stores them.
+
+## Rate limit keying
+
+Every route bucket on this page is keyed on the caller identity as well as on the path parameter in its bucket name. The caller identity is the authenticated account when a request resolves one and the client IP address otherwise, so a token-authenticated operation is bounded per address.
+
+Fluxer resolves an `Authorization` credential on a token-authenticated operation for this keying alone. [Rate limits](/topics/rate-limits/) defines the keying in full.
+
+The [global HTTP limit](/topics/rate-limits/) applies to every management route. [Get webhook with token](#get-webhook-with-token), [Update webhook with token](#update-webhook-with-token), and [Delete webhook with token](#delete-webhook-with-token) consume it under the caller identity. Every other route on this page is exempt and is bounded only by its own route bucket.
+
+## Origin refusal
+
+Fluxer refuses a call from the official web client on four routes. A request whose `Origin` header is exactly `https://web.fluxer.app` or `https://web.canary.fluxer.app` returns 403 `INVALID_API_ORIGIN` on [Execute webhook](#execute-webhook), [Get webhook message](#get-webhook-message), [Edit webhook message](#edit-webhook-message), and [Delete webhook message](#delete-webhook-message).
+
+A request that sends no `Origin`, or any other `Origin` value, passes the check. It runs after the route rate limit and before path validation.
+
+Both token paths also have a second CORS policy that answers every origin, which the [cross-origin request contract](/http-api/#cross-origin-requests) states in full.
+
+## Webhook object
+
+A webhook belongs to exactly one guild and posts into exactly one guild text or voice channel of that guild. Fluxer generates its execution token once at creation and never rotates it, so the pair of ID and token is a bearer credential for the lifetime of the webhook.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the webhook |
+| guild_id | snowflake | The ID of the guild containing the webhook |
+| channel_id | snowflake | The ID of the channel the webhook sends messages to |
+| name | string | The name the webhook posts under (1-80 characters) |
+| avatar | ?string | The avatar hash the webhook posts under, or null when no avatar is set |
+| token1 | string | The secret token that authorises execution of this webhook |
+| user2 | [partial user](/http-api/users/#partial-user-object) object | The account that created the webhook |
+
+1 64 characters drawn from the 62-character alphanumeric alphabet. No operation rotates or reissues it
+
+2 Absent from the [token webhook object](#token-webhook-object). The value is the deleted user partial when the creating account no longer exists
+
+### Example
+
+```json
+{
+ "id": "1501314428688998182",
+ "guild_id": "1501314428688990000",
+ "channel_id": "1501314428688991111",
+ "name": "Build bot",
+ "avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
+ "token": "Xk3Qw9ZpL2vB7nR4tY6uI8oP0aS1dF5gH2jK4lZ9xC7vB1nM3qW5eR8tY0uI2oP4",
+ "user": {
+ "id": "1501314428688990001",
+ "username": "ada",
+ "discriminator": "0001",
+ "avatar": null
+ }
+}
+```
+
+## Token webhook object
+
+Token-authenticated operations return this object. It has every field of the [webhook object](#webhook-object) except `user`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | snowflake | The ID of the webhook |
+| guild_id | snowflake | The ID of the guild containing the webhook |
+| channel_id | snowflake | The ID of the channel the webhook sends messages to |
+| name | string | The name the webhook posts under (1-80 characters) |
+| avatar | ?string | The avatar hash the webhook posts under, or null when no avatar is set |
+| token | string | The secret token that authorises execution of this webhook |
+
+:::danger[The token is returned in full]
+Every operation returning a [webhook object](#webhook-object) or a token webhook object includes the complete execution token, so its response body grants full execution and management authority over each webhook it names.
+:::
+
+## Webhook message body
+
+[Execute webhook](#execute-webhook) accepts this JSON body. Every field may be omitted, and `{}` is a valid body that fails later as an empty message.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| content?1 | ?string | Message content |
+| embeds?2 | array[[rich embed input](/http-api/messages/#rich-embed-input-objects) object] | Rich embeds |
+| attachments?3 | array[[webhook attachment input](#webhook-attachment-input-object) object] | Attachment metadata |
+| message_reference?4 | ?[message reference input](/http-api/messages/#message-reference-input-object) object | Reply or forward reference, or null |
+| allowed_mentions?5 | ?[allowed mentions](/http-api/messages/#allowed-mentions-object) object | Mention parsing policy, or null |
+| flags? | integer | [Message flags](/http-api/messages/#message-flags), where omission is treated as zero and every bit outside the sendable mask is cleared |
+| favorite_meme_id? | ?snowflake | Favorite meme to attach |
+| sticker_ids? | ?array[snowflake] | At most 3 sticker IDs |
+| tts?6 | boolean | Text-to-speech request |
+| nonce?7 | string \| integer | Client-generated message identifier (1-32 characters). A non-negative safe integer becomes its decimal string |
+| username?8 | ?string | Per-message webhook name override (1-80 characters), or null |
+| avatar_url?8 | ?string | Absolute `http` or `https` per-message webhook avatar URL override of at most 2,048 characters, or null |
+
+1 A webhook author always resolves an effective [max_message_length](/http-api/instance/#limit-keys) of at least 4000 characters, and a higher configured value for the guild raises it further. Exceeding it returns 400 `INVALID_FORM_BODY` with the validation code `CONTENT_EXCEEDS_MAX_LENGTH`
+
+2 Bounded by the resolved [max_embeds_per_message](/http-api/instance/#limit-keys) value for the guild, defaulting to 10. Exceeding it returns 400 `INVALID_FORM_BODY` with `TOO_MANY_EMBEDS`
+
+3 A JSON body attaches no file. A multipart body uses the ordinary message attachment contract
+
+4 A forward reference requires both `channel_id` and `message_id`, and it must not accompany content, embeds, or attachments. See [message references](#message-references)
+
+5 An omitted policy suppresses every mention, which is the opposite of the [Create message](/http-api/messages/#create-message) default
+
+6 The field takes no part in the non-empty message check, so a body supplying `tts` and nothing else returns 400 `CANNOT_SEND_EMPTY_MESSAGE`. A webhook message is never marked text-to-speech
+
+7 The nonce is remembered per webhook for five minutes and is echoed on the [Message Create](/gateway/events/#message-create) Dispatch. A repeated execution presenting it inside that window creates no second message and returns the first one, and a repeat after the webhook moved to another channel returns 404 `UNKNOWN_MESSAGE`
+
+8 Applies to the created message, and the stored webhook keeps its own name and avatar
+
+When `embeds` is absent, Fluxer rewrites the singular key `embed` to a one-element `embeds` array, and an `embed` of null becomes an empty array. When both are present, `embed` is dropped.
+
+A multipart body accepts both [pre-uploaded attachments](/http-api/messages/#pre-uploaded-attachment-object) and [direct multipart attachment metadata](/http-api/messages/#direct-multipart-attachment-metadata-object).
+
+Fluxer fetches an `avatar_url` through the media boundary.
+
+## Webhook attachment input object
+
+A JSON [webhook message body](#webhook-message-body) accepts this attachment metadata shape. Every member is optional, and a member outside this table is discarded.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | snowflake \| integer | The ID of the attachment |
+| filename? | string | Attachment filename (1-1024 characters) |
+| description? | string | Attachment description (1-4096 characters) |
+| content_type? | string | Media type (1-256 characters) |
+| size? | integer | Byte count, as a non-negative safe integer |
+| url? | string | Absolute `http` or `https` URL of at most 2,048 characters |
+| proxy_url? | string | Absolute `http` or `https` URL of at most 2,048 characters |
+| height? | integer | Pixel height |
+| width? | integer | Pixel width |
+| ephemeral? | boolean | Whether the attachment is ephemeral |
+| duration? | number | Audio duration in seconds |
+| waveform? | string | Base64 waveform data (1-256 characters) |
+| flags? | integer | [Attachment flags](/http-api/messages/#attachment-flags) |
+
+:::caution[A JSON body cannot attach a file]
+The execution attaches only an entry with `upload_filename`, and this object defines no such member. A webhook that needs to attach a file uses a [multipart body](#execute-webhook).
+:::
+
+Every entry supplied in a JSON body is dropped, and it counts towards neither the attachment limit nor the non-empty message check.
+
+## Message references
+
+A webhook execution accepts the [message reference input](/http-api/messages/#message-reference-input-object) object defined by the Messages resource. Three constraints are specific to webhooks.
+
+The `channel_id` field is required for a forward reference and must equal the webhook's own channel. A value naming any other channel returns 404 `UNKNOWN_MESSAGE`. A reply reference always resolves in the webhook's own channel and ignores this field.
+
+The referenced message must exist in the webhook's channel, and a missing message returns 404 `UNKNOWN_MESSAGE`. A reply reference additionally requires the referenced message to be an ordinary or reply message. Any other type returns 400 `INVALID_FORM_BODY` with the validation code `CANNOT_REPLY_TO_SYSTEM_MESSAGE`.
+
+A forward reference must not accompany content, embeds, or attachments, and one that does returns 400 `INVALID_FORM_BODY` with the validation code `FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT`. A forward reference that omits `channel_id` or `message_id` returns the same status with `FORWARD_REFERENCE_REQUIRES_CHANNEL_AND_MESSAGE`.
+
+## Webhook message edit body
+
+[Edit webhook message](#edit-webhook-message) accepts this JSON body. Every supplied field replaces the stored value outright, and an omitted field keeps it. A body with no visible `content`, no non-empty `embeds`, and no `flags` is rejected with 400 `CANNOT_SEND_EMPTY_MESSAGE`.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| content?1 | ?string | Replacement message content, or null |
+| embeds?2 | array[[rich embed input](/http-api/messages/#rich-embed-input-objects) object] | Replacement collection of embeds |
+| flags? | integer | Replacement [message flags](/http-api/messages/#message-flags) |
+| allowed_mentions? | ?[allowed mentions](/http-api/messages/#allowed-mentions-object) object | Replacement mention parsing policy, or null |
+
+1 The same effective maximum length as [Execute webhook](#execute-webhook) applies
+
+2 The supplied array becomes the complete embed collection, so an empty array removes every embed. The same resolved [max_embeds_per_message](/http-api/instance/#limit-keys) ceiling applies
+
+`flags` replaces only the bits inside the sendable mask, and every other stored bit is kept.
+
+## Slack callback objects
+
+These objects define the body [Execute Slack webhook](#execute-slack-webhook) accepts and the embed each part converts to.
+
+### Slack callback object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| text?1 | string | Message text |
+| username? | string | Webhook name override for this message (1-80 characters) |
+| icon_url?2 | string | Webhook avatar URL override for this message |
+| attachments? | array[[Slack attachment](#slack-attachment-object) object] | Slack attachments converted to embeds |
+
+1 When text is omitted and at least one attachment converts to an embed, the created message content is the empty string
+
+2 The value is used only when it parses as an absolute `http` or `https` URL of at most 2,048 characters, and any other value is discarded silently
+
+### Slack attachment object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| fallback?1 | string | Fallback text |
+| pretext?1 | string | Text above the attachment |
+| text?1 | string | Main attachment text |
+| color?2 | string | Sidebar colour value |
+| title? | string | Attachment title, mapped to the embed title |
+| title_link?3 | string | Title URL, mapped to the embed URL |
+| fields?4 | array[[Slack attachment field](#slack-attachment-field-object) object] | Fields mapped to embed fields |
+| footer? | string | Footer text, mapped to the embed footer |
+| ts?5 | number | Unix seconds, mapped to the embed timestamp |
+| author_name?6 | string | Author name, mapped to the embed author name |
+| author_link?3 | string | Author URL, mapped to the embed author URL |
+| author_icon?3 | string | Author icon URL, mapped to the embed author icon |
+| image_url?3 | string | Main image URL, mapped to the embed image |
+| thumb_url?3 | string | Thumbnail URL, mapped to the embed thumbnail |
+
+1 The embed description is pretext and text joined by a newline in that order, and fallback is used only when both pretext and text are absent
+
+2 The value is mapped to the embed colour only when it is exactly six hexadecimal digits with an optional leading `#`, and any other value leaves the embed without a colour
+
+3 The value is used only when it parses as an absolute `http` or `https` URL of at most 2,048 characters, and any other value is discarded silently
+
+4 A field is converted only when it supplies both a title and a value, and any other field is discarded silently
+
+5 The value is accepted as a JSON number or as a decimal string and must be a non-negative integer
+
+6 The embed author is emitted only when `author_name` is present, so `author_link` and `author_icon` alone produce no author
+
+### Slack attachment field object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| title? | string | Field title, mapped to the embed field name |
+| value? | string | Field value, mapped to the embed field value |
+| short? | boolean | Whether the embed field is rendered inline (default false) |
+
+:::caution[Converted embeds skip the rich embed schema]
+No per-embed title, description, footer, or field maximum applies to a Slack value.
+:::
+
+The Slack schema bounds only `username`, which must be 1 through 80 characters and rejects a longer value with `WEBHOOK_NAME_LENGTH_INVALID`. Fluxer applies no bound to any other Slack string or to either array.
+
+Three checks do apply. A conversion yielding neither content nor an embed returns 400 `CANNOT_SEND_EMPTY_MESSAGE`. Content longer than the effective maximum returns 400 `INVALID_FORM_BODY` with the validation code `CONTENT_EXCEEDS_MAX_LENGTH`, and more converted embeds than the resolved ceiling returns the same status with `TOO_MANY_EMBEDS`. Fluxer discards an attachment that produces no embed property.
+
+## GitHub callback objects
+
+These objects define the body [Execute GitHub webhook](#execute-github-webhook) accepts.
+
+### GitHub callback object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| action?1 | ?string | GitHub event action, or null |
+| answer? | ?[GitHub comment](#github-comment-object) object | Accepted discussion answer, or null (validated, never rendered) |
+| check_run? | ?[GitHub check run](#github-check-run-object) object | Check run data, or null |
+| check_suite? | ?[GitHub check suite](#github-check-suite-object) object | Check suite data, or null |
+| comment? | ?[GitHub comment](#github-comment-object) object | Issue, review, commit, or discussion comment, or null |
+| commits? | ?array[[GitHub commit](#github-commit-object) object] | Push commits, or null |
+| compare? | ?string | Absolute comparison URL, or null |
+| discussion? | ?[GitHub discussion](#github-discussion-object) object | Discussion data, or null |
+| forced? | ?boolean | Whether a push was forced, or null |
+| forkee? | ?[GitHub service](#github-service-object) object | Fork repository, or null |
+| head_commit? | ?[GitHub commit](#github-commit-object) object | Head commit, or null |
+| issue? | ?[GitHub issue](#github-issue-object) object | Issue data, or null |
+| member? | ?[GitHub user](#github-user-object) object | Repository member data, or null |
+| pull_request?2 | ?[GitHub issue](#github-issue-object) object | Pull request representation, or null |
+| ref_type?3 | ?string | Git reference type, or null |
+| ref? | ?string | Git reference, or null |
+| release? | ?[GitHub release](#github-release-object) object | Release data, or null |
+| repository?4 | ?[GitHub service](#github-service-object) object | Repository data, or null |
+| review? | ?[GitHub review](#github-review-object) object | Pull request review data, or null |
+| sender | [GitHub user](#github-user-object) object | Event sender |
+
+1 The value gates rendering for most event types, and the exact accepted action for each type is stated in [GitHub event types](#github-event-types)
+
+2 A pull request uses the same representation as an issue, so only the fields listed in the [GitHub issue structure](#github-issue-object) are consumed
+
+3 Only the exact values `branch` and `tag` are rendered for a create or delete event, and any other reference type produces no message
+
+4 Every rendered event type requires repository, so a callback without it produces no message
+
+A top-level `pull_request` field selects the wording of an `issue_comment` rendering, which names a pull request when the field is present and an issue when it is absent.
+
+Every GitHub string in these objects accepts at most 152,133 characters unless a narrower bound is stated, and every field typed as an absolute URL must parse as an `http` or `https` URL of at most 2,048 characters. A field marked as accepted and validated still has its declared type and bound, so a malformed value rejects the callback with 400 `INVALID_FORM_BODY`.
+
+Every rendered embed title is truncated to 70 characters, except the ordinary push, check run, and check suite titles, which are truncated to 256. Every rendered embed description is truncated to 350 characters, except the forced push description, which is the fixed compare link and is neither decoded nor truncated. Fluxer decodes HTML entities and trims each value before truncating it.
+
+### GitHub user object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | Non-negative 32-bit GitHub user ID (validated, never rendered) |
+| login | string | GitHub login, used as the rendered embed author name |
+| html_url | string | Absolute profile URL |
+| avatar_url | string | Absolute avatar URL |
+
+### GitHub service object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | Non-negative 32-bit GitHub repository ID (validated, never rendered) |
+| html_url | string | Absolute repository URL |
+| name1 | string | Repository name |
+| full_name1 | string | Owner-qualified repository name |
+
+1 The push, check run, and check suite renderings use name, and every other rendering uses `full_name`
+
+### GitHub author object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| username? | ?string | GitHub username, or null (validated, never rendered) |
+| name | string | Author display name, which is the value rendered beside each pushed commit |
+
+### GitHub commit object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string | Commit identifier |
+| url | string | Absolute commit API URL |
+| message2 | string | Commit message |
+| author | [GitHub author](#github-author-object) object | Commit author |
+
+1 Renderings abbreviate the identifier to its first seven characters
+
+2 Every `This reverts commit .` sentence with a 40-character identifier is rewritten to a Markdown link to the reverted commit
+
+### GitHub comment object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | string \| integer (validated, never rendered) | Signed 64-bit comment ID, accepted as a decimal string or as a safe integer |
+| html_url | string | Absolute comment URL |
+| user | [GitHub user](#github-user-object) object | Comment author |
+| commit_id?1 | ?string | Associated commit identifier, or null |
+| body | string | Comment body |
+
+1 The field is required for a `commit_comment` event and unused for every other comment-bearing event
+
+### GitHub discussion object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| title | string | Discussion title |
+| number | integer | Non-negative 32-bit discussion number |
+| html_url | string | Absolute discussion URL |
+| answer_html_url? | ?string | Absolute accepted-answer URL, or null (validated, never rendered) |
+| body? | ?string | Discussion body, or null |
+| user | [GitHub user](#github-user-object) object | Discussion author |
+
+### GitHub issue object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id1 | string \| integer | Signed 64-bit issue or pull request ID, accepted as a decimal string or as a safe integer |
+| number | integer | Non-negative 32-bit issue or pull request number |
+| html_url | string | Absolute issue or pull request URL |
+| user1 | [GitHub user](#github-user-object) object | Issue or pull request author |
+| title | string | Issue or pull request title |
+| body?2 | ?string | Issue or pull request body, or null |
+
+1 The field is accepted and validated and never rendered. The issue, pull request, and `issue_comment` renderings take their embed author from the callback sender or the comment author
+
+2 The body is rendered as the embed description only for the `opened` action, and a `closed` or `reopened` action renders no description
+
+No other key is read. An unlisted key, including a `pull_request` marker that GitHub places on the issue itself, is discarded.
+
+### GitHub release object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id | integer | Non-negative 32-bit release ID (validated, never rendered) |
+| tag_name | string | Git tag name |
+| html_url | string | Absolute release URL |
+| body? | ?string | Release body, or null (validated, never rendered) |
+
+### GitHub review object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user | [GitHub user](#github-user-object) object | Review author |
+| body? | ?string | Review body, or null |
+| html_url | string | Absolute review URL |
+| state1 | string | Review state |
+
+1 The field is accepted and validated but does not contribute to the rendered message, so an approval, a change request, and a comment review all render the same embed
+
+### GitHub check pull request object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| number | integer | Non-negative 32-bit pull request number (validated, never rendered) |
+
+The object exists only inside the `pull_requests` arrays of the [GitHub check suite structure](#github-check-suite-object) and the [GitHub check run structure](#github-check-run-object). Both arrays are validated and never read.
+
+### GitHub check application object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Check application name, rendered in the check suite embed title |
+
+### GitHub check suite object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| conclusion?1 | ?string | Check conclusion, or null |
+| head_branch? | ?string | Head branch, or null |
+| head_sha | string | Head commit identifier, used to build the rendered commit URL |
+| pull_requests? | ?array[[GitHub check pull request](#github-check-pull-request-object) object] | Associated pull requests, or null (validated, never rendered) |
+| app | [GitHub check application](#github-check-application-object) object | Check application |
+
+1 The exact value `skipped` suppresses the message entirely, the exact value `success` renders a green embed, and every other value renders a red embed
+
+### GitHub check run output object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| title? | ?string | Output title, or null (validated, never rendered) |
+| summary? | ?string | Output summary, or null (validated, never rendered) |
+
+### GitHub check run object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| conclusion?1 | ?string | Check conclusion, or null |
+| name | string | Check run name |
+| html_url2 | string | Absolute check run URL |
+| check_suite3 | [GitHub check suite](#github-check-suite-object) object | Parent check suite |
+| details_url?2 | ?string | Absolute details URL, or null |
+| output?2 | ?[GitHub check run output](#github-check-run-output-object) object | Check output, or null |
+| pull_requests?2 | ?array[[GitHub check pull request](#github-check-pull-request-object) object] | Associated pull requests, or null |
+
+1 The exact value `success` renders a green embed and every other value renders a red embed
+
+2 The field is accepted and validated and never rendered. The check run embed links the commit built from the parent suite's `head_sha`
+
+3 A check run whose parent suite reports the conclusion `skipped` produces no message
+
+## GitHub event types
+
+The value of the `X-GitHub-Event` request header selects the rendering. An event type outside this registry, and an absent header, are both acknowledged without creating a message. Every listed type additionally requires repository.
+
+| Value | Description |
+| --- | --- |
+| commit_comment1 | New comment on a commit, requiring the action `created` and a comment with commit_id |
+| create2 | Creation of a branch or a tag, requiring ref and ref_type |
+| delete2 | Deletion of a branch or a tag, requiring ref and ref_type |
+| fork | Creation of a fork, requiring forkee |
+| issue_comment1 | New comment on an issue or a pull request, requiring the action `created` and both comment and issue |
+| issues3 | Issue lifecycle change, requiring issue and the action `opened`, `closed`, or `reopened` |
+| member | Repository collaborator addition, requiring the action `added` and member |
+| public | Repository becoming public |
+| pull_request3 | Pull request lifecycle change, requiring pull_request and the action `opened`, `closed`, or `reopened` |
+| pull_request_review1 | Pull request review, requiring the action `submitted` and both review and pull_request |
+| pull_request_review_comment1 | New review comment, requiring the action `created` and both comment and pull_request |
+| push4 | Git push, requiring ref |
+| release | Release publication, requiring the action `published` and release |
+| watch | Repository star, requiring the action `started` |
+| check_run5 | Check run completion, requiring the action `completed` and check_run |
+| check_suite5 | Check suite completion, requiring the action `completed` and check_suite |
+| discussion1 | New discussion, requiring the action `created` and discussion |
+| discussion_comment1 | New discussion comment, requiring the action `created` and both comment and discussion |
+| repository | Repository creation, requiring the action `created` |
+
+1 The embed author is the comment, discussion, or review author
+
+2 Only the reference types `branch` and `tag` render, and any other `ref_type` produces no message
+
+3 The description is rendered only for the `opened` action
+
+4 A forced push requires `head_commit` and compare, and renders a compare link as its whole description
+
+5 A conclusion of `skipped` on the relevant suite suppresses the message
+
+An ordinary push requires compare and at least one commit, and renders one line for each commit. The line set is not capped by count, and the assembled description is truncated to the ordinary 350-character ceiling.
+
+## Instatus callback objects
+
+These objects define the body [Execute Instatus webhook](#execute-instatus-webhook) accepts.
+
+### Instatus callback object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| meta? | ?[Instatus metadata](#instatus-metadata-object) object | Callback metadata, or null (validated, never rendered) |
+| page?1 | ?[Instatus page](#instatus-page-object) object | Status page data, or null |
+| incident?2 | ?[Instatus incident](#instatus-incident-object) object | Incident data, or null |
+| maintenance?2 | ?[Instatus maintenance](#instatus-maintenance-object) object | Maintenance data, or null |
+| component_update?2 | ?[Instatus component update](#instatus-component-update-object) object | Component transition data, or null |
+| component?2 | ?[Instatus component](#instatus-component-object) object | Component data, or null |
+
+1 The page supplies the embed footer text and the fallback embed URL when the incident or maintenance item supplies no URL of its own. A component transition always takes its URL from the page
+
+2 Exactly one rendering is selected in the order incident, maintenance, then component transition. A section with no non-empty name renders nothing, and the next section in that order is tried
+
+### Instatus metadata object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| unsubscribe? | ?string | Unsubscribe value (max 2,048 characters), or null |
+| documentation? | ?string | Documentation value (max 2,048 characters), or null |
+
+### Instatus page object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Page ID (max 256 characters), or null |
+| status_indicator?1 | ?string | Status indicator (max 256 characters), or null |
+| status_description?1 | ?string | Status description (max 1,024 characters), or null |
+| url?2 | ?string | Page URL (max 2,048 characters), or null |
+
+1 The footer text is `status_description` when it is non-empty, otherwise the humanised form of `status_indicator`
+
+2 Used as an embed URL only when it parses as an absolute `http` or `https` URL
+
+A backfilled incident or maintenance item appends `Backfilled` to the footer text, so the footer is omitted only when both page values are empty and the rendered item is not backfilled.
+
+### Instatus affected component object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Component ID (max 256 characters), or null |
+| name?1 | ?string | Component name (max 1,024 characters), or null |
+| status?2 | ?string | Component status (max 256 characters), or null |
+
+1 A component without a non-empty name is omitted from the rendered affected components field, and a callback whose components all lack names renders no such field
+
+2 A non-empty status is rendered in lower case in parentheses after the name, and a component without one renders its name alone
+
+### Instatus component object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Component ID (max 256 characters), or null |
+| name?1 | ?string | Component name (max 1,024 characters), or null |
+| status?2 | ?string | Component status (max 256 characters), or null |
+| created_at?3 | ?string | Provider creation time (max 64 characters), or null |
+
+1 A component transition with no non-empty name renders the literal words `A component` in its place
+
+2 The value is the fallback for the rendered transition status when the [component update](#instatus-component-update-object) supplies no `new_status`
+
+3 The value is the fallback for the embed timestamp when the component update supplies no `created_at`, and a value the runtime cannot parse as a date leaves the embed without a timestamp
+
+### Instatus incident update object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Update ID (max 256 characters), or null |
+| incident_id? | ?string | Incident ID (max 256 characters), or null |
+| markdown?1 | ?string | Update body (max 65,536 characters), or null |
+| status? | ?string | Update status (max 256 characters), or null (validated, never rendered) |
+| created_at?1 | ?string | Provider creation time (max 64 characters), or null |
+| updated_at? | ?string | Provider update time (max 64 characters), or null (validated, never rendered) |
+
+1 Only the update with the latest `created_at` contributes. Its markdown becomes the embed description, truncated to 4,096 characters, and its `created_at` becomes the embed timestamp
+
+### Instatus incident object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Incident ID (max 256 characters), or null |
+| name?1 | ?string | Incident name (max 1,024 characters), or null |
+| url? | ?string | Incident URL (max 2,048 characters), or null |
+| status?2 | ?string | Incident status (max 256 characters), or null |
+| backfilled?3 | ?boolean | Whether the incident was backfilled, or null |
+| created_at?4 | ?string | Provider creation time (max 64 characters), or null |
+| updated_at?4 | ?string | Provider update time (max 64 characters), or null |
+| resolved_at? | ?string | Provider resolution time (max 64 characters), or null (validated, never rendered) |
+| incident_updates? | ?array[[Instatus incident update](#instatus-incident-update-object) object] | Incident updates, or null |
+| affected_components? | ?array[[Instatus affected component](#instatus-affected-component-object) object] | Affected components, or null |
+
+1 The name becomes the embed title, truncated to 256 characters. An incident with no non-empty name is skipped, and the maintenance or component data in the same callback renders in its place
+
+2 The value selects the embed colour and the rendered Status field through [Instatus status values](#instatus-status-values), and an absent status renders the neutral colour and no Status field
+
+3 A true value appends `Backfilled` to the embed footer, separated from the page footer text by a vertical bar. When the page supplies no footer text, `Backfilled` is the whole footer
+
+4 The embed timestamp is the latest update's `created_at`, then `updated_at`, then `created_at`, and the first value the runtime can parse as a date wins
+
+### Instatus maintenance update object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Update ID (max 256 characters), or null |
+| maintenance_id? | ?string | Maintenance ID (max 256 characters), or null |
+| markdown?1 | ?string | Update body (max 65,536 characters), or null |
+| created_at?1 | ?string | Provider creation time (max 64 characters), or null |
+| updated_at? | ?string | Provider update time (max 64 characters), or null (validated, never rendered) |
+
+1 Only the update with the latest `created_at` contributes. Its markdown becomes the embed description, truncated to 4,096 characters, and its `created_at` becomes the embed timestamp
+
+### Instatus maintenance object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| id? | ?string | Maintenance ID (max 256 characters), or null |
+| name?1 | ?string | Maintenance name (max 1,024 characters), or null |
+| url? | ?string | Maintenance URL (max 2,048 characters), or null |
+| status?2 | ?string | Maintenance status (max 256 characters), or null |
+| maintenance_start_date?3 | ?string | Provider start time (max 64 characters), or null |
+| maintenance_end_date?3 | ?string | Provider end time (max 64 characters), or null |
+| backfilled? | ?boolean | Whether the maintenance was backfilled, or null |
+| created_at?4 | ?string | Provider creation time (max 64 characters), or null |
+| updated_at?4 | ?string | Provider update time (max 64 characters), or null |
+| resolved_at? | ?string | Provider resolution time (max 64 characters), or null (validated, never rendered) |
+| maintenance_updates? | ?array[[Instatus maintenance update](#instatus-maintenance-update-object) object] | Maintenance updates, or null |
+| affected_components? | ?array[[Instatus affected component](#instatus-affected-component-object) object] | Affected components, or null |
+
+1 The name becomes the embed title, truncated to 256 characters. A maintenance item with no non-empty name is skipped, and the component transition in the same callback renders in its place
+
+2 An absent status renders the maintenance colour
+
+3 Each parseable value renders as a full timestamp in the Window field, and the field is omitted when neither value parses
+
+4 The embed timestamp is the latest update's `created_at`, then `updated_at`, then `created_at`, and the first value the runtime can parse as a date wins
+
+### Instatus component update object
+
+#### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| created_at? | ?string | Provider creation time (max 64 characters), or null |
+| new_status?1 | ?string | New component status (max 256 characters), or null |
+| component_id? | ?string | Component ID (max 256 characters), or null (validated, never rendered) |
+
+1 The rendering prefers `new_status` and falls back to the status of the accompanying component, using the literal word `updated` when neither is present. The selected value is rendered in lower case
+
+## Instatus status values
+
+Fluxer normalises an incident, maintenance, or component status by uppercasing it and removing every character outside `A` to `Z`, so `degraded_performance` becomes `DEGRADEDPERFORMANCE`. A normalised value in this registry selects the rendered label and the embed colour, and any other value is rendered unchanged with a neutral colour.
+
+| Value | Description |
+| --- | --- |
+| UP | Rendered as `All systems operational`, with the neutral colour |
+| HASISSUES | Rendered as `Has issues`, with the neutral colour |
+| OPERATIONAL | Rendered as `Operational`, with the operational colour |
+| UNDERMAINTENANCE | Rendered as `Under maintenance`, with the maintenance colour |
+| DEGRADEDPERFORMANCE | Rendered as `Degraded performance`, with the degraded colour |
+| PARTIALOUTAGE | Rendered as `Partial outage`, with the partial outage colour |
+| MAJOROUTAGE | Rendered as `Major outage`, with the major outage colour |
+| INVESTIGATING | Rendered as `Investigating`, with the major outage colour |
+| IDENTIFIED | Rendered as `Identified`, with the partial outage colour |
+| MONITORING | Rendered as `Monitoring`, with the degraded colour |
+| RESOLVED | Rendered as `Resolved`, with the operational colour |
+| NOTSTARTEDYET | Rendered as `Scheduled`, with the maintenance colour |
+| PLANNED | Rendered as `Planned`, with the maintenance colour |
+| INPROGRESS | Rendered as `In progress`, with the maintenance colour |
+| COMPLETED | Rendered as `Completed`, with the operational colour |
+
+## List guild webhooks
+
+
+
+Returns an array of [webhook objects](#webhook-object) in a guild. The caller must be a member of the guild and hold the [MANAGE_WEBHOOKS](/http-api/permissions/) permission at guild level.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | snowflake | The ID of the guild whose webhooks are returned |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | array[[webhook](#webhook-object) object] | Webhooks were returned, and an empty array is returned when the guild has none |
+| 400 | [error response](/http-api/#error-response) | Path parameter is not a valid snowflake |
+| 4002 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 4033 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 4033 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 4033 | [error response](/http-api/#error-response) | The guild is unavailable |
+| 4033 | [error response](/http-api/#error-response) | The caller is not a member of the guild or lacks `MANAGE_WEBHOOKS` |
+| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
+
+1 The array can be shorter than the number of stored webhooks
+
+2 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after Fluxer confirms the permission itself
+
+3 `ACCOUNT_SUSPICIOUS_ACTIVITY` for an outstanding required action, `MISSING_ACCESS` for an unavailable guild, `MISSING_PERMISSIONS` for a membership or permission failure, and `ACCESS_DENIED` otherwise
+
+A webhook is returned only when the caller also holds both `VIEW_CHANNEL` and `MANAGE_WEBHOOKS` in that webhook's own channel. A webhook with no channel target is never returned.
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and guild ID, on the `webhook:list::guild_id` bucket.
+
+## List channel webhooks
+
+
+
+Returns an array of [webhook objects](#webhook-object) in a guild text or voice channel. The caller must be able to view the channel and hold the [MANAGE_WEBHOOKS](/http-api/permissions/) permission at guild level and in the channel.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild text or voice channel whose webhooks are returned |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | array[[webhook](#webhook-object) object] | Webhooks were returned, and an empty array is returned when the channel has none |
+| 400 | [error response](/http-api/#error-response) | Path parameter is not a valid snowflake |
+| 4001 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 4032 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 4032 | [error response](/http-api/#error-response) | The guild is unavailable |
+| 4032 | [error response](/http-api/#error-response) | The caller is not a member of the channel's guild |
+| 4032 | [error response](/http-api/#error-response) | The caller lacks channel access or `MANAGE_WEBHOOKS` |
+| 4032 | [error response](/http-api/#error-response) | The channel is age restricted and the account is not age verified |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or is not a guild text or voice channel, each returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | Its guild does not resolve, returning `UNKNOWN_GUILD` |
+
+1 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after Fluxer confirms the permission itself
+
+2 `ACCOUNT_SUSPICIOUS_ACTIVITY` for an outstanding required action, `MISSING_ACCESS` for an unavailable guild, `NSFW_CONTENT_AGE_RESTRICTED` for an unverified account in an age restricted channel, `MISSING_PERMISSIONS` for a membership, channel access, or permission failure, and `ACCESS_DENIED` otherwise
+
+### Rate limit
+
+40 requests per 10 seconds for each authenticated user and channel ID, on the `webhook:list::channel_id` bucket.
+
+## Create webhook
+
+
+
+Creates a webhook in a guild text or voice channel and returns the new [webhook object](#webhook-object) with its execution token. The caller must be able to view the channel and hold the [MANAGE_WEBHOOKS](/http-api/permissions/) permission at guild level and in the channel. The operation accepts an audit reason.
+
+Creation emits a [Webhooks Update](/gateway/events/#webhooks-update).
+
+Fluxer checks the request in a fixed order: the content filter, the rate limit, the credential, the body schema, channel access and `MANAGE_WEBHOOKS`, the guild allowance, the channel allowance, the name scan, and only then the avatar itself.
+
+The guild allowance is the resolved [max_webhooks_per_guild](/http-api/instance/#limit-keys) value for the guild, defaulting to 1000, and the channel allowance is the resolved [max_webhooks_per_channel](/http-api/instance/#limit-keys) value, defaulting to 15.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| channel_id | snowflake | The ID of the guild text or voice channel the webhook is created in |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name | string | Webhook name (1-80 characters) |
+| avatar?1 | ?base64 string | Base64-encoded avatar image, or null |
+
+1 A leading data URI header is stripped at the first comma before decoding, and the base64 payload is 1 through 13981016 characters
+
+The decoded bytes must be at most the resolved [avatar_max_size](/http-api/instance/#limit-keys) value, which is the 10 MiB ceiling of 10485760 bytes by default. The decoded image must be an accepted avatar upload format, and an animated AVIF is rejected.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [webhook](#webhook-object) object | Webhook was created |
+| 4001 | [error response](/http-api/#error-response) | Body or avatar is invalid |
+| 4002 | [error response](/http-api/#error-response) | The avatar is blocked as explicit media |
+| 4003 | [error response](/http-api/#error-response) | The guild or channel webhook allowance is already reached |
+| 400 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 403 | [error response](/http-api/#error-response) | The guild is unavailable |
+| 403 | [error response](/http-api/#error-response) | The caller is not a member of the channel's guild |
+| 403 | [error response](/http-api/#error-response) | The caller lacks channel access or `MANAGE_WEBHOOKS` |
+| 403 | [error response](/http-api/#error-response) | The channel is age restricted and the account is not age verified |
+| 403 | [error response](/http-api/#error-response) | The name is blocked |
+| 403 | [error response](/http-api/#error-response) | The avatar hash is banned |
+| 404 | [error response](/http-api/#error-response) | Channel does not exist or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | Its guild does not resolve, returning `UNKNOWN_GUILD` |
+
+1 An avatar failure names the `avatar` path with `BASE64_LENGTH_INVALID`, `INVALID_BASE64_FORMAT`, `IMAGE_SIZE_EXCEEDS_LIMIT`, or `INVALID_IMAGE_FORMAT`
+
+2 The classifier block has its score in `probability`
+
+3 The reached allowance is in a top-level member named after its limit key
+
+| Condition | Error |
+| --- | --- |
+| Guild or channel webhook allowance reached | 400 `MAX_WEBHOOKS_PER_GUILD` or 400 `MAX_WEBHOOKS_PER_CHANNEL` |
+| Caller holds the permission but has no enrolled authenticator | 400 `TWO_FACTOR_REQUIRED` |
+| Explicit media classifier blocks the avatar | 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
+| Schema or image failure | 400 `INVALID_FORM_BODY` |
+| Name is blocked, or the avatar hash is banned | 403 `CONTENT_BLOCKED` |
+| Account has an outstanding required action | 403 `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| Guild is unavailable | 403 `MISSING_ACCESS` |
+| Unverified account in an age restricted channel | 403 `NSFW_CONTENT_AGE_RESTRICTED` |
+| Membership, channel access, or permission failure | 403 `MISSING_PERMISSIONS` |
+| Any other credential refusal | 403 `ACCESS_DENIED` |
+
+### Side effects
+
+The operation consumes one guild and channel webhook slot, generates a 64-character execution token, and stores the optional avatar. It records a `WEBHOOK_CREATE` guild audit entry that names the created webhook, has the target channel in its metadata, and has the supplied reason. A failure to write that entry does not fail the request, and the webhook still exists.
+
+It emits [Webhooks Update](/gateway/events/#webhooks-update) with the guild and channel IDs to guild sessions that can view the channel. A failed creation leaves no webhook and consumes no slot.
+
+### Rate limit
+
+10 requests per minute for each authenticated user and channel ID, on the `webhook:create::channel_id` bucket.
+
+## Get webhook
+
+
+
+Returns a [webhook object](#webhook-object). The caller must be a member of the webhook's guild and hold the [MANAGE_WEBHOOKS](/http-api/permissions/) permission at guild level and in the webhook's current channel.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to return |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [webhook](#webhook-object) object | Webhook was returned |
+| 400 | [error response](/http-api/#error-response) | Path parameter is not a valid snowflake |
+| 4001 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 4032 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 4032 | [error response](/http-api/#error-response) | The caller is not a member of the webhook's guild or lacks `MANAGE_WEBHOOKS` in the webhook's channel |
+| 404 | [error response](/http-api/#error-response) | Webhook does not exist, returning `UNKNOWN_WEBHOOK`, or its guild does not resolve, returning `UNKNOWN_GUILD` |
+
+1 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after Fluxer confirms the permission itself
+
+2 `ACCOUNT_SUSPICIOUS_ACTIVITY` for an outstanding required action, `MISSING_PERMISSIONS` for a membership or permission failure, and `ACCESS_DENIED` otherwise
+
+### Rate limit
+
+100 requests per 10 seconds for each authenticated user and webhook ID, on the `webhook:read::webhook_id` bucket.
+
+## Update webhook
+
+
+
+Updates a webhook and returns the modified [webhook object](#webhook-object). Emits a [Webhooks Update](/gateway/events/#webhooks-update) Gateway event.
+
+### Limitations
+
+- The caller is a member of the webhook's guild and holds [MANAGE_WEBHOOKS](/http-api/permissions/) at guild level and in the webhook's current channel.
+- Moving the webhook additionally requires channel access and that permission in the destination channel, which must belong to the same guild.
+- The operation accepts an audit reason.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to update |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | string | Replacement webhook name (1-80 characters) |
+| avatar?1 | ?base64 string | Base64-encoded replacement avatar, or null to remove the current avatar |
+| channel_id?2 | snowflake | Destination guild text or voice channel in the same guild |
+
+1 An omitted avatar leaves the stored hash unchanged, and an explicit null clears it. The same encoding, size, and format rules as [create webhook](#create-webhook) apply
+
+2 A destination equal to the current channel is a no-op, and any other destination is subject to the destination channel's own [max_webhooks_per_channel](/http-api/instance/#limit-keys) allowance
+
+:::caution[A move notifies the source channel only]
+The Dispatch is emitted once and names the webhook's channel as it stood before the update. Sessions watching the destination channel receive nothing, so a client tracking a channel's webhook set refreshes it itself.
+:::
+
+The returned webhook object has the destination channel.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [webhook](#webhook-object) object | Webhook was updated |
+| 400 | [error response](/http-api/#error-response) | Body or avatar is invalid |
+| 400 | [error response](/http-api/#error-response) | The avatar is blocked as explicit media |
+| 4001 | [error response](/http-api/#error-response) | The destination channel already holds its maximum webhooks |
+| 400 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 403 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 403 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 403 | [error response](/http-api/#error-response) | The caller is not a member of the webhook's guild |
+| 403 | [error response](/http-api/#error-response) | The caller lacks `MANAGE_WEBHOOKS` in the current or destination channel |
+| 4032 | [error response](/http-api/#error-response) | The destination channel is age restricted and the account is not age verified |
+| 403 | [error response](/http-api/#error-response) | The name is blocked |
+| 403 | [error response](/http-api/#error-response) | The avatar hash is banned |
+| 404 | [error response](/http-api/#error-response) | Webhook does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The destination channel is missing or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The destination channel belongs to another guild, returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The webhook's guild does not resolve, returning `UNKNOWN_GUILD` |
+
+1 The destination allowance failure has the reached value in a top-level `max_webhooks_per_channel` member
+
+2 The age restriction applies to the destination channel
+
+| Condition | Error |
+| --- | --- |
+| Destination channel already holds its maximum webhooks | 400 `MAX_WEBHOOKS_PER_CHANNEL` |
+| Caller holds the permission but has no enrolled authenticator | 400 `TWO_FACTOR_REQUIRED` |
+| Explicit media classifier blocks the avatar | 400 `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
+| Schema or image failure | 400 `INVALID_FORM_BODY` |
+| Name is blocked, or the avatar hash is banned | 403 `CONTENT_BLOCKED` |
+| Account has an outstanding required action | 403 `ACCOUNT_SUSPICIOUS_ACTIVITY` |
+| Unverified account in an age restricted destination channel | 403 `NSFW_CONTENT_AGE_RESTRICTED` |
+| Membership, channel access, or permission failure | 403 `MISSING_PERMISSIONS` |
+| Any other credential refusal | 403 `ACCESS_DENIED` |
+
+### Side effects
+
+The operation replaces the supplied fields, uploading a new avatar when one is supplied and clearing the stored hash when avatar is null. It records a `WEBHOOK_UPDATE` guild audit entry containing the previous and next webhook snapshots, the webhook's channel in its metadata, and the supplied audit reason.
+
+It emits [Webhooks Update](/gateway/events/#webhooks-update) with the guild ID and the webhook's previous channel ID to guild sessions that can view that channel.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and webhook ID, on the `webhook:update::webhook_id` bucket.
+
+## Delete webhook
+
+
+
+Permanently deletes a webhook and returns 204 with an empty body on success. The caller must be a member of the webhook's guild and hold the [MANAGE_WEBHOOKS](/http-api/permissions/) permission at guild level and in the webhook's channel. The operation accepts an audit reason.
+
+Deletion emits a [Webhooks Update](/gateway/events/#webhooks-update).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to delete |
+
+:::caution[Deletion removes the webhook and its token permanently]
+No operation restores the webhook or reissues its token. Messages it already created remain in the channel and can no longer be edited or deleted through a webhook route.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Webhook was deleted |
+| 400 | [error response](/http-api/#error-response) | Path parameter is not a valid snowflake |
+| 4001 | [error response](/http-api/#error-response) | The caller holds `MANAGE_WEBHOOKS` without an enrolled authenticator in an elevated-MFA guild |
+| 4032 | [error response](/http-api/#error-response) | Credential is a bearer token |
+| 4032 | [error response](/http-api/#error-response) | The account has an outstanding required action |
+| 4032 | [error response](/http-api/#error-response) | The caller is not a member of the webhook's guild or lacks `MANAGE_WEBHOOKS` in the webhook's channel |
+| 404 | [error response](/http-api/#error-response) | Webhook does not exist, returning `UNKNOWN_WEBHOOK`, or its guild does not resolve, returning `UNKNOWN_GUILD` |
+
+1 The missing-authenticator code is `TWO_FACTOR_REQUIRED`, returned only after Fluxer confirms the permission itself
+
+2 `ACCOUNT_SUSPICIOUS_ACTIVITY` for an outstanding required action, `MISSING_PERMISSIONS` for a membership or permission failure, and `ACCESS_DENIED` otherwise
+
+### Side effects
+
+The operation removes the webhook, frees its guild and channel webhook slot, and records a `WEBHOOK_DELETE` guild audit entry with the deleted webhook, its channel in the entry metadata, and the supplied reason. It emits [Webhooks Update](/gateway/events/#webhooks-update) with the guild and channel IDs to guild sessions that can view the channel.
+
+### Rate limit
+
+20 requests per 10 seconds for each authenticated user and webhook ID, on the `webhook:delete::webhook_id` bucket.
+
+## Token routes
+
+The ten routes below take the matching webhook ID and token in their paths as the complete credential. The `Authorization` header is neither required nor read on any of them.
+
+## Get webhook with token
+
+
+
+Returns a [token webhook object](#token-webhook-object). No permission is evaluated.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to return |
+| token1 | string | Webhook execution token (1-256 characters) |
+
+1 A token outside that length returns 400 `INVALID_FORM_BODY`. A well-formed token that does not match the webhook is reported as an unknown webhook, so valid and invalid webhook IDs are indistinguishable
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [token webhook](#token-webhook-object) object | Webhook was returned |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+
+### Rate limit
+
+100 requests per 10 seconds for each caller identity and webhook ID, on the `webhook:read::webhook_id` bucket.
+
+## Update webhook with token
+
+
+
+Updates a webhook and returns the modified [token webhook object](#token-webhook-object). This form cannot move the webhook and does not accept an audit reason.
+
+A successful update emits a [Webhooks Update](/gateway/events/#webhooks-update).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to update |
+| token | string | Webhook execution token (1-256 characters) |
+
+### JSON body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| name? | string | Replacement webhook name (1-80 characters) |
+| avatar?1 | ?base64 string | Base64-encoded replacement avatar, or null to remove the current avatar |
+
+1 An omitted avatar leaves the stored hash unchanged and an explicit null clears it, exactly as in the authenticated form. The same encoding, size, and format rules as [create webhook](#create-webhook) apply
+
+:::note[The token body is strict]
+Unlike [update webhook](#update-webhook), this body rejects any field it does not define. Sending `channel_id` or any other unknown key returns 400, so a token alone cannot move a webhook between channels.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [token webhook](#token-webhook-object) object | Webhook was updated |
+| 400 | [error response](/http-api/#error-response) | Path, body, or avatar is invalid |
+| 400 | [error response](/http-api/#error-response) | The body has an unknown field |
+| 400 | [error response](/http-api/#error-response) | The avatar is blocked as explicit media, returning `EXPLICIT_CONTENT_CANNOT_BE_SENT` |
+| 403 | [error response](/http-api/#error-response) | Name is blocked or the avatar hash is banned, each returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+
+### Side effects
+
+The operation replaces the supplied fields and emits [Webhooks Update](/gateway/events/#webhooks-update) with the guild and channel IDs to guild sessions that can view the channel. It records no guild audit entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each caller identity and webhook ID, on the `webhook:update::webhook_id` bucket.
+
+## Delete webhook with token
+
+
+
+Permanently deletes a webhook and returns 204 with an empty body on success. The operation does not accept an audit reason.
+
+Deletion emits a [Webhooks Update](/gateway/events/#webhooks-update).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to delete |
+| token | string | Webhook execution token (1-256 characters) |
+
+:::caution[Any token holder can delete the webhook]
+The route evaluates no guild permission and records no audit entry naming a user. The webhook and its token are permanently removed and cannot be restored.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Webhook was deleted |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+
+### Side effects
+
+The operation removes the webhook, frees its guild and channel webhook slot, and emits [Webhooks Update](/gateway/events/#webhooks-update) with the guild and channel IDs to guild sessions that can view the channel. It records no guild audit entry.
+
+### Rate limit
+
+20 requests per 10 seconds for each caller identity and webhook ID, on the `webhook:delete::webhook_id` bucket.
+
+## Execute webhook
+
+
+
+Creates a webhook-authored [message](/http-api/messages/#message-object) in the webhook's channel. When `wait` is true the created message is returned, and otherwise the operation returns 204 with an empty body.
+
+The route refuses a call from the official web client. See [origin refusal](#origin-refusal). A successful execution emits a [Message Create](/gateway/events/#message-create) Gateway Dispatch whether or not wait is true.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to execute |
+| token | string | Webhook execution token (1-256 characters) |
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| wait?1 | boolean | Whether to return the created message (default false) |
+
+1 Only the exact trimmed values `true`, `True`, and `1` are treated as true, and every other value, including `TRUE` and `yes`, is treated as false
+
+### JSON body
+
+The JSON representation of the request body is a [webhook message body](#webhook-message-body). Any request whose `Content-Type` does not contain `multipart/form-data` is read as JSON. Fluxer reads a body that is empty, whitespace-only, or unparseable as `{}`, and the request then fails as an empty message.
+
+### Multipart body
+
+| Field | Type | Description |
+| --- | --- | --- |
+| payload_json?1 | string | JSON encoded [webhook message body](#webhook-message-body) |
+| files[n]?2 | binary | Direct attachment at zero-based index n |
+| content?3 | string | Message content, merged into the payload |
+| nonce?3 | string | Client-generated message identifier, merged into the payload |
+| tts?3 | string | Text-to-speech request, merged into the payload |
+| flags?3 | string | Message flags, merged into the payload |
+
+1 An absent `payload_json` is read as `{}`. A present value that is not a string, or that is not valid JSON, returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_JSON_IN_PAYLOAD_JSON`
+
+2 The index must be a decimal integer from 0 through the resolved [max_attachments_per_message](/http-api/instance/#limit-keys) ceiling minus one, defaulting to 9. The indices may have gaps, and each index has at most one file. A field name beginning with `files[` that does not match the exact form is rejected, and the legacy names `file` and `file` are also accepted
+
+3 Supplied as an ordinary form field, and it overrides the value in `payload_json` under the same name. Every other form field name is ignored
+
+A multipart execution uploads every direct file as the webhook's creating account, and as the deleted user account when that creator no longer exists. The upload requires `VIEW_CHANNEL`, `SEND_MESSAGES`, and `ATTACH_FILES` in the webhook's channel, so a body with a direct file returns 403 `MISSING_PERMISSIONS` when the uploading account no longer holds them. A body with no direct file is unaffected.
+
+An attachment metadata entry whose `id` matches a supplied file index supplies that file's filename, title, description, flags, duration, and waveform. An entry whose `id` matches no supplied file and that has a `filename` returns 400 `INVALID_FORM_BODY` with the validation code `NO_FILE_FOR_ATTACHMENT_METADATA`. Two entries claiming the same file index return `DUPLICATE_ATTACHMENT_IDS_NOT_ALLOWED`. When the payload supplies no attachment metadata at all, one entry is synthesised for each file from its index and its own filename.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](/http-api/messages/#message-object) object | Message was created and wait is true |
+| 204 | empty | Message was created and wait is false or omitted |
+| 4001 | [error response](/http-api/#error-response) | Path, query, multipart framing, or message input is invalid |
+| 4001 | [error response](/http-api/#error-response) | The resolved payload has no content, embed, or attachment |
+| 4001 | [error response](/http-api/#error-response) | The content exceeds the effective maximum length |
+| 4001 | [error response](/http-api/#error-response) | The embed or attachment count exceeds its effective ceiling |
+| 403 | [error response](/http-api/#error-response) | Request has an official web client `Origin`, returning `INVALID_API_ORIGIN` |
+| 403 | [error response](/http-api/#error-response) | The resolved content or embed text is blocked, returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The target channel is missing or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The referenced message does not exist in the webhook's channel, returning `UNKNOWN_MESSAGE` |
+
+1 `CANNOT_SEND_EMPTY_MESSAGE` is returned as the 400 error code itself. Every other code below, including `CONTENT_EXCEEDS_MAX_LENGTH`, sits inside an `INVALID_FORM_BODY` body
+
+| Condition | Error |
+| --- | --- |
+| Resolved payload has nothing sendable | `CANNOT_SEND_EMPTY_MESSAGE` |
+| Content longer than the effective maximum | `CONTENT_EXCEEDS_MAX_LENGTH` |
+| Payload the webhook message schema rejects | `INVALID_MESSAGE_DATA` |
+| Embed or attachment count ceiling exceeded | `TOO_MANY_EMBEDS` or `TOO_MANY_FILES` |
+| Forward reference omits `channel_id` or `message_id` | `FORWARD_REFERENCE_REQUIRES_CHANNEL_AND_MESSAGE` |
+| Forward reference accompanies content, embeds, or attachments | `FORWARD_MESSAGES_CANNOT_CONTAIN_CONTENT` |
+| Reply reference names a system message | `CANNOT_REPLY_TO_SYSTEM_MESSAGE` |
+
+### Side effects
+
+The operation creates one webhook-authored message, attaches direct multipart files, and resolves forward snapshots and mentions under the allowed mentions policy.
+
+A supplied `username` replaces the author name on this message. A supplied `avatar_url` is fetched for this message, and the webhook's stored name and avatar do not change. When the avatar cannot be fetched, or the explicit media classifier blocks it, Fluxer creates the message without the override.
+
+The operation updates channel state and search results and emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel.
+
+### Rate limit
+
+60 requests per minute for each caller identity and webhook ID, on the `webhook:execute::webhook_id` bucket, which is exempt from the global limit.
+
+## Get webhook message
+
+
+
+Returns a [message](/http-api/messages/#message-object) that the webhook authored.
+
+The route refuses a call from the official web client. See [origin refusal](#origin-refusal).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook that authored the message |
+| token | string | Webhook execution token (1-256 characters) |
+| message_id1 | snowflake | The ID of the message to return |
+
+1 The message is resolved within the webhook's current channel, so a message the webhook created before it was moved to another channel is no longer reachable through this route
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](/http-api/messages/#message-object) object | Webhook message was returned |
+| 403 | [error response](/http-api/#error-response) | Request has an official web client `Origin`, returning `INVALID_API_ORIGIN` |
+| 403 | [error response](/http-api/#error-response) | The message was authored by another webhook, a user, or a bot, returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The webhook has no channel target, returning `UNKNOWN_CHANNEL` |
+| 404 | [error response](/http-api/#error-response) | The message does not exist in that channel, returning `UNKNOWN_MESSAGE` |
+
+### Rate limit
+
+60 requests per minute for each caller identity and webhook ID, on the `webhook:message_get::webhook_id` bucket, which is exempt from the global limit.
+
+## Edit webhook message
+
+
+
+Updates a message that the webhook authored and returns the modified [message](/http-api/messages/#message-object).
+
+The route refuses a call from the official web client. See [origin refusal](#origin-refusal). A successful edit emits a [Message Update](/gateway/events/#message-update).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook that authored the message |
+| token | string | Webhook execution token (1-256 characters) |
+| message_id | snowflake | The ID of the message to update |
+
+### JSON body
+
+The body is a [webhook message edit body](#webhook-message-edit-body).
+
+:::note[A default message and a reply are editable]
+Any other type returns 400 `CANNOT_MODIFY_SYSTEM_WEBHOOK`, and a forward, which has message snapshots, returns 400 `INVALID_FORM_BODY` with the validation code `MESSAGES_WITH_SNAPSHOTS_CANNOT_BE_EDITED`. The body defines no field for the author name, avatar, attachments, message reference, or stickers, so an edit cannot change them.
+:::
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | [message](/http-api/messages/#message-object) object | Webhook message was updated |
+| 400 | [error response](/http-api/#error-response) | Path, body, embed, flag, or mention input is invalid |
+| 4001 | [error response](/http-api/#error-response) | The body leaves the message empty |
+| 400 | [error response](/http-api/#error-response) | The content or embed count exceeds its effective ceiling |
+| 400 | [error response](/http-api/#error-response) | The target message is not an editable type |
+| 400 | [error response](/http-api/#error-response) | The webhook's stored channel no longer resolves to a guild channel |
+| 403 | [error response](/http-api/#error-response) | Request has an official web client `Origin`, returning `INVALID_API_ORIGIN` |
+| 403 | [error response](/http-api/#error-response) | The message was authored by another webhook, a user, or a bot, returning `MISSING_PERMISSIONS` |
+| 403 | [error response](/http-api/#error-response) | The replacement content or embed text is blocked, returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The message does not exist in the webhook's channel, returning `UNKNOWN_MESSAGE` |
+
+1 A `flags` member of any value, including zero, satisfies the non-empty check, so `{}` is rejected while `{"flags": 0}` is accepted
+
+| Condition | Error |
+| --- | --- |
+| Target message type cannot be edited | 400 `CANNOT_MODIFY_SYSTEM_WEBHOOK` |
+| Stored channel no longer resolves to a guild channel | 400 `CANNOT_EXECUTE_ON_DM` |
+| Body has no visible content, no non-empty embeds, and no `flags` | 400 `CANNOT_SEND_EMPTY_MESSAGE` |
+| Content longer than the effective maximum | 400 `CONTENT_EXCEEDS_MAX_LENGTH` |
+| Anything else | 400 `INVALID_FORM_BODY` |
+
+`CONTENT_EXCEEDS_MAX_LENGTH` is a validation code inside a 400 `INVALID_FORM_BODY` body.
+
+### Side effects
+
+The operation replaces the supplied fields, and a content change marks the message edited. An edit does not re-extract mentions. The stored mention lists are kept, and `allowed_mentions` is accepted and not read. Supplying embeds replaces the complete embed collection and revalidates every attachment reference the embeds make. The operation emits [Message Update](/gateway/events/#message-update) to sessions that can read the channel.
+
+### Rate limit
+
+30 requests per minute for each caller identity and webhook ID, on the `webhook:message_edit::webhook_id` bucket, which is exempt from the global limit.
+
+## Delete webhook message
+
+
+
+Deletes a message that the webhook authored and returns 204 with an empty body on success.
+
+The route refuses a call from the official web client. See [origin refusal](#origin-refusal). Deletion emits a [Message Delete](/gateway/events/#message-delete).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook that authored the message |
+| token | string | Webhook execution token (1-256 characters) |
+| message_id | snowflake | The ID of the message to delete |
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Webhook message was deleted |
+| 400 | [error response](/http-api/#error-response) | Path parameters are invalid |
+| 400 | [error response](/http-api/#error-response) | The webhook's stored channel no longer resolves to a guild channel, returning `CANNOT_EXECUTE_ON_DM` |
+| 403 | [error response](/http-api/#error-response) | Request has an official web client `Origin`, returning `INVALID_API_ORIGIN` |
+| 403 | [error response](/http-api/#error-response) | The message was authored by another webhook, a user, or a bot, returning `MISSING_PERMISSIONS` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The message does not exist in the webhook's channel, returning `UNKNOWN_MESSAGE` |
+
+### Side effects
+
+The operation permanently removes the message, purges its attachments, and removes it from search. It emits [Message Delete](/gateway/events/#message-delete) to sessions that can read the channel. Deleting a pinned message also removes the pin and emits [Channel Pins Update](/gateway/events/#channel-pins-update).
+
+### Rate limit
+
+30 requests per minute for each caller identity and webhook ID, on the `webhook:message_delete::webhook_id` bucket, which is exempt from the global limit.
+
+## Execute GitHub webhook
+
+
+
+Accepts a GitHub callback and creates one formatted message when the event renders. The route always returns 204 with an empty body, whether or not a message was created.
+
+A rendered event emits a [Message Create](/gateway/events/#message-create).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to execute |
+| token | string | Webhook execution token (1-256 characters) |
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-GitHub-Event?1 | string | [GitHub event type](#github-event-types) |
+| X-GitHub-Delivery?2 | string | Delivery identifier used for deduplication |
+
+1 Neither header is validated and neither is bounded in length. An absent header is read as the empty string, which matches no event type and therefore renders nothing
+
+2 An absent or empty delivery identifier disables deduplication for that request, so the callback is processed and no dedup marker is recorded
+
+### JSON body
+
+The body is a [GitHub callback](#github-callback-object) object. A body the schema rejects returns 400 `INVALID_FORM_BODY`.
+
+:::note[Deduplication is per webhook and delivery]
+Only the first callback for a webhook and non-empty delivery identifier is processed within a 24-hour window. A repeated delivery is acknowledged without creating a second message.
+:::
+
+A callback that renders nothing, and one whose message creation fails, both leave the delivery identifier free for a later retry.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Callback was accepted, was not renderable, or repeated a delivery within the deduplication window |
+| 403 | [error response](/http-api/#error-response) | Rendered embed text is blocked, returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The target channel is missing or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+A renderable event creates one message with exactly one embed, authored under the fixed name `GitHub` with the bundled GitHub avatar, and emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel. Mention parsing is disabled for the created message, so no mention in a commit message, issue title, or comment body notifies anyone.
+
+An unrecognised event type, a recognised type whose required fields or action are absent, and a repeated non-empty delivery identifier all create nothing and emit no Dispatch.
+
+### Rate limit
+
+200 requests per minute for each caller identity and webhook ID, on the `webhook:github::webhook_id` bucket, which is exempt from the global limit.
+
+## Execute Slack webhook
+
+
+
+Accepts a Slack-compatible callback, converts it to the Fluxer message contract, and creates one webhook-authored message.
+
+A successful callback emits a [Message Create](/gateway/events/#message-create).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to execute |
+| token | string | Webhook execution token (1-256 characters) |
+
+### JSON body
+
+The body is a [Slack callback](#slack-callback-object) object.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | the literal string `ok` | Callback message was created |
+| 400 | [error response](/http-api/#error-response) | Path or Slack callback is invalid |
+| 4001 | [error response](/http-api/#error-response) | The conversion yields neither content nor an embed |
+| 4001 | [error response](/http-api/#error-response) | The resulting content exceeds the effective maximum length |
+| 4001 | [error response](/http-api/#error-response) | The converted embed count exceeds its effective ceiling |
+| 403 | [error response](/http-api/#error-response) | Converted content or embed text is blocked, returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The target channel is missing or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+
+1 The [error code](/http-api/errors/) is `CANNOT_SEND_EMPTY_MESSAGE` for a conversion that yields nothing, `CONTENT_EXCEEDS_MAX_LENGTH` for over-length content, and `INVALID_FORM_BODY` with the validation code `TOO_MANY_EMBEDS` for the embed ceiling
+
+:::note[The Slack callback answers in HTML]
+The success body is the literal string `ok` under the `text/html` content type. It has no representation of the created message.
+:::
+
+### Side effects
+
+The converted callback creates one webhook-authored message with the converted content and embeds, and emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel.
+
+A supplied username replaces the author name on that message. A supplied `icon_url` that parses as an absolute URL is fetched through the media boundary and stored as the message avatar, and the stored webhook name and avatar do not change. When the URL cannot be fetched, or the explicit media classifier blocks the image, the callback still succeeds and the message has no avatar override.
+
+The webhook execution default applies, and every mention in the converted content is suppressed. The callback has no nonce, so a repeated callback creates a second message.
+
+### Rate limit
+
+60 requests per minute for each caller identity and webhook ID, on the `webhook:execute::webhook_id` bucket, which is exempt from the global limit and is the same bucket [execute webhook](#execute-webhook) consumes.
+
+## Execute Instatus webhook
+
+
+
+Accepts an Instatus callback and creates one formatted message when the callback renders. The route always returns 204 with an empty body, whether or not a message was created.
+
+A rendered callback emits a [Message Create](/gateway/events/#message-create).
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| webhook_id | snowflake | The ID of the webhook to execute |
+| token | string | Webhook execution token (1-256 characters) |
+
+### JSON body
+
+The body is an [Instatus callback](#instatus-callback-object) object. A body the schema rejects returns 400 `INVALID_FORM_BODY`.
+
+:::note[Deduplication is per webhook and callback]
+Only the first callback for a webhook and a given incident update, maintenance update, or component update is processed within a 24-hour window. A repeated callback is acknowledged and creates no second message. A callback with no such identifier is processed every time.
+:::
+
+A callback that renders nothing, and one whose message creation fails, both leave the identifier free for a later retry.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 204 | empty | Callback was accepted, was not renderable, or repeated a callback within the deduplication window |
+| 403 | [error response](/http-api/#error-response) | Rendered embed text is blocked, returning `CONTENT_BLOCKED` |
+| 404 | [error response](/http-api/#error-response) | Webhook and token pair does not exist, returning `UNKNOWN_WEBHOOK` |
+| 404 | [error response](/http-api/#error-response) | The target channel is missing or is not a guild text or voice channel, returning `UNKNOWN_CHANNEL` |
+
+### Side effects
+
+An incident with a non-empty name, a maintenance item with a non-empty name, or a component transition creates one message with exactly one embed, authored under the fixed name `Instatus` with the bundled Instatus avatar. Fluxer emits [Message Create](/gateway/events/#message-create) to sessions that can read the channel.
+
+Mention parsing is disabled for the created message, so no mention in a provider-supplied name or update body notifies anyone. A callback that selects none of those renderings creates nothing and emits no Dispatch. A repeated callback inside the deduplication window does the same.
+
+### Rate limit
+
+200 requests per minute for each caller identity and webhook ID, on the `webhook:instatus::webhook_id` bucket, which is exempt from the global limit.
+
diff --git a/fluxer_docs/src/content/docs/index.md b/fluxer_docs/src/content/docs/index.md
new file mode 100644
index 000000000..f3307d7f9
--- /dev/null
+++ b/fluxer_docs/src/content/docs/index.md
@@ -0,0 +1,55 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Fluxer API
+description: The four Fluxer protocol surfaces and the contracts they share.
+---
+
+Fluxer is a self-hostable chat platform. Its API has four surfaces, and all four share one identifier space.
+
+- To build a client or a bot, start with the [HTTP API](/http-api/) and the [Gateway](/gateway/overview/).
+- For voice or a screen share, read [Voice](/voice/).
+- To run an instance, start with [Get started](/operator/get-started/).
+- To look up one route, use the sidebar or the [Protocol surfaces](#protocol-surfaces) table.
+
+## Protocol surfaces
+
+| Surface | What it is | Reference |
+| --- | --- | --- |
+| HTTP API | Resource reads and mutations below `/v1` | [HTTP API](/http-api/) |
+| Gateway | A persistent WebSocket for session state and real-time events | [Gateway](/gateway/overview/) |
+| Media Proxy | Attachments, image assets, themes, entrance sound audio, and the upload relay | [Media Proxy](/media-proxy/overview/) |
+| Admin API | The privileged namespace below `/v1/admin` | [Admin API](/admin-api/) |
+
+A client mutates a resource over the HTTP API and receives the resulting update as a Gateway [Dispatch](/gateway/events/). Each operation states the Dispatches it fires, and [Events](/gateway/events/) defines each payload and its recipient scope.
+
+A [snowflake](/snowflakes/) is the identifier all four surfaces share. Voice runs on LiveKit, and [Voice](/voice/) defines the placement protocol and the media transport.
+
+## Shared contracts
+
+| Read this | For |
+| --- | --- |
+| [Conventions](/conventions/) | Wire table notation, footnotes, omission and `null` |
+| [Authentication](/authentication/) | The `Authorization` grammar and the four credential kinds |
+| [Snowflakes](/snowflakes/) | Identifiers, ordering, and pagination cursors |
+| [Errors](/http-api/errors/) | The error envelope and the code registries |
+| [Rate limits](/topics/rate-limits/) | Buckets, the 429 body, and the `X-RateLimit-*` headers |
+| [Locales](/topics/locales/) | The locale registry and `Accept-Language` negotiation |
+
+## Endpoint discovery
+
+A client that knows only a Fluxer origin reads endpoint discovery first. `GET /.well-known/fluxer` is unversioned, accepts no credential, and is readable from any origin.
+
+```text
+GET https://example.com/.well-known/fluxer
+```
+
+It returns the [instance discovery object](/http-api/instance/#instance-discovery-object). Every base URL a client uses comes from the [instance endpoints object](/http-api/instance/#instance-endpoints-object) inside it. A client MUST read every base URL from that response, and it MUST NOT derive one from the origin it was given or assume an official Fluxer domain.
+
+Take `endpoints.api` from that response, then send a credential in the `Authorization` header.
+
+```text
+GET https://api.example.com/v1/users/@me
+Authorization: flx_ZDb1GURItsMuYl1zvrgxv2qLBxyNmgNSEaWT
+```
+
+That credential is a user session token. [Log in with a password](/http-api/authentication/#log-in-with-a-password) issues one. A bot sends a bot token with the `Bot` prefix, issued by [Create application](/http-api/applications/#create-application). [Authentication](/authentication/) gives the exact form of all four kinds.
diff --git a/fluxer_docs/src/content/docs/media-proxy/overview.md b/fluxer_docs/src/content/docs/media-proxy/overview.md
new file mode 100644
index 000000000..95f803b5e
--- /dev/null
+++ b/fluxer_docs/src/content/docs/media-proxy/overview.md
@@ -0,0 +1,194 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Media Proxy overview
+description: Media Proxy surfaces, methods, byte ranges, response headers, and cache behaviour.
+---
+
+The Media Proxy serves Fluxer attachments, image assets, themes, entrance sound audio, and signed external media. It also accepts uploads authorised by a capability, a signed grant in the upload URL.
+
+## Route families
+
+| Family | Paths | Reference |
+| --- | --- | --- |
+| Attachment | `/attachments/{path}` | [Get attachment](/media-proxy/routes/#get-attachment) |
+| Signed external | `/external/{signature}/{target}` | [Get signed external media](/media-proxy/routes/#get-signed-external-media) |
+| Theme CSS | `/themes/{path}.css` | [Get theme CSS](/media-proxy/routes/#get-theme-css) |
+| Entrance sound | `/entrance-sounds/{user_id}/{filename}` | [Get entrance sound](/media-proxy/routes/#get-entrance-sound) |
+| Image asset1 | `/avatars`, `/icons`, `/branding`, `/banners`, `/splashes`, `/embed-splashes`, `/emojis`, `/stickers` | [Image asset contract](/media-proxy/routes/#image-asset-contract) |
+| Static object2 | `/{key}` | [Get static object](/media-proxy/routes/#get-static-object) |
+| Upload relay | `/v1/relay/{key}` | [Upload relay](/media-proxy/upload-relay/) |
+| Operator and internal | `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames` | [Operator and internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) |
+
+1 Guild member avatars and banners are image assets too, at `/guilds/{guild_id}/users/{user_id}/avatars` and `/guilds/{guild_id}/users/{user_id}/banners`
+
+2 Served by a `static` endpoint alone
+
+[Transformations](/media-proxy/transformations/) defines representation selection for every family that has one. [Responses and limits](/media-proxy/responses-and-limits/) consolidates statuses, size bounds, deadlines, and cache policies.
+
+## Base URLs
+
+Read paths have no `/v1` prefix. Take `endpoints.media` and `endpoints.static_cdn` from [instance discovery](/http-api/instance/#instance-endpoints-object).
+
+The upload relay is the exception in both respects. It is served below `/v1/relay/`, and its base comes from a deployment setting. A client MUST take that base from the upload URL the HTTP API issued and MUST NOT rebuild one.
+
+## Authorisation
+
+No public Media Proxy route reads the HTTP API `Authorization` header. A stored object is authorised by its path, signed external media by its path signature, and an upload by the capability in its URL. Only the internal `POST` endpoints read `Authorization`, and they require the exact value `Bearer {secret}` built from the deployment secret.
+
+:::caution[A media URL is a bearer capability]
+An attachment path has no signature and no expiry, so anyone holding the URL reads the object for as long as it exists. Leaving the channel does not revoke a URL already issued.
+:::
+
+The Media Proxy has no request-count rate limit, so no response has the [rate limit headers](/topics/rate-limits/#rate-limit-headers) of the versioned HTTP API. A failure returns a short `text/plain` body, and [media error response](/media-proxy/responses-and-limits/#media-error-response) defines that shape.
+
+## Deployment modes
+
+One Media Proxy process serves exactly one mode. The mode is fixed at startup and defaults to `mp`.
+
+| Mode | Serves |
+| --- | --- |
+| `mp` | Attachments, signed external media, themes, entrance sounds, and every image asset route |
+| `static` | Every read path as a raw object read from the static bucket, with no transformation and no SVG rasterisation1 |
+| `upload` | The [upload relay](/media-proxy/upload-relay/), plus every `mp` read route from the same buckets |
+
+1 A `static` mode endpoint also strips `X-Robots-Tag` from every response and sends no `Content-Disposition`
+
+The relay `PUT` is the only route with a mode gate, and it returns 404 outside `upload` mode. On a read that requests no transformation, only an `mp` endpoint rasterises SVG, so an `upload` endpoint returns the original SVG bytes. The [operator and internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) behave the same in every mode.
+
+Which published base URL serves which mode is a deployment choice. The reference self-hosted deployment serves `endpoints.media` from an `upload` mode process.
+
+## Methods
+
+Every read route accepts `GET` and `HEAD`. HEAD returns the same status and representation headers as GET with an empty body, and a `Range` on HEAD still selects 206 or 416. Any other method on a read path returns 405 with the body `Method Not Allowed`.
+
+The relay path accepts `PUT`. Any other method there returns 405 with an `Allow` header. An unknown path returns 404 with the body `Not Found`.
+
+The [signed external route](/media-proxy/routes/#get-signed-external-media) is the one place a HEAD can answer differently from the matching GET. A HEAD with no range and no transformation is served from an origin HEAD when that origin returns 200, declares a length within the [500 MiB media bound](/media-proxy/responses-and-limits/#request-and-media-limits), and names a non-SVG media type.
+
+:::note[An origin HEAD resolves the type without bytes]
+That answer uses the declared type and the filename alone, so an origin that mislabels its bytes produces a different `Content-Type` and `Content-Disposition` than the GET of the same URL.
+:::
+
+## Request headers
+
+Only `Range` affects the representation a public read returns. `X-Forwarded-For` decides which address the [media access allowlist](#access-restrictions) evaluates, so it can turn a 200 into a 403 without changing the representation. Every other request header, including `Authorization`, `Cookie`, `Accept`, `If-Range`, `If-None-Match`, and `Origin`, is ignored on a public read route.
+
+### Common request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Range?1 | string | One byte range under the [byte-range contract](#byte-ranges) |
+| X-Forwarded-For?2 | string | Client address chain read by the [media access allowlist](#access-restrictions) |
+
+1 A value whose unit is not the exact lowercase `bytes`, or that names more than one range, is ignored, and the response has the complete representation
+
+2 Read only when the allowlist gate is enabled and the peer address is a configured trusted proxy. A value from any other peer is ignored
+
+The Media Proxy reads no `If-None-Match`, `If-Modified-Since`, `If-Range`, `If-Match`, or `If-Unmodified-Since`, and it sends no `ETag` or `Last-Modified` on a read route, so a cache revalidates a representation by fetching it again.
+
+## Access restrictions
+
+An operator MAY gate public reads on a CDN edge address allowlist. The gate is disabled by default. When it is enabled, the process fetches the Bunny edge address list at startup and refreshes it every 3600 seconds by default, and a process whose first fetch fails does not start.
+
+A request from an address outside the list returns 403 with the body `origin not in bunny allowlist`. `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames`, and every path below `/v1/relay/` are exempt.
+
+When the peer address is a configured trusted proxy, the client address is the rightmost `X-Forwarded-For` hop that is not itself a trusted proxy. Otherwise the Media Proxy uses the peer address and ignores `X-Forwarded-For`. It does the same when there is no `X-Forwarded-For`, or when every hop in it is unparseable or is itself a trusted proxy.
+
+## Selector parsing
+
+A read route names its representation in its query string. The attachment, signed external, and image asset routes accept selectors. The theme and entrance sound routes accept none and ignore any query string.
+
+Query names and values use URL form decoding, so `+` decodes to a space and a percent escape decodes to its byte. When a name occurs more than once, the final value wins. An unknown name is ignored.
+
+The Media Proxy canonicalises nothing and issues no redirect for a noncanonical target. Two spellings of the same selection are two separate cache entries.
+
+A Boolean is true only for case-insensitive `true` or the exact value `1`. Every other value, including `false` and `0`, is false. [Transformations](/media-proxy/transformations/) defines dimensions, formats, quality values, and animation flags.
+
+## Byte ranges
+
+This contract governs every range the Media Proxy resolves itself. A range is recognised only when its unit is the exact lowercase `bytes`, so `bytes=0-99` selects an interval and `BYTES=0-99` is ignored. Surrounding spaces and tabs are trimmed. A range containing a comma names multiple ranges and is ignored. A malformed range is ignored and produces the complete representation. `HEAD` applies the same contract as `GET`.
+
+| Value | Description |
+| --- | --- |
+| `bytes={start}-{end}` | Selects the inclusive interval and clamps an end beyond the representation |
+| `bytes={start}-` | Selects from start through the final byte |
+| `bytes=-{length}` | Selects the final length bytes and selects the complete representation when length is larger |
+
+A reversed range, a zero-length suffix, a start outside the representation, or any range over an empty representation returns 416 with `Content-Range: bytes */{size}` and `Accept-Ranges: bytes`. A satisfiable range returns 206, `Accept-Ranges: bytes`, and exact `Content-Range` and `Content-Length` values.
+
+### Ranges on the signed external route
+
+The route forwards a range to the origin only when no transformation is requested. It sends the range verbatim when the value after `bytes=` is non-empty and every byte of it is an ASCII graphic character, so a multiple range reaches the origin and the origin decides how to answer it. A value with a space anywhere is dropped, and no range is sent. The route relays the origin partial response with the origin `Content-Range` unchanged.
+
+A transforming request forwards no range to the origin and applies the client range to the transformed bytes, so it still returns 206 or 416. An origin 200 on a non-transforming request is relayed as that 200 when its declared type is trustworthy and the response is not SVG by declared type, filename, or leading bytes. A relayed 200 does not reapply the client range. An SVG response is rasterised and the client range is applied to the rasterised bytes.
+
+A trustworthy type is a normalised `image/`, `video/`, or `audio/` type other than `application/octet-stream`. An absent or empty `Content-Type`, `text/plain`, `application/pdf`, and `application/zip` are all untrustworthy. Fluxer buffers the body of a 200 under an untrustworthy type and applies the client range to those bytes, so that read returns 206.
+
+The route fetches twice in exactly one case. When an origin answers a forwarded range with 206 under a declared SVG media type, Fluxer discards that partial response, fetches the whole object again without a range, rasterises it, and applies the client range to the rasterised bytes.
+
+:::caution[A mislabelled SVG reaches the client as bytes]
+The re-fetch tests the declared media type alone. An origin that answers a forwarded range with SVG bytes under another type produces a 206 of raw SVG under that type.
+:::
+
+Disposition follows that declared type, so SVG mislabelled as an image or video media type is served inline.
+
+## Representation headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Content-Type1 | string | The detected or selected media type |
+| Content-Length?2 | integer | The selected body length, including on HEAD |
+| Content-Range? | string | Present on 206 as `bytes {start}-{end}/{size}` and on 416 as `bytes */{size}` |
+| Content-Disposition?3 | string | The route-selected inline or attachment disposition |
+| Accept-Ranges | string | The literal `bytes` on every media representation |
+| Access-Control-Allow-Origin | string | The literal `*` on media responses |
+| Cache-Control4 | string | The browser cache policy for the representation |
+| CDN-Cache-Control | string | The corresponding shared cache policy |
+| Vary | string | The literal `Accept-Encoding` |
+| X-Content-Type-Options | string | The literal `nosniff` |
+| X-Robots-Tag5 | string | The indexing policy |
+| X-Fluxer-Version6 | string | The serving Fluxer build identifier, or `dev` when none is configured |
+
+1 Fixed at `text/css; charset=utf-8` on the theme route. A 416 response has none
+
+2 Counts the selected bytes, so a 206 advertises the range length. Omitted from a streamed [signed external](/media-proxy/routes/#get-signed-external-media) response whose origin declared no length
+
+3 Present on the attachment, image asset, and signed external routes, absent on the theme and entrance sound routes, absent on every `static` mode read, and omitted when an image asset falls back to its original bytes after a failed transcode
+
+4 An audio or video representation appends `no-transform`. A route-produced error uses `no-store` instead
+
+5 The literal `noindex, nofollow, nosnippet, noimageindex, notranslate, max-snippet:0, max-image-preview:none, max-video-preview:0`. Present in `mp` and `upload` mode. A `static` mode endpoint removes it
+
+6 Absent from a media access allowlist rejection
+
+Every successful media representation uses `Cache-Control: public, max-age=31536000` and `CDN-Cache-Control: public, max-age=31536000`. An audio or video representation adds `no-transform` to the browser-facing policy only. No response repeats its policy in an `Expires` header. [Cache policies](/media-proxy/responses-and-limits/#cache-policies) lists the responses that have no policy at all.
+
+No read route sends an `ETag`. The upload relay is the only route that returns one, and it relays the object storage value for the stored object.
+
+:::note[External media is cached for a year too]
+The signed path is derived from the target URL, so the bytes behind one unchanged target are cached for a year at both layers.
+:::
+
+A 416 response has `Accept-Ranges`, `Content-Range`, `Access-Control-Allow-Origin`, `Vary`, and `X-Robots-Tag`, with no `Content-Type` and no cache policy. Its body is empty.
+
+## Content detection
+
+For a streamed object, Fluxer first reads stored media type metadata that names an image, audio, or video. Otherwise it takes the filename extension, then any other non-empty declared type except `application/octet-stream`, and finally `application/octet-stream`.
+
+When the complete input is available, detected SVG takes precedence. Another trustworthy declared image, audio, or video type remains authoritative. Without one, Fluxer checks the leading 8,192 bytes, then the filename extension, then the declared type, and finally uses `application/octet-stream`.
+
+A filename extension that identifies MP4 audio overrides a declared `video/mp4` with `audio/mp4`.
+
+## Content disposition
+
+Disposition follows the resolved media type. An image other than SVG and a video are served inline. Every other type, including SVG and PDF, is served as an attachment. An explicit `download` request forces attachment disposition on every route that accepts the parameter.
+
+The disposition filename comes from the route. An attachment or signed external read uses the filename in the path or target URL. An image asset uses the path hash with any `a_` prefix stripped, followed by the canonical name of the path extension, so `/avatars/1/a_abcd1234.jpg` is offered as `abcd1234.jpeg`.
+
+When `download` resolves to true and the served media type has a canonical extension the filename does not already use, the filename keeps its stem and takes that extension, so a PNG transformation of `holiday.jpg` is offered as `holiday.png`. When a filename is not safe as a quoted ASCII value, Fluxer sends a sanitised quoted fallback and an RFC 5987 `filename*` parameter.
+
+:::caution[A scriptable document is never inline]
+The attachment, image asset, and signed external routes rasterise SVG to WebP, so a browser does not execute the document in the Media Proxy origin.
+:::
+
+On a non-transforming attachment read, only an `mp` endpoint rasterises SVG, and that path always uses lossless WebP. A transforming request uses the `format` and `quality` it was given, and an image asset path defaults to `high`. A `static` mode endpoint serves the original bytes.
diff --git a/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md b/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md
new file mode 100644
index 000000000..b6354bb97
--- /dev/null
+++ b/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md
@@ -0,0 +1,142 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Responses and limits
+description: Media Proxy statuses, error bodies, size bounds, work admission, deadlines, and cache policies.
+---
+
+Almost every failed Media Proxy request answers with a short `text/plain` reason phrase and nothing else. Every bound below is a resource bound, because the Media Proxy has no request-count rate limit.
+
+## Media error response
+
+An unsuccessful response body is an English reason phrase under the content type `text/plain; charset=utf-8`. The JSON [HTTP API error response](/http-api/#error-response) never appears here. `HEAD` returns the same status and headers with an empty body. The method rejection of a registered path2 and a locally unsatisfiable range have neither a body nor a `Content-Type`.
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 400 | `Bad Request` | An invalid storage key, dimension, format, or external path, an unparsable relay `partNumber`, a failed attachment or external transformation, or a relay upload the endpoint could not take1 |
+| 401 | `Unauthorized` | An invalid signed external path signature, a missing, malformed, or expired relay capability, or a missing or invalid internal bearer token |
+| 403 | `origin not in bunny allowlist` | The media access allowlist rejected the client address |
+| 403 | `Forbidden` | A relay capability presented for another bucket, key, method, `uploadId`, or `partNumber` |
+| 403 | `FORBIDDEN` | `/_metrics` requested from a non-loopback address |
+| 404 | `Not Found` | A `GET` or `HEAD` of an unrouted path, an object that does not exist, or a relay `PUT` outside `upload` mode |
+| 405 | `Method Not Allowed`2 | A method other than `GET` or `HEAD` on a read route or on an unrouted path |
+| 413 | `Payload Too Large` | A stored object, external body, upload body, or internal request body beyond its bound |
+| 500 | `Transcode Failed` | An image asset transcode failed and the source is not directly displayable |
+| 500 | `Internal Server Error` | A relay spool write to the endpoint's own disk failed |
+| 502 | `Bad Gateway` | An object store read or write failed, an external origin could not be used, or an outbound socket deadline or a relay object storage write deadline expired |
+| 503 | `Service Unavailable` | The upload relay spool budget is exhausted, an external buffer reservation or allocation failed, or an external origin answered `/_metadata` with 4293 |
+| 504 | `Gateway Timeout` | A transformation admission slot was unavailable or the transformation deadline expired |
+
+1 The relay also answers 400 when the client connection fails part way through the body
+
+2 The six registered paths, `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames`, and `/v1/relay/{key}`, answer an unaccepted method with an empty body, no `Content-Type`, and an `Allow` header
+
+3 `/_metadata` is the only endpoint that remaps an origin 429. The signed external read route retains 429 as 429
+
+A retained external origin status uses the body `Upstream fetch failed` on the signed external read route and the canonical reason phrase of its status on `/_metadata`. An object store error that maps to no case above uses the canonical reason phrase of its status.
+
+Every error a route produces uses `Cache-Control: no-store` and the standard [security headers](/media-proxy/overview/#representation-headers). Three failures have the security headers and set no cache policy, and [Cache policies](#cache-policies) names them. No plain-text error has CORS headers unless it came from the [upload relay](/media-proxy/upload-relay/), and no error has `Retry-After` or a request identifier.
+
+### Handling contract
+
+The set of reason phrases is open. A client MUST branch on the HTTP status and MUST NOT parse, compare, or pattern-match the body, because a phrase can be reworded and a new condition can introduce one without a version change. A client MUST NOT expect a JSON body on a failure, and MUST NOT expect a failure to name the key, parameter, or field that caused it.
+
+## Status registry
+
+Every `HEAD` response has an empty body, so the Body column describes `GET`, `PUT`, and `POST`.
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 2001 | Media bytes, or an empty `HEAD` or relay `PUT` body | A complete representation was served or a relay write succeeded |
+| 206 | Selected media bytes for `GET` and empty for `HEAD` | One byte range is satisfiable |
+| 400 | [Media error response](#media-error-response) | A path, query, target, transformation, or relay request is invalid |
+| 401 | [Media error response](#media-error-response) | An external path signature, a relay capability, or an internal token is invalid |
+| 403 | [Media error response](#media-error-response) | The media access policy denied the request, a relay capability does not match the request, or an external origin returned 403 |
+| 404 | [Media error response](#media-error-response) | The route or object does not exist, the relay is not served here, or an external origin returned 404 |
+| 4052 | [Media error response](#media-error-response) | The route rejects the method, or an external origin returned 405 |
+| 406, 408, 409, 410, 411, 412, 414, 415, 428, 4293 | [Media error response](#media-error-response) | An external origin returned that status |
+| 413 | [Media error response](#media-error-response) | A source, external response, or upload exceeds its applicable bound |
+| 4164 | empty locally, otherwise [Media error response](#media-error-response) | A local range is unsatisfiable, or an external origin returned 416 |
+| 500 | [Media error response](#media-error-response) | An image asset transcode failed with no usable source, or a relay spool write failed |
+| 502 | [Media error response](#media-error-response) | An object store, external origin, or upload destination returned an unusable response |
+| 503 | [Media error response](#media-error-response) | Upload spool capacity is exhausted, an external buffer reservation or allocation failed, or an external origin answered `/_metadata` with 4295 |
+| 504 | [Media error response](#media-error-response) | Transformation capacity was unavailable or a transformation deadline expired |
+
+1 Four [internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) answer 200 with a non-media body. `/_metadata` and `/_frames` answer with JSON, `/_health` with the plain text `OK`, and `/_metrics` with the Prometheus text exposition
+
+2 A path served by the read fallback answers with the body `Method Not Allowed`. Each registered path answers with an empty body, no `Content-Type`, and an `Allow` header
+
+3 A retained external origin status is the only source of these statuses
+
+4 A locally unsatisfiable range answers with an empty body, `Content-Range: bytes */{size}`, and `Accept-Ranges: bytes`, and has no `Content-Type` and no cache policy
+
+5 The remap produces the body `Service Unavailable`, which is indistinguishable from a budget exhaustion 503
+
+An external origin status of 400, 401, 403, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415, 416, 428, or 429 reaches the client unchanged, with the body `Upstream fetch failed`. The origin response body and headers are not forwarded. Any other unsuccessful origin status becomes 502.
+
+The Media Proxy evaluates no conditional request header and never redirects a noncanonical target, so it returns no 304 and no 308.
+
+:::note[A retained status describes the external URL]
+The third-party origin chose it, and Fluxer passed the status through.
+:::
+
+## Request and media limits
+
+Proxied or stored media is limited to 500 MiB, and exceeding that returns 413. The bound applies to a streamed object, a buffered object, an external response body, and any input selected for transformation. When a streamed external body passes the bound only after the response head is committed, the Media Proxy truncates it.
+
+A decoded signed external target URL is limited to 8,192 bytes, and the route follows at most five redirects. A longer URL returns 400. A sixth redirect returns 502, and so does a redirect back to an already visited URL. A redirect target is subject to the same bound and the same address policy as the original URL. Content detection inspects the leading 8,192 bytes of a body.
+
+Buffered external bodies share one endpoint budget of 500 MiB for every [work admission](#work-admission) slot plus 512 KiB. A body the budget cannot cover returns 503, and so does a failed buffer allocation.
+
+Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixels in total. Animated input is limited to 20,000 frames and 1,073,741,824 decoded pixels across all frames. No configuration changes these bounds. [Transformations](/media-proxy/transformations/#transformation-limits) defines the resulting failure statuses.
+
+The upload relay limits a body to the smaller of the capability's declared maximum and the endpoint's configured body limit, which defaults to the same 500 MiB ceiling and can be configured from 1 byte through 5 GiB. A request that declares no `Content-Length` is spooled to disk first, and spooled bodies share an 8 GiB endpoint budget by default.
+
+An internal `/_metadata`, `/_thumbnail`, or `/_frames` request body is limited to the base64 expansion of the 500 MiB media bound plus 1 MiB. All three answer a larger body with 413 and the body `Payload Too Large`.
+
+## Work admission
+
+A transformation first takes an admission slot without waiting. The pool holds one slot for every concurrent native transform plus one for every queued transform. Native transform concurrency defaults to the process parallelism clamped to 2 through 8 and can be configured from 1 through 128. The queue defaults to eight times that concurrency and can be configured from 1 through 8192. When no slot is free the request returns 504 immediately.
+
+Once admitted, the transformation waits for a native transform permit until the transformation deadline. A wait that outlives the deadline also returns 504.
+
+The image branch of `/_thumbnail` follows the same admission and deadline rules. Its video branch takes no admission slot and no permit at all.
+
+A successful transformation stays in the memory cache for 120,000 ms by default, within a 256 MiB total budget and a 64 MiB per-entry budget1. External content-type hints are cached for the same interval across at most 4,096 targets.
+
+1 The per-entry budget is clamped to the total budget, and setting the interval, the total budget, or the per-entry budget to zero disables the transform cache
+
+## Deadlines
+
+| Deadline | Default | Configurable range |
+| --- | --- | --- |
+| Outbound socket connect and total timeout1 | 30,000 ms | 0 through 300,000 ms |
+| Transformation | 15,000 ms | 1,000 through 120,000 ms |
+| Upload relay object storage write2 | 900,000 ms | 1,000 through 3,600,000 ms |
+
+1 One value sets both the connect timeout and the total request timeout, and it applies to external origin fetches, to object storage requests, and to the response body streamed back to the client
+
+2 A relay write with a declared `Content-Length` extends this deadline by one second for every 16 KiB of declared length
+
+The same socket timeout bounds every streamed response body. A streamed stored object and a streamed signed external response terminate when the gap between two body chunks exceeds that timeout. The whole transfer has a second deadline of that timeout plus one second for every 16 KiB of expected length, which is a floor of 16 KiB per second. A body that ends before, or runs past, the advertised `Content-Length` also terminates with an error.
+
+Animated encoding stops adding frames 3,000 ms before the transformation deadline, so the encoder has time to flush what it already holds. The request then succeeds with a shorter animation. The separate 30,000 ms animation bound caps the playback length of the encoded animation, and [Transformations](/media-proxy/transformations/#transformation-limits) defines it.
+
+:::caution[A deadline after the head truncates the body]
+A status and its headers are chosen before the body is sent. A streamed object store or external response that fails afterwards terminates the body, so the observed body can be shorter than the advertised `Content-Length`.
+:::
+
+## Cache policies
+
+Every successful media representation uses `Cache-Control: public, max-age=31536000` and `CDN-Cache-Control: public, max-age=31536000`. An audio or video representation appends `no-transform` to `Cache-Control` only. Signed external media uses the same policy as stored media.
+
+No route sets `immutable`, `Expires`, `ETag`, or `Last-Modified` on a read response, and no route selects a shorter cache policy. A cache revalidates a representation by fetching it again.
+
+Every route-produced error response uses `Cache-Control: no-store`, and a successful `/_metrics` read uses it too. A 416 response, a successful upload relay response, a media access allowlist rejection, and the empty-body method rejection of a registered path set no cache policy at all.
+
+## Range response headers
+
+A complete media response has `Accept-Ranges: bytes`, the representation `Content-Type`, and an exact `Content-Length`1. A 206 additionally sets `Content-Range` to the complete size and `Content-Length` to the selected byte count. A 416 has `Content-Range: bytes */{size}` and `Accept-Ranges: bytes` with an empty body.
+
+1 A streamed signed external response omits `Content-Length` when the origin declared none
+
+`HEAD` applies the same range contract as `GET` and returns 206 or 416 with an empty body. A forwarded external range is answered with the origin `Content-Range` and `Content-Length` unchanged. [Byte ranges](/media-proxy/overview/#byte-ranges) defines which request ranges are recognised.
diff --git a/fluxer_docs/src/content/docs/media-proxy/routes.mdx b/fluxer_docs/src/content/docs/media-proxy/routes.mdx
new file mode 100644
index 000000000..bf6febafb
--- /dev/null
+++ b/fluxer_docs/src/content/docs/media-proxy/routes.mdx
@@ -0,0 +1,501 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Read and asset routes
+description: Every Media Proxy read path with its inputs, representations, and statuses.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+A Media Proxy read route resolves one path to one file and returns it. These paths are unversioned and sit under the base URL published as `endpoints.media` by [instance discovery](/http-api/instance/#instance-endpoints-object). The [static object read](#get-static-object) uses `endpoints.static_cdn`.
+
+## Shared route contract
+
+Every route here accepts `GET` and `HEAD`, reads no `Authorization` header, and has no request-count rate limit. HEAD returns the GET status and representation headers with an empty body, including 206 and 416.
+
+[Selector parsing](/media-proxy/overview/#selector-parsing) defines query decoding. [Byte ranges](/media-proxy/overview/#byte-ranges) defines which ranges are recognised and which headers a 206 or 416 has.
+
+## Common responses
+
+Every media read route can produce these statuses in addition to the ones in its own table.
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 403 | `origin not in bunny allowlist` | The request address is not permitted by the instance media access policy |
+| 404 | `Not Found` | The path is not a media route or the object does not exist |
+| 405 | `Method Not Allowed` | The method is neither `GET` nor `HEAD` |
+| 413 | `Payload Too Large` | The stored object exceeds the 500 MiB media bound |
+| 502 | `Bad Gateway` | The object store could not be read |
+
+## Get attachment
+
+
+
+Returns the attachment identified by the path, optionally transformed. The path comes from the attachment URL the HTTP API returned.
+
+:::caution[An attachment URL is a bearer capability]
+The path has no signature and no expiry, so anyone holding the URL reads the object from any origin for as long as it exists. Leaving the guild does not revoke a URL a member already holds.
+:::
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| path1 | string | The attachment object path from a [message attachment object](/http-api/messages/#message-attachment-object) |
+
+1 Fluxer issues `{channel_id}/{attachment_id}/{filename}`. After percent-decoding, the storage key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| width? | integer | The output width in pixels (1 through the [decoded edge bound](/media-proxy/transformations/#allowed-dimensions), any other value returns 400) |
+| height? | integer | The output height in pixels under the same rule |
+| format? | string | The output format under the [attachment and external format contract](/media-proxy/transformations/#attachment-and-external-formats) (any other value returns 400) |
+| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality) |
+| animated? | boolean | Whether animated output is requested |
+| effort?1 | integer | The WebP encoder effort, clamped to 9 |
+| download? | boolean | Whether attachment disposition is requested |
+
+1 Read only on this route, and an empty or unparsable value is ignored. Only lossless animated WebP output uses a value above 6
+
+A transformation begins when `width`, `height`, `format`, or `quality` is present, or when `animated` resolves to true. `download` and `effort` select none on their own.
+
+Without a transformation Fluxer streams the object from the store and forwards the range to it. With a transformation it reads the complete object into memory first, and the range then applies to the transformed bytes.
+
+An SVG attachment with a transformation parameter is rasterised, and `format` and `quality` select the output as they do for any other source, defaulting to WebP. Without a transformation parameter, only an `mp` endpoint rasterises it, always as lossless WebP. An `upload` endpoint returns the stored SVG bytes.
+
+A video source requires an explicit image `format`. Another transformation parameter without `format` returns 400. A source that is neither an image nor a video returns 400 when `format` is present. Without `format`, Fluxer returns it unchanged.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete media representation | The object and selected representation are available |
+| 206 | Selected media bytes | One range is satisfiable |
+| 400 | `Bad Request` | The decoded key, a dimension, the format, or the transformation request is invalid, or the transformation failed |
+| 404 | `Not Found` | The attachment object does not exist |
+| 416 | empty | The range is unsatisfiable |
+| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
+
+The [common responses](#common-responses) also apply.
+
+## Get signed external media
+
+
+
+Returns external media through Fluxer, optionally transformed. Fluxer verifies the signature before it decodes or fetches the target.
+
+Fluxer constructs the signed path and exposes it on `proxy_url` of an [embed media object](/http-api/messages/#embed-media-object), `proxy_icon_url` of an [embed author object](/http-api/messages/#embed-author-object), `proxy_icon_url` of an [embed footer object](/http-api/messages/#embed-footer-object), `proxy_url` of a [resolved GIF entry](/http-api/memes/#resolved-gif-entry-object), and `proxy_src` of a [GIF media format object](/http-api/memes/#gif-media-format-object).
+
+A field holds a signed path only when its source URL is external. A URL already under the Media Proxy endpoint, and any URL the deployment cannot sign, is returned verbatim, so a client MUST request the value exactly as issued and MUST NOT assume it has the signed shape.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| signature | string | The unpadded base64url HMAC-SHA256 of the target component under the deployment secret |
+| target1 | string | The encoded target component, which can contain `/` separators |
+
+1 The opaque form is `v2/` followed by the unpadded base64url of the complete URL. The segmented form joins an optional percent-encoded `?query` segment, the scheme, the host and port, and the percent-encoded path segments with `/`
+
+A client MUST preserve both components exactly as issued and MUST NOT decode or reconstruct either one. The signature covers the target component alone, so adding or changing a query parameter does not invalidate it. A path with no `/` after `/external/` returns 400.
+
+:::caution[The signed path is the only authorisation]
+Anyone holding a signed path fetches that external target through Fluxer for as long as the deployment secret is unchanged. The path has no expiry, user binding, or individual revocation.
+:::
+
+### Target validation
+
+Fluxer validates the decoded target before every request and again for every redirect hop. It must be at most 8,192 bytes, must use `http` or `https`, must have a non-zero port when it names one, and must not contain credentials or a control character. A fragment is dropped before the fetch.
+
+The target must resolve to a public address. A hostname must be at most 253 bytes and must contain at least one dot. Its labels use only ASCII letters, digits, and `-`, run to at most 63 bytes, and begin and end alphanumerically. The final label is not digits only. Fluxer rejects a literal address in an unspecified, loopback, private, link-local, carrier-grade NAT, benchmarking, documentation, 6to4 relay, multicast, reserved, broadcast, unique-local, IPv4-mapped, 6to4, or NAT64-embedded range. Fluxer validates every address DNS returns the same way before it opens the connection.
+
+A hostname whose lookup fails or resolves to no address returns 400.
+
+The route follows at most five redirects. A further redirect, a repeated URL, or a redirect without a `Location` header returns 502. A redirect to a rejected target returns 400.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| width? | integer | The output width in pixels (1 through the [decoded edge bound](/media-proxy/transformations/#allowed-dimensions), any other value returns 400) |
+| height? | integer | The output height in pixels under the same rule |
+| format? | string | The output format under the [attachment and external format contract](/media-proxy/transformations/#attachment-and-external-formats) (any other value returns 400) |
+| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality) |
+| animated? | boolean | Whether animated output is requested |
+| download? | boolean | Whether attachment disposition is requested |
+
+`effort` is read only on [Get attachment](#get-attachment) and is ignored here.
+
+A transformation begins when `width`, `height`, `format`, or `quality` is present, when `animated` resolves to true, when the target filename ends in `.svg`, or when the origin body is SVG by media type or by its first bytes. A transforming request forwards no `Range` to the origin and applies the client range to the transformed bytes, so it still selects 206 or 416.
+
+A non-transforming request forwards the client range to the origin under the filter [byte ranges](/media-proxy/overview/#byte-ranges) defines, and relays an origin 206 with its `Content-Range` and `Content-Length` unchanged. An origin that ignores the range answers 200, and Fluxer streams that whole body through when the origin names an `image/`, `video/`, or `audio/` media type other than `image/svg+xml`. Fluxer buffers a transforming request, an SVG body, and any body whose declared media type is empty, `application/octet-stream`, or outside those three categories. A buffered response has the range applied to the bytes the route finally serves, so a client range over a buffered origin 200 produces 206.
+
+Buffering reserves the whole body from a process-wide external buffer budget of 500 MiB for each configured transform slot and queue slot, plus 512 KiB. A reservation the budget cannot cover returns 503.
+
+Fluxer answers a `HEAD` with no transformation and no range from an origin `HEAD` when that origin returns 200, declares a `Content-Length` of at most 500 MiB, and names a non-SVG media type. Any other `HEAD` runs the `GET` path and returns its headers with an empty body.
+
+A video target requires an explicit image `format` to produce a thumbnail. Without one, Fluxer returns the original bytes unchanged, and it does the same for a target that is neither an image nor a video.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete external representation | The validated origin returned a complete representation |
+| 2061 | Selected external bytes | The origin answered a forwarded range, or a range over buffered or transformed bytes is satisfiable |
+| 400 | `Bad Request` | The path shape, decoded URL, redirect target, transformation request, or transformation is invalid or prohibited |
+| 401 | `Unauthorized` | The path signature is invalid |
+| 413 | `Payload Too Large` | The origin declared or delivered more than 500 MiB |
+| 416 | empty | A local range is unsatisfiable |
+| 502 | `Bad Gateway` | The origin could not be reached, exceeded the redirect bound, or returned a status outside the retained set |
+| 503 | `Service Unavailable` | The external buffer budget cannot cover the body, or the buffer allocation failed |
+| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
+
+1 A forwarded range relays the origin `Content-Range` and `Content-Length` unchanged and omits either header the origin did not send, so a chunked origin 206 produces a 206 with no `Content-Length`. A local range over buffered or transformed bytes always has both
+
+An origin status of 400, 401, 403, 404, 405, 406, 408, 409, 410, 411, 412, 413, 414, 415, 416, 428, or 429 reaches the client unchanged, with the body `Upstream fetch failed`. The origin body and headers are not returned. Any other unsuccessful origin status becomes 502. The [common responses](#common-responses) also apply.
+
+### Side effects
+
+The route requests the target from the validated third-party origin using the user agent `Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)`, which is visible to that origin.
+
+## Get theme CSS
+
+
+
+Returns a theme stylesheet with the fixed media type `text/css; charset=utf-8`. The [themes resource](/http-api/themes/) writes the object to `themes/{theme_id}.css`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| path1 | string | The path below `/themes/`, whose raw route path must end in the exact lowercase suffix `.css` |
+
+1 After percent-decoding, the storage key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
+
+The route selects no representation and accepts a `Range`.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete CSS representation | The theme object is available |
+| 206 | Selected CSS bytes | One range is satisfiable |
+| 400 | `Bad Request` | The decoded theme key is invalid |
+| 416 | empty | The range is unsatisfiable |
+
+The [common responses](#common-responses) also apply. A path that does not end in `.css` is not a theme path and falls through to 404.
+
+## Get entrance sound
+
+
+
+Returns the stored audio of a user entrance sound. The [entrance sounds resource](/http-api/entrance-sounds/) writes the object to `entrance-sounds/{user_id}/{hash}.{ext}`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id1 | string | The owning user [snowflake](/snowflakes/) |
+| filename2 | string | `{hash}.{ext}` for the stored sound |
+
+1 A non-empty run of ASCII digits. Any other value makes the path unroutable and returns 404
+
+2 The hash is non-empty and ASCII alphanumeric, and Fluxer issues the first 16 hexadecimal characters of the MD5 digest of the audio. The extension is the exact lowercase `mp3`, `ogg`, `m4a`, or `wav`
+
+The route selects no representation, accepts a `Range`, and sends no `Content-Disposition`. A successful response streams the stored bytes with the detected audio media type and the one-year `no-transform` cache policy.
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete audio representation | The sound object is available |
+| 206 | Selected audio bytes | One range is satisfiable |
+| 416 | empty | The range is unsatisfiable |
+
+The [common responses](#common-responses) also apply.
+
+## Image asset contract
+
+An image asset is a stored picture Fluxer serves at a requested size and format, such as an avatar, a guild icon, or an emoji. A resource object exposes the asset hash, and the path combines the owning resource, that hash, and a file extension. An emoji or sticker path has its own identifier as the filename. The grammar, query parameters, and responses below govern every image asset route. [Asset size selection](/media-proxy/transformations/#asset-size-selection) defines every size class.
+
+A client builds the complete URL from the Media Proxy [base URL](/media-proxy/overview/#base-urls), the path template for the asset class, the hash, and a file extension.
+
+:::note[Some resources publish their URL in the response]
+An attachment has its own `url` and `proxy_url`, a signed external media path has its own signature, and endpoint discovery publishes the instance branding images as complete URLs.
+:::
+
+Fluxer matches an asset path by its shape alone. A path with the wrong number of segments, an empty owner segment, or a filename without exactly one dot is not an asset path and returns 404. A hash containing anything but ASCII letters, digits, and `_` returns 404 as well, as does an extension outside the known image set. The known set is `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, `avif`, `heic`, `heif`, `jxl`, and `svg`, matched case-insensitively.
+
+Fluxer issues an eight-character lowercase hexadecimal hash, taken from the MD5 digest of the submitted image before metadata stripping. An owner segment is any non-empty segment, and Fluxer never checks it against a snowflake.
+
+The storage key drops an `a_` prefix from the hash, except on an emoji or sticker path, whose key keeps the filename stem exactly as given. A path whose key is missing is also tried under the canonical name of its extension, so both `.jpg` and `.JPG` resolve an object stored as `.jpeg`.
+
+An owner segment of the literal `.` or `..` parses as an asset path but produces an unsafe storage key and returns 400. So does a hash of the literal `a_` on a path that strips the prefix.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| size?1 | integer | The requested square edge in pixels, snapped to the [size ladder](/media-proxy/transformations/#allowed-dimensions) and clamped to the asset class |
+| format?2 | string | The requested output format, also accepted under the name `fmt` |
+| quality? | string | The encoder profile under the [quality contract](/media-proxy/transformations/#quality), defaulting to `high` |
+| animated?3 | boolean | Whether animated output is requested |
+| download? | boolean | Whether attachment disposition is requested |
+
+1 An absent or unparsable value selects 128 before clamping. No value is rejected
+
+2 Accepts case-insensitive `auto`, `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, and `avif`, where `auto` keeps path-based selection and `avif` selects WebP. Any other value falls back to path-based selection, and a sticker path always selects WebP and ignores the field
+
+3 An absent value keeps the hash-derived default, which is animated only for an `a_` hash. PNG and JPEG output has no animation
+
+`width`, `height`, and `effort` are not read on an asset path, and no asset query value produces a 400. The route accepts a `Range` over the selected representation.
+
+
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete asset representation | The original or transformed asset is available |
+| 206 | Selected asset bytes | One range is satisfiable |
+| 400 | `Bad Request` | An owner segment of `.` or `..`, or a hash of `a_` on a prefix-stripping path, makes the storage key unsafe |
+| 404 | `Not Found` | The path is not a valid asset path or the asset does not exist |
+| 416 | empty | The range is unsatisfiable |
+| 5001 | `Transcode Failed` | A required transcode failed and the source is not directly displayable |
+| 504 | `Gateway Timeout` | Transformation capacity was unavailable or the transformation exceeded its deadline |
+
+1 A failed transcode whose stored media type begins with `image/` and is not `image/avif`, `image/heic`, `image/heif`, or SVG instead returns 200 with the original stored bytes and media type but without `Content-Disposition`
+
+The [common responses](#common-responses) also apply. [Transformations](/media-proxy/transformations/) defines geometry, animation, output formats, and original representation selection.
+
+## Get user avatar
+
+
+
+Returns the user avatar under the icon size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| user_id | string | The owning user [snowflake](/snowflakes/) paired with the avatar hash |
+| hash | string | The avatar hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get guild icon
+
+
+
+Returns the guild icon under the icon size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the icon hash |
+| hash | string | The icon hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get instance branding
+
+
+
+Returns the instance branding image written by [Create branding asset](/admin-api/instance/#create-branding-asset), under the icon size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| entity_id | string | The instance branding entity key supplied by the branding URL |
+| hash | string | The branding hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get owner banner
+
+
+
+Returns the owner banner under the banner size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| owner_id | string | The owning user or guild [snowflake](/snowflakes/) paired with the banner hash |
+| hash | string | The banner hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get guild splash
+
+
+
+Returns the guild splash under the banner size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the splash hash |
+| hash | string | The splash hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get guild embed splash
+
+
+
+Returns the guild embed splash under the banner size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | The owning guild [snowflake](/snowflakes/) paired with the embed splash hash |
+| hash | string | The embed splash hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get guild member avatar
+
+
+
+Returns the guild member avatar under the icon size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | The guild [snowflake](/snowflakes/) owning the member asset |
+| user_id | string | The member user [snowflake](/snowflakes/) paired with the member avatar hash |
+| hash | string | The member avatar hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get guild member banner
+
+
+
+Returns the guild member banner under the banner size class.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| guild_id | string | The guild [snowflake](/snowflakes/) owning the member asset |
+| user_id | string | The member user [snowflake](/snowflakes/) paired with the member banner hash |
+| hash | string | The member banner hash, optionally prefixed with `a_` to default to animated output |
+| ext | string | The path extension, which selects the output format |
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get emoji image
+
+
+
+Returns the emoji image under the emoji size class with a square cover crop.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| emoji_id1 | string | The emoji [snowflake](/snowflakes/), which is also the storage key |
+| ext2 | string | The path extension, which selects the output format |
+
+1 Any non-empty run of ASCII letters, digits, and `_`. Fluxer clients request `{emoji_id}.webp`
+
+2 An extension whose encoder is not enabled for output selects WebP instead
+
+Fluxer issues emoji paths without an `a_` prefix, so an emoji request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key, so `/emojis/a_123.webp` names a different object than `/emojis/123.webp`.
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get sticker image
+
+
+
+Returns the sticker image under the sticker size class with a square cover crop. Output is always WebP, except that an animated GIF source stays GIF when `animated` resolves to true.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| sticker_id1 | string | The sticker [snowflake](/snowflakes/), which is also the storage key |
+| ext | string | The path extension, used only as the source hint |
+
+1 Any non-empty run of ASCII letters, digits, and `_`. Fluxer clients request `{sticker_id}.webp`
+
+Fluxer issues sticker paths without an `a_` prefix, so a sticker request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key.
+
+### Response
+
+The operation uses the shared [image asset response](#image-asset-response).
+
+## Get static object
+
+
+
+Returns a raw object from the static bucket. The route exists only on the `static` deployment mode, published as `endpoints.static_cdn` by [instance discovery](/http-api/instance/#instance-endpoints-object). It serves default avatars, client bundles, and other published files.
+
+The complete request path is the storage key after percent-decoding. The route applies no transformation and reads no query parameter. The response uses the detected media type and the one-year cache policy, and a `static` endpoint omits `X-Robots-Tag`.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key1 | string | The object key in the static bucket, taken from the complete request path |
+
+1 After percent-decoding, the key must be non-empty, must not begin with `/`, and must not contain an empty, `.`, or `..` segment, or the route returns 400
+
+### Response
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | Complete object | The object is available |
+| 206 | Selected bytes | One range is satisfiable |
+| 400 | `Bad Request` | The decoded key is invalid |
+| 416 | empty | The range is unsatisfiable |
+
+The [common responses](#common-responses) also apply.
+
+## Operator and internal endpoints
+
+These paths are not part of the public API surface. They are exempt from the media access allowlist and are not published by instance discovery. A client MUST NOT depend on them.
+
+| Path | Method | Description |
+| --- | --- | --- |
+| `/_health` | GET | Returns 200 with the body `OK` |
+| `/_metrics` | GET | Returns the Prometheus text exposition of the process. A request from a non-loopback address returns 403 with the body `FORBIDDEN` |
+| `/_metadata` | POST | Extracts media metadata, a placeholder, and an optional NSFW verdict for the HTTP API |
+| `/_thumbnail` | POST | Produces a WebP thumbnail of a staged upload for the HTTP API |
+| `/_frames` | POST | Extracts one JPEG video frame for the HTTP API |
+
+`/_metadata`, `/_thumbnail`, and `/_frames` require `Authorization: Bearer {deployment secret key}` and return 401 with the body `Unauthorized` without it. Their JSON request bodies are bounded, and a body beyond the bound returns 413 with the body `Payload Too Large` on all three.
diff --git a/fluxer_docs/src/content/docs/media-proxy/transformations.md b/fluxer_docs/src/content/docs/media-proxy/transformations.md
new file mode 100644
index 000000000..00dcdd405
--- /dev/null
+++ b/fluxer_docs/src/content/docs/media-proxy/transformations.md
@@ -0,0 +1,175 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Image and video transformations
+description: Transformation parameters, geometry, formats, quality, animation, and limits.
+---
+
+The Media Proxy resizes and converts images, preserves or removes animation, and extracts a still image from a video. The [attachment](/media-proxy/routes/#get-attachment) and [signed external media](/media-proxy/routes/#get-signed-external-media) routes share one parameter set, and the [image asset contract](/media-proxy/routes/#image-asset-contract) has a smaller one of its own.
+
+## When a transformation runs
+
+The theme, entrance sound, and static object routes read no parameter and never transform.
+
+An attachment or signed external request transforms when `width`, `height`, `format`, or `quality` is present, or when `animated` resolves to true. `download` and `effort` select no transformation.
+
+A signed external request also transforms when the target filename ends in `.svg`, and when the origin body is SVG by media type or by its first bytes, whatever the filename. An SVG origin body is transformed unless the origin answered a forwarded range with a 206 under a non-SVG media type, which the proxy relays as raw bytes.
+
+The Media Proxy still rasterises an SVG attachment that selects no transformation. That path always uses lossless WebP and runs only in [`mp` mode](/media-proxy/overview/#deployment-modes).
+
+An image asset request always runs through the transformation engine. It returns the original bytes only when the resolved format already matches the source and the resolved size is at least the source dimensions.
+
+The Media Proxy canonicalises nothing and issues no redirect, so two spellings of one selection are two separate HTTP cache entries.
+
+## Representation parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| width?1 | integer | The output width for an attachment or signed external request |
+| height?1 | integer | The output height for an attachment or signed external request |
+| size?2 | integer | The requested square edge for an image asset request |
+| format?3 | string | The requested output format under the rules for the route |
+| quality? | string | The requested [quality profile](#quality) |
+| animated?4 | boolean | Whether animated output is requested |
+| effort?5 | integer | The WebP encoder effort for an attachment request |
+| download? | boolean | Whether the response uses attachment disposition |
+
+1 An empty, unparsable, zero, or over-bound value returns 400. Any integer from 1 through 16,384 is accepted
+
+2 No value is rejected. An absent or unparsable value selects 128, a parsable value snaps to the ladder, and the result is then clamped by [asset size selection](#asset-size-selection)
+
+3 An image asset request accepts the same value under the name `fmt`, and reads `fmt` only when `format` is absent
+
+4 Overrides the route default in both directions
+
+5 An empty or unparsable value is ignored. A value from 10 through 255 is clamped to 9, and a value above 255 does not parse, so it too is ignored
+
+A name that occurs more than once resolves to its final value, and an unknown name is ignored. `animated` and `download` are true only for case-insensitive `true` or the exact value `1`. Every other value, including `false` and `0`, is false.
+
+## Allowed dimensions
+
+`width` and `height` are free integers from 1 through 16,384. Any other value returns 400, and neither is clamped into range. The decoded source is bounded separately by [transformation limits](#transformation-limits).
+
+`size` selects from a fixed ladder: 16, 20, 22, 24, 28, 32, 40, 44, 48, 56, 60, 64, 80, 96, 100, 128, 160, 240, 256, 300, 320, 480, 512, 600, 640, 1024, 1280, 1536, 2048, 3072, 4096, 8192, and 16,384. A requested value snaps up to the first rung that is at least as large, so 641 selects 1024. A request above 16,384 selects 16,384, and the class clamp in [asset size selection](#asset-size-selection) can reduce the result further.
+
+## Geometry
+
+Transformations never enlarge an image.
+
+`width` alone scales proportionally to the requested width, and `height` alone scales proportionally to the requested height. A fit inside a rectangle uses the smaller of the two ratios, so a dimension requested larger than the source still shrinks when the other requested dimension is smaller than the source.
+
+A cover crop scales a still image to cover the requested rectangle and crops it centrally. An attachment or signed external request asks for one by supplying both `width` and `height`. An emoji or sticker asset always uses one. Every other image asset fits inside the selected square and preserves its full aspect ratio.
+
+:::note[An animated transformation fits the whole frame]
+The Media Proxy downgrades a cover crop to a plain fit whenever it opens the decoder for every page. An animated emoji or sticker is fitted inside its square.
+:::
+
+## Asset size selection
+
+An asset request snaps `size` to the ladder and then clamps the result into the range of its asset class. The resolved value is applied to both dimensions, and the [geometry rules](#geometry) then decide whether the image is cropped or fitted inside that square.
+
+| Asset class | Routes | Range |
+| --- | --- | --- |
+| Icon | avatars, icons, branding, guild member avatars | 128 through 1024 |
+| Banner1 | banners, splashes, embed splashes, guild member banners | 480 through 2400 |
+| Emoji | emojis | 32 through 512 |
+| Sticker | stickers | 128 through 512 |
+
+1 The banner maximum of 2400 is not a ladder rung and is reachable only by clamping, so 3072 and every larger request collapse onto it
+
+An absent `size` resolves to 128 before clamping, so an avatar defaults to 128 and a banner defaults to 480. An emoji and a sticker also default to 128. Every ladder value below a class minimum collapses onto that minimum, and every value above a class maximum collapses onto that maximum.
+
+## Attachment and external formats
+
+Format matching is case-insensitive. A value outside this table returns 400, and `auto` is not a value here.
+
+| Value | Output format | Media type |
+| --- | --- | --- |
+| `png`1 | PNG | `image/png` |
+| `jpg`, `jpeg` | JPEG | `image/jpeg` |
+| `webp`2 | WebP | `image/webp` |
+| `gif` | GIF | `image/gif` |
+| `apng` | APNG | `image/apng` |
+| `avif`, `heic`, `heif`, `jxl`, `svg` | WebP | `image/webp` |
+
+1 An APNG source requesting PNG output with `animated=true` keeps APNG and `image/apng`
+
+2 A GIF source requesting WebP output with `animated=true` keeps GIF and `image/gif`
+
+AVIF, HEIC, HEIF, JXL, and SVG decode but have no output encoder, so they are returned as WebP. SVG is rasterised, and a transforming route never returns SVG bytes.
+
+When `format` is omitted, an attachment uses the filename extension in its path. Signed external media uses the origin media type first and then the target filename. If neither identifies an image format, the output is WebP.
+
+## Image asset formats
+
+An image asset path can use PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, or SVG. A path extension without an output encoder selects WebP.
+
+The `format` query parameter, also accepted as `fmt`, overrides the path extension. It recognises case-insensitive `auto`, `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, and `avif`. `auto` leaves the path extension to select the output, and `avif` selects WebP. Any other value, including `heic`, `heif`, `jxl`, `svg`, and an unknown name, falls back to path-based selection.
+
+A sticker always selects WebP and ignores `format`. An animated GIF sticker stays GIF when `animated` resolves to true.
+
+## Quality
+
+Quality names are matched exactly and are case-sensitive. An unrecognised value selects `high`.
+
+| Value | Description |
+| --- | --- |
+| `low`1 | Encodes at quality 65 with WebP encoder effort 2 |
+| `auto`2 | Resolves to `lossless` or `high` |
+| `high` | Encodes at quality 85 |
+| `lossless`3 | Selects lossless WebP encoding and JPEG quality 100 |
+
+1 PNG, APNG, and GIF have fixed encoder settings and ignore `quality`, so the quality number reaches WebP and JPEG output only
+
+2 Resolves to `lossless` only for animated WebP output whose source sniffs as GIF or APNG, is at most 4,194,304 bytes, and decodes to at most 16,777,216 pixels across all frames. It resolves to `high` otherwise, and an animated palette WebP that fails those tests uses WebP encoder effort 0 unless an attachment request supplies `effort`
+
+3 Lossless applies to WebP alone, and JPEG at quality 100 is still a lossy encode
+
+An image asset defaults to `high`. An attachment or signed external image defaults to `lossless`, except that a JPEG, HEIC, or HEIF source defaults to `high`. Animated WebP output defaults to `auto` on every route that reads `quality`. A video thumbnail is extracted at `high`, and `quality` then applies only to the resize step that `width` or `height` requests. A non-transforming SVG rasterisation always uses `lossless`.
+
+Encoder effort defaults to 2 for animated output or `low` quality and 4 otherwise, and it applies to WebP output only. JPEG and PNG have fixed encoder settings, and GIF always encodes at effort 7. The attachment-only `effort` parameter replaces the default and is clamped to 9. Still WebP output clamps it again to 6, and so does lossy animated WebP. Only lossless animated WebP uses 7 through 9.
+
+## Animation
+
+An owner-and-hash asset whose hash begins with `a_` requests animated output by default, and a bare hash requests static output by default. The prefix is stripped from the storage key, so both spellings read the same stored object.
+
+The `animated` parameter overrides that default in both directions. It is read whenever the name is present and is true only for case-insensitive `true` or the exact value `1`, so `animated=false` forces static output even for an `a_` hash. Omitting the name keeps the route default.
+
+Fluxer issues emoji and sticker paths without an `a_` prefix, so those requests default to static output and need `animated=true` for animation. An attachment or signed external request also defaults to static output.
+
+When encoding occurs, animation survives only when `animated` resolves to true and the selected output is WebP, GIF, or APNG. The Media Proxy rejects the original bytes when the stored bytes sniff as animated and the request resolves to static, so that request is re-encoded to a single frame. Only a read that selects no transformation returns the source animation without `animated=true`.
+
+:::caution[PNG and JPEG output stacks the frames]
+PNG and JPEG have no animation, but `animated=true` still opens every page of the source. The encoder receives the frames stacked vertically and writes one tall image.
+:::
+
+An animated GIF request for GIF output is resized in its original container, and is returned untouched when the requested size would not change it. Requesting a cover crop routes it through the image pipeline, which still emits GIF.
+
+## Video thumbnails
+
+Fluxer extracts one thumbnail from a video only when the request supplies an explicit image `format`. The video itself is never transcoded. The extractor scans at most 512 packets to find a frame and encodes it as JPEG, PNG, WebP, GIF, or APNG. A `format` value with no output encoder has already been coerced to WebP, so no other target reaches the extractor. `width` and `height` fit the thumbnail inside the requested rectangle without cropping.
+
+An attachment video request with another transformation parameter but no `format` returns 400. A signed external video request without `format` returns the original bytes instead.
+
+An attachment source that is neither an image nor a video returns 400 when `format` is present and is otherwise returned unchanged. A signed external source that is neither an image nor a video is always returned unchanged.
+
+## Original representations
+
+The Media Proxy returns the original bytes when the source already has the selected format, no resize or crop is required, and no encoder option requires a new representation. A source whose bytes sniff as animated also requires a request that resolves to animated, and a static request against it is encoded.
+
+An `effort` value forces encoding, and a `quality` value forces encoding for every source except GIF. An animated attachment or signed external request for the source's own GIF, WebP, or APNG format with neither `width` nor `height` bypasses both tests and can still reuse the original animation.
+
+The Media Proxy derives the response `Content-Type` from the content when the stored media type is empty, is case-insensitively `application/octet-stream`, or is outside the set `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/apng`, `image/avif`, `image/heic`, `image/heif`, `image/jxl`, and `image/svg+xml`. An original response can therefore use a different media type from the stored metadata. A stored media type from that set is trusted even when it disagrees with the bytes and is served unchanged.
+
+## Transformation limits
+
+Proxied or stored media is limited to 500 MiB. A stored object above that bound returns 413, and so does an external origin that declares or delivers more. The same bound applies to a transforming request and to every buffer it produces.
+
+Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixels in total. Animated input is additionally limited to 20,000 decoded frames and 1,073,741,824 decoded pixels across all frames. These four bounds are fixed. Exceeding a decoded image or animation limit fails the transformation.
+
+Animated WebP and animated APNG output is bounded again at encode time, and exceeding one of those bounds truncates the output. The encoder stops adding frames after 20,000 frames, or once the accumulated frame delays reach 30,000 ms of playback, and emits the frames it already has. An operator can configure the frame cap from 1 through 100,000 and the playback cap from 100 through 600,000 ms. Animated GIF output has neither cap on either of its paths, so the decode limits above are its only bound.
+
+The overall transformation deadline defaults to 15,000 ms and can be configured from 1,000 through 120,000 ms. An animated WebP or APNG encode stops 3,000 ms before that deadline so it has time to flush what it has already encoded. Animated GIF output has no such headroom, and a GIF resize that reaches the deadline fails.
+
+An attachment or signed external transformation failure returns 400 with the body `Bad Request`. An image asset transformation failure returns 500 with the body `Transcode Failed` when the source is not directly displayable, and otherwise returns 200 with the original stored bytes and no `Content-Disposition`.
+
+A transformation refused admission returns 504, and so does one that exceeds its deadline. [Responses and limits](/media-proxy/responses-and-limits/) defines the admission capacity and the deadlines.
diff --git a/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx b/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx
new file mode 100644
index 000000000..cf433949e
--- /dev/null
+++ b/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx
@@ -0,0 +1,167 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Upload relay
+description: Capability-authorised uploads and the relay streaming limits.
+---
+
+import RouteHeader from '@/components/RouteHeader.astro';
+
+The upload relay is where a client sends the bytes of a file. Each upload URL has a signed capability, and the relay stores the one object it authorises. [Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) and [Create stream preview upload URL](/http-api/streams/#create-stream-preview-upload-url) issue them. [Attachment uploads](/topics/uploads/) states the flow the relay takes part in.
+
+## Base URL and authorisation
+
+Both paths here are relative to the relay base in the issued upload URL. That base is a deployment setting.
+
+The capability is the complete authorisation. The relay reads no HTTP API `Authorization` header and has no request-count rate limit. The `PUT` has a [deployment mode](/media-proxy/overview/#deployment-modes) gate and returns 404 outside `upload` mode. Every path below `/v1/relay/` is exempt from the [media access policy](/media-proxy/overview/#access-restrictions).
+
+:::note[Some upload URLs address object storage directly]
+The HTTP API decides per request whether to relay, from the caller's country. A caller on the instance's direct-upload list receives a presigned object storage URL, and this contract does not describe it.
+:::
+
+:::caution[The complete URL is authenticated]
+The path key and the `t` capability are bound together by the signature. A client MUST use the URL exactly as issued and MUST NOT rebuild, decode, reorder, or re-encode it. The URL is a bearer capability.
+:::
+
+## Relay capability object
+
+A relay capability is a JSON payload signed with HMAC-SHA256 under the deployment relay secret. The signed value is the encoded payload text, and the capability is that text, a `.`, and the signature. Both halves use unpadded base64url.
+
+The payload is opaque to a client.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| b1 | string | The destination bucket |
+| k2 | string | The destination object key |
+| m3 | string | The authorised method, always `put` |
+| u?4 | string | The multipart upload identifier |
+| p?4 | integer | The multipart part number |
+| ct?5 | string | The media type stored with the object |
+| mb6 | integer | The maximum body length in bytes |
+| e7 | integer | The expiry as a Unix timestamp in seconds |
+
+1 The uploads bucket configured on the endpoint. Any other value returns 403
+
+2 Equal to the decoded request key. Any other value returns 403
+
+3 A payload naming another method returns 401
+
+4 Present together on a multipart part capability and absent together on a single-object capability. The relay checks the query string values against these fields and then forwards both to the object store
+
+5 Takes precedence over the request `Content-Type`. When neither declares one, an S3 backend stores the object as `application/octet-stream`
+
+6 The HTTP API sets it to the exact length of the object or part for an attachment, and to 1,000,000 for a stream preview
+
+7 The bound is exclusive, so the relay rejects the capability once the clock reaches it. The HTTP API issues a 900 second lifetime by default
+
+### Example
+
+```json
+{
+ "b": "fluxer-uploads",
+ "k": "a3f1c8de-4b21-4f0e-9c77-2d5b6e1a0f43",
+ "m": "put",
+ "ct": "image/png",
+ "mb": 184320,
+ "e": 1780000000
+}
+```
+
+The relay answers 401 to a malformed capability, a signature that is not 32 bytes, an incorrect signature, a payload that is not valid unpadded base64url, a payload that is not valid JSON, and an expired capability.
+
+## Put relay object
+
+
+
+Stores the exact object the [relay capability](#relay-capability-object) authorises and returns 200 with an empty body. An `ETag` accompanies it when the object store supplied one. An S3 backend supplies one and a local filesystem backend does not.
+
+### Path parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| key1 | string | The object key of the issued URL |
+
+1 The key can contain `/` separators and the issuer percent-encodes each segment. The relay compares the decoded key against the capability `k` field, and any other value returns 403
+
+Fluxer issues a UUID for an attachment and `stream_previews/{name}-{digest}.jpg` for a stream preview.
+
+### Query parameters
+
+| Field | Type | Description |
+| --- | --- | --- |
+| t1 | string | The capability, as the unpadded base64url payload, a `.`, and the unpadded base64url signature |
+| uploadId?2 | string | The multipart upload identifier, present only when the capability has one |
+| partNumber?3 | integer | The multipart part number, present only when the capability has one |
+
+1 A missing `t` returns 401
+
+2 When the capability has `u`, the relay rejects an absent or differing value with 403. When it has no `u`, the relay rejects a non-empty value with 403 and accepts an absent or empty one
+
+3 An empty or unparsable value returns 400. When the capability has `p`, the relay rejects an absent or differing value with 403, and when it has no `p`, the relay rejects any value with 403
+
+### Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Content-Length?1 | integer | The declared length of the request body |
+| Content-Type?2 | string | The media type to store, read only when the capability declares none |
+
+1 A declared length above the capability `mb` field or above the endpoint body limit returns 413
+
+2 The capability `ct` field takes precedence over it
+
+The relay ignores every other request header.
+
+### Request body
+
+The body is arbitrary bytes.
+
+With `Content-Length`, the relay streams the body to the object store as it arrives. A body longer than the declared length returns 413 and a body shorter than it returns 400.
+
+Without `Content-Length`, the relay spools the body to a temporary file first, bounded by the smaller of the capability `mb` field and the endpoint body limit. Exceeding that bound returns 413. A client transfer that fails part way returns 400. The relay returns 503 when the endpoint spool budget cannot cover the request.
+
+### Response
+
+The relay forwards no object storage response body and no arbitrary response headers.
+
+| Status | Body | Condition |
+| --- | --- | --- |
+| 200 | empty | The object or part was stored |
+| 400 | `Bad Request` | The `partNumber` value is unparsable or the client body ended early |
+| 401 | `Unauthorized` | The capability is missing, malformed, incorrectly signed, or expired |
+| 403 | `Forbidden` | The bucket, key, `uploadId`, or `partNumber` disagrees with the capability |
+| 404 | `Not Found` | The endpoint does not serve the upload relay |
+| 4051 | empty | The method is not `PUT` |
+| 413 | `Payload Too Large` | The declared or delivered body exceeds the capability `mb` field or the endpoint body limit |
+| 500 | `Internal Server Error` | A spool write to the relay's own disk failed |
+| 502 | `Bad Gateway` | The object store refused the write or could not be written to |
+| 503 | `Service Unavailable` | The endpoint spool budget cannot cover the request |
+
+1 It has no relay CORS headers and no cache policy
+
+:::caution[A message naming the key creates the attachment]
+A successful relay response stores bytes only. [Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) are the requests that can name the key.
+:::
+
+[Complete attachment upload](/http-api/messages/#complete-attachment-upload) assembles a multipart upload once every part is stored, and a singlepart upload needs no completion step. A stream preview becomes readable as soon as the stored object is in place, and the [Streams resource](/http-api/streams/) defines that flow.
+
+The relay has no replay tracking. Repeating a request with the same capability writes the object again, and the last successful write wins. Retrying a multipart part after a 502 or 503 is safe, because a part is addressed by its number and the repeated write replaces it.
+
+### Response headers
+
+The 200, 400, 401, 403, 413, 500, 502, and 503 have the relay CORS headers. The 404 and the 405 have none of them.
+
+Relay CORS means `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Content-Type, Content-Length, Authorization, X-Fluxer-Features, X-Client-Context`, and `Access-Control-Expose-Headers: ETag, X-Fluxer-Version`.
+
+### Side effects
+
+A successful request writes the object the capability selects to the uploads bucket. It creates no attachment record and no message.
+
+## Streaming limits
+
+The endpoint body limit defaults to 500 MiB and an operator can configure it from 1 byte through 5 GiB. The effective limit for one request is the smaller of that value and the capability `mb` field.
+
+A spooled body is written through a 1 MiB buffer by default, configurable from 64 KiB through 64 MiB. Spooled bodies share an 8 GiB endpoint budget by default, configurable up to 256 GiB. A request that arrives with no `Content-Length` reserves the complete effective limit from that budget before the relay reads a byte. The endpoint refuses to start when its body limit exceeds that budget, so one reservation always fits an idle endpoint.
+
+The object storage write has a 900,000 ms deadline by default, configurable from 1,000 through 3,600,000 ms. For a streamed body the relay extends the deadline by one second for every 16 KiB of declared length.
diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx
new file mode 100644
index 000000000..6850d1b24
--- /dev/null
+++ b/fluxer_docs/src/content/docs/operator/configuration.mdx
@@ -0,0 +1,1974 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Configuration
+description: Every variable a self-hosted instance reads, grouped by concern.
+---
+
+:::tip[Plutonium and donations fund Fluxer]
+We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
+:::
+
+Fluxer reads its settings from environment variables. They live in a file named `.env`, in the same directory as `docker-compose.yml`. This page names every variable that file can hold.
+
+A first run touches two sections. [Core identity and public address](#core-identity-and-public-address) has `FLUXER_DOMAIN`, the hostname people type into a browser. [Secrets you must generate](#secrets-you-must-generate) has the fourteen values that ship as `CHANGE_ME`. Everything after those two is optional and already has a working value, so read it when you want to change something.
+
+The installer in [Get started](/operator/get-started/) writes `.env` for you and fills in all fourteen secrets. [Upgrading](/operator/upgrading/) covers moving between releases.
+
+## How configuration is loaded
+
+Compose reads `.env` and passes the values it names into containers. A name reaches a container only when `docker-compose.yml` lists it, either in the shared `x-fluxer-env` block or in that service's own `environment` block. No service declares `env_file`, so a name in `.env` that appears in neither block never arrives, whatever it is set to.
+
+A container's environment is fixed when the container is created, and `api` and `worker` cache their configuration at first load. Either way a change needs the process restarted, which `docker compose up -d` does by recreating the service.
+
+Precedence, highest first:
+
+1. A value stored by the admin dashboard, for the settings listed under [Runtime settings](#runtime-settings-in-the-admin-dashboard).
+2. The environment variable.
+3. The built-in default.
+
+Three runtimes read the environment, each with its own parser, so the same name can have a different default in different services.
+
+#### Node named overrides
+
+Read by `api` and `worker`. A fixed table of 278 `FLUXER_*` names. A name outside it is invisible.
+
+#### Rust direct reads
+
+Read by `media-proxy`, `app-proxy`, `admin`, and the five internal services. Each crate reads its own names. Blank usually counts as unset.
+
+#### Erlang direct reads
+
+Read by `gateway`. Reads the OS environment. An unknown boolean falls back to the default. A bad integer fails startup.
+
+Parsing rules:
+
+- Node treats an empty string as set. `FLUXER_EMAIL_FROM_NAME=` overrides the `Fluxer` default with an empty name.
+- Node booleans accept only `true` and `false`, in any letter case. `FLUXER_POSTGRES_SSL=1` parses as the number `1` and fails startup with `FLUXER_POSTGRES_SSL must be true or false`.
+- Rust has three boolean parsers. `app-proxy` and `admin` treat anything other than `1`, `true`, `yes`, or `on` as false. `media-proxy` and the internal services reject an unrecognised value with a startup error.
+- A value that starts with `{` or `[` is parsed as JSON. A failed parse fails startup with a message naming the variable.
+- A name read as an integer fails startup on any other value, in Node and in the Gateway. An empty value takes the default.
+- Comma separated lists are split on `,`, trimmed, and stripped of empty entries.
+
+## Core identity and public address
+
+`FLUXER_DOMAIN` is required. Everything else here is optional.
+
+| Variable | Value in `.env.example` | Controls |
+| --- | --- | --- |
+| FLUXER_DOMAIN | `chat.example.com` | The hostname users type. Reaches services as `FLUXER_BASE_DOMAIN`. Compose refuses to start when it is unset or empty |
+| FLUXER_PUBLIC_SCHEME | `https` | The scheme users see. Must be `http` or `https`. Anything else fails startup |
+| FLUXER_PUBLIC_PORT | `443` | The port users see. Integer. Omitted from derived URLs when it is the default for the scheme |
+
+Outside Compose these fall back to an empty base domain, `http`, and port `8088`. Compose supplies `https` and `443`.
+
+`FLUXER_DOMAIN` also feeds the default passkey relying party identifier, the default VAPID contact address, and the edge listener address.
+
+#### `FLUXER_INTERNAL_SCHEME`
+
+Default `http`. The scheme for internal service URLs. Must be `http` or `https`, and anything else fails startup. Nothing outside the config loader reads it.
+
+## Secrets you must generate
+
+Every value below ships as `CHANGE_ME`. Replace all fourteen before the first start. Compose refuses to start when one is unset or empty, and names the variable. The installer in [Get started](/operator/get-started/) generates all fourteen, so come here to rotate one value later or to set them up by hand.
+
+#### `POSTGRES_PASSWORD`
+
+Generate with `openssl rand -hex 32`. The database login, reused as `FLUXER_POSTGRES_PASSWORD`. Requires changing the stored role password too.
+
+#### `MEILI_MASTER_KEY`
+
+Generate with `openssl rand -hex 32`. The Meilisearch master key, reused as `FLUXER_SEARCH_API_KEY`. Recreate `meilisearch` and every service that searches.
+
+#### `FLUXER_S3_SECRET_KEY`
+
+Generate with `openssl rand -hex 32`. The object-storage secret, reused as `FLUXER_S3_SECRET_ACCESS_KEY`. `seaweedfs-init` installs it as the object store's only S3 identity on every `compose up`, so a changed value takes effect on the next start.
+
+#### `FLUXER_SUDO_MODE_SECRET`
+
+Generate with `openssl rand -hex 32`. Sudo mode JWTs, as a raw HS256 key. Invalidates every elevated session.
+
+#### `FLUXER_CONNECTION_INITIATION_SECRET`
+
+Generate with `openssl rand -hex 32`. Connection initiation tokens and harvest download links. Invalidates in-flight authorisations and issued download links.
+
+#### `FLUXER_GATEWAY_RPC_AUTH_TOKEN`
+
+Generate with `openssl rand -hex 32`. Internal RPC between the API and the Gateway. Must be byte-identical on `api`, `worker`, and `gateway`.
+
+#### `FLUXER_MEDIA_PROXY_SECRET_KEY`
+
+Generate with `openssl rand -hex 32`. Media Proxy URLs. Must match across `api`, `worker`, `media-proxy`, `gifs`, and `unfurl`.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64`
+
+Generate with `openssl rand -base64 32`. [Upload relay](/media-proxy/upload-relay/) capability tokens. Must be standard base64 decoding to at least 32 bytes. Hex fails. Must match on `api`, `worker`, and `media-proxy`. The API and the Media Proxy both check it at boot and refuse to start without it in `upload` mode.
+
+#### `FLUXER_ADMIN_SECRET_KEY_BASE`
+
+Generate with `openssl rand -hex 32`. Admin sessions, CSRF tokens, and OAuth state. Signs every admin out. The admin service refuses to start when it is empty.
+
+#### `FLUXER_ADMIN_OAUTH_CLIENT_SECRET`
+
+Generate with `openssl rand -hex 32`. The admin OAuth2 client secret. The API requires a non-empty value to serve the admin application.
+
+#### `FLUXER_ERLANG_COOKIE`
+
+Generate with `openssl rand -hex 32`. The BEAM distribution secret. Only the Gateway reads it. Rotate it on every Gateway node at once when clustering.
+
+#### `LIVEKIT_API_SECRET`
+
+Generate with `openssl rand -hex 32`. LiveKit access tokens. Must be at least 32 characters.
+
+#### `FLUXER_VAPID_PUBLIC_KEY`
+
+Base64url of the 65-byte uncompressed P-256 point, unpadded, which is 87 characters. Config validation rejects any other shape at boot. `install.sh` derives it with `openssl ecparam` and `openssl ec`, and `npx web-push generate-vapid-keys` produces the same pair.
+
+#### `FLUXER_VAPID_PRIVATE_KEY`
+
+The matching half of the pair. Base64url of the 32-byte P-256 scalar, unpadded, which is 43 characters. The API refuses to start when the scalar does not derive the public point. Both values are required even when nobody uses browser notifications.
+
+Two more values ship with a usable value. Both are required.
+
+#### `FLUXER_S3_ACCESS_KEY`
+
+`.env.example` `fluxer`. The object-storage access key. Reaches services as `FLUXER_S3_ACCESS_KEY_ID`, which config validation requires to be non-empty.
+
+#### `LIVEKIT_API_KEY`
+
+`.env.example` `fluxer`. The LiveKit API key. Compose passes it to LiveKit as `LIVEKIT_KEYS` and as the webhook signing key, and to the API as `FLUXER_LIVEKIT_API_KEY`, so one change in `.env` moves all three.
+
+## Endpoint derivation from FLUXER_BASE_DOMAIN
+
+Fluxer builds eleven public endpoints. Ten of them come from the scheme, the base domain, and the port, and the docs endpoint is a fixed value. A port of `443` under `https` or `wss`, or `80` under `http` or `ws`, is omitted.
+
+| Endpoint | Derived value |
+| --- | --- |
+| api, api_client | scheme, base domain, optional port, `/api` |
+| app | scheme, base domain, optional port |
+| gateway | `ws` or `wss`, base domain, optional port, `/gateway` |
+| media | scheme, base domain, optional port, `/media` |
+| static_cdn | scheme, base domain, optional port. With `FLUXER_STATIC_CDN_DOMAIN` set it becomes `https://` and that domain, with no port |
+| admin | scheme, base domain, optional port, `/admin` |
+| docs | Always `https://fluxer.dev`. Not built from the domain settings |
+| marketing | scheme, base domain, optional port, `/marketing` |
+| invite | scheme, `FLUXER_INVITE_DOMAIN` or the base domain, optional port, `/invite` |
+| gift | scheme, `FLUXER_GIFT_DOMAIN` or the base domain, optional port, `/gift` |
+
+Nothing in the stack reads `X-Forwarded-Proto` or `X-Forwarded-Host`. Every absolute URL comes from configuration, so set the scheme, the host, and the port by hand and keep them in sync with whatever terminates TLS.
+
+## The public origin
+
+`FLUXER_PUBLIC_ORIGIN` is the public origin browsers use, with no trailing slash. Only `docker-compose.yml` reads it, and it substitutes the value into fourteen values that need a full origin, including `FLUXER_MEDIA_ENDPOINT`, `FLUXER_MARKETING_ENDPOINT`, `FLUXER_ADMIN_ENDPOINT`, `FLUXER_ADMIN_OAUTH_REDIRECT_URI`, `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT`, `PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT`, and the Gateway's media and static endpoints.
+
+It ships commented out. When it is unset, Compose falls back to `FLUXER_PUBLIC_SCHEME` and `FLUXER_DOMAIN` with no port, which is correct for the usual https-on-443 case.
+
+Serving the instance on any other port means setting `FLUXER_PUBLIC_ORIGIN` by hand, with that port in it, because the fallback has no port and the media and admin links are built from it.
+
+## Endpoint overrides
+
+Each of these replaces its derived endpoint wholesale. Set one only when part of the instance answers at an address the derivation does not produce. All are optional.
+
+#### `FLUXER_STATIC_CDN_DOMAIN`
+
+Default empty. A separate host for static assets. When set, the static endpoint is forced to `https` with no port.
+
+#### `FLUXER_INVITE_DOMAIN`
+
+Default empty. The host used in invite links. A hostname with no scheme and no path.
+
+#### `FLUXER_GIFT_DOMAIN`
+
+Default empty. The host used in gift links. A hostname with no scheme and no path.
+
+#### `FLUXER_API_ENDPOINT`
+
+Defaults to the derived endpoint. The public API base. The `admin` service reads the same name as its internal API target, which is why Compose sets it to `http://api:8080` for that container only.
+
+#### `FLUXER_API_CLIENT_ENDPOINT`
+
+Defaults to the derived endpoint. The API base handed to clients. Setting only one of the two API endpoints splits what clients see from what the instance advertises.
+
+#### `FLUXER_APP_ENDPOINT`
+
+Defaults to the derived endpoint. The web app origin. Also one of the two allowed CORS origins. Serving the client from another hostname requires setting this.
+
+#### `FLUXER_GATEWAY_ENDPOINT`
+
+Defaults to the derived endpoint. The public Gateway WebSocket URL. Also the source of the internal Gateway URL when `FLUXER_INTERNAL_GATEWAY_ENDPOINT` is unset, with `ws` rewritten to `http`.
+
+#### `FLUXER_MEDIA_ENDPOINT`
+
+Defaults to the derived endpoint. The public Media Proxy URL. The `gifs` service accepts it as a fallback for `FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT`.
+
+#### `FLUXER_STATIC_CDN_ENDPOINT`
+
+Defaults to the derived endpoint. The static asset origin. Read by `api`, `worker`, `admin`, `app-proxy`, and `unfurl`.
+
+#### `FLUXER_ADMIN_ENDPOINT`
+
+Defaults to the derived endpoint. The admin origin. The API accepts exactly this value plus `/oauth2_callback` as the admin OAuth redirect URI.
+
+The admin dashboard sets its cookie flags from the scheme of this value, which Compose builds from `FLUXER_PUBLIC_SCHEME`. Over HTTPS its CSRF cookie is named `__Host-csrf_token` and has `Secure`. Over HTTP it is named `csrf_token` without `Secure`.
+
+#### `FLUXER_DOCS_ENDPOINT`
+
+Defaults to `https://fluxer.dev`. The docs origin. Never handed to a client.
+
+#### `FLUXER_MARKETING_ENDPOINT`
+
+Defaults to the derived endpoint. The marketing origin. The second allowed CORS origin. Its hostname is extracted and matched.
+
+#### `FLUXER_INVITE_ENDPOINT`
+
+Defaults to the derived endpoint. The invite base. Hostname is extracted and matched.
+
+#### `FLUXER_GIFT_ENDPOINT`
+
+Defaults to the derived endpoint. The gift base. Hostname is extracted and matched.
+
+Internal endpoints address one container from another and never appear in a browser. `FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT` is required by `gifs`. The rest are optional.
+
+#### `FLUXER_INTERNAL_API_ENDPOINT`
+
+Default `http://127.0.0.1:8080`. The API address used by `worker` and `gateway`. Compose sets `http://api:8080`.
+
+#### `FLUXER_INTERNAL_GATEWAY_ENDPOINT`
+
+No default. The Gateway address the API calls. Falls back to the public Gateway URL with the scheme rewritten. Compose sets `http://gateway:8080`.
+
+#### `FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT`
+
+Default `http://127.0.0.1:8082`. The internal Media Proxy address for `api` and `worker`. `FLUXER_MEDIA_PROXY_ENDPOINT` is read as an alias when this name is unset.
+
+#### `FLUXER_MEDIA_PROXY_ENDPOINT`
+
+No default. The internal Media Proxy address. `unfurl-shard` reads this name alone and exits without it. On `api` and `worker` it is an alias of `FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT`, read only when that name is unset.
+
+#### `FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT`
+
+No default. The public Media Proxy URL, read by `gifs`, `unfurl`, and `media-proxy` itself. `gifs` exits at boot without this or `FLUXER_MEDIA_ENDPOINT`. `api` and `worker` never read it. `media-proxy` trims trailing slashes from the value at load and locally resolves a media URL that matches it on scheme, host, port, and path prefix. An `/attachments/` path, a `/themes/` stylesheet, an entrance sound, and an image asset are read from the `FLUXER_S3_BUCKET_CDN` bucket, or from `FLUXER_S3_BUCKET_STATIC` in `static` mode. A signed `/external/{signature}/{target}` path has its signature verified and is unwrapped to the third-party target, which is then fetched. With this unset, `media-proxy` fetches every such URL over HTTP.
+
+#### `FLUXER_UNFURL_STATIC_CDN_ENDPOINT`
+
+Falls back to `FLUXER_STATIC_CDN_ENDPOINT`. The static origin used by `unfurl`. Read only by `unfurl`.
+
+## The edge
+
+The `edge` container is the only HTTP entry point. Both layouts below work with the edge left alone.
+
+Bundled TLS is the default. `docker compose up -d` binds `80/tcp`, `443/tcp`, and `443/udp` and obtains its own certificate for `FLUXER_DOMAIN`. Point DNS at the host and set nothing else.
+
+Put your own reverse proxy in front by adding `docker-compose.proxy.yml`, a second Compose file that overrides parts of the first. Load it with `-f` twice, or set `COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` in `.env` once. The edge then serves plain HTTP on one port, and the proxy in front terminates TLS. [Behind your own reverse proxy](/operator/reverse-proxy/) owns the switch, the requirements, and the per-proxy configuration.
+
+All are optional.
+
+#### `FLUXER_EDGE_SITE_ADDRESS`
+
+Default `FLUXER_DOMAIN`. What the edge listens on. Honoured in the bundled layout only, because `docker-compose.proxy.yml` sets the literal `:8080` and discards any `.env` value. Several hostnames or a non-default TLS port therefore needs the bundled layout. It is independent of `FLUXER_PUBLIC_SCHEME` and `FLUXER_PUBLIC_PORT`, so keep the listener and the advertised origin in step by hand.
+
+#### `FLUXER_EDGE_TRUSTED_PROXIES`
+
+Default `private_ranges`. Which upstream hops the edge believes about `X-Forwarded-For`. The default is `192.168.0.0/16`, `172.16.0.0/12`, `10.0.0.0/8`, `127.0.0.1/8`, `fd00::/8` and `::1`, which covers every same-host proxy. Narrow it to the proxy's own address when the proxy reaches the instance from a public address, or when clients reach the proxy from a range the default already covers.
+
+#### `FLUXER_EDGE_BIND`
+
+Default `127.0.0.1:8080`. Where the plain-HTTP port binds. Read only under the overlay. `0.0.0.0:8080` must be firewalled to the proxy host, because a trusted peer's `X-Forwarded-For` is honoured.
+
+#### `FLUXER_HTTP_PORT`
+
+Default `80`. The host side of the edge's HTTP publish. The container still listens on `80` inside. It takes an optional bind address in front of the port, so `127.0.0.1:80` keeps the publish off every public interface. Not read under the overlay, which publishes `FLUXER_EDGE_BIND` instead.
+
+#### `FLUXER_HTTPS_PORT`
+
+Default `443`. The host side of the edge's HTTPS publish. It moves the TCP and the UDP publish together, because HTTP/3 needs both on the same port. Same optional bind address, and it is not read under the overlay either.
+
+#### `COMPOSE_FILE`
+
+No default. Which Compose files are loaded. Read by Docker Compose itself. Set it to select the overlay for every command.
+
+`FLUXER_CADDY_SITE_ADDRESS` is the old name for `FLUXER_EDGE_SITE_ADDRESS`. Compose reads it only when `FLUXER_EDGE_SITE_ADDRESS` is unset, so an older `.env` keeps the listener it already had and can be renamed at any time.
+
+## Client IP
+
+The edge resolves one client address and rewrites `X-Forwarded-For` to it on every upstream hop, so no service reads what a visitor sent. When the peer is outside `FLUXER_EDGE_TRUSTED_PROXIES`, the edge uses the peer address and discards the header. When the peer is inside it, the edge takes the rightmost header entry that is not itself trusted, so a proxy that appends still delivers the real caller. A client whose own address is inside the list resolves to the proxy, which is why [Trusted proxies](/operator/reverse-proxy/#trusted-proxies) tells a LAN or VPN deployment to narrow it.
+
+All are optional.
+
+#### `FLUXER_TRUST_CLIENT_IP_HEADER`
+
+Default `false`. Whether the client-IP header is trusted. Compose sets `true` for every service that merges the shared environment block, which `app-proxy` does not. The API has no peer-address fallback, so `false` on `api` makes every non-exempt request a 403.
+
+#### `FLUXER_CLIENT_IP_HEADER_NAME`
+
+Default `x-forwarded-for`. Which header has the client IP. Read by `api`, `worker`, `admin`, `app-proxy`, and `gateway`. It must match what the edge sets.
+
+#### `FLUXER_CLIENT_IP_HEADER`
+
+No default. Alias for `FLUXER_CLIENT_IP_HEADER_NAME`. Accepted only by `admin` and `app-proxy`.
+
+#### `CLIENT_IP_HEADER_NAME` and `CLIENT_IP_HEADER`
+
+No default. Legacy unprefixed aliases. Accepted only by `admin` and `app-proxy`, last in the preference chain.
+
+#### `FLUXER_API_IP_BAN_EXEMPT_IPS`
+
+Default empty. Addresses exempt from IP bans. Comma separated. Every entry must parse as an IP or the API fails at boot.
+
+The API rejects any request whose client-IP header is missing, empty, not a parsable address, or not trusted under `FLUXER_TRUST_CLIENT_IP_HEADER` with 403, except on `/_health`, `/webhooks/livekit`, `/test`, and the Bluesky client metadata and JWKS routes. The edge sets the header on every upstream hop, so this applies only to a layout that puts something directly in front of `api`. A proxy in front of the edge that never sets the header passes the check, and every request then looks as though it came from the proxy.
+
+## Images
+
+These three pick which container images Compose pulls. All are optional.
+
+| Variable | Value in `.env.example` |
+| --- | --- |
+| FLUXER_REGISTRY_OWNER | `fluxerapp` |
+| FLUXER_REGISTRY | `ghcr.io/${FLUXER_REGISTRY_OWNER}` |
+| FLUXER_IMAGE_TAG | `v1` |
+
+`FLUXER_REGISTRY_OWNER` is the owner segment of the image names and falls back to `fluxerapp`. `FLUXER_REGISTRY` is the registry images are pulled from and falls back to `ghcr.io/` followed by the owner. `FLUXER_IMAGE_TAG` is the tag every Fluxer image uses and falls back to `v1`.
+
+These affect only the eleven Fluxer images. `docker-compose.yml` pins the `caddy`, `postgres`, `valkey`, `nats`, `meilisearch`, `seaweedfs`, and `livekit` images, and `.env` cannot change them.
+
+## Database
+
+`FLUXER_POSTGRES_HOST`, `FLUXER_POSTGRES_DATABASE`, `FLUXER_POSTGRES_USERNAME`, and `FLUXER_POSTGRES_PASSWORD` are required in production. The rest are optional.
+
+#### `FLUXER_DATABASE_BACKEND`
+
+Default `postgres`. Which backend is used. Node accepts only `postgres` or `cassandra`. The internal services also accept `postgresql`, `pg`, `scylla`, and `scylladb`. Every service rejects any other value at startup.
+
+#### `FLUXER_POSTGRES_URL`
+
+Default empty. A full connection URL. When set, the discrete host, database, user, and password production checks are skipped.
+
+#### `FLUXER_POSTGRES_HOST`
+
+Default `127.0.0.1`. The database host. In production with `postgres` and no URL it must not be `127.0.0.1` or `localhost`.
+
+#### `FLUXER_POSTGRES_PORT`
+
+Default `5432`. The database port. Integer 1 to 65535.
+
+#### `FLUXER_POSTGRES_DATABASE`
+
+Default `fluxer`. The database name. Must be non-empty.
+
+#### `FLUXER_POSTGRES_USERNAME`
+
+Default `fluxer`. The role. Must be non-empty.
+
+#### `FLUXER_POSTGRES_PASSWORD`
+
+Default `fluxer`. The password. The literal `fluxer` is rejected in production. `CHANGE_ME` is not.
+
+#### `FLUXER_POSTGRES_SSL`
+
+Default `false`. TLS to the database. Must be `true` or `false`. In production it must be `true` unless `FLUXER_SELF_HOSTED=true`.
+
+#### `FLUXER_POSTGRES_SSL_CA`
+
+Default empty. A CA certificate in PEM form. Used only when SSL is on.
+
+#### `FLUXER_POSTGRES_MAX_CONNECTIONS`
+
+Default `20`. Pool size. Integer 1 to 1000, per process. The total is the sum across every container. Compose sets 25 for `api` and `worker` and 20 for the two database-backed shards.
+
+#### `FLUXER_POSTGRES_KV_TABLE`
+
+Default `fluxer_kv`. The key-value table name. Must match a safe Postgres identifier or startup fails.
+
+#### `FLUXER_POSTGRES_PREPARED_STATEMENTS`
+
+Default `true`. Named prepared statements. Must be `true` or `false`. Compose passes it in the shared block, so one value governs `api`, `worker` and the Rust services at once. Set it to `false` behind a transaction-pooling pooler such as PgBouncer, where a named statement outlives the session that declared it.
+
+Cassandra or Scylla is an alternative backend, selected with `FLUXER_DATABASE_BACKEND=cassandra`. The shipped stack does not use it and ships no Cassandra container. All are optional.
+
+#### `FLUXER_CASSANDRA_HOSTS`
+
+Default `127.0.0.1`. Contact points. Comma separated. The Rust services normalise each entry to host and port, with IPv6 bracketed.
+
+#### `FLUXER_CASSANDRA_PORT`
+
+Default `9042`. The port. Integer.
+
+#### `FLUXER_CASSANDRA_KEYSPACE`
+
+Default `fluxer`. The keyspace. Must exist already.
+
+#### `FLUXER_CASSANDRA_LOCAL_DC`
+
+Default `datacenter1`. The local datacentre. Read by `api` and `worker` only. The Rust services ignore it.
+
+#### `FLUXER_CASSANDRA_USERNAME`
+
+Default empty. The login. Empty means no authentication.
+
+#### `FLUXER_CASSANDRA_PASSWORD`
+
+Default empty. The password. Empty means no authentication.
+
+## Cache and key-value
+
+Fluxer uses Valkey, which speaks the Redis protocol, as its cache, its pub/sub bus, and its queue backend. All are optional.
+
+#### `FLUXER_KV_URL`
+
+Default `redis://localhost:6379/0`. The Redis-protocol key-value store, pub/sub bus and queue backend. The `admin` service defaults to empty instead. Compose sets `redis://valkey:6379/0` everywhere.
+
+#### `FLUXER_KV_MODE`
+
+Default `standalone`. Which client shape the API and the worker build. `standalone` or `cluster`. Compose leaves it unset.
+
+#### `FLUXER_KV_PROVIDER`
+
+Default `redis`, which is the only accepted value.
+
+#### `FLUXER_SVC_CACHE_TTL_MS`
+
+Default `30000`. Soft cache lifetime in the internal services. Milliseconds.
+
+#### `FLUXER_SVC_CACHE_HARD_TTL_MS`
+
+Default `600000`. Hard cache lifetime. Clamped to at least the soft TTL.
+
+#### `FLUXER_SVC_CACHE_MAX_ENTRIES`
+
+Default `100000`. Cache entry ceiling. Per process.
+
+#### `FLUXER_GIFS_SHARD_CACHE_MAX_BYTES`
+
+Default `536870912`. GIF cache size. Must be at least 16777216. Set it to 134217728 to keep the cache inside the `256mb` ceiling `FLUXER_GIFS_SHARD_MEMORY_LIMIT` gives `gifs-shard`.
+
+#### `FLUXER_IP_BAN_REFRESH_INTERVAL_MS`
+
+Default `300000`. How often the API refreshes its IP-ban cache. A non-finite value or one at or below zero disables the timer.
+
+Two sorted sets in the bundled Valkey have no expiry: `bulk_message_deletion_queue` and the account deletion queue. The worker rebuilds each of them from the users table whenever its state version is absent or older than a day, so a lost set costs one rebuild and up to a day of delay. `docker-compose.yml` still starts Valkey with `--appendonly yes`, `--appendfsync everysec`, a named volume and `noeviction`. An over-limit write then returns an error to the caller and drops no queued work.
+
+Distributed locks in the same store all have a TTL, so they expire on their own. [Volumes and buckets](#volumes-and-buckets) states what losing `valkey-data` costs.
+
+## Object storage
+
+Every uploaded file lands in an S3-compatible object store, which the stack provides with SeaweedFS. `FLUXER_S3_ACCESS_KEY_ID` and `FLUXER_S3_SECRET_ACCESS_KEY` are required. The rest are optional.
+
+#### `FLUXER_S3_ENDPOINT`
+
+Default `http://localhost:3900`. The S3 API address. `media-proxy` defaults to empty instead.
+
+#### `FLUXER_S3_PUBLIC_ENDPOINT`
+
+No default. The host substituted into presigned URLs. The name misleads. Presigned URLs are the only place it appears.
+
+#### `FLUXER_S3_FORCE_PATH_STYLE`
+
+Default `false`. Path-style addressing. `media-proxy` defaults to `true` instead. Compose sets `true`.
+
+#### `FLUXER_S3_REGION`
+
+Default `local`. The region string. `media-proxy` defaults to `us-east-1`. Compose sets `us-east-1`.
+
+#### `FLUXER_S3_ACCESS_KEY_ID`
+
+Default empty. The access key. Config validation requires it non-empty on `api` and `worker`. Optional for `media-proxy`.
+
+#### `FLUXER_S3_SECRET_ACCESS_KEY`
+
+Default empty. The secret key. Config validation requires it non-empty on `api` and `worker`. Optional for `media-proxy`.
+
+#### `FLUXER_S3_SESSION_TOKEN`
+
+Default empty. A temporary session token. Read by `media-proxy` only.
+
+#### `FLUXER_S3_BUCKET_CDN`
+
+Default `fluxer`. Processed assets. `media-proxy` defaults to `cdn`.
+
+#### `FLUXER_S3_BUCKET_UPLOADS`
+
+Default `fluxer-uploads`. Raw uploads. `media-proxy` defaults to `uploads`, `app-proxy` to `fluxer-uploads`.
+
+#### `FLUXER_S3_BUCKET_DOWNLOADS`
+
+Default `fluxer-downloads`. Desktop build artifacts. Read by `api` and `worker`.
+
+#### `FLUXER_S3_BUCKET_REPORTS`
+
+Default `fluxer-reports`. Abuse report evidence. Read by `api` and `worker`.
+
+#### `FLUXER_S3_BUCKET_HARVESTS`
+
+Default `fluxer-harvests`. Data archives. Read by `api` and `worker`.
+
+#### `FLUXER_S3_BUCKET_STATIC`
+
+Default `static`. Static assets. Read by `media-proxy` only in `static` mode, which the stack does not use. `api` and `worker` never read it, and `seaweedfs-init` does not create this bucket.
+
+A separate downloads provider is available. `FLUXER_S3_DOWNLOADS_ENDPOINT`, `FLUXER_S3_DOWNLOADS_PUBLIC_ENDPOINT`, `FLUXER_S3_DOWNLOADS_FORCE_PATH_STYLE`, `FLUXER_S3_DOWNLOADS_REGION`, `FLUXER_S3_DOWNLOADS_ACCESS_KEY_ID`, and `FLUXER_S3_DOWNLOADS_SECRET_ACCESS_KEY` take effect only when `FLUXER_S3_DOWNLOADS_ENDPOINT` is non-empty, and they then replace the primary configuration for the downloads bucket.
+
+The Media Proxy read path has four overrides of its own. All are optional.
+
+#### `FLUXER_S3_READ_ENDPOINT`
+
+Falls back to `FLUXER_S3_ENDPOINT`. The read-side S3 address. Must be http or https with a host, and have no credentials, query string, or fragment.
+
+#### `FLUXER_S3_READ_BUCKET`
+
+Falls back to `FLUXER_S3_BUCKET_CDN`. The read-side bucket. Read by `media-proxy` only.
+
+#### `FLUXER_S3_READ_BUCKET_STYLE`
+
+Defaults to `path` when path style is on, else `virtual`. How the bucket appears in the URL. `path`, `virtual`, or `root`. Anything else is a startup error.
+
+#### `FLUXER_S3_READ_SIGNED`
+
+Default `false`. Whether read requests are signed. Read by `media-proxy` only. Compose sets `true`, because `seaweedfs-init` installs an S3 identity and the store then refuses anonymous reads.
+
+Compose sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, and `AWS_EC2_METADATA_DISABLED` for the AWS SDKs. No Fluxer code reads them directly.
+
+## Search
+
+Message search runs against Meilisearch in the shipped stack. All are optional.
+
+#### `FLUXER_SEARCH_ENGINE`
+
+Default `elasticsearch`. Which engine is used. `elasticsearch` or `meilisearch`. Compose sets `meilisearch`.
+
+#### `FLUXER_SEARCH_URL`
+
+Default `http://127.0.0.1:9200`. The engine address. Compose sets `http://meilisearch:7700`.
+
+#### `FLUXER_SEARCH_API_KEY`
+
+Default empty. The API key. Meilisearch uses it as the key. Elasticsearch uses it as an API key that takes precedence over the username and password.
+
+#### `FLUXER_SEARCH_USERNAME`
+
+Default empty. Basic auth user. Elasticsearch only. Ignored under Meilisearch.
+
+#### `FLUXER_SEARCH_PASSWORD`
+
+Default empty. Basic auth password. Elasticsearch only.
+
+#### `FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED`
+
+Default `true`. Certificate verification. Elasticsearch only. Never passed to the Meilisearch client.
+
+## Message bus and internal services
+
+NATS is the message bus between Fluxer processes, and five small internal services sit behind it. All are optional.
+
+#### `FLUXER_NATS_URL`
+
+Default `nats://127.0.0.1:4222`. The core NATS address for `api`, `worker`, and `gateway`. The Gateway defaults to `nats://nats:4222` instead.
+
+#### `FLUXER_NATS_CORE_URL`
+
+Alias of `FLUXER_NATS_URL` on `api` and `worker`, read only when that name is unset. The core NATS address. Not read by the Gateway.
+
+#### `FLUXER_NATS_JETSTREAM_URL`
+
+Default `nats://127.0.0.1:4222`. The JetStream address. Read by `api` and `worker`.
+
+#### `FLUXER_NATS_AUTH_TOKEN`
+
+Default empty. NATS authentication. Read by `api`, `worker`, and `gateway`. The shipped NATS runs without authentication.
+
+#### `FLUXER_SVC_NATS_URL`
+
+Default `nats://127.0.0.1:4222`. The NATS address for the five internal services. A separate variable from `FLUXER_NATS_URL`.
+
+#### `FLUXER_GATEWAY_API_RPC_ENDPOINT`
+
+No default. Where the Gateway calls the API. Read by the Gateway.
+
+The five internal services read the same topology variables. All are optional.
+
+#### `FLUXER_SVC_NAME`
+
+Default `default`. The metrics prefix and the concurrency default. NATS subjects come from a hardcoded per-crate name. Compose sets it to the crate name on all ten containers.
+
+#### `FLUXER_SVC_MODE`
+
+Default `router`. Router or shard. Anything but the exact string `shard` is a router.
+
+#### `FLUXER_SVC_SHARD_COUNT`
+
+Default `1`. How many shards exist. Fixed at 1 in the shipped stack.
+
+#### `FLUXER_SVC_SHARD_ID`
+
+Derived from the numeric suffix of `POD_NAME`, else `0`. Which shard this process is. A shard ID at or above the shard count is a startup error.
+
+#### `FLUXER_SVC_LISTEN_HOST`
+
+Default `0.0.0.0`. The bind address. Serves health and metrics only.
+
+#### `FLUXER_SVC_PORT`
+
+Default `8090`. The health and metrics port. Not published.
+
+#### `FLUXER_SVC_MAX_CONCURRENT_REQUESTS`
+
+Defaults to 192 for messages, 320 for snowflakes, 64 otherwise. In-flight request ceiling. The built-in defaults key off `FLUXER_SVC_NAME`. Compose forwards this name to `users`, `users-shard`, `messages`, and `messages-shard` at a default of 20, which pairs with their 20-connection Postgres pools, and leaves the six other containers on the built-in defaults.
+
+#### `POD_NAME`
+
+No default. The shard ordinal source and node identity. Also read by the Gateway and by API RPC timing.
+
+The API tunes its NATS clients through fifteen further names, none of which are in the override table or in `.env.example`: `FLUXER_SNOWFLAKE_SERVICE_SUBJECT`, `FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME`, `FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE`, `FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK`, `FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS`, `FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS`, `FLUXER_USERS_SERVICE_SUBJECT`, `FLUXER_USERS_SERVICE_NATS_CLIENT_NAME`, `FLUXER_USERS_SERVICE_TIMEOUT_MS`, `FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES`, `FLUXER_GIF_SERVICE_SUBJECT`, `FLUXER_GIF_SERVICE_NATS_CLIENT_NAME`, `FLUXER_GIF_SERVICE_TIMEOUT_MS`, and `FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS`. Each falls back to its default when the value is not a positive number.
+
+## Voice and LiveKit
+
+LiveKit is the media server for voice and video. `FLUXER_LIVEKIT_API_KEY` and `FLUXER_LIVEKIT_API_SECRET` are required for voice. The rest are optional.
+
+#### `FLUXER_LIVEKIT_ENABLED`
+
+Default `false`. The master voice and video switch. The name misleads. It gates all voice. Compose sets `true`.
+
+#### `FLUXER_LIVEKIT_API_KEY`
+
+Default empty. The LiveKit API key. Compose fills it from `LIVEKIT_API_KEY`.
+
+#### `FLUXER_LIVEKIT_API_SECRET`
+
+Default empty. The LiveKit secret. Compose fills it from `LIVEKIT_API_SECRET`.
+
+#### `FLUXER_LIVEKIT_URL`
+
+Default empty. The client-facing LiveKit URL. When empty the API derives it from the public API origin as `wss://host/livekit`, or `ws://` under `http`, and keeps a non-default port. Set it only when LiveKit is served from another host. Compose forwards the `.env` value, empty by default.
+
+#### `FLUXER_LIVEKIT_INTERNAL_URL`
+
+Default empty. The server-side LiveKit control API. Compose sets `http://livekit:7880`.
+
+#### `FLUXER_LIVEKIT_WEBHOOK_URL`
+
+Default empty. Where LiveKit posts webhooks. The route is exempt from user auth and from both client-IP middlewares.
+
+#### `FLUXER_LIVEKIT_DEFAULT_REGION`
+
+No default. The default voice region. JSON with `id`, `name`, `emoji`, `latitude`, and `longitude`.
+
+#### `FLUXER_LIVEKIT_TCP_PORT`
+
+Default `7881`. The TCP media port. Read by Compose only. Compose publishes it on the host and passes it to LiveKit as `rtc.tcp_port`, so the port LiveKit advertises in ICE candidates is the one the host forwards.
+
+#### `FLUXER_LIVEKIT_UDP_PORT`
+
+Default `7882`. The UDP media port, published and passed to LiveKit as `rtc.udp_port` the same way.
+
+LiveKit media does not traverse the edge. Compose publishes both media ports directly, so both must stay open in the host firewall and be forwarded to the host when it sits behind NAT. Compose points LiveKit at the webhook target `http://api:8080/webhooks/livekit` and configures no TURN server. A client that cannot use UDP falls back to ICE-TCP on the TCP port.
+
+Moving a media port is one line in `.env` followed by `docker compose up -d livekit`. Compose puts the same value on the host side of the mapping, on the container side, and on the `rtc` port LiveKit advertises in the ICE candidates it hands to clients.
+
+Moving the key pair takes two steps. Set both names in `.env`, then run `docker compose up -d livekit api worker` so LiveKit restarts on the new key and the API rebuilds its webhook receivers and upserts the stored voice server row.
+
+The `Caddyfile` is a bind mount, so the edge reads the copy that sits on disk beside `docker-compose.yml`. Editing it takes `docker compose restart edge`, because `docker compose up -d` leaves a container alone when only a mounted file changed. An upgrade does that restart itself, which [What the script does](/operator/upgrading/#what-the-script-does) covers. A change to any LiveKit value in `.env` needs `docker compose up -d`, because `restart` reuses the existing container with its old environment.
+
+Voice reconciliation runs in `worker`. All are optional.
+
+#### `FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION`
+
+Default `true`. Whether the sweeper runs. Compose sets `true` explicitly.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS`
+
+Default `15000`. Sweep interval. Milliseconds.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS`
+
+Default `25`. Delay between rooms. Milliseconds.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS`
+
+Defaults to 180 or three intervals, whichever is larger. Sweep lock lifetime. Seconds.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS`
+
+Defaults to three intervals, at least 1. Cadence marker lifetime. Seconds.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS`
+
+Default `10000`. Grace before culling a Gateway-only participant. Milliseconds.
+
+#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS`
+
+Default `60000`. Grace before culling a LiveKit-only participant. Milliseconds.
+
+## Email
+
+Email is off by default, and two conditions turn it on. The switch must be on, and the provider must be `smtp` with a complete SMTP configuration, meaning `FLUXER_EMAIL_FROM_EMAIL`, `FLUXER_EMAIL_SMTP_HOST`, `FLUXER_EMAIL_SMTP_PORT`, `FLUXER_EMAIL_SMTP_USERNAME`, and `FLUXER_EMAIL_SMTP_PASSWORD` are all non-empty. All are optional.
+
+#### `FLUXER_EMAIL_ENABLED`
+
+`.env.example` `false`. The delivery switch. The admin dashboard value wins over this.
+
+#### `FLUXER_EMAIL_PROVIDER`
+
+`.env.example` `none`. The transport. `smtp` or `none`. Anything else fails startup.
+
+#### `FLUXER_EMAIL_FROM_EMAIL`
+
+`.env.example` `noreply@example.com`. The sender address. Compose falls back to `noreply@localhost`.
+
+#### `FLUXER_EMAIL_FROM_NAME`
+
+`.env.example` `Fluxer`. The sender name. An empty value sets an empty sender name.
+
+#### `FLUXER_EMAIL_APP_BASE_URL`
+
+`.env.example` empty. The base URL used in links. Must be http or https with no username, password, query, or fragment, or the API fails at boot. Falls back to the app endpoint.
+
+#### `FLUXER_EMAIL_SMTP_HOST`
+
+`.env.example` empty. The SMTP host. Setting any SMTP variable creates the whole SMTP block, which is absent by default.
+
+#### `FLUXER_EMAIL_SMTP_PORT`
+
+`.env.example` `587`. The SMTP port. Falls back to 587.
+
+#### `FLUXER_EMAIL_SMTP_USERNAME`
+
+`.env.example` empty. The SMTP login. Part of the completeness check.
+
+#### `FLUXER_EMAIL_SMTP_PASSWORD`
+
+`.env.example` empty. The SMTP password. Part of the completeness check.
+
+#### `FLUXER_EMAIL_SMTP_SECURE`
+
+`.env.example` `true`. Implicit TLS. Defaults to `true` whenever the SMTP block exists.
+
+The API reads `FLUXER_EMAIL_WEBHOOK_SECRET` for inbound delivery webhooks. Neither `.env.example` nor `docker-compose.yml` has it.
+
+The API registers `POST /webhooks/sweego` on every deployment. The route authenticates the Standard Webhooks header triple `webhook-id`, `webhook-timestamp`, and `webhook-signature` against that secret, and answers 404 `Email not enabled` while email is off. The route sits outside the client-IP exempt list that covers `/webhooks/livekit`, so give the provider a delivery URL that goes through the edge, `https://chat.example.com/api/webhooks/sweego`.
+
+Only `api` and `worker` build the mail service, so recreating those two is enough after a change.
+
+## CAPTCHA
+
+All are optional.
+
+| Variable | Value in `.env.example` | Controls |
+| --- | --- | --- |
+| FLUXER_CAPTCHA_ENABLED | `false` | The [CAPTCHA](/topics/captcha/) switch. Startup fails when it is `true` without a provider and that provider's two keys |
+| FLUXER_CAPTCHA_PROVIDER | `none` | The provider. `hcaptcha`, `turnstile`, or `none`. Anything else fails startup |
+
+`FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY`, `FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY`, `FLUXER_CAPTCHA_TURNSTILE_SITE_KEY`, and `FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY` ship empty in `.env.example` and the shipped Compose file forwards all four. Set the pair the selected provider needs. The admin dashboard's Runtime Integrations panel sets the same provider and keys, and a value stored there wins over the environment. An instance that configures CAPTCHA only there must leave `FLUXER_CAPTCHA_ENABLED` at `false`, because the boot check reads the environment alone.
+
+## Single sign-on and passkeys
+
+All are optional.
+
+#### `FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES`
+
+Default `false`. Whether the SSO provider URL may resolve to a private address. Off by default as SSRF protection. Turn it on only for split-horizon DNS or a LAN identity provider.
+
+#### `FLUXER_PASSKEY_RP_ID`
+
+Defaults to `FLUXER_BASE_DOMAIN`. The WebAuthn relying party identifier. Changing it invalidates every passkey already registered.
+
+#### `FLUXER_PASSKEY_RP_NAME`
+
+Default `Fluxer`. The relying party name browsers display. Does not follow `FLUXER_DOMAIN`.
+
+#### `FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS`
+
+Defaults to the origin of the public web app endpoint. The complete accepted origin set. Comma separated. Despite the name, a value must list every origin browsers use.
+
+:::caution[Changing the relying party identifier invalidates every passkey]
+A passkey is bound to the `FLUXER_PASSKEY_RP_ID` it was registered under. Members have to enrol again after a change, so pick the value before opening registration.
+:::
+
+Bluesky OAuth login is off by default and is configured through `FLUXER_AUTH_BLUESKY_ENABLED`, `FLUXER_AUTH_BLUESKY_CLIENT_NAME`, `FLUXER_AUTH_BLUESKY_CLIENT_URI`, `FLUXER_AUTH_BLUESKY_LOGO_URI`, `FLUXER_AUTH_BLUESKY_TOS_URI`, `FLUXER_AUTH_BLUESKY_POLICY_URI`, and `FLUXER_AUTH_BLUESKY_KEYS`. None reach a container in the shipped stack, and the effective state also requires at least one key. The terms and policy URIs have no default, and the client metadata omits them until they are set.
+
+## Web push
+
+`FLUXER_VAPID_PUBLIC_KEY` and `FLUXER_VAPID_PRIVATE_KEY` are required. `FLUXER_VAPID_EMAIL` is optional.
+
+| Variable | Value in `.env.example` | Controls |
+| --- | --- | --- |
+| FLUXER_VAPID_PUBLIC_KEY | `CHANGE_ME` | The VAPID public key. Base64url of the 65-byte uncompressed P-256 point |
+| FLUXER_VAPID_PRIVATE_KEY | `CHANGE_ME` | The VAPID private key. Base64url of the 32-byte scalar, and the matching half of the pair |
+| FLUXER_VAPID_EMAIL | unset | The VAPID contact address. Compose derives `admin@` followed by `FLUXER_DOMAIN` when it is unset |
+
+The Gateway reads the same three names. A malformed pair, or a private key that does not derive the public point, does not stop it. It records the fault in its log at startup and then drops every web push notification.
+
+`FLUXER_GATEWAY_PUSH_ENABLED` is read by the Gateway alone, defaults to `true`, and turns that check off when it is `false`.
+
+## Mobile push
+
+Both `api` and `gateway` read the APNs and FCM names. None appear in `.env.example` or in `docker-compose.yml`, so configuring mobile push means editing the Compose file. All are optional.
+
+#### `FLUXER_PUSH_APNS_ENABLED`
+
+Default `false`. The APNs switch. Must be set on both `api` and `gateway`.
+
+#### `FLUXER_PUSH_APNS_TEAM_ID`
+
+No default. The Apple team. Paired with the key ID.
+
+#### `FLUXER_PUSH_APNS_KEY_ID`
+
+No default. The signing key ID. Paired with the team ID.
+
+#### `FLUXER_PUSH_APNS_PRIVATE_KEY`
+
+No default. The signing key in PEM form. Set exactly one of this and the path.
+
+#### `FLUXER_PUSH_APNS_PRIVATE_KEY_PATH`
+
+No default. A path to the signing key. Must be readable inside the container.
+
+#### `FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT`
+
+Default `production`. Which APNs environment is used. `production` or `development`.
+
+#### `FLUXER_PUSH_APNS_APPS`
+
+Default `[]`. Per-app APNs configuration. JSON array. An entry with no `app_id` fails startup.
+
+#### `FLUXER_PUSH_FCM_ENABLED`
+
+Default `false`. The FCM switch. Must be set on both `api` and `gateway`.
+
+#### `FLUXER_PUSH_FCM_PROJECT_ID`
+
+No default. The Firebase project. Paired with the client email.
+
+#### `FLUXER_PUSH_FCM_CLIENT_EMAIL`
+
+No default. The service account address. Paired with the project.
+
+#### `FLUXER_PUSH_FCM_PRIVATE_KEY`
+
+No default. The service account key. Use one of the three key sources.
+
+#### `FLUXER_PUSH_FCM_PRIVATE_KEY_PATH`
+
+No default. A path to the key. Must be readable inside the container.
+
+#### `FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH`
+
+No default. A path to the whole service account JSON. Must be readable inside the container.
+
+#### `FLUXER_PUSH_FCM_TOKEN_URI`
+
+Default `https://oauth2.googleapis.com/token`. The OAuth token endpoint. Change only for a proxy or a test double.
+
+#### `FLUXER_PUSH_FCM_APPS`
+
+Default `[]`. Per-app FCM configuration. JSON array, under the same `app_id` rule as APNs.
+
+## Payments
+
+Stripe billing, which the shipped stack keeps off. All are optional.
+
+#### `FLUXER_STRIPE_ENABLED`
+
+Default `false`. The Stripe switch. Compose hardcodes `false`, so a self-hosted instance cannot enable it from `.env`.
+
+#### `FLUXER_STRIPE_SECRET_KEY`
+
+Default empty. The Stripe secret key. Not forwarded by the shipped Compose file.
+
+#### `FLUXER_STRIPE_WEBHOOK_SECRET`
+
+Default empty. The webhook signing secret. Not forwarded by the shipped Compose file.
+
+#### `FLUXER_STRIPE_PRICES`
+
+Default `{}`. Every price ID at once. JSON object. The individual price variables are declared after it and win.
+
+Twenty-four individual price variables also exist, one per product and currency: `FLUXER_STRIPE_PRICE_MONTHLY_` and `FLUXER_STRIPE_PRICE_YEARLY_` in USD, EUR, BRL, INR, PLN, and TRY, `FLUXER_STRIPE_PRICE_VISIONARY_` and `FLUXER_STRIPE_PRICE_GIFT_VISIONARY_` in USD and EUR, and `FLUXER_STRIPE_PRICE_GIFT_1_MONTH_` and `FLUXER_STRIPE_PRICE_GIFT_1_YEAR_` in the same six currencies.
+
+## Moderation and abuse
+
+All are optional.
+
+#### `FLUXER_NCMEC_ENABLED`
+
+Default `false`. NCMEC reporting. Compose hardcodes `false`.
+
+#### `FLUXER_NCMEC_BASE_URL`
+
+Default empty. The reporting endpoint. Required when reporting is on.
+
+#### `FLUXER_NCMEC_USERNAME`
+
+Default empty. The reporting login. Required when reporting is on.
+
+#### `FLUXER_NCMEC_PASSWORD`
+
+Default empty. The reporting password. Required when reporting is on.
+
+#### `FLUXER_NCMEC_REPORTER_EMAIL`
+
+Default empty. The contact address on reports. Required when reporting is on.
+
+#### `FLUXER_CLAMAV_ENABLED`
+
+Default `false`. Upload virus scanning. Compose hardcodes `false`, and no ClamAV container ships.
+
+#### `FLUXER_CLAMAV_HOST`
+
+Default `127.0.0.1`. The scanner host. Needs a reachable scanner.
+
+#### `FLUXER_CLAMAV_PORT`
+
+Default `3310`. The scanner port. Integer.
+
+#### `FLUXER_CLAMAV_FAIL_OPEN`
+
+Default `false`. Behaviour when the scanner is unreachable. With scanning on and this off, an unreachable scanner rejects every upload.
+
+#### `FLUXER_API_CONTENT_MODERATION_NSFW_THRESHOLD`
+
+Default `0.7`. The API-side NSFW score cutoff. No range check. Distinct from the Media Proxy threshold, which defaults to `0.85`.
+
+#### `FLUXER_RISK_INTEGRATION_ENABLED`
+
+Default `false`. IP intelligence. Needs an ipinfo key to do anything.
+
+#### `FLUXER_RISK_IPINFO_API_KEY`
+
+Default empty. The ipinfo key. Paired with `FLUXER_RISK_INTEGRATION_ENABLED`.
+
+#### `FLUXER_ACCOUNT_POLICY_DSL`
+
+No default. The account risk policy. JSON. Malformed JSON fails startup, and a well-formed policy with unknown keys surfaces at use.
+
+#### `FLUXER_RISK_TOR_BLOCK_ALL_RELAYS`
+
+Default `false`. Whether every Tor relay is blocked. Covers entry and middle relays as well as exit nodes.
+
+#### `FLUXER_RISK_TOR_REVERSE_DNS_HEURISTIC`
+
+Default `false`. Reverse DNS Tor detection. Adds a lookup to the request path.
+
+#### `FLUXER_RISK_TOR_REVERSE_DNS_TIMEOUT_MS`
+
+Default `750`. The lookup timeout. Milliseconds.
+
+#### `FLUXER_ABUSE_INBOUND_PHONE_COUNTRY_CODES`
+
+Default empty. Allowed inbound phone countries. Comma separated, passed through unvalidated.
+
+#### `FLUXER_ABUSE_PHONE_INBOUND_REQUIRED_PREFIXES`
+
+Default empty. Required inbound prefixes. Comma separated.
+
+#### `FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ENABLED`
+
+Default `false`. Direct contact spam detection.
+
+#### `FLUXER_ABUSE_DIRECT_CONTACT_SPAM_COUNTRY_CODES`
+
+Default empty. Countries the rule applies to. Comma separated, unvalidated.
+
+#### `FLUXER_ABUSE_DIRECT_CONTACT_SPAM_DISTINCT_TARGET_THRESHOLD`
+
+Default `25`. Distinct targets before the rule fires. Integer.
+
+#### `FLUXER_ABUSE_DIRECT_CONTACT_SPAM_TARGET_WINDOW_MS`
+
+Default `7200000`. The observation window. Milliseconds, two hours by default.
+
+#### `FLUXER_ABUSE_DIRECT_CONTACT_SPAM_ACTION`
+
+Default `flag_spammer`. What happens when it fires. `flag_spammer` or `suppress_delivery`. Anything else fails startup.
+
+#### `FLUXER_BLOCKLIST_FEEDS_ENABLED`
+
+Defaults to the inverse of `FLUXER_SELF_HOSTED`. External blocklist feeds. Off by default on a self-hosted instance.
+
+A second family, unrelated to the rules above, tunes the IP auto-banner: `FLUXER_ABUSE_WINDOW_MS`, four `FLUXER_ABUSE_THRESHOLD_` names, four `FLUXER_ABUSE_TOKEN_DIVERSITY_` names, `FLUXER_ABUSE_BAN_TTL_SEC`, `FLUXER_ABUSE_BATCH_FLUSH_MS`, `FLUXER_ABUSE_MAX_BATCH_TICKS`, `FLUXER_ABUSE_MAX_NEW_TOKENS_PER_TICK`, `FLUXER_ABUSE_MAX_TRACKED_IPS`, `FLUXER_ABUSE_MAX_TOKEN_HASHES_PER_IP`, `FLUXER_ABUSE_MIN_SCORE_FOR_LOOKUP`, `FLUXER_ABUSE_MIN_TOKENS_FOR_LOOKUP`, and `FLUXER_ABUSE_REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN`. All are read directly from the environment, none are in `.env.example` or the Compose file, and a non-finite value or one at or below zero falls back to the default.
+
+## Limits
+
+No environment variable changes an instance limit. Fluxer keeps the limits in the key-value store under `limit_config:self_hosted` or `limit_config:saas`, seeded from `FLUXER_SELF_HOSTED`, and an operator edits them through the admin dashboard's Limit Config page or the [Admin API](/admin-api/). The published values are the [limit configuration object](/http-api/instance/#limit-configuration-object).
+
+Request concurrency is separate from instance limits, and each process sets its own. All are optional.
+
+#### `FLUXER_API_MAX_INFLIGHT_REQUESTS`
+
+Default `512`. The API in-flight ceiling. Integer 1 to 100000, checked at startup.
+
+#### `FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY`
+
+Default `512`. Gateway HTTP RPC concurrency.
+
+#### `FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS`
+
+Default `512`. Gateway NATS handler count.
+
+#### `FLUXER_DISABLE_RATE_LIMITS`
+
+Default `false`. Turns rate limits off. Read by the API and, as a raw presence check, by the Gateway.
+
+#### `FLUXER_RELAX_REGISTRATION_RATE_LIMITS`
+
+Default `false`. Loosens registration rate limits. Reaches production containers if set.
+
+## Telemetry, logging and build metadata
+
+All are optional.
+
+#### `LOG_LEVEL`
+
+Defaults to `debug` in development, `info` otherwise. The Node log level. Read by `api` and `worker`.
+
+#### `RUST_LOG`
+
+Default `info`. The Rust log filter. Read by `media-proxy`, `app-proxy`, `admin`, and the five internal services. Not in `.env.example` or the Compose file.
+
+#### `FLUXER_GATEWAY_LOGGER_LEVEL`
+
+Default `info`. The Gateway log level. Compose sets `info`.
+
+#### `LOGGER_LEVEL`
+
+Falls back to `FLUXER_GATEWAY_LOGGER_LEVEL`. The Gateway log level at runtime. Overrides the prefixed name.
+
+#### `BUILD_VERSION`
+
+Default `dev`. The reported build. Baked into the images. A fallback outside development prints a warning.
+
+#### `RELEASE_CHANNEL`
+
+Default `stable`. The reported channel. Only `canary` is recognised as non-stable.
+
+#### `FLUXER_TELEMETRY_ENABLED`
+
+Defaults to `false` at the Gateway's environment layer. Gateway telemetry. Read only by the Gateway, and in neither `.env.example` nor the Compose file.
+
+#### `HOSTNAME`
+
+Set by Docker. Node identity in metrics and logs. Also used by the IP auto-banner.
+
+#### `FLUXER_ENV`
+
+Default `development`. The runtime mode. `development`, `production`, or `test`. Compose pins `production`, which gates Postgres validation and the admin cookie flags.
+
+#### `FLUXER_SELF_HOSTED`
+
+Default `false`. The self-host switch. Compose sets `true`. It relaxes the production Postgres SSL requirement, seeds the limit tier, gates registration, billing and discovery controllers, and turns blocklist feeds off.
+
+`/_metrics` on `api`, `media-proxy`, and `gateway`, plus the Gateway's `/_health/ready`, `/_health/drain`, and `/_health/undrain`, are gated to loopback peers, so no proxy reaches them. The probes that work from outside are `/api/_health`, `/gateway/_health`, `/media/_health`, and the edge's own `/_health`.
+
+## Feature flags and development switches
+
+All are optional.
+
+#### `FLUXER_DISCOVERY_ENABLED`
+
+Default `true`. The public guild [discovery](/http-api/discovery/) surface. With it off, discovery search, discovery join, and the three guild discovery application routes return 400 `DISCOVERY_DISABLED`.
+
+#### `FLUXER_DISCOVERY_MIN_MEMBER_COUNT`
+
+Default `1`. Minimum members for discovery eligibility. Integer.
+
+#### `FLUXER_DELETION_GRACE_PERIOD_HOURS`
+
+Default `336`. How long a deleted account is recoverable. Forced to 0.01 hours when `FLUXER_TEST_MODE_ENABLED` is on.
+
+#### `FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED`
+
+Default `false`. Presigned attachment uploads. Compose sets `true`.
+
+#### `FLUXER_API_PRESIGNED_DOWNLOADS_ENABLED`
+
+Default `false`. Presigned downloads. Applies to the downloads bucket.
+
+#### `FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED`
+
+Default `true`. Presigned harvest downloads. Applies to the harvests bucket.
+
+#### `FLUXER_API_EMBEDS_OEMBED_HTML_ENABLED`
+
+Default `false`. oEmbed HTML in embeds.
+
+#### `FLUXER_API_EMBEDS_OEMBED_HTML_ALLOW_UNTRUSTED_ON_SELF_HOSTED`
+
+Default `false`. Untrusted oEmbed HTML on a self-hosted instance. Security relevant. Leave it off.
+
+#### `FLUXER_API_EMBEDS_OEMBED_HTML_ALLOWED_HOSTS`
+
+Default empty. Hosts allowed to supply oEmbed HTML. Comma separated.
+
+#### `FLUXER_API_EMBEDS_CACHE_DEFAULT_TTL_SECONDS`
+
+Default `86400`. Default embed cache lifetime. Seconds.
+
+#### `FLUXER_API_EMBEDS_CACHE_MAX_TTL_SECONDS`
+
+Default `604800`. Maximum embed cache lifetime. Seconds.
+
+#### `FLUXER_API_EMBEDS_CACHE_MIN_TTL_SECONDS`
+
+Default `300`. Minimum embed cache lifetime. Seconds.
+
+#### `FLUXER_API_EMBEDS_CACHE_RESPECT_REMOTE_TTL`
+
+Default `true`. Whether a remote cache header is honoured. Clamped by `FLUXER_API_EMBEDS_CACHE_MIN_TTL_SECONDS` and `FLUXER_API_EMBEDS_CACHE_MAX_TTL_SECONDS`.
+
+#### `FLUXER_API_UNFURL_IGNORED_HOSTS`
+
+Default empty. Hosts never unfurled. Comma separated, unvalidated.
+
+#### `FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES`
+
+Default empty. Countries redirected to GitHub for desktop downloads. Each entry must be two uppercase letters or the API fails at boot.
+
+#### `FLUXER_TEST_MODE_ENABLED`
+
+Default `false`. Test mode. Collapses the deletion grace period. Reaches production containers if set.
+
+#### `FLUXER_TEST_HARNESS_TOKEN`
+
+No default. The test harness credential. Reaches production containers if set.
+
+#### `FLUXER_VALIDATE_RESPONSES`
+
+Defaults to on outside production. Response schema validation. Costs latency when forced on.
+
+#### `FLUXER_KLIPY_API_KEY`
+
+Default empty. The Klipy GIF provider key. GIF search reports itself unavailable while this and the admin dashboard key are both empty. Also read by `unfurl`, which accepts `KLIPY_API_KEY` as a fallback.
+
+#### `FLUXER_YOUTUBE_API_KEY`
+
+Default empty. The YouTube Data API key. Also read by `unfurl`, which accepts `YOUTUBE_API_KEY` as a fallback.
+
+#### `FLUXER_BUNNY_PURGE_ENABLED`
+
+Default `false`. Bunny CDN cache purging. Needs `FLUXER_BUNNY_API_KEY` and `FLUXER_BUNNY_PULL_ZONE_ID`.
+
+#### `FLUXER_BUNNY_API_KEY`
+
+Default empty. The Bunny API key. Paired with the pull zone.
+
+#### `FLUXER_BUNNY_PULL_ZONE_ID`
+
+Default `0`. The Bunny pull zone. Integer.
+
+#### `FLUXER_GEOIP_DB_PATH`
+
+Default empty. The GeoIP database. A filesystem path, or an `s3://bucket/key` URL whose `download_path` query parameter is mandatory and must be absolute. `app-proxy` also accepts `MAXMIND_DB_PATH`.
+
+## Instance identity and branding
+
+None of these are in `.env.example` or in `docker-compose.yml`. Set branding from the admin dashboard instead. All are optional.
+
+#### `FLUXER_APP_PRODUCT_NAME`
+
+Default `Fluxer`. The product name clients display. Also settable in the admin dashboard, which wins.
+
+#### `FLUXER_APP_ICON_URL`
+
+Default empty. The client icon. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_APP_SYMBOL_URL`
+
+Default empty. The symbol mark. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_APP_LOGO_URL`
+
+Default empty. The logo. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_APP_WORDMARK_URL`
+
+Default empty. The wordmark. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_APP_FAVICON_URL`
+
+Default empty. The favicon. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_APP_THEME_COLOR`
+
+Default empty. The theme colour. Its origin is added to the CSP by `app-proxy`.
+
+#### `FLUXER_INSTANCE_SETUP_CONFIGURED`
+
+Default `false`. Whether setup is marked complete. The stored `app_public_config` row wins whenever its `setup.configured` is a boolean. This variable supplies the default only while no such row exists, and only on a self-hosted instance. Off a self-hosted instance the state is always true whatever this says.
+
+#### `FLUXER_AUTO_JOIN_INVITE_CODE`
+
+Default empty. An invite every new account joins. Must be a live invite code.
+
+#### `FLUXER_VISIONARIES_GUILD_ID`
+
+Default empty. The guild that grants the visionary role. Snowflake.
+
+#### `FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID`
+
+Default empty. The role granted there. Snowflake.
+
+That stored row lives in the `instance_configuration` table under `app_public_config`. Unparseable JSON or a non-boolean `setup.configured` falls through to `FLUXER_INSTANCE_SETUP_CONFIGURED` the same way an absent row does. Finishing the wizard writes the row, and every later admin write of branding or legal URLs rewrites it with the field still set.
+
+Setting the variable back to `false` therefore does not reopen the setup wizard, and it does not restore the unauthenticated instance-configuration access or the first-registration admin grant that [Get started](/operator/get-started/) describes.
+
+## API and worker settings
+
+`FLUXER_API_WORKER_TASK` is required under `single_task`. The rest are optional.
+
+#### `FLUXER_API_PORT`
+
+Default `8080`. The API listen port. Compose sets `8080` and the edge proxies to it.
+
+#### `FLUXER_API_HEADERS_TIMEOUT_MS`
+
+Default `30000`. How long a client may take to send the request line and the headers. Integer 1000 to 3600000, checked at startup. It is clamped down to `FLUXER_API_REQUEST_TIMEOUT_MS`, so raising it alone does nothing.
+
+#### `FLUXER_API_REQUEST_TIMEOUT_MS`
+
+Default `120000`. How long a client may take over the whole request. Integer 1000 to 3600000, checked at startup. This is the one to raise for large uploads or high latency links.
+
+#### `FLUXER_API_WORKER_MODE`
+
+Default `all_lanes`. Which lanes the worker runs. `all_lanes`, `single_lane`, or `single_task`. Anything else fails startup.
+
+#### `FLUXER_API_WORKER_LANE`
+
+No default. Which lane, under `single_lane`. `realtime`, `unfurl`, `lifecycle`, or `batch`.
+
+#### `FLUXER_API_WORKER_TASK`
+
+No default. Which task, under `single_task`. Must name a known task.
+
+#### `FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER`
+
+No default. Whether the cron scheduler runs. Compose sets `true`. Without it no scheduled job runs anywhere.
+
+#### `FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES`
+
+No default. Per-lane concurrency. JSON object keyed by lane. Each value must be an integer of at least 1 or startup fails. Built-in concurrency is realtime 10, unfurl 20, lifecycle 8, batch 12.
+
+## Media Proxy settings
+
+The Media Proxy takes uploads, transforms images, and serves media back. All are optional.
+
+#### `FLUXER_MEDIA_PROXY_MODE`
+
+Default `mp`. Which routes are served. `mp`, `static`, or `upload`. Anything else is a startup error. Compose sets `upload`.
+
+#### `FLUXER_MEDIA_PROXY_HOST`
+
+Default `0.0.0.0`. The bind address. Also settable with `--bind-host`.
+
+#### `FLUXER_MEDIA_PROXY_PORT`
+
+Default `8080`. The listen port. Also settable with `--port`.
+
+#### `FLUXER_MEDIA_PROXY_READ_ONLY`
+
+Default `false`. Refuses writes. `--read-only` can force it on.
+
+#### `FLUXER_MEDIA_PROXY_STORAGE_BACKEND`
+
+Default `local`. Where objects live. `local` or `s3`. Compose sets `s3`.
+
+#### `FLUXER_MEDIA_PROXY_STORAGE_ROOT`
+
+Default `./media_proxy_storage`. The local storage directory. Used only by the local backend.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES`
+
+Default `524288000`. The upload size cap on both sides. Rust accepts 1 byte to 5 GiB and refuses to start when the value is above `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES`. The effective cap is the smaller of the token's value and this.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS`
+
+Default `900`. Relay token lifetime. Read by the API alone.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT`
+
+Default `http://localhost:8088/media`. The relay URL handed to clients. Node side only. A trailing slash and a trailing `/v1/relay` are stripped.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES`
+
+Default empty. Countries that upload directly to S3. Node side only. Empty means every client uses the relay, which is what keeps the internal S3 address out of browsers.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS`
+
+Default `900000`. Relay upload timeout. Accepts 1000 to 3600000.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR`
+
+Defaults to the system temporary directory. Where relay bodies spool. Must be writable.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES`
+
+Default `1048576`. Spool chunk size. Accepts 64 KiB to 64 MiB.
+
+#### `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES`
+
+Default `8589934592`. Total spool ceiling. Accepts 0 to 256 GiB, and must be at or above `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES`, which puts the usable floor at 500 MiB by default.
+
+#### `FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS`
+
+Defaults to available parallelism, clamped to 2 to 8. Concurrent image transforms. Accepts 1 to 128.
+
+#### `FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY`
+
+Defaults to eight times the transform limit. Transform queue depth. Accepts 1 to 8192.
+
+#### `FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES`
+
+Default `268435456`. Transform cache size. Accepts 0 to 4 GiB.
+
+#### `FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES`
+
+Default `67108864`. Largest cacheable result. Accepts 0 to 512 MiB.
+
+#### `FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS`
+
+Default `120000`. Transform cache lifetime. Accepts 0 to 3600000.
+
+#### `FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS`
+
+Default `30000`. Socket read and write timeout. Accepts 0 to 300000.
+
+#### `FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS`
+
+Default `30000`. The deadline for the shutdown drain after SIGTERM or Ctrl-C. Accepts 0 to 300000. In-flight requests, the transform coalescer, and native transform tasks share the one deadline, and passing it exits the process with an error.
+
+#### `FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS`
+
+Default `15000`. Per-transform timeout. Accepts 1000 to 120000.
+
+#### `FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES`
+
+Defaults to the animated frame default. Animation frame ceiling. Accepts 1 to 100000.
+
+#### `FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS`
+
+Default `30000`. Animation duration ceiling. Accepts 100 to 600000.
+
+#### `FLUXER_NSFW_SERVICE_ENDPOINT`
+
+Default empty. An external NSFW classifier. No such service ships with the stack.
+
+#### `FLUXER_MEDIA_PROXY_NSFW_THRESHOLD`
+
+Default `0.85`. The media-side NSFW cutoff. Accepts 0.0 to 1.0 and finite. Distinct from the API threshold.
+
+#### `FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED`
+
+Default `false`. Restricts reads to Bunny edge IPs.
+
+#### `FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES`
+
+Default empty. Extra trusted addresses for that gate. Every entry must parse as an IP or the process exits.
+
+#### `FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS`
+
+Default `3600`. How often the edge IP list refreshes. Accepts 60 to 86400.
+
+`media-proxy` range-checks two values at startup and then reads them nowhere: `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES` and `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES`. A bad value still fails the boot.
+
+## Gateway settings
+
+The Gateway is the WebSocket service clients hold open for live events. All are optional.
+
+#### `FLUXER_GATEWAY_PORT`
+
+Default `8771`. The listen port. Compose sets `8080`.
+
+#### `FLUXER_GATEWAY_ROLE`
+
+Default `all`. Which subsystems this node runs. `websocket`, `sessions`, `presence`, `guilds`, `calls`, `push`, or `all`. An unrecognised value also becomes `all`.
+
+#### `FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT`
+
+Default `http://localhost:8088/media`. The public media URL used in payloads. Compose builds it from the public origin.
+
+#### `FLUXER_GATEWAY_STATIC_CDN_ENDPOINT`
+
+Default `http://localhost:8088`. The public static origin used in payloads. Compose builds it from the public origin.
+
+#### `FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES`
+
+Default `128`. Presence push buffer depth.
+
+#### `FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES`
+
+Default `1048576`. Presence push buffer size.
+
+#### `FLUXER_GATEWAY_SHUTDOWN_DRAIN_WAIT_MS`
+
+Default `5000`. How long a drain waits. Milliseconds.
+
+#### `FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD`
+
+Default `6`. Failures before the API circuit opens. Integer.
+
+#### `FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS`
+
+Default `15000`. How long the circuit stays open. Milliseconds.
+
+#### `FLUXER_GATEWAY_CLUSTER_ENABLED`
+
+Default `false`. BEAM clustering. Single-node by default.
+
+#### `FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME`
+
+No default. The DNS name peers are discovered through. Needs clustering on.
+
+#### `FLUXER_GATEWAY_CLUSTER_DISCOVERY_NODE_BASENAME`
+
+No default. The node basename for discovery. Needs clustering on.
+
+#### `FLUXER_GATEWAY_CLUSTER_DISCOVERY_POLL_INTERVAL_MS`
+
+Default `5000`. Discovery poll interval. Milliseconds.
+
+#### `FLUXER_GATEWAY_CLUSTER_STATIC_PEERS`
+
+Default empty. A fixed peer list. Comma separated Erlang node names, capped at 256. An entry that is not a node name fails startup.
+
+#### `FLUXER_ERLANG_NODE_NAME`
+
+Default `fluxer_gateway@127.0.0.1`. The BEAM node name. Must be resolvable by peers when clustering.
+
+#### `FLUXER_ERLANG_COOKIE`
+
+No default. The BEAM distribution secret. The Gateway refuses to start without it, and Compose refuses to start the stack when `.env` lacks it. Anyone who reaches the distribution port with the value gets code execution, so keep the port unpublished.
+
+#### `FLUXER_ERLANG_DIST_PORT`
+
+Default `8081`. The BEAM distribution port. Not published by Compose. Never expose it.
+
+#### `FLUXER_ERLANG_SCHEDULERS`
+
+Defaults to the container CPU count, clamped to 2 through 16. Normal scheduler count. Positive integer, passed to the BEAM as `+S N:N`. A value that is not a positive integer is ignored and the derivation runs instead.
+
+#### `FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS`
+
+Defaults to two thirds of the scheduler count, rounded up. Dirty CPU scheduler count. Positive integer, passed as `+SDcpu N:N`. Same fallback rule as `FLUXER_ERLANG_SCHEDULERS`.
+
+#### `FLUXER_ERLANG_SCHEDULERS_MIN`
+
+Default `2`. The floor of the scheduler clamp. Compose forwards it to `gateway`.
+
+#### `FLUXER_ERLANG_SCHEDULERS_MAX`
+
+Default `16`. The ceiling of the scheduler clamp. Compose forwards it to `gateway`, and `.env.example` ships both names commented out.
+
+The Gateway entrypoint derives the scheduler counts before the BEAM starts. It reads the container CPU quota, clamps the result between `FLUXER_ERLANG_SCHEDULERS_MIN` and `FLUXER_ERLANG_SCHEDULERS_MAX`, exports the answer as `FLUXER_ERLANG_SCHEDULERS`, and derives `FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS` from it. `vm.args.src` then substitutes both into `+S` and `+SDcpu`.
+
+The two clamp bounds reach the Gateway from `.env`. To pin the count, set `FLUXER_ERLANG_SCHEDULERS` on the `gateway` service in `docker-compose.yml`, which skips the clamp.
+
+The Gateway protocol version is `1`. The Gateway answers any other value in `?v=` with 101, then a close frame reading `Invalid API version`.
+
+The Gateway reads every `FLUXER_GATEWAY_` name straight from the environment, so all of them work.
+
+## App proxy settings
+
+`app-proxy` serves the web client. All are optional.
+
+#### `FLUXER_APP_PROXY_HOST`
+
+Default `0.0.0.0`. The bind address. Compose sets it explicitly.
+
+#### `FLUXER_APP_PROXY_PORT`
+
+Default `8080`. The listen port. Compose sets it explicitly.
+
+#### `FLUXER_STATIC_DIR`
+
+Default `./static`. Where the client bundle lives. The name misleads. It is the SPA bundle directory, unrelated to `static-proxy`.
+
+#### `FLUXER_APP_PROXY_INDEX_UPSTREAM_URL`
+
+No default. An upstream to fetch `index.html` from. Leave unset for the shipped image.
+
+#### `DISCOVERY_UPSTREAM_URL`
+
+Default `http://localhost:8088/api/.well-known/fluxer`. Where the bootstrap discovery document is fetched. Compose sets `http://edge:8088/.well-known/fluxer`. That internal listener supplies the client-IP header the API requires, so leave it pointed at the edge.
+
+#### `DISCOVERY_REFRESH_INTERVAL_MS`
+
+Default `60000`. How often discovery is refetched. Unprefixed name.
+
+#### `PUBLIC_BOOTSTRAP_API_ENDPOINT`
+
+Default `/api`. The API path put in the page bootstrap.
+
+#### `PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT`
+
+No default. The absolute API URL in the bootstrap. Compose builds it from the public origin.
+
+#### `FLUXER_APP_PROXY_TIME_FREEZE_ENABLED`
+
+Defaults to the inverse of `FLUXER_SELF_HOSTED`. A frozen asset snapshot. Also compiled out of the self-hosted image.
+
+## Admin settings
+
+`admin` serves the dashboard at `/admin`. All are optional.
+
+#### `FLUXER_ADMIN_HOST`
+
+Default `0.0.0.0`. The bind address. Compose sets it explicitly.
+
+#### `FLUXER_ADMIN_PORT`
+
+Default `3020`. The listen port. The image sets 8080 and Compose sets 8080.
+
+#### `FLUXER_ADMIN_BASE_PATH`
+
+Defaults to empty in Rust, `/admin` in Node. The path the dashboard is mounted under. Normalised to a leading slash with no trailing slash. The service serves at root and re-prefixes every emitted URL with this.
+
+#### `FLUXER_ADMIN_OAUTH_REDIRECT_URI`
+
+Defaults to the admin endpoint plus `/oauth2_callback`. The OAuth2 redirect. Must equal what the API derives from `FLUXER_ADMIN_ENDPOINT`. The API does not read this name.
+
+#### `FLUXER_RELEASE_CHANNEL`
+
+Default `stable`. The reported channel. `RELEASE_CHANNEL` is preferred over it.
+
+#### `FLUXER_BUILD_VERSION`
+
+Defaults to the crate version. The reported build. `BUILD_VERSION` is preferred over it.
+
+## Content Security Policy
+
+`app-proxy` builds a per-request nonce-based policy for the client HTML and the assets it serves. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards all eleven.
+
+`FLUXER_CSP_EXTRA_DEFAULT_SRC`, `FLUXER_CSP_EXTRA_CONNECT_SRC`, `FLUXER_CSP_EXTRA_IMG_SRC`, `FLUXER_CSP_EXTRA_MEDIA_SRC`, `FLUXER_CSP_EXTRA_FONT_SRC`, `FLUXER_CSP_EXTRA_SCRIPT_SRC`, `FLUXER_CSP_EXTRA_STYLE_SRC`, `FLUXER_CSP_EXTRA_FRAME_SRC`, `FLUXER_CSP_EXTRA_WORKER_SRC`, and `FLUXER_CSP_EXTRA_MANIFEST_SRC` take one or more sources separated by commas, spaces, tabs, or newlines. Blank entries and sources the directive already lists are dropped. `FLUXER_CSP_REPORT_URI` sets a single `report-uri` value.
+
+`object-src`, `base-uri`, and `frame-ancestors` are fixed and have no override. `app-proxy` reads the discovery document and adds the static CDN endpoint, the media endpoint, and the origins of the configured branding images, so a stack on one hostname needs no extra sources. The usual reason to set one is a voice server on another hostname, which needs its WebSocket origin in `FLUXER_CSP_EXTRA_CONNECT_SRC`.
+
+A front proxy must not add a Content-Security-Policy of its own.
+
+## Keys in .env.example that no service reads
+
+`.env.example` has names that no Fluxer process ever sees. Docker Compose or the edge container consumes most of them, and each of those reaches a service under a different name. Setting one outside Compose, in Kubernetes or a systemd unit, does nothing. Each entry below says what consumes it.
+
+#### `FLUXER_DOMAIN`
+
+Interpolated into `FLUXER_BASE_DOMAIN` and into the derived URL strings.
+
+#### `FLUXER_PUBLIC_ORIGIN`
+
+Interpolated into fourteen values that need a full origin.
+
+#### `COMPOSE_FILE`
+
+Read by Docker Compose to select the proxy overlay.
+
+#### `FLUXER_EDGE_SITE_ADDRESS`
+
+Read by the edge container only, and only in the bundled layout. The overlay overwrites it with `:8080`.
+
+#### `FLUXER_CADDY_SITE_ADDRESS`
+
+Read by Docker Compose as the default for `FLUXER_EDGE_SITE_ADDRESS` when that name is unset.
+
+#### `FLUXER_EDGE_TRUSTED_PROXIES`
+
+Read by the edge container only. It takes effect on `docker compose up -d edge`.
+
+#### `FLUXER_EDGE_BIND`
+
+Used as the host side of the overlay's port mapping.
+
+#### `FLUXER_HTTP_PORT` and `FLUXER_HTTPS_PORT`
+
+Used as the host side of the edge's published ports.
+
+#### `FLUXER_REGISTRY_OWNER`, `FLUXER_REGISTRY` and `FLUXER_IMAGE_TAG`
+
+Image name selection.
+
+#### `POSTGRES_PASSWORD`
+
+Becomes `FLUXER_POSTGRES_PASSWORD` and the Postgres image's own password.
+
+#### `MEILI_MASTER_KEY`
+
+Becomes `FLUXER_SEARCH_API_KEY` and the Meilisearch image's own key.
+
+#### `FLUXER_S3_ACCESS_KEY` and `FLUXER_S3_SECRET_KEY`
+
+Become `FLUXER_S3_ACCESS_KEY_ID` and `FLUXER_S3_SECRET_ACCESS_KEY`, and the `AWS_` pair.
+
+#### `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`
+
+Become `FLUXER_LIVEKIT_API_KEY` and `FLUXER_LIVEKIT_API_SECRET`, and LiveKit's own `LIVEKIT_KEYS`.
+
+## Keys Compose does not forward
+
+`.env.example` names every variable `docker-compose.yml` reads from `.env`. The Node override table has 278 names, on top of roughly ninety Rust-only and twenty-five Erlang-only names, and Compose forwards a fraction of them. A name below reaches a service only through a Compose override file that adds it to that service's environment block.
+
+#### `FLUXER_AUTH_BLUESKY_` and the six names under it
+
+Bluesky login defaults off. The admin dashboard configures it too, under Runtime Integrations.
+
+#### `FLUXER_PUSH_APNS_` and `FLUXER_PUSH_FCM_`
+
+Mobile push cannot be configured at all from the example.
+
+#### `RUST_LOG`, `LOG_LEVEL` and `LOGGER_LEVEL`
+
+The only way to change log verbosity.
+
+#### `FLUXER_APP_PRODUCT_NAME` and the six branding URLs
+
+Branding is otherwise admin-dashboard only.
+
+#### `FLUXER_INSTANCE_SETUP_CONFIGURED`
+
+Supplies the initial setup state on a self-hosted instance, until the first write of the stored `app_public_config` row takes over.
+
+#### Every `FLUXER_ABUSE_` auto-banner name and every Media Proxy performance knob
+
+No example coverage at all.
+
+## Runtime settings in the admin dashboard
+
+The admin dashboard at `/admin` stores these in the database. They apply without recreating containers, and a value set here wins over the matching environment variable.
+
+#### Instance Config, Public App Identity
+
+Product name, client-visible brand assets, and the setup state in the instance discovery document.
+
+#### Instance Config, Registration Controls
+
+Registration mode of `open`, `approval`, or `closed`, admin-issued registration URLs, and pending approval requests.
+
+#### Instance Config, Community & Policy
+
+Single-community mode, direct messages and friends, the premium model, and optional embed services.
+
+#### Instance Config, Runtime Integrations
+
+The Klipy GIF key, the YouTube Data API key, the CAPTCHA provider and its keys, email delivery with an SMTP connection test, and Bluesky OAuth.
+
+#### Instance Config, Media Expiry
+
+Size-based attachment lifetimes. The built-in default is on.
+
+#### Instance Config, Gateway Rollout Configuration
+
+Session and guild rollout percentages, NATS timeouts, and Gateway concurrency.
+
+#### Instance Config, Single Sign-On (SSO)
+
+OIDC-style SSO for the client and admin apps, and whether SSO is enforced.
+
+#### Limit Config
+
+The [instance limits](/http-api/instance/#limit-configuration-object) published to clients.
+
+#### Voice Regions and Voice Servers
+
+The voice regions offered and the servers behind them.
+
+#### Admin API Keys
+
+Credentials for the [Admin API](/admin-api/).
+
+Each write publishes a refresh on the key-value pub/sub channel, so other processes drop their cached copy without a restart.
+
+Two settings exist only in the dashboard: attachment decay, which defaults to on, and the inactivity deletion threshold, which defaults to 365 days.
+
+## Services
+
+The stack runs 25 containers on one Docker bridge network, which is private to the stack.
+
+| Service | Image | What it does |
+| --- | --- | --- |
+| edge | caddy:2.10-alpine | TLS and path routing, the only HTTP entry point |
+| app-proxy | fluxer-app-proxy-self-hosted | Serves the web client and builds its CSP header |
+| static-proxy | fluxer-static | Serves the static asset bundle |
+| api | fluxer-api | The HTTP API |
+| worker | fluxer-api | Background lanes, the cron scheduler, and voice reconciliation |
+| gateway | fluxer-gateway | The Gateway WebSocket |
+| media-proxy | fluxer-media-proxy | Uploads, transforms, and media delivery |
+| admin | fluxer-admin | The admin dashboard |
+| snowflakes, snowflakes-shard | fluxer-snowflakes | Identifier allocation |
+| users, users-shard | fluxer-users | User reads and writes |
+| messages, messages-shard | fluxer-messages | Message reads and writes |
+| gifs, gifs-shard | fluxer-gifs | GIF provider access |
+| unfurl, unfurl-shard | fluxer-unfurl | Link unfurling |
+| postgres | postgres:16-alpine | The database |
+| valkey | valkey/valkey:8.1-alpine | The key-value store and pub/sub bus, and the two deletion queues |
+| nats | nats:2.14-alpine | Core messaging, and the JetStream streams holding queued background jobs |
+| meilisearch | getmeili/meilisearch:v1.12 | The search index |
+| seaweedfs | chrislusf/seaweedfs:4.34 | S3-compatible object storage |
+| seaweedfs-init | chrislusf/seaweedfs:4.34 | Creates the buckets and the S3 identity, then exits |
+| livekit | livekit/livekit-server:v1.12.0 | Voice and video |
+
+The edge and LiveKit are the only services that publish ports. The edge publishes 80/tcp, 443/tcp, 443/udp, or one plain-HTTP port under the overlay. LiveKit publishes 7881/tcp and 7882/udp. Everything else is reachable only over the bridge network.
+
+`api` is the one service an operator configures directly, through the shared environment block. `worker`, `gateway`, `app-proxy`, and `media-proxy` are touched rarely, `worker` for lane concurrency, `app-proxy` for CSP extras, and `media-proxy` for transform limits. The edge takes only the three `FLUXER_EDGE_` variables, `postgres` only the password, `meilisearch` only the master key, `valkey` only the two `FLUXER_VALKEY_` tuning values, and `livekit` only the key pair and the two port variables. `static-proxy` reads no environment variables, and the remaining services need none.
+
+The five internal services each run a router, which takes requests and holds no state, and one shard, which holds the caches and the database connections. `FLUXER_SVC_SHARD_COUNT` is fixed at `1` in the shipped stack.
+
+## Resources
+
+Each of the 25 services has a memory limit and four also have a memory reservation, all under `deploy.resources`. Compose reads `gb` as 1024 MiB and `mb` as 1 MiB, so `5gb` is 5368709120 bytes. Plain `docker compose up` applies both keys on a single host, with no Swarm and no `--compatibility` flag. The engine rejects any limit below `6mb`, and rejects a limit lower than the same service's reservation with `Minimum memory limit can not be less than memory reservation limit`.
+
+A limit is a ceiling. The 25 limits below sum to 16.75 GiB and the stack does not need a host that large, because a container costs what it touches.
+
+`deploy.resources.reservations.memory` becomes the container's cgroup v2 `memory.low`, which biases kernel reclaim toward other containers under host pressure. It reserves nothing on its own.
+
+All are optional.
+
+#### `FLUXER_CADDY_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `edge`. The name says Caddy and the service is named `edge`.
+
+#### `FLUXER_POSTGRES_MEMORY_LIMIT`
+
+Default `5gb`. The ceiling for `postgres`. Must be at or above `FLUXER_POSTGRES_MEMORY_RESERVATION` or the container fails to create. The 256mb `shm_size` is charged against it.
+
+#### `FLUXER_VALKEY_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `valkey`. Must stay above `FLUXER_VALKEY_MAXMEMORY`, which bounds the stored dataset alone.
+
+#### `FLUXER_NATS_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `nats`. Covers JetStream file store metadata as well as the core server.
+
+#### `FLUXER_MEILISEARCH_MEMORY_LIMIT`
+
+Default `768mb`. The ceiling for `meilisearch`. Must stay well above `FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY`, which bounds the indexer alone.
+
+#### `FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY`
+
+Default `384mb`. Becomes `MEILI_MAX_INDEXING_MEMORY`. The memory the indexer may use for one batch. Lower it whenever you lower `FLUXER_MEILISEARCH_MEMORY_LIMIT`.
+
+#### `FLUXER_SEAWEEDFS_MEMORY_LIMIT`
+
+Default `512mb`. The ceiling for `seaweedfs`. One process runs the master, the volume server and the S3 gateway.
+
+#### `FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `seaweedfs-init`. A one-shot container that exits, so it never overlaps steady state.
+
+#### `FLUXER_LIVEKIT_MEMORY_LIMIT`
+
+Default `512mb`. The ceiling for `livekit`. Grows with the number of concurrent voice publishers.
+
+#### `FLUXER_API_MEMORY_LIMIT`
+
+Default `2560mb`. The ceiling for `api`. Node derives its heap ceiling from this at about half, and the derivation stops falling at or below `512mb`.
+
+#### `FLUXER_WORKER_MEMORY_LIMIT`
+
+Default `2560mb`. The ceiling for `worker`. Same Node derivation as `api`, applied to every background lane at once.
+
+#### `FLUXER_GATEWAY_MEMORY_LIMIT`
+
+Default `1gb`. The ceiling for `gateway`. The BEAM has no heap ceiling of its own, so this limit is the only bound on the Gateway.
+
+#### `FLUXER_MEDIA_PROXY_MEMORY_LIMIT`
+
+Default `512mb`. The ceiling for `media-proxy`. Image and video transforms decode into this ceiling, so an oversize upload is where it binds.
+
+#### `FLUXER_STATIC_PROXY_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `static-proxy`. The service reads no environment variables and serves files only.
+
+#### `FLUXER_APP_PROXY_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `app-proxy`. It holds the discovery cache and reads no database.
+
+#### `FLUXER_SNOWFLAKES_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `snowflakes`. A router holds no shard state.
+
+#### `FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `snowflakes-shard`. Holds the identifier buffer sized by `FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE`.
+
+#### `FLUXER_USERS_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `users`. A router holds no shard state.
+
+#### `FLUXER_USERS_SHARD_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `users-shard`. Holds the read cache bounded by `FLUXER_SVC_CACHE_MAX_ENTRIES`, which defaults to 100000 entries.
+
+#### `FLUXER_GIFS_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `gifs`. A router holds no shard state.
+
+#### `FLUXER_GIFS_SHARD_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `gifs-shard`. Lower than the 536870912 byte default of `FLUXER_GIFS_SHARD_CACHE_MAX_BYTES`.
+
+#### `FLUXER_MESSAGES_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `messages`. A router holds no shard state.
+
+#### `FLUXER_MESSAGES_SHARD_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `messages-shard`. The shard has a Postgres pool of 20 connections alongside the read cache.
+
+#### `FLUXER_UNFURL_MEMORY_LIMIT`
+
+Default `128mb`. The ceiling for `unfurl`. A router holds no shard state.
+
+#### `FLUXER_UNFURL_SHARD_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `unfurl-shard`. Fetches remote pages, so a slow upstream holds bytes for the length of the fetch.
+
+#### `FLUXER_ADMIN_MEMORY_LIMIT`
+
+Default `256mb`. The ceiling for `admin`. Serves the dashboard and proxies no media.
+
+Four services have a reservation. Losing any of the four takes the instance down, so the kernel reclaims from everything else first. All are optional.
+
+#### `FLUXER_POSTGRES_MEMORY_RESERVATION`
+
+Default `3gb`. The reclaim floor for `postgres`. Must be at or below `FLUXER_POSTGRES_MEMORY_LIMIT`. Lowering the limit alone fails container creation.
+
+#### `FLUXER_API_MEMORY_RESERVATION`
+
+Default `1gb`. The reclaim floor for `api`. Must be at or below `FLUXER_API_MEMORY_LIMIT`.
+
+#### `FLUXER_WORKER_MEMORY_RESERVATION`
+
+Default `1gb`. The reclaim floor for `worker`. Must be at or below `FLUXER_WORKER_MEMORY_LIMIT`.
+
+#### `FLUXER_GATEWAY_MEMORY_RESERVATION`
+
+Default `384mb`. The reclaim floor for `gateway`. Must be at or below `FLUXER_GATEWAY_MEMORY_LIMIT`.
+
+Node sizes its own old-space heap at roughly half the container limit, with a floor near 259 MB. A container limit of `2560mb` gives a 1328 MB heap ceiling, `1280mb` gives 664 MB, `768mb` gives 396 MB, and every limit at or under `512mb` gives the same 259 MB. Pin the value only to move it away from that derivation. All are optional.
+
+#### `FLUXER_API_NODE_HEAP_MB`
+
+No default. The V8 old-space ceiling for `api`, in MB. Appended to `NODE_OPTIONS` as `--max-old-space-size` only when non-empty. Keep it below `FLUXER_API_MEMORY_LIMIT`.
+
+#### `FLUXER_WORKER_NODE_HEAP_MB`
+
+No default. The V8 old-space ceiling for `worker`, in MB. Same rule against `FLUXER_WORKER_MEMORY_LIMIT`.
+
+Six names on the Postgres command line tune the bundled server. Keep them consistent with `FLUXER_POSTGRES_MEMORY_LIMIT`. All are optional.
+
+#### `FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS`
+
+Default `150`. The server-wide connection ceiling. The shipped pools total 90, from 25 each for `api` and `worker` and 20 each for `messages-shard` and `users-shard`. A value under about 95 exhausts the server before the pools fill, and the connections it turns away are refused with `too many clients already`.
+
+#### `FLUXER_POSTGRES_SHARED_BUFFERS`
+
+Default `512MB`. The shared buffer pool. Allocated at server start, so it is charged to the container whether or not it is used.
+
+#### `FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE`
+
+Default `2GB`. The planner's assumption about disk cache. Allocates nothing. Lowering it makes the planner prefer sequential scans and frees no memory.
+
+#### `FLUXER_POSTGRES_WORK_MEM`
+
+Default `8MB`. The per-operation sort and hash budget. Charged per sort or hash node, so one complex query can spend several multiples of it.
+
+#### `FLUXER_POSTGRES_MAINTENANCE_WORK_MEM`
+
+Default `256MB`. The budget for one VACUUM, CREATE INDEX, or ALTER TABLE. One such operation at a time normally holds it.
+
+#### `FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM`
+
+Default `128MB`. The budget for each autovacuum worker. Postgres runs three workers by default, so budget three times this value.
+
+The remaining Postgres settings are fixed on the command line, with no variable of their own: `min_wal_size=512MB`, `max_wal_size=2GB`, `wal_buffers=16MB`, and `shm_size: 256mb` on the service itself.
+
+The bundled Valkey holds durable state as well as cache, so it runs with an append-only file and refuses a write above its ceiling. All are optional.
+
+#### `FLUXER_VALKEY_MAXMEMORY`
+
+Default `192mb`. The dataset ceiling. Bounds stored keys only. Client buffers, replication buffers and allocator overhead sit outside it and inside `FLUXER_VALKEY_MEMORY_LIMIT`.
+
+#### `FLUXER_VALKEY_MAXMEMORY_POLICY`
+
+Default `noeviction`. What happens to a write above the ceiling. Under `noeviction` an over-limit write returns an OOM error to the caller. Under any eviction policy Valkey can drop the two deletion queues and the distributed locks. The worker rebuilds both queues from the users table within a day, and every lock has a TTL.
+
+No service sets a CPU limit, a CPU reservation or `cpu_shares`, so every container sees the host's full CPU count. Bound the Gateway's scheduler count with `FLUXER_ERLANG_SCHEDULERS_MIN` and `FLUXER_ERLANG_SCHEDULERS_MAX`, or pin it with `FLUXER_ERLANG_SCHEDULERS` and `FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS` from [Gateway settings](#gateway-settings).
+
+On a host smaller than the 16 GB the defaults assume, lower the four large ceilings and the Postgres tuning together. The block below is the 8 GB profile.
+
+```bash
+FLUXER_POSTGRES_MEMORY_LIMIT=2560mb
+FLUXER_POSTGRES_MEMORY_RESERVATION=1gb
+FLUXER_POSTGRES_SHARED_BUFFERS=384MB
+FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=1536MB
+FLUXER_API_MEMORY_LIMIT=1280mb
+FLUXER_API_MEMORY_RESERVATION=768mb
+FLUXER_WORKER_MEMORY_LIMIT=1280mb
+FLUXER_WORKER_MEMORY_RESERVATION=768mb
+FLUXER_GATEWAY_MEMORY_LIMIT=512mb
+FLUXER_GATEWAY_MEMORY_RESERVATION=256mb
+FLUXER_MEILISEARCH_MEMORY_LIMIT=512mb
+FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
+FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=134217728
+```
+
+At 8 GB the api and worker heap ceilings fall to 664 MB each and Postgres caches less of the working set, which shows up as slower search and slower history scrolling under load. Nothing is turned off.
+
+The 4 GB profile trades more.
+
+```bash
+FLUXER_POSTGRES_MEMORY_LIMIT=1280mb
+FLUXER_POSTGRES_MEMORY_RESERVATION=512mb
+FLUXER_POSTGRES_SHARED_BUFFERS=192MB
+FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=768MB
+FLUXER_POSTGRES_WORK_MEM=4MB
+FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=120
+FLUXER_API_MEMORY_LIMIT=768mb
+FLUXER_API_MEMORY_RESERVATION=512mb
+FLUXER_WORKER_MEMORY_LIMIT=640mb
+FLUXER_WORKER_MEMORY_RESERVATION=384mb
+FLUXER_GATEWAY_MEMORY_LIMIT=384mb
+FLUXER_GATEWAY_MEMORY_RESERVATION=192mb
+FLUXER_MEILISEARCH_MEMORY_LIMIT=384mb
+FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=128mb
+FLUXER_MEDIA_PROXY_MEMORY_LIMIT=320mb
+FLUXER_SEAWEEDFS_MEMORY_LIMIT=320mb
+FLUXER_LIVEKIT_MEMORY_LIMIT=320mb
+FLUXER_VALKEY_MEMORY_LIMIT=192mb
+FLUXER_VALKEY_MAXMEMORY=128mb
+FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=67108864
+```
+
+At 4 GB the api heap ceiling is 396 MB and the worker heap ceiling is 332 MB. That covers chat. A large attachment and a search reindex at the same time exceed it. Media transforms above roughly 20 MB start failing in `media-proxy`, Meilisearch indexes a backlog more slowly, and a busy voice room is the first thing to drop. Keep `FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS` at or above 110.
+
+## Routing
+
+The edge is the only HTTP entry point, and every route is served from the one public hostname. It rewrites each path before handing it to an upstream. [What the single port routes](/operator/reverse-proxy/#what-the-single-port-routes) has that path table, and [What every proxy must do](/operator/reverse-proxy/#what-every-proxy-must-do) has the requirements for anything in front of the edge.
+
+None of the variables on this page change that routing. The `Caddyfile` is a bind mount, so an edit to it takes `docker compose restart edge`.
+
+CORS origins are exactly the app and marketing endpoints. Serving the client from a hostname other than `FLUXER_DOMAIN` requires overriding `FLUXER_APP_ENDPOINT`.
+
+## Volumes and buckets
+
+| Volume | Holds | Back up |
+| --- | --- | --- |
+| postgres-data | Every account, message, and configuration row | Yes |
+| seaweedfs-data | Every uploaded file | Yes |
+| valkey-data | The two deletion queues, held locks, and cached values | Yes |
+| nats-data | The JetStream `JOBS` and `JOBS_DLQ` streams, on file storage | Yes |
+| edge-data | Issued TLS certificates | Optional, a loss only costs a re-issue |
+| edge-config | The edge's own state | No |
+| meilisearch-data | The search index, rebuildable | No |
+
+`valkey-data` reads as a cache and holds queued work. Both deletion queues survive its loss, because the worker rebuilds each sorted set from the users table whenever its state version is absent or older than a day. Locks and cached values are the disposable part of the volume.
+
+`nats-data` holds queued work. `JOBS` is a workqueue stream on file storage with a maximum age of 7 days, and `JOBS_DLQ` keeps dead-lettered jobs for 30. A newly created `JOBS_DLQ` also has a 64 MiB cap and drops its oldest jobs once full, so a busy dead-letter stream loses them well before 30 days. If the JetStream store has no room for 64 MiB, the cap halves down to a floor of 8 MiB. If even 8 MiB does not fit, the stream is never created and failed jobs stay in `JOBS` until they expire. Losing the volume drops every job that had not run yet, and nothing replays them from the job ledger in Postgres.
+
+Volume names are prefixed with the Compose project name, so `postgres-data` is `fluxer_postgres-data` on the host.
+
+`seaweedfs-init` creates five buckets and exits.
+
+| Bucket | Holds |
+| --- | --- |
+| fluxer | Avatars, guild and entity assets, themes, entrance sounds, memes, and processed attachments |
+| fluxer-uploads | Raw attachment uploads, before processing |
+| fluxer-downloads | Desktop client build artifacts |
+| fluxer-reports | Abuse report evidence, and NCMEC payloads where that integration is on |
+| fluxer-harvests | User and guild data archives |
+
+An attachment lands in `fluxer-uploads` first. Once processing succeeds, Fluxer copies it into `fluxer` and deletes the original. `FLUXER_S3_BUCKET_STATIC` names a sixth bucket that `seaweedfs-init` skips and that the stack's mode never reads.
diff --git a/fluxer_docs/src/content/docs/operator/get-started.mdx b/fluxer_docs/src/content/docs/operator/get-started.mdx
new file mode 100644
index 000000000..9fa44567a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/operator/get-started.mdx
@@ -0,0 +1,281 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Get started
+description: Bring up a Fluxer instance with Docker Compose, from a fresh host to first login.
+---
+
+import InstallerChecksum from '@/components/InstallerChecksum.astro';
+
+:::tip[Plutonium and donations fund Fluxer]
+We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
+:::
+
+By the end you have a Fluxer instance on a hostname you own, with the web app, HTTP API, Gateway, admin dashboard, uploads, search and voice signalling behind it. All of it runs in Docker containers on one machine. Allow about 30 minutes, most of it spent downloading images.
+
+## What you need
+
+| Requirement | Value |
+| --- | --- |
+| Host | Any machine that runs Docker with Linux containers. A Linux server, or a Mac or Windows PC with Docker Desktop |
+| Runtime | Docker Engine 24 or newer with the Compose plugin v2.20.2 or newer. Docker Desktop ships both |
+| Processor | Intel, AMD and Apple Silicon all work. Every image ships for `amd64` and `arm64`, and Docker pulls the matching one |
+| CPU | 2 vCPU minimum, 4 vCPU for a small active community. No service sets a CPU limit, so every container sees all of them |
+| RAM | 8 GB minimum with the shipped defaults, 16 GB for a small active community. 4 GB needs the [low-memory overrides](/operator/configuration/#resources) |
+| Disk | 20 GB minimum, more for uploads |
+| Inbound ports | 80/tcp, 443/tcp and 443/udp for the web tier, plus 7881/tcp and 7882/udp for voice media |
+| DNS | An `A` record for the hostname pointing at this machine, optionally an `AAAA` record |
+| Outbound | Working DNS resolution from inside the containers |
+| Host tools | `curl` and `openssl` on Linux. macOS ships both. The Windows installer needs neither |
+
+The per-container memory limits are ceilings and reserve nothing, so the host does not need their total free. On a 4 GB host, lower the `api` and `worker` limits first, because those two size their JavaScript heap from the limit they are given.
+
+Docker Desktop runs the containers inside a Linux virtual machine, so the RAM figure above is the RAM its settings give that machine, which starts well below what the hardware has.
+
+The stack is one Compose project. The edge and LiveKit are the only services that publish ports.
+
+The steps below use the bundled Caddy, which obtains and renews a TLS certificate for the hostname and needs no configuration.
+
+To put your own proxy in front, add the `docker-compose.proxy.yml` overlay. The stack then leaves 80 and 443 alone and publishes one plain HTTP port, and your proxy forwards everything to that port. [Behind your own reverse proxy](/operator/reverse-proxy/) has the overlay, the requirements, and worked configuration for nginx, Caddy, Traefik, HAProxy, Apache, a Cloudflare Tunnel and Nginx Proxy Manager.
+
+## Step 1: Install Docker
+
+Check what you have:
+
+```bash
+docker --version
+docker compose version
+```
+
+On Debian and Ubuntu, use Docker's apt repository from [Install Docker Engine](https://docs.docker.com/engine/install/). The distribution's `docker.io` package ships no Compose plugin. Add your user to the `docker` group with the [post-installation steps](https://docs.docker.com/engine/install/linux-postinstall/), then log out and back in so the commands below run without `sudo`.
+
+On a Mac or a Windows PC, install [Docker Desktop](https://docs.docker.com/desktop/) and leave it on Linux containers. It has both commands above.
+
+On Linux, install the two host tools the steps below use. On Debian and Ubuntu:
+
+```bash
+sudo apt-get install -y curl openssl
+```
+
+Other distributions use `dnf`, `zypper`, `pacman` or `apk` with the same two package names. `curl --version` and `openssl version` both answer once the install finishes.
+
+## Step 2: Point DNS at the host
+
+Choose the hostname the instance answers on. Step 4 writes it into `.env` as `FLUXER_DOMAIN`, and every example below uses `chat.example.com` in its place.
+
+Create an `A` record for that hostname pointing at the machine's public address, and an `AAAA` record when the machine has a public IPv6 address. A hosting provider shows that address in its console. On a home connection the address belongs to the router, and the router's status page shows it.
+
+:::caution[Finish setup before anyone else reaches the hostname]
+An unconfigured instance takes registrations from anyone, and lets any account that registers read and change the instance configuration. Registration defaults to `open`. The window opens the moment DNS and the ports are live, so finish the wizard in [Step 6](#first-run) in the same sitting that brings the stack up.
+:::
+
+## Step 3: Open the ports
+
+Open six ports on the host:
+
+| Port | Protocol | Used by |
+| --- | --- | --- |
+| 22 | tcp | SSH, on a host you reach remotely |
+| 80 | tcp | ACME HTTP validation and the redirect to HTTPS |
+| 443 | tcp | The web app, HTTP API, Gateway and admin dashboard |
+| 443 | udp | HTTP/3 for the same traffic |
+| 7881 | tcp | LiveKit media, published by the LiveKit container |
+| 7882 | udp | LiveKit media, published by the LiveKit container |
+
+A machine behind a home router needs ports 80, 443 and the two LiveKit ports forwarded to it, because the certificate is issued from the public internet and voice media arrives on the LiveKit ports directly.
+
+Behind your own reverse proxy the proxy holds 80 and 443 and the stack binds a plain HTTP port on the loopback. 7881 and 7882 still have to reach the host, because voice media never goes through a proxy.
+
+On a cloud VM, open them in the provider's firewall or security group. On a Linux host running firewalld:
+
+```bash
+sudo firewall-cmd --permanent --add-service=ssh
+sudo firewall-cmd --permanent --add-port=80/tcp --add-port=443/tcp --add-port=443/udp \
+ --add-port=7881/tcp --add-port=7882/udp
+sudo firewall-cmd --reload
+```
+
+## Step 4: Bring up the instance
+
+The installer downloads the stack files, writes `.env`, generates every secret, and starts the instance. Download it, check the published SHA-256, read it, then run it.
+
+On Linux:
+
+```bash
+curl -fsSLO https://fluxer.dev/install.sh
+curl -fsSLO https://fluxer.dev/install.sh.sha256
+sha256sum -c install.sh.sha256
+```
+
+On macOS, the same two downloads and:
+
+```bash
+shasum -a 256 -c install.sh.sha256
+```
+
+On Windows:
+
+```powershell
+Invoke-WebRequest https://fluxer.dev/install.ps1 -OutFile install.ps1
+Invoke-WebRequest https://fluxer.dev/install.ps1.sha256 -OutFile install.ps1.sha256
+(Get-FileHash .\install.ps1 -Algorithm SHA256).Hash -eq (Get-Content .\install.ps1.sha256).Split()[0]
+```
+
+`install.sh: OK` on Linux and macOS, and `True` on Windows, mean the file arrived whole. The digest of the copy this site serves is for `install.sh` and for `install.ps1`.
+
+Read the script before you run it. It writes `.env`, which holds every secret the instance has.
+
+The run below uses the bundled TLS. Add `--tls proxy` when something else terminates TLS, and read [Behind your own reverse proxy](/operator/reverse-proxy/) first.
+
+Then run it. The hostname is the one from Step 2, and `--email` becomes the contact address on outgoing web push:
+
+```bash
+sh install.sh --domain chat.example.com --email you@example.com
+```
+
+On Windows:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File .\install.ps1 -Domain chat.example.com -Email you@example.com
+```
+
+The run prints one line per phase and ends with the URL to open. It takes several minutes, most of it spent pulling eighteen images.
+
+`~/fluxer` then holds `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml`, `Caddyfile` and `.env.example`, plus a `.env` of 20 lines readable only by you. Every value that ships as `CHANGE_ME` in `.env.example` is a fresh random value. Every command from here on runs in that directory.
+
+### Installer flags
+
+| Flag | Meaning |
+| --- | --- |
+| `--domain ` | Hostname the instance answers on. Prompted when absent |
+| `--email ` | Contact address written as `FLUXER_VAPID_EMAIL`. Prompted when absent |
+| `--dir ` | Working directory. Default `~/fluxer` |
+| `--ref ` | Ref the stack files come from. Defaults to the image tag, and to `main` when that tag is `v1` or `latest` |
+| `--image-tag ` | Image tag the stack runs. Default `v1` |
+| `--tls ` | `bundled` or `proxy`. Default `bundled` |
+| `--edge-bind ` | Plain HTTP bind under `--tls proxy`. Default `127.0.0.1:8080` |
+| `--non-interactive` | Never prompt. A missing required value is an error |
+| `--dry-run` | Print the plan. Write nothing and start nothing |
+| `--no-start` | Write everything and skip `docker compose up -d` |
+| `--update` | Upgrade: record, back up, refresh, pull, recreate, verify |
+| `--rollback` | Put back the images and stack files of the newest record |
+| `--allow-root` | Permit running as root |
+
+Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
+
+### The script is the reference
+
+Each step in the script has a comment beside the code. The comment holds the command that does that step alone, and the reason the step exists: the download URL for every stack file, how `.env` is built from `.env.example`, what each `CHANGE_ME` takes, and how the VAPID pair is derived. Read it at [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or the Windows script at [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1).
+
+## Step 5: Check that it works
+
+Watch the stack settle:
+
+```bash
+cd ~/fluxer
+docker compose ps
+docker compose logs -f api
+```
+
+Every service reads `running` or `healthy` except `seaweedfs-init`, which creates the five upload buckets and then reads `exited (0)`.
+
+Then the public probes. Replace the hostname with your own:
+
+```bash
+base=https://chat.example.com
+for p in /_health /api/_health /gateway/_health /media/_health /.well-known/fluxer /; do
+ printf '%s %s\n' "$(curl -sS -o /dev/null -w '%{http_code}' "$base$p")" "$p"
+done
+```
+
+On Windows:
+
+```powershell
+$base = 'https://chat.example.com'
+foreach ($p in '/_health', '/api/_health', '/gateway/_health', '/media/_health', '/.well-known/fluxer', '/') {
+ '{0} {1}' -f (Invoke-WebRequest "$base$p" -UseBasicParsing).StatusCode, $p
+}
+```
+
+All six return 200. `/.well-known/fluxer` lists the endpoints clients use, with your own hostname in them.
+
+
+
+## Step 6: Create the first admin
+
+Open the hostname in a browser. A fresh instance is unconfigured, so it serves the setup wizard.
+
+The wizard runs in two halves. Before you are signed in it walks a welcome step, a theme choice, an admin introduction, and an account form. After that account exists it walks branding, registration mode, community policy and media expiry, then the GIF, YouTube, captcha, email and Bluesky integrations, then the service toggles, the premium model and a finish step. Finishing marks the instance configured and takes the wizard down.
+
+Create the owner account with an email address at a domain you control. On a self-hosted instance the first registration that supplies an email address receives the wildcard admin ACL, which is full access to the admin dashboard, unless registration is set to hold new accounts for approval. Finishing the wizard also grants the wildcard ACL to the account that completes it when that account holds no admin ACL yet.
+
+`.env.example` ships `FLUXER_EMAIL_ENABLED=false`, which marks every address verified at creation and sends no mail at all. Nothing goes out by mail, so a forgotten owner password has no email reset. Record it, and register a passkey or a second admin account before you open registration.
+
+Then sign in to the admin dashboard at `https://chat.example.com/admin` with the account holding the wildcard ACL. The **Instance Config** page has everything the wizard asked, plus registration mode, approvals and integration keys. **Limit Config** holds the instance limits published to clients. **Voice Regions** and **Voice Servers** come seeded, so voice needs no setup there.
+
+The desktop client opens the hosted web app for its release channel, so reach your instance through the web app. [Issue #1088](https://github.com/fluxerapp/fluxer/issues/1088) tracks progress.
+
+[Configuration](/operator/configuration/) lists every runtime setting and the environment variable each one overrides. [Deployment availability](/http-api/deployment-availability/) lists the routes that exist only on the hosted deployment.
+
+## Backups
+
+An instance comes back from two artifacts and its `.env`: a dump of the database, which covers `postgres-data`, and a tarball of `seaweedfs-data`, which holds every upload, avatar, report and harvest. [Volumes and buckets](/operator/configuration/#volumes-and-buckets) lists all seven volumes with what each one holds.
+
+The dump costs no downtime, so run it on a schedule while the stack serves:
+
+```bash
+cd ~/fluxer
+mkdir -p backups
+docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom \
+ > "backups/fluxer-$(date -u +%Y%m%dT%H%M%SZ).dump"
+```
+
+PowerShell writes UTF-16 through `>`, which corrupts a binary dump. On Windows, write the dump inside the container and copy it out:
+
+```powershell
+mkdir -Force backups
+docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom -f /tmp/fluxer.dump
+docker compose cp postgres:/tmp/fluxer.dump backups/fluxer.dump
+docker compose exec -T postgres rm /tmp/fluxer.dump
+```
+
+The uploads copy stops the stack, because a live copy can catch a file mid-write. Compose prefixes volume names with the project name, which the stack sets to `fluxer`:
+
+```bash
+docker compose stop
+docker run --rm -v fluxer_seaweedfs-data:/data -v "$PWD/backups:/backup" alpine:3.22 \
+ tar czf /backup/seaweedfs-data.tgz -C /data .
+docker compose up -d
+```
+
+In PowerShell write `${PWD}` in place of `$PWD`.
+
+A dump and a tarball in `backups`, and every service back to `running`, is the success signal.
+
+`sh install.sh --update` takes both before every upgrade. [What the backup covers](/operator/upgrading/#what-the-backup-covers) puts the dump on a nightly timer, and [Restore a backup](/operator/upgrading/#restore-a-backup) puts either artifact back.
+
+### Remove the instance
+
+Take the instance down and delete its data:
+
+```bash
+docker compose down -v
+```
+
+`-v` deletes all seven named volumes, so every account, message, upload, search index and certificate the instance holds is gone, and no `docker compose up -d` brings them back. Take the copies above first.
+
+## Upgrading
+
+The default tag `v1` tracks the latest compatible release. The same script upgrades the instance:
+
+```bash
+cd ~/fluxer
+sh install.sh --update
+```
+
+`--update` records the running images, backs up the database and the uploads, refreshes the four stack files, pulls, recreates, and verifies. It leaves every secret in `.env` untouched. On Windows it is `.\install.ps1 -Update`. Run `sh install.sh --update --dry-run` first to see the plan.
+
+[Upgrading](/operator/upgrading/) covers what the backup holds, rolling back, pinning a release and reclaiming disk.
+
+## Getting help
+
+File issues and follow development on [GitHub](https://github.com/fluxerapp/fluxer).
diff --git a/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx
new file mode 100644
index 000000000..0522cd8a4
--- /dev/null
+++ b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx
@@ -0,0 +1,490 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Behind your own reverse proxy
+description: Serving a Fluxer instance from nginx, Caddy, Traefik, HAProxy, Apache, or a tunnel.
+---
+
+:::tip[Plutonium and donations fund Fluxer]
+We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
+:::
+
+A reverse proxy takes the HTTPS connection from a browser and passes each request on to Fluxer. Fluxer runs behind any proxy that terminates TLS and forwards WebSocket upgrades.
+
+The stack ships Caddy and runs it by default on `80` and `443`. One extra Compose file stops that and publishes a single plain HTTP port instead. That port already routes every path to the right internal service, so the proxy in front needs one routing rule: send everything to it.
+
+## Enable the overlay
+
+`docker-compose.proxy.yml` ships next to `docker-compose.yml` and layers on top of it. Pass both files on every command:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
+```
+
+Or set `COMPOSE_FILE` in `.env` once and keep typing plain `docker compose` commands:
+
+```ini
+COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
+```
+
+On a first install, `sh install.sh --tls proxy` writes that line and `FLUXER_EDGE_BIND` into `.env` for you, so there is nothing to add by hand. [Installer flags](/operator/get-started/#installer-flags) lists it with the rest.
+
+The port binds `127.0.0.1:8080` by default. `FLUXER_EDGE_BIND` sets both the interface and the host port. Move the port when something else on the host already holds `8080`:
+
+```ini
+FLUXER_EDGE_BIND=127.0.0.1:8081
+```
+
+The container side stays `8080` whatever you set. A proxy on the host then points at `127.0.0.1:8081`, and a proxy on the stack's own Docker network points at `edge:8080`. Bind a routable address only when the proxy runs on another machine, and firewall the port to that machine:
+
+```ini
+FLUXER_EDGE_BIND=0.0.0.0:8080
+```
+
+Confirm the port answers before you configure anything in front of it:
+
+```bash
+curl -i http://127.0.0.1:8080/_health
+```
+
+`/_health` returns `200 OK` from the edge itself without reaching an upstream, so it is the health check to give your proxy.
+
+:::note[There is nothing to configure inside the instance]
+The routing lives in a config file inside the stack. Point your proxy at the port and leave that file alone.
+:::
+
+## What every proxy must do
+
+#### Terminate TLS and serve the hostname over HTTPS
+
+With `FLUXER_PUBLIC_SCHEME=https` the admin CSRF cookie is `__Host-csrf_token`, which browsers accept only over HTTPS.
+
+#### Forward WebSocket upgrades
+
+The client connection is on `/gateway` and voice signalling is on `/livekit/*`. Both open as WebSocket upgrades. Check that the handshake completes through whatever is in front of the instance, with your own hostname in place of the one below:
+
+```bash
+curl -sS -i --http1.1 --max-time 5 \
+ -H 'Connection: Upgrade' \
+ -H 'Upgrade: websocket' \
+ -H 'Sec-WebSocket-Version: 13' \
+ -H 'Sec-WebSocket-Key: AAAAAAAAAAAAAAAAAAAAAA==' \
+ 'https://chat.example.com/gateway?v=1&encoding=json' | head -1
+```
+
+The answer is `HTTP/1.1 101 Switching Protocols`. Confirm a real client connects as well, which covers the whole path including the query string.
+
+#### Replace `X-Forwarded-For` with the real client address
+
+Set the header from the connection address and discard whatever the visitor sent. Rate limits, IP bans and abuse detection all apply to the address that arrives in it.
+
+#### Accept a request body above the attachment limit
+
+Uploads pass through the proxy. Raise any default body limit, such as nginx's 1 MB, above the instance attachment limit.
+
+#### Hold a connection open for an hour
+
+The Gateway socket stays open for the life of a client session, so give idle and read timeouts an hour.
+
+#### Pass `Sec-Fetch-Site` through untouched
+
+The browser sends it, and the admin dashboard refuses a mutating request with a cross-site value.
+
+#### Send no `Content-Security-Policy` of its own
+
+The instance sends its own policy, and its nonce is what lets the web app boot. Browsers enforce every policy they receive.
+
+Every worked configuration below answers all seven.
+
+## The public address
+
+Three variables tell the instance the address browsers use. Fluxer reads neither `X-Forwarded-Proto` nor `X-Forwarded-Host`, so you keep these correct by hand:
+
+```ini
+FLUXER_DOMAIN=chat.example.com
+FLUXER_PUBLIC_SCHEME=https
+FLUXER_PUBLIC_PORT=443
+```
+
+They stay `https` on `443` even though the instance itself speaks plain HTTP on `8080`. Clients read every base URL from the discovery document the API builds out of these values.
+
+Serving on a port other than `443` also needs `FLUXER_PUBLIC_ORIGIN`. Several endpoints are built from the scheme and the domain alone, and the port is lost without it:
+
+```ini
+FLUXER_PUBLIC_ORIGIN=https://chat.example.com:8443
+```
+
+## nginx
+
+The `map` block makes `Connection` follow `Upgrade`. The snippet raises the body limit and the read timeout above nginx's own 1 MB and 60 seconds.
+
+```nginx
+map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+}
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name chat.example.com;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name chat.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
+
+ client_max_body_size 512m;
+
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+
+ proxy_set_header Host $host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_set_header X-Forwarded-For $remote_addr;
+
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_request_buffering off;
+ proxy_buffering off;
+ }
+}
+```
+
+Set `X-Forwarded-For` from `$remote_addr`. `$proxy_add_x_forwarded_for` appends whatever the client sent, which puts an attacker-chosen address in front of the real one.
+
+## Caddy
+
+An external Caddy needs one site block. It forwards WebSocket upgrades natively, requests the certificate itself, and applies no request body limit and no read timeout of its own.
+
+```caddy
+chat.example.com {
+ reverse_proxy 127.0.0.1:8080 {
+ header_up X-Forwarded-For {client_ip}
+ }
+}
+```
+
+`{client_ip}` resolves to the peer address unless the peer is in this Caddy's own `trusted_proxies`. The `header_up` line replaces `X-Forwarded-For` with that one address.
+
+## Traefik
+
+Traefik forwards WebSocket upgrades natively, and by default it strips inbound `X-Forwarded-*` headers from untrusted clients and writes its own. Leave `forwardedHeaders` alone unless another proxy sits in front of Traefik.
+
+### File provider
+
+Static configuration:
+
+```yaml
+entryPoints:
+ web:
+ address: ":80"
+ http:
+ redirections:
+ entryPoint:
+ to: websecure
+ scheme: https
+ websecure:
+ address: ":443"
+ transport:
+ respondingTimeouts:
+ readTimeout: 0s
+ idleTimeout: 3600s
+
+providers:
+ file:
+ filename: /etc/traefik/dynamic.yml
+
+certificatesResolvers:
+ letsencrypt:
+ acme:
+ email: admin@example.com
+ storage: /etc/traefik/acme.json
+ httpChallenge:
+ entryPoint: web
+```
+
+`readTimeout` defaults to 60 seconds and covers reading the whole request including the body, so `0s` removes the bound and lets a large upload finish. `idleTimeout` defaults to 180 seconds, so raise it to an hour to hold the Gateway socket open.
+
+Dynamic configuration in `/etc/traefik/dynamic.yml`:
+
+```yaml
+http:
+ routers:
+ fluxer:
+ rule: Host(`chat.example.com`)
+ entryPoints:
+ - websecure
+ service: fluxer
+ tls:
+ certResolver: letsencrypt
+
+ services:
+ fluxer:
+ loadBalancer:
+ passHostHeader: true
+ servers:
+ - url: http://127.0.0.1:8080
+```
+
+### Docker labels
+
+Traefik reaches the container over a shared Docker network, so the published port is not used. Keep the overlay applied anyway, or the instance takes `80` and `443` away from Traefik.
+
+Put this in `traefik.compose.yml` next to the stack:
+
+```yaml
+services:
+ edge:
+ networks:
+ - fluxer
+ - traefik
+ labels:
+ traefik.enable: "true"
+ traefik.docker.network: traefik
+ traefik.http.routers.fluxer.rule: Host(`chat.example.com`)
+ traefik.http.routers.fluxer.entrypoints: websecure
+ traefik.http.routers.fluxer.tls.certresolver: letsencrypt
+ traefik.http.services.fluxer.loadbalancer.server.port: "8080"
+
+networks:
+ traefik:
+ external: true
+```
+
+Then list all three files so every command picks them up:
+
+```ini
+COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:traefik.compose.yml
+```
+
+## HAProxy
+
+One frontend, one backend, and four timeouts:
+
+```haproxy
+defaults
+ mode http
+ log stdout format raw local0
+ option httplog
+ timeout connect 5s
+ timeout client 1h
+ timeout server 1h
+ timeout tunnel 1h
+
+frontend fluxer
+ bind :80
+ bind :443 ssl crt /etc/haproxy/certs/chat.example.com.pem alpn h2,http/1.1
+ http-request redirect scheme https unless { ssl_fc }
+ http-request set-header X-Forwarded-For %[src]
+ default_backend fluxer_edge
+
+backend fluxer_edge
+ server edge 127.0.0.1:8080
+```
+
+`timeout client` and `timeout server` stop applying once a connection is upgraded, so `timeout tunnel` is what keeps the Gateway socket and LiveKit signalling alive. `set-header` replaces any header the client sent. `option forwardfor` appends to it. Give the frontend and the backend different names. HAProxy warns on a shared name and drops support for it in 3.3.
+
+## Apache httpd
+
+This configuration needs three modules: `mod_proxy`, `mod_proxy_http`, and `mod_headers`. Since 2.4.47 `mod_proxy_http` handles the WebSocket upgrade itself, so `mod_proxy_wstunnel` is not needed.
+
+```apache
+
+ ServerName chat.example.com
+
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/chat.example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/chat.example.com/privkey.pem
+
+ LimitRequestBody 536870912
+ ProxyPreserveHost On
+ ProxyTimeout 3600
+
+ RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
+
+ ProxyPass / http://127.0.0.1:8080/ upgrade=websocket timeout=3600
+ ProxyPassReverse / http://127.0.0.1:8080/
+
+```
+
+`upgrade=websocket` is the whole WebSocket configuration on 2.4.47 and later.
+
+`RequestHeader set` replaces whatever the client sent, and the quotes around `"expr=%{REMOTE_ADDR}"` are required by the expression parser. `ProxyAddHeaders` is on by default and appends the connection address after that value. The edge reads the real address either way.
+
+`LimitRequestBody` is 0 by default, which means no limit, but distribution packages and `` blocks often set it lower. The 536870912 above is 512 MiB. `ProxyTimeout` and the `timeout=3600` parameter both bound the upstream connection, and an hour covers the Gateway socket.
+
+## Cloudflare Tunnel
+
+One ingress rule for the hostname, with the catch-all `cloudflared` requires under it:
+
+```yaml
+tunnel: fluxer
+credentials-file: /etc/cloudflared/fluxer.json
+
+ingress:
+ - hostname: chat.example.com
+ service: http://127.0.0.1:8080
+ originRequest:
+ connectTimeout: 30s
+ - service: http_status:404
+```
+
+When `cloudflared` runs as a container on the stack's Docker network, point it at `http://edge:8080`. The published port is then never used. Compose prefixes the network name with the project name, which the stack sets to `fluxer`, so the network is `fluxer_fluxer` and an external declaration names it in full:
+
+```yaml
+networks:
+ fluxer:
+ external: true
+ name: fluxer_fluxer
+```
+
+Cloudflare appends the visitor address to any `X-Forwarded-For` the visitor sent, so the header can arrive with a forged value in front of the real one. The edge reads the rightmost address that is not in `FLUXER_EDGE_TRUSTED_PROXIES`, which is the one Cloudflare appended. A Transform Rule that sets `X-Forwarded-For` to `cf.connecting_ip` removes the ambiguity outright.
+
+Cloudflare caps a request body at 100 MB on Free and Pro, 200 MB on Business, and 500 MB on Enterprise, and answers 413 above the cap. The cap applies to tunnel traffic, and no `cloudflared` setting raises it. Keep the [`max_attachment_file_size`](/http-api/instance/#limit-keys) limit below the cap for your plan, in the admin dashboard under limit configuration. It defaults to 26214400 bytes for a non-premium account and 524288000 bytes for a premium one, which is above every cap below Enterprise.
+
+A tunnel has the web app, API, Gateway, admin dashboard, media routes, and LiveKit signalling. It has no LiveKit media.
+
+## Nginx Proxy Manager
+
+Add a proxy host with these values:
+
+| Field | Value |
+| --- | --- |
+| Domain Names | The hostname `chat.example.com` |
+| Scheme | Plain `http` |
+| Forward Hostname / IP | `127.0.0.1`, or the `edge` container when both run in Docker |
+| Forward Port | Port `8080` |
+| Websockets Support | On, and required |
+| Block Common Exploits | Off, because its request filtering rejects legitimate API traffic |
+| SSL | Request a certificate, then turn on Force SSL and HTTP/2 Support |
+
+In the Advanced tab:
+
+```nginx
+client_max_body_size 512m;
+proxy_read_timeout 3600s;
+proxy_send_timeout 3600s;
+```
+
+Nginx Proxy Manager appends to `X-Forwarded-For`, and the visitor's own value arrives in front of the real address. The edge takes the rightmost entry that is not in `FLUXER_EDGE_TRUSTED_PROXIES`, which is the one Nginx Proxy Manager appended. Users on a LAN or a VPN inside the default `private_ranges` are the exception and resolve to the forged entry, so narrow `FLUXER_EDGE_TRUSTED_PROXIES` to the proxy's address in that layout.
+
+## What the single port routes
+
+The instance rewrites paths before handing them to an upstream. Your proxy must not rewrite anything itself.
+
+#### `/_health`
+
+No upstream. Answered by the edge itself.
+
+#### `/gateway?v=1&encoding=json&compress=zstd-stream&stream=1`
+
+Goes to `gateway:8080` and reaches the upstream as `/?v=1&encoding=json&compress=zstd-stream&stream=1`.
+
+#### `/gateway/foo`
+
+Goes to `gateway:8080` and reaches the upstream as `/foo`.
+
+#### `/api/v1/users/@me`
+
+Goes to `api:8080` and reaches the upstream as `/v1/users/@me`.
+
+#### `/media/attachments/1/2/a.png`
+
+Goes to `media-proxy:8080` and reaches the upstream as `/attachments/1/2/a.png`.
+
+#### `/livekit/rtc/v1`
+
+Goes to `livekit:7880` and reaches the upstream as `/rtc/v1`.
+
+#### `/admin`
+
+Goes to `admin:8080` and reaches the upstream as `/`.
+
+#### `/admin/users`
+
+Goes to `admin:8080` and reaches the upstream as `/users`.
+
+#### Static asset paths
+
+`/web/*`, `/emoji/*`, `/libs/*`, `/avatars/*`, `/badges/*`, `/desktop/*` and `/embeds/*` go to `static-proxy:8080` unchanged.
+
+#### `/.well-known/fluxer`
+
+Goes to `api:8080` unchanged.
+
+#### Apple app site association
+
+`/.well-known/apple-app-site-association`, and `/apple-app-site-association` for older iOS, go to `app-proxy:8080` unchanged.
+
+#### `/.well-known/assetlinks.json`
+
+Goes to `app-proxy:8080` unchanged.
+
+#### `/version.json`
+
+Goes to `app-proxy:8080` unchanged.
+
+#### Anything else
+
+Goes to `app-proxy:8080` unchanged.
+
+Pass the query string on `/gateway` through untouched. Clients always send `?v=`, `?encoding=`, `?compress=` and `?stream=`, and `1` is the only version the Gateway accepts.
+
+Apple and Google fetch the two association files themselves at those fixed paths, for saved-password autofill in the iOS apps and link handling in the Android ones. The last entry already has all four, so a proxy that forwards `/` needs no extra rule. A proxy that forwards a named path allowlist has to list these four paths along with everything else that entry covers.
+
+`/_metrics` on the API, Media Proxy, and Gateway, plus `/_health/ready`, `/_health/drain`, and `/_health/undrain` on the Gateway, are gated to loopback and are unreachable through any proxy. The probes that work through a proxy are `/_health`, `/api/_health`, `/gateway/_health`, and `/media/_health`.
+
+## Trusted proxies
+
+`FLUXER_EDGE_TRUSTED_PROXIES` names the peer addresses whose `X-Forwarded-For` the edge believes. It defaults to `private_ranges`, which Caddy expands to `192.168.0.0/16`, `172.16.0.0/12`, `10.0.0.0/8`, `127.0.0.1/8`, `fd00::/8`, and `::1`, so a proxy on the same host or the same Docker network needs no change.
+
+The edge resolves one client address per request and rewrites `X-Forwarded-For` to it on every upstream hop, so no service ever reads what a visitor sent. A peer outside the list becomes the client address, and the edge discards the header it sent. A peer inside the list contributes the rightmost header entry that is not itself trusted, and a proxy that appends still delivers the real caller.
+
+A proxy that reaches the instance from a public address is not trusted, so the edge discards its header and attributes every request to the proxy itself. That puts all your users in one rate limit bucket and one geolocation. Add the address, and list `private_ranges` too if you still need it:
+
+```ini
+FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 203.0.113.10/32
+```
+
+`private_ranges` does not cover `100.64.0.0/10`, so a proxy reaching the instance over a Tailscale or other CGNAT tailnet address is untrusted and collapses every visitor onto that one address. Name the tailnet address of the proxy:
+
+```ini
+FLUXER_EDGE_TRUSTED_PROXIES=private_ranges 100.101.102.103/32
+```
+
+The edge believes a trusted peer without question. Anyone who can open a TCP connection from a trusted address can set `X-Forwarded-For` to any value and be recorded as that address, which defeats bans and rate limits and pollutes abuse detection. Keep the list down to the one address your proxy arrives from. That is also the only correct list on a LAN or VPN deployment, where a visitor whose own address falls inside a wider list is recorded under the proxy's address.
+
+Find the address the edge sees a host-side proxy arrive from:
+
+```bash
+docker network inspect fluxer_fluxer -f '{{(index .IPAM.Config 0).Gateway}}'
+```
+
+The edge reads the variable at container start, so apply a change with `docker compose up -d`.
+
+:::caution[Keep the plain HTTP port off the LAN]
+`private_ranges` trusts the whole LAN. With `FLUXER_EDGE_BIND=0.0.0.0:8080`, any host on that network can impersonate any client address. Firewall the port to the proxy host.
+:::
+
+## Voice media does not use the proxy
+
+LiveKit signalling goes through `/livekit/*` like everything else. WebRTC media does not touch the proxy at all:
+
+- `7882/udp` is media.
+- `7881/tcp` is the fallback when UDP is blocked.
+
+Both ports are published directly by the stack and must reach the host. A proxy or tunnel in front of `443` does nothing for them.
+
+Hosting LiveKit on a hostname other than `FLUXER_DOMAIN` means widening the Content-Security-Policy the web app runs under:
+
+```ini
+FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
+```
diff --git a/fluxer_docs/src/content/docs/operator/upgrading.mdx b/fluxer_docs/src/content/docs/operator/upgrading.mdx
new file mode 100644
index 000000000..2460cbeb2
--- /dev/null
+++ b/fluxer_docs/src/content/docs/operator/upgrading.mdx
@@ -0,0 +1,237 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Upgrading
+description: Upgrading and rolling back an instance with the installer, what the backup covers, and how to restore it.
+---
+
+:::tip[Plutonium and donations fund Fluxer]
+We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
+:::
+
+An upgrade moves an instance to a newer release. `install.sh --update` does all of it: back up, download the new stack files, pull the new images, recreate the containers that changed. The stack files are the three compose files, the `Caddyfile` and `.env.example`. Allow about twenty minutes, most of it the backup and the pull.
+
+| Requirement | Value |
+| --- | --- |
+| Working directory | The one holding `.env`, `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml` and `Caddyfile` |
+| Compose commands | Behind your own reverse proxy, set `COMPOSE_FILE` in `.env` once and every command picks up the overlay |
+| Free disk | Room for one database dump, one copy of the uploads, and the new images beside the old ones |
+| Downtime | A minute or two for the recreate, plus however long the uploads copy takes with the stack stopped |
+
+Read the release notes between the version you run and the one you are moving to, at [Releases](https://github.com/fluxerapp/fluxer/releases).
+
+## Match the images to the stack files
+
+The images come from `FLUXER_IMAGE_TAG` in `.env`. The stack files come from a git ref, which is a branch or a tag in the Fluxer repository. The installer derives that ref from the image tag: `main` when the tag is `v1` or `latest`, and the tag string itself for anything else. `--ref` overrides the derivation. `docker compose pull` never updates a stack file, and refreshing a stack file never moves an image.
+
+The repository holds no ref by the name of a pinned image tag. A release tags each image on its own, as `fluxer-api@2026.813.205040`, so pass `--ref` with that tag or with the commit it points at. Without that override the download fails with exit 4.
+
+## What the script does
+
+`--update` runs these steps in this order, and stops on the first one that fails:
+
+| Step | What it does |
+| --- | --- |
+| Record | Writes the image references, the image ID each container has, and the tag `.env` names, into a new record directory |
+| Save | Copies `.env` and the five stack files into that record |
+| Dump | Dumps the database with the stack still serving, and checks the custom-format header on the result |
+| Copy | Stops the stack, copies the uploads volume, and starts it again on the images it was already running |
+| Fetch | Downloads the stack files at the ref into a staging directory inside the working directory |
+| Guard | Refuses when the refreshed `docker-compose.yml` moves Postgres to a new major version |
+| Place | Moves every downloaded file into the working directory at once |
+| Pull | Pulls the images while the old containers still serve |
+| Recreate | Runs `docker compose up -d --remove-orphans`, then restarts `edge` when the `Caddyfile` changed |
+| Verify | Polls Compose state until every service is ready, then probes `/_health` on the hostname |
+
+A failed run leaves the instance running on the images it already had.
+
+`docker compose up -d` does not notice a changed `Caddyfile`. The script restarts `edge` by name to pick it up.
+
+The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and the two publish the same host ports. `--remove-orphans` takes the old container down in the same call, so the new one can bind them. Without it the step stops with `Bind for 0.0.0.0:443 failed`. It also removes any container in the project whose service the loaded Compose files no longer define, so keep `COMPOSE_FILE` the same across an upgrade.
+
+The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data` and `edge-config`, so the old two are left unused and the edge requests its certificate again on the first start.
+
+`FLUXER_IMAGE_TAG` is the only line in `.env` that either upgrade mode writes. Every secret is left as it is.
+
+The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer has no such line, so add `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` to it before you upgrade. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`.
+
+## The script is the reference
+
+Every step above sits in the script beside a comment holding the command that does that step alone and the reason the step exists. Read [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1) for Windows.
+
+## Run the upgrade
+
+Keep the installer beside the stack files. [Get started](/operator/get-started/#step-4-bring-up-the-instance) has the download and the checksum check for a fresh copy.
+
+See the plan first:
+
+```bash
+cd ~/fluxer
+sh install.sh --update --dry-run
+```
+
+It prints the images the stack runs now, the backup it intends to take, which stack files the ref changes, which services that makes it restart, and any refusal that would stop the run. It writes nothing outside a temporary directory it removes on exit.
+
+Then run it:
+
+```bash
+sh install.sh --update
+```
+
+On Windows:
+
+```powershell
+.\install.ps1 -Update
+```
+
+The run ends by naming the record it wrote and the command that rolls back to it. It checks its own work with the readiness poll and one probe of `/_health`. Run the public probes in [Check that it works](/operator/get-started/#step-5-check-that-it-works) afterwards.
+
+| Flag | Windows | Meaning |
+| --- | --- | --- |
+| `--update` | `-Update` | Record, back up, refresh, pull, recreate, verify |
+| `--rollback` | `-Rollback` | Put back the images and stack files of the newest record |
+| `--dry-run` | `-DryRun` | Print the plan. Change nothing |
+| `--backup-dir ` | `-BackupDir` | Where records go. Default `/backups` |
+| `--no-volume-backup` | `-NoVolumeBackup` | Take the database dump and skip the uploads copy |
+| `--skip-backup-accept-data-loss` | `-SkipBackupAcceptDataLoss` | Upgrade with no backup at all |
+
+`--dir` and `--ref` mean what they mean on an install, and the installer derives an omitted `--ref` from the `FLUXER_IMAGE_TAG` line in `.env`. Every failure prints a sentence on standard error and exits non-zero, and the script header lists what each code means. A Postgres major version change is exit 3.
+
+## What the backup covers
+
+Each upgrade writes one record directory, named `record-` and a UTC stamp, under `backups` or wherever `--backup-dir` points. It holds:
+
+| Artifact | What it covers |
+| --- | --- |
+| `fluxer.dump` | Every account, message, guild and configuration row, in the Postgres custom format |
+| `seaweedfs-data.tgz` | Every upload, avatar, report and harvest |
+| `.env` | Every secret the instance was built with |
+
+The five stack files sit in the record beside those three, so a rollback puts back the exact files the instance was running. The record directory is created `0700` and the `.env` copy inside it is `0600`. Keep records wherever you already keep secrets.
+
+The dump costs no downtime. The uploads copy stops the stack, and the script starts the stack again on the old images before it goes any further.
+
+Nothing else is copied. The dump covers `postgres-data`. The other five volumes either rebuild themselves or hold queued work, and [Volumes and buckets](/operator/configuration/#volumes-and-buckets) lists all seven with what each one holds.
+
+Put a dump on a timer as well. On Linux and macOS, this crontab line writes one a night and keeps two weeks of them:
+
+```cron
+15 3 * * * cd /srv/fluxer && docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom > "backups/fluxer-$(date -u +\%Y\%m\%dT\%H\%M\%SZ).dump" && find backups -name 'fluxer-*.dump' -mtime +14 -delete
+```
+
+`/srv/fluxer` stands for the directory holding `.env`. Write it as an absolute path, because cron does not expand `~`. An unescaped `%` in a crontab is a newline, which is why every one of them has a backslash.
+
+## Roll back
+
+A rollback puts the previous release back:
+
+```bash
+cd ~/fluxer
+sh install.sh --rollback
+```
+
+On Windows it is `.\install.ps1 -Rollback`. It takes the newest record, puts back the images and the stack files it holds, recreates, restarts `edge`, and verifies. `--dry-run` prints that plan too.
+
+A rollback never pulls, so it needs the old images still on the host. Run [Reclaim disk](#reclaim-disk) once an upgrade satisfies you and not before.
+
+The database does not move. `api`, `worker`, `users-shard` and `messages-shard` apply schema work in place while they start, and an older image does not undo it. Across a release that changed the schema, the dump in the record is the only way back, and putting it back is a decision you make.
+
+## Restore a backup
+
+Both artifacts go back with the stack stopped, so nothing writes while they are replaced. Name your own record directory in place of the one below.
+
+The database restores from the custom-format dump:
+
+```bash
+docker compose stop
+docker compose up -d --wait postgres
+docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists \
+ < backups/record-20260831T120000Z/fluxer.dump
+docker compose up -d
+```
+
+`--clean` prints notices about objects that do not exist yet, which is expected against a fresh directory.
+
+PowerShell has no `<` redirection. On Windows, copy the dump into the container and name it as a file:
+
+```powershell
+docker compose stop
+docker compose up -d --wait postgres
+docker compose cp backups\record-20260831T120000Z\fluxer.dump postgres:/tmp/fluxer.dump
+docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists /tmp/fluxer.dump
+docker compose exec -T postgres rm /tmp/fluxer.dump
+docker compose up -d
+```
+
+The uploads restore through the same helper container the backup used, in reverse:
+
+```bash
+docker compose stop
+docker run --rm -v fluxer_seaweedfs-data:/data \
+ -v "$PWD/backups/record-20260831T120000Z:/backup" alpine:3.22 \
+ sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/seaweedfs-data.tgz -C /data'
+docker compose up -d
+```
+
+`tar` extracts over whatever is already on the volume, so the `find` empties it first. In PowerShell write `${PWD}` in place of `$PWD`. Success is an existing attachment URL answering 200 again. The `fluxer_` prefix is the Compose project name, which `docker-compose.yml` sets to `fluxer`.
+
+## Move to a new Postgres major version
+
+`docker-compose.yml` pins `postgres:16-alpine`. A newer major does not read the data directory an older major wrote, so the data moves across through a dump. The upgrade refuses when the refreshed compose file changes that pin, because the move destroys the volume holding the database.
+
+Take the dump while the old major is still serving, then stop everything and drop the volume:
+
+```bash
+docker compose exec -T postgres pg_dump -U fluxer -d fluxer --format=custom \
+ > backups/pre-major.dump
+docker compose down
+docker volume rm fluxer_postgres-data
+```
+
+On Windows, take that dump the way [Restore a backup](#restore-a-backup) moves one, with `-f /tmp/pre-major.dump` and `docker compose cp`, and restore it the same way.
+
+Once that volume is removed, the dump is the only copy of the database.
+
+Put the new `postgres` tag in `docker-compose.yml`, start the database on its own, and restore into the empty directory:
+
+```bash
+docker compose up -d --wait postgres
+docker compose exec -T postgres pg_restore -U fluxer -d fluxer --clean --if-exists \
+ < backups/pre-major.dump
+docker compose up -d
+```
+
+Run `sh install.sh --update` afterwards to finish the move on the refreshed files.
+
+## Pin a release
+
+`FLUXER_IMAGE_TAG=v1` tracks the latest compatible release, so every upgrade can move the instance. Set it to a release tag to hold one version and upgrade on your own schedule.
+
+A pinned instance also pins its stack files. [Match the images to the stack files](#match-the-images-to-the-stack-files) has the `--ref` a pinned tag needs.
+
+The tag applies only to the eleven Fluxer images. `caddy`, `postgres`, `valkey`, `nats`, `meilisearch`, `seaweedfs`, and `livekit` are pinned inside `docker-compose.yml` and move only when an upgrade refreshes that file.
+
+## Change the domain or the passkey relying party
+
+`FLUXER_DOMAIN` supplies the default `FLUXER_PASSKEY_RP_ID`, which is the domain a passkey is tied to. A passkey works only under the identifier it was created with.
+
+:::danger[Changing either value invalidates every passkey]
+Passkeys registered under the old `FLUXER_PASSKEY_RP_ID` stop working and cannot be recovered. Every user has to register a new one, so keep another sign-in method working before you change it.
+:::
+
+Changing `FLUXER_DOMAIN` also changes the certificate the edge requests, the CORS origins the API accepts, and the admin OAuth redirect URI.
+
+## Reclaim disk
+
+Each upgrade leaves the previous images behind, which adds up to a few gigabytes over time:
+
+```bash
+docker image prune -f
+```
+
+On the default moving tag, the pull moves `v1` onto the new images and leaves the ones it replaced untagged, so this removes them. Those are the images a rollback on a moving tag needs, so prune only once the upgrade satisfies you. A pinned release keeps its own tag and survives the prune. Remove one of those by naming its tag in `docker image rm`.
+
+`docker system prune -a` reclaims more. It also deletes every image that no container references, including ones unrelated to Fluxer.
+
+:::danger[Never add -a to a volume prune]
+`docker volume prune -a` removes named volumes no container uses. After a `docker compose down` that is every volume the instance has, which is the whole instance.
+:::
diff --git a/fluxer_docs/src/content/docs/snowflakes.md b/fluxer_docs/src/content/docs/snowflakes.md
new file mode 100644
index 000000000..01de1ee15
--- /dev/null
+++ b/fluxer_docs/src/content/docs/snowflakes.md
@@ -0,0 +1,88 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Snowflakes
+description: Fluxer's 64-bit identifiers, their bit layout, wire grammar, and pagination behaviour.
+---
+
+A snowflake is a 64-bit identifier for a Fluxer resource such as a user, guild, channel, message, role, or attachment. It is the type written as `snowflake` in every wire table of this reference, and the HTTP API, the Admin API, and the Gateway all share this identifier space.
+
+A snowflake is unique within the deployment that issued it. The value stays stable, and Fluxer does not reuse it after the resource is deleted. Separate deployments can issue the same numeric value, so a client MUST scope every snowflake to its deployment.
+
+:::caution[Never parse a snowflake as a JSON number]
+Snowflake values exceed the exact integer range of a JSON double, so parsing one as a number can silently change it. A client MUST parse the string into a 64-bit integer and MUST compare snowflakes as integers, because unequal-length decimal strings do not sort as text.
+:::
+
+## Format
+
+A snowflake packs three fields into 64 bits. The worker and sequence fields distinguish identifiers minted during the same millisecond. Bit 63 is always zero, so an issued snowflake fits a signed 64-bit integer.
+
+| Field | Bits | Description |
+| --- | --- | --- |
+| Timestamp1 | 62 to 22 | The number of milliseconds since `2015-01-01T00:00:00.000Z` |
+| Worker ID | 21 to 12 | The unsigned allocator worker identifier, from `0` through `1023` |
+| Sequence2 | 11 to 0 | The unsigned per-worker sequence, from `0` through `4095` |
+
+1 The timestamp records the instant the identifier was minted, which can fall shortly before the resource exists
+
+2 Sequence order applies only within one worker and one millisecond
+
+The epoch is 1420070400000 milliseconds after the Unix epoch. Every Fluxer instance uses the same value. The lower 22 bits expose allocator details, so a client MUST NOT use them for routing or resource semantics.
+
+One worker issues strictly increasing values. It advances the sequence for each identifier minted during the same millisecond, and it waits for the next millisecond once the sequence passes 4095.
+
+A worker whose clock moves backwards keeps minting against the highest millisecond it has already used. An extracted timestamp can therefore fall later than the wall clock at the moment of minting.
+
+## Extracting a timestamp
+
+To obtain the creation time in Unix milliseconds, shift the snowflake right by 22 bits and add the epoch.
+
+```text
+timestamp_ms = (snowflake >> 22) + 1420070400000
+```
+
+The inverse expression produces the smallest possible snowflake for a Unix millisecond timestamp. It is useful as a pagination boundary.
+
+```text
+snowflake = (timestamp_ms - 1420070400000) << 22
+```
+
+The representable range runs from `2015-01-01T00:00:00.000Z` through `2084-09-06T15:47:35.551Z`.
+
+## Wire representation
+
+Fluxer emits a snowflake as an unsigned decimal string in every JSON body, path parameter, query string parameter, and Gateway payload. An emitted value contains only ASCII digits. Inbound traffic is more permissive, and a client SHOULD send the emitted form everywhere.
+
+### Accepted input
+
+| Surface | Accepted input |
+| --- | --- |
+| HTTP request1 | `0`, or a non-zero digit followed by further digits, sent as a JSON string or as a JSON integer |
+| Gateway command2 | A non-zero digit followed by further digits, sent as a string of ASCII digits or as a positive JSON integer |
+
+1 Fluxer removes leading and trailing whitespace before it reads the text. In that text a leading zero, a sign, a decimal point, and any other non-digit character are rejected with the validation code [`INVALID_SNOWFLAKE_FORMAT`](/http-api/errors/#validation-error-code-registry), and a well-formed value above 9223372036854775807 is rejected with [`SNOWFLAKE_OUT_OF_RANGE`](/http-api/errors/#validation-error-code-registry)
+
+2 The Gateway rejects `0`, a leading zero, a signed value, a negative integer, and a value above 9223372036854775807
+
+Both HTTP validation codes are element codes inside a 400 [`INVALID_FORM_BODY`](/http-api/errors/#api-error-code-registry) response. Each Gateway [command](/gateway/commands/) states what its own rejection does, from discarding the field to abandoning the whole command.
+
+A snowflake sent as a JSON number keeps its exact value at any size.
+
+A fractional JSON number is an ordinary type failure and reports [`INVALID_FORMAT`](/http-api/errors/#validation-error-code-registry). An exponent form is read as the number it denotes, and one above 9007199254740991 reports [`INVALID_SNOWFLAKE_FORMAT`](/http-api/errors/#validation-error-code-registry). A path parameter and a query string parameter always arrive as text.
+
+:::note[`0` is the beginning of snowflake time]
+An HTTP pagination cursor accepts `0`. An HTTP path parameter or field that identifies a real resource also accepts `0`, and the request then receives the ordinary not-found result for that resource.
+:::
+
+## Pagination
+
+A collection endpoint that pages over a snowflake-ordered resource accepts a cursor such as `before`, `after`, or `around`. The endpoint defines which cursors it supports, whether they are mutually exclusive, the result order, and how `limit` is applied.
+
+The `before` cursor selects identifiers lower than the cursor value, and the `after` cursor selects identifiers higher than it. Both exclude the cursor value itself. The `around` cursor selects a window centred on the cursor value and includes that value. [List channel messages](/http-api/messages/#list-channel-messages) accepts it as a query string parameter and [List messages from multiple channels](/http-api/messages/#list-messages-from-multiple-channels) accepts it on each entry of its request body. No other operation accepts it.
+
+The type of a `before` or `after` cursor follows the operation. [List pinned messages](/http-api/messages/#list-pinned-messages) pages on the pin time, so its `before` is an ISO 8601 timestamp rather than a message ID. [List blocklist entries](/admin-api/blocklists/#list-blocklist-entries) pages on the entry value, and its `after` is the stored value of the last entry on the previous page. Each operation states the type of its own cursors.
+
+Because the timestamp occupies the high bits, numeric snowflake order is creation time order at millisecond resolution.
+
+:::note[A cursor is a numeric boundary]
+A cursor does not need to identify a resource, so a snowflake derived from a timestamp can delimit a time range without a dedicated timestamp parameter.
+:::
diff --git a/fluxer_docs/src/content/docs/topics/captcha.md b/fluxer_docs/src/content/docs/topics/captcha.md
new file mode 100644
index 000000000..10f860963
--- /dev/null
+++ b/fluxer_docs/src/content/docs/topics/captcha.md
@@ -0,0 +1,87 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: CAPTCHA handling
+description: How a client discovers the CAPTCHA provider and answers a challenge.
+---
+
+An instance can require a CAPTCHA solution on a small set of abuse-sensitive operations. A client reads the selected provider and its site key from [instance discovery](/http-api/instance/#instance-discovery-object), renders that provider's widget, and sends the solution on the gated request.
+
+## Discovering the provider
+
+`GET /.well-known/fluxer` publishes the [CAPTCHA configuration object](/http-api/instance/#captcha-configuration-object) inside the [instance discovery object](/http-api/instance/#instance-discovery-object). Its `provider` field names the selected [CAPTCHA provider](/http-api/instance/#captcha-providers), and `hcaptcha_site_key` or `turnstile_site_key` is that provider's site key. The other key is null, and both are null when `provider` is `none`.
+
+The value `none` means the instance challenges no operation. A gated operation then proceeds with no CAPTCHA header. The values `hcaptcha` and `turnstile` name the provider whose widget a client renders.
+
+Fluxer names a provider only while that provider holds both a site key and a secret key. An incomplete pair reports `none`, so a named provider means the instance enforces verification.
+
+## Gated operations
+
+The following operations verify a CAPTCHA while the instance enforces verification.
+
+| Method | Route | Operation |
+| --- | --- | --- |
+| POST | /v1/auth/register | [Register an account](/http-api/authentication/#register-an-account) |
+| POST | /v1/auth/login | [Log in with a password](/http-api/authentication/#log-in-with-a-password) |
+| POST | /v1/auth/forgot | [Request password recovery](/http-api/authentication/#request-password-recovery) |
+| POST | /v1/oauth2/applications | [Create application](/http-api/applications/#create-application) |
+| POST | /v1/gifts/{code}/redeem | [Redeem gift](/http-api/gifts/#redeem-gift) |
+| POST | /v1/users/@me/channels | [Create private channel](/http-api/users/private-channels/#create-private-channel) |
+| PUT | /v1/channels/{channel_id}/recipients/{user_id} | [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient) |
+
+Create private channel is gated only on the group direct message path, where the request body has a `recipients` member. A one-to-one direct message request omits the field and is never gated.
+
+Create application, redeem gift, create private channel, and add group direct message recipient reject an unauthenticated request before Fluxer reads the CAPTCHA. The three authentication operations accept a request with no credential, and each of them returns 403 `SSO_REQUIRED` before Fluxer reads the CAPTCHA when the instance enforces single sign-on.
+
+## Exemption
+
+Two exemptions skip the challenge. Fluxer tests both of them before it reads the token. A request that passes either one proceeds as though the instance had no provider configured.
+
+The first exemption is the `captcha_exempt` capability the instance account policy grants to a contact address. A policy rule matches the address itself or the domain it belongs to, so one grant can cover a whole domain. Fluxer tests it against the resolved account's email address, then against an `email` member of the request body.
+
+The second exemption is the `APP_STORE_REVIEWER` user flag. Fluxer tests it against the resolved account, then against the account an `email` member of the request body resolves to.
+
+The body checks parse the request body as JSON and read a string `email` member, and a body that is absent, is not JSON, or is not a JSON object yields no address. Those checks exempt a login or a registration attempt before any account is resolved.
+
+The exemptions run before request validation on the three authentication operations, on create application, and on redeem gift. Create private channel and add group direct message recipient validate the request first, so an invalid request is rejected before any exemption is tested.
+
+No exemption is visible in an API response. A client cannot predict one and handles a challenge on every gated operation.
+
+## Request headers
+
+| Field | Type | Description |
+| --- | --- | --- |
+| X-Captcha-Token?1 | string | The solution issued by the provider widget |
+| X-Captcha-Type?2 | string | The provider that produced the solution, accepting `hcaptcha` or `turnstile` |
+
+1 An absent or empty value on a gated operation returns 400 `CAPTCHA_REQUIRED`
+
+2 An absent value selects the instance's configured provider, and so does any value other than `hcaptcha` or `turnstile`. Naming a provider the instance holds no secret key for returns 400 `INVALID_CAPTCHA`.
+
+## The retry handshake
+
+A client that has never been challenged sends the gated request without any CAPTCHA header. When the instance enforces verification and no exemption applies, that request fails with 400 `CAPTCHA_REQUIRED`.
+
+The client then renders the selected provider's widget with its advertised site key, obtains a solution, and repeats the identical request with `X-Captcha-Token` set to the solution. It can send `X-Captcha-Type` to state which provider produced the solution.
+
+A retry succeeds when the provider accepts the solution and fails with 400 `INVALID_CAPTCHA` when it does not.
+
+:::caution[A solution is single-use]
+The provider treats an already redeemed solution as invalid. A client obtains a new solution before retrying after `INVALID_CAPTCHA` and MUST NOT replay the previous `X-Captcha-Token` value.
+:::
+
+## Provider verification
+
+Fluxer submits the solution to the selected provider's verification endpoint over HTTPS with a 10-second deadline. It sends the caller's client IP address alongside the solution and omits it when the request resolves none.
+
+Any outcome other than a successful provider verdict answers 400 `INVALID_CAPTCHA`. That covers an unsuccessful verdict, a non-2xx provider status, an unparseable provider payload, the 10-second timeout, and any transport failure. A rejected solution is therefore never distinguishable from an unreachable provider.
+
+## Error codes
+
+| Code | Status | Description |
+| --- | --- | --- |
+| CAPTCHA_REQUIRED1 | 400 | The operation is gated and the request has no solution |
+| INVALID_CAPTCHA | 400 | The provider rejected the solution, or verification could not be completed |
+
+1 [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) also answers this code when its phone attempt risk controls return a captcha decision. That operation is not gated and accepts no solution, so retrying it with `X-Captcha-Token` never helps
+
+Both codes are defined in the [API error code registry](/http-api/errors/#api-error-code-registry), and the body of each is the ordinary [error response](/http-api/#error-response) envelope.
diff --git a/fluxer_docs/src/content/docs/topics/locales.md b/fluxer_docs/src/content/docs/topics/locales.md
new file mode 100644
index 000000000..4d563a7cf
--- /dev/null
+++ b/fluxer_docs/src/content/docs/topics/locales.md
@@ -0,0 +1,74 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Locales
+description: The supported locale registry and how Fluxer resolves a response locale.
+---
+
+A locale tells Fluxer which language to write human-readable text in. Each one is a BCP 47 tag from the registry below, either a bare language subtag such as `de`, or one joined to a region or numeric subtag by a hyphen, such as `en-GB`, `pt-BR`, and `es-419`.
+
+## Supported locales
+
+This registry is the complete set for every Fluxer surface. Wherever Fluxer stores a locale, such as in [user settings](/http-api/users/settings/), it requires an exact value from this table.
+
+| Value | Description |
+| --- | --- |
+| ar | Arabic |
+| bg | Bulgarian |
+| cs | Czech |
+| da | Danish |
+| de | German |
+| el | Greek |
+| en-GB | English (United Kingdom) |
+| en-US1 | English (United States) |
+| es-ES | Spanish (Spain) |
+| es-419 | Spanish (Latin America) |
+| fi | Finnish |
+| fr | French |
+| he | Hebrew |
+| hi | Hindi |
+| hr | Croatian |
+| hu | Hungarian |
+| id | Indonesian |
+| it | Italian |
+| ja | Japanese |
+| ko | Korean |
+| lt | Lithuanian |
+| nl | Dutch |
+| no | Norwegian |
+| pl | Polish |
+| pt-BR | Portuguese (Brazil) |
+| ro | Romanian |
+| ru | Russian |
+| sv-SE2 | Swedish |
+| th | Thai |
+| tr | Turkish |
+| uk | Ukrainian |
+| vi | Vietnamese |
+| zh-CN | Chinese (Simplified) |
+| zh-TW | Chinese (Traditional) |
+
+1 [Negotiation](#negotiation) resolves to this value whenever it selects no other registry value. The bare tag `en` is a registered alias for it during negotiation and is not itself a storable value
+
+2 The bare tag `sv` is a registered alias for it during negotiation and is not itself a storable value
+
+## Negotiation
+
+Fluxer resolves the response locale once for each request. When a request resolves an authenticated user with a stored locale, Fluxer takes that locale. Every other request negotiates the `Accept-Language` header against the [supported locale registry](#supported-locales), and `en-US` is the result whenever negotiation selects no registry value.
+
+An account created by password registration stores the locale negotiated from its own registration request, so the `Accept-Language` header on that request sets the stored value. An account provisioned through single sign-on stores no account locale. Its [user settings](/http-api/users/settings/) locale reads `en-US`, and its requests negotiate `Accept-Language` until the locale setting is changed.
+
+Fluxer splits the header on commas. It trims each member and then splits it on semicolons. The text before the first semicolon is the language range, and Fluxer reads only the first parameter after it, looking for a `q=` weight. A member with no readable `q=` value has weight 1. Fluxer orders the members by descending weight, and members of equal weight keep their header order.
+
+Fluxer then runs two passes over that ordered list.
+
+The first pass takes the earliest member whose range names a registry value exactly. Fluxer trims the range, replaces every underscore with a hyphen, and lowercases it before comparing, so `EN-GB` and `en_gb` both name `en-GB`. The bare tags `en` and `sv` are registered aliases for `en-US` and `sv-SE` and match in this pass.
+
+The second pass runs only when the first selects nothing. It reduces each member in the same order to its language subtag. A language subtag with a declared preference selects that value. The declared preferences are `en` to `en-US`, `es` to `es-ES`, `pt` to `pt-BR`, `zh` to `zh-CN`, and `sv` to `sv-SE`, so `en-AU` selects `en-US` and `pt-PT` selects `pt-BR`. A language subtag without a declared preference selects the first registry value whose tag begins with that subtag and a hyphen.
+
+No registry tag begins with a subtag outside those five followed by a hyphen, so `de-AT` and `xx-YY` both select nothing. A weight of 0 orders a member last, and that member can still be selected. A range of `*` matches no registry value.
+
+The resolved locale selects the localised `message` in an [error response](/http-api/#error-response) and in each element of a validation `errors` array. The `code` field is never localised.
+
+:::note[Locale selection changes human-readable text only]
+A field name, an enumeration value, an error `code`, a [snowflake](/snowflakes/), and a timestamp representation are identical under every locale.
+:::
diff --git a/fluxer_docs/src/content/docs/topics/rate-limits.md b/fluxer_docs/src/content/docs/topics/rate-limits.md
new file mode 100644
index 000000000..86eb21b8a
--- /dev/null
+++ b/fluxer_docs/src/content/docs/topics/rate-limits.md
@@ -0,0 +1,212 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Rate limits
+description: The route and global buckets, the denial body, and the rate limit headers.
+---
+
+Fluxer bounds HTTP API traffic with two allowances, a per-route bucket and one global bucket. A bucket is one allowance counted over one window. Fluxer denies an over-allowance request with 429, `code` set to `RATE_LIMITED`, a [rate limit response object](#rate-limit-response-object), and the [rate limit headers](#rate-limit-headers).
+
+## Buckets and scope
+
+Nearly every route declares its own bucket. A bucket name can have a path parameter placeholder written as a colon followed by the parameter name, as in `guild:emojis:list::guild_id`. Fluxer substitutes the request value before keying the bucket, so one route holds a separate allowance for each path resource.
+
+Every bucket is also keyed by the caller's identity. An authenticated request is keyed by the account and its credential kind, and an OAuth2 bearer credential is keyed by the owning application as well. A session, a bot token, an Admin API key, and each bearer application therefore draw on separate allowances for the same account.
+
+A request that resolves no account is keyed by the client IP address, exactly for IPv4 and by the `/64` for IPv6, so clients in the same `/64` share an allowance. Where the deployment is configured to read the address from a header the request does not have, it is refused with 403 `FORBIDDEN` before any bucket is evaluated.
+
+Fluxer also evaluates a route bucket against the global bucket unless the route declares that bucket exempt. The global bucket is keyed by the same identity, so a request that resolves no account consumes the global allowance of its client IP address.
+
+Seven buckets are exempt, and each is the only bucket its route declares. Those routes draw on no global allowance: `webhook:execute::webhook_id`, `webhook:message_get::webhook_id`, `webhook:message_edit::webhook_id`, `webhook:message_delete::webhook_id`, `webhook:github::webhook_id`, `webhook:instatus::webhook_id`, and `stripe:webhook`. The `user:group_dm:create` and `user:group_dm:recipient:add` buckets are exempt as well. Each sits on a route that already consumed a non-exempt bucket, so both routes still draw on the global allowance.
+
+Every HTTP API and Admin API operation declares a bucket, apart from the seven [desktop download](/http-api/downloads/) routes, which declare none. A caller that sends no credential on a [Bluesky client document](/http-api/connections/#get-bluesky-client-metadata) is keyed by the client IP address.
+
+The global window is one second. The default allowance is 50 requests per second, and an account holding the [`HIGH_GLOBAL_RATE_LIMIT`](/admin-api/users/#account-flags) flag receives 1,200 requests per second instead. An account holding the [`RATE_LIMIT_BYPASS`](/admin-api/users/#account-flags) flag is evaluated against neither the global bucket nor any route bucket, and receives no rate limit header on a successful response.
+
+Some operations enforce a further limit inside the handler. `RATE_LIMIT_BYPASS` exempts an account from none of them. [Limits enforced inside a handler](#limits-enforced-inside-a-handler) has the complete set.
+
+The global bucket is evaluated first. A route bucket is consumed only after the global check admits the request.
+
+:::note[An allowance drains continuously]
+Every bucket is a leaky bucket. It admits at most the declared limit at once and refills continuously at that limit for each declared window, so a client that exhausts an allowance can send again as soon as enough of it has drained.
+:::
+
+:::note[The headers and the body report different instants]
+`X-RateLimit-Reset` and `X-RateLimit-Reset-After` report when the bucket has drained completely. `retry_after` in the response body reports the shorter delay after which one further request is admitted.
+:::
+
+:::note[A 429 can hide a 401 or 403]
+A route's rate limit middleware normally runs before its authentication policy, so an over-allowance request returns 429 `RATE_LIMITED` where the same request inside its allowance would return 401 or 403. Fluxer resolves the credential before either check, and both buckets are keyed by the authenticated account whenever one resolves.
+:::
+
+Four routes charge a second bucket. [Create private channel](/http-api/users/private-channels/#create-private-channel) and [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient) evaluate that second bucket after the authentication policy and the request validation, so an unauthenticated or malformed request is refused before it is consumed. Create private channel consumes `user:group_dm:create` only when the validated body supplies `recipients`. A one-to-one direct message create reaches no second bucket.
+
+[Delete guild emoji](/http-api/guild-emojis/#delete-guild-emoji) and [Delete guild sticker](/http-api/guild-stickers/#delete-guild-sticker) declare both of their buckets ahead of the authentication policy and the request validation, so an unauthenticated or malformed request consumes the second bucket too. The `guild:emoji:delete:daily::guild_id` and `guild:sticker:delete:daily::guild_id` buckets draw on the global allowance, and one delete request evaluates it twice.
+
+:::caution[A global denial revokes a user session]
+When the global bucket denies a request authenticated by a user session token belonging to a non-bot account, Fluxer revokes that session token before writing the 429 and the client must authenticate again. A bot token, an OAuth2 access token, and an Admin API key are never revoked this way, and a route bucket denial never revokes a credential.
+:::
+
+A deployment can disable both buckets through instance configuration. While they are disabled, no response has an `X-RateLimit-*` header and no request is refused with 429 `RATE_LIMITED`. That switch also turns off the two login allowances. Every other [limit enforced inside a handler](#limits-enforced-inside-a-handler) stays in force.
+
+## Rate limit response object
+
+The denial body has the two members of the ordinary [error response](/http-api/#error-response) and two further members.
+
+### Structure
+
+| Field | Type | Description |
+| --- | --- | --- |
+| code1 | string | `RATE_LIMITED` on a bucket denial |
+| message2 | string | The localised rate limit message |
+| global | boolean | Whether the global bucket produced the denial, present and false on a route denial |
+| retry_after3 | number | The delay in fractional seconds before another request is admitted |
+
+1 A limit enforced outside the route bucket middleware can reuse this body with its own code. [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) is the only live one, reporting `PHONE_RATE_LIMIT_EXCEEDED`
+
+2 The locale is the one resolved for the request, which the account setting selects ahead of [Accept-Language](/http-api/#standard-request-headers)
+
+3 Never below 0.001, falling back to the whole-second `Retry-After` value when no fractional delay was computed
+
+The [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation) cooldown answers 429 with its own code and a different body. [Allowances answering 429](#allowances-answering-429) states what that body has.
+
+A [slowmode](#slowmode) denial answers 400 `SLOWMODE_RATE_LIMITED`.
+
+### Example
+
+```json
+{
+ "code": "RATE_LIMITED",
+ "message": "You're being rate limited.",
+ "global": false,
+ "retry_after": 0.428
+}
+```
+
+## Rate limit scopes
+
+The `X-RateLimit-Scope` header is the scope that produced a denial.
+
+| Value | Description |
+| --- | --- |
+| user | The denial came from an allowance keyed by the caller alone |
+| global | The denial came from the global bucket |
+| shared1 | The denial came from an allowance that several accounts can exhaust for each other |
+
+1 No route bucket declares a scope of its own, so every route bucket denial reports `user`. [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) is the only live source of `shared`
+
+Phone verification reports `shared` when the per-number send allowance or a number-scoped provider cooldown produced the denial. Both are keyed by the submitted number, so two accounts sending to one number share the allowance.
+
+## Rate limit headers
+
+These headers describe a rate limit decision. An operation that answers 429 returns this set.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| Retry-After?1 | string | The delay in whole seconds before another request is admitted, rounded up and never below 1 |
+| X-RateLimit-Scope?1 | string | The [rate limit scope](#rate-limit-scopes) that produced the denial |
+| X-RateLimit-Global?2 | string | The literal value `true` on a global HTTP 429 |
+| X-RateLimit-Limit?3 | string | The maximum requests the route allowance admits at once |
+| X-RateLimit-Remaining?3 | string | The whole requests the route allowance still admits, always 0 on a denial |
+| X-RateLimit-Reset?34 | string | The Unix timestamp in seconds at which the whole route allowance is available again |
+| X-RateLimit-Reset-After?35 | string | The seconds until the whole route allowance is available again |
+| X-RateLimit-Bucket?36 | string | The stable 16-character hexadecimal identifier of the route bucket |
+
+1 Sent on every rate limit denial and on no successful response
+
+2 Sent only when the global bucket produced the denial, in which case none of the route bucket headers is sent
+
+3 Sent on a route HTTP 429, subject to footnote 6 for `X-RateLimit-Bucket`. On a successful response they are sent only when the credential resolves to a bot account, or when the request resolves no account at all on a route with both a `webhook_id` and a `token` path parameter
+
+4 A value at or before the current second is replaced with the next second
+
+5 Rounded to millisecond precision with trailing zeros removed on a successful response, and emitted as the exact computed decimal on a denial
+
+6 The leading 16 hexadecimal characters of the SHA-256 digest of the route's declared bucket name before any path parameter is substituted, so it identifies the route and never the caller
+
+A 429 from a limit enforced inside a handler has the other four route headers and no `X-RateLimit-Bucket`.
+
+:::note[A browser client reads none of these headers]
+The [cross-origin policy](/http-api/#cross-origin-requests) exposes only `X-Fluxer-Version`, so a script running on an allowed origin observes the 429 status and the response body, and no header.
+:::
+
+## Limits enforced inside a handler
+
+Some operations bound a further allowance inside the handler. Each one is keyed independently of the route bucket and of the global bucket, so exhausting it denies the request while both buckets still have room. The set below is complete.
+
+Two deployment switches disable part of this set. `disable_rate_limits` turns off the two login allowances along with both buckets. `relax_registration_rate_limits` turns off the three registration allowances. Every other allowance below is enforced on every deployment.
+
+A denial takes one of two shapes. A send or submission allowance answers 429 with the [rate limit response object](#rate-limit-response-object) and the [rate limit headers](#rate-limit-headers) minus `X-RateLimit-Bucket`. A change allowance answers 400 `INVALID_FORM_BODY` with one [validation error](/http-api/#validation-error-object) entry whose `code` names the exhausted allowance.
+
+The 400 shape has no `retry_after` member, no `X-RateLimit-*` header, and no `Retry-After` header. The remaining delay appears only in the entry's localised `message`.
+
+### Allowances answering 429
+
+| Operation | Allowance | Code |
+| --- | --- | --- |
+| [Log in with a password](/http-api/authentication/#log-in-with-a-password) | 5 per 15 minutes, keyed by the submitted email address | `RATE_LIMITED` |
+| [Log in with a password](/http-api/authentication/#log-in-with-a-password) | 10 per 30 minutes, keyed by the client IP address, exactly for IPv4 and by the `/64` for IPv6 | `RATE_LIMITED` |
+| [Register an account](/http-api/authentication/#register-an-account) | 3 per 15 minutes, keyed by the submitted email address | `RATE_LIMITED` |
+| [Register an account](/http-api/authentication/#register-an-account) | 3 per hour, keyed by the client IP address | `RATE_LIMITED` |
+| [Register an account](/http-api/authentication/#register-an-account) | 15 per hour, keyed by the client subnet, the IPv4 `/24` or the IPv6 `/48` | `RATE_LIMITED` |
+| [Resend email verification](/http-api/authentication/#resend-email-verification) | 3 per 15 minutes, keyed by the account's stored email address | `RATE_LIMITED` |
+| [Request password recovery](/http-api/authentication/#request-password-recovery) | 20 per 30 minutes, keyed by the client IP address | `RATE_LIMITED` |
+| [Request password recovery](/http-api/authentication/#request-password-recovery) | 5 per 30 minutes, keyed by the submitted email address | `RATE_LIMITED` |
+| [Start email change](/http-api/users/email-and-password/#start-email-change) and [Resend original email code](/http-api/users/email-and-password/#resend-original-email-code) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
+| [Request new email](/http-api/users/email-and-password/#request-new-email), [Resend new email code](/http-api/users/email-and-password/#resend-new-email-code), and both bounced recovery sends | 5 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
+| [Start password change](/http-api/users/email-and-password/#start-password-change) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
+| [Resend password change code](/http-api/users/email-and-password/#resend-password-change-code) | 3 sends per 15 minutes, keyed by the authenticated account | `RATE_LIMITED` |
+| Every code resend and every new-address request on an email or password change ticket | 1 send per 30 seconds, keyed by the previous send recorded on that ticket | `RATE_LIMITED` |
+| [Report message](/http-api/reports/#report-message), [Report user](/http-api/reports/#report-user), [Report guild](/http-api/reports/#report-guild), and [Create DSA report](/http-api/reports/#create-dsa-report) | 5 per hour, keyed by the reporter, an account or a verified email address | `RATE_LIMITED` |
+| [Report message](/http-api/reports/#report-message) | 3 per hour, keyed by the reporter and the channel together | `RATE_LIMITED` |
+| [Report message](/http-api/reports/#report-message) | 20 per hour, keyed by the reported message, across all reporters | `RATE_LIMITED` |
+| [Report message](/http-api/reports/#report-message) | 4 per hour, keyed by the reporter and the guild together, for a guild message | `RATE_LIMITED` |
+| [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) | 3 per 6 hours, keyed by the authenticated account | `PHONE_RATE_LIMIT_EXCEEDED` |
+| [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) | 3 per 5 days, keyed by the submitted number | `PHONE_RATE_LIMIT_EXCEEDED` |
+| [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation) | Nothing in the first 30 seconds after the ticket was issued, keyed by the authorisation ticket | `IP_AUTHORIZATION_RESEND_COOLDOWN` |
+
+Fluxer stores a further cooldown when the SMS provider itself throttles a send. The provider names the account, the number, or both, and a send inside that cooldown reports `PHONE_RATE_LIMIT_EXCEEDED` with the ordinary denial body and the remaining delay.
+
+The Resend IP authorisation cooldown has no `X-RateLimit-*` header. It has a `Retry-After` header in whole seconds, and the body reports that delay again as a top-level `resend_available_in` and `retry_after`. A second resend on one ticket returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`. The allowance never refills, and the ticket expires 15 minutes after it was issued.
+
+### Allowances answering 400
+
+| Operation | Allowance | Validation code |
+| --- | --- | --- |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 5 per 3 hours on the resulting username or discriminator | `USERNAME_CHANGED_TOO_MANY_TIMES` |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the biography, when the submitted value differs | `BIO_CHANGED_TOO_MANY_TIMES` |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the pronouns, when the submitted value differs | `PRONOUNS_CHANGED_TOO_MANY_TIMES` |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on the accent colour, when the submitted value differs | `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES` |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on any non-null avatar | `AVATAR_CHANGED_TOO_MANY_TIMES` |
+| [Modify current user](/http-api/users/current-user/#modify-current-user) | 25 per 30 minutes on any banner value past the entitlement check | `BANNER_CHANGED_TOO_MANY_TIMES` |
+| [Update bot profile](/http-api/applications/#update-bot-profile) | 5 per 3 hours on the bot's resulting username or discriminator | `USERNAME_CHANGED_TOO_MANY_TIMES` |
+| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild avatar, whenever supplied | `AVATAR_CHANGED_TOO_MANY_TIMES` |
+| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild banner, whenever supplied | `BANNER_CHANGED_TOO_MANY_TIMES` |
+| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild biography, when the submitted value differs | `BIO_CHANGED_TOO_MANY_TIMES` |
+| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild pronouns, when the submitted value differs | `PRONOUNS_CHANGED_TOO_MANY_TIMES` |
+| [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) | 25 per 30 minutes on the guild accent colour, when the submitted value differs | `ACCENT_COLOR_CHANGED_TOO_MANY_TIMES` |
+| [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) | 1 per 24 hours on the sharing default | `VOICE_ACTIVITY_SHARING_ON_COOLDOWN` |
+| [Complete login with TOTP](/http-api/authentication/#complete-login-with-totp) and [Complete login with WebAuthn MFA](/http-api/authentication/#complete-login-with-webauthn-mfa) | 10 per 15 minutes on one multi-factor attempt | `INVALID_CODE` |
+| [Complete login with TOTP](/http-api/authentication/#complete-login-with-totp) and [Complete login with WebAuthn MFA](/http-api/authentication/#complete-login-with-webauthn-mfa) | 5 per 5 minutes on one multi-factor attempt against one MFA ticket | `INVALID_CODE` |
+| [Sudo mode](/http-api/users/mfa/#sudo-mode) with the `totp` method | 10 per 15 minutes on one multi-factor attempt | `INVALID_MFA_CODE` |
+
+Every Modify current user allowance is keyed by the authenticated account, and the bot tag allowance by the bot account, so an owner changing a bot's tag draws on the bot's allowance. The five guild member allowances are keyed by the guild and the member together, and one account holds a separate allowance in each guild. The login allowances are keyed by the account and by the MFA ticket respectively, and the sudo allowance by the account.
+
+Fluxer consumes every multi-factor allowance before it checks the code, so a correct code drawn against an exhausted allowance is reported exactly like a wrong one. A correct code clears the counter. The ticket allowance also destroys the MFA ticket as it denies, and the client restarts from [Log in with a password](/http-api/authentication/#log-in-with-a-password).
+
+### Allowances answering neither shape
+
+[Get desktop handoff information](/http-api/authentication/#get-desktop-handoff-information) and [Complete desktop handoff](/http-api/authentication/#complete-desktop-handoff) share one failed-attempt counter keyed by the client IP address. Five failures block both operations for 15 minutes from the most recent failure, and a blocked request returns 400 `INVALID_HANDOFF_CODE` as a top-level code rather than as a validation entry. Get desktop handoff information separately permits three successful lookups for each handoff code and reports a fourth with the same top-level code.
+
+[Refund latest purchase](/http-api/billing/#refund-latest-purchase) permits one self-serve refund every 30 days for each account and reports a request inside that window as 403 `STRIPE_REFUND_COOLDOWN_ACTIVE`.
+
+[Slowmode](#slowmode) is enforced inside the handler as well.
+
+## Slowmode
+
+Slowmode limits how often one account sends a message in one channel. Fluxer reports a denial as an ordinary request failure. A denied send returns 400 `SLOWMODE_RATE_LIMITED` with a top-level `retry_after` in fractional seconds and a `Retry-After` header in whole seconds. The response has no `X-RateLimit-*` header, so a client tells it apart from a bucket denial by the status and the code.
+
+The allowance is one message for each interval the channel configures in `rate_limit_per_user`, counted separately for each account and channel pair. Fluxer counts it only for a non-bot account sending in a guild channel whose configured interval is above zero. A caller holding [BYPASS_SLOWMODE](/http-api/permissions/) is exempt. [Get channel slowmode state](/http-api/channels/#get-channel-slowmode-state) reports the caller's remaining delay before a send is attempted.
+
+## Other surfaces
+
+Each protocol surface documents its own rate limit contract. The [main Gateway](/gateway/overview/) states its session, command, replay, backpressure, and admission limits in [Gateway limits and rate limits](/gateway/limits-and-rate-limits/). The [Media Proxy API](/media-proxy/overview/) has no request-count rate limit and bounds work through concurrency, payload, and deadline limits. The [upload relay](/media-proxy/upload-relay/) authorises each transfer with a bounded capability.
diff --git a/fluxer_docs/src/content/docs/topics/uploads.md b/fluxer_docs/src/content/docs/topics/uploads.md
new file mode 100644
index 000000000..7f25ee9d4
--- /dev/null
+++ b/fluxer_docs/src/content/docs/topics/uploads.md
@@ -0,0 +1,123 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Attachment uploads
+description: The pre-upload plan, its two modes, and how a client claims the result.
+---
+
+Fluxer accepts an attachment inline as a `files[n]` part of a [multipart request](/http-api/#request-body-formats), or pre-uploaded before the message exists. The pre-upload operations and objects live on the [Messages resource](/http-api/messages/).
+
+The flow has two modes and the server chooses between them from the declared byte count. A file of 10485760 bytes or less, the 10 MiB threshold, is planned as a singlepart upload and is finished as soon as its bytes are stored. A larger file is planned as a multipart upload and needs an explicit completion call. Both modes end the same way, by naming the resulting `upload_filename` in an ordinary message operation.
+
+:::caution[Pre-uploads can be switched off]
+When a deployment disables them, the plan request and the completion request both answer 403 `FEATURE_TEMPORARILY_DISABLED` ahead of the channel, permission, and size checks, and the [instance features object](/http-api/instance/#instance-features-object) reports `presigned_attachment_uploads` false. A client falls back to the inline multipart path.
+:::
+
+## Requesting an upload plan
+
+[Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) takes from 1 through 10 attachment declarations. Each is a client-chosen `id`, a `filename`, an exact `file_size` in bytes, and a `content_type`. A user session credential and a bot token are accepted, and an OAuth2 bearer credential is rejected with 403 `ACCESS_DENIED`.
+
+The channel must support messages, and any other channel type returns 400 `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`. A guild channel additionally requires [SEND_MESSAGES](/http-api/permissions/) and [ATTACH_FILES](/http-api/permissions/), returning 403 `MISSING_PERMISSIONS` otherwise, and a caller under a communication timeout receives 403 `COMMUNICATION_DISABLED`.
+
+Fluxer then checks every declared size on its own against the `max_attachment_file_size` limit resolved for the caller and the guild. A size above it returns 400 `FILE_SIZE_TOO_LARGE` with the resolved ceiling before anything is planned. That limit defaults to 26214400 bytes, the 25 MiB non-premium allowance, and to 524288000 bytes, the 500 MiB premium allowance. A bot credential is clamped to 52428800 bytes, the 50 MiB bot ceiling, even when the resolved limit is higher.
+
+The response returns one entry for each declaration, in request order, discriminated by `upload_mode`. Every entry has the `id` from the request, the `filename`, the opaque `upload_filename` to claim later, the `file_size`, and the `content_type` the server derived from the filename. The issued capability authorises that derived media type, and the stored object has it.
+
+### Upload modes
+
+| Value | Selected for | Added fields |
+| --- | --- | --- |
+| singlepart1 | A declared `file_size` of at most 10485760 bytes | `upload_url` |
+| multipart2 | A declared `file_size` above 10485760 bytes | `upload_id`, `part_size`, and `parts` |
+
+1 Stored as soon as its `PUT` succeeds, so it is never sent to [Complete attachment upload](/http-api/messages/#complete-attachment-upload)
+
+2 Each `parts` entry has a one-based `part_number` and its own `upload_url`, and the array is ordered by ascending part number
+
+## Part geometry
+
+Fluxer derives multipart geometry from the file size. The part size is the declared size divided by 20, rounded up to a whole mebibyte, and never below 10485760 bytes. Every part is exactly `part_size` bytes except the last, which is the remainder.
+
+A plan is bounded at 10,000 parts. One that would need more returns 400 `FILE_SIZE_TOO_LARGE` before the storage multipart upload is opened. The resolved file size limit is the only binding constraint.
+
+## Transferring the bytes
+
+Each `upload_url` is a `PUT` target with its own authorisation in its query string. A direct storage URL has the object store's own signature and a relay URL has the signed relay capability in its `t` parameter, so neither shape reads an `Authorization` header.
+
+The direct storage capability signs the exact byte count, so a `PUT` of any other length is rejected. A relay capability bounds the length at the same value and answers 413 above it, and the relay applies its own body ceiling, 500 MiB by default, on top of that. A transfer that declares no `Content-Length` is spooled to the smaller of the two bounds and answers 413 past it. A relay request whose capability is missing, malformed, or expired answers 401. A client sends exactly the authorised byte count either way.
+
+A singlepart transfer sends the whole file and must send the entry's `content_type` as its `Content-Type` header. A relay capability takes the media type from the capability and ignores the header. A multipart part transfer sends only that part's bytes and has no signed media type.
+
+The instance decides per request whether to relay. It resolves the caller's country from the client IP address and issues a direct storage URL only when that country is on the deployment's direct-upload list. Every other caller, including one whose geolocation lookup fails, receives a URL on the [upload relay](/media-proxy/upload-relay/). A client treats both shapes the same way and MUST NOT parse, rewrite, or reorder the query string of either.
+
+### Capability lifetimes
+
+| Capability | Lifetime |
+| --- | --- |
+| Direct singlepart upload URL | 5 minutes |
+| Direct multipart part URL | 1 hour |
+| Relay URL of either kind1 | The relay token lifetime the deployment configures, 900 seconds by default |
+
+1 A relay expiry is exclusive, so the capability is already rejected at its expiry second
+
+:::caution[The complete URL is the credential]
+An issued upload URL authorises writing one object or one part. A client treats the whole URL, query string included, as secret until it expires, and keeps it out of logs, referrers, and redirect targets.
+:::
+
+## Completing a multipart upload
+
+[Complete attachment upload](/http-api/messages/#complete-attachment-upload) finalises from 1 through 10 multipart uploads. Each entry names the `upload_filename` and the `upload_id` from the plan. The caller sends no part list and no entity tags. The server lists the parts the storage backend has already accepted, sorts them by part number, and assembles them in that order.
+
+The channel, permission, and communication checks of the plan request run again, and the operation answers 403 `FEATURE_TEMPORARILY_DISABLED` when pre-uploads are switched off.
+
+Two failures are reported as [validation error object](/http-api/#validation-error-object) entries on a 400 `INVALID_FORM_BODY` response.
+
+| Code | Path | Condition |
+| --- | --- | --- |
+| UPLOADED_ATTACHMENT_NOT_FOUND1 | `uploads.{index}.upload_filename` | The key is not a pending multipart upload of the authenticated identity in this channel |
+| NO_UPLOADED_PARTS_TO_FINALIZE2 | `parts` | The storage backend holds no part for the upload |
+
+1 An upload another identity planned, one planned for another channel, one planned as singlepart, and one a message has already consumed are all reported this way, so a caller cannot probe another identity's upload state
+
+2 The multipart upload is aborted before the error is returned
+
+Fluxer then sums the listed part sizes. A total above the resolved file size limit aborts the upload and returns 400 `FILE_SIZE_TOO_LARGE`. A storage failure during assembly aborts it as well. An aborted upload discards its parts, and its `upload_filename` can never be claimed. A client requests a new plan for the file.
+
+A client never sends a singlepart upload to this operation.
+
+## Claiming the upload
+
+Nothing before this step creates a message, changes a channel, or emits a Gateway Dispatch. The attachment exists only once a [Create message](/http-api/messages/#create-message) or [Modify message](/http-api/messages/#modify-message) request has the `upload_filename` in a [pre-uploaded attachment](/http-api/messages/#pre-uploaded-attachment-object) entry.
+
+An upload is bound to the identity and the channel that planned it. A key the authenticated identity does not own, a key planned for another channel, and a key an attachment has already consumed each return 400 `INVALID_FORM_BODY` with `UPLOADED_ATTACHMENT_NOT_FOUND` on `attachments.{index}.upload_filename`. A key whose object is absent from storage, which is what an untransferred plan leaves behind, returns the same status with `FILE_NOT_FOUND` on the same path.
+
+A key is single use, and a second claim of the same key returns the same `UPLOADED_ATTACHMENT_NOT_FOUND` entry.
+
+:::caution[There is no resume operation]
+A capability that expires cannot be refreshed and a plan cannot be re-read. A client that loses its plan, or whose part capabilities expire mid transfer, requests a new plan for the file and starts again.
+:::
+
+## Stream previews
+
+A stream preview is a still image attached to one voice connection. It does not use the attachment flow. A preview belongs to the voice connection that publishes the stream. The [stream key](/http-api/streams/#stream-key) names the scope, the channel, and that connection ID. Every preview operation is user-only.
+
+Uploading the image, issuing an upload capability, and deleting the preview each require the caller to hold a voice state in that channel whose connection ID matches the key. In a guild channel they require the [STREAM](/http-api/permissions/) permission as well. Reading the preview requires only access to the channel, and [CONNECT](/http-api/permissions/) in a guild channel, so any member who can join can read it.
+
+:::note[Treat a stored preview as publisher-asserted]
+A voice state has no flag the check reads, so a caller can upload and read a preview for a connection that is publishing nothing.
+:::
+
+[Upload stream preview](/http-api/streams/#upload-stream-preview) posts the image inline. Its JSON body has the `channel_id` the connection is in, the base64-encoded image in `thumbnail` of 1 through 2000000 characters, and an optional `content_type` of 1 through 64 characters. A `thumbnail` that is not canonical base64 returns 400 `INVALID_STREAM_THUMBNAIL_PAYLOAD`. Canonical base64 here means the standard alphabet, a length that is a multiple of four, at most two trailing `=`, and decoded bytes that re-encode to the string the request sent. A `content_type` containing `jpeg` or `jpg` in any case is accepted without inspecting the bytes. Every other value, an absent field included, is accepted only when the decoded bytes begin with `FF D8` and end with `FF D9`. A failure returns 400 `PREVIEW_MUST_BE_JPEG`. Decoded bytes above 1000000 return 400 `FILE_SIZE_TOO_LARGE`.
+
+The operation answers 204 once the image is accepted. Fluxer absorbs a transient storage failure, so a 204 confirms acceptance alone.
+
+[Create stream preview upload URL](/http-api/streams/#create-stream-preview-upload-url) instead issues a reusable `PUT` capability for the same purpose. Its `content_type` must contain `jpeg` or `jpg` in any case, and every other value returns 400 `PREVIEW_MUST_BE_JPEG`. It answers with `upload_url`, `method` fixed to `PUT`, the `content_type` the client sends, `expires_at`, `expires_in`, and `max_bytes`, which is always 1000000. A direct storage capability lasts one day and a relay capability lasts the relay token lifetime, so `expires_in` differs between the two shapes. The capability writes the same object every time it is used, and a publisher refreshes the thumbnail without asking for a new URL.
+
+Nothing inspects the bytes written through that capability. A relay capability still refuses a declared length above `max_bytes` with 413, and a direct storage capability enforces nothing beyond its signed media type. A publisher encodes a valid JPEG of at most 1000000 bytes itself.
+
+[Get stream preview](/http-api/streams/#get-stream-preview) returns the current image bytes with `Cache-Control: no-store, private`, and [Delete stream preview](/http-api/streams/#delete-stream-preview) removes it. The stored preview record expires one day after the inline upload that wrote it, or one day after the call that issued the capability, and using a capability again does not extend it. A read past that point answers an empty 404 even when the object is still in storage.
+
+## Failures
+
+Every HTTP API operation in this flow returns the ordinary [error response](/http-api/#error-response) envelope. A relay request answers with the plain-text [media error response](/media-proxy/responses-and-limits/#media-error-response) that the [Media Proxy API](/media-proxy/overview/) defines. A direct storage URL returns whatever the storage backend returns, in that backend's own format.
+
+The relay has no request-count rate limit. [Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) and [Complete attachment upload](/http-api/messages/#complete-attachment-upload) share the `channel:attachment:upload::channel_id` bucket, which permits 10 requests per 10 seconds for each authenticated identity and channel ID. Each stream preview operation declares its own bucket. [Rate limits](/topics/rate-limits/) defines both.
diff --git a/fluxer_docs/src/content/docs/voice/index.md b/fluxer_docs/src/content/docs/voice/index.md
new file mode 100644
index 000000000..803e15df1
--- /dev/null
+++ b/fluxer_docs/src/content/docs/voice/index.md
@@ -0,0 +1,194 @@
+---
+# SPDX-License-Identifier: AGPL-3.0-or-later
+title: Voice
+description: Voice placement, guild voice channels, calls, Go Live, and entrance sounds.
+---
+
+Fluxer runs voice over LiveKit. The [main Gateway](/gateway/overview/) places a session into a voice channel and hands it one credential, the client presents that credential to a media server, and every track goes over the connection it opens there. Microphone, camera, and screen share are track sources on that one connection, so going live opens nothing new.
+
+[Client commands](/gateway/commands/) and [Gateway events](/gateway/events/) define the placement protocol.
+
+:::note[A voice rewrite is in progress]
+Every statement on this page is what an instance serves today, and a later release can change it. Re-read the page after an instance upgrade.
+:::
+
+## Voice surfaces
+
+| Surface | What it is | Reference |
+| --- | --- | --- |
+| Guild voice channel | A channel of [type](/http-api/channels/#channel-types) `2`, which also has messages, pins, and slowmode | [Channels](/http-api/channels/) |
+| Private call | The direct message and group direct message counterpart of a voice channel | [Calls](/http-api/calls/) |
+| Go Live stream1 | Screen share published as an extra track on a voice connection the member already holds | [Streams](/http-api/streams/) |
+| Entrance sound2 | A short clip announced to everyone already connected to a voice channel | [Entrance sounds](/http-api/entrance-sounds/) |
+| Voice activity sharing | Whether a friend is told which voice channel the account is in | [User settings](/http-api/users/settings/#modify-voice-activity-sharing) |
+
+1 Going live opens no second connection and mints no second credential
+
+2 The one surface that publishes no media track
+
+## Media transport
+
+Media never crosses the HTTP API. No route returns an audio or video track, the credential a media connection presents, or the key material an end-to-end encrypted channel uses.
+
+Fluxer runs media over LiveKit and publishes no signalling protocol of its own. There is no voice websocket, no voice opcode set, no UDP discovery step, and no separate encryption handshake. A client connects to the `endpoint` the [Voice Server Update](/gateway/events/#voice-server-update) grant names, presents the grant `token`, and speaks the LiveKit protocol from there.
+
+| Concept | Value |
+| --- | --- |
+| Room name, guild voice channel | `guild_{guild_id}_channel_{channel_id}` |
+| Room name, private call | `dm_channel_{channel_id}` |
+| Participant identity | `user_{user_id}_{connection_id}` |
+
+The grant lives for 600 seconds.
+
+One room is one voice channel, and one participant is one voice connection. A member holding several connections in the same channel is several participants in the same room, which is how one account is present from more than one device.
+
+The grant also names the track sources the connection may publish. SPEAK admits the microphone source, and STREAM admits the camera source together with the two screen share sources. A server-deafened connection may neither publish nor subscribe.
+
+## Deployment feature state
+
+A deployment can be configured without voice. The [instance discovery document](/http-api/instance/#instance-features-object) reports that state as `features.voice_enabled`. No other surface warns a client in advance.
+
+A deployment that reports false mints no media credential, so a placement request is refused with `VOICE_TOKEN_FAILED`. [List RTC regions](/http-api/channels/#list-rtc-regions) answers 200 with an empty array before it resolves the channel, and [Modify call region](/http-api/calls/#modify-call-region) accepts any region string.
+
+## Placement
+
+[Voice State Update](/gateway/commands/#voice-state-update) declares where a session wants to be. It has the target `guild_id`, `channel_id`, and `connection_id` together with `self_mute`, `self_deaf`, `self_video`, and `self_stream`. A null `channel_id` leaves, and a call placement always has a null `guild_id`.
+
+`channel_id` and `connection_id` together select the operation.
+
+| Command shape | Result |
+| --- | --- |
+| `channel_id` and no `connection_id` | Opens a new connection |
+| `channel_id` and a `connection_id` | Updates or moves that connection |
+| Null `channel_id` and a `connection_id`, in a guild | Drops that connection |
+| Null `channel_id` and no `connection_id`, in a guild | Refused with `VOICE_MISSING_CONNECTION_ID` |
+| Null `channel_id` and no `connection_id`, in a call | Drops every voice membership the session holds |
+| Null `channel_id` with a non-string, non-null `connection_id` | Refused with `VALIDATION_INVALID_PARAMS` |
+
+The server answers with [Voice State Update](/gateway/events/#voice-state-update) for the resulting membership, delivered to every session that can view the channel, and with [Voice Server Update](/gateway/events/#voice-server-update) for the requesting session alone. That grant has the `token`, `endpoint`, `channel_id`, and `connection_id` of the media server the deployment selected. `guild_id` is present for a guild voice channel and omitted for a call, so a client reads the scope of the grant from that field. It also has an `e2ee_key` when the channel is end-to-end encrypted.
+
+A grant is issued when a connection opens, when it moves to another channel, and when the region changes. An update that stays in the same channel reissues nothing, so toggling `self_mute`, `self_video`, or `self_stream` produces one [Voice State Update](/gateway/events/#voice-state-update) and no new grant.
+
+The grant `token` is minted for the media server and consumed by the media connection alone. No route on this API accepts it.
+
+Fluxer reports a guild refusal as [Voice State Ack](/gateway/events/#voice-state-ack) with a `status` of `rejected` and an `error_code` naming the exact reason, and only a command with a `mutation_id` receives one. A refused placement without it produces no Dispatch, so a client that needs to observe a guild failure MUST send `mutation_id`.
+
+A call never acks. A refused placement into a direct message or group direct message call produces no Dispatch whether or not the command had `mutation_id`, so a client observes that refusal only as the absence of a grant.
+
+## Guild voice channels
+
+A guild voice channel stores its `bitrate`, `user_limit`, `voice_connection_limit`, and `rtc_region` on the [channel object](/http-api/channels/#channel-object). It also has ordinary messages, pins, and slowmode, so its text history is read and written through the [Messages resource](/http-api/messages/).
+
+### Permissions
+
+| Permission | Effect on voice |
+| --- | --- |
+| VIEW_CHANNEL and CONNECT1 | Both are required to hold a voice connection in the channel |
+| SPEAK2 | Publish audio |
+| STREAM3 | Publish camera video and Go Live media |
+| MUTE_MEMBERS4 | Apply and clear a moderator mute |
+| DEAFEN_MEMBERS4 | Apply and clear a moderator deafen |
+| MOVE_MEMBERS | Move a member to another guild voice channel, or disconnect it |
+| UPDATE_RTC_REGION | Change the channel's `rtc_region` |
+
+1 A member missing either bit is refused with `VOICE_PERMISSION_DENIED`
+
+2 A member without it is admitted, and its voice state is published with `suppress` true
+
+3 One bit gates both, so there is no separate camera permission
+
+4 MUTE_MEMBERS covers the `mute` field and DEAFEN_MEMBERS the `deaf` field of the [guild member update object](/http-api/guild-members/#guild-member-update-object). `PRIORITY_SPEAKER` and `USE_VAD` are defined and assignable [permission bits](/http-api/permissions/#permissions) that no HTTP route and no Gateway command evaluates
+
+[ADMINISTRATOR](/http-api/permissions/) resolves to the complete mask before any channel overwrite is applied, so it satisfies every row of that table. Two states skip the VIEW_CHANNEL and CONNECT check. A member the guild is already moving is admitted. So is a member holding virtual access to the channel, which the guild grants to a connected member that loses VIEW_CHANNEL or that a moderator moves into a channel it cannot see. Virtual access also grants SPEAK and STREAM in that channel on its own.
+
+A grant is evaluated when it is minted, and the guild re-evaluates a connection that is already open. Joining, moving, a region change, a role edit, an overwrite edit, and a member role change each recompute SPEAK and STREAM.
+
+The guild applies the new result to a live connection and issues no new [Voice Server Update](/gateway/events/#voice-server-update). The media server mutes a published microphone, camera, or screen share track the member may no longer publish, and drops a connection that fails the VIEW_CHANNEL and CONNECT check.
+
+Moderation is an HTTP operation on the guild membership. [Modify guild member](/http-api/guild-members/#modify-guild-member) and [Modify current guild member](/http-api/guild-members/#modify-current-guild-member) share one request body. Each applies a moderator mute, applies a moderator deafen, moves a member between guild voice channels, and forces a disconnect. Both hold the caller to MUTE_MEMBERS, DEAFEN_MEMBERS, and MOVE_MEMBERS, including when the target is the caller itself. No other HTTP route and no Gateway command does any of it.
+
+That mute and that deafen reach the media server without a new credential. The change applies to every connection the account holds in that channel, and no [Voice Server Update](/gateway/events/#voice-server-update) follows.
+
+### Capacity
+
+| Bound | Refusal |
+| --- | --- |
+| `user_limit`1 | `VOICE_CHANNEL_FULL` |
+| `voice_connection_limit`2 | `VOICE_CONNECTION_LIMIT_REACHED` |
+| 25 members with a camera on3 | `VOICE_CAMERA_USER_LIMIT` |
+
+1 A stored `0` means no limit. While any member in the channel has a camera on, the effective occupancy limit becomes the lower of `user_limit` and 25, and a channel with no limit is capped at 25 for as long as that holds
+
+2 The ceiling on simultaneous connections one member may hold in the channel. A channel that stores no usable value is evaluated at 5, and a stored value above 100 is evaluated at 100
+
+3 One member with several connections counts once, and the requesting member is counted before the comparison
+
+A private call reads no `voice_connection_limit` and applies a fixed ceiling of 5 connections for each member.
+
+A member whose `communication_disabled_until` is still in the future is refused with `VOICE_MEMBER_TIMED_OUT` before any permission or capacity check runs. An account that has never claimed its credentials is refused with `VOICE_UNCLAIMED_ACCOUNT` for a one-on-one direct message call and for any guild voice channel whose guild it does not own. A group direct message call is not refused. A session that did not identify with `e2ee_capable` is refused with `VOICE_E2EE_REQUIRED` while the guild has voice encryption enabled and every connection already in the channel is capable, and a bot is exempt from that one.
+
+### Regions
+
+[List RTC regions](/http-api/channels/#list-rtc-regions) returns the [RTC region objects](/http-api/channels/#rtc-region-object) the caller MAY select for one guild voice channel. A region is returned only when the caller passes every restriction configured for it and at least one voice server accessible to the caller is active in it, so the array can be empty.
+
+`rtc_region` is written by [Modify channel](/http-api/channels/#modify-channel) and requires UPDATE_RTC_REGION. A null value selects automatic routing, and so does a stored value the placing account cannot reach.
+
+The first placement in the channel pins one voice server for it, and every later placement inherits that pinned server whatever its own coordinates are. A placement that finds no usable pin takes the accessible server nearest to the `latitude` and `longitude` the placement command supplied. A command that supplied no usable coordinates falls back to the deployment's default region, and then to the first accessible region.
+
+The pin drops when the channel's `rtc_region` changes, when a call changes region, when the pinned server stops being accessible, or when the media server reports the room finished. That last case also disconnects every connection in a guild voice channel.
+
+The literal `automatic` is not a channel region. Only the `region` field of [Modify call region](/http-api/calls/#modify-call-region) accepts it, as a synonym for null.
+
+An operator manages the regions and the voice servers registered inside them through the [Admin voice resource](/admin-api/voice/).
+
+## Private calls
+
+A private call is the direct message and group direct message counterpart of a voice channel. It has no moderator, no permission overwrites, and no moderator mute, deafen, or disconnect.
+
+The [Calls resource](/http-api/calls/) owns its HTTP surface, which reads whether the caller may ring, changes the region of an active call, rings recipients, and stops ringing them. [End call session](/http-api/calls/#end-call-session) ends no call. [Call Create](/gateway/events/#call-create), [Call Update](/gateway/events/#call-update), and [Call Delete](/gateway/events/#call-delete) publish the call state, and both joining and leaving are the same [Voice State Update](/gateway/commands/#voice-state-update) a guild voice channel uses.
+
+A recipient's [incoming call flags](/http-api/users/#incoming-call-flags) decide whether it is rung. The flags can admit nobody, friends only, friends of friends, guild members, or everyone, and they can admit everyone silently.
+
+[Get call eligibility](/http-api/calls/#get-call-eligibility) applies two conditions of the caller's own before it reads that policy. A caller already connected to the channel's call is reported as not ringable. So is an unclaimed account in a direct message. The operation applies no recipient policy to a group direct message, and reports one as ringable unless the caller is already connected to its call.
+
+[Ring call recipients](/http-api/calls/#ring-call-recipients) applies neither of those two conditions and evaluates the policy once per targeted recipient, in a group direct message as well as in a direct message. The result selects who is rung, so a recipient the policy excludes and a recipient it admits silently are both left out of the ringing set while the request still answers 204.
+
+## Go Live streams
+
+Going live publishes a screen share track, and its screen share audio track, on the LiveKit participant the member already holds in the channel. There is no second connection, no second participant, and no second `connection_id`. A member that held STREAM at placement already has both screen share sources in its grant, so no new credential is minted and no [Voice Server Update](/gateway/events/#voice-server-update) follows.
+
+The publisher advertises the stream by setting `self_stream` on that connection with [Voice State Update](/gateway/commands/#voice-state-update), naming the connection's own `connection_id`. The server bumps the voice state `version` and rebroadcasts the state as one [Voice State Update](/gateway/events/#voice-state-update).
+
+The voice state of a connection without STREAM in its channel has `self_stream` and `self_video` false, whatever the client sent. The guild clears both when a live connection loses STREAM, and rebroadcasts the state.
+
+A viewer declares which streams it is watching with `viewer_stream_keys` on its own voice state, and a channel move resets that list to empty.
+
+That connection's `connection_id` is the last segment of the [stream key](/http-api/streams/#stream-key), which is `{guild_id}:{channel_id}:{connection_id}` for a guild voice channel and `dm:{channel_id}:{connection_id}` for a private call.
+
+The [Streams resource](/http-api/streams/) owns the operations addressed by that key, which record a region preference and read, upload, and delete a JPEG preview image. Reading a preview takes the same access that lets a member join the channel, so any member holding CONNECT there MAY read it without owning the connection. Mutating one additionally requires STREAM on a guild channel and a voice state matching exactly the channel and the connection the key names.
+
+:::caution[An oversized track loses only its own source]
+On an instance that is not self-hosted, Fluxer mutes a camera or screen share track above 1280x720 from a member without the higher video quality entitlement, and removes that source from the connection's grant. The voice connection stays up.
+:::
+
+Fluxer removes screen share audio from the grant together with screen share, and removes camera on its own. The member keeps publishing its remaining sources, and a connection whose grant has no source left may publish nothing. A track published without a `sid` is neither muted nor revoked.
+
+## Entrance sounds
+
+An entrance sound is a short clip an account plays for everyone already connected to a voice channel. An account keeps a personal library of at most eight clips, each 100 through 5200 milliseconds and at most 1048576 decoded bytes, stored as `mp3`, `ogg`, `m4a`, or `wav`. It then assigns one clip per [scope](/http-api/entrance-sounds/#entrance-sound-scopes), and the four scopes are `global`, `guilds`, `dms`, and `guild:{guild_id}`.
+
+Fluxer records which clip belongs to which scope and nothing more. [Play entrance sound](/http-api/entrance-sounds/#play-entrance-sound) names the clip explicitly, so the client decides which selection applies to a given channel.
+
+Playback requires a voice state in the target channel and no channel permission. A caller that holds none is refused at the `channel_id` path with the validation code `ENTRANCE_SOUND_INVALID_SCOPE`. A successful call fans one [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Dispatch out to every other account with a voice state in the channel, at most once per account and never back to the caller. That Dispatch has the clip's CDN URL, and each recipient fetches and plays it locally, so no audio track is published for it.
+
+Every session the account holds receives the Dispatch, including sessions that are not in the channel. A client filters on `channel_id`.
+
+## Voice activity sharing
+
+An account chooses whether a friend is told which voice channel it is in. [Modify voice activity sharing](/http-api/users/settings/#modify-voice-activity-sharing) writes the account's default and rewrites the caller's side of every existing friendship to the same value in one operation. It then holds a 24 hour cooldown, and a second attempt inside that window is refused at the `share_voice_activity` path with the validation code `VOICE_ACTIVITY_SHARING_ON_COOLDOWN` and a `retry_after` in seconds.
+
+The stored result is `share_voice_activity` on the caller's own [relationship object](/http-api/users/relationships/#relationship-object), and `friend_shares_voice_activity` reports the reciprocal record. That reciprocal is resolved by [List relationships](/http-api/users/relationships/#list-relationships) and by the two [Relationship Update](/gateway/events/#relationship-update) Dispatches this operation emits for each rewritten friendship, one to the caller and one to the friend. Every other operation that returns a relationship object reports it as true.
+
+:::caution[The Gateway does not enforce voice activity sharing]
+A [voice state](/gateway/events/#voice-state-object) reaches every session that can view the channel whatever `share_voice_activity` holds, so an account that shares nothing is still visible there. A client MUST NOT present the flag as concealment.
+:::
diff --git a/fluxer_docs/src/fonts/Fonts.ts b/fluxer_docs/src/fonts/Fonts.ts
new file mode 100644
index 000000000..958df4864
--- /dev/null
+++ b/fluxer_docs/src/fonts/Fonts.ts
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+import {fontProviders} from 'astro/config';
+
+const fontsource = fontProviders.fontsource();
+
+type NonEmpty = [T, ...Array];
+
+type AstroFontFamily = {
+ name: string;
+ cssVariable: `--${string}`;
+ provider: typeof fontsource;
+ weights: NonEmpty;
+ styles: NonEmpty<'normal' | 'italic'>;
+ subsets: NonEmpty;
+ fallbacks: Array;
+};
+
+export const radioCanadaBigFont: AstroFontFamily = {
+ cssVariable: '--font-radio-canada-big',
+ fallbacks: [],
+ name: 'Radio Canada Big',
+ provider: fontsource,
+ styles: ['normal'],
+ subsets: ['latin', 'latin-ext'],
+ weights: ['400 700'],
+};
+
+export const notoSansFont: AstroFontFamily = {
+ cssVariable: '--font-noto-sans',
+ fallbacks: [],
+ name: 'Noto Sans',
+ provider: fontsource,
+ styles: ['normal'],
+ subsets: ['cyrillic', 'cyrillic-ext', 'devanagari', 'greek', 'greek-ext', 'vietnamese'],
+ weights: ['400 700'],
+};
+
+export const jetBrainsMonoFont: AstroFontFamily = {
+ cssVariable: '--font-jetbrains-mono',
+ fallbacks: [],
+ name: 'JetBrains Mono',
+ provider: fontsource,
+ styles: ['normal'],
+ subsets: ['latin', 'latin-ext'],
+ weights: ['400 700'],
+};
diff --git a/fluxer_docs/src/fonts/LatinFontFaces.astro b/fluxer_docs/src/fonts/LatinFontFaces.astro
new file mode 100644
index 000000000..03e668f5c
--- /dev/null
+++ b/fluxer_docs/src/fonts/LatinFontFaces.astro
@@ -0,0 +1,14 @@
+---
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import {Font} from 'astro:assets';
+
+interface Props {
+ readonly preloadFallback: boolean;
+}
+
+const {preloadFallback} = Astro.props;
+---
+
+