Add a support chat to your app.
From nothing to a working chat in your product: your signed-in users open requests and talk to your support team, and your agents answer from the Revvly Support inbox. One backend route, one small script, three settings.
#The order to do things in
- Set up Support for your team: Revvly switches Support on for your workspace, then you check the prefix, roles, ticket types and notifications.
- Add your knowledge so the AI has something to answer from (optional, and you can add it after launch).
- The five steps below: key, allowed origins, token route, chat client, widget.
- Sign users in to the support chat when you want roles, plans or bulk-synced users: every claim and the refresh rules.
#Give this prompt to an integration agent
It covers every step on this page. Copied from the dashboard (Org Settings, Support, Secret keys) it also carries your tenant id.
You are adding a Revvly Support chat to the operator's product so their signed-in users can open support requests and chat with the operator's support team, using a backend token route and a widget built on the Revvly support chat API.
Working agreement before you implement:
- Do not write production code until you have enough concrete context from the operator.
- Ask clarifying questions first — prefer one short round of questions over guessing.
- Confirm at least: source system / trigger, field mapping, where the API key and any signing secret will be stored, the target environment (local, staging, or production), and the Revvly tenantId when it is not already provided below.
- Once those answers are in place, implement the full integration against the contract below end-to-end.
- Open or fetch the Full guide URL if you need examples or edge-case detail beyond this prompt.
- Never invent API keys, signing secrets, region ids, subscription ids, or other tenant data.
- Never put secrets in browser code, logs, client bundles, or source control.
Tenant context
- The operator has not supplied a tenantId in this prompt. Ask for the Revvly tenant id before making tenant-scoped assumptions.
Platform rules that apply to every Revvly API call:
- Auth is a Bearer API key issued in the dashboard under Settings → API Keys. Tokens are prefixed `rvly_sk_live_`.
- Rate limits are per API key and default to 10 requests/second and 100 requests/minute. Overage is HTTP 429 with a `Retry-After` header in seconds (public GraphQL reports `errors[].extensions.code = RATE_LIMITED`). Honour Retry-After and back off — never retry a 429 in a tight loop.
- A key can be scoped to specific subscriptions and meters. List reads are filtered to that scope; reading or writing a single out-of-scope resource is 403 (`FORBIDDEN`), not an empty result.
- Every call is tenant-scoped by the key itself. There is no tenant parameter to switch — a different tenant means a different key.
- The token and the signing secret are server-side credentials: no browser code, no client bundles, no logs, no source control.
Full guide: https://www.revvly.ai/docs/guides/support-widget
Goal
Let a signed-in user of the operator's product open a support request, see their requests, chat with the support team and get replies, from inside the product. Revvly Support stores the conversations and the operator's agents answer them in the Revvly Support inbox.
Two credentials, never mixed
- Support secret key (rvly_sk_live_…): created in the Revvly dashboard under Settings → Support → Secret keys (NOT Settings → API Keys). Server-side only. Its only job here is minting end-user tokens. It can read tenant data but cannot change anything.
- End-user token: a short-lived (≤ 1 h) RS256 JWT for one user, minted by the backend and handed to the browser or app. Every chat call uses it. It is not accepted on https://api.revvly.ai/api/v1/graphql, and the secret key is not accepted on the chat endpoint.
Step 1 — Operator setup in the Revvly dashboard (ask the operator to confirm each is done; you cannot do it for them)
- Revvly Support is enabled for the workspace (Support appears in the sidebar).
- A support secret key exists (Settings → Support → Secret keys → Create secret key). The plaintext is shown once; it goes into the backend's secret store as e.g. REVVLY_SUPPORT_SECRET_KEY.
- Every origin the chat runs on is listed under Settings → Support → General → Widget allowed origins (https only, origin only; https://*.example.com covers every subdomain but not example.com itself, so list the bare domain and each white-label domain on their own lines; up to 100; http://localhost is allowed for development). A browser call from any other origin is refused with FORBIDDEN. Changes take up to a minute.
- If the product passes support roles, each role is declared under Settings → Support → Roles first; an undeclared role is refused at mint time with BAD_REQUEST.
Step 2 — Backend: a token route
- Add one authenticated route (e.g. POST /api/support-token) that only works for a user the product's own session has signed in. Never mint for an unauthenticated caller or for a user id taken from the request body.
- It calls POST https://api.revvly.ai/api/v1/graphql with Authorization: Bearer <support secret key>:
mutation CreateEndUserToken($input: EndUserClaimsInput!) {
createEndUserToken(input: $input) { token expiresAt }
}
- Claims (input):
- externalUserId — required. The product's own stable user id (max 200). Never reuse one for a different person.
- name — optional, shown to agents (max 200).
- email — optional. Needed if the user should get an email about replies they have not read in the app.
- role — optional, a declared support role (case-insensitive).
- plan — optional (max 64).
- organizationExternalId — optional, the user's account by the externalId set on the client in Revvly; links new requests to that client, and the user to that client's contact with the same email.
- attributes — optional, up to 25 { key, value } facts agents see beside the user (e.g. company_stage, cohort). Key: a letter, then up to 39 letters, digits, _, - or .; value up to 500 chars. The list replaces the stored one; omit it to keep what is stored, [] clears it. Stored by Revvly, not put in the token.
- ttlSeconds — optional, 60–3600, default 3600.
- Cache the token per externalUserId in the backend's memory or its existing cache (e.g. Redis) and return the cached one until about a minute before expiresAt; mint a new one only then. Every page load and tab otherwise mints its own token, and a login rush then spends the key's rate limit on tokens that are still valid. Key the cache on the same claims you send, so a change of name, email, role, plan or organization mints a fresh token. Never write tokens to a database.
- Return { token, expiresAt } to the caller. Do not log the token and do not put it in a URL.
- The secret key's limits apply to all of the product's users together: 50 mints/second and 3,000/minute by default. With tokens cached for their hour that is enough for well over 100,000 sign-ins an hour; if it is not, the operator raises them under Settings → Support → Secret keys → Limits, which also shows the last 24 hours of traffic and how much was refused.
- Mint errors (errors[].extensions.code): UNAUTHENTICATED (secret key missing, wrong, revoked or past rotation grace), FORBIDDEN (not a support secret key, or Support not enabled), BAD_REQUEST (invalid claim, undeclared role, bad ttlSeconds), RATE_LIMITED (honour Retry-After).
Optional — Sync users in bulk
- To have users in Support → End users before they first open the chat, or to update attributes between sign-ins, call upsertSupportUsers(users: [SupportUserInput!]!) on the same endpoint with the same secret key: the same fields as the claims above without ttlSeconds, up to 500 users per call, matched by externalUserId. It never mints a token.
- It answers { created updated failed results { index externalUserId status code message organizationLinked contactLinked } }. Each user succeeds or fails on its own (FAILED with BAD_REQUEST for an invalid field or undeclared role, CONFLICT for an anonymised user); log the failures, do not retry them unchanged. organizationLinked: false means no client in Revvly has that externalId yet.
- Run it from a job (e.g. nightly, or after a user's profile changes), never from a browser.
Step 3 — Frontend: the chat client
- Endpoint: POST https://api.revvly.ai/api/v1/support/graphql, Content-Type: application/json, Authorization: Bearer <end-user token>. Standard GraphQL { query, variables } body. No cookies: never send credentials.
- Keep the token in memory. Fetch one from the backend route when the chat opens; fetch a new one a minute before expiresAt, and on any UNAUTHENTICATED answer (then retry that call once).
- Introspection works with any valid token; the operations below are the whole contract.
Step 4 — Frontend: the chat UI, using exactly these operations
- query { me { externalUserId name email role unreadCount unreadTickets } myNotificationPreferences { progressUpdates emailWhenOffline } ticketTypes { id key name } } — ticketTypes are the kinds of request to offer, in display order. me.unreadCount is the total of unread replies across all the user's requests (unreadTickets: how many requests have one); use it for the launcher badge.
- myTickets(statusCategory, first, after) → { nodes { id number title type { name } status { key name category } createdAt lastMessageAt closedAt unreadCount progress { key label reachedAt } } pageInfo { hasNextPage endCursor } } — the user's requests, most recent activity first. first is 1–100 (default 20).
- ticket(id) → Ticket or null (null for any id that is not the user's own).
- ticketMessages(ticketId, first, before) → { nodes { id author body createdAt } pageInfo { hasPreviousPage startCursor } } — the latest page, oldest first within it; pass startCursor as before for older messages and prepend them. first is 1–100 (default 50).
- createTicket(input: { typeId, body, title, clientMessageId, attachmentIds, source }) → { ticket { … } message { … } } — opens a request with its first message. body max 20,000 chars, title optional max 200. source optional: where in the product it was opened (e.g. "web", "ios", "billing-page"; a letter or digit then up to 63 of A–Z a–z 0–9 _ - . / :) — agents can filter by it, so pass a stable value per surface.
- sendMessage(input: { ticketId, body, clientMessageId }) → Message — a reply; a reply to a closed request reopens it.
- markRead(ticketId) → Ticket — call when the user actually sees the conversation; sets unreadCount to 0 and cancels the email about that unread reply.
- updateMyNotificationPreferences(input: { emailWhenOffline: Boolean, progressUpdates: ALL | FINAL | NONE }) → { progressUpdates emailWhenOffline } — the user's own choices (myNotificationPreferences reads them). The reply email is on by default and every such email carries a link to turn it off; offer a toggle in the chat's settings. Only the user can change them: minting a token never turns it back on. progressUpdates controls the stage notices the user receives as the work behind a request moves forward.
- Attachments (optional): POST https://api.revvly.ai/api/v1/support/chat/attachments?filename=<name>[&ticketId=<id>] with Authorization: Bearer <end-user token>, Content-Type: the file's type, and the raw file as the body (fetch with body: file). Returns { attachment: { id filename mimeType size url previewUrl } }; errors as { error: { code, message } }. Omit ticketId when the file is for a request not yet created. Then pass attachmentIds: [id, …] (max 10) on sendMessage for that ticket, or on createTicket. Allowed: PNG, JPEG, GIF, WebP, PDF, plain text; max 20 MB (413 above). Message.attachments lists files; show previewUrl inline for images and url as a download link; both expire in 30 minutes, so re-read rather than store them. Limits: 2 uploads/second, 20/minute per user.
- closeTicket(input: { ticketId, reason, comment }) → Ticket — reason is SOLVED | NO_LONGER_NEEDED | OTHER; comment optional (max 2,000). Closing a closed request returns it unchanged.
- clientMessageId: generate a fresh unique id per send (crypto.randomUUID(), max 100 chars). Retrying with the same id returns the original request or message instead of posting twice, so retry with the same id after a timeout or network error.
- message.author is YOU (the user), AGENT (the support team, never named), AI or SYSTEM. Bodies are plain text: render as text, never as HTML.
- status.name is the operator's own status name; branch on status.category (OPEN | IN_PROGRESS | PENDING | CLOSED). PENDING means waiting on the user and closes by itself after the operator's quiet period unless the user replies.
Step 5 — Live updates (server-sent events)
- Stream: GET https://api.revvly.ai/api/v1/support/chat/stream, one per signed-in user while the chat is open; close it when the chat closes.
- Browser: EventSource cannot send Authorization, so call the chat mutation createStreamTicket { path expiresAt } and open new EventSource(API origin + path). The ticket lasts one minute, works only on the stream, and must be fetched fresh for every connection. Apps and servers may instead send Authorization: Bearer <end-user token> with any SSE client.
- Frames: ready { resumed } ends the handshake (resumed false → re-read the list and the open thread); ticket_updated { ticketId, change: created|message|status|type|title|closed|reopened|progress, messageId, by: you|agent|ai|system, eventId } → re-read that request with the queries above (the frame carries no content; dedupe on eventId); typing { ticketId, expiresInMs } → show "Support is typing" on that request for expiresInMs; reauthenticate → close and reconnect with a new ticket or token.
- Resume: keep the last frame id (event.lastEventId) and pass it as ?lastEventId= on the next connection (or the Last-Event-ID header); missed frames arrive before ready.
- On error: close the EventSource (the ticket is spent) and reconnect after a few seconds with a new ticket. A 503 means live updates are unavailable: fall back to re-reading ticketMessages every 3–5 s and myTickets every 10–30 s while the chat is open and the tab visible.
- Use me.unreadCount for the launcher badge and each ticket's unreadCount in the list, and call markRead when the user views a conversation with unreadCount > 0.
- The stream ends by itself at the token's expiry and after 15 minutes at most; that is normal, reconnect.
Optional — Live updates through the operator's backend (a proxy)
- Only when the user's browser talks to the operator's backend rather than to Revvly. Add an endpoint in the dashboard (Settings → Support → Webhooks, https) and choose its events (up to five endpoints, each with its own secret); the signing secret is shown once and goes into the backend secret store as e.g. REVVLY_SUPPORT_WEBHOOK_SECRET.
- Revvly POSTs { id, type, createdAt, data: { ticketId, ticketNumber, messageId, by, endUser: { externalUserId } } } for each change a user can see, where type is one of ticket.created, ticket.message, ticket.status_changed, ticket.updated, ticket.closed, ticket.reopened, ticket.progressed (only those the endpoint chose). Ids only, except ticket.progressed, which also carries data.stage { key, label } (the step you named in Settings): read the content from the chat API with that user's token.
- Verify every request before trusting it: header Revvly-Signature: t=<unix seconds>,v1=<hex>; v1 = HMAC-SHA256(secret, "<t>.<raw request body>") compared in constant time; reject when |now − t| > 300 s. Use the raw bytes, not re-serialised JSON.
- Answer 2xx within 5 seconds and do the work afterwards. Non-2xx and timeouts are retried after 10 s, 1 min, 5 min, 30 min, 2 h and 6 h, then dropped. Delivery is at least once and may be out of order: dedupe on Revvly-Event-Id.
- Then push to that user however the product already does (its own socket, SSE or push), keyed on endUser.externalUserId.
Limits and errors on the chat endpoint
- Per user: 10 requests/second and 120/minute; for the whole workspace together 200/second and 6,000/minute; at most 10 new requests per user per hour (a retried create with the same clientMessageId does not count). Over a limit → RATE_LIMITED with a Retry-After header (seconds); for the new-request limit it says when the oldest request in the hour ages out.
- UNAUTHENTICATED: no token, invalid, expired, or its secret key was revoked → mint a new token and retry once.
- FORBIDDEN: the page's origin is not in Widget allowed origins (fix in the dashboard, not in code).
- NOT_FOUND: the request does not exist or is not the user's own.
- BAD_REQUEST: empty or too long body/title, unknown or retired type, bad cursor or page size, missing clientMessageId.
- QUERY_DEPTH_EXCEEDED: query nested deeper than 8 levels.
- INTERNAL_ERROR: safe to retry with the same clientMessageId.
Step 6 — Test before shipping
- In the Revvly dashboard, Settings → Support → Secret keys → "Test the chat as a customer" chats through this same API as a test customer, live stream included; use it to see what users will see.
- With a webhook: Settings → Support → Webhooks → "Test" posts a signed webhook.test; check the backend verifies it and answers 2xx.
- Then run the integration against staging: open a request from the product, answer it from the Revvly Support inbox (Support in the sidebar), confirm the reply appears, unreadCount clears after viewing, and closing and replying again reopens it.
Step 7 — Go-live checklist
1. The secret key is only in the backend secret store: not in the client bundle, logs, URLs or source control.
2. The token route mints only for the signed-in user from the product's own session.
3. Every production origin is in Widget allowed origins.
4. email is passed if users should be told about unread replies by email.
5. Tokens are refreshed before expiry and on UNAUTHENTICATED; nothing persists them.
6. The live stream closes with the chat, reconnects with a new ticket after an error or reauthenticate, and backs off on RATE_LIMITED; any fallback polling stops when the chat is closed or the tab is hidden.
7. Message bodies render as text.
Optional — verifying tokens on the operator's own services
- Only if their services also want to accept the token: RS256, JWKS at https://api.revvly.ai/api/v1/support/jwks.json (cacheable 1 h, header kid names the key). Require iss = "revvly-support" AND aud = the operator's Revvly tenantId (shown in the tenant context above when this prompt was copied from the dashboard; otherwise read it from the aud of any minted token and pin it). Without the aud check another workspace's token for a user with the same id would pass. A signature check does not detect a revoked key; only Revvly's endpoints do.
Clarifying questions to ask the operator first
1. Which frontend framework the product uses, and where the chat should appear (floating button, help page, in-app panel).
2. How the backend identifies the signed-in user (session, JWT) and which fields map to externalUserId, name, email, role, plan and organizationExternalId.
3. Where the support secret key will be stored, and whether it has been created yet.
4. Which origins (production, staging, local) the chat runs on, and whether they are in Widget allowed origins.
5. Whether users should get email about unread replies (then email must be passed).#How the pieces fit
- Your backendMints a one-hour token for the signed-in user.
- The chat in your pageOpens a request with that token.
- Support inboxThe request waits unassigned until someone takes it.
- Your team repliesThe reply appears in your chat.
#Step 1: Turn it on in Revvly
Support is an add-on that Revvly switches on for your workspace, so Support has to appear in your sidebar first; if it does not, ask Revvly to turn it on. The key below is a Support key, created on the Support settings page. It is not the same as an API key under Settings, API Keys, and one cannot do the other’s job.
- Org Settings
- Support
- Secret keys
- Create secret key
Admins and owners. The key is shown once: put it in your server's secret store, e.g. REVVLY_SUPPORT_SECRET_KEY.
- Org Settings
- Support
- General
- Widget allowed origins
Every site the chat runs on, e.g. https://app.example.com, plus http://localhost:3000 for development. Any other site is refused.
For many subdomains write https://*.example.com: it covers every subdomain at any depth but not example.com itself, another scheme or another port, so list the bare domain on its own line. A white-label domain is always its own line. Up to 100 lines.
If you want to pass support roles (for example founder or mentor), declare them first with the Roles button on the Knowledge page (Support, Knowledge): a role Revvly does not know is refused when you mint the token, so typos show up straight away.
#Step 2: Add a token route to your backend
One route your signed-in pages call. It reads the user from your own session, never from the request, and returns a token for them. It reuses each user’s token until a minute before it expires, so a user with five tabs open costs one sign-in an hour, not five per page load. Details on every field are in Sign users in to the support chat.
// server.js (Express). The secret key lives here and nowhere else.
import express from 'express';
const app = express();
// One token per user, reused until a minute before it expires, so page loads
// and tabs share it instead of each minting their own. Use Redis instead of a
// Map when you run more than one server. Never write tokens to a database.
const tokens = new Map(); // cache key -> { token, expiresAt }
// requireSignedInUser is your own auth middleware.
app.post('/api/support-token', requireSignedInUser, async (req, res) => {
const user = req.user; // from YOUR session, never from the request body
// Everything you send as claims. The cache key is this very object, so a
// changed name, email, role or plan mints a new token, and a claim you add
// later is covered without touching the key.
const input = {
externalUserId: user.id,
name: user.name,
email: user.email, // lets Revvly email replies the user has not read
organizationExternalId: user.accountId, // optional: links requests to the client
};
const key = JSON.stringify(input);
const cached = tokens.get(key);
const fresh = req.query.refresh === '1'; // the client asks after UNAUTHENTICATED
if (cached && !fresh && new Date(cached.expiresAt) - Date.now() > 60_000) {
return res.json(cached);
}
const response = await fetch('https://api.revvly.ai/api/v1/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.REVVLY_SUPPORT_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `mutation($input: EndUserClaimsInput!) {
createEndUserToken(input: $input) { token expiresAt }
}`,
variables: { input },
}),
});
const { data, errors } = await response.json();
if (errors) return res.status(502).json({ error: errors[0].extensions?.code });
tokens.set(key, data.createEndUserToken);
res.json(data.createEndUserToken); // { token, expiresAt }
});#Step 3: Add the chat client
A small helper that keeps the token in memory, refreshes it before it expires, and retries once with a newly minted token if the API says the token is no longer valid.
// support-chat.js: talks to the support chat API with the user's token.
const CHAT_URL = 'https://api.revvly.ai/api/v1/support/graphql';
let session = null; // { token, expiresAt }, kept in memory only
async function token(refresh = false) {
if (refresh || !session || new Date(session.expiresAt) - Date.now() < 60_000) {
const res = await fetch(`/api/support-token${refresh ? '?refresh=1' : ''}`, { method: 'POST' });
if (!res.ok) throw new Error('Could not start the support chat');
session = await res.json();
}
return session.token;
}
export async function chat(query, variables = {}, retried = false) {
const res = await fetch(CHAT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await token(retried)}`,
},
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await res.json();
const code = errors?.[0]?.extensions?.code;
if (code === 'UNAUTHENTICATED' && !retried) {
// Expired or revoked: get a newly minted token, past the server's cache, and retry once.
return chat(query, variables, true);
}
if (errors) throw Object.assign(new Error(errors[0].message), { code });
return data;
}#Step 4: Add the chat to your page
A complete, unstyled chat: a list of the user’s requests, a form to open one, the conversation and a reply box. Style it like the rest of your product, or use it as the shape of your own React or Vue component.
Markup<!-- Anywhere in your signed-in pages -->
<section id="support" hidden>
<header>
<strong>Support</strong>
<button type="button" id="support-new">New request</button>
</header>
<ul id="support-list"></ul>
<form id="support-open" hidden>
<select id="support-type" required></select>
<input id="support-title" maxlength="200" placeholder="Subject (optional)" />
<textarea id="support-first" required placeholder="How can we help?"></textarea>
<button type="submit">Send request</button>
</form>
<div id="support-thread" hidden>
<ol id="support-messages"></ol>
<p id="support-typing" hidden>Support is typing…</p>
<form id="support-reply">
<textarea id="support-body" required placeholder="Write a reply"></textarea>
<button type="submit">Send</button>
</form>
</div>
</section>
<button type="button" id="support-toggle">Help</button>
<style>
/* 16px or more on anything that takes text, or mobile Safari zooms in on focus. */
#support input, #support select, #support textarea { font-size: 16px; }
#support-messages { list-style: none; padding: 0; }
#support-messages .you { text-align: right; }
#support-messages p { white-space: pre-wrap; margin: 4px 0; }
</style>Install what the script importsnpm install marked dompurify
There is no Revvly package: the script talks to the chat API directly. It imports two libraries, marked and dompurify, to show the formatted replies the AI and your team write. Use your bundler as usual; if you would rather not add them, render every message with textContent and replies appear as plain text. Keep both up to date: the sanitiser is what stands between a reply and your page.
Script// support-widget.js
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { chat } from './support-chat.js';
const $ = (id) => document.getElementById(id);
let openId = null;
let stream = null;
let lastEventId = null;
let typingTimer = null;
const LIST = `query { myTickets(first: 20) { nodes {
id number title type { name } status { name category } unreadCount } } }`;
const THREAD = `query($id: ID!) { ticketMessages(ticketId: $id, first: 50) {
nodes { id author body format notice createdAt } } }`;
// AI and team replies are a small Markdown subset (format: MARKDOWN). Render them with a
// sanitiser that allows only those tags and https links; everything else stays text.
const MARKDOWN = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href'],
ALLOWED_URI_REGEXP: /^https:/i,
};
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
function renderBody(el, message) {
if (message.format === 'MARKDOWN') {
el.innerHTML = DOMPurify.sanitize(marked.parse(message.body, { async: false }), MARKDOWN);
} else {
el.textContent = message.body; // plain text: never innerHTML
}
}
function renderList(tickets) {
$('support-list').replaceChildren(...tickets.map((t) => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
// textContent, never innerHTML: titles and messages are user text.
link.textContent = `${t.number} ${t.title ?? t.type.name} · ${t.status.name}` +
(t.unreadCount ? ` (${t.unreadCount} new)` : '');
link.onclick = (e) => { e.preventDefault(); openThread(t.id); };
li.append(link);
return li;
}));
}
function renderThread(messages) {
$('support-messages').replaceChildren(...messages.map((m) => {
const li = document.createElement('li');
// The AI handed the request to your team: show a notice, not a chat bubble.
if (m.notice === 'AI_HANDOVER') {
li.className = 'notice';
li.textContent = 'A teammate will reply.';
return li;
}
li.className = m.author === 'YOU' ? 'you' : 'team';
const who = document.createElement('small');
who.textContent = m.author === 'YOU' ? 'You' : m.author === 'AI' ? 'Assistant' : 'Support';
const body = document.createElement('div');
renderBody(body, m);
li.append(who, body);
return li;
}));
}
async function refresh() {
const { myTickets } = await chat(LIST);
renderList(myTickets.nodes);
if (!openId) return;
const { ticketMessages } = await chat(THREAD, { id: openId });
renderThread(ticketMessages.nodes);
const open = myTickets.nodes.find((t) => t.id === openId);
if (open?.unreadCount > 0) {
await chat('mutation($id: ID!) { markRead(ticketId: $id) { id } }', { id: openId });
}
}
async function openThread(id) {
openId = id;
$('support-open').hidden = true;
$('support-thread').hidden = false;
await refresh();
}
$('support-new').onclick = async () => {
const { ticketTypes } = await chat('query { ticketTypes { id name } }');
$('support-type').replaceChildren(...ticketTypes.map((t) => new Option(t.name, t.id)));
openId = null;
$('support-thread').hidden = true;
$('support-open').hidden = false;
};
$('support-open').onsubmit = async (e) => {
e.preventDefault();
const { createTicket } = await chat(
`mutation($input: CreateTicketInput!) { createTicket(input: $input) { ticket { id } } }`,
{ input: {
typeId: $('support-type').value,
title: $('support-title').value || null,
body: $('support-first').value,
clientMessageId: crypto.randomUUID(), // a retry with the same id never posts twice
} },
);
$('support-open').reset();
await openThread(createTicket.ticket.id);
};
$('support-reply').onsubmit = async (e) => {
e.preventDefault();
await chat(
`mutation($input: SendMessageInput!) { sendMessage(input: $input) { id } }`,
{ input: { ticketId: openId, body: $('support-body').value, clientMessageId: crypto.randomUUID() } },
);
$('support-reply').reset();
await refresh();
};
// Live updates while the chat is open. A browser EventSource cannot send the
// token, so a one-minute stream ticket goes in the URL instead.
async function listen() {
const { createStreamTicket } = await chat('mutation { createStreamTicket { path } }');
const resume = lastEventId ? `&lastEventId=${encodeURIComponent(lastEventId)}` : '';
stream = new EventSource(`https://api.revvly.ai${createStreamTicket.path}${resume}`);
stream.addEventListener('ready', (e) => {
if (!JSON.parse(e.data).resumed) refresh().catch(console.error);
});
stream.addEventListener('ticket_updated', (e) => {
lastEventId = e.lastEventId;
$('support-typing').hidden = true;
refresh().catch(console.error);
});
stream.addEventListener('typing', (e) => {
const { ticketId, expiresInMs } = JSON.parse(e.data);
if (ticketId !== openId) return;
$('support-typing').hidden = false;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => ($('support-typing').hidden = true), expiresInMs);
});
// The token behind the stream is ending, or the connection dropped: the
// ticket is spent either way, so reconnect with a new one.
const reconnect = (delay) => {
stream.close();
stream = null;
if (!$('support').hidden) setTimeout(() => listen().catch(console.error), delay);
};
stream.addEventListener('reauthenticate', () => reconnect(0));
stream.onerror = () => reconnect(5000);
}
$('support-toggle').onclick = async () => {
const panel = $('support');
panel.hidden = !panel.hidden;
if (panel.hidden) {
stream?.close();
stream = null;
return;
}
await refresh();
await listen();
};#Step 5: Test it
- Support
- Inbox
- Sandbox
Chats through the same API as a practice customer, so you can see what your users will see.
Then try your own integration: open a request from your product, answer it from Support in the Revvly sidebar, and check that the reply appears in your chat, that the unread count clears once you open it, and that replying to a closed request reopens it.
#Before you go live
- The secret key is only on your server: not in the page, logs, URLs or git.
- The token route mints only for the user your own session signed in.
- Every production site is in Widget allowed origins, the bare domain next to any wildcard.
- You pass email if users should hear about unread replies by email, and your chat settings let them turn those emails off.
- Your server reuses each user’s token until it is about to expire, and the browser keeps it in memory only.
- The live stream closes with the chat, reconnects with a new ticket after an error, and backs off on RATE_LIMITED.
- Plain messages are shown as text (textContent). Messages with format MARKDOWN go through a sanitiser that allows only the documented tags and https links, never raw HTML.
- A message with notice AI_HANDOVER is shown as “A teammate will reply” rather than as a reply.