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 all of them. |
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 integrations also send
client_secret (or use HTTP Basic auth). CDN and embed
integrations 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 only. An app token with guild.read and/or
guild.announce.
|
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "vtxr_...",
"scope": "identify chat.read chat.write",
"user": { "id": "...", "username": "cherry", ... }
}
refresh_token is left out for app tokens.
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 integration, 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 integrations 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 integrations. 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 }.
/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 the dashboard.
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", "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", "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" } } }.
Messages use them as :name:.
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.
Commands
Send a command as the message content, like
/timeout @cherry 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) |
/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.
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 integration. 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 integration right away.
Channels
Same tokens as moderation, with the manage channels permission. Your guild can have up to 50 channels, and the shared Vortex channel can't be changed here.
/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.
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": "cherry",
"avatar": "https://.../img/uploads/...",
"avatarFrame": "",
"rank": "VIP",
"color": "#ff4f8b",
"premium": 0,
"level": 12
}
Guild
{
"id": "...",
"ownerId": "...",
"name": "Cherri",
"tag": "CHR",
"description": "",
"icon": "https://...",
"logo": "https://...",
"themeColor": "#bb00ff",
"themeColorSecondary": ""
}
Channel
{
"id": "...",
"type": "text", // or "global" for the shared Vortex channel
"name": "general",
"description": "",
"icon": "",
"category": "",
"position": 0,
"locked": false,
"slowmodeSeconds": 0,
"readOnly": false,
"editable": true, // false for the shared Vortex channel
"canSend": true
}
RawMessage
{
"_id": "...",
"channelId": "...",
"content": "Hello **world** :wave:",
"authorId": { "_id": "...", "username": "cherry", "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. 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 integration.
- App announcements: 10 per minute.
- Site checks: 30 new checks per minute per integration.