Source: https://www.revvly.ai/docs/api/support-chat

API Reference

# Build your own chat widget on the *support chat API*.

Everything a widget or in-app help panel needs to talk to your support team: list the signed-in user’s requests, open a new one, reply, read the answers and close it. Called from the browser or your app with a short-lived token your backend mints.

## Before you start

Your backend needs a support secret key and a route that mints a token for the user it has signed in. [Sign users in to the support chat](https://www.revvly.ai/docs/guides/support-tokens) walks through both. Everything on this page is called with that token, not with a secret key.

i

This is a **separate endpoint and schema** from the [public GraphQL API](https://www.revvly.ai/docs/api/graphql-public). A token here can only reach its own user’s requests; a secret key is not accepted here, and a token is not accepted there.

## [#](#support-chat)Endpoint & authentication

POST

https://api.revvly.ai/api/v1/support/graphql

application/json

Send a standard GraphQL request (a query and optional variables) as JSON.

- **Authorization**: Bearer <end-user token>, checked on every request: signature, expiry, and that the secret key that minted it is still live. Anything else ⇒ UNAUTHENTICATED; mint a fresh token and retry once.

- **Allowed origins**: A browser call must come from a site listed under Org Settings, Support, General, Widget allowed origins (https, origin only; https://*.example.com covers every subdomain but not the bare domain). Any other site ⇒ FORBIDDEN, so a token copied off your site is useless on someone else’s. Calls with no Origin header, from a native app or a server, are not checked. Changes take up to a minute to apply.

- **CORS**: Preflights are answered for any origin. No cookies are used or allowed: send the token in the header, never with credentials.

- **Rate limits**: Per user: 10 requests a second and 120 a minute, and for your whole workspace 200 a second and 6,000 a minute ⇒ RATE_LIMITED with a Retry-After header. At most 10 new requests per user an hour (a retried create does not count); over it, createTicket answers RATE_LIMITED with Retry-After set to when the oldest of those requests turns an hour old.

- **Query depth**: At most 8 levels; deeper queries ⇒ QUERY_DEPTH_EXCEEDED. Introspection works with any valid token.

Request headers

```
Authorization: Bearer <end-user token>
Content-Type:  application/json
```

A small client with token refresh

```
// A minimal client for the support chat API. getToken() calls the route on
// your backend that mints a token (see "Sign users in to the support chat").
const CHAT_URL = 'https://api.revvly.ai/api/v1/support/graphql';
let session = null; // { token, expiresAt }

async function token() {
  if (!session || new Date(session.expiresAt).getTime() - Date.now() < 60_000) {
    session = await getToken(); // POST /api/support-token on your backend
  }
  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()}` },
    body: JSON.stringify({ query, variables }),
  });
  const { data, errors } = await res.json();
  if (errors?.[0]?.extensions?.code === 'UNAUTHENTICATED' && !retried) {
    session = null; // expired or revoked: mint a fresh token and try once more
    return chat(query, variables, true);
  }
  if (errors) throw Object.assign(new Error(errors[0].message), errors[0].extensions);
  return data;
}
```

## Give this prompt to an integration agent

The whole integration, backend route to widget, as one prompt. The step-by-step version is [Add a support chat to your app](https://www.revvly.ai/docs/guides/support-widget).

```
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).
```

## [#](#privacy)What the user sees

- **Only their own**: Every request comes back for the token’s user only. Another user’s request id answers null or NOT_FOUND, never FORBIDDEN, so nobody can probe which ids exist.

- **Never internal**: Internal notes, assignees, priorities, tags and agent names are never returned. A reply from your team has author: AGENT, with no name.

- **Your status names**: status.name is the name you gave the status in your workflow; branch on status.category, which is always one of four values.

## [#](#me)The user and the request types

QUERY

me · ticketTypes

me echoes who the token is for, and me.unreadCount totals the replies the user has not read across all their requests: the number for the badge on your chat launcher, without listing every request. ticketTypes lists the kinds of request the user can open, in the order you set; show them as the first choice in your form.

```
query {
  me { externalUserId name email role unreadCount unreadTickets }
  myNotificationPreferences { progressUpdates emailWhenOffline }
  ticketTypes { id key name }
}
```

Response

```
{
  "data": {
    "me": {
      "externalUserId": "user_8412",
      "name": "Ana Silva",
      "email": "ana@acme.com",
      "role": "founder",
      "unreadCount": 2,
      "unreadTickets": 1
    },
    "myNotificationPreferences": { "progressUpdates": "ALL", "emailWhenOffline": true },
    "ticketTypes": [
      { "id": "66f0…a1", "key": "question", "name": "Question" },
      { "id": "66f0…a2", "key": "issue_report", "name": "Issue report" },
      { "id": "66f0…a3", "key": "suggestion", "name": "Suggestion" }
    ]
  }
}
```

## [#](#my-tickets)List the user’s requests

QUERY

myTickets

Most recent conversation activity first. Use unreadCount for a badge.

```
query MyTickets($after: String) {
  myTickets(first: 20, after: $after) {
    nodes {
      id
      number
      title
      type { name }
      status { name category }
      lastMessageAt
      unreadCount
      progress { label }
    }
    pageInfo { hasNextPage endCursor }
  }
}
```

### Arguments

- **statusCategoryoptional**: Only requests in this category, e.g. CLOSED for a history tab.

- **firstoptional**: Page size, 1 to 100. Default 20.

- **afteroptional**: pageInfo.endCursor of the previous page.

## [#](#ticket)Read one request

QUERY

ticket

```
query Ticket($id: ID!) {
  ticket(id: $id) {
    id
    number
    title
    status { key name category }
    createdAt
    closedAt
    unreadCount
    progress { key label reachedAt }
  }
}
```

Returns null for any id that is not the user’s own.

## [#](#ticket-messages)Read the conversation

QUERY

ticketMessages

The latest page of messages, oldest first within the page, ready to render top to bottom. For older messages pass pageInfo.startCursor as before and prepend the result.

```
query Thread($ticketId: ID!, $before: String) {
  ticketMessages(ticketId: $ticketId, first: 50, before: $before) {
    nodes { id author body createdAt }
    pageInfo { hasPreviousPage startCursor }
  }
}
```

Response

```
{
  "data": {
    "ticketMessages": {
      "nodes": [
        { "id": "6701…01", "author": "YOU", "body": "The export button does nothing.", "createdAt": "2026-10-05T09:12:03.000Z" },
        { "id": "6701…02", "author": "AGENT", "body": "Thanks Ana, looking at it now.", "createdAt": "2026-10-05T09:14:40.000Z" },
        { "id": "6701…03", "author": "AI", "body": "Export is limited to active subscriptions. Is the one you expect cancelled?", "createdAt": "2026-10-05T09:15:10.000Z" }
      ],
      "pageInfo": { "hasPreviousPage": false, "startCursor": "MjAyNi0xMC0w…" }
    }
  }
}
```

### Arguments

- **ticketIdrequired**: One of the user’s requests.

- **firstoptional**: Page size, 1 to 100. Default 50.

- **beforeoptional**: pageInfo.startCursor, for the page before.

author is YOU (the user), AGENT (your team), AI (the AI assistant) or SYSTEM. Read format before rendering a body: PLAIN is text (show it as text, never as Markdown or HTML); MARKDOWN is a small subset, written by the AI and by your team, with numbered and bulleted lists, bold, italic, code and https links; replies from your team may also hold fenced code blocks and quotes (lines starting with >). Nothing else appears, and a typed < that could open an HTML tag arrives escaped with a backslash. Render it with a Markdown renderer that does not pass raw HTML, and open links in a new tab with rel="noopener noreferrer". In an AI reply a link only ever points at a help page or an address written in your own help content; in a reply from your team it is any https address the agent chose. Either way it is https only, so it is safe to make clickable. Replies stored before this existed stay PLAIN.

When the AI hands a request to your team, the request gets handedOverAt and a message arrives with notice: AI_HANDOVER. Show that message as a notice ("A teammate will reply") rather than as a chat bubble, and keep showing it until an AGENT message comes.

## [#](#create-ticket)Open a request

MUTATION

createTicket

Opens a request with its first message. It lands in your Support inbox, unassigned, and notifies your agents.

```
mutation Open($input: CreateTicketInput!) {
  createTicket(input: $input) {
    ticket { id number status { name category } }
    message { id author body createdAt }
  }
}

{
  "input": {
    "typeId": "66f0…a2",
    "title": "Export button",
    "body": "The export button does nothing.",
    "clientMessageId": "1f0c2a3e-7d1b-4c6e-9a51-2b7c0e9d4f10"
  }
}
```

### Input

- **typeIdrequired**: One of ticketTypes.

- **bodyrequired**: The first message, plain text, up to 20,000 characters.

- **titleoptional**: A subject, up to 200 characters.

- **clientMessageIdrequired**: A unique id you generate per send, such as crypto.randomUUID() (max 100 characters). Retrying with the same id returns the request the first attempt opened instead of opening a second one, so retry freely after a timeout.

- **attachmentIdsoptional**: Up to 10 files uploaded without a ticketId (see Attach files).

- **sourceoptional**: Where in your product the request was opened, such as web, ios or billing-page: a letter or digit, then up to 63 letters, digits, _ - . / or :. Agents see it on the request and can filter the inbox by it.

## [#](#send-message)Reply

MUTATION

sendMessage

Adds a message to one of the user’s requests. A reply to a closed request reopens it. clientMessageId works as for createTicket: the same id returns the original message.

```
mutation Reply($input: SendMessageInput!) {
  sendMessage(input: $input) { id author body createdAt }
}

{
  "input": {
    "ticketId": "6700…ff",
    "body": "It happens on Chrome and Safari.",
    "clientMessageId": "8c2d5e0a-31f4-4b7a-a9c2-5d6e7f809a1b"
  }
}
```

## [#](#attachments)Attach files

POST

https://api.revvly.ai/api/v1/support/chat/attachments

application/json

Upload each file first, then pass the returned ids as attachmentIds on sendMessage, or on createTicket for files uploaded without a ticketId. Up to 10 files a message.

```
// 1. Upload each file: the raw bytes as the body, its type as Content-Type.
async function upload(file, ticketId) {
  const params = new URLSearchParams({ filename: file.name });
  if (ticketId) params.set('ticketId', ticketId); // omit when opening a new request
  const res = await fetch(`https://api.revvly.ai/api/v1/support/chat/attachments?${params}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': file.type },
    body: file, // a File from <input type="file">
  });
  const json = await res.json();
  if (!res.ok) throw Object.assign(new Error(json.error.message), json.error);
  return json.attachment; // { id, filename, mimeType, size, url, previewUrl }
}

// 2. Send the ids with the message.
const files = await Promise.all([...input.files].map((f) => upload(f, openId)));
await chat(
  'mutation($input: SendMessageInput!) { sendMessage(input: $input) { id attachments { filename url } } }',
  { input: { ticketId: openId, body, clientMessageId: crypto.randomUUID(), attachmentIds: files.map((f) => f.id) } },
);
```

- **Files**: PNG, JPEG, GIF, WebP, PDF or plain text, up to 20 MB. The type is checked against the file’s bytes, not just its name, and files are scanned for malware wherever a virus scanner is configured. Anything else ⇒ BAD_REQUEST; over 20 MB ⇒ 413.

- **Who can use a file**: Only the user who uploaded it, and only on the request it was uploaded for. A file is kept a day if no message uses it.

- **Showing files**: Each message lists its attachments. Use previewUrl for an inline image (a re-encoded copy) and url for a download. Both last 30 minutes: read the message again for fresh ones.

- **Limits and errors**: The chat API’s auth, origin check and per-user limit, plus 2 uploads a second and 20 a minute. Errors come back as { "error": { "code", "message" } } with the same codes.

## [#](#mark-read)Mark as read

MUTATION

markRead

Call it when the user actually sees the conversation. It sets unreadCount back to 0 and cancels the email Revvly would otherwise send about the unread reply (sent only to users whose token carries an email, after the delay set in Support settings).

```
mutation Seen($ticketId: ID!) {
  markRead(ticketId: $ticketId) { id unreadCount }
}
```

## [#](#notifications)Notification preferences

MUTATION

updateMyNotificationPreferences

The email about an unread reply is on by default. The user can turn it off from your chat settings with emailWhenOffline: false, or with the “Turn off these emails” link at the foot of every such email; read the current choice with myNotificationPreferences. The choice belongs to the user: minting a token or syncing profiles never changes it, so your backend cannot turn the emails back on for them. Fields you leave out stay as they are.

progressUpdates (ALL, FINAL or NONE) is the user’s choice about progress updates on the work behind a request. It controls the stage notices a client receives as the work moves forward, so offer it in your settings screen.

```
mutation Prefs($input: EndUserNotificationPreferencesInput!) {
  updateMyNotificationPreferences(input: $input) { progressUpdates emailWhenOffline }
}
```

## [#](#close-ticket)Close a request

MUTATION

closeTicket

Lets the user close their own request, with an optional reason (SOLVED, NO_LONGER_NEEDED, OTHER) and comment (up to 2,000 characters). Closing a closed request returns it unchanged; a new message reopens it.

```
mutation Done($input: CloseTicketInput!) {
  closeTicket(input: $input) { id status { name category } closedAt }
}

{ "input": { "ticketId": "6700…ff", "reason": "SOLVED" } }
```

## [#](#request-human)Ask for a person

MUTATION

requestHuman

Lets the user leave the AI assistant and hand the request to your support team. It is always allowed, whatever the assistant has said. The assistant stops answering that request, the user sees a short hand-over message, and your team takes it. Calling it again changes nothing and sends no second message. Use it behind a “Talk to a person” button.

```
mutation Human($ticketId: ID!) {
  requestHuman(ticketId: $ticketId) { id status { name category } }
}
```

Returns the updated Ticket. NOT_FOUND if the request is not the user’s own.

## [#](#rate-answer)Rate an AI reply

MUTATION

rateAnswer

Lets the user judge one reply written by the AI assistant (a message whose author is AI). Pass its id as messageId and one of two outcomes.

- **RESOLVED**: This solved it. The request closes as solved by the assistant. It is refused once a person has the request: it was closed, handed over, assigned, or an agent has replied in public.

- **NOT_HELPFUL**: It did not help. The assistant stops and a person takes over, as with requestHuman.

```
mutation Rate($messageId: ID!, $outcome: AnswerRating!) {
  rateAnswer(messageId: $messageId, outcome: $outcome) {
    id
    status { name category }
    closedAt
  }
}

{ "messageId": "6701…03", "outcome": "RESOLVED" }
```

A reply is rated once. Sending the same outcome again is accepted and changes nothing; sending a different one is refused with BAD_REQUEST, as is a message id that is unknown, is not an AI reply, or is not on one of the user’s own requests. Returns the updated Ticket. Offer the two buttons under an AI reply.

## [#](#types)Types

### Ticket

- **id**: Stable id.

- **number**: The reference the user and your agents both see, e.g. AA-42.

- **title**: Null until the user or an agent gives it one.

- **type**: TicketType: id, key, name.

- **status**: TicketStatus: key, name (yours), category.

- **createdAt · lastMessageAt · closedAt**: ISO 8601 timestamps.

- **unreadCount**: Replies the user has not read yet.

- **progress**: How far the work behind the request has got, in steps you name in Settings (key, label, reachedAt). Null until work starts. Never a tracker status.

- **handedOverAt**: ISO 8601, set once the AI has handed the request to your team; null before. Show "A teammate will reply" while it is set and no AGENT message has come.

### StatusCategory

- **OPEN**: New, waiting for your team.

- **IN_PROGRESS**: Your team is working on it.

- **PENDING**: Waiting on the user. Closes by itself after the quiet period you set, unless the user replies.

- **CLOSED**: Closed. A new message reopens it.

### Message

- **id · ticketId**: Ids.

- **author**: YOU, AGENT, AI or SYSTEM.

- **body**: The text. Read format to know how.

- **format**: PLAIN for the user's own messages and system notices; MARKDOWN for AI and team replies (the subset above).

- **notice**: AI_HANDOVER on the system message that says the AI handed over; null on every other message.

- **attachments**: id, filename, mimeType, size, url (download) and previewUrl (images only). Links last 30 minutes.

- **citations**: AI replies only: the help pages the answer is taken from (title, url, anchor). Empty for every other author. Show them as sources under the message.

- **createdAt**: ISO 8601.

## [#](#updates)Live updates

Open one stream per signed-in user while your chat is open, and re-read what it says changed. Frames say which request changed and how, never the content: you fetch that with the queries above, so what a user sees always goes through the same rules.

GET

https://api.revvly.ai/api/v1/support/chat/stream

text/event-stream

- **From a browser**: Call createStreamTicket (below), then open EventSource(origin + path). The ticket lasts a minute and works only on this endpoint.

- **From an app or server**: Send Authorization: Bearer <end-user token> with any client that reads server-sent events.

- **Resume**: Send the last id you received as Last-Event-ID or ?lastEventId=. Missed frames arrive before ready, which then says resumed: true. With resumed: false, re-read what is on screen.

- **Lifetime**: The stream ends with reauthenticate when the token expires, and after 15 minutes at most, so a revoked key or a changed role reaches open connections. Reconnect with a fresh ticket or token.

- **Limits**: The same origin check and per-user rate limit as GraphQL; opening a stream counts as one request. 503 means live updates are unavailable: fall back to reading every few seconds.

Frames

```
event: ready
data: {"type":"ready","resumed":false}

id: 1791270098678-0
event: ticket_updated
data: {"type":"ticket_updated","streamId":"1791270098678-0","eventId":"6ac4…83",
       "ticketId":"6ac4…7c","change":"message","messageId":"6ac4…7d","by":"agent",
       "createdAt":"2026-10-06T07:01:38.486Z"}

event: typing
data: {"type":"typing","ticketId":"6ac4…7c","expiresInMs":6000}

event: reauthenticate
data: {"type":"reauthenticate"}
```

- **ticket_updated.change**: created, message, status, type, title, closed, reopened or progress. Delivery is at least once: dedupe on eventId.

- **ticket_updated.by**: you, agent, ai or system. Agents are never named.

- **typing**: Your support team is writing a reply on that request. Show it for expiresInMs unless another frame arrives. Internal notes are never announced.

MUTATION

createStreamTicket

A browser listener

```
// Live updates in the browser. EventSource cannot send an Authorization
// header, so ask for a one-minute stream ticket and put it in the URL.
let lastEventId = null;

async function listen(onChange, onTyping) {
  const { createStreamTicket } = await chat('mutation { createStreamTicket { path } }');
  const resume = lastEventId ? `&lastEventId=${encodeURIComponent(lastEventId)}` : '';
  const stream = new EventSource(`https://api.revvly.ai${createStreamTicket.path}${resume}`);

  stream.addEventListener('ready', (e) => {
    // resumed: false means updates may have been missed: re-read what is on screen.
    if (!JSON.parse(e.data).resumed) onChange(null);
  });
  stream.addEventListener('ticket_updated', (e) => {
    lastEventId = e.lastEventId;
    onChange(JSON.parse(e.data)); // { ticketId, change, messageId, by }: re-read that request
  });
  stream.addEventListener('typing', (e) => onTyping(JSON.parse(e.data)));
  // The token behind the stream is ending: reconnect with a fresh ticket.
  stream.addEventListener('reauthenticate', () => {
    stream.close();
    listen(onChange, onTyping);
  });
  stream.onerror = () => {
    stream.close(); // the ticket is spent; get a new one
    setTimeout(() => listen(onChange, onTyping), 5000);
  };
  return stream;
}
```

## [#](#webhook)Live updates through your backend

If your users’ browsers talk to your backend and your backend talks to Revvly, let Revvly post each change to your backend instead and pass it on to the right user. Add an endpoint under Org Settings, Support, Webhooks and choose its events. Each endpoint has its own signing secret, shown once, and you can add up to five.

```
POST https://api.example.com/webhooks/revvly
Content-Type: application/json
Revvly-Event-Id: 6ac49cd2866904b09b303383
Revvly-Signature: t=1791270098,v1=5f2c…e1

{
  "id": "6ac49cd2866904b09b303383",
  "type": "ticket.message",
  "createdAt": "2026-10-06T07:01:38.486Z",
  "data": {
    "ticketId": "6ac49cd2866904b09b30337c",
    "ticketNumber": "AA-42",
    "messageId": "6ac49cd2866904b09b30337d",
    "by": "agent",
    "endUser": { "externalUserId": "user_8412" }
  }
}
```

- **Events**: type is one of ticket.created, ticket.message, ticket.status_changed, ticket.updated (type or title), ticket.closed, ticket.reopened and ticket.progressed (the client stage moved). Each endpoint gets only the ones you chose. webhook.test is sent by the Test button.

- **What is sent**: Every change a user can see on a request they opened, with endUser.externalUserId so you know whose it is. Ids only: read the content from this API as that user. The one exception is ticket.progressed, whose data.stage is { key, label }, the step you named in Settings.

- **Signature**: Revvly-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t.body with your secret. Reject anything that does not match or is more than 5 minutes old.

- **Delivery**: Answer 2xx within 5 seconds. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h and 6 h, then dropped. At least once and possibly out of order: dedupe on Revvly-Event-Id. https only; redirects are not followed.

- **Failing endpoints**: An endpoint whose deliveries give up ten times in a row is switched off, and nothing more is queued for it until an admin turns it on. One success resets the count.

- **Replay**: Every delivery is kept for 30 days under Support, Webhooks, Deliveries. Replay sends a finished one again with the same body and event id, a fresh signature, and a new run of retries.

Check the signature (Node)

```
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the exact bytes received, before any JSON parsing.
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(String(header ?? '').split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 min
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '');
  // timingSafeEqual throws on different lengths, so check that first.
  return given.length === expected.length && timingSafeEqual(Buffer.from(expected), given);
}
```

## [#](#errors)Errors

Errors come back in the standard GraphQL errors array, each with a stable extensions.code.

- **UNAUTHENTICATED**: No token, or it is invalid, expired or its secret key was revoked. Mint a new token and retry once.

- **FORBIDDEN**: The page’s origin is not in your widget allowed origins.

- **NOT_FOUND**: The request does not exist or is not the user’s own.

- **BAD_REQUEST**: Invalid input: an empty or too long body or title, an unknown or retired type, a bad cursor or page size, a missing clientMessageId.

- **RATE_LIMITED**: Over the per-user rate limit or the new-request limit. Wait for the Retry-After seconds.

- **INTERNAL_ERROR**: Something failed on our side. Safe to retry with the same clientMessageId.

## [#](#try-it)Try it from the dashboard

The Sandbox link at the foot of the Support inbox opens a page that chats through this exact API with a token for a practice customer you choose, so you can see what a user will see before you write the widget. The sandbox is a hidden copy of your Support setup: nothing in it reaches your real inbox, metrics or email.

## Related

- [Set up Support for your team ›](https://www.revvly.ai/docs/guides/support-setup)
- [Add a support chat to your app ›](https://www.revvly.ai/docs/guides/support-widget)
- [Sign users in to the support chat ›](https://www.revvly.ai/docs/guides/support-tokens)
- [Public GraphQL API ›](https://www.revvly.ai/docs/api/graphql-public)

