Relay docs
Implementation

Identity verification

Tell Relay who your signed-in user is by signing a short-lived token with your Relay Secret.

Chat needs no identity. The helpdesk (tickets), feature requests (voting, commenting) and per-user data do: Relay has to know that the person in the widget really is customer 123 of your product before it shows them their tickets. Rather than a login inside the widget, your own server — which already knows who is signed in — signs a short-lived identity token with a Relay Secret, and Relay verifies it.

How it works

  1. Relay issues a Relay Secret per product (rlys_…). You store it on your server as RELAY_SECRET. It never goes to the browser.
  2. On each page load, your server mints a JWT for the signed-in user, signed with RELAY_SECRET (HS256).
  3. Your page exposes it as window.RelayEmbed.identity before the Relay script loads.
  4. The widget (or embed) sends it to Relay, Relay verifies the signature and claims, resolves the user to a contact, and issues a session.

Tokens are short-lived (5 minutes is plenty; 24 hours is the maximum), so mint one fresh on every page load. The widget exchanges it for a longer-lived Relay session immediately.

Get your Relay Secret

Open Settings → Products → your product, and in the Identity section click Generate next to Relay Secret. Copy it into your server environment:

RELAY_SECRET=rlys_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The Relay Secret is server-only. Never expose it to the browser, commit it, or send it to Relay. Only the signed token goes on the page.

The token

A standard HS256 JWT signed with your Relay Secret:

// header
{ "alg": "HS256", "typ": "JWT" }

// payload
{
  "aud": "superrelay",
  "sub": "customer-123",
  "email": "[email protected]",
  "name": "Ada Lovelace",
  "organization": { "externalId": "acct_456", "name": "Acme" },
  "iat": 1781190000,
  "exp": 1781190300
}
ClaimRequiredMeaning
audyesExactly "superrelay".
subyesThe user's stable id in your system. This is what Relay keys the contact on — never reuse it for another person.
emailnoLets Relay attach the identity to a contact you already have with that email, and shows the email to agents.
namenoDisplay name; fills in a contact that has none.
organizationno{ externalId, name? } — the account/company/store the user belongs to. Relay resolves it to a company (creating one on first sight, keyed by externalId) and links the contact. Everyone with the same organization can share the same helpdesk, if you turn that on.
iatyesIssued-at, unix seconds.
expyesExpiry, unix seconds. At most 24 h after iat.
issnoFree-form issuer string; stored, not interpreted.

Put it on the page before the Relay script:

<script>
  window.RelayEmbed = { identity: { token: "eyJhbGciOiJIUzI1NiIs…" } }
</script>
<script src="https://app.superrelay.ai/widget/widget.js" data-relay-token="YOUR_WIDGET_TOKEN" defer></script>

Node.js / Express

import jwt from "jsonwebtoken"

app.get("/app", (req, res) => {
  const user = req.user // your session
  const now = Math.floor(Date.now() / 1000)
  const token = jwt.sign(
    {
      aud: "superrelay",
      sub: user.id,
      email: user.email,
      name: user.name,
      organization: { externalId: user.accountId, name: user.accountName },
      iat: now,
      exp: now + 300,
    },
    process.env.RELAY_SECRET!,
    { algorithm: "HS256" }
  )
  res.render("app", { relayIdentity: token })
})
<script>
  window.RelayEmbed = { identity: { token: "<%= relayIdentity %>" } }
</script>

Next.js (App Router)

// app/layout.tsx
import { SignJWT } from "jose"
import { getSession } from "@/lib/auth"

async function relayIdentity() {
  const user = await getSession()
  if (!user) return null
  return new SignJWT({
    aud: "superrelay",
    sub: user.id,
    email: user.email,
    name: user.name,
    organization: { externalId: user.orgId, name: user.orgName },
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("5m")
    .sign(new TextEncoder().encode(process.env.RELAY_SECRET!))
}

export default async function RootLayout({ children }) {
  const token = await relayIdentity()
  return (
    <html>
      <body>
        {token ? (
          <script
            dangerouslySetInnerHTML={{
              __html: `window.RelayEmbed=${JSON.stringify({ identity: { token } })}`,
            }}
          />
        ) : null}
        {children}
      </body>
    </html>
  )
}

PHP

<?php
use Firebase\JWT\JWT;

$now = time();
$token = JWT::encode([
  'aud' => 'superrelay',
  'sub' => $user->id,
  'email' => $user->email,
  'name' => $user->name,
  'organization' => ['externalId' => $user->account_id, 'name' => $user->account_name],
  'iat' => $now,
  'exp' => $now + 300,
], getenv('RELAY_SECRET'), 'HS256');
?>
<script>
  window.RelayEmbed = { identity: { token: <?php echo json_encode($token); ?> } };
</script>

Any language

Any JWT library that does HS256 works. Without one: base64url-encode the header and payload JSON, join them with ., HMAC-SHA256 that string with RELAY_SECRET, base64url-encode the digest, and append it with another ..

What the identity unlocks

  • Widget helpdesk — the customer sees and raises tickets. Without a verified identity the helpdesk tab signs in with an error; nothing is shown.
  • Embed (Embed) — the helpdesk surface, and creating, voting and commenting on feature requests.
  • Contact resolution — Relay maps sub to a contact once and keeps using it; email only helps the first time. Agents see the verified identity on the contact.
  • Organizations — with organization, the contact is linked to a company. In Settings → Products → your product → Identity, Share tickets across an organization decides whether that company's people see each other's tickets (on by default) or only their own.

Rotating the secret

Settings → Products → your product → Identity → Rotate. Rotation invalidates the previous secret immediately; update RELAY_SECRET on your server first, then rotate.

Troubleshooting

  • Signature verification failed — the token was signed with a different secret, or with an algorithm other than HS256. Check RELAY_SECRET.
  • aud must be "superrelay" / sub is required — the payload is missing a required claim.
  • Identity token has expired / Token lifetime exceeds 24 hoursiat/exp are in seconds, and exp - iat must be ≤ 86400. Mint on each page load.
  • Relay Secret is not configured — generate one in the product's Identity section first.
  • Helpdesk tab says identity is missingwindow.RelayEmbed.identity was set after the widget loaded, or is malformed. Set it before the script tag.

Shopify apps

Shopify apps use the same Relay Secret. The signed-shop format ({ shop, ts, signature }, an HMAC of v1:{shop}:{ts}) that existing apps ship keeps working unchanged; a token as above with the shop as organization gives you per-staff identities on top. See Shopify identity verification.

On this page