Developer Portal
Experimental The bot & app platform is in active development. Self-hosted server support shipped on 2026-08-13 with a smaller feature surface than cloud. See what's supported.
← Docs

In-App Settings

GameVox can render your app's configuration form inside the client. Your app describes the form over the gateway, an operator edits it in Server Settings ▸ Integrations ▸ Settings, and the edited values come straight back to your bot. GameVox stores none of it — your app remains the only owner of its configuration.

This has no Discord equivalent. It is the one part of the GameVox app platform that is deliberately not compatible, because there is nothing on the other side to be compatible with.

The GameVox client's Server Settings window, showing an installed app's Settings tab with a language dropdown, a toggle and a channel checklist.
A live panel, rendered entirely from one bot's reply: a select, a boolean and a channels picker, grouped into a section with a description. The bot sent no channel ids — GameVox supplied that list. Server and channel names redacted.

Why this exists

On Discord, a bot with any configuration at all ships a web dashboard: a domain, an OAuth2 login, session handling, a permission check that re-reads the user's guild list, CSRF protection, and hosting for all of it — usually so an operator can pick a log channel and toggle three features. GameVox already knows who the operator is, which server they are configuring, and whether they are allowed to configure it, so it offers that surface to your app directly.

DiscordGameVox
Where operators configure your app A web dashboard you host Server Settings ▸ Integrations ▸ Settings
What you build Web app, OAuth2 login, sessions, permission checks, hosting One gateway handler and one REST call
Who authenticates the operator You, via OAuth2 GameVox, before your app is ever asked
Who stores the configuration You You. GameVox retains nothing.
Channel and role pickers You fetch the guild's channels and roles and render them You name a type; GameVox supplies the choices
Cost of exposing one toggle A dashboard About twenty lines

This replaces nothing. Slash commands still work, and if you already have a dashboard you can keep it — the two are not exclusive, and plenty of apps will want the panel for the five settings operators actually change and the dashboard for everything else.

How it works

Operator opens the Settings tab
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "describe") ────▶ your bot
                                                                │
your bot ─── POST /applications/@me/settings-response ──────────┘
             { nonce, version: 2, sections: [ ... ] }
      │
      ▼
GameVox renders the form, operator edits it, clicks Save
      │
      ▼
GameVox ──── APP_SETTINGS_REQUEST (action: "save", values) ─▶ your bot
                                                                │
your bot ─── POST /applications/@me/settings-response ──────────┘
             { nonce, message: "Saved." }

Two actions, one request shape, one reply endpoint. describe asks what your app exposes for this server; save hands back what the operator changed. Both are answered the same way.

1. Receiving the request

GameVox dispatches APP_SETTINGS_REQUEST over the gateway to whichever session is holding your app's connection. No intent gates it — the dispatch is addressed to your app specifically, not filtered against an intent mask, so it arrives whatever you identified with.

{
  "op": 0,
  "t": "APP_SETTINGS_REQUEST",
  "d": {
    "nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
    "server_id": "1387452901234567890",
    "action": "describe",
    "schema_version": 2
  }
}
FieldTypeNotes
nonce string Opaque. Echo it back verbatim; it is the only thing tying your reply to the waiting operator. Answerable for 15 seconds.
server_id snowflake The guild, in the same id space every other dispatch uses. This is the id your GUILD_CREATE carried, so your existing guild cache resolves it.
action string "describe" or "save".
schema_version integer The richest schema this server can render. Currently 2. See Serving an older server.
values object Present on save only. Keys are the field keys you sent.

server_id is stamped by GameVox from the operator's own authenticated request. It is never read back off your reply, so an app cannot answer a question about one server with settings for another.

Reading the dispatch in your library

No bot library ships a handler for an event Discord does not have, so the packet is dropped before it reaches an event handler. Every library exposes the raw dispatch stream for exactly this case:

LibraryHookEnable
discord.js (v14) client.on('raw', packet) On by default. Emitted for every dispatch before the packet is handled.
discord.py (v2) on_socket_raw_receive(msg) Pass enable_debug_events=True to the client.
Eris client.on('rawWS', packet) On by default.
JDA RawGatewayEvent JDABuilder.setRawEventsEnabled(true)
DSharpPlus DiscordClient.UnknownEvent On by default. EventName plus raw Json.
serenity RawEventHandler::raw_event ClientBuilder::raw_event_handler

Against real Discord this handler never fires, so the same build runs on both platforms without a branch.

2. Replying

Answer over REST, not the gateway. Every library exposes an HTTP client; most do not let application code write arbitrary opcodes to the socket, which is why the reply is shaped this way.

POST https://bot-api.gamevox.com/api/v10/applications/@me/settings-response
Authorization: Bot YOUR_BOT_TOKEN
Content-Type: application/json
{
  "nonce": "5f3c1b0e-8a4d-4b2f-9c11-2f7a6d0b4e93",
  "version": 2,
  "message": "Optional note shown above the form.",
  "sections": [
    {
      "key": "welcome",
      "label": "Welcome messages",
      "description": "Posted when someone joins.",
      "fields": [
        { "key": "welcome.enabled", "label": "Send a welcome message",
          "type": "boolean", "value": true }
      ]
    }
  ]
}

A successful reply returns 204 No Content. You may append ?server_id= with the guild id; it is used only to attribute the call in your app's activity log.

KeyTypeNotes
nonce string Required. From the request.
version integer Schema you are answering in. Send 2 when you send sections.
sections array Schema v2 form. Mutually exclusive with fields.
fields array Schema v1 flat form. Renders as one unlabelled section.
message string On describe, a note above the form. On save, the confirmation toast. Clipped at 200 characters.
error string Shown to the operator instead of a form. Clipped at 300 characters.

Always reply, including on failure. Dropping the request silently leaves the operator on a spinner until GameVox's 12-second timeout, which reads as “this app is broken” rather than “something went wrong once”.

3. Field types

Twelve types, and the list is closed — an unrecognised type rejects the whole reply rather than rendering as nothing.

Type Control value you sendWhat you get back on save
string One-line text box string string ("" when blank)
text Textarea string string
boolean Toggle switch boolean true / false
number Numeric input number number, or null if cleared
select Dropdown of your options option value string, or null if unset
multiselect Checklist of your options array of option values array of strings (may be empty)
channel Dropdown of this server's channels channel snowflake snowflake string, or null
channels Checklist of this server's channels array of snowflakes array of snowflake strings
role Dropdown of this server's groups role snowflake snowflake string, or null
roles Checklist of this server's groups array of snowflakes array of snowflake strings
color Colour swatch #rrggbb #rrggbb, always present
static Read-only line of text string Never submitted — the key is absent from values

A colour input always holds a value, so an app that stored none gets #000000 back on the first save. If you need “no colour”, pair the swatch with a boolean.

4. Field attributes

AttributeApplies toEffect
key all Required. [A-Za-z0-9][A-Za-z0-9._:-]{0,63}. Unique across the whole reply, not per section.
label all Falls back to the key. Clipped at 100 characters.
help all Hint line under the control. Clipped at 200.
placeholder text inputs, number Clipped at 100.
options select, multiselect Required for those two. { value, label, description }, max 25. Dropped on any other type.
min , max , step number Shapes the input. Advisory — re-check the value you receive.
max_length string, text Caps the input, up to the platform ceiling for that type.
channel_kinds channel, channels Narrows the picker. text, voice, forum, news, fileshare, header. Unknown kinds are dropped; an empty result means every kind.
required all Marks the label and replaces the picker's “None” slot with “Select…”. Presentational — enforce it on save.
disabled all Greys the control. Still submitted, with its current value.
secret string, text Masks the input and blanks the current value on the way out. Any other type rejects the reply.
show_if all { key, equals }. Hides the field until another field in the same reply holds that value.

show_if is an equality test and nothing else — no expressions, no operators. The referenced key must belong to a single-value field in the same reply and must not be the field's own key; a condition that fails any of those is dropped and the field shows unconditionally. equals may be a string, boolean, number or null.

A hidden field is still submitted. It has a value your app sent, and dropping it would read at your app as the operator clearing it.

5. Channel and role pickers

These are the part worth understanding, because they invert the usual arrangement. Your app names a kind; GameVox supplies the choices.

A channel field carries no ids. You send { "type": "channel" } and, if you want, a channel_kinds filter. GameVox attaches this server's own channel list, the client renders a picker from it, and the operator can only ever submit an option GameVox issued.

Every catalog entry is keyed by the same snowflake your app already sees for that channel or group. There is no id translation step to get wrong: the operator picks a label, your app receives an id in its own namespace, and a value naming something outside this server simply matches no option.

A channels field rendered as a checklist of the server's own channels, each prefixed by its kind glyph.
The whole of what the app sent for this field was { "key": "ignored", "type": "channels", "channel_kinds": ["text", "news", "forum", "fileshare"] }. The list, the kind glyphs and the ids are GameVox's.
SituationWhat the operator sees
Nothing stored yet A “None” slot, selected. A required field shows “Select…” instead, so a save cannot commit a value nobody chose.
Stored id no longer exists (channel deleted) Kept as “Current selection (no longer listed)”, still selected. Saving does not silently clear it.
Stored ids in a multi-picker that are no longer listed Rendered and ticked alongside the live ones, for the same reason.
Catalog unavailable (self-hosted box offline) Disabled picker reading “Not available right now”. The stored value is echoed back on save, so opening the panel during an outage cannot wipe a setting.
Server has more than 500 channels or groups The list is truncated at 500 rather than shipping a megabyte of options.

The role catalog excludes two things: the server owner group, which no app should be offered as an assignable role, and the per-app groups that carry installed apps' own permissions.

6. Scenarios

Scenario 1 — The smallest useful panel

One toggle, no sections. A flat fields array is valid at any schema version and renders as a single unlabelled group.

{
  "nonce": nonce,
  "fields": [
    { "key": "greetings", "label": "Greet new members", "type": "boolean", "value": true }
  ]
}

Scenario 2 — Grouping into sections

{
  "nonce": nonce,
  "version": 2,
  "sections": [
    {
      "key": "general",
      "label": "General",
      "description": "How the bot behaves across this server.",
      "fields": [
        { "key": "prefix", "label": "Command prefix", "type": "string",
          "value": "!", "max_length": 4, "help": "Used for legacy text commands." }
      ]
    },
    {
      "key": "logging",
      "label": "Audit logging",
      "fields": [
        { "key": "log.enabled", "label": "Log moderation actions", "type": "boolean", "value": false }
      ]
    }
  ]
}

Section key is optional but must be unique when present. label and description are both optional; a section with no fields is not rendered.

Scenario 3 — Ask for a channel

No ids, no fetching the guild's channel list. Restrict the picker to the kinds that can hold a message.

{
  "key": "log.channel",
  "label": "Log channel",
  "type": "channel",
  "value": stored.logChannelId ?? null,
  "channel_kinds": ["text", "news"],
  "help": "Where moderation actions are recorded."
}

Use "channel_kinds": ["header"] when you want a category rather than somewhere to post — headers are GameVox's categories.

Scenario 4 — Reveal fields conditionally

The usual shape: a boolean that gates the rest of its feature.

"fields": [
  { "key": "welcome.enabled", "label": "Send a welcome message",
    "type": "boolean", "value": true },

  { "key": "welcome.channel", "label": "Channel", "type": "channel",
    "value": stored.welcomeChannel ?? null,
    "channel_kinds": ["text", "news"],
    "show_if": { "key": "welcome.enabled", "equals": true } },

  { "key": "welcome.message", "label": "Message", "type": "text",
    "value": stored.welcomeMessage ?? "",
    "max_length": 1800,
    "placeholder": "Welcome {user} to {server}!",
    "help": "{user}, {server} and {membercount} are substituted when sent.",
    "show_if": { "key": "welcome.enabled", "equals": true } }
]

Conditions can key off a select too, which is how you build a mode switch: "show_if": { "key": "mode", "equals": "advanced" }.

Scenario 5 — Your own choices

select and multiselect are the two types that carry their own options. Both require at least one option and allow at most 25.

{
  "key": "automod.action",
  "label": "When a filter matches",
  "type": "select",
  "value": stored.action,
  "required": true,
  "options": [
    { "value": "delete",  "label": "Delete the message" },
    { "value": "warn",    "label": "Warn the author",  "description": "Deletes and DMs the member." },
    { "value": "timeout", "label": "Time the author out", "description": "10 minutes." },
    { "value": "none",    "label": "Do nothing" }
  ]
}
{
  "key": "automod.filters",
  "label": "Active filters",
  "type": "multiselect",
  "value": stored.filters,
  "options": [
    { "value": "invites",   "label": "Discord/GameVox invites" },
    { "value": "links",     "label": "Links" },
    { "value": "mentions",  "label": "Mass mentions" },
    { "value": "caps",      "label": "Excessive caps" }
  ]
}

An option's description appears after the label in a dropdown and as a tooltip in a checklist.

Scenario 6 — Roles

Same catalog rule as channels. GameVox lists this server's groups by rank, with their colours.

"fields": [
  { "key": "autorole.role", "label": "Role given on join",
    "type": "role", "value": stored.autoRole ?? null },

  { "key": "moderator.roles", "label": "Roles that may use moderation commands",
    "type": "roles", "value": stored.modRoles,
    "help": "Members with any of these can run /ban and /timeout." }
]

Scenario 7 — Numbers with bounds

{
  "key": "automod.threshold",
  "label": "Messages before a raid is declared",
  "type": "number",
  "value": stored.threshold ?? 10,
  "min": 3,
  "max": 100,
  "step": 1,
  "help": "Counted over a rolling 60-second window."
}

min, max and step shape the control only. The operator's browser is not a validator you control, so clamp the value again when it comes back — and note that clearing the box submits null, not 0.

Scenario 8 — Secrets

Mark a credential secret and GameVox blanks its current value on the way out. It is not in the WebSocket frame, not in the DOM, and not in a screenshot of the panel — the operator sees an empty masked box placeholdered “Unchanged”.

{
  "key": "integrations.apiKey",
  "label": "Weather API key",
  "type": "string",
  "secret": true,
  "help": "Leave blank to keep the current key."
}

The contract that follows from that: an empty submitted secret means “leave unchanged”, never “clear this”. If an operator needs to remove a credential, give them an explicit boolean to do it. secret on anything other than string or text rejects the reply.

Scenario 9 — Read-only status

static renders a line of text and is never submitted. Use it for state the operator needs while configuring but cannot edit here.

"fields": [
  { "key": "plan", "label": "Plan", "type": "static", "value": "Pro — 4,000 lookups/day" },
  { "key": "usage", "label": "Used today", "type": "static", "value": "1,284 lookups" },
  { "key": "lastSync", "label": "Last sync", "type": "static", "value": "2026-09-02 14:31 UTC" }
]

Scenario 10 — Handling a save

The save request carries values, keyed by the field keys you sent. Persist, then reply with a confirmation.

// action === "save"
const v = req.values ?? {};

const guild = client.guilds.cache.get(req.server_id);
if (!guild) return reply(nonce, { error: "This bot is no longer in that server." });

const patch = {};

// boolean: always a real boolean
patch.welcomeEnabled = v["welcome.enabled"] === true;

// channel: a snowflake string or null — re-check it against THIS guild
const chan = v["welcome.channel"];
patch.welcomeChannel =
  (typeof chan === "string" && guild.channels.cache.has(chan)) ? chan : null;

// number: number or null when the operator cleared the box
const n = v["automod.threshold"];
patch.threshold = typeof n === "number" ? Math.min(100, Math.max(3, Math.round(n))) : 10;

// secret: empty means unchanged
const key = v["integrations.apiKey"];
if (typeof key === "string" && key.trim() !== "") patch.apiKey = key.trim();

await store.update(req.server_id, patch);
await reply(nonce, { message: "Settings saved." });

Your message becomes the operator's confirmation toast. The form is left exactly as they had it, so they can keep editing.

Scenario 11 — Rejecting a save

Return error and the operator sees your text instead of a confirmation. Use it for anything you cannot accept: a value that fails your own validation, a plan limit, an upstream credential that no longer works.

// `required` is presentational — enforce it here.
const action = v["automod.action"];
if (!["delete", "warn", "timeout", "none"].includes(action)) {
  return reply(nonce, guildId, {
    error: "Choose what AutoMod should do when a filter matches.",
  });
}

// Anything only your side can know.
if (patch.apiKey && !(await upstream.verifyKey(patch.apiKey))) {
  return reply(nonce, guildId, {
    error: "That API key was rejected by the weather provider.",
  });
}

The same applies to describe: if your database is down, answer with an error rather than not answering. “This app could not read its settings right now” is a far better outcome for the operator than a twelve-second spinner.

Scenario 12 — Serving an older server

The request carries schema_version, so you never have to probe. Serve the richest form the server will actually render.

const version = typeof req.schema_version === "number" ? req.schema_version : 1;

if (version >= 2) {
  await reply(nonce, { version: 2, sections: describeV2(guildId) });
} else {
  // v1: flat list, four types — string, boolean, number, select
  await reply(nonce, { fields: describeV1(guildId) });
}

The reverse also holds. A v2 reply reaching an older client still renders: GameVox sends a flattened fields array alongside sections, and a client that predates the newer types draws them as text boxes. Degraded, but nothing is hidden.

Scenario 13 — Self-hosted servers

Works unchanged. On a self-hosted server the channel and role catalogs are fetched from the customer's own box rather than from cloud tables, which is invisible to your app — same field types, same snowflakes, same reply.

The one difference you can observe: if the box is unreachable, pickers arrive empty and render as “Not available right now”. Your app's stored values are echoed back on save, so nothing is lost. Do not treat an empty picker as “the operator cleared it”.

7. What GameVox validates, and what it does not

GameVox checks the shape of a save, and explicitly not its meaning. It does not retain the form it rendered — storing none of your configuration is the whole point — so by the time a save arrives, nothing on our side knows which key was a channel picker and which was a free-text box.

GameVox guaranteesYou must still check
Keys match the key charset and are not __proto__, constructor or prototype That the key is one you actually sent
Values are null, boolean, number, string, or an array of strings — never a nested object That the type matches the field you declared
Strings are bounded (2,000 characters) and arrays hold at most 25 entries Your own tighter limits
Every picker id was issued by GameVox from this server's catalog That the id still resolves in this guild — catalogs are a snapshot, and a channel can be deleted between render and save
The operator is the server owner or holds Manage Server, and the app is installed here Any authorisation of your own (a plan tier, a linked account)

This is not a gap the panel introduces — an operator can already POST anything at all to an app's own dashboard. It is the contract, and it is exactly why the picker types hand you ids GameVox minted instead of letting an app decide what an id means.

8. Limits

Two behaviours here, and the difference matters: counts and structure reject the whole reply, so you find out; text ceilings clip quietly, so an operator never sees a broken layout.

LimitValueOn overflow
Sections per reply 12 Rejected
Fields per reply (total, not per section) 60 Rejected
Options per select / multiselect 1–25 Rejected
Selected entries in a multi-value 25 Rejected
Field / section key 64 chars Rejected
Option value 64 chars Rejected
Reply body 256 KB 413
Label, placeholder 100 chars Clipped
Help text 200 chars Clipped
Section description 300 chars Clipped
message 200 chars Clipped
error 300 chars Clipped
Value of a string-family field 1,000 chars Clipped
Value of a text / static field 2,000 chars Clipped
Channels or roles in a catalog 500 Truncated

A rejection is reported to the operator as “This app sent settings GameVox could not read: <reason>”, naming the ceiling you hit and the field that hit it. Duplicate keys, an unknown type, a select with no options, and sending both sections and fields are rejected the same way.

Silently corrected rather than rejected

  • A current value that does not fit its type becomes empty. A stale value should blank one field, not take down the panel.
  • options on a type that does not use them are dropped.
  • min / max / step on a non-numeric field are dropped.
  • Unrecognised channel_kinds entries are dropped.
  • A show_if naming an unknown key, itself, or a multi-value field is dropped, and the field shows unconditionally.

9. Timing and failure modes

BehaviourValueWhat happens
Reply window 12s The operator is told “This app did not respond. It may not support in-app settings.”
Nonce lifetime 15s A late reply is dropped rather than delivered to nobody. Answering with an expired nonce returns 404.
Cooldown 2s per action, per app, per server “Give the app a moment, then try again.” describe and save have separate windows, so opening the panel does not block an immediate save.
App offline Refused up front: “This app is offline, so it cannot be configured right now.” No dispatch is attempted.
Wrong app answering 403. A nonce is bound to the application it was issued for.
Duplicate reply The first answer wins; the second is a no-op.

Every request turns into a gateway dispatch at your app, so the cooldown exists to stop an operator using GameVox to hammer a third party's bot. Budget your describe handler accordingly: it runs on a page open, and a database read is expected, but twelve seconds is the wall.

10. Complete example (discord.js)

Drop-in, minus your own storage layer. This is the same shape the reference implementation uses.

const SCHEMA_V2 = 2;
const POSTABLE = ["text", "news", "forum", "fileshare"];

client.on("raw", (packet) => {
  if (!packet || packet.t !== "APP_SETTINGS_REQUEST") return;
  void handleSettings(packet.d ?? {});
});

async function reply(nonce, guildId, body) {
  await client.rest.post("/applications/@me/settings-response", {
    body: { nonce, ...body },
    query: new URLSearchParams({ server_id: guildId }),
  });
}

async function handleSettings(req) {
  const nonce = typeof req.nonce === "string" ? req.nonce : "";
  const guildId = typeof req.server_id === "string" ? req.server_id : "";
  if (!nonce || !guildId) return;

  try {
    if (req.action === "save") {
      const message = await applySettings(guildId, req.values ?? {});
      return await reply(nonce, guildId, { message });
    }

    const version = typeof req.schema_version === "number" ? req.schema_version : 1;
    if (version >= SCHEMA_V2) {
      return await reply(nonce, guildId, {
        version: SCHEMA_V2,
        sections: await describe(guildId),
      });
    }
    return await reply(nonce, guildId, { fields: await describeLegacy(guildId) });
  } catch (err) {
    // Answer even on failure — a silent drop reads as a broken app.
    await reply(nonce, guildId, {
      error: "The bot could not read its settings for this server.",
    }).catch(() => undefined);
  }
}

async function describe(guildId) {
  const cfg = await store.get(guildId);
  return [
    {
      key: "general",
      label: "General",
      description: "How the bot behaves across this server.",
      fields: [
        { key: "prefix", label: "Command prefix", type: "string",
          value: cfg.prefix, max_length: 4 },
        { key: "ignored", label: "Ignored channels", type: "channels",
          value: cfg.ignored, channel_kinds: POSTABLE,
          help: "Commands are not answered in these channels." },
      ],
    },
    {
      key: "welcome",
      label: "Welcome messages",
      description: "Posted when someone joins.",
      fields: [
        { key: "welcome.enabled", label: "Send a welcome message",
          type: "boolean", value: cfg.welcome.enabled === true },
        { key: "welcome.channel", label: "Channel", type: "channel",
          value: cfg.welcome.channel ?? null, channel_kinds: POSTABLE,
          show_if: { key: "welcome.enabled", equals: true } },
        { key: "welcome.message", label: "Message", type: "text",
          value: cfg.welcome.message ?? "", max_length: 1800,
          placeholder: "Welcome {user} to {server}!",
          show_if: { key: "welcome.enabled", equals: true } },
      ],
    },
  ];
}

async function applySettings(guildId, v) {
  const guild = client.guilds.cache.get(guildId);
  if (!guild) throw new Error("not in guild");

  const channel = (id) =>
    typeof id === "string" ? (guild.channels.cache.has(id) ? id : null) : null;

  await store.set(guildId, {
    prefix: String(v.prefix ?? "!").slice(0, 4) || "!",
    ignored: Array.isArray(v.ignored) ? v.ignored.filter(channel).slice(0, 25) : [],
    welcome: {
      enabled: v["welcome.enabled"] === true,
      channel: channel(v["welcome.channel"]),
      message: String(v["welcome.message"] ?? "").slice(0, 1800),
    },
  });

  return "Settings saved.";
}

11. Checklist before you ship

  • Every field key is stable. Renaming one orphans whatever the operator already configured.
  • Every id from a picker is re-checked against the guild before it is written.
  • An empty secret is treated as unchanged, never as cleared.
  • The describe path answers in well under 12 seconds, including its database read.
  • Every failure path still replies — with error, not with silence.
  • required and min/max are enforced again on save.
  • An empty picker means the catalog was unavailable, not that the operator cleared the field.
  • The handler is a no-op on Discord, so one build runs on both.

Who can open the panel

The server owner, or a member with Manage Server — the same gate that governs installing and uninstalling apps. On a self-hosted server the check is made against the customer's own box, so an admin there is not denied for lacking a cloud permission row.

GameVox refuses the request outright if your app is not installed on that server, so a panel request can only ever reach an app the operator has already authorised.

← Migrating from Discord  ·  Back to docs