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
- Relay generates a per-product Relay Secret (
rlys_…). You store it on your server asRELAY_SECRET. - On each page load, your server — which already knows the authenticated shop — computes an HMAC signature over the shop domain and a timestamp.
- Your page exposes that signature to the embed/widget as
window.RelayEmbed.identity. - 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThe 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.comdomain (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:
| Step | Value |
|---|---|
| Message | v1:{shop}:{ts} |
| Key | your RELAY_SECRET |
| Hash | SHA-256 |
| Output | lowercase 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_SECRETand goes in the samewindow.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. subdecides the contact.sub: session.shopkeeps 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_SECRETrotation and the troubleshooting messages below are unchanged.
Troubleshooting
Signature verification failed— the message or secret don't match. Check the exact formatv1:{shop}:{ts}and thatRELAY_SECRETmatches the admin.Signature timestamp is outside the allowed window—tsmust 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 embed —
window.RelayEmbed.identityis missing or malformed; the embed still shows public surfaces (like changelog) but can't authenticate.