CDN Script Docs

Developer docs

CDN script

Build your own chat interface in the browser. The script handles signing in, tokens, live updates and safe message formatting. You handle the design.

Browser only No client secret No dependencies

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)

OptionDefaultDescription
clientIdRequiredYour integration's client ID.
channelFirst channelChannel ID to start in.
redirectUriCurrent pageWhere 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.
originWhere the script loaded fromVortex 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.

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

PropertyTypeDescription
readyPromiseResolves once the script has checked the site and restored any saved session.
userUser | nullThe signed-in person, or null. See User.
guildGuild | nullYour guild's name, icon, logo, colors and tag.
channelsChannel[]Channels this person can see, in order. Each has canSend.
channelstringThe current channel ID. Change it with join().
emotesobjectEmotes by name, used by render().
permissionsobjectModerator 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.
socketSocket | nullThe underlying Socket.IO connection, once signed in.
onmessagefunction | nullShortcut 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.

MethodReturnsDescription
login({ popup })Promise<User>Start signing in. See Signing in.
logout()voidSign 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 })PromiseDelete one of the person's own messages.
timeout(userId, minutes, reason) / removeTimeout(userId)PromiseTime someone out for up to 28 days, or end it early. Needs the timeout permission.
ban(userId, { reason, minutes }) / unban(userId)PromiseBan someone (permanently if minutes is left out), or lift a ban. Needs the ban permission.
addRole(userId, roleId) / removeRole(userId, roleId)PromiseGive 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 })PromiseReport someone else's message to Vortex staff. reason is one of spam, harassment, hate_speech, nsfw, self_harm, other.
report()WindowOpen 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 })PromiseMark the channel as read for this person.
typing({ channel })voidShow others that this person is typing. Call it as they type; it stops on its own.
render(text)stringTurn message text into safe HTML: bold, italics, underline, strikethrough, code, mentions, emotes and links.
asset(path)stringTurn 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)thisAdd or remove an event listener.

Events

vortex.on("message", (msg) => { /* ... */ });
vortex.on("typing", ({ username, typing }) => { /* ... */ });
EventPayloadWhen
readyclientStartup finished, signed in or not.
loginUserSomeone signed in or a session was restored.
logoutNoneThe session ended.
messageMessageA new message in the current channel.
editMessageA 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 / disconnectreasonThe live connection changed. It reconnects on its own.
errorErrorSomething 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.