Install
Set your integration type to CDN script and allow your site under Where it works. Then load the script:
<script src="%ORIGIN%/sdk/v1/vortex.js"></script>
It adds a global Vortex object. Create one client per page:
const vortex = Vortex.init({ clientId: "vtx_your_client_id" });
Want to start from working code? Jump to the full example at the bottom of this page.
Vortex.init(options)
| Option | Default | Description |
|---|---|---|
clientId | Required | Your integration's client ID. |
channel | First channel | Channel ID to start in. |
redirectUri | Current page | Where people return after signing in. Must be on an allowed site. Sign-in parameters are removed from the URL for you. |
scope | "identify chat.read chat.write" | Space-separated scopes. Drop chat.write for a read-only chat. |
storage | "local" | "local" keeps people signed in across visits, "session" until the tab closes, "memory" until the page reloads. |
origin | Where the script loaded from | Vortex address to talk to. Only change this if you proxy Vortex. |
Signing in
Redirect (default)
vortex.login();
Sends the visitor to Vortex and back to redirectUri. When the page loads again, the script finishes signing in on its own before vortex.ready resolves.
Popup
await vortex.login({ popup: true });
console.log("Signed in as", vortex.user.username);
Opens a centered window and resolves with the user once they approve. The popup returns to redirectUri, so that page must load the script too (the same page is easiest). Call login from a click handler or browsers will block the popup.
Logging out
vortex.logout();
Revokes the session on Vortex and clears it locally.
Properties
| Property | Type | Description |
|---|---|---|
ready | Promise | Resolves once the script has checked the site and restored any saved session. |
user | User | null | The signed-in person, or null. See User. |
guild | Guild | null | Your guild's name, icon, logo, colors and tag. |
channels | Channel[] | Channels this person can see, in order. Each has canSend. |
channel | string | The current channel ID. Change it with join(). |
emotes | object | Emotes by name, used by render(). |
permissions | object | Moderator actions this person can take: { manageMessages, timeout, ban, manageRoles }. Use it to show moderation buttons. |
site | { allowed, reason } | Whether this page is allowed to use your integration. |
socket | Socket | null | The underlying Socket.IO connection, once signed in. |
onmessage | function | null | Shortcut for on("message"). |
Methods
Methods that take opts.channel use the current channel when it's left out. All network methods return promises and reject with an Error that has a status when the server refused.
| Method | Returns | Description |
|---|---|---|
login({ popup }) | Promise<User> | Start signing in. See Signing in. |
logout() | void | Sign out and revoke the session. |
history({ channel, limit, before }) | Promise<{ messages, hasMore }> | Load up to limit (1 to 100, default 50) messages, oldest first. Pass a message ID as before to page back. |
send(text, { channel, replyTo }) | Promise<Message> | Send a message, optionally as a reply to a message ID. |
edit(id, text, { channel }) | Promise<Message> | Edit one of the person's own messages. |
delete(id, { channel }) | Promise | Delete one of the person's own messages. |
timeout(userId, minutes, reason) / removeTimeout(userId) | Promise | Time someone out for up to 28 days, or end it early. Needs the timeout permission. |
ban(userId, { reason, minutes }) / unban(userId) | Promise | Ban someone (permanently if minutes is left out), or lift a ban. Needs the ban permission. |
addRole(userId, roleId) / removeRole(userId, roleId) | Promise | Give or remove a guild role. Needs the manage roles permission. |
members({ q, limit, after }) / bans({ limit, after }) | Promise<{ members | bans, nextCursor }> | Search members or list active bans, a page at a time. Pass nextCursor as after for the next page. |
reportMessage(id, reason, note, { channel }) | Promise | Report someone else's message to Vortex staff. reason is one of spam, harassment, hate_speech, nsfw, self_harm, other. |
report() | Window | Open the Vortex window for reporting your guild. Good for a small "Report" link in your footer. |
join(channelId) | Promise<string> | Switch the current channel and its live updates. |
markRead({ channel }) | Promise | Mark the channel as read for this person. |
typing({ channel }) | void | Show others that this person is typing. Call it as they type; it stops on its own. |
render(text) | string | Turn message text into safe HTML: bold, italics, underline, strikethrough, code, mentions, emotes and links. |
asset(path) | string | Turn a Vortex image path into a full URL. |
checkSite() | Promise<{ allowed, reason }> | Check again whether this page is allowed. Runs on its own at start. |
request(path, { method, body }) | Promise<any> | Call any REST endpoint under /api/partner/v1 with the person's token. |
on(event, fn) / off(event, fn) | this | Add or remove an event listener. |
Events
vortex.on("message", (msg) => { /* ... */ });
vortex.on("typing", ({ username, typing }) => { /* ... */ });
| Event | Payload | When |
|---|---|---|
ready | client | Startup finished, signed in or not. |
login | User | Someone signed in or a session was restored. |
logout | None | The session ended. |
message | Message | A new message in the current channel. |
edit | Message | A message was edited. |
delete | { id, channelId } | A message was deleted. |
typing | { channelId, userId, username, typing } | Someone started (true) or stopped (false) typing. |
join / leave | { channelId, user, text } | Someone joined or left the channel. |
connect / disconnect | reason | The live connection changed. It reconnects on its own. |
error | Error | Something failed in the background, like this site not being allowed. |
Message object
{
id: "6650f1...",
channelId: "6650e2...",
content: "Hello **world**", // raw text
html: "Hello <strong>world</strong>", // safe HTML
author: { id, username, avatar, rank, color, premium },
color: null, // custom text color, if any
system: false, // true for join notices and announcements
kind: "", // e.g. "partnerAnnounce"
source: "vortex", // or "discord" for bridged messages
createdAt: Date,
editedAt: Date | null,
replyTo: { id, content, author } | null,
attachments: ["https://..."], // image URLs
gif: { url, width, height } | null,
mentions: { users: [ids], everyone: false, here: false },
mentionsMe: false,
raw: { /* original server object */ }
}
Styling rendered messages
render() and msg.html use these classes so you can style them:
.vortex-mention { color: #bb00ff; font-weight: 600; }
.vortex-emote { height: 1.4em; vertical-align: middle; }
pre code { display: block; padding: 8px; background: #111; }
Name colors in author.color are usually a hex color, but can also be a Vortex color effect such as a gradient. For a plain color, use the first #hex value in the string.
Troubleshooting
Testing on your own computer
Pages opened straight from a file (file:///...) can't sign in, because Vortex needs a web address to send people back to. Serve the page instead, then add that address under Domains:
npx serve .
This serves the current folder at http://localhost:3000. Add http://localhost:3000 to your Domains list (plain http is allowed for localhost only).
"This client ID belongs to an Embed integration"
Each guild has one integration, and its type decides how it can be used. Open your guild dashboard, go to Website and change the integration type to CDN script. Your client ID stays the same.
vortex.site.allowed is false
Read vortex.site.reason, then fix it under Where it works. The browser console also prints a warning.
"Origin not allowed" when signing in
The page that receives the sign-in (your redirectUri) must be on an allowed site.
The popup opens but nothing happens after approving
The page in the popup needs to load vortex.js with the same client ID so it can hand the sign-in back to the opener.
Sending fails with 403
The channel is locked, the person lacks permission there, or they're banned from your guild. Check channel.canSend before showing a composer.
Full example
A small, styled chat box. Copy it into a page on an allowed site, or download it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chat</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d0d10; font-family: system-ui, sans-serif; color: #eee; }
#chat { width: min(420px, 100vw - 32px); height: 560px; display: flex; flex-direction: column; background: #16161b; border: 1px solid #26262e; border-radius: 14px; overflow: hidden; }
header { padding: 14px 16px; border-bottom: 1px solid #26262e; font-weight: 600; }
#status { margin: 0; padding: 10px 16px; color: #8a8a96; font-size: 14px; }
#status:empty { display: none; }
#messages { flex: 1; margin: 0; padding: 8px 16px; overflow-y: auto; list-style: none; }
#messages li { display: flex; gap: 10px; padding: 6px 0; line-height: 1.4; }
#messages img { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; }
#messages b { display: block; font-size: 14px; }
#messages span { overflow-wrap: anywhere; }
#composer { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #26262e; }
#composer input { flex: 1; padding: 10px 12px; background: #0d0d10; border: 1px solid #26262e; border-radius: 8px; color: inherit; font: inherit; }
button { padding: 10px 16px; border: 0; border-radius: 8px; background: #bb00ff; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
#login { margin: auto; }
.vortex-mention { color: #d27bff; font-weight: 600; }
.vortex-emote { height: 1.4em; vertical-align: middle; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div id="chat">
<header id="title">Chat</header>
<p id="status">Loading...</p>
<button id="login" hidden>Log in with Vortex</button>
<ul id="messages"></ul>
<form id="composer" hidden>
<input id="text" maxlength="2000" placeholder="Say something" autocomplete="off" />
<button>Send</button>
</form>
</div>
<script src="%ORIGIN%/sdk/v1/vortex.js"></script>
<script>
const vortex = Vortex.init({ clientId: "vtx_your_client_id" });
const list = document.getElementById("messages");
const status = (text) => (document.getElementById("status").textContent = text || "");
function show(msg) {
const li = document.createElement("li");
li.dataset.id = msg.id;
const avatar = Object.assign(document.createElement("img"), { src: msg.author.avatar, alt: "" });
const body = document.createElement("div");
const name = Object.assign(document.createElement("b"), { textContent: msg.author.username });
const text = Object.assign(document.createElement("span"), { innerHTML: msg.html });
body.append(name, text);
li.append(avatar, body);
list.appendChild(li);
list.scrollTop = list.scrollHeight;
}
vortex.ready.then(async () => {
if (!vortex.site.allowed) return status(vortex.site.reason);
if (!vortex.user) {
status("");
const btn = document.getElementById("login");
btn.hidden = false;
btn.onclick = () =>
vortex.login({ popup: true }).then(
() => location.reload(),
(err) => status(err.message),
);
return;
}
const channel = vortex.channels.find((c) => c.id === vortex.channel);
document.getElementById("title").textContent = `${vortex.guild.name}${channel ? ` ยท #${channel.name}` : ""}`;
try {
const { messages } = await vortex.history();
messages.forEach(show);
status(messages.length ? "" : "No messages yet. Say hi!");
} catch (err) {
return status(err.message);
}
vortex.onmessage = (msg) => {
status("");
show(msg);
};
vortex.on("delete", ({ id }) => list.querySelector(`[data-id="${id}"]`)?.remove());
const form = document.getElementById("composer");
form.hidden = !channel?.canSend;
form.onsubmit = async (e) => {
e.preventDefault();
const input = document.getElementById("text");
if (!input.value.trim()) return;
try {
await vortex.send(input.value);
input.value = "";
} catch (err) {
status(err.message);
}
};
});
</script>
</body>
</html>
msg.html is already escaped and safe to insert. Never insert msg.content as HTML; use textContent for raw text. Vortex strips common script tricks out of content as a backup, but that's a safety net, not a guarantee.