Authentication
The Headless API has two kinds of endpoints:
| Kind | Examples | Auth |
|---|---|---|
| Public (guest) | /programs, /tiers, /shop-config, /loyalty-page/structure | None. Identify the shop with the shop query parameter (acme.myshopify.com; d is accepted as an alias). Calls from your server can be signed for a higher rate limit. |
| Customer | /customer, /customer/rewards, /customer/redeem, ... | Authorization: Bearer <customer JWT> |
The customer is always taken from the JWT, never from a query parameter or the request body.
Token flow
- The shopper logs in on your storefront (for Hydrogen: Customer Account API).
- A server-side route on your storefront (for example
/api/joy-token) signs an HMAC with your shop's headless secret and callsPOST /auth/token. - Joy returns a customer JWT. Your route hands only the JWT to the browser.
- The browser calls
/customer/*withAuthorization: Bearer <token>. - Shortly before the token expires (for example 1–2 minutes before
expiresInelapses), or on any401, the browser calls your token route again for a fresh token. On a401, retry the failed request once.
@joy-loyalty/hydrogen and @joy-loyalty/weaverse-hydrogen ship this token route ready to use; the @joy-loyalty/sdk client calls your onTokenExpired callback on a 401 and retries the request.
Headless secret
In the Joy admin, go to Settings > Manage keys > Headless secret and click Generate headless secret. The Headless API accepts only this secret — the REST API Secret key on the same page does not work for headless.
Revoke and regenerate headless secret rotates it: the old secret stops working immediately and existing customer tokens are invalidated. Web sign-in through the hmac grant fails until you update your server with the new secret. The mobile shopify_customer_token grant does not use the secret, so it keeps working.
The hmac grant needs a headless secret. The mobile grant does not: if the shop has none yet, Joy creates one on the first mobile sign-in, so you may find a secret already set when you open this page.
The headless secret is server-only. Anyone who holds it can mint tokens for any customer of your shop.
- Read it only in server code (Oxygen worker, loaders, resource routes).
- Never prefix the variable with
PUBLIC_orVITE_— those are shipped to the browser. - Never return it from a loader or import code that reads it into a client bundle.
Mint a token: POST /auth/token
hmac grant (web / Hydrogen / Weaverse)
Sign on your server:
hmac = HEX( HMAC_SHA256( "{customerId}-{lowercased email}-{ts}", headlessSecret ) )customerId— the numeric Shopify customer ID ("6543210987654"), not thegid://shopify/Customer/6543210987654GID. Strip the prefix if your session holds a GID.email— lowercase it for the signature only. Send the email in the body as-is.ts— Unix time in seconds (a number or a numeric string). Grants more than 300 seconds off the server clock are rejected.
The customer must already exist in Joy, and the email must match the customer's email in Joy (case-insensitive). Otherwise /auth/token answers 401.
import crypto from 'node:crypto';
const customerId = '6543210987654';
const email = '[email protected]';
const ts = Math.floor(Date.now() / 1000);
const hmac = crypto
.createHmac('sha256', process.env.JOY_API_SECRET) // your headless secret, server-only
.update(`${customerId}-${email.toLowerCase()}-${ts}`)
.digest('hex');curl -X POST "https://api.joy.so/app/api/v1/headless/auth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "hmac",
"shopDomain": "acme.myshopify.com",
"customerId": "6543210987654",
"email": "[email protected]",
"ts": 1753939674,
"hmac": "9f1c2b...e7"
}'shopify_customer_token grant (mobile / native)
The client sends a Shopify Customer Account API access token. Joy verifies it with Shopify and issues the same JWT, so the client never holds the headless secret.
{
"grant_type": "shopify_customer_token",
"shopDomain": "acme.myshopify.com",
"shopifyToken": "shcat_eyJ..."
}Response
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 3600
},
"timestamp": "2026-07-31T07:27:54.123Z"
}Treat the token as an opaque bearer string — do not parse or verify it on the client. It lives 60 minutes (expiresIn is in seconds) and there is no refresh token: re-mint shortly before it expires, or when a call returns 401. Do not store it longer than its lifetime.
Call customer endpoints
curl "https://api.joy.so/app/api/v1/headless/customer" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."A 401 on any /customer/* route means the token is expired, logged out, or revoked, or the customer no longer exists. Mint a new token through your token route and retry once.
Server-signed requests
Public routes are rate-limited per client IP (300 requests/min). On Oxygen and other serverless hosts, all of your server-side loaders share one egress IP, so a busy store can throttle itself. Sign public calls made from your server and Joy rate-limits them per shop instead (3000 requests/min).
Send two headers:
| Header | Value |
|---|---|
x-joy-server-ts | Unix time in seconds, as digits. Must be within 300 seconds of Joy's clock. |
x-joy-server-signature | HEX( HMAC_SHA256( "joy-headless-server-v1:{lowercased shop domain}:{ts}", headlessSecret ) ) |
The secret is the same headless secret that signs the hmac grant. A missing or invalid signature is not an error: the request is processed and rate-limited per IP.
import crypto from 'node:crypto';
function joyServerHeaders(shopDomain, headlessSecret) {
const ts = String(Math.floor(Date.now() / 1000));
const signature = crypto
.createHmac('sha256', headlessSecret)
.update(`joy-headless-server-v1:${shopDomain.trim().toLowerCase()}:${ts}`)
.digest('hex');
return { 'x-joy-server-ts': ts, 'x-joy-server-signature': signature };
}
const res = await fetch(
'https://api.joy.so/app/api/v1/headless/programs?shop=acme.myshopify.com',
{ headers: joyServerHeaders('acme.myshopify.com', process.env.JOY_API_SECRET) },
);Compute these headers on the server only. Never send them from the browser — that would expose your headless secret.
Log out and revocation
POST /customer/auth/logoutrevokes only the token used for the call — this device only. The customer's other devices stay logged in. After the call, that token gets401everywhere. The response is{ "revoked": true }. If a token cannot be revoked individually, the response isrevoked: falseand the token simply expires within 60 minutes.- Excluding a customer from the loyalty program revokes all of their sessions on every device, and
POST /auth/tokenthen answers403 EXCLUDEDfor that customer.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | A required field is missing or the request is malformed. |
| 400 | UNSUPPORTED_GRANT_TYPE | /auth/token: grant_type is not hmac or shopify_customer_token. |
| 401 | UNAUTHORIZED | /auth/token: identity proof invalid or expired, shop not found, customer not found in Joy, or the email does not match the customer's Joy email. /customer/*: missing, invalid, expired, or revoked token — re-mint and retry. |
| 403 | EXCLUDED | The customer is excluded from the loyalty program; no token is minted. Do not retry. |
| 403 | PLAN_NOT_ELIGIBLE | The shop is not on the Joy Ultimate plan. Returned by every Headless API endpoint. |
| 404 | NOT_FOUND | Unknown route. |
| 405 | METHOD_NOT_ALLOWED | The route exists but not for this HTTP method. |
| 429 | RATE_LIMITED | A rate limit was hit (see Rate limits). Wait for the Retry-After header (seconds), then retry. |
| 500 | INTERNAL_ERROR | Unexpected server failure. Safe to retry with backoff. |
| 503 | SERVICE_UNAVAILABLE | /auth/token: the token could not be issued. Retry. |
| 503 | RATE_LIMITER_UNAVAILABLE | /auth/token only: the token rate limiter is temporarily unavailable. Retry after Retry-After seconds. |
Endpoint-specific codes are listed in the API references.