App channels
An app channel is a channel in a server’s channel list whose content is a set of screens your app supplies while it is online: a raid planner, a ranked ladder, a ticket desk, a server status page. GameVox stores where the channel is, who may see it and what they may do. Your app owns the content and renders every screen on request.
Overview
An app is one place with many screens. Every screen is a route, a string your app defines (home, raids, raids/42, roster?sort=ilvl). A member who opens the channel lands on its start route (normally your home), and moves around through your navigation menu, through links and buttons inside screens, and with Back and Forward.
What members see
- The channel in the channel list with your icon (or one the server picked), and a small dot while your app is offline.
- Your navigation menu, Back and Forward, a Home button and Refresh, around the screen you returned.
- Screens drawn natively by each client from your JSON, in the client’s own theme, light and dark.
- While your bot is offline: the last public snapshot you pushed, marked as the last update, or an offline notice if there is none.
Clients: the desktop app (Windows, macOS, Linux), the web app, iOS and Android. The Xbox app does not show app channels. A client too old to know them never receives app channels in its channel list, so nobody sees an empty text channel.
How this differs from slash commands and components
- Slash commands and message components live in messages in a text channel and reach your app as
INTERACTION_CREATEwith an interaction token. An app channel has no messages at all: posting to one is refused. - A view request arrives as its own gateway event,
APP_VIEW_REQUEST, and you answer it with one REST call carrying a single-use nonce. There is no interaction token, no deferring, no follow-ups. HTTP interaction endpoints are not used, so your app needs a live gateway connection. - Every screen is personal. GameVox asks for it each time a member opens or uses it, and never stores it (only the optional public snapshot is stored).
- Inside the screen your app defines its own permissions, called abilities. Server admins grant them per group, and GameVox tells you which ones the viewer holds on every request.
- This is a GameVox extension with no Discord equivalent. Bots written against Discord libraries keep working; they just never receive the event.
How it fits together
A member opens the channel, navigates, or presses a button
│
▼
GameVox ──── APP_VIEW_REQUEST { nonce, kind, route, user, abilities } ────▶ your bot
▲
│
your bot ──── POST /applications/@me/app-view-response { nonce, view } ────▶ GameVox
│
▼
GameVox checks and cleans the view, then the client draws it GameVox checks the viewer’s permission to view the channel on every open and every action, computes their abilities, and only then asks your app. Your reply goes to that one viewer.
Lifecycle
- A server installs your app the usual way (the install link or OAuth2 authorize page).
- At every startup your bot publishes its manifest. An app without a manifest cannot have app channels.
- A server admin picks Create Channel ▸ App. The list shows installed apps that have published a manifest, with their name, description and icon. The channel name is filled in from your app, and an optional Opens on menu lists your nav items (default: home). Creating channels needs the usual permission to create channels. Your bot can create one too (below).
- In Channel Settings the admin can rename the channel, change its icon (your app’s, an emoji, or an uploaded image), change where it opens, and set permissions: who may view the channel, and which groups hold each of your abilities.
- Members who can view the channel open it, and your app answers
APP_VIEW_REQUESTfor every screen they look at. - If your app is removed from the server, its app channels stay (reinstalling brings them back) and show “This app was removed from the server”, with a Delete button for managers. If the application is deleted, or has never published a manifest, the channel shows “This app is no longer available”.
App channels count toward a server’s channel limit like any other channel. A server usually has one channel per app; a second channel for the same app is allowed (for example a raid team channel that opens on the raid planner) and is the same app with the same navigation. Every request carries the channel_id, so you can keep separate data per channel.
Creating an app channel from your bot
Use the normal channel create route with type 100 and an optional gamevox_app object:
POST /api/v10/guilds/1491230000000000001/channels
Authorization: Bot <token>
Content-Type: application/json
{
"type": 100,
"name": "raid-planner",
"position": 7,
"gamevox_app": { "start_route": "raids", "icon": { "builtin": "calendar" } }
}
HTTP/1.1 201 Created
{
"id": "1491240000000000002",
"type": 100,
"gamevox_type": "app",
"name": "raid-planner",
"guild_id": "1491230000000000001",
"position": 7,
"gamevox_app": { "application_id": "1491200000000000000", "start_route": "raids" }
} - Only for your own application, which must be installed in that server and must have published a manifest. The bot needs permission to create channels, as for any channel.
gamevox_app.start_routeis a route (empty or absent: your manifest’shome).gamevox_app.iconis anemoji, abuiltinglyph, or aurlyour manifest already holds. Bots cannot upload a channel image; only admins can.- Without
positionthe channel goes at the end of the list. The name is 1 to 100 characters. - Who may view the channel and who holds each ability is the server admins’ to set in Channel Settings.
An app channel is a normal channel object with GameVox extensions, in REST answers and in gateway channel events:
typeis 100, outside Discord’s range on purpose. A library that does not know the number may treat it as a plain channel;gamevox_type: "app"is the reliable way to recognise one.GET /channels/{id},GET /guilds/{id}/channels,PATCHandDELETE /channels/{id}work as for other channels.PATCHalso takesgamevox_app: {"start_route", "icon"}, only on a channel of your own application, and needs permission to edit channels.
The manifest
Each app publishes one manifest: its name, icon, home route, navigation menu and abilities. Publish it at every startup with PUT /applications/@me/app-manifest. It overwrites the stored one and is safe to repeat.
PUT /api/v10/applications/@me/app-manifest
Authorization: Bot <token>
Content-Type: application/json
{
"name": "Raid Planner",
"description": "Plan raids and sign up, right in a channel.",
"icon": { "builtin": "calendar" },
"home": "home",
"nav": [
{ "label": "Home", "route": "home", "icon": { "builtin": "home" } },
{ "label": "Raids", "route": "raids", "icon": { "builtin": "calendar" } },
{ "label": "Officers", "ability": "manage", "children": [
{ "label": "Roster", "route": "roster", "ability": "manage" }
] }
],
"abilities": [
{ "key": "signup", "label": "Sign up for raids", "default": "everyone" },
{ "key": "manage", "label": "Manage raids",
"description": "Create, edit and cancel raids", "default": "managers" }
]
} | Field | Notes |
|---|---|
name | Required, up to 50 characters. May have name_localizations. |
description | Up to 300 characters. May have description_localizations. |
icon | An icon (see below). |
home | The landing route. Default home. |
nav | The menu: up to 12 top-level items and 30 in total. |
abilities | Up to 25 app-defined permissions. |
kind | Must be layout (the default). Other kinds are reserved and refused. |
storage | Optional. Only for apps that take file uploads (see App files). An app that declares no storage gets no uploads. |
Nav items are {label, route, icon?, ability?, children?}. A label is up to 40 characters. An item with children is a group: it may omit its own route, and groups nest one level only. An item with an ability is hidden from viewers who lack it, and that ability must be declared in abilities. Routes match ^[A-Za-z0-9._~/?=&%:-]{1,200}$.
Abilities are {key, label, description?, default}: a key matching ^[a-z0-9_]{1,32}$, a label up to 40 characters, a description up to 200, and a default of everyone (every group that can view the channel), managers (manager groups, see Per-viewer context) or nobody. Admins can override the default per group on each app channel.
Icons are exactly one of {"emoji": "…"} (a single emoji, drawn as Twemoji), {"builtin": "…"} (a GameVox glyph: calendar, trophy, list, chart, shield, sword, map, users, clock, star, flag, book, gift, bolt, home, game) or {"image_data": "<base64>"} (a square PNG or WebP, 16 to 2048 pixels, up to 128 KB). GameVox scans and stores image_data and returns {"url": …} in the stored manifest. A URL you host yourself is refused, because it would never be shown to members; you may send back a url icon you read from GameVox.
The answer is 200 with the stored manifest (defaults filled in, images replaced by URLs). A mistake is a 400 that names the field, for example nav[2].route is not a valid route. Publishing the same manifest again changes nothing. A changed manifest makes every open app channel of yours refresh, so a new menu shows up without anyone reloading. GET /applications/@me/app-manifest returns what is stored, or 404 if you never published one.
Localizing the manifest
Every text in the manifest that a person reads can carry translations, the way a Discord slash command carries name_localizations. Put a map next to the text it translates. The plain field stays the default: it is what a member sees when the map has no entry for their language.
{
"name": "Raid Planner",
"name_localizations": { "de": "Raidplaner", "pt-BR": "Planejador de raids", "ja": "レイドプランナー" },
"nav": [
{ "label": "Roster", "route": "roster",
"label_localizations": { "de": "Aufstellung", "fr": "Effectif", "es-ES": "Plantilla" } }
],
"abilities": [
{ "key": "manage", "label": "Manage raids", "default": "managers",
"label_localizations": { "de": "Raids verwalten" },
"description": "Create, edit and cancel raids",
"description_localizations": { "de": "Raids erstellen, bearbeiten und absagen" } }
]
} name_localizationsanddescription_localizationson the manifest (texts up to 50 and 300 characters),label_localizationson a nav item (up to 40),label_localizationsanddescription_localizationson an ability (up to 40 and 200).- Keys are Discord locale codes (
de,fr,pt-BR,es-ES,zh-CN,jaand the rest of Discord’s list). A language Discord has no code for (ar,he,fa,ms,sk,et,lv,sr,sl,tl,ur,bn,ta,sw,ca) uses its GameVox language tag. Keys are case-sensitive, a map holds at most 64 locales, and an unknown key is a400that names the field. - A blank value,
nullor a value equal to the default text is dropped, sonullclears a locale. A value over the text’s limit is a400, for examplenav[2].label_localizations[de] is longer than 40 characters. - Ability keys, routes and icons are not localized. Views are not part of the manifest: render each view in the viewer’s language, using the
localeof the request.
The manifest without its maps keeps its 64 KB limit. The maps get their own budget of 192 KB on top, so a manifest in 44 languages fits (the whole document is at most 256 KB). A manifest with no maps is held to 64 KB as before.
GameVox resolves the maps for each member and sends clients plain strings. The language is the one the member’s client last reported, the same one that picks the language of slash command descriptions. It covers the app name and description in the channel header and the Create Channel list, the menu, the ability names and descriptions in the permission editor, and the app name in notifications. GET /applications/@me/app-manifest returns the stored manifest with its maps.
Requests and replies
When a member opens the channel, navigates, refreshes, or presses a button, your bot receives a gateway dispatch (opcode 0) named APP_VIEW_REQUEST. It needs no intent. It goes to a live gateway session of your application only.
{
"op": 0,
"s": 812,
"t": "APP_VIEW_REQUEST",
"d": {
"nonce": "2f6c1a9e-4b7d-4c1e-9a53-0d8e7f6a5b41",
"kind": "action",
"guild_id": "1491230000000000001",
"channel_id": "1491240000000000002",
"route": "raids/42",
"params": { "raid": 42 },
"action": "signup",
"values": { "role": "healer", "note": "Late by 10 min" },
"user": {
"id": "1491250000000000003",
"username": "ben",
"display_name": "ben",
"avatar_url": "https://..."
},
"abilities": ["signup"],
"is_manager": false,
"locale": "en-US",
"platform": "desktop",
"self_hosted": false,
"plan": {
"key": "free",
"limits": {
"max_file_bytes": 8388608, "max_files_per_field": 4, "max_total_bytes": 524288000,
"max_uploads_per_user_per_day": 50, "kinds": [], "retention_days": 0, "max_video_bytes": 0
}
}
}
} | Field | Notes |
|---|---|
nonce | Single use. Send it back with your reply. |
kind | open (the member opened, navigated to or refreshed route) or action (they pressed a button or submitted a form). |
guild_id, channel_id | The server and the app channel being viewed, as snowflakes. Key per-channel data by channel_id. |
route | The route to show. Never empty: when the client sends none it is the channel’s start route, else your home. |
params | The params of the link or button that led here (plain data, up to 2 KB). Always an object. |
action, values | Only on kind: "action": the action name and the submitted form values (an empty object for a plain button). |
user | The viewer: id (snowflake), username, display_name (the same as the username today) and avatar_url (may be empty). |
abilities | The abilities this viewer holds on this channel. Enforce them yourself. |
is_manager | True for the server owner, an admin group, or a group with the Edit Server or Manage Integrations permission. |
locale | The viewer’s client language (for example en-US or de); may be empty. Use it to answer in their language. |
platform | desktop, web, ios or android. |
self_hosted | True when the channel lives on a self-hosted server. |
plan | The app’s plan in this server: {key, limits} (see Plans). |
Replying
Answer with one call to POST /applications/@me/app-view-response, authenticated like every other bot call (Authorization: Bot <token>):
POST /api/v10/applications/@me/app-view-response
Authorization: Bot <token>
Content-Type: application/json
{
"nonce": "2f6c1a9e-4b7d-4c1e-9a53-0d8e7f6a5b41",
"toast": "Signed up as healer",
"view": { "version": 1, "title": "Mythic raid", "route": "raids/42", "blocks": [ ... ] }
}
HTTP/1.1 204 No Content viewreplaces the viewer’s screen. On an action that only needs a toast, leave it out: the viewer keeps the current screen.toastis a short line (up to 200 characters) shown as a toast. It can go with aview.error(up to 300 characters) shows the viewer a message instead of a screen. Whenerroris set,viewandtoastare ignored.open_urlasks the client to open anhttpsURL after its normal external-link prompt. Any other scheme is dropped.- Success is
204. The view is checked and cleaned after that (see What the sanitizer does), so a204does not mean every field survived.
Timing
- The viewer waits 10 seconds. After that they see “The app didn’t answer. Try again.”, and a late reply is dropped. Aim to answer well inside it; the GameVox bot library gives each request an 8 second deadline.
- The nonce is accepted for 15 seconds and only once. A second reply, or a reply after it expired, gets
404. - If your application holds several gateway sessions (shards, or a restart overlap), more than one may receive the same request. Answer it once; only the first answer counts.
- Your bot counts as online while its gateway session is connected and heartbeating. While it is offline GameVox does not ask it: an open shows your snapshot, and an action tells the member the app is offline.
The view schema
A view is a JSON layout document: kind layout, version 1. Each client draws it natively. Unknown fields are ignored and unknown block types are dropped, so new optional fields can be added without breaking anyone.
{
"version": 1,
"title": "Raids",
"subtitle": "Next 7 days",
"route": "raids",
"nav_active": "raids",
"tabs": [
{ "label": "Upcoming", "route": "raids" },
{ "label": "Past", "route": "raids?past=1" }
],
"actions": [
{ "label": "New raid", "route": "raids/new", "style": "primary", "ability": "manage" }
],
"refresh_seconds": 60,
"blocks": [
{ "type": "stats", "items": [
{ "label": "Signed up", "value": 18, "trend": "up" },
{ "label": "Open slots", "value": 2, "color": "warning" }
] },
{ "type": "countdown", "label": "Next pull", "until": "2026-11-02T19:00:00Z" },
{ "type": "table",
"columns": [
{ "key": "name", "label": "Raid", "sortable": true },
{ "key": "when", "label": "When", "kind": "time" },
{ "key": "lead", "label": "Lead", "kind": "user" },
{ "key": "fill", "label": "Filled", "kind": "progress" }
],
"rows": [
{ "id": "42",
"cells": { "name": "Mythic", "when": "2026-11-02T19:00:00Z",
"lead": "1491250000000000003", "fill": { "value": 18, "max": 20 } },
"action": { "route": "raids/42" } }
] },
{ "type": "text", "markdown": "Bring flasks. Questions to <@1491250000000000003>." }
]
} | Field | Notes |
|---|---|
version | 1 or absent. Any other version shows “This screen needs a newer version of GameVox.” |
kind | layout or absent. Same rule. |
title | Up to 100 characters, shown in the header. |
subtitle | Up to 200 characters. |
route | The route this screen shows. It may differ from the one asked for (a redirect); the client records it in its history. If you leave it out GameVox fills in the requested route. |
nav_active | Which nav item to highlight. |
breadcrumbs | Up to 5 {label, route?}, labels up to 100 characters. |
tabs | Up to 8 sub-navigation tabs {label, route, badge?}; the tab whose route matches is selected. Labels up to 40, badges up to 20. |
actions | Up to 4 header buttons (see Buttons). |
refresh_seconds | Clients re-open the screen this often while it is visible and the window is focused: 15 to 3600 seconds. |
blocks | The content: at most 60 blocks in one list and 600 blocks in the whole view. |
Blocks
Every block has a type and an optional id (up to 64 characters). The notes give the limits: anything past a limit is cut or dropped.
| Type | Fields | Notes |
|---|---|---|
heading | text, level | A heading. Text up to 200 characters, level 1 to 3 (default 2). |
text | markdown | A paragraph of markdown, up to 4,000 characters. |
callout | style: info | success | warning | error, markdown | A highlighted box with markdown. Style defaults to info. |
divider | A horizontal line. | |
section | title, description, blocks, collapsible, collapsed | A group of blocks with an optional title (100) and description (200), optionally collapsible. Containers nest two deep. |
columns | columns: [{width 1-12, blocks}] | Side by side columns on a 12-wide grid (up to 4 columns). They stack on narrow screens. |
stats | items: [{label, value, delta, trend, color, icon}] | Stat tiles (up to 8). Value up to 100 characters, delta up to 30, trend up, down or flat. |
key_value | items: [{label, value, user}] | Label and value pairs (up to 30). Values up to 500 characters. A row may be only a user, which draws a member chip. |
table | columns: [{key, label, align, sortable, kind}], rows: [{id, cells, action}], page_size, emptykind: text | number | user | badge | progress | time | icon | image | A table: up to 10 columns and 200 rows, sorted and paged by the client (page_size 1 to 100, default 25). Cell values follow the column kind; cells up to 500 characters. Rows without an id get one. |
list | items: [{id, title, subtitle, user, icon, image, badges, meta, action, buttons}] | A list (up to 100 items). Titles up to 200 characters, up to 5 badges and 5 buttons per item. |
progress | label, value, max, color | A progress bar. max defaults to 100; the value is kept between 0 and max. |
countdown | label, until | A live countdown to an RFC 3339 time. Dropped without until. |
timeline | items: [{time, title, markdown, icon, color}] | A timeline (up to 50 entries). Entry markdown up to 500 characters. |
calendar | mode: month | week | agenda, start, events: [{id, title, start, end, color, action}] | A calendar (up to 200 events). Narrow screens default to the agenda view. The previous and next buttons send the action calendar_nav (see Actions). |
chart | kind: bar | line, series: [{name, color, points: [{x, y}]}], x_label, y_label | A bar or line chart: up to 5 series of 100 points. x is a number or a label. |
board | columns: [{id, title, cards: [{id, title, subtitle, user, image, badges, action}]}] | A board of columns and cards: up to 6 columns of 50 cards. No drag and drop. |
image | url | file_id, alt, aspect | An image: an https URL or an app file. Alt text up to 200 characters, aspect such as "16:9" or 1.5. |
video | file_id, poster_file_id, title | A video app file (only for apps whose plan allows video; see App files). |
buttons | items: [Button] | A row of buttons (up to 5). |
form | id, fields: [Field], submit: Button | A form (up to 15 fields). See Forms. |
empty | title, text, icon | An empty state: title (100), text (200) and icon. |
Values used across blocks
- Colours are
#rrggbbor a token:accent,success,warning,danger,muted. Badges are{text, color?}(text up to 30) or a bare string. - Times are RFC 3339 and shown in the viewer’s time zone. Calendar
startandendalso take aYYYY-MM-DDdate. - Members (
user) are member snowflakes. The client draws an avatar and display name, and a click opens the profile. - Markdown (
text,callout, timeline entries) is GameVox chat markdown. HTML is never rendered. Mentions such as<@id>,<@&id>and<#id>are drawn as names and never notify anyone. Every other text field is plain text. - Images must be
httpsURLs without credentials, up to 2000 characters. GameVox has no image proxy: an image that is not on GameVox storage is kept but marked"external": true, and clients treat it like a third-party image in chat (no referrer, no cookies, possibly behind a click). App files avoid that.
What the sanitizer does
Your view is third-party content drawn in every member’s client, so GameVox rebuilds it from an allow-list before any client sees it:
- Unknown block types and unknown fields are dropped.
- Text loses invalid UTF-8, control characters and bidirectional override characters, and is cut to its limit.
- Lists are cut to their maximum count; blocks past 60 in a list or 600 in the view are dropped.
- Colours, routes, times, ids and member references that do not match their shape are cleared.
- Items that miss what they need are dropped: a list item without a title, a timeline entry without a time, a button without a label or a target, a form without an
idorsubmit.action. - Button and row
paramsmust be plain data (strings, numbers, booleans, lists and objects, up to 5 levels, 50 entries each, strings up to 500 characters, 2 KB in all), or they are dropped with their button. - A view is refused as a whole only when it is not a JSON object, is larger than 256 KB (128 KB for a snapshot), or has another
versionorkind. The viewer then sees “This app sent a screen GameVox could not show.”
The snapshot endpoint answers with warnings listing what was changed. Live replies do not. The Go bot library’s View.Validate() lists the same changes before you send.
Actions and interactions
Buttons
A button either navigates (it has a route) or sends an action to your app (it has an action):
{
"label": "Cancel raid",
"action": "cancel",
"params": { "raid": 42 },
"style": "danger",
"ability": "manage",
"confirm": { "title": "Cancel this raid?", "text": "Everyone who signed up is told.", "confirm_label": "Cancel raid" }
} label: required, up to 40 characters.action: a name matching^[A-Za-z0-9._:-]{1,64}$. Pressing it sends youkind: "action"with this name and the button’sparams.route: navigate instead. The client opens that route and you receivekind: "open"with the button’sparams. When both are set,routewins.params: an object up to 2 KB, given back to you exactly as you wrote it.style(primary,secondary,success,danger),icon,disabled.ability: hide the button from viewers who lack that ability.confirm:{title, text, confirm_label}(80, 300 and 40 characters) asks the member before the action is sent.
Table rows, list items, board cards and calendar events take an action target of the same two shapes: {"action": "…", "params": …} or {"route": "…", "params": …}. Clients show a busy state on the pressed control and never send the same press twice.
Forms
A form block has an id (unique in the view), up to 15 fields and a submit button, which must have an action. Submitting sends you kind: "action" with the submit button’s action and params, and the field values in values, keyed by field key.
{
"type": "form",
"id": "signup",
"fields": [
{ "key": "role", "type": "select", "label": "Role", "required": true,
"options": [
{ "value": "tank", "label": "Tank" },
{ "value": "healer", "label": "Healer" },
{ "value": "dps", "label": "Damage" }
] },
{ "key": "note", "type": "string", "label": "Note", "max_length": 100 }
],
"submit": { "label": "Sign up", "action": "signup", "params": { "raid": 42 }, "ability": "signup" }
} key: matches^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$, unique in the form.type:string,text,number,boolean,select,multiselect,channel,channels,role,roles,color,static,date,datetime,user(member picker) orfile(see App files).label(100),help(200),placeholder(100),required,disabled,min,max,step,max_length(up to 2,000) andvalueto prefill.optionsforselectandmultiselect: up to 25{value, label?, description?}. A missing label shows the value.channel_kindsnarrows a channel picker. Channel and role pickers list the server’s own channels and roles; GameVox supplies them, you send no ids.
Values you receive: strings (up to 2,000 characters), numbers, booleans, or lists of up to 25 strings; at most 30 values and 32 KB in all. Channel and role pickers give snowflakes, a member picker gives the member’s snowflake, and a file field gives a list of file objects. GameVox checks the shape only: required, min, max and option lists are enforced by the client, so check every value yourself.
Calendar paging: the previous and next buttons of a calendar block send the action calendar_nav with params {"start": "YYYY-MM-DD", "mode": "month|week|agenda", "id": "<block id>"}. Answer with a view whose calendar shows that range.
Keeping screens fresh
refresh_secondsin a view (15 to 3600) re-opens it on a timer while it is visible.POST /channels/{channel_id}/app-view/invalidatewith{"routes": [...]}tells everyone viewing the channel that their screen is stale. Leaveroutesout to refresh every viewer, or list up to 20 routes to refresh only viewers on them (raidsalso matchesraids?past=1). Clients re-open after a short random delay (1 to 3 seconds), so a burst costs you one request per viewer, not one per event. At most 5 calls per second per channel go through at once; the rest are folded into one more a second later. Answer:204.PUT /channels/{channel_id}/app-view/snapshotwith{"view": {…}}stores a public screen, the same for everyone who can see the channel, up to 128 KB. It is shown while your bot is offline, marked as the last update. Send{"view": null}to clear it (answer{"cleared": true}). The answer is{"warnings": [...]}. Up to 6 pushes per 10 seconds per channel. Never put anything personal in a snapshot.- Only the application a channel belongs to can invalidate it or set its snapshot.
POST /api/v10/channels/1491240000000000002/app-view/invalidate
{ "routes": ["raids", "raids/42"] }
HTTP/1.1 204 No Content
PUT /api/v10/channels/1491240000000000002/app-view/snapshot
{ "view": { "version": 1, "title": "Raids", "blocks": [ ... ] } }
HTTP/1.1 200 OK
{ "warnings": [] } Per-viewer context
Every request describes one viewer, so screens can be personal: “your characters”, “your sign-up”, buttons only managers see.
- Identity:
user.idis the member’s snowflake, the same id your bot sees in gateway events and REST. Keep per-member data by it. - Abilities: for each ability, the viewer holds it if any of their groups that can view the channel either has an override that grants it, or has no override and the manifest default covers it (
everyone: every such group;managers: manager groups only;nobody: none). The server owner holds every ability. A member with no group falls back to the server’s default group. - Managers (
is_managerand themanagersdefault) are the server owner, admin groups, and groups with the Edit Server or Manage Integrations permission. - Navigation is filtered for the viewer: nav items that need an ability they lack are hidden. That is a courtesy. Hiding a button is not security, and a modified client can send any action, route or params.
- Language:
localeis the viewer’s client language. A screen meant for everyone (a snapshot) cannot know it; use the server’s language there. - Members in what you send: write member snowflakes in
userfields, member table cells, member picker values and mentions. GameVox translates them for the clients and back;paramsare never touched.
App files, plans and app notifications
App files
An app can take uploads (for example a screenshot of a score) through a form field of type file, and show stored files in its screens. Files live in private GameVox storage, are scanned like chat attachments, and are only ever served through short-lived signed URLs.
- Declare storage in the manifest:
"storage": {"kinds": ["image"], "max_file_bytes"?, "max_total_bytes"?, "max_video_bytes"?}. Without it your app gets no uploads. The effective limits are your plan’s, lowered by what you declare. - Add a file field:
{"type": "file", "key", "label", "accept": ["image"], "max_files": 1 to 10, "required"?}. The client uploads straight to storage and waits for the scan before the form can be sent. - Read the values: the field’s value is a list of
{id, url, name, mime, size, width, height}.urlis signed for one hour; fetch the file inside your handler or callGETon it later for a fresh URL. - Keep what you use. An upload is
pendinguntil it is kept, and a pending file is deleted after 24 hours. Answering the action that carried it without anerrorkeeps it automatically.POST /applications/@me/app-files/{id}/keepkeeps one by hand. A kept file stays until you delete it, the plan’s retention runs out, a moderator blocks it, or your app has been removed from the server for 30 days. - Show files with
{"file_id": "<id>"}instead of a URL: an image blockfile_id, a list item or board cardimage, a table cell in a column of kindimage. GameVox swaps in a signed URL for the viewer. Putting a file in a view is what lets that viewer see it. Ids of another app or another server are dropped, and a view carries at most 100 file ids. - Snapshots only show files you marked public:
PATCH /applications/@me/app-files/{id}with{"public_in_snapshot": true}. - Kinds: images (PNG, JPEG, GIF, WebP) work today. Video (MP4 with H.264) works only where GameVox has granted your app a video allowance in that server; WebM is refused because it cannot be scanned.
documentis reserved: a manifest may name it, but no document type is accepted yet.
// manifest
"storage": { "kinds": ["image"], "max_file_bytes": 4194304 }
// a field in a form
{ "key": "shot", "type": "file", "label": "Screenshot", "accept": ["image"], "max_files": 2 }
// what the action brings you
"values": {
"shot": [ { "id": "8c1e2f4a-0b6d-4e7f-9a21-3c5d7e9f1a2b", "url": "https://...signed...",
"name": "score.png", "mime": "image/png", "size": 183224, "width": 1920, "height": 1080 } ]
}
// showing it later
{ "type": "image", "file_id": "8c1e2f4a-0b6d-4e7f-9a21-3c5d7e9f1a2b", "alt": "Score screen" } REST (all under /applications/@me/, bot token, guild, channel and uploader ids as snowflakes, file ids as UUID strings): GET app-files?guild_id=&state=&page= answers {files, page, has_more} (50 per page); GET, PATCH and DELETE app-files/{id}; POST app-files/{id}/keep; GET app-usage?guild_id= answers {plan, limits, used_bytes, files}. Another app’s file is 404; a file that is not uploaded yet, deleted or blocked is 409 for keep.
Plans
Every request carries plan: {key, limits}, the limits of your app in that server after your manifest’s storage cap. Use it to gate extras of your own (an app built on another company’s data must follow that company’s rules on paid features). Today every app runs on the free plan unless GameVox staff set another plan for a server; there is no way to buy a plan yet.
| Field | Notes |
|---|---|
max_file_bytes | Largest image: 8 MB. |
max_files_per_field | Files per file field: 4. |
max_total_bytes | Storage per app per server: 500 MB. Pending and kept files both count. |
max_uploads_per_user_per_day | Uploads per member per day: 50. |
kinds | Kinds allowed: ["image"], then narrowed by your manifest (empty if you declare no storage). |
retention_days | Kept files are deleted after this many days; 0 keeps them until you delete them. |
max_video_bytes | Largest video; 0 means no video. |
App notifications
Bots cannot send direct messages. An app can instead notify members (a ticket reply, a raid reminder) with POST /applications/@me/app-notifications. Members see it in the notification bell with your app’s icon, as a toast, and as a mobile push if they allow app notifications. Tapping it opens your app channel at route.
POST /api/v10/applications/@me/app-notifications
Authorization: Bot <token>
Content-Type: application/json
{
"guild_id": "1491230000000000001",
"user_ids": ["1491250000000000003", "1491250000000000009"],
"title": "Raid starts in 30 minutes",
"body": "Mythic, 19:00. You signed up as healer.",
"route": "raids/42",
"dedupe_key": "raid-42-reminder"
}
HTTP/1.1 200 OK
{
"delivered": ["1491250000000000003"],
"skipped": [ { "user_id": "1491250000000000009", "reason": "muted" } ]
} guild_id(required),user_ids(1 to 25 member snowflakes),title(required, up to 80 characters),body(up to 300, may be empty),route,channel_id(an app channel of your app in that server),dedupe_key(up to 64). Too much text is a400that names the limit; text is never cut.- Title and body are plain text. Control and bidirectional characters are removed.
- A tap opens
channel_idif you gave one, otherwise the first app channel of your app that the member can view. An app with no app channel in the server sends notifications that open nothing. - The same
dedupe_keyto the same member within 24 hours replaces the earlier notification (it shows as unread again) instead of adding one. - The answer lists
deliveredmember ids andskippedones with areason:not_member(also for an id GameVox does not know),muted(the member muted your app in that server),cannot_view,rate_limited, orerror(retry later). A skipped member uses up nothing. - Limits: 10 an hour and 30 a day per app per member, 500 an hour per app per server (a refused attempt still counts), and 20 calls per 10 seconds per bot.
403if your app is not installed in that server. - Notifications are kept 30 days, 200 per member at most.
Self-hosted servers
App channels work on self-hosted servers with the same events and endpoints. Your bot still connects only to the cloud. The differences:
self_hostedistrueinAPP_VIEW_REQUEST.- The customer’s box checks who may view the channel and computes the viewer’s abilities from its own groups, then asks the cloud to send you the request. GameVox trusts the box for that, as it does for every other permission on a self-hosted server.
- Snapshot, invalidate and channel create, edit and delete for a self-hosted channel are forwarded to the box like other self-hosted calls:
503when the box is not connected,504when it does not answer in time. - A changed manifest reaches a box within about a minute (it caches the manifest for 60 seconds) instead of right away.
- When the box cannot reach the cloud, members see your snapshot (files in it are left out) or an offline notice.
- App files work as on the cloud and are stored in GameVox storage, not on the box. Video needs an updated box.
- For app notifications the cloud asks the box who can view the app channel. An older or unreachable box is not asked: then only membership is checked, and a
channel_idyou give is passed on unchecked. - Manifest translations work on self-hosted servers too, in the language each member’s connection announces. A box that is not updated yet shows the default text.
Security notes for bot authors
- Enforce abilities on every action. Check
abilities(and your own rules) before you change anything. The client hiding a button is a courtesy. - Treat route, params, action and values as user input. They come from the member’s client. A modified client can send any route, any params and any action name, and skip the form’s required fields and limits. GameVox only checks their shape.
- Trust the ids GameVox gives you (
guild_id,channel_id,user.id,abilities), not ids inside params. To act on “raid 42”, check that raid 42 belongs to this channel. - Keep snapshots public. Everyone who can view the channel sees them. Never put personal data in one, and only mark files public that may be shown to everyone.
- Do not leak signed file URLs. They work for an hour for whoever holds them. Put
file_ids in views and let GameVox sign them per viewer. - Keep your nonces to yourself. Each one answers one viewer’s request; GameVox checks it belongs to your application.
- Prefer GameVox-hosted images. Third-party image URLs are marked external and may be hidden behind a click; they also tell the image host when the screen is shown.
- GameVox, for its part, never trusts your view: it is rebuilt from an allow-list, HTML is never rendered, mentions never notify, and
open_urlishttpsonly and always goes through the external-link prompt.
Errors and limits
REST answers
400: invalid body. Manifest and snapshot errors name the field. A reply without a nonce is a 400 too.403: the nonce or the channel belongs to another application, or your app is not installed in that server.404: unknown or expired nonce; unknown channel, or a channel that is not an app channel (snapshot, invalidate); unknown file; no manifest published (GET).409: the file is not in a state that allows this.413: body over the limit: manifest 64 KB (plus 192 KB of translations), view reply 256 KB, snapshot 128 KB, notification request 64 KB.429: over a rate limit, with the usual rate limit headers.
Rate limits
- Manifest: 10 publishes a minute per application (GET 30 a minute).
- View replies: 200 a second per bot.
- Snapshot: 6 pushes per 10 seconds per channel.
- Invalidate: 5 a second per channel go through at once; more are coalesced, not refused.
- App files: 30 calls per 10 seconds per route; usage 30 a minute.
- App notifications: 20 calls per 10 seconds per bot, plus the per-member and per-server limits above.
- Per viewer: 20 opens and 10 actions per 10 seconds. Per application, across all viewers: 600 requests per 10 seconds. Past these the viewer is told to slow down or that the app is busy; your bot is not asked.
What the viewer sees
- “The app didn’t answer. Try again.” No reply within 10 seconds.
- “The app is offline right now.” An action while your bot is offline.
- “This app sent a screen GameVox could not show.” The view was refused as a whole.
- “This screen needs a newer version of GameVox.” A view with another
versionorkind. - Your own
errortext, when you send one.
A minimal end-to-end example
A raid sign-up app with one screen and one form, using raw HTTP. Ids are shortened.
1. Publish the manifest when the bot starts:
PUT /api/v10/applications/@me/app-manifest
Authorization: Bot <token>
{ "name": "Raid sign-up", "icon": { "builtin": "calendar" }, "home": "home",
"nav": [ { "label": "Home", "route": "home" } ],
"abilities": [ { "key": "signup", "label": "Sign up", "default": "everyone" } ] }
HTTP/1.1 200 OK 2. Get a channel. An admin adds your app with Create Channel ▸ App, or your bot creates one:
POST /api/v10/guilds/1491230000000000001/channels
{ "type": 100, "name": "raid-signup" }
HTTP/1.1 201 Created
{ "id": "1491240000000000002", "type": 100, "gamevox_type": "app", ... } 3. A member opens it. Your bot receives:
{ "op": 0, "t": "APP_VIEW_REQUEST", "d": {
"nonce": "a1...", "kind": "open", "guild_id": "1491230000000000001",
"channel_id": "1491240000000000002", "route": "home", "params": {},
"user": { "id": "1491250000000000003", "username": "ben", ... },
"abilities": ["signup"], "is_manager": false, "locale": "en-US", "platform": "web", ... } } 4. Answer with the screen (within 10 seconds):
POST /api/v10/applications/@me/app-view-response
Authorization: Bot <token>
{ "nonce": "a1...",
"view": { "version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{ "type": "text", "markdown": "You are not signed up yet." },
{ "type": "form", "id": "signup",
"fields": [ { "key": "role", "type": "select", "label": "Role", "required": true,
"options": [ { "value": "tank" }, { "value": "healer" }, { "value": "dps" } ] } ],
"submit": { "label": "Sign up", "action": "signup", "ability": "signup" } }
] } }
HTTP/1.1 204 No Content 5. The member submits the form. You receive an action with the values:
{ "op": 0, "t": "APP_VIEW_REQUEST", "d": {
"nonce": "b2...", "kind": "action", "route": "home", "action": "signup",
"params": {}, "values": { "role": "healer" },
"channel_id": "1491240000000000002", "user": { "id": "1491250000000000003", ... },
"abilities": ["signup"], ... } } 6. Check, store, answer with a toast and the updated screen, then tell other viewers to refresh:
POST /api/v10/applications/@me/app-view-response
{ "nonce": "b2...", "toast": "Signed up as healer",
"view": { "version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{ "type": "text", "markdown": "You are signed up as **healer**." } ] } }
HTTP/1.1 204 No Content
POST /api/v10/channels/1491240000000000002/app-view/invalidate
{ "routes": ["home"] }
HTTP/1.1 204 No Content Optional: a snapshot for when the bot is offline. It is public, so it shows the count, not who signed up:
PUT /api/v10/channels/1491240000000000002/app-view/snapshot
{ "view": { "version": 1, "title": "Raid sign-up", "blocks": [
{ "type": "stats", "items": [ { "label": "Signed up", "value": 18 } ] } ] } }
HTTP/1.1 200 OK
{ "warnings": [] } In a Discord library, read the event from its raw gateway hook (on_socket_raw_receive in discord.py, the raw event in discord.js; see the table in In-app settings). A discord.py version of steps 3 to 6:
# discord.py: answer APP_VIEW_REQUEST from the raw gateway hook
# (create the client with enable_debug_events=True)
import json
API = "https://app-api.gamevox.com/api/v10"
ROLES = ("tank", "healer", "dps")
signups = {} # (channel_id, user_id) -> role
def screen(req, note):
return {"version": 1, "title": "Raid sign-up", "route": "home", "blocks": [
{"type": "text", "markdown": note},
{"type": "form", "id": "signup",
"fields": [{"key": "role", "type": "select", "label": "Role", "required": True,
"options": [{"value": r} for r in ROLES]}],
"submit": {"label": "Sign up", "action": "signup", "ability": "signup"}}]}
@client.event
async def on_socket_raw_receive(msg):
packet = json.loads(msg)
if packet.get("t") != "APP_VIEW_REQUEST":
return
req = packet["d"]
key = (req["channel_id"], req["user"]["id"])
reply = {"nonce": req["nonce"]}
if req["kind"] == "action" and req["action"] == "signup":
role = req["values"].get("role")
if "signup" not in req["abilities"]: # enforce, never trust the client
reply["error"] = "You can't sign up in this channel."
elif role not in ROLES: # values are user input
reply["error"] = "Pick a role."
else:
signups[key] = role
reply["toast"] = f"Signed up as {role}"
if "error" not in reply:
mine = signups.get(key)
reply["view"] = screen(req, f"You are signed up as **{mine}**." if mine else "You are not signed up yet.")
await session.post(f"{API}/applications/@me/app-view-response",
headers={"Authorization": f"Bot {TOKEN}"}, json=reply) GameVox’s own bots use the Go bot library in server/shared-gamebot, which publishes the manifest, answers requests under a deadline, validates views and batches invalidations for you.
Not available yet
- A
webkind (a sandboxed page instead of a layout) is reserved. Manifests and views with it are refused. - Uploads of documents, and buying a plan, are not available.
- Drag and drop on boards.