feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

@@ -89,12 +89,14 @@ The request field is a free string of 1 through 64 characters, so a value outsid
Every `url`, `url-domain`, `file-sha`, and `avatar-hash` row has a severity. A row created without an explicit severity is stored as `2`.
The severity is a stored label for moderators. Fluxer refuses the content of every matching row whatever its severity, so a row stored as `0` or `1` blocks exactly like one stored as `2`. Delete a row to stop it blocking.
| 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 |
| 0 | ALLOW | Labelled as content to allow |
| 1 | WARN | Labelled as content to warn about |
| 2 | BLOCK | Labelled as content to refuse |
| 3 | BLOCK_AND_REPORT | Labelled as content to refuse and report |
## Profile substring scopes
@@ -162,7 +164,7 @@ One stored row of one blocklist. Every field is present on every entry, and a fi
| value<sup>1</sup> | string | The canonical stored value (1-2048 characters) |
| scope<sup>2</sup> | ?string | The [profile substring scope](#profile-substring-scopes) the row is stored under |
| category<sup>3</sup> | ?string | The [content blocklist category](#content-blocklist-categories) the row was recorded under |
| severity<sup>3</sup> | ?integer | The [content blocklist severity](#content-blocklist-severities) applied when the row matches |
| severity<sup>3</sup> | ?integer | The [content blocklist severity](#content-blocklist-severities) label stored on the row |
| source_url<sup>3</sup> | ?string | The feed or evidence URL the row was recorded from |
| notes<sup>4</sup> | ?string | The internal note stored alongside the row |
| content_type<sup>5</sup> | ?string | The media type hint recorded alongside a `file-sha` row |
@@ -143,7 +143,7 @@ Every task writes one summary Admin audit entry when it finishes, with the actio
`delete_user_messages` deletes every message each account wrote, across every channel. It writes one `delete_all_user_messages` entry for each account, with the audit reason, the account, and the deleted message count, and reports progress after every account.
`schedule_user_deletion` marks each account deleted. The task runs the same steps as [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one account. It stores the reason code, public reason, and audit reason on the account, reschedules its pending deletion, terminates its sessions, cancels and refunds its Stripe subscription when one is on file, dispatches [User Update](/gateway/events/#user-update), writes one `schedule_deletion` entry for each account with the audit reason and the reason code, and emails the account holder when an address is on file. A failed email is logged and does not fail the entity. When the reason is not `USER_REQUESTED`, the task also bans the account's identifiers and resolves the pending reports against it.
`schedule_user_deletion` marks each account deleted. The task runs the same steps as [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one account. It stores the reason code, public reason, and audit reason on the account, reschedules its pending deletion, terminates its sessions, cancels and refunds its Stripe subscription when one is on file, dispatches [User Update](/gateway/events/#user-update), writes one `schedule_deletion` entry for each account with the audit reason and the reason code, and emails the account holder when `notify_user` is true and an address is on file. A failed email is logged and does not fail the entity. The summary entry records `notify_user`. When the reason is not `USER_REQUESTED`, the task also bans the account's identifiers. When the reason is one that resolves reports under [Schedule user deletion](/admin-api/users/#schedule-user-deletion), the task also resolves the pending reports against the account, leaves the `child_safety`, `underage_user`, and `self_harm` categories pending, and sends each reporter of a resolved report the generic notice [Update report](/admin-api/reports/#update-report) describes. The skip cases of that notice apply.
### Rate limit
@@ -288,6 +288,7 @@ An entry with one of these actions has `access` set to `read`.
| list_guild_members | The members of a guild were listed |
| list_guild_memory_stats | Guild memory statistics were listed |
| list_guild_stickers | The stickers of a guild were listed |
| list_report_reasons | The report reason catalog was listed |
| list_user_applications | The applications an account owns were read |
| list_user_change_log | The contact change log of an account was read |
| list_user_dm_channels | The direct message channels of an account were read |
@@ -313,7 +314,7 @@ An entry with any other action has `access` set to `write`.
| NCMEC Report | An attachment was submitted through the NCMEC reporting integration |
| approve_discovery_application | A discovery application was approved |
| approve_registration | A pending registration was approved |
| auto_resolve_reports_on_deletion | Reports against an account were resolved automatically as part of its deletion |
| auto_resolve_reports_on_deletion | The pending reports against an account were processed 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 |
@@ -337,6 +338,7 @@ An entry with any other action has `access` set to `write`.
| change_email | An email address was changed |
| change_username | A username or discriminator was changed |
| clear_fields<sup>1</sup> | Profile fields of an account, or fields of a guild, were cleared |
| clear_report_legal_hold | The legal hold on a report was cleared |
| create_admin_api_key | An Admin API key was created |
| create_password_reset_link | A password reset link was created for an account |
| create_registration_url | A registration URL was issued |
@@ -376,6 +378,7 @@ An entry with any other action has `access` set to `write`.
| 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_report_legal_hold | A legal hold was placed on a report |
| 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 |
@@ -574,7 +577,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
| 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:resolve | Updates and resolves [reports](/admin-api/reports/), and places legal holds on them |
| report:view | Reads and searches reports |
| report:view:reporter_pii<sup>6</sup> | Unredacts the contact fields a reporter supplied |
| system_dm:send | Sends an official direct message from the instance to one or many accounts |
@@ -1,14 +1,14 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Admin reports
description: Report listing and search, captured message evidence, and report resolution.
description: Report listing and search, captured evidence, report resolution, and report retention.
---
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.
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, resolve a report with a comment shown to the reporter, and hold a report past its retention period.
Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the category registries `category` is drawn from.
Report submission belongs to the public [Reports](/http-api/reports/) resource, which defines the [report categories](/http-api/reports/#report-categories) and the [report reasons](/http-api/reports/#report-reasons) a report is filed under.
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.
@@ -22,7 +22,7 @@ Fluxer reads the tag, username, display name, and discriminator of both accounts
## 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.
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 sends each reporter the generic notice [Update report](#update-report) describes. A report is deleted at the end of its [retention period](#report-retention).
[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.
@@ -32,7 +32,7 @@ A report is written once, when the reporter files it. [Update report](#update-re
| --- | --- | --- |
| report_id | snowflake | The ID of the report |
| reporter_id<sup>1</sup> | ?snowflake | The account that filed the report |
| reporter_tag<sup>1</sup> <sup>2</sup> <sup>10</sup> | ?string | The reporter username and four-digit discriminator joined by `#` |
| reporter_tag<sup>1</sup> <sup>2</sup> | ?string | The reporter username and four-digit discriminator joined by `#` |
| reporter_username<sup>1</sup> <sup>2</sup> | ?string | The username of the reporter |
| reporter_global_name<sup>1</sup> <sup>2</sup> | ?string | The display name of the reporter, or null when unset |
| reporter_discriminator<sup>1</sup> <sup>2</sup> | ?string | The zero-padded four-digit reporter discriminator |
@@ -42,14 +42,36 @@ A report is written once, when the reporter files it. [Update report](#update-re
| 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 |
| category | ?string | The [report category](/http-api/reports/#report-categories), derived from `reason` when the report has one |
| reason?<sup>7</sup> <sup>10</sup> | ?string | The [report reason](/http-api/reports/#report-reasons) the report was filed under |
| reason_label?<sup>7</sup> <sup>11</sup> | ?string | The English label of the reason |
| reason_highest_priority?<sup>7</sup> <sup>12</sup> | ?boolean | Whether the reason is one moderators see first |
| flow?<sup>7</sup> <sup>10</sup> | ?[report flow answers](#report-flow-answers-object) object | The answers the reporter gave, described in English |
| reporter_good_faith_confirmed?<sup>7</sup> <sup>13</sup> | ?boolean | Whether the reporter confirmed the notice in good faith |
| 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_tag<sup>2</sup> <sup>10</sup> | ?string | The reported username and four-digit discriminator joined by `#` |
| reported_user_id<sup>14</sup> | ?snowflake | The reported account, or null for a guild report |
| reported_user_tag<sup>2</sup> | ?string | The reported username and four-digit discriminator joined by `#` |
| reported_user_username<sup>2</sup> | ?string | The username of the reported account |
| reported_user_global_name<sup>2</sup> | ?string | The display name of the reported account, or null when unset |
| reported_user_discriminator<sup>2</sup> | ?string | The zero-padded four-digit reported account discriminator |
| reported_user_avatar_hash<sup>4</sup> | ?string | The avatar hash of the reported account, or null when unset |
| reported_user_bot?<sup>2</sup> <sup>7</sup> <sup>17</sup> | ?boolean | Whether the reported account is a bot |
| reported_webhook_id?<sup>7</sup> <sup>14</sup> | ?snowflake | The [webhook](/http-api/webhooks/) that sent the reported message |
| reported_webhook_name?<sup>4</sup> <sup>7</sup> <sup>14</sup> | ?string | The name the reported message was sent under |
| reported_webhook_avatar_hash?<sup>4</sup> <sup>7</sup> <sup>14</sup> | ?string | The avatar hash the reported message was sent with, or null when unset |
| reported_webhook_default_name?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?string | The name set on the webhook itself |
| reported_webhook_default_avatar_hash?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?string | The avatar hash set on the webhook itself, or null when unset |
| reported_webhook_type?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?integer | The [webhook type](/http-api/webhooks/#webhook-types) |
| reported_webhook_application_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The [application](/http-api/applications/#application-object) whose bot account created the webhook, or null when a user account created it |
| reported_webhook_channel_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The channel the webhook belonged to |
| reported_webhook_guild_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The guild the webhook belonged to |
| reported_webhook_created_at?<sup>7</sup> <sup>14</sup> | ?ISO8601 timestamp | When the webhook was created, derived from its ID |
| reported_webhook_creator_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The account that created the webhook |
| reported_webhook_creator_tag?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The creator username and four-digit discriminator joined by `#` |
| reported_webhook_creator_username?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The username of the creator |
| reported_webhook_creator_global_name?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The display name of the creator, or null when unset |
| reported_webhook_creator_discriminator?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The zero-padded four-digit creator discriminator |
| reported_webhook_creator_avatar_hash?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The avatar hash of the creator, or null when unset |
| reported_guild_id | ?snowflake | The reported guild, or null when the report names none |
| reported_guild_name<sup>4</sup> | ?string | The name of the reported guild |
| reported_guild_icon_hash<sup>4</sup> | ?string | The icon hash of the reported guild, or null when unset |
@@ -76,7 +98,7 @@ A report is written once, when the reporter files it. [Update report](#update-re
<sup>2</sup> Resolved from the account when the response is produced, and null when the account can no longer be read
<sup>3</sup> Null unless the caller holds `report:view:reporter_pii`, and null when the reporter supplied no value
<sup>3</sup> Null unless the caller holds `report:view:reporter_pii`, and null when the reporter supplied no value. For a report filed from an account, `reporter_email` is the current address of that account, and null when the account no longer exists or has no address. For a Digital Services Act notice it is the verified address the notice was filed with
<sup>4</sup> Captured when the report was filed and never updated afterwards
@@ -90,7 +112,21 @@ A report is written once, when the reporter files it. [Update report](#update-re
<sup>9</sup> Null while the report is PENDING and set when it moves to RESOLVED
<sup>10</sup> On an instance with the `none` tag style the tag of an account that is not a bot is the bare username, with no `#0000`
<sup>10</sup> Null on a report filed without a report flow, which includes every report filed before report flows existed
<sup>11</sup> Null when `reason` is null, and the raw reason key when this API does not know the reason
<sup>12</sup> Null when `reason` is null or unknown to this API
<sup>13</sup> True on a Digital Services Act notice filed through a report flow, and null on every other report
<sup>14</sup> A message a webhook sent has no author account, so its report has a null `reported_user_id` and null reported user fields, and records the webhook instead. Every webhook field is null on any other report, and a message a bot account sent is reported as that account
<sup>15</sup> Read from the webhook when the report was filed, and null when the webhook had already been deleted by then
<sup>16</sup> Captured from the account that created the webhook. Null when that account had been deleted by the time the report was filed, while `reported_webhook_creator_id` still names it. The creator is context for moderators and is not the reported account, so `reported_user_id` stays null
<sup>17</sup> Null when the report names no reported account, which includes a report about a message a webhook sent
### Example
@@ -104,8 +140,23 @@ A report is written once, when the reporter files it. [Update report](#update-re
"status": 0,
"report_type": 0,
"category": "harassment",
"additional_info": "Repeated after being asked to stop.",
"reason": "harassment",
"reason_label": "Harassment or bullying",
"reason_highest_priority": false,
"flow": {
"revision_hash": "6b1f0c9e2a7d4413",
"surface": "in_app",
"locale": "de",
"steps": [
{"screen_id": "root_message", "screen_title": "Report message", "option_id": "abuse", "option_label": "Abusive or harmful content", "items": []},
{"screen_id": "abuse", "screen_title": "What does it involve?", "option_id": "harassment", "option_label": "Bullying, harassment or stalking", "items": []},
{"screen_id": "harassment", "screen_title": "How is someone being targeted?", "option_id": "harassment_direct", "option_label": "Repeatedly targeting someone with insults or abuse", "items": []}
]
},
"reporter_good_faith_confirmed": null,
"additional_info": null,
"reported_user_id": "1490118377238528000",
"reported_user_bot": false,
"reported_guild_id": "1471002884199612416",
"reported_message_id": "1502771119476146176",
"reported_channel_id": "1471002884199612420",
@@ -143,13 +194,16 @@ The detail object extends the [Admin report object](#admin-report-object) with t
| mutual_dm_channel_id<sup>1</sup> | ?snowflake | The direct message channel shared by the reporter and the reported account |
| message_context<sup>2</sup> | array[[report message context](#report-message-context-object) object] | The messages captured around the reported content |
| message_responses<sup>3</sup> | array[[message](/http-api/messages/#message-object) object] | The current state of the same messages, read live |
| reported_profile_snapshot?<sup>4</sup> | ?[report profile snapshot](#report-profile-snapshot-object) object | The reported profile as it was when the report was filed |
<sup>1</sup> 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
<sup>2</sup> Empty for a report that captured no message context, which is every user report and every guild report
<sup>2</sup> Empty for a report that captured no message context, which is every user report and every guild report. An entry for which neither the capture nor the report records a channel is left out
<sup>3</sup> Resolved when the response is produced, so an edited message differs from its captured entry
<sup>4</sup> Always emitted. Null for a report filed before profile snapshots were stored, for a report about a message a webhook sent, and when the stored snapshot cannot be read
:::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.
:::
@@ -158,14 +212,14 @@ Fluxer skips an entry when neither the capture nor the report records its channe
A context entry preserves a message as it was when the report was filed. Later edits or deletions do not change it, and attachment evidence survives 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.
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 Digital Services Act notice about a direct message, a group DM, or a guild channel that @everyone cannot view and read the history of captures the reported message alone. A message whose author account no longer existed at capture time is kept under the `DeletedUser` identity with discriminator `0000` and its original author ID. A message a webhook sent is captured under the name and avatar it was sent with.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the message |
| channel_id<sup>1</sup> | string | The channel the captured messages were read from |
| channel_id<sup>1</sup> | snowflake | The channel the captured messages were read from |
| channel_nsfw<sup>2</sup> | ?boolean | The current NSFW state of that channel |
| channel_content_warning_level?<sup>3</sup> <sup>4</sup> | ?integer | The channel [content warning level](/http-api/channels/#content-warning-levels) |
| channel_content_warning_text?<sup>3</sup> <sup>4</sup> | ?string | The channel content warning text |
@@ -177,14 +231,17 @@ A message report captures the reported message together with at most 25 messages
| content | string | The captured message content, empty when the message had none |
| timestamp<sup>6</sup> | ISO8601 timestamp | When the message was sent |
| attachments<sup>7</sup> | 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_id<sup>9</sup> | snowflake | The author of the message |
| author_username<sup>9</sup> | string | The captured author username |
| author_global_name<sup>4</sup> | ?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 |
| author_discriminator<sup>9</sup> | string | The captured zero-padded four-digit author discriminator |
| author_avatar<sup>9</sup> | ?string | The captured author avatar hash, or null when unset |
| webhook_id?<sup>3</sup> <sup>10</sup> | ?snowflake | The [webhook](/http-api/webhooks/) that sent the message |
| author_bot?<sup>3</sup> <sup>11</sup> | ?boolean | Whether the author account is a bot |
| missing_attachments?<sup>3</sup> <sup>12</sup> | array[[report missing attachment](#report-missing-attachment-object) object] | Attachments that could not be copied into the report |
| user_prior_ncmec_report_ids?<sup>3</sup> <sup>8</sup> | array[string] | The NCMEC report IDs previously filed against the author |
<sup>1</sup> 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
<sup>1</sup> The channel of the captured entry, otherwise the reported channel. An entry for which neither records a channel is left out of `message_context`
<sup>2</sup> Read from the current channel or guild, and null when the record can no longer be read
@@ -198,7 +255,15 @@ A message report captures the reported message together with at most 25 messages
<sup>7</sup> 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
<sup>8</sup> Sorted ascending, with at most 100 entries, and empty when no NCMEC report has been filed against the author
<sup>8</sup> Sorted ascending, with at most 100 entries, and empty when no NCMEC report has been filed against the author or a webhook sent the message
<sup>9</sup> For a message a webhook sent, the webhook ID, the name and avatar hash the message was sent with, and the discriminator `0000`, as the [message object](/http-api/messages/#message-object) shows the author of a webhook message
<sup>10</sup> Null for a message an account sent
<sup>11</sup> Read from the author account when the response is produced. Null for a message a webhook sent and for an author account that no longer resolves
<sup>12</sup> Empty when every attachment was copied, and on a report filed before missing attachments were recorded. An attachment is listed here when its copy failed or, for a file embedded in the message, when the file was no longer stored
### Report message attachment object
@@ -208,19 +273,19 @@ A message report captures the reported message together with at most 25 messages
| --- | --- | --- |
| id | snowflake | The ID of the attachment |
| filename | string | The captured file name |
| url<sup>9</sup> | string | The download URL for the evidence copy |
| url<sup>13</sup> | 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?<sup>3</sup> | ?integer | The size in bytes, or null when unknown |
| ncmec_status<sup>10</sup> | string | The [NCMEC submission status](#ncmec-submission-statuses) |
| ncmec_status<sup>14</sup> | 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 |
<sup>9</sup> The URL expires after five minutes. Fetch the report again for fresh attachment links
<sup>13</sup> The URL expires after five minutes. Fetch the report again for fresh attachment links
<sup>10</sup> An in-flight submission is stored as `submitting`, a value the registry below does not list
<sup>14</sup> An in-flight submission is stored as `submitting`, a value the registry below does not list
#### NCMEC submission statuses
@@ -230,6 +295,194 @@ A message report captures the reported message together with at most 25 messages
| submitted | The attachment has been submitted to NCMEC |
| failed | Submission to NCMEC was attempted and failed |
### Report missing attachment object
An attachment of a captured message that Fluxer could not copy into the report. It has the captured metadata and no download URL.
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the attachment |
| filename | string | The captured file name |
| 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<sup>15</sup> | ?integer | The size in bytes, or null when unknown |
<sup>15</sup> `0` for a file embedded in the message that was no longer stored when the report was filed
## Report profile snapshot object
The reported profile as it was when the report was filed. A user report keeps the account, a guild report keeps the guild, and a message report keeps the author account. Each image is a copy Fluxer made when the report was filed, so a later profile change or deletion does not affect it.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| captured_at | ISO8601 timestamp | When the snapshot was taken |
| user<sup>1</sup> | ?[snapshot user](#snapshot-user-object) object | The reported account |
| member<sup>2</sup> | ?[snapshot member](#snapshot-member-object) object | The guild profile of the reported account |
| guild<sup>3</sup> | ?[snapshot guild](#snapshot-guild-object) object | The reported guild |
<sup>1</sup> Null on a guild report
<sup>2</sup> Set when the report names a guild the account was a member of, which is a user report with `guild_id` or a message report in a guild channel
<sup>3</sup> Set on a guild report only
### Snapshot user object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the account |
| username<sup>4</sup> | ?string | The username |
| discriminator<sup>4</sup> | ?string | The zero-padded four-digit discriminator |
| global_name<sup>4</sup> | ?string | The display name, or null when unset |
| bio<sup>4</sup> | ?string | The profile bio, or null when unset |
| pronouns<sup>4</sup> | ?string | The pronouns, or null when unset |
| avatar<sup>4</sup> | ?[snapshot asset](#snapshot-asset-object) object | The avatar, or null when unset |
| banner<sup>4</sup> | ?[snapshot asset](#snapshot-asset-object) object | The profile banner, or null when unset |
<sup>4</sup> Null for an account whose deletion had started when the report was filed, which is kept by ID alone with no member profile
### Snapshot member object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| guild_id | snowflake | The guild the profile belongs to |
| nick | ?string | The guild nickname, or null when unset |
| bio | ?string | The guild profile bio, or null when unset |
| pronouns | ?string | The guild profile pronouns, or null when unset |
| joined_at | ?ISO8601 timestamp | When the account joined the guild |
| avatar | ?[snapshot asset](#snapshot-asset-object) object | The guild avatar, or null when unset |
| banner | ?[snapshot asset](#snapshot-asset-object) object | The guild profile banner, or null when unset |
### Snapshot guild object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | snowflake | The ID of the guild |
| name | ?string | The guild name |
| vanity_url_code | ?string | The vanity invite code, or null when unset |
| icon | ?[snapshot asset](#snapshot-asset-object) object | The icon, or null when unset |
| banner | ?[snapshot asset](#snapshot-asset-object) object | The banner, or null when unset |
| splash | ?[snapshot asset](#snapshot-asset-object) object | The invite splash, or null when unset |
### Snapshot asset object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| hash | string | The asset hash when the report was filed |
| url<sup>5</sup> | ?string | The download URL for the copy |
<sup>5</sup> Expires after five minutes. Null when the image could not be copied into the report, or when no download URL could be made
### Example
```json
{
"captured_at": "2026-03-04T18:22:10.502Z",
"user": {
"id": "1490118377238528000",
"username": "marlow",
"discriminator": "0117",
"global_name": "Marlow",
"bio": "Ask me about trains",
"pronouns": null,
"avatar": {"hash": "4be1c7a09d2e3f56", "url": "https://reports.example.com/reports/1502771904419102720/profile/user_avatar/4be1c7a09d2e3f56?X-Amz-Expires=300"},
"banner": null
},
"member": {
"guild_id": "1471002884199612416",
"nick": "Conductor",
"bio": null,
"pronouns": null,
"joined_at": "2026-01-12T09:41:00.000Z",
"avatar": null,
"banner": null
},
"guild": null
}
```
## Report flow answers object
The answers a reporter gave in a [report flow](/http-api/reports/#report-flows), described in English whatever locale the reporter saw. Fluxer builds the description from the stored IDs when it produces the response, so the labels follow the current catalog.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| revision_hash | string | The [revision hash](/http-api/reports/#revision-hash) the reporter's client sent |
| surface | string | The [report flow surface](/http-api/reports/#report-flow-surfaces), `in_app` or `dsa` |
| locale | ?string | The [locale](/topics/locales/) the reporter saw the flow in |
| steps | array[[report flow answer step](#report-flow-answer-step-object) object] | The answered screens in walk order |
### Report flow answer step object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| screen_id | string | The ID of the screen |
| screen_title<sup>1</sup> | string | The English title of the screen |
| option_id<sup>2</sup> | ?string | The ID of the chosen option |
| option_label<sup>1</sup> <sup>2</sup> | ?string | The English label of the chosen option |
| items<sup>3</sup> | array[[report flow answer item](#report-flow-answer-item-object) object] | The ticked checklist items |
<sup>1</sup> The raw ID when the current catalog no longer has the screen or option
<sup>2</sup> Null on a checklist screen and an info screen
<sup>3</sup> In checklist order, not the order the reporter ticked them, and empty on a choice screen and an info screen
### Report flow answer item object
#### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | string | The ID of the item |
| label | string | The English label of the item, or its raw ID when the current catalog no longer has it |
## Report reason object
One entry of the [report reason](/http-api/reports/#report-reasons) catalog, as [List report reasons](#list-report-reasons) returns it.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| key | string | The reason key stored on a report |
| label | string | The English label |
| highest_priority | boolean | Whether moderators see reports with this reason first |
| legacy_category_message | string | The [category](/http-api/reports/#report-categories) a message report stores |
| legacy_category_user | string | The category a user report stores |
| legacy_category_guild | string | The category a guild report stores |
### Example
```json
{
"key": "csam",
"label": "Child sexual abuse material",
"highest_priority": true,
"legacy_category_message": "child_safety",
"legacy_category_user": "child_safety",
"legacy_category_guild": "child_safety"
}
```
## 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.
@@ -254,15 +507,45 @@ A report resolution is the reduced view [Update report](#update-report) returns.
}
```
## Report legal hold object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| report_id | snowflake | The ID of the report |
| legal_hold_until | ?ISO8601 timestamp | When the hold ends, or null when the report has no hold |
| legal_hold_reason | ?string | Why the report is held, or null when the report has no hold |
### Example
```json
{
"report_id": "1502771904419102720",
"legal_hold_until": "2027-06-30T00:00:00.000Z",
"legal_hold_reason": "Preservation request from a court"
}
```
## Report retention
Fluxer deletes a report 365 days after it was filed, whether it is pending or resolved, together with its stored evidence: the attachment copies, the profile snapshot images, and its search index entry. A report with a [legal hold](#set-report-legal-hold) that ends later is kept until the next run after the hold ends. An evidence copy that a retained report also uses is kept for that report.
The deletion runs once a day in `worker`. Each run logs one `Processed report retention` line with the counts it found and deleted. A report that fails to delete keeps its record and is retried on the next run.
:::caution[Retention starts in dry-run mode]
Until `FLUXER_REPORT_RETENTION_DRY_RUN` is set to `false` on `worker`, each run only counts what it would delete and deletes nothing. The [operator configuration](/operator/configuration/#moderation-and-abuse) describes the setting.
:::
## List reports
<RouteHeader method="GET" path="/v1/admin/reports" auditReason />
Returns a page of [Admin report](#admin-report-object) objects without message context. Requires `report:view`.
The operation has these 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 operation has two branches. Both read the report search index, and both return `total`, `offset`, and `limit` beside the page. A request that supplies `status` and no other filter takes the status branch, which lists the reports with that status newest first by `reported_at` and ignores `sort_by` and `sort_order`. Every other request takes the search branch, which applies the supplied filters and sort.
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.
The search branch applies no status filter when `status` is omitted, so a request with no query string returns reports of every status.
An instance with no search backend returns 403 `FEATURE_TEMPORARILY_DISABLED` on both branches.
@@ -274,39 +557,37 @@ An instance with no search backend returns 403 `FEATURE_TEMPORARILY_DISABLED` on
| 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) |
| reason? | string | Restrict results to one [report reason](/http-api/reports/#report-reasons) key (1-64 characters) |
| reporter_id? | snowflake | Restrict results to one reporter |
| reported_user_id? | snowflake | Restrict results to one reported account |
| reported_webhook_id? | snowflake | Restrict results to messages one [webhook](/http-api/webhooks/) sent |
| reported_guild_id? | snowflake | Restrict results to one reported guild |
| reported_channel_id?<sup>2</sup> | snowflake | Restrict results to one reported channel |
| reported_channel_id? | 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?<sup>3</sup> | created_at \| reported_at \| resolved_at | Sort field (default `reported_at`) |
| sort_by?<sup>2</sup> | 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?<sup>4</sup> | integer | Reports to skip (0-10000, default 0) |
| offset?<sup>3</sup> | integer | Reports to skip (0-10000, default 0) |
<sup>1</sup> Matched against `category`, `additional_info`, `reported_guild_name`, and `reported_channel_name` only. Omitting it matches every report satisfying the remaining filters
<sup>2</sup> The route accepts it, and supplying it with no other filter still selects the search branch. The search ignores its value and returns matching reports from every channel
<sup>2</sup> `created_at` orders by the time encoded in the report ID, and `reported_at` by the submission time stored on the report
<sup>3</sup> `created_at` orders by the time encoded in the report ID, and `reported_at` by the submission time stored on the report
<sup>4</sup> The next page is requested with a larger `offset`
<sup>3</sup> The next page is requested with a larger `offset`
### Response body
| Field | Type | Description |
| --- | --- | --- |
| reports<sup>1</sup> | array[[Admin report](#admin-report-object) object] | Reports in this page, without message context |
| total?<sup>2</sup> <sup>3</sup> | integer | Index hits matching the filters, less the hits on this page that no longer resolve |
| offset?<sup>2</sup> | integer | The offset this page was read from |
| limit?<sup>2</sup> | integer | The limit this page was read with |
| total<sup>2</sup> | integer | Index hits matching the filters |
| offset | integer | The offset this page was read from |
| limit | integer | The limit this page was read with |
<sup>1</sup> 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`
<sup>1</sup> 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`. Fluxer logs the dropped IDs, and a [Refresh search index](/admin-api/search-indexes/#refresh-search-index) of `reports` removes them from the index
<sup>2</sup> Present on the search branch only, so the status-only branch reports no total
<sup>3</sup> 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
<sup>2</sup> The index count, which still includes hits whose report record no longer exists
### Response
@@ -317,7 +598,35 @@ An instance with no search backend returns 403 `FEATURE_TEMPORARILY_DISABLED` on
### Side effects
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `search_reports`, target type `report`, and target ID `0` on both branches. The metadata keys are `limit`, `offset`, `result_count` for the reports in this page, and `total`. The search branch adds `sort_by` and `sort_order`. A supplied `q` adds `has_query` with the value `true`, and the query text is not recorded. The supplied filters add `status`, `report_type`, `reported_user_id`, `reported_guild_id`, and `reported_channel_id` under their own names, `reporter_id` as `reporter_user_id`, `guild_context_id` as `context_guild_id`, and `resolved_by_admin_id` as `resolved_by_admin_user_id`. `category` is not recorded. A 403 records nothing.
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `search_reports`, target type `report`, and target ID `0` on both branches. The metadata keys are `limit`, `offset`, `result_count` for the reports in this page, and `total`. The search branch adds `sort_by` and `sort_order`. A supplied `q` adds `has_query` with the value `true`, and the query text is not recorded. The supplied filters add `status`, `report_type`, `category`, `reason`, `reported_user_id`, `reported_webhook_id`, `reported_guild_id`, and `reported_channel_id` under their own names, `reporter_id` as `reporter_user_id`, `guild_context_id` as `context_guild_id`, and `resolved_by_admin_id` as `resolved_by_admin_user_id`. A 403 records nothing.
### Rate limit
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
## List report reasons
<RouteHeader method="GET" path="/v1/admin/report-reasons" auditReason />
Returns every [report reason](/http-api/reports/#report-reasons) a report flow can file a report under, with the category each one stores for each report type. Requires `report:view`.
The labels are English. The list is the catalog this API was built with, so an admin client can label and filter a reason without a copy of its own.
### Response body
| Field | Type | Description |
| --- | --- | --- |
| reasons | array[[report reason](#report-reason-object) object] | Every reason, in catalog order |
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | The catalog was returned |
### Side effects
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_report_reasons`, target type `report`, target ID `0`, and the metadata key `result_count`.
### Rate limit
@@ -372,13 +681,17 @@ Resolves one report and returns a [report resolution](#report-resolution-object)
| --- | --- | --- |
| status | resolved | The status to move the report to |
| public_comment?<sup>1</sup> | string | The comment shown to the reporter (0-512 characters) |
| notify_reporter? | boolean | Whether to notify the reporter by system DM and email (default true) |
| resolution? | string | How the report was resolved: `actioned`, `no_violation` or `duplicate` |
| notify_reporter?<sup>2</sup> | boolean | Whether the notice to the reporter includes `public_comment` (default true) |
| resolution?<sup>3</sup> | string | How the report was resolved, one of `actioned`, `no_violation`, or `duplicate` |
<sup>1</sup> Stored as null when it is omitted or empty, and shown to the reporter in the resolution notice otherwise
<sup>1</sup> Stored as null when it is omitted or empty. Otherwise it is stored, and the resolution notice shows it to the reporter unless `notify_reporter` is false
<sup>2</sup> The reporter is notified either way. False sends the generic notice without the comment, and the comment is still stored on the report
<sup>3</sup> Optional, so older admin clients that send none still work. It is not stored on the report, not returned by any report route, and not shown to the reporter. It is recorded in the audit entry
:::caution[Resolution is terminal]
Resolving a report notifies the reporter unless `notify_reporter` is false. A resolved report never returns to PENDING, and a second update against it returns 400 `REPORT_ALREADY_RESOLVED` without changing the stored public comment.
Resolving a report notifies the reporter whatever `notify_reporter` says, as the side effects below describe. 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
@@ -393,12 +706,53 @@ Resolving a report notifies the reporter unless `notify_reporter` is false. A re
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`, `report_type`, `notify_reporter`, `reporter_dm_sent`, and `reporter_email_sent`, plus `resolution` when the request sets it.
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`, `report_type`, `notify_reporter`, `reporter_dm_sent`, `reporter_email_sent`, and `reporter_dsa_email_sent`. When Fluxer does not attempt a notice, `reporter_notice_skipped` names the reason: `reporter_account_deleted`, `reporter_report_banned`, `reporter_lookup_failed`, or `no_reporter_email`.
Unless `notify_reporter` is false, 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.
Fluxer notifies the reporter of every resolution. A reporter with an account receives a system direct message, and an email when the account has an address. A Digital Services Act notice has no reporter account, so its verified address receives a decision email with the report ID, the comment when one is shared, and how to appeal. Nothing is sent when the reporter account is deleted or barred from reporting.
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.
## Set report legal hold
<RouteHeader method="POST" path="/v1/admin/reports/{report_id}/legal-hold" auditReason />
Places or clears a legal hold on one report and returns a [report legal hold](#report-legal-hold-object) object. Requires `report:resolve`.
A held report is kept past the end of its [retention period](#report-retention) until the hold ends. A hold can be placed on a pending or a resolved report, and a new hold replaces the previous one.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| report_id | snowflake | The ID of the report |
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| legal_hold_until<sup>1</sup> | ?ISO8601 timestamp | When the hold ends, or null to clear the hold |
| legal_hold_reason?<sup>2</sup> | ?string | Why the report is kept (1-512 characters) |
<sup>1</sup> Must be in the future. A time in the past, or a value that is not an ISO 8601 timestamp, returns 400 `INVALID_FORM_BODY` on `legal_hold_until`
<sup>2</sup> Required to place a hold, and a missing or null reason returns 400 `INVALID_FORM_BODY` on `legal_hold_reason`. Ignored when the hold is cleared, which also clears the reason
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [report legal hold](#report-legal-hold-object) object | The hold was placed or cleared |
| 400 | [error response](/admin-api/#error-response) | The end time or the reason is invalid and the request returns `INVALID_FORM_BODY` |
| 404 | [error response](/admin-api/#error-response) | Report does not exist and the request returns `UNKNOWN_REPORT` |
### Side effects
Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with target type `report` and the report ID as target ID. Placing a hold records action `set_report_legal_hold` with the metadata keys `report_id`, `report_type`, `legal_hold_until`, and `legal_hold_reason`. Clearing a hold records action `clear_report_legal_hold` with `report_id` and `report_type`. Either adds `previous_legal_hold_until` when the report already had a hold end time stored, including one that has passed. A rejected request records nothing.
### Rate limit
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
@@ -1588,7 +1588,7 @@ The account holder is emailed the deadline and the supplied `public_reason` when
For every reason other than `USER_REQUESTED`, Fluxer also blocks the account's email address and resolves the pending reports against the account. These enforcement steps are best-effort and can fail without cancelling the deletion schedule.
[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`, `reason_code`, and `pending_deletion_at`. A replacement adds `replaced_pending_deletion_at`, `replaced_scheduled_by`, `replaced_scheduled_at`, and `replaced_reason_code` for the schedule it replaced. The identifier bans record their own blocklist entries, and when Fluxer resolves at least one report, it records a second entry with action `auto_resolve_reports_on_deletion` and a metadata key `resolved_count`.
[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`, `reason_code`, and `pending_deletion_at`. A replacement adds `replaced_pending_deletion_at`, `replaced_scheduled_by`, `replaced_scheduled_at`, and `replaced_reason_code` for the schedule it replaced. The identifier bans record their own blocklist entries. For every reason other than `USER_REQUESTED`, Fluxer also records a second entry with action `auto_resolve_reports_on_deletion`, target type `user`, and the metadata keys `outcome`, `found_count`, `resolved_count`, `failed_count`, and `notified_count`. `outcome` is `completed`, `search_unavailable` when the instance has no report search backend and every report stays pending, or `enumeration_failed` when listing the pending reports failed part way and only those already found were resolved. Each resolved report sends its reporter the generic notice [Update report](/admin-api/reports/#update-report) describes, with no public comment.
### Rate limit
@@ -20,7 +20,7 @@ An OAuth2 protocol failure raised by the [OAuth2 resource](/http-api/oauth2/) an
## Supplementary members
The error code determines which supplementary members a failure has, and most codes have none. A client reads only the members documented for the code it matched. `errors` is the list of field violations. `retry_after` is the delay before another attempt is admitted. `global` is `true` on a global rate limit denial and `false` on a route one. `required_scope` is the OAuth2 scope the request is missing. `has_mfa` and `methods` are the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply. `captcha_provider` and `altcha_challenge` are the challenge described in [CAPTCHA handling](/topics/captcha/).
The error code determines which supplementary members a failure has, and most codes have none. A client reads only the members documented for the code it matched. `errors` is the list of field violations. `retry_after` is the delay before another attempt is admitted. `global` is `true` on a global rate limit denial and `false` on a route one. `required_scope` is the OAuth2 scope the request is missing. `has_mfa` and `methods` are the [sudo mode](/http-api/users/mfa/#sudo-mode) proofs an account can supply. `captcha_provider` and `altcha_challenge` are the challenge described in [CAPTCHA handling](/topics/captcha/). `step_index` is the zero-based index of the first [report flow step](/http-api/reports/#report-flow-step-object) that does not fit the current flow, on `INVALID_REPORT_FLOW_ANSWERS`.
`GLOBAL_IP_BANNED` and `GLOBAL_IP_TEMPORARILY_BANNED` have their own members:
@@ -120,6 +120,8 @@ A temporary ban lasts 24 hours by default. Requests from the banned address retu
These codes appear in the top-level `code` field of an error response, sent as the exact JSON string shown. The registry is closed. 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.
The [OpenAPI document](/http-api/instance/#get-openapi-document) and the Admin API specification publish the same list as the `APIErrorCode` schema. The `code` member of their error schemas stays a plain string that refers to it.
:::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.
:::
@@ -596,6 +598,10 @@ Permissions must be a valid integer
Permissions must be non-negative
### `INVALID_REPORT_FLOW_ANSWERS`
These answers don't match the report form
### `INVALID_REQUEST`
Invalid request
@@ -880,6 +886,10 @@ You've been banned from submitting reports
Email verification is required for this action
### `REPORT_FLOW_OUTDATED`
The report form changed while you were filling it in
### `RESOURCE_LOCKED`
This resource is being modified
+534 -156
View File
@@ -1,18 +1,18 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Reports
description: Safety report submission, the category registries, and the DSA notice flow.
description: Report flows, safety report submission, the reason catalog, 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. Digital Services Act notices need no account, only a verified email address. The [Admin Reports API](/admin-api/reports/) covers reading, triaging, and closing a stored report.
A safety report tells moderators that a message or a user profile broke the rules, and any user account can file one. Before a report is filed, the reporter answers a short series of questions. Fluxer serves those questions as a [report flow](#report-flows), and the answers decide the [reason](#report-reasons) the report is filed under. Digital Services Act notices need no account, only a verified email address, and they can also name a guild. 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 |
| [Get report flow](#get-report-flow) | None | The questions for one target type |
| [Submit message report flow](#submit-message-report-flow) | User session | One message the reporter can read |
| [Submit user report flow](#submit-user-report-flow) | User session | One user profile, optionally with a guild for context |
| [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 |
@@ -20,28 +20,291 @@ A safety report tells moderators that a message, a user, or a guild broke the ru
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.
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, and every report copies the reported profile with its images. Deleting the message, editing the profile, or leaving the guild afterwards does not remove that snapshot. The snapshot is kept until the report reaches the end of its [retention period](/admin-api/reports/#report-retention).
:::
## 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`.
[Submit message report flow](#submit-message-report-flow) and [Submit user report flow](#submit-user-report-flow) 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 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.
[Get report flow](#get-report-flow) and the 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, and Get report flow renders the copy in that account's saved locale unless the request names one.
Every route bucket is keyed by the authenticated user ID when a credential resolves and by client IP address otherwise. Report creation also limits how many reports one reporter creates each hour. The rate limit section of each route that creates a report lists those limits.
## 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.
The instance-wide content filter screens the JSON body of [Submit message report flow](#submit-message-report-flow) and [Submit user report flow](#submit-user-report-flow) 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`. Every string those two bodies define is an ID, a hash, or a locale, and the filter exempts all of them, so only a member the schema does not define can be blocked.
Every route under `/reports/dsa` is exempt by path, so Fluxer never screens a Digital Services Act notice. A notice about hate speech or a phishing link has to quote the slur or the URL it reports.
:::note[Screening precedes bucket and credential checks]
A blocked body returns `CONTENT_BLOCKED`, not `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.
A blocked body returns `CONTENT_BLOCKED`, not `RATE_LIMITED` or `UNAUTHORIZED`.
:::
## Submission side effects
Every accepted submission writes one report and returns it as a [report object](#report-object). Only [Report message](#report-message) refuses a repeat, returning 409 `CONFLICT` when the same reporter reports the same message twice. Every accepted [Report user](#report-user) and [Report guild](#report-guild) request writes a new report, and nothing deduplicates a repeat submission. Submission emits no Gateway Dispatch.
Every accepted submission writes one report and returns it as a [report object](#report-object). A report filed through a flow also stores its reason, the [category](#report-categories) that reason maps to, the submitted steps, the revision hash, and the resolved locale. Submission emits no Gateway Dispatch.
Two kinds of repeat are refused with 409 `CONFLICT`:
- The same reporter reporting the same message again within 365 days of the earlier report.
- The same reporter reporting the same user again within 24 hours of the earlier report.
Nothing deduplicates a Digital Services Act notice.
Fluxer tells the reporter when a moderator resolves the report. A reporter with an account receives a system direct message, and an email when the account has an address. A Digital Services Act notice gets a receipt email at its verified address as soon as it is filed, and a decision email at the same address when it is resolved. [Update report](/admin-api/reports/#update-report) describes when no notice is sent.
### Profile snapshot
Every report about a user, a guild, or a message an account sent also keeps a copy of the reported profile as it was at submission:
- The account's username, discriminator, display name, bio, pronouns, avatar, and banner.
- The account's guild nickname, bio, pronouns, join time, avatar, and banner, when the report names a guild the account is a member of.
- The guild's name, vanity URL code, icon, banner, and splash, for a report about a guild.
Fluxer copies each image into report storage. An image whose copy fails keeps only its hash, and the report is still filed. An account whose deletion has started is recorded by ID alone. Moderators read the snapshot as the [report profile snapshot object](/admin-api/reports/#report-profile-snapshot-object).
## Report flows
A report flow is the set of screens a reporter moves through before a report is filed. Fluxer serves one flow for each [target type](#report-flow-target-types). [Get report flow](#get-report-flow) returns every screen of it in one response, already translated, so a client renders the whole flow from that response and hard-codes no question.
### Screens
Each [screen](#report-flow-screen-object) is one of three kinds, told apart by the member that is set.
| Kind | Member | What the reporter does |
| --- | --- | --- |
| Choice | `options` | Picks one option. At least one option is not a link |
| Checklist | `checklist` | Ticks at least `min_checked` items, then continues |
| Info | `next_screen_id` | Reads the text and continues, optionally opening a link option first |
An option's [outcome](#report-flow-outcome-object) says what choosing it does. A checklist has one outcome for the whole screen. A screen with `urgent` set covers one of the most serious harms, such as threats, self-harm, sexual content, or a risk to a minor. A client shows an urgency notice on it and on the summary of every walk that passes through it.
### Surfaces
A flow has up to two variants, one for each [surface](#report-flow-surfaces). `in_app` is the variant a signed-in client shows. `dsa` is the variant the public Digital Services Act form shows before it calls [Create DSA report](#create-dsa-report).
The two variants share their screens and differ in a few options. Only `in_app` has the options that end on the thank-you screen without a report, the options for a reporter worried about someone's safety, and the links to the Digital Services Act form, its copyright option among them. Its user flow also starts with an info screen. On `dsa` the copyright option files a report instead. The `guild` flow exists on the `dsa` surface alone.
A self-hosted instance drops the links to the Digital Services Act form. It keeps the crisis line, and keeps the guidelines links only when its operator has set a [guidelines URL](/http-api/instance/#public-legal-configuration-object). Without one its flow has a null `guidelines_url`. Its `in_app` revision hashes therefore differ from those of the hosted instance, and differ again between a self-hosted instance with a guidelines URL and one without. Its `dsa` revision hashes match the hosted ones.
### Walking a flow
A walk starts at `start_screen_id`. Each screen the reporter passes through adds one [step](#report-flow-step-object):
- A choice screen records the chosen `option_id`.
- A checklist screen records the ticked `item_ids`.
- An info screen records its `screen_id` alone.
The walk follows `screen` outcomes and `next_screen_id` until an option or a checklist reaches a `submit` outcome. The client then shows a summary and submits every step in order. A `link` outcome opens its `url` and adds no step.
An `end` outcome finishes the walk without a report. The client shows the [notice](#report-flow-notice-object) it names, or a thank-you screen when it names none. Such a walk is never submitted, and Fluxer rejects a submission that contains one.
The report is filed under the `reason` of the final `submit` outcome. A few checklist items name a more specific reason of their own, which takes precedence, and the flow object does not expose it.
Fluxer checks a submitted walk against the current variant. The first step must be the start screen, each later step must be the screen the previous answer led to, every answer must exist on its screen, and the last answer must reach a `submit` outcome.
### Revision hash
`revision_hash` identifies the structure of one variant: its screens, options, items, and outcomes. It does not depend on the locale or the product name, and it changes whenever a screen, option, item, or outcome changes. A submission sends back the hash of the flow the reporter answered.
| Walk | Hash | Result |
| --- | --- | --- |
| Valid | Current | The report is filed |
| Valid | Outdated | The report is filed with the hash that was sent |
| Invalid | Current | 400 `INVALID_REPORT_FLOW_ANSWERS` with `step_index` |
| Invalid | Outdated | 409 `REPORT_FLOW_OUTDATED` |
An option or item ID never changes meaning once it ships, so a valid walk from an outdated flow still means what the reporter saw. `step_index` is the zero-based index of the first step that does not fit the current flow. After `REPORT_FLOW_OUTDATED`, fetch the flow again and restart the walk.
### Locale
Fluxer renders the copy in one [supported locale](/topics/locales/#supported-locales). [Get report flow](#get-report-flow) uses its `locale` query parameter when one is sent, and the [negotiated](/topics/locales/#negotiation) request locale otherwise. The response `locale` names the locale used, and the copy names the instance's own product name.
Resolving a tag never fails. Matching ignores case and accepts underscores in place of hyphens, `nb` and `nn` resolve to `no`, and a bare language resolves to its preferred regional locale, so `pt` becomes `pt-BR`. A tag that matches nothing resolves to `en-US`.
A submission stores the locale the reporter saw, resolved the same way from its `locale` member, or the request locale when that member is omitted. The [Admin Reports API](/admin-api/reports/) describes a stored walk in English whatever that locale is.
## Report flow object
[Get report flow](#get-report-flow) returns every screen of one variant in this object.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| target_type | string | [Report flow target type](#report-flow-target-types) |
| surface | string | [Report flow surface](#report-flow-surfaces) |
| revision_hash<sup>1</sup> | string | The [revision hash](#revision-hash) of this variant |
| locale | string | The [locale](/topics/locales/) the copy was rendered in |
| start_screen_id | string | The ID of the screen a walk starts on |
| guidelines_url<sup>2</sup> | ?string | The instance's community guidelines page |
| screens | array[[report flow screen](#report-flow-screen-object) object] | Every screen of the variant |
| notices | array[[report flow notice](#report-flow-notice-object) object] | Every notice an `end` outcome in the variant names |
<sup>1</sup> 16 lowercase hexadecimal characters
<sup>2</sup> The guidelines URL the operator set. Without one, a hosted instance links its own guidelines page and a self-hosted instance has null. A client links its summary disclaimer to it
### Example
An abridged user flow:
```json
{
"target_type": "user",
"surface": "in_app",
"revision_hash": "9c41d2a07e5b18f3",
"locale": "en-US",
"start_screen_id": "profile_intro",
"guidelines_url": "https://fluxer.app/guidelines",
"screens": [
{
"id": "profile_intro",
"title": "Report profile",
"subtitle": "Tell us what's wrong with this profile. They won't be told who reported them.",
"urgent": false,
"options": [
{
"id": "learn_more",
"label": "Read the Fluxer Community Guidelines",
"outcome": {"type": "link", "screen_id": null, "reason": null, "notice_id": null, "url": "https://fluxer.app/guidelines"}
}
],
"options_heading": "Learn more",
"checklist": null,
"next_screen_id": "profile_parts"
},
{
"id": "profile_parts",
"title": "Which parts of their profile are a problem?",
"subtitle": "Pick as many as you need.",
"urgent": false,
"options": [],
"options_heading": null,
"checklist": {
"items": [
{"id": "photo", "label": "Pictures", "description": "What they use as avatar and banner"},
{"id": "name", "label": "Names", "description": "Username, display name or community nickname"},
{"id": "profile_text", "label": "Profile text", "description": "Bio, custom status or pronouns"}
],
"min_checked": 1,
"outcome": {"type": "screen", "screen_id": "root_user", "reason": null, "notice_id": null, "url": null}
},
"next_screen_id": null
}
],
"notices": [
{
"id": "need_more_info_profile",
"title": "We can't act on this yet",
"body": "To review this, we need to see where they say how old they are or make it clear they're under Fluxer's minimum age. Report that message or profile instead."
}
]
}
```
## Report flow screen object
One question, checklist, or information page in a [report flow](#report-flows).
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | string | The screen ID, unique within the flow |
| title | string | The question or heading |
| subtitle | ?string | Supporting text shown under the title |
| urgent | boolean | Whether the screen covers one of the most serious harms |
| options<sup>1</sup> | array[[report flow option](#report-flow-option-object) object] | The options, in display order |
| options_heading | ?string | A heading shown above the options |
| checklist | ?[report flow checklist](#report-flow-checklist-object) object | The checklist, set only on a checklist screen |
| next_screen_id | ?string | The screen the Next button leads to, set only on an info screen |
<sup>1</sup> Empty on a checklist screen, and holding only link options on an info screen
## Report flow option object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | string | The option ID, unique within its screen |
| label | string | The option text |
| outcome | [report flow outcome](#report-flow-outcome-object) object | What choosing the option does |
## Report flow outcome object
Every member is always present. The members that do not apply to `type` are null.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| type | string | [Report flow outcome type](#report-flow-outcome-types) |
| screen_id | ?string | The next screen when `type` is `screen` |
| reason<sup>1</sup> | ?string | The [report reason](#report-reasons) when `type` is `submit` |
| notice_id | ?string | The notice to show when `type` is `end`, or null for the thank-you screen |
| url | ?string | The page to open when `type` is `link` |
<sup>1</sup> A plain string, so a client MUST tolerate a reason the [catalog](#report-reasons) does not list yet
## Report flow checklist object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| items | array[[report flow checklist item](#report-flow-checklist-item-object) object] | The items, in display order |
| min_checked | integer | The fewest items the reporter must tick before continuing |
| outcome | [report flow outcome](#report-flow-outcome-object) object | Where the screen leads, always a `screen` or `submit` outcome |
## Report flow checklist item object
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | string | The item ID, unique within its checklist |
| label | string | The item text |
| description | ?string | A second line shown under the label |
## Report flow notice object
A notice explains why a walk ended without a report, for example when a specific message has to be reported instead.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| id | string | The notice ID |
| title | string | The notice heading |
| body | string | The notice text |
## Report flow step object
One answered screen in a submission. A submission lists its steps in walk order.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| screen_id | string | The ID of the screen (1-48 characters) |
| option_id?<sup>1</sup> | string | The ID of the chosen option (1-48 characters) |
| item_ids?<sup>1</sup> | array[string] | The IDs of the ticked items, 1-32 unique IDs of 1-48 characters each |
<sup>1</sup> Sent on a choice screen and a checklist screen respectively. An info screen's step sends neither, and a step that sends the member its screen does not use is invalid
### Example
```json
[
{"screen_id": "profile_intro"},
{"screen_id": "profile_parts", "item_ids": ["photo"]},
{"screen_id": "root_user", "option_id": "abuse"},
{"screen_id": "profile_abuse", "option_id": "harassment"}
]
```
## Report object
@@ -117,54 +380,123 @@ The single credential [Create DSA report](#create-dsa-report) accepts. Fluxer bi
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`.
## Report flow target types
| Value | Description |
| --- | --- |
| harassment | Content that harasses, bullies, or intimidates users |
| hate_speech | Content promoting hatred against protected groups |
| message | A message, on both surfaces |
| user | A user profile, on both surfaces |
| guild | A guild, on the `dsa` surface only |
## Report flow surfaces
| Value | Description |
| --- | --- |
| in_app | The flow a signed-in client shows, and the default |
| dsa | The flow the public Digital Services Act form shows |
## Report flow outcome types
| Value | Description |
| --- | --- |
| screen | Go to the screen named by `screen_id` |
| submit | Show the summary, then file a report under `reason` |
| end | Finish without a report and show the notice named by `notice_id`, or a thank-you screen |
| link | Open `url` and stay on the current screen |
## Report reasons
A report filed through a flow stores a reason, together with the [category](#report-categories) that reason maps to for the report's target type. Moderators can filter on both. The catalog grows over time, so a client MUST tolerate a reason it does not list.
The second column gives the reason value, then the category it stores on a message report, a user report, and a guild report, in that order.
| Reason | Value: message, user, guild category |
| --- | --- |
| Spam | `spam`: `spam`, `spam_account`, `spam` |
| Fake or deceptive account | `fake_account`: `spam`, `spam_account`, `spam` |
| Scraping, automation or other platform abuse | `platform_abuse`: `spam`, `spam_account`, `spam` |
| Buying or selling accounts, logins or card details | `account_trading`: `illegal_activity`, `spam_account`, `illegal_activity` |
| Faked member or reaction counts | `metric_manipulation`: `spam`, `spam_account`, `spam` |
| Harassment or bullying | `harassment`: `harassment`, `harassment`, `harassment` |
| Stalking or unwanted contact | `unwanted_contact`: `harassment`, `harassment`, `harassment` |
| Raid or coordinated attack | `raid`: `harassment`, `harassment`, `raid_coordination` |
| False claims used to target a person | `false_claims_about_person`: `harassment`, `harassment`, `harassment` |
| Slurs, hateful symbols or stereotypes | `hate_slurs`: `hate_speech`, `hate_speech`, `hate_speech` |
| Dehumanizing a group | `hate_dehumanizing`: `hate_speech`, `hate_speech`, `hate_speech` |
| Calls for violence or exclusion against a group<sup>1</sup> | `hate_incitement`: `hate_speech`, `hate_speech`, `hate_speech` |
| Genocide or atrocity denial or celebration | `atrocity_denial`: `hate_speech`, `hate_speech`, `hate_speech` |
| Misgendering, deadnaming, outing or identity denial | `lgbtq_targeting`: `hate_speech`, `hate_speech`, `hate_speech` |
| Promoting conversion therapy | `conversion_therapy`: `hate_speech`, `hate_speech`, `hate_speech` |
| Other attacks on people for who they are | `hate_other`: `hate_speech`, `hate_speech`, `hate_speech` |
| Threat of violence<sup>1</sup> | `violent_threat`: `violent_content`, `harassment`, `harassment` |
| Encouraging violence or instructions to harm<sup>1</sup> | `violence_incitement`: `violent_content`, `inappropriate_profile`, `violent_content` |
| Glorifying real-world violence | `violence_glorification`: `violent_content`, `inappropriate_profile`, `violent_content` |
| Real violence shown to shock | `graphic_violence`: `violent_content`, `inappropriate_profile`, `violent_content` |
| Terrorism or violent extremism | `terrorism_extremism`: `violent_content`, `inappropriate_profile`, `extremist_community` |
| False claims likely to cause serious harm | `harmful_false_claims`: `other`, `other`, `other` |
| Fake or AI media presented as real | `deceptive_synthetic_media`: `other`, `other`, `other` |
| Sexual images or messages nobody asked for | `sexual_unwanted`: `harassment`, `harassment`, `harassment` |
| Adult content outside an 18+ space | `adult_content_unmarked`: `nsfw_violation`, `inappropriate_profile`, `nsfw_violation` |
| Intimate images shared without consent<sup>1</sup> | `intimate_image_abuse`: `doxxing`, `inappropriate_profile`, `harassment` |
| Sexual exploitation, coercion or trafficking<sup>1</sup> | `sexual_exploitation`: `illegal_activity`, `inappropriate_profile`, `illegal_activity` |
| Sexualized fictional child<sup>1</sup> | `minor_fictional_sexualization`: `child_safety`, `child_safety`, `child_safety` |
| Sexual comments about minors<sup>1</sup> | `minor_sexualization`: `child_safety`, `child_safety`, `child_safety` |
| Flirting with or sexually messaging a minor<sup>1</sup> | `minor_sexual_contact`: `child_safety`, `child_safety`, `child_safety` |
| A minor sharing sexual content themselves<sup>1</sup> | `minor_sexual_content`: `child_safety`, `child_safety`, `child_safety` |
| Child sexual abuse material<sup>1</sup> | `csam`: `child_safety`, `child_safety`, `child_safety` |
| Grooming a minor<sup>1</sup> | `minor_grooming`: `child_safety`, `child_safety`, `child_safety` |
| Predatory contact with a minor<sup>1</sup> | `minor_contact`: `child_safety`, `child_safety`, `child_safety` |
| A minor in an 18+ space | `minor_in_adult_space`: `child_safety`, `child_safety`, `child_safety` |
| Community for dating between minors<sup>1</sup> | `minor_dating_community`: `child_safety`, `child_safety`, `child_safety` |
| Private information shared without consent | `doxxing`: `doxxing`, `inappropriate_profile`, `harassment` |
| Under the minimum age | `underage`: `underage_user`, `underage_user`, `underage_user` |
| Worried about someone's safety<sup>1</sup> | `wellbeing_concern`: `self_harm`, `other`, `self_harm` |
| Encouraging self-harm or sharing methods | `self_harm_promotion`: `self_harm`, `inappropriate_profile`, `self_harm` |
| Encouraging suicide | `suicide_promotion`: `self_harm`, `inappropriate_profile`, `self_harm` |
| Encouraging disordered eating | `eating_disorder_promotion`: `self_harm`, `inappropriate_profile`, `self_harm` |
| Graphic self-harm images | `self_harm_graphic`: `self_harm`, `inappropriate_profile`, `self_harm` |
| Pressuring someone to harm themselves | `self_harm_pressure`: `self_harm`, `inappropriate_profile`, `self_harm` |
| Pretending to be staff or support | `impersonation_staff`: `impersonation`, `impersonation`, `impersonation` |
| Impersonating a person | `impersonation_person`: `impersonation`, `impersonation`, `impersonation` |
| Impersonating a well-known person | `impersonation_public_figure`: `impersonation`, `impersonation`, `impersonation` |
| Impersonating a company, brand or group | `impersonation_organization`: `impersonation`, `impersonation`, `impersonation` |
| Conning people out of money or accounts | `fraud`: `illegal_activity`, `spam_account`, `illegal_activity` |
| Drug, weapon or other illegal sales | `illegal_goods`: `illegal_activity`, `inappropriate_profile`, `illegal_activity` |
| Other illegal activity | `illegal_activity`: `illegal_activity`, `inappropriate_profile`, `illegal_activity` |
| Phishing, malware or hacking attempts | `malware_phishing`: `malicious_links`, `spam_account`, `malware_distribution` |
| Getting around a ban | `ban_evasion`: `other`, `other`, `other` |
| Community allows serious rule-breaking | `permits_violations`: `other`, `other`, `other` |
| Copyright or trademark complaint | `copyright`: `other`, `other`, `other` |
| Something not listed | `other`: `other`, `other`, `other` |
<sup>1</sup> Highest priority, so moderators see a report with this reason first
## Report categories
Every report stores one category from this registry. A report filed through a flow stores the category its [reason](#report-reasons) maps to for the target type.
| Value | Description |
| --- | --- |
| harassment | Harassment, bullying, or intimidation |
| hate_speech | Hatred against protected groups |
| violent_content | Content depicting or promoting violence |
| extremist_community | A guild promoting extremist or terrorist ideologies |
| raid_coordination | A guild coordinating attacks on other communities |
| spam | Unsolicited bulk messages or promotional content |
| spam_account | An account used for spamming or bulk messaging |
| nsfw_violation | Adult content posted outside age-restricted channels |
| inappropriate_profile | Inappropriate or offensive profile content |
| 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 |
| underage_user | Someone who appears to be under the minimum required age |
| 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 |
| malware_distribution | Distribution of malware or malicious software |
| impersonation | Falsely claiming to be another person or entity |
| other | Anything the other categories do not cover |
## 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 |
A user report only ever stores `harassment`, `hate_speech`, `spam_account`, `impersonation`, `underage_user`, `inappropriate_profile`, `child_safety`, or `other`.
## EU country codes
@@ -200,24 +532,65 @@ No instance configuration changes this registry. A value outside it returns 400
| ES | Spain |
| SE | Sweden |
## Report message
## Get report flow
<RouteHeader method="POST" path="/v1/reports/message" />
<RouteHeader method="GET" path="/v1/reports/flows/{target_type}" unauthenticated />
Creates and returns a [report object](#report-object) for a message the reporter can currently read.
Returns the [report flow object](#report-flow-object) for one target type and surface, with its copy rendered in one locale.
The response has `Cache-Control: private, no-cache` and `Vary: Accept-Language`. A client can keep the flow for a session, and `no-cache` makes the refetch after 409 `REPORT_FLOW_OUTDATED` reach Fluxer instead of a stored copy.
### Path parameters
| Field | Type | Description |
| --- | --- | --- |
| target_type | string | [Report flow target type](#report-flow-target-types) |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| surface? | string | [Report flow surface](#report-flow-surfaces) (default `in_app`) |
| locale?<sup>1</sup> | string | The language tag of the client UI (1-35 characters) |
<sup>1</sup> Resolved as [Locale](#locale) describes, and the request locale is used when it is omitted. A client SHOULD send it, because the language of its UI can differ from the account's saved locale
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [report flow](#report-flow-object) object | The flow was rendered |
| 400 | [error response](/http-api/#error-response) | The target type or surface is unknown and the request returns `INVALID_FORM_BODY` |
| 400 | [error response](/http-api/#error-response) | `guild` was requested on a surface other than `dsa` and the request returns `INVALID_FORM_BODY` |
### Rate limit
40 requests per 10 seconds, on the `report:list` bucket.
<a id="report-message"></a>
## Submit message report flow
<RouteHeader method="POST" path="/v1/reports/flows/message/submissions" />
Files a report about a message from the reporter's answers to the `message` flow on the `in_app` surface, and returns a [report object](#report-object). The reporter must be able to read the message.
A message a bot sent is reported as the bot account, like any other account. A message a [webhook](/http-api/webhooks/) sent has no author account, so the report records the webhook instead, with the name and avatar the message was sent with.
### 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`.
- A guild channel the reporter is not a member of returns 403 `ACCESS_DENIED`, and a member without [VIEW_CHANNEL](/http-api/permissions/) receives 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`.
- A message not stored under `channel_id`, a message with neither an author account nor a webhook, 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.
Fluxer checks the answers right after the claimed account and verified email checks, before the report ban check and before it reads the channel. An invalid walk still counts against the `report:create` bucket but uses none of the further allowances below.
:::note[Reporting without Read Message History]
A reporter without `READ_MESSAGE_HISTORY` can report a message sent at or after the earlier of two points: the guild's message history cutoff and the time the reporter joined the guild. Every message since the join reached the reporter live, so a guild that sets no cutoff still leaves those messages reportable. This window applies to reports only, and message history reads keep their own rules.
:::
### JSON body
@@ -226,34 +599,44 @@ A reporter without `READ_MESSAGE_HISTORY` can report only a message at or after
| --- | --- | --- |
| 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) |
| revision_hash | string | The `revision_hash` of the flow the reporter answered (1-64 characters) |
| steps | array[[report flow step](#report-flow-step-object) object] | The answered screens in walk order (1-16 steps) |
| locale?<sup>1</sup> | string | The language tag the reporter saw the flow in (1-35 characters) |
<sup>1</sup> Stored with the report after it is resolved as [Locale](#locale) describes. The request locale is stored when it is omitted
### 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` |
| 400 | [error response](/http-api/#error-response) | The answers do not fit the current flow and the request returns `INVALID_REPORT_FLOW_ANSWERS` |
| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS` |
| 400 | [error response](/http-api/#error-response) | 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 reporter is not a member of the guild and the request returns `ACCESS_DENIED` |
| 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 reporter 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 |
| 409 | [error response](/http-api/#error-response) | The answers came from an outdated flow and do not fit the current one, returning `REPORT_FLOW_OUTDATED` |
| 409<sup>1</sup> | [error response](/http-api/#error-response) | The reporter already reported this message, returning `CONFLICT` |
<sup>1</sup> 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
<sup>1</sup> The reporter, the channel, and the message form a submission key that expires 365 days after the report. A second report of the same message by the same reporter within that time is refused whichever answers it sends. A submission that fails or is rate limited releases the key
### 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 operation writes one report with a frozen evidence snapshot. The snapshot records the reporter's account ID, the message author's identity and avatar hash, the channel and guild identity, and the guild and channel NSFW and content warning state. For a message a webhook sent it records the webhook's ID and the name and avatar hash the message was sent with in place of the author. It also records the webhook's own name, avatar hash, type, channel, guild, and creation time, and the ID, username, discriminator, display name, and avatar hash of the account that created the webhook. Only the creator ID is recorded when that account has been deleted. The creator is recorded for moderators and is not the reported account. The report also stores the reason, its category, the steps, the revision hash, and the resolved locale.
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. When the reporter lacks `READ_MESSAGE_HISTORY`, the window holds only messages created at or after the guild's message history cutoff, and holds no messages when the guild sets no cutoff.
For a message an account sent, the report also keeps a [profile snapshot](#profile-snapshot) of the author, with the author's guild profile when the channel is in a guild.
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 message a webhook sent is recorded under its webhook. A context message whose author account no longer exists stays in the window under a deleted user identity that keeps the author ID. An attachment whose copy fails, and an embedded file that is no longer stored, is listed as missing on its context message with its metadata, and the request still succeeds. When the reporter lacks `READ_MESSAGE_HISTORY`, the window holds only messages created at or after the earlier of the guild's message history cutoff and the time the reporter joined the guild.
### Rate limit
10 requests per hour, on the `report:create` bucket, which is shared with [Report user](#report-user) and [Report guild](#report-guild).
10 requests per hour, on the `report:create` bucket, which is shared with [Submit user report flow](#submit-user-report-flow).
Further allowances apply, each counted over one hour:
@@ -264,102 +647,62 @@ Further allowances apply, each counted over one hour:
A duplicate submission returns 409 `CONFLICT` before any of them is consumed.
## Report user
<a id="report-user"></a>
<RouteHeader method="POST" path="/v1/reports/user" />
## Submit user report flow
Creates and returns a [report object](#report-object) for another user, optionally noting the guild in which the conduct occurred.
<RouteHeader method="POST" path="/v1/reports/flows/user/submissions" />
Files a report about another user's profile from the reporter's answers to the `user` flow on the `in_app` surface, and returns a [report object](#report-object). The report can name the guild the reporter saw the profile in.
### 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`.
- A `guild_id` that resolves to no guild, or to a guild the reporter is not a member of, returns 404 `UNKNOWN_GUILD`.
Fluxer checks the answers right after the claimed account and verified email checks, before the report ban check and before it looks up the user. An invalid walk still counts against the `report:create` bucket but not against the per-reporter allowance below.
### 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 |
| guild_id? | snowflake | The ID of the guild in which the reporter saw the profile |
| revision_hash | string | The `revision_hash` of the flow the reporter answered (1-64 characters) |
| steps | array[[report flow step](#report-flow-step-object) object] | The answered screens in walk order (1-16 steps) |
| locale?<sup>1</sup> | string | The language tag the reporter saw the flow in (1-35 characters) |
<sup>1</sup> Stored with the report after it is resolved as [Locale](#locale) describes. The request locale is stored when it is omitted
### 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` |
| 400 | [error response](/http-api/#error-response) | The answers do not fit the current flow and the request returns `INVALID_REPORT_FLOW_ANSWERS` |
| 400 | [error response](/http-api/#error-response) | The account is unclaimed and the request returns `UNCLAIMED_ACCOUNT_CANNOT_SUBMIT_REPORTS` |
| 400 | [error response](/http-api/#error-response) | 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) | 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` |
| 404 | [error response](/http-api/#error-response) | The supplied `guild_id` is not a guild the reporter is in and the request returns `UNKNOWN_GUILD` |
| 409 | [error response](/http-api/#error-response) | The answers came from an outdated flow and do not fit the current one, returning `REPORT_FLOW_OUTDATED` |
| 409<sup>1</sup> | [error response](/http-api/#error-response) | The reporter already reported this user within 24 hours, returning `CONFLICT` |
<sup>1</sup> The reporter and the reported user form a submission key that expires 24 hours after the report. A submission that fails or is rate limited releases the key
### Side effects
The operation writes one report. It records the reporter's account ID and email address, and the reported user's ID and avatar hash. A supplied `guild_id` adds the guild's ID, name, icon hash, NSFW flag, and content warning state. No message evidence is captured and no media bytes are copied.
The operation writes one report. It records the reporter's account ID, and the reported user's ID and avatar hash. A supplied `guild_id` adds the guild's ID, name, icon hash, NSFW flag, and content warning state. The report also stores the reason, its category, the steps, the revision hash, and the resolved locale. The steps name the profile parts the reporter selected, and the report keeps a [profile snapshot](#profile-snapshot) of the account, with its guild profile when `guild_id` is supplied. No message evidence is captured.
### Rate limit
10 requests per hour, on the `report:create` bucket, which is shared with [Report message](#report-message) and [Report guild](#report-guild).
10 requests per hour, on the `report:create` bucket, which is shared with [Submit message report flow](#submit-message-report-flow).
One reporter creates at most 5 reports each hour across all report types.
## Report guild
<RouteHeader method="POST" path="/v1/reports/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 these ways, tried in order:
1. A current member needs nothing further.
2. Any reporter can report a guild with the `DISCOVERABLE` [guild feature](/http-api/guilds/#guild-features).
3. Any other reporter supplies `invite_code` for an invite pointing at that exact guild.
A reporter satisfying none of them 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?<sup>1</sup> | string | [Invite](/http-api/invites/) code proving access to the guild (1-64 characters) |
<sup>1</sup> 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`, not 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) | 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. It records the reporter's account ID and email address, the guild's ID, name, icon hash, NSFW flag, and content warning state, and the invite code when one was sent. No message evidence is captured and no media bytes are copied.
### 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.
One reporter creates at most 5 reports each hour across all report types. Fluxer checks that allowance right after the report ban check and before it looks up the user, so a reporter who has used it up receives 429 even for a duplicate. Otherwise a duplicate submission returns 409 `CONFLICT` without consuming it.
## Send DSA report email
@@ -369,7 +712,7 @@ Issues a Digital Services Act verification code for an email address and returns
An instance where members sign in with a username sends no email, and returns 400 `EMAIL_UNAVAILABLE_ON_INSTANCE` before it issues a code.
The address must be deliverable. A domain with no MX, A, or AAAA record returns 400 `INVALID_FORM_BODY` with `INVALID_EMAIL_ADDRESS` on `email`. A temporary DNS failure does not reject the address.
The address must be deliverable. A domain with no MX, A, or AAAA record returns 400 `INVALID_FORM_BODY` with `EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL` on `email`. A temporary DNS failure does not reject the address.
### JSON body
@@ -383,23 +726,30 @@ The address must be deliverable. A domain with no MX, A, or AAAA record returns
| 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` |
| 200<sup>1</sup> | [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 and the request returns `INVALID_FORM_BODY` |
| 400 | [error response](/http-api/#error-response) | The domain publishes no usable mail DNS record and the request returns `INVALID_FORM_BODY` |
| 429 | [error response](/http-api/#error-response) | The address was sent a code in the last minute, or used its 3 sends this hour |
| 503 | [error response](/http-api/#error-response) | The email could not be sent and the request returns `SERVICE_UNAVAILABLE` |
<sup>1</sup> Also returned on an instance with email delivery switched off, where no email leaves the instance
### Side effects
The code expires after 10 minutes. Sending again to the same address replaces the previous code and restarts that window. Codes use uppercase letters and digits in `XXXX-XXXX` form.
The code expires after 10 minutes. Codes use uppercase letters and digits in `XXXX-XXXX` form.
A new code can go to the same address once a minute has passed since the last one, or once that code has expired. It replaces the previous code and restarts the 10 minute window. A request within the minute returns 429 with `retry_after` and leaves the code already sent usable.
When the provider refuses the message, or the address is recorded as a hard bounce, Fluxer keeps no code for the address, so a code sent to it earlier stops working too, and returns 503 `SERVICE_UNAVAILABLE`. That attempt still counts toward the 3 sends an address gets each hour.
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 and when it refuses it. It is 200 for an address recorded as a hard bounce, and on an instance with email delivery switched off.
:::
### Rate limit
5 requests per hour, on the `dsa:report:email:send` bucket.
One address gets at most 3 sends each hour, and at most one each minute while its last code is unexpired. The minute check runs first, so a request it refuses does not count toward the hourly 3. Both return 429 `RATE_LIMITED` with `retry_after`.
## Verify DSA report email
<RouteHeader method="POST" path="/v1/reports/dsa/email/verify" unauthenticated />
@@ -422,25 +772,39 @@ Consumes a verification code and returns the [ticket object](#ticket-object) tha
| 200 | [ticket](#ticket-object) object | The code matched the unexpired verification record for the address |
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The address or code failed validation, or no unexpired record matches, returning `INVALID_DSA_VERIFICATION_CODE` |
<sup>1</sup> 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`.
<sup>1</sup> An address with no verification record, an expired record, a wrong code, and an attempt over the per-address limit 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.
The operation deletes the verification record and writes one ticket that expires one hour later. A wrong code leaves the outstanding code usable until it expires or the address reaches the attempt limit below. The operation emits no Gateway Dispatch.
Each address has 5 verification attempts in 10 minutes, counted across letter case, and right and wrong codes both count. The attempt over that limit deletes the outstanding code and returns 400 `INVALID_DSA_VERIFICATION_CODE`. Sending a new code starts a new count.
### Rate limit
10 requests per hour, on the `dsa:report:email:verify` bucket.
<a id="report-guild"></a>
## Create DSA report
<RouteHeader method="POST" path="/v1/reports/dsa" unauthenticated />
Consumes a ticket, files the notice, and returns a [report object](#report-object).
Consumes a ticket, files the notice from the reporter's answers to the flow on the `dsa` surface, and returns a [report object](#report-object). This is the only way to report a guild.
`report_type` selects which extra fields the body has, and `category` comes from the registry that matches it.
Fetch the flow with [Get report flow](#get-report-flow) and `surface=dsa` for the target type the notice is about, walk it as [Walking a flow](#walking-a-flow) describes, and send the steps with the details below. `report_type` selects which target fields the body has.
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.
A ticket that is unknown or past its one hour expiry returns 400 `INVALID_DSA_TICKET` before the answers are checked. 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.
| Article 16(2) | Fields |
| --- | --- |
| (a) An explanation of the problem | `additional_info` |
| (b) The exact location of the content | `message_link`, `user_id`, `user_tag`, `guild_id`, `invite_code` |
| (c) The name and email address of the reporter | `reporter_full_legal_name`, `ticket` |
| (d) A statement of good faith | `good_faith_confirmed` |
A notice whose walk resolves to the `child_safety` [category](#report-categories) may omit the legal name, as Article 16(2)(c) allows for child sexual abuse material. Every other notice needs one, and Fluxer checks this after it resolves the walk.
### JSON body
@@ -450,13 +814,19 @@ A ticket that is unknown or past its one hour expiry returns 400 `INVALID_DSA_TI
| --- | --- | --- |
| 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) |
| revision_hash | string | The `revision_hash` of the `dsa` flow the reporter answered (1-64 characters) |
| steps | array[[report flow step](#report-flow-step-object) object] | The answered screens in walk order (1-16 steps) |
| additional_info<sup>1</sup> | string | The reporter's explanation of the problem (1-1000 characters) |
| good_faith_confirmed | boolean | Always `true`, confirming the notice is accurate and complete to the reporter's knowledge |
| reporter_full_legal_name?<sup>2</sup> | 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?<sup>1</sup> | string | The reporter's own tag in `username#1234` form (3-37 characters) |
| locale?<sup>3</sup> | string | The language tag the reporter saw the flow in (1-35 characters) |
<sup>1</sup> Validated for shape and then discarded, so Fluxer never resolves it against an account and no moderator sees it
<sup>1</sup> A value that is empty or only whitespace returns 400 `INVALID_FORM_BODY`. When the reporter believes the content is illegal, the explanation says which law it breaks and why
<sup>2</sup> Required unless the walk resolves to the `child_safety` category. A missing name otherwise returns 400 `INVALID_FORM_BODY` on `reporter_full_legal_name`
<sup>3</sup> Stored with the notice after it is resolved as [Locale](#locale) describes. The request locale is stored when it is omitted
#### Message fields
@@ -465,15 +835,11 @@ A ticket that is unknown or past its one hour expiry returns 400 `INVALID_DSA_TI
| message_link<sup>1</sup> | string | The link to the reported message (1-2048 characters) |
| reported_user_tag?<sup>2</sup> | string | The message author's tag in `username#1234` form (3-37 characters) |
<sup>1</sup> Parsed as an absolute URL whose path is `/channels/&lt;guild_id&gt;/&lt;channel_id&gt;/&lt;message_id&gt;`. Fluxer ignores the origin and the guild segment, and reads only the channel and message segments
<sup>1</sup> Parsed as an absolute URL whose path is `/channels/&lt;guild_id&gt;/&lt;channel_id&gt;/&lt;message_id&gt;`, with `@me` in place of the guild ID for a direct message or group DM. Fluxer ignores the origin
<sup>2</sup> 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`
<sup>2</sup> When supplied it must be the tag of the account that authored the linked message. A message a webhook sent has no author account, so no tag matches it
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.
:::
The link must name a message that exists, in the channel it names, and in the guild that channel belongs to. Every link or tag that does not resolve returns the same 404 `UNKNOWN_MESSAGE`, and the ticket stays usable. That covers a value that is not an absolute URL, a path with fewer than four segments, a first segment other than `channels`, a channel or message segment that is not a decimal snowflake, and a guild segment that does not match the channel. It also covers a missing channel or message, a message stored in another channel, a message whose author account no longer exists, and a tag that does not match the author.
#### User fields
@@ -502,15 +868,27 @@ An unknown code returns 404 `UNKNOWN_INVITE`. A code that resolves to an invite
| 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 a supplied Fluxer tag or invite code is malformed or does not match the reported message author, user, or guild 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 |
| 400 | [error response](/http-api/#error-response) | The ticket is unknown or expired and the request returns `INVALID_DSA_TICKET` |
| 400 | [error response](/http-api/#error-response) | A user ID and tag name different accounts, and the request returns `INVALID_DSA_REPORT_TARGET` |
| 400 | [error response](/http-api/#error-response) | A user tag is malformed and the request returns `INVALID_DSA_REPORT_TARGET` |
| 400 | [error response](/http-api/#error-response) | The invite code belongs to another guild or type and the request returns `INVALID_DSA_REPORT_TARGET` |
| 400 | [error response](/http-api/#error-response) | The answers do not fit the current `dsa` flow and the request returns `INVALID_REPORT_FLOW_ANSWERS` |
| 400 | [error response](/http-api/#error-response) | A required field is missing or malformed and the request returns `INVALID_FORM_BODY` |
| 404 | [error response](/http-api/#error-response) | The message link or author tag does not resolve and the request returns `UNKNOWN_MESSAGE` |
| 404 | [error response](/http-api/#error-response) | The reported user, guild, or invite does not exist |
| 409 | [error response](/http-api/#error-response) | The answers came from an outdated flow and do not fit the current one, returning `REPORT_FLOW_OUTDATED` |
### Side effects
The operation resolves the target, deletes the ticket, and writes one report under a newly 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.
The operation resolves the target, deletes the ticket, and writes one report under a newly generated [snowflake](/snowflakes/).
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`.
A message notice about a guild channel that @everyone can view and read the history of captures the same conversation window as [Submit message report flow](#submit-message-report-flow), including the byte copy of every attachment. For any other guild channel, and for a direct message or group DM, it captures only the reported message and its attachments. A message notice also keeps a [profile snapshot](#profile-snapshot) of an author account, with the author's guild profile in a guild channel.
A user notice records the reported user's ID, avatar hash, and [profile snapshot](#profile-snapshot). 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, together with the guild's [profile snapshot](#profile-snapshot).
Every notice also records `additional_info`, the reporter's legal name when one was supplied, the country of residence, the good faith confirmation, and the verified address behind the ticket. It stores the reason, its category, the steps, the revision hash, and the resolved locale like any flow report. A notice has no account ID even when the request presented a credential.
Once the notice is stored, Fluxer emails a receipt with the report ID to the verified address. A receipt that cannot be sent is logged and does not fail the request. When a moderator resolves the notice, the same address receives the decision.
### Rate limit
@@ -1044,6 +1044,10 @@ Defaults to the inverse of `FLUXER_SELF_HOSTED`. Off by default on a self-hosted
With feeds off, Fluxer checks none of this data. The first worker start with feeds off removes the URL feed file and every `malware_bazaar` file-SHA ban that no Admin added. File-SHA bans added through the Admin API stay. Turning feeds back on downloads the URLs within six hours and the hashes within twelve. Compose forwards it from `.env`.
#### `FLUXER_REPORT_RETENTION_DRY_RUN`
Default dry run. Read by `worker`. The daily [report retention](/admin-api/reports/#report-retention) job deletes reports 365 days after they were filed, with their evidence copies and search entries, unless a legal hold is in place. Until this is `false` or `0`, the job only logs what it would delete. Any other value, or no value, keeps the dry run. Check the counts in the `Processed report retention` log line before you turn deletion on, because deleted evidence cannot be restored. Compose does not forward it, so add it to `worker` through an override file.
#### `FLUXER_BREACHED_PASSWORD_CHECK_ENABLED`
Defaults to the inverse of `FLUXER_SELF_HOSTED`. Breached password rejection. Sends the first five characters of the password's SHA-1 hash to `api.pwnedpasswords.com`. Off by default on a self-hosted instance. Compose forwards it from `.env`.
@@ -1731,6 +1735,10 @@ Compose writes each of these from the `.env` name given, so setting the name its
`BUILD_VERSION`, `FLUXER_BUILD_VERSION` and `RELEASE_CHANNEL` describe the shipped build. `POD_NAME` and `HOSTNAME` come from the platform.
#### Report retention
`FLUXER_REPORT_RETENTION_DRY_RUN`, so the [report retention](/admin-api/reports/#report-retention) job stays in dry run until an override sets it on `worker`.
#### Test and development switches
`FLUXER_TEST_MODE_ENABLED`, `FLUXER_TEST_HARNESS_TOKEN`, `FLUXER_DISABLE_RATE_LIMITS`, `FLUXER_VALIDATE_RESPONSES` and `FLUXER_RELAX_REGISTRATION_RATE_LIMITS`. None of them belongs on a production instance.
@@ -1753,7 +1761,7 @@ The admin dashboard at `FLUXER_ADMIN_BASE_PATH` stores these in the database. Th
#### Instance Config, Public App Identity
Product name, client-visible brand assets, and the setup state in the instance discovery document.
Product name, client-visible brand assets, the terms, privacy, and community guidelines links, and the setup state in the instance discovery document. No environment variable sets a legal link. On a self-hosted instance, the enforcement emails list only the terms and guidelines links set here, and the clients and report flows show no guidelines link until one is set.
#### Instance Config, Registration Controls
@@ -152,10 +152,10 @@ The 400 shape has no `retry_after` member, no `X-RateLimit-*` header, and no `Re
| [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 ticket and counted from its previous send | `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` |
| [Submit message report flow](/http-api/reports/#submit-message-report-flow), [Submit user report flow](/http-api/reports/#submit-user-report-flow), 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` |
| [Submit message report flow](/http-api/reports/#submit-message-report-flow) | 3 per hour, keyed by the reporter and the channel together | `RATE_LIMITED` |
| [Submit message report flow](/http-api/reports/#submit-message-report-flow) | 20 per hour, keyed by the reported message, across all reporters | `RATE_LIMITED` |
| [Submit message report flow](/http-api/reports/#submit-message-report-flow) | 4 per hour, keyed by the reporter and the guild together, for a guild message | `RATE_LIMITED` |
| [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` |
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.