Revvly
Docs
Guide

Sign your users in to the support chat.

Your backend vouches for the user it has already signed in, and Revvly hands back a short-lived token. The browser or app uses that token to chat with your support team as that user, and as nobody else.

#How it works

1. Your server
Calls createEndUserToken with your support secret key and who the user is. The key never leaves your server.
2. The browser or app
Receives only the token (valid for up to one hour) from your server.
3. The chat
Calls the support chat API with that token to open requests, send messages and read replies, for that user only.
iRevvly never sees your users’ passwords and has no account for them. Whoever your backend says is signed in is who the chat talks to, so only mint a token for a user your own session has authenticated.

#Create a support secret key

Where
  1. Org Settings
  2. Support
  3. Secret keys
  4. Create secret key

Admins and owners only. The key is shown once: copy it into your server's secret store.

A support secret key (rvly_sk_live_…) can do one thing: sign your users in to the support chat. It can read your data through the public API but cannot change anything, so it is not the key your billing integration uses, and that key cannot mint support tokens. Keep it on your server only, never in a browser, a mobile app or a repository.

#Mint a token

POSThttps://api.revvly.ai/api/v1/graphqlapplication/json

Call createEndUserToken on the public GraphQL API with the secret key as the Bearer token. Mint a token when the user opens the chat, not on every page load.

Mutation and variables
mutation CreateEndUserToken($input: EndUserClaimsInput!) {
  createEndUserToken(input: $input) {
    token
    expiresAt
  }
}

{
  "input": {
    "externalUserId": "user_8412",
    "name": "Ana Silva",
    "email": "[email protected]",
    "role": "founder",
    "plan": "growth",
    "organizationExternalId": "acct_123",
    "attributes": [
      { "key": "company_stage", "value": "seed" },
      { "key": "cohort", "value": "Spring 2026" }
    ]
  }
}
With curl
curl -s -X POST https://api.revvly.ai/api/v1/graphql \
  -H "Authorization: Bearer $REVVLY_SUPPORT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation($input: EndUserClaimsInput!) { createEndUserToken(input: $input) { token expiresAt } }",
    "variables": { "input": { "externalUserId": "user_8412", "name": "Ana Silva", "email": "[email protected]" } }
  }'
Response
{
  "data": {
    "createEndUserToken": {
      "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
      "expiresAt": "2026-10-05T11:42:07.000Z"
    }
  }
}

Claims you send

externalUserIdrequired
Your own stable id for the user (max 200 characters). The same id always reaches the same requests, so never reuse one for a different person.
nameoptional
Shown to your agents on the ticket (max 200).
emailoptional
Where Revvly emails the user a reply they have not read in the app, after the delay set in Support settings. Without it, replies stay in the chat only.
roleoptional
The user’s role in your own product, as you already store it (max 64). Send it as it is: any value is accepted. The roles you define under Org Settings, Support, Roles say which values (and which attributes) they match, so a value no role matches simply grants no role and the user sees only articles open to everyone. Matching is case-insensitive, a user can have several roles, and editing a role applies to the next answer without a new token.
planoptional
The user’s plan in your product (max 64).
organizationExternalIdoptional
The user’s account, by the externalId you set on the client in Revvly. When it matches, new requests are linked to that client, so agents see whose request it is. If that client has a contact with the same email, the user is linked to the contact too.
attributesoptional
Up to 25 { key, value } facts agents see beside the user’s requests, such as company stage or cohort. A key starts with a letter and has up to 40 letters, digits, _, - or .; a value is up to 500 characters. The list you send replaces the stored one. Leave it out to keep what is stored, send [] to clear it. Revvly stores them; they are not put in the token.
ttlSecondsoptional
Lifetime in seconds, clamped to 60–3600. Defaults to 3600 (one hour).

#Give the token to the browser

Add one route to your backend that your signed-in pages call. It looks up the user from your own session, mints the token and returns it. A minimal Express version:

Life of a support token
  1. User opens the chat
    Your page asks your backend for a token.
  2. More than a minute left
    Your server returns the cached token.
    or
    No token, or about to expire
    Your server mints one with the secret key.
  3. Chat API calls
    The token is kept in memory only.
  4. UNAUTHENTICATED
    Skip your cache, mint a new token and retry once.
Reuse a token until a minute before it expires, and mint a new one if the chat API rejects it.
// Your backend: one route your signed-in pages call to get a support token.
// The secret key stays here; the browser only ever sees the token.
app.post('/api/support-token', requireSignedInUser, async (req, res) => {
  const user = req.user; // whoever your own session says is signed in
  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: {
          externalUserId: user.id,
          name: user.name,
          email: user.email,
          role: user.role, // your own value; your Revvly role rules decide what it grants
          organizationExternalId: user.accountId,
        },
      },
    }),
  });
  const { data, errors } = await response.json();
  if (errors) return res.status(502).json({ error: errors[0].extensions?.code });
  res.json(data.createEndUserToken); // { token, expiresAt }
});
Reuse
Cache each user’s token on your server, in memory or your existing cache, and return it until about a minute before expiresAt. Otherwise every page load and tab mints its own token, and a morning login rush spends your key’s limit on tokens that are still valid. Key the cache on the claims you send, so a changed name, email or role mints a new one.
Refresh
Fetch a new token a minute before expiresAt, or whenever the chat API answers UNAUTHENTICATED, then retry the call once. That second case should skip your cache and mint a new token.
Storage
Keep it in memory. It is a credential: do not log it, put it in a URL or store it longer than it lives.
Sign-out
Drop the token, in the browser and in your cache, when the user signs out of your product. It cannot be revoked on its own, which is why it lives an hour at most.

#Sync your users in bulk (optional)

Each token you mint also updates what Revvly knows about that user. To have everyone in Support, End users before they first open the chat, or to update attributes without waiting for the next sign-in, send up to 500 users per call to upsertSupportUsers with the same secret key. It takes the same fields as the token request, without ttlSeconds, matches users by externalUserId, and never mints a token.

mutation UpsertSupportUsers($users: [SupportUserInput!]!) {
  upsertSupportUsers(users: $users) {
    created
    updated
    failed
    results { index externalUserId status code message organizationLinked contactLinked }
  }
}

{
  "users": [
    {
      "externalUserId": "user_8412",
      "name": "Ana Silva",
      "email": "[email protected]",
      "role": "founder",
      "organizationExternalId": "acct_123",
      "attributes": [{ "key": "cohort", "value": "Spring 2026" }]
    },
    { "externalUserId": "user_9001", "name": "Ben Ode", "role": "investor" },
    { "externalUserId": "", "name": "No id" }
  ]
}
Response
{
  "data": {
    "upsertSupportUsers": {
      "created": 2,
      "updated": 0,
      "failed": 1,
      "results": [
        { "index": 0, "externalUserId": "user_8412", "status": "CREATED", "code": null,
          "message": null, "organizationLinked": true, "contactLinked": true },
        { "index": 1, "externalUserId": "user_9001", "status": "CREATED", "code": null,
          "message": null, "organizationLinked": null, "contactLinked": false },
        { "index": 2, "externalUserId": "", "status": "FAILED", "code": "BAD_REQUEST",
          "message": "externalUserId is required",
          "organizationLinked": null, "contactLinked": null }
      ]
    }
  }
}
Per user
Each user succeeds or fails on its own, and results lists them in the order you sent them. A failed one carries BAD_REQUEST (an invalid field) or CONFLICT (a user Revvly anonymised), and a message saying what to fix. The same id twice in one call fails the second.
Links
organizationLinked: false means no client in Revvly has that externalId yet. contactLinked says whether the user was matched to that client’s contact by email.
Limits
500 users per call; more is refused with BAD_REQUEST. Each call counts once against the key’s rate limit, so a nightly sync of 50,000 users is 100 calls.

#Revoke and rotate

Revvly checks the minting key on every chat request, not only at mint. Revoke a secret key (Org Settings, Support, Secret keys) and every token it minted stops working on the next request, without waiting for them to expire. Rotating a key gives you a new one and keeps the old one working for its grace period, so you can deploy the new secret before the old one stops.

#Errors

UNAUTHENTICATED
The secret key is missing, wrong, revoked or expired.
FORBIDDEN
The key is not a support secret key, or Revvly Support is not enabled for your workspace.
BAD_REQUEST
A claim is invalid: no externalUserId, a value too long or with control characters, an invalid email, or a ttlSeconds that is not a positive whole number.
RATE_LIMITED
Over the key’s rate limit: 50 requests a second and 3,000 a minute by default, across all your users. Wait for the Retry-After seconds. If tokens are already reused and you still hit it, raise the limits under Settings, Support, Secret keys, Limits (up to 500 a second and 30,000 a minute), where you can also see the last 24 hours of traffic.

#Verify a token on your side (optional)

You only need this if your own services want to accept the same token. Tokens are RS256 JWTs; the public keys are published at https://api.revvly.ai/api/v1/support/jwks.json (cacheable for an hour, the header’s kid names the key).

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://api.revvly.ai/api/v1/support/jwks.json'));

export async function verifySupportToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ['RS256'],
    issuer: 'revvly-support',
    audience: process.env.REVVLY_TENANT_ID, // required: see below
  });
  return { userId: payload.sub, role: payload.role ?? null };
}
!Always check aud. The same keys sign tokens for every Revvly workspace, so a valid signature alone would also accept another workspace’s token for a user with the same id. Your tenant id is the aud of any token you mint: decode one once and pin the value in your configuration. Checking the signature also does not tell you the key was revoked since; only Revvly’s own endpoints check that.
Token payload
{
  "iss": "revvly-support",
  "aud": "<your tenant id>",
  "sub": "user_8412",
  "name": "Ana Silva",
  "email": "[email protected]",
  "role": "founder",
  "plan": "growth",
  "org": "acct_123",
  "jti": "5b0c…",
  "mk": "<id of the secret key that minted it>",
  "iat": 1791283327,
  "exp": 1791286927
}

#Try it in the sandbox

Where
  1. Support
  2. Inbox
  3. Sandbox

Needs no secret key and touches no real customer.

The sandbox is a practice copy of your Support setup. Pick a practice customer (a name, a role value, attributes, a plan, a company), chat as them through the same API your widget will use, and answer from the inbox next to it. Nothing reaches your real inbox, client pages, metrics or email, so you can watch the unread count, notifications and replies behave as a customer would see them before you write any code.

Related