Relay docs
Use casesShopify apps

Identity Verification

Prove which shop is using your app by signing a short-lived identity with your Relay Secret.

Relay needs to know which Shopify shop is loading the embed or widget. Instead of handing Relay your Shopify app secret, your own server signs each shop's identity with a Relay Secret that Relay issues per product. Relay verifies the signature with the same secret. Your Shopify client secret never leaves your infrastructure.

This page documents the signed-shop format Shopify apps have always used ({ shop, ts, signature }). It keeps working unchanged. Relay also accepts the generic signed identity token with the same Relay Secret — use it when you want a per-staff identity (sub = the staff user, organization.externalId = the shop domain) instead of one identity per shop. Both resolve to the same store.

How it works

  1. Relay generates a per-product Relay Secret (rlys_…). You store it on your server as RELAY_SECRET.
  2. On each page load, your server — which already knows the authenticated shop — computes an HMAC signature over the shop domain and a timestamp.
  3. Your page exposes that signature to the embed/widget as window.RelayEmbed.identity.
  4. Relay verifies the signature and issues a short-lived session.

The signature is only valid for 5 minutes, so sign it fresh on every page load. The embed exchanges it for a longer-lived Relay session immediately on mount.

Get your Relay Secret

In the Relay admin, open Settings → Products → your product and, in the Identity section, click Generate next to Relay Secret. Copy the value 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 derived signature is safe to put on the page.

The signature

The canonical message is:

v1:{shop}:{ts}
  • shop — the *.myshopify.com domain (e.g. acme.myshopify.com)
  • ts — current Unix time in seconds
signature = hex( HMAC_SHA256(RELAY_SECRET, "v1:{shop}:{ts}") )

Expose the result as a global the embed reads:

<script>
  window.RelayEmbed = {
    identity: { shop: "acme.myshopify.com", ts: 1781190000, signature: "a1b2c3…" },
  }
</script>

React Router / Remix (Shopify app template)

If you use the Shopify React Router app template, sign the identity right in your app route loader — no extra API endpoint needed. The loader already authenticates the shop, so session.shop is verified.

// app/routes/app.tsx  (your authenticated app layout route)
import { createHmac } from "node:crypto"
import type { LoaderFunctionArgs } from "react-router"
import { authenticate } from "../shopify.server"

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { session } = await authenticate.admin(request)

  const ts = Math.floor(Date.now() / 1000)
  const signature = createHmac("sha256", process.env.RELAY_SECRET!)
    .update(`v1:${session.shop}:${ts}`)
    .digest("hex")

  return {
    relay: {
      embedToken: process.env.RELAY_EMBED_TOKEN, // public
      identity: { shop: session.shop, ts, signature },
    },
  }
}

Then render the identity into the page before embed.js runs:

import { useLoaderData } from "react-router"

export default function App() {
  const { relay } = useLoaderData<typeof loader>()
  return (
    <>
      <script
        // biome-ignore lint: trusted, server-generated values
        dangerouslySetInnerHTML={{
          __html: `window.RelayEmbed = ${JSON.stringify({ identity: relay.identity })}`,
        }}
      />
      <script src="https://app.superrelay.ai/embed/embed.js" data-token={relay.embedToken} />
      <div id="relay-helpdesk" />
      <div id="relay-feature-requests" />
      {/* …your app… */}
    </>
  )
}

Because the loader runs on every navigation to the app, the signature is always fresh.

PHP

<?php
// $shop comes from your verified Shopify session.
$shop = $session->getShop();          // e.g. "acme.myshopify.com"
$ts = time();
$signature = hash_hmac('sha256', "v1:{$shop}:{$ts}", getenv('RELAY_SECRET'));

$identity = json_encode([
  'shop' => $shop,
  'ts' => $ts,
  'signature' => $signature,
]);
?>
<script>
  window.RelayEmbed = { identity: <?php echo $identity; ?> };
</script>
<script src="https://app.superrelay.ai/embed/embed.js" data-token="<?php echo getenv('RELAY_EMBED_TOKEN'); ?>"></script>
<div id="relay-helpdesk"></div>

Any language

The algorithm is plain HMAC-SHA256 — implement it with your standard library:

StepValue
Messagev1:{shop}:{ts}
Keyyour RELAY_SECRET
HashSHA-256
Outputlowercase hex

Set window.RelayEmbed.identity = { shop, ts, signature } before the Relay script loads, and you're done.

Rotating the secret

You can rotate the Relay Secret any time from the product's Identity section in Settings → Products (Rotate). Rotation invalidates the previous secret immediately, so update RELAY_SECRET on your server before the next request.

Per-staff identities (token format)

The signed-shop format identifies the shop: every staff member of one store shares one Relay contact. If you want each staff member to be their own contact (their own tickets, their own votes) while still belonging to the store, mint the generic token instead — the store becomes the organization:

const token = jwt.sign(
  {
    aud: "superrelay",
    sub: `${session.shop}:${staffUser.id}`,   // stable per staff member
    email: staffUser.email,
    name: staffUser.name,
    organization: { externalId: session.shop, name: shopName },
    iat: now,
    exp: now + 300,
  },
  process.env.RELAY_SECRET!,
  { algorithm: "HS256" }
)
window.RelayEmbed = { identity: { token } }

Whether the store's people see each other's tickets is the product's Share tickets across an organization setting (on by default — the behaviour Shopify apps have always had). Details on the token: Identity verification.

Moving from the signed-shop format to the token

You don't have to. If you do, here is what changes and what doesn't:

  • Same secret, same place. The token is signed with the same RELAY_SECRET and goes in the same window.RelayEmbed.identity. Relay accepts either shape on every request, so you can switch page by page — there is no cut-over.
  • The shop stays the organization. Put the shop domain in organization.externalId; Relay resolves it to the same company the signed-shop format created, so existing tickets and Partners links follow.
  • sub decides the contact. sub: session.shop keeps one contact per shop (identical to today). sub: "{shop}:{staffUserId}" gives one contact per staff member; the first token for a staff member creates a new contact under the store — earlier shop-level tickets stay on the shop contact and remain visible to staff through the organization setting.
  • Sharing is by organization only. Ticket visibility comes from the store's company, never from a shop-domain match on old records; run the store through the dashboard's Companies page if something looks missing.
  • Nothing else moves: the widget snippet, embed token, RELAY_SECRET rotation and the troubleshooting messages below are unchanged.

Troubleshooting

  • Signature verification failed — the message or secret don't match. Check the exact format v1:{shop}:{ts} and that RELAY_SECRET matches the admin.
  • Signature timestamp is outside the allowed windowts must be within 5 minutes of now, in seconds (not milliseconds). Sign on each page load.
  • Relay Secret is not configured — generate one in the Relay admin first.
  • Read-only embedwindow.RelayEmbed.identity is missing or malformed; the embed still shows public surfaces (like changelog) but can't authenticate.

On this page