Basics
| Sign-in page | %ORIGIN%/oauth/authorize |
| OAuth API | %ORIGIN%/api/partner/oauth |
| REST API | %ORIGIN%/api/partner/v1 |
| Realtime | Socket.IO namespace %ORIGIN%/partner |
- Send JSON with
Content-Type: application/json. -
Authenticate with
Authorization: Bearer <access_token>. -
From a browser, add
?client_id=vtx_your_client_idto every request so Vortex can check that your site is allowed. Cookies are never used. -
REST errors look like
{ "message": "..." }. OAuth errors look like{ "error": "...", "error_description": "..." }. - IDs are 24-character hex strings.
OAuth
Sign-in page
/oauth/authorize
Send the person here in their browser. They sign in to Vortex if needed and approve your guild.
| Parameter | Required | Description |
|---|---|---|
client_id |
Yes | Your client ID. |
response_type |
Yes | Always code. |
redirect_uri |
Yes | An HTTPS URL on an allowed site. |
scope |
No |
Space-separated user scopes. Defaults
to identify, plus chat.read and
chat.write for apps with a guild. Chat scopes are
ignored for apps without one.
|
state |
Recommended | A random value you check on return. Up to 500 characters. |
code_challenge |
CDN: yes SDK: recommended |
Base64url SHA-256 of your code verifier (PKCE). |
code_challenge_method |
With challenge | Always S256. |
Vortex redirects back to redirect_uri with
?code=...&state=..., or
?error=access_denied&state=... if they cancel. Codes
last 2 minutes and work once. Embeds use their own built-in flow and
can't use this page directly.
Get tokens
/api/partner/oauth/token
Send client_id in the body. SDK apps also send
client_secret (or use HTTP Basic auth). CDN and embed
apps must not send a secret.
grant_type |
Other fields | Use |
|---|---|---|
authorization_code |
code, redirect_uri,
code_verifier
|
Finish a sign-in. redirect_uri must match exactly.
|
refresh_token |
refresh_token |
Get a new access token. Returns a new refresh token; the old one stops working. |
client_credentials |
scope (optional) |
SDK apps with a guild only. An app token with any of
guild.read, guild.announce and
guild.moderate. Defaults to all three.
|
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "vtxr_...",
"scope": "identify chat.read chat.write",
"user": { "id": "...", "username": "alex", ... }
}
refresh_token is left out for app tokens. Guild and chat
endpoints return 404 This app has no guild for apps
without one.
user is only included when finishing a sign-in with
identify.
Reusing a refresh token or a code that was already used signs out that whole session, in case it was stolen.
| Error | Meaning |
|---|---|
invalid_client |
Unknown or disabled client, wrong secret, a secret sent by a browser app, or a disallowed site. |
invalid_grant |
The code or refresh token is wrong, expired or used, the
redirect_uri or code_verifier doesn't
match, or the account can't sign in.
|
unauthorized_client |
Only SDK apps can use client_credentials.
|
invalid_scope |
An unknown app scope was requested. |
unsupported_grant_type |
The grant_type isn't one of the three above. |
Sign out
/api/partner/oauth/revoke
Body: client_id, token (access or refresh),
plus client_secret for SDK apps. Ends the whole
session. Always returns { "ok": true }.
Who is this token for?
/api/partner/oauth/userinfoidentify
Returns { "user": User }. Same as
GET /api/partner/v1/me.
REST API
All paths below are relative to %ORIGIN%/api/partner/v1.
The pill after each path is the scope it needs.
Account and guild
/meidentify
The signed-in person: { "user": User }. The user includes
banner and ownedEmotePacks.
/guildchat.read or guild.read
{ "guild": Guild, "channels": Channel[], "app": { "name", "type" }
}. With a user token, only channels they can see are listed,
canSend reflects their permissions, and
permissions says which moderator actions they can take:
{ "manageMessages", "timeout", "ban", "manageRoles", "manageChannels"
}. standing says whether they can talk right now:
{ "muted", "mutedUntil", "timedOut", "timeoutUntil" },
where muted is a Vortex-wide mute and
timedOut is a timeout in your guild (a
null date means it doesn't expire). rules is
{ "items": [{ "id", "title", "description", "locked"?, "links"? }],
"accepted": boolean }: the first rule always points to the Vortex Community Guidelines and
Terms of Service, then your own rules from your app's Rules section.
accepted is only there for user tokens. Returns 403 if
they're banned.
/guild/rules/acceptchat.read
Record that the person agreed to the rules. Show the rules and ask before they chat the first time, like the embed does.
/guild/roleschat.read or guild.read
Your guild's roles, highest first:
{ "roles": [{ "id", "name", "color", "icon", "position" }] }.
/guild/members/:userIdchat.read or guild.read
A member's guild profile:
{ "user", "member", "owner", "joinedAt", "roles": [{ "id", "name",
"color", "icon" }] }. vortexStaff is true for Vortex staff, who can moderate
every guild, and vortexMuted is
{ "until" } while they're muted across Vortex (otherwise
null). Moderators also get timeoutUntil and
banned.
/guild/users?ids=ID,IDchat.read or guild.read
Public profiles for up to 100 user IDs, handy for showing names in
<@userId> mentions:
{ "users": { "<userId>": { "id", "username", "avatar",
"avatarFrame", "banner", "rank", "color" } } }.
/guild/online?channelId=IDchat.read or guild.read
Who's in a channel right now, on your site or on Vortex, grouped by
their top guild role:
{ "count": 3, "groups": [{ "role": { "id", "name", "color", "icon" }
| null, "members": [{ "id", "username", "avatar", "avatarFrame",
"banner", "rank", "color", "status" }] }] }. Invisible people aren't listed. Up to 100 members are returned.
Refetch when you get channelPresenceChanged.
/guild/mentions?q=namechat.read or guild.read
Up to 8 guild members whose username starts with q, for
mention autocomplete:
{ "users": [{ "id", "username", "avatar", "rank", "color" }] }. Send the pick as <@userId>.
/guild/commandschat.read or guild.read
The slash commands this person can use:
{ "commands": [{ "name", "usage", "description", "moderation" }]
}. See Commands.
/guild/member-roles?ids=ID,IDchat.read or guild.read
Each member's highest guild role, for up to 100 user IDs:
{ "roles": { "<userId>": { "name", "color", "icon" } | null }
}.
/emotesany token
Emotes by name:
{ "emotes": { "<name>": { "name", "src", "pack", "inPack": true
} } }. Messages use them as :name:. If the sender does not own
a custom emote pack, Vortex replaces that token with
:unknown-emote:.
Messages (as a person)
/chat/channelschat.read
Channels with unread state: { "guildChannels": [...] }.
/chat/channels/:channelId/messages?limit=50&before=IDchat.read
Up to limit (1 to 100) messages before before,
oldest first:
{ "messages": RawMessage[], "hasMore": boolean }.
/chat/channels/:channelId/messages/:messageIdchat.read
One message.
/chat/channels/:channelId/messageschat.write
Body: { "content": "Hi!", "replyToId": "ID" }.
content is up to 2000 characters. Returns
{ "message": RawMessage }. Content starting with
/ runs a command instead.
Add "attachments": ["/img/uploads/..."] (up to the
person's limit) to send images uploaded with the endpoint below. To send
a GIF, set content to the GIF's send link from
GIF search.
Commands
Send a command as the message content, like
/timeout @alex 10m spam. Commands reply with
{ "command": true, "ok": true, "message": "...", "announced": boolean
}
(or a 4xx with ok: false) and nothing is posted as the
person.
| Command | Needs |
|---|---|
/help |
Anyone. The reply also has commands. |
/timeout @user <duration> [reason],
/untimeout @user
|
Timeout members |
/ban @user [duration|permanent] [reason],
/unban @user
|
Ban members |
/purge <count> [reason] |
Manage messages (up to 100). In a shared channel it only removes messages sent from your guild. |
/role add|remove @user <role name> |
Manage roles |
Durations look like 10m, 1h,
1d or 2w. Moderation commands post a notice in
the channel (a system message with commandKind set to
timeout, untimeout, ban,
unban or purge) unless you add
--private, and only in your guild's own channels.
/chat/channels/:channelId/messages/:messageIdchat.write
Body: { "content": "..." }. Only the author can edit.
/chat/channels/:channelId/messages/:messageIdchat.write
Delete one of the person's messages.
/chat/channels/:channelId/messages/:messageId/reportchat.write
Body:
{ "reason": "spam", "note": "optional, up to 500 characters" }. reason is spam, harassment,
hate_speech, nsfw, self_harm or
other. Returns 201, or 409 if they already reported it.
People can't report their own messages.
/chat/channels/:channelId/readchat.read
Mark the channel as read for the person.
GIFs and images
/chat/gifs?q=cats&pos=chat.write
Search GIFs. Leave q empty for trending. Returns
{ "results": [{ "send", "url", "preview", "width", "height", "title"
}], "next": "" }. Pass next back as pos for more. Show
preview in your picker and post send as the
message content.
/chat/attachments/usagechat.write
{ "eligible": false }, or eligible: true with
the person's upload usage and limits. Image
uploads need Eclipse or Nexus, so hide your upload button when this is
false.
/chat/channels/:channelId/attachmentschat.write
Multipart form with one image in the file field (PNG, JPEG,
WebP or GIF). Images are scanned before they're stored. Returns
{ "url": "/img/uploads/..." }, which you then send in
attachments.
Moderation
Works with an SDK app token that has guild.moderate, or a
person's token (chat.write) when their guild roles give
them the matching permission. With a person's token, they can only act
on members ranked below them and only manage roles below their own.
Nobody can moderate the guild owner. Every action shows up in your
guild's audit log. See the
Partner Policy.
:userId can be a user ID or a username.
/guild/members?q=name&limit=25&after=CURSORany moderator permission
Search members, newest first:
{ "members": [{ "user", "roleIds", "joinedAt", "owner",
"timeoutUntil", "banned" }], "nextCursor": "..." | null }. limit is 1 to 100 (default 25). Pass
nextCursor as after to get the next page. For
numbered pages, send page instead: you get
total, page and pages, and can
add sort (joined-desc,
joined-asc, name-asc,
name-desc or role) and
filter (timedout, noroles or a
role ID).
/guild/bans?limit=25&after=CURSORban
Active bans, newest first:
{ "bans": [{ "user", "reason", "bannedAt", "expiresAt", "by" }],
"nextCursor": "..." | null, "total": 12 }. Pages work the same way as members, including page
for numbered pages.
/guild/bans/:userIdban
Body: { "reason": "...", "minutes": 0 }.
minutes 0 or left out means permanent, otherwise up to a
year. Banned people lose access to your guild everywhere and can't sign
in to your app. Add "channelId" to post a notice in
one of your channels, like the /ban command does. The
DELETE endpoints for bans and timeouts take
?channelId=ID for the same thing.
/guild/bans/:userIdban
Unban.
/guild/timeouts/:userIdtimeout
Body: { "minutes": 60, "reason": "..." }. Up to 40320
minutes (28 days). They can still read but can't send messages in your
guild. Takes "channelId" like bans.
/guild/timeouts/:userIdtimeout
End a timeout early.
/guild/members/:userId/roles/:roleIdmanage roles
/guild/members/:userId/roles/:roleIdmanage roles
Give or remove a guild role. The person must already be a member.
Returns { "ok": true, "user", "roleIds" }.
/guild/audit?limit=25&after=CURSOR&category=moderationany moderator permission
Your guild's audit log, newest first:
{ "entries": [{ "id", "action", "category", "message", "at", "by": {
"id", "username", "avatar" } | null, "via" }], "nextCursor": "..." |
null }. category is moderation or
admin. via is "integration" for
actions taken through your app, "staff" for Vortex staff,
otherwise empty.
Moderation endpoints return
{ "ok": true, "user": { "id", "username" } }, or
403 when the token or person isn't allowed. Banning someone
also signs them out of your app right away.
Channels
Same tokens as moderation, with the manage channels permission. Your
guild can have up to 50 channels. On a shared channel you can only send
discordChannelId, which links it to a channel in your own
Discord server without changing anyone else's link.
/guild/channelsmanage channels
Body:
{ "name": "general", "description": "", "category": "",
"slowmodeSeconds": 0, "readOnly": false, "discordChannelId": ""
}. Only name is required. readOnly stops
everyone without a role that allows sending. Returns 201 with
{ "channel" }.
/guild/channels/:channelIdmanage channels
Send only the fields you want to change. Returns
{ "channel" }.
/guild/channels/:channelIdmanage channels
Deletes the channel and all of its messages.
/guild/discord-channelsmanage channels
Text channels in your linked Discord server:
{ "connected": boolean, "channels": [{ "id", "name", "category",
"usable", "linkedTo": { "id", "name" } | null }] }. Pass an id as discordChannelId to mirror a
channel, or "" to unlink it. usable is false
when the bot can't manage webhooks there. If the webhook is deleted on
Discord, Vortex re-creates it on the next message.
App endpoints (SDK only)
/guild/channels/:channelId/messages?limit=50&before=IDguild.read
Read any of your guild's channels as your app. Same response as above.
/guild/channels/:channelId/announceguild.announce
Body: { "content": "..." }. Posts a highlighted
announcement with your app's name. 10 per minute. Returns 201 with
{ "message": { "_id", "content", "createdAt" } }.
Public endpoints
/site-check?client_id=...&page=URLnone
Call from a page to see if it's allowed:
{ "allowed": boolean, "reason": string, "type": "embed" | "cdn" |
"sdk" }. See Site access.
/embed-optionsnone
Every embed style option with its type, limits and default.
Shared channels
Vortex can invite your guild to a shared channel. Once you accept it in
Partner Tools, it shows up in your channel list with
"type": "global" and its messages come from every guild in
it. Each message has a guildId for the guild it was sent
from.
Your guild needs a tag before it can join shared channels. If you remove the tag, shared channels are paused for your guild: they drop out of your channel list, reads and sends return 403, and your Discord link stops relaying until you set a tag again.
If your app shows a shared channel, the Partner Policy requires you to show the sender's guild tag next to their name: the tag text and its icon when the guild has them. Everything else below is up to you.
Guild tags
/guild-tags?ids=ID,IDany token
Pass the guildIds from the messages you're showing.
Returns
{ "guilds": { "ID": { "id", "name", "slug", "tag", "icon",
"themeColor" } } }. tag is 3 or 4 characters and icon is a
small square image; either can be empty. Batch your lookups and cache
the result for a minute or so.
Guild cards
/guild-card/:guildIdany token
Everything you need for a popup when someone clicks a tag:
{
"guild": {
"id": "...", "name": "Example Guild", "slug": "example", "tag": "EXMP",
"icon": "...", // use inside the tag
"logo": "...", // use on the card, fall back to icon
"themeColor": "#bb00ff",
"themeColorSecondary": "", // banner gradient end, empty means fade to dark
"description": "...",
"official": false, // true for the Vortex guild
"members": 1200,
"online": 34,
"sharedChannels": [{ "id", "name", "icon" }]
}
}
The official embed draws a banner with a gradient from
themeColor to themeColorSecondary, the logo
over it, then the name, tag, counts and description.
What works in shared channels
- Text, replies, mentions, emotes, GIFs and images all work the same as in your own channels.
-
Commands work, but only on your own members.
/purgeonly removes messages sent from your guild, and moderation notices aren't posted in shared channels. -
Chat games, giveaways and duels are turned off. Starting one there
returns
403. - Vortex moderates the whole channel. You can moderate your own members like anywhere else.
- Each guild can link the channel to its own Discord channel. Messages from any linked Discord channel reach every guild and every other linked Discord channel, with the sender's guild tag after their name.
Realtime
Connect with
Socket.IO v4
to the /partner namespace, then join the channels you want.
import { io } from "socket.io-client";
const socket = io("%ORIGIN%/partner", {
auth: { token: accessToken },
query: { client_id: "vtx_your_client_id" },
transports: ["websocket"],
});
socket.on("connect", () => socket.emit("joinChannelRoom", { channelId }));
socket.on("chatMessage", ({ channelId, message }) => render(message));
socket.on("connect_error", () => { /* refresh the token, update socket.auth, reconnect */ });
You send
| Event | Payload | Description |
|---|---|---|
joinChannelRoom |
{ channelId } |
Start receiving a channel's events. |
leaveChannelRoom |
{ channelId } |
Stop receiving them. |
chatTyping |
{ channelId } |
Show the person as typing (user tokens only). |
You receive
| Event | Payload |
|---|---|
chatMessage |
{ channelId, message: RawMessage } |
chatMessageEdited |
{ channelId, message: RawMessage } |
chatMessageDeleted |
{ channelId, messageId } |
chatTyping |
{ channelId, userId, username } |
chatTypingStop |
{ channelId, userId } |
chatMessagesPurged |
{ channelId, messageIds }: several messages were
deleted at once
|
channelPresenceChanged |
{ channelId }: someone opened or left the channel,
so refetch /guild/online
|
channelSystemMessage |
{ channelId, user, verb }: someone joined or left
|
channelAccessDenied |
{ channelId }: you can't join that channel |
guildChannelsUpdated |
{ channelId?, deleted? }: a channel was added,
changed or removed, so reload /guild
|
guildRolesUpdated |
{}: roles changed, so refresh role chips |
guildBanned |
{ reason, expiresAt }: the signed-in person was
banned from your guild. Their tokens are already revoked and the
socket closes right after, so sign them out
|
standingUpdate |
{ muted, mutedUntil } or
{ timedOut, timeoutUntil }: the signed-in person
was muted, timed out, or let back in (user tokens only)
|
The partner namespace only carries your guild's channel events. It never receives Vortex-wide presence or DMs.
Objects
User
{
"id": "...",
"username": "alex",
"avatar": "https://.../img/uploads/...",
"avatarFrame": "",
"banner": "",
"ownedEmotePacks": ["packid"],
"rank": "VIP",
"color": "#ff4f8b",
"premium": 0,
"level": 12
}
Guild
{
"id": "...",
"ownerId": "...",
"name": "Example Guild",
"tag": "EXMP",
"description": "",
"icon": "https://...",
"logo": "https://...",
"themeColor": "#bb00ff",
"themeColorSecondary": ""
}
Channel
{
"id": "...",
"type": "text", // or "global" for a shared channel
"name": "general",
"description": "",
"icon": "",
"category": "",
"position": 0,
"locked": false,
"slowmodeSeconds": 0,
"readOnly": false,
"editable": true, // false for shared channels
"hostGuildId": null, // shared channels only: the host guild, null means Vortex
"canSend": true
}
RawMessage
{
"_id": "...",
"channelId": "...",
"guildId": "...", // the guild it was sent from, for guild tags
"content": "Hello **world** :wave:",
"authorId": { "_id": "...", "username": "alex", "avatar": "...", "rank": "VIP", "color": "#ff4f8b", "premium": 0 },
"createdAt": "2026-01-01T12:00:00.000Z",
"editedAt": null,
"replyToId": { "_id": "...", "content": "...", "authorId": { "username": "..." } } | null,
"attachments": ["/img/uploads/..."],
"gif": { "url": "...", "width": 320, "height": 240 } | null,
"mentionedUserIds": ["..."],
"mentionsEveryone": false,
"mentionsHere": false,
"system": false,
"commandKind": "", // for system messages: "timeout", "ban", "purge", "partnerAnnounce", ...
"color": null,
"source": "vortex"
}
Message formatting
| Syntax | Result |
|---|---|
**bold** *italic*
__underline__ ~~strike~~
|
Text styles |
`code` and ```code blocks``` |
Code |
<@userId>, <@everyone>,
<@here>
|
Mentions |
<#channelId> |
Channel link |
:name: |
Emote, from /emotes |
Content is plain text. Always escape it before showing it as HTML. Never
send a message as another user or make text appear to have been written
by someone who did not write it. As a backup, Vortex removes common
script tricks from
content before sending it to partner apps (things like
<script> tags, onerror= handlers and
javascript: links), so HTML-like text in messages may look
slightly different than it does on Vortex.
Rate limits
-
150 API requests per minute per person or app. Over the limit you get
429. - Channel slowmode applies to messages sent through your app.
- App announcements: 10 per minute.
- Site checks: 30 new checks per minute per app.