Developer Docs

Headless SDK

Add the full Anchor loyalty widget to any headless storefront: Hydrogen, Next.js, Gatsby, Vue, or custom builds.

Full widget experience
Lightweight footprint
Framework-agnostic
The Headless SDK is available on the Pro plan ($39.99/mo)

#Implement a loyalty program in Hydrogen: 30-minute quickstart

Adding a loyalty program to a Shopify Hydrogen storefront takes about 30 minutes with an API-first provider: roughly 10 minutes for environment variables and a signed-token route, 15 minutes for the widget component and route wiring, and the rest for testing the logged-in and logged-out states.

The reason it is a 30-minute job rather than a multi-day one comes down to three architecture decisions. Get these right and the implementation is wiring; get them wrong and you end up rebuilding a loyalty UI from scratch.

1. Rendering: SDK component, not theme injection

Most Shopify loyalty apps deliver their customer-facing UI through the theme layer with script tags, app embeds, or theme app extensions. A Hydrogen storefront has no theme, so none of those injection points exist. Points keep accruing from order webhooks while every customer-facing surface silently disappears. On headless you need a widget you mount from your own component tree, or a JSON API plus a UI you build and maintain yourself.

2. Identity: resolve and sign server-side

Never send a raw customer ID from the browser to a loyalty API. Resolve the logged-in customer inside a Hydrogen loader with context.customerAccount.isLoggedIn() and a customer query, then sign the ID with a secret only your server holds. A proxy route that accepts an unsigned customer ID or email address is the most common security hole in headless loyalty implementations, and it leaks balances by email to anyone who guesses the endpoint. The full code is in Security, Customer Token.

3. Earning: order webhooks, not front-end events

Point accrual has to run server-side off order webhooks. Anything that depends on a pixel firing or a theme event will drift the moment a customer checks out in a different context, and drifted balances are far more expensive to fix than a missing widget.

The four steps

  1. 1
    Set your environment variables (5 min)

    Add PUBLIC_ANCHOR_LOYALTY_API_KEY and ANCHOR_SIGNING_SECRET to your Hydrogen environment. The API key is public and travels to the browser; the signing secret never leaves the server.

  2. 2
    Add a token route that signs the customer token (5 min)

    A small resource route (for example /api/loyalty-token) checks the Customer Account session and returns HMAC-SHA256 over customerId.timestamp. Tokens are valid for 60 seconds, so the SDK calls this route itself through getCustomerToken whenever it needs a fresh one. Copy-paste route code is in Security.

  3. 3
    Mount the widget component (15 min)

    A small client component loads the SDK and calls AnchorLoyaltySDK.init() with the customer ID, a getCustomerToken function, and your locale. Render it once in your root layout, not per route. Full component in Quick Start, and the ID-extraction detail in Getting the Customer ID.

  4. 4
    Test both states before you ship (5 min)

    Logged out: the widget should show the guest prompt and no balance. Logged in: balance renders and a redemption returns a discount code. Then confirm a redemption attempt with a tampered or expired token returns 403. If it succeeds, your token is not actually being verified.

Evaluating providers rather than implementing? The three decisions above are also the three questions worth asking any loyalty vendor before you commit: does the storefront API have documented authentication, is there an SDK or only a JSON API, and does earning run server-side. Our evaluation framework scores those alongside cost and migration, and the headless overview covers what breaks when they are missing.

#Overview

The Anchor Loyalty Headless SDK allows you to add the loyalty widget to any storefront that doesn't use Shopify's Online Store channel: including Hydrogen, Next.js, Gatsby, Vue, or any custom frontend.

The SDK renders the exact same widget as the embedded version: same UI, same features, lightweight footprint. No custom UI development required.

How it works

The SDK file is served from anchorloyalty.app/api/sdk.js and all API calls go to anchorloyalty.app/api/* with your API key in the X-Anchor-Api-Key header. For a logged-in customer the SDK also sends a short-lived signed customer token in the X-Anchor-Customer-Token header, which your backend creates. CORS is enabled for any storefront domain.

  • Anchor Loyalty app installed on your Shopify store
  • PRO plan subscription
  • API key and signing secret generated from the Anchor Loyalty dashboard
  • A backend endpoint (server route or serverless function) that signs customer tokens for logged-in customers

#Authentication

Generating a key gives you two credentials, shown together and only once: the public API key (ak_...), passed as apiKey to the SDK, and the private signing secret (sk_...), which stays on your server. Never put the signing secret in browser code or in a VITE_, NEXT_PUBLIC_ or PUBLIC_ variable.

  1. 1Generate the credentials, on the Anchor Loyalty app home page, in the Headless SDK section. Copy both values; neither is shown again.
  2. 2Lost the signing secret?, click "Regenerate Key". You get a new API key and a new signing secret; the old API key stops working, so update your storefront too.
  3. 3Include apiKey in your SDK initialization, call (see Quick Start below).
  4. 4Revoke, the key from the dashboard if it is compromised. A new key can be generated immediately.
  5. 5Sign customer tokens on your backend, with HMAC-SHA256 and your signing secret, and hand them to the SDK through getCustomerToken. Required for every request about a logged-in customer (reads and redemptions). See Security, Customer Token.

#Security, Customer Token

The API key authenticates your store, but it's visible in your page source. So every request for a customer's data (points, codes, redemptions) must also carry a customer token, an HMAC-signed proof that your backend verified the customer. Without it, /api/customer, /api/codes and /api/redeem return 403 for a logged-in customer. Guests need no token.

Format{customerId}.{unixSeconds}.{hmac}, where hmac is the hex HMAC-SHA256 of {customerId}.{unixSeconds} using your signing secret, and customerId is the numeric Shopify customer ID.
LifetimeA token is accepted for 60 seconds after its timestamp (and up to 30 seconds ahead, for clock skew). Use getCustomerToken so the SDK fetches a fresh one when needed.
SecurityYour backend must sign a token only for the customer of its own authenticated session (for example the Customer Account API session). Never sign a customer ID sent by the browser.

#How it works

  1. 1Customer logs in, on your storefront.
  2. 2Your backend exposes a token route, that checks the session and returns a token signed with HMAC-SHA256 and your signing secret (never send the signing secret to the browser).
  3. 3The SDK calls that route, through the getCustomerToken function you pass to init(), and sends the token in the X-Anchor-Customer-Token header.
  4. 4Anchor verifies the signature, and the timestamp before processing any request.

#Signing the token (backend)

Node.jsJS
import { createHmac } from 'node:crypto';

export function signCustomerToken(customerId, signingSecret) {
  const ts = Math.floor(Date.now() / 1000);
  const hmac = createHmac('sha256', signingSecret).update(`${customerId}.${ts}`).digest('hex');
  return `${customerId}.${ts}.${hmac}`;
}

On Hydrogen / Oxygen and other edge runtimes there is no node:crypto; use Web Crypto:

Hydrogen / Oxygen, edge runtimes (Web Crypto)JS
export async function signCustomerToken(customerId, signingSecret) {
  const ts = Math.floor(Date.now() / 1000);
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(signingSecret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${customerId}.${ts}`));
  const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
  return `${customerId}.${ts}.${hex}`;
}

#The token route

Expose an endpoint on your own backend, for example GET /api/loyalty-token, that checks the logged-in session and returns a freshly signed token as plain text. In Hydrogen that is a resource route:

app/routes/api.loyalty-token.tsx (Hydrogen)TSX
import { signCustomerToken } from '~/lib/loyalty-token'; // the Web Crypto helper above

export async function loader({ context }: LoaderFunctionArgs) {
  const isLoggedIn = await context.customerAccount.isLoggedIn();
  if (!isLoggedIn) {
    return new Response('', { status: 401 });
  }

  const { data } = await context.customerAccount.query(`
    query { customer { id } }
  `);
  const customerId = data.customer.id.replace('gid://shopify/Customer/', '');
  const token = await signCustomerToken(customerId, context.env.ANCHOR_SIGNING_SECRET);

  return new Response(token, { headers: { 'Cache-Control': 'no-store' } });
}

#Giving the token to the SDK

Pass a function that calls your route. The SDK calls it when it needs a token, not on every request:

init with getCustomerTokenJS
AnchorLoyaltySDK.init({
  apiKey: 'YOUR_API_KEY',
  customerId: '12345',
  getCustomerToken: () =>
    fetch('/api/loyalty-token', { credentials: 'include' }).then((r) => r.text()),
});

The SDK reuses a token for up to 45 seconds, asks getCustomerToken for a new one before it expires, and retries a request once with a fresh token if the server answers 403. You can also pass a ready token as customerToken (for example one rendered by your server into the page); it works until it is 60 seconds old.

#When is it required?

EndpointTokenNote
POST /api/redeemREQUIREDReturns 403 without valid token
GET /api/customerREQUIREDReturns 403 without valid token
GET /api/codesREQUIREDReturns 403 without valid token

#Quick Start

#HTML / Custom Storefront

Recommended, only API key needed:

index.html, recommendedHTML
<script src="https://anchorloyalty.app/api/sdk.js?key=YOUR_API_KEY"></script>
<script>
  AnchorLoyaltySDK.init({
    apiKey: 'YOUR_API_KEY',
    customerId: '12345', // Shopify Customer ID (numeric); null for guests
    getCustomerToken: () =>
      fetch('/api/loyalty-token', { credentials: 'include' }).then((r) => r.text()),
  });
</script>

When loading via ?key=, the SDK automatically resolves your store, no shop parameter needed. Your myshopify.com domain is never exposed in page source.

Alternative, with explicit shop domain:

index.html, alternativeHTML
<script src="https://anchorloyalty.app/api/sdk.js?shop=YOUR-STORE.myshopify.com"></script>
<script>
  AnchorLoyaltySDK.init({
    shop: 'YOUR-STORE.myshopify.com',
    apiKey: 'YOUR_API_KEY',
    customerId: '12345',
    getCustomerToken: () =>
      fetch('/api/loyalty-token', { credentials: 'include' }).then((r) => r.text()),
  });
</script>

#Hydrogen / React

Create a LoyaltyWidget component that loads the SDK and initializes it with the authenticated customer. Store the API key in PUBLIC_ANCHOR_LOYALTY_API_KEY; the signing secret is not public, keep it in a server-only variable and use it only inside the token route.

LoyaltyWidget.tsxTSX
import { useEffect } from 'react';
import { useCustomer } from '~/hooks/useCustomer';

const API_KEY = import.meta.env.PUBLIC_ANCHOR_LOYALTY_API_KEY;

export function LoyaltyWidget() {
  const customer = useCustomer();

  useEffect(() => {
    if (!API_KEY) {
      console.error('Missing PUBLIC_ANCHOR_LOYALTY_API_KEY env var');
      return;
    }
    const script = document.createElement('script');
    script.src = `https://anchorloyalty.app/api/sdk.js?key=${API_KEY}`;
    script.async = true;
    script.onload = () => {
      window.AnchorLoyaltySDK?.init({
        apiKey: API_KEY,
        customerId: customer?.id?.replace('gid://shopify/Customer/', '') || null,
        customerName: customer?.firstName || '',
        locale: 'en',
        // Resource route on your storefront that signs a token for the
        // logged-in customer (see "Security, Customer Token" above).
        getCustomerToken: () =>
          fetch('/api/loyalty-token', { credentials: 'include' }).then((r) => r.text()),
      });
    };
    document.body.appendChild(script);

    return () => {
      window.AnchorLoyaltySDK?.destroy();
      script.remove();
    };
  }, [customer]);

  return null;
}

#Next.js

For Next.js, use the built-in next/script component with the onReady callback. This ensures the SDK re-initializes on SPA route transitions:

LoyaltyWidget.tsxTSX
'use client';
import Script from 'next/script';

const API_KEY = process.env.NEXT_PUBLIC_ANCHOR_LOYALTY_API_KEY;

export function LoyaltyWidget({ customerId, customerName }: {
  customerId: string | null;
  customerName?: string;
}) {
  if (!API_KEY) {
    console.error('Missing NEXT_PUBLIC_ANCHOR_LOYALTY_API_KEY env var');
    return null;
  }
  return (
    <Script
      src={`https://anchorloyalty.app/api/sdk.js?key=${API_KEY}`}
      strategy="lazyOnload"
      onReady={() => {
        window.AnchorLoyaltySDK?.init({
          apiKey: API_KEY,
          customerId,
          customerName,
          // A Route Handler (app/api/loyalty-token/route.ts) that signs a
          // token for the logged-in customer with the Node.js helper above.
          getCustomerToken: () =>
            fetch('/api/loyalty-token', { credentials: 'include' }).then((r) => r.text()),
        });
      }}
    />
  );
}

Why next/script? It handles deduplication (loads only once across navigations), supports lazyOnload for zero performance impact, and onReady re-fires on every mount, perfect for SPA route changes.

#Getting the Customer ID

The customerId must be the Shopify numeric Customer ID (not the GID).

#From Hydrogen (Customer Account API)

Hydrogen loaderTSX
// In a Hydrogen loader:
const isLoggedIn = await context.customerAccount.isLoggedIn();
if (isLoggedIn) {
  const { data } = await context.customerAccount.query(`
    query { customer { id firstName lastName } }
  `);
  // data.customer.id = "gid://shopify/Customer/12345"
  const numericId = data.customer.id.replace('gid://shopify/Customer/', '');
  // numericId = "12345"
}

#From Storefront API

Storefront API QueryGraphQL
query {
  customer(customerAccessToken: "TOKEN") {
    id  # Returns "gid://shopify/Customer/12345"
  }
}

Extract the numeric part: id.split('/').pop() → "12345"

#From Custom Backend (Admin GraphQL API)

Admin GraphQL APIGraphQL
query CustomerByEmail($query: String!) {
  customers(first: 1, query: $query) {
    nodes { id }
  }
}

With variables { "query": "email:user@example.com" }. Requires the read_customers scope. The id is a GID; extract the numeric part with id.split('/').pop().

#Guest Users (Not Logged In)

Pass null or omit customerId:

Guest ModeJS
AnchorLoyaltySDK.init({
  apiKey: 'YOUR_API_KEY',
  customerId: null, // Guest mode, shows sign-in prompt
});

#All Parameters

ParameterTypeDefaultDescription
apiKeystringrequiredAPI key from your Anchor Loyalty dashboard
shopstringauto-resolvedYour *.myshopify.com domain. Auto-resolved from API key when SDK is loaded via ?key=
customerIdstringnullShopify Customer ID (numeric). Omit or pass null for guest mode
customerTokenstringnullSigned customer token (see Security, Customer Token). Valid for 60 seconds; prefer getCustomerToken so the SDK can refresh it.
getCustomerToken() => Promise<string> | stringnullReturns a fresh signed token from your backend. Called when the current token is about to expire or is rejected. Recommended for logged-in customers.
customerNamestring''Customer's first name for welcome message
localestring'en'Language code. 32 locales built-in (see below)
positionstring'bottom-right''bottom-right' or 'bottom-left'
primaryColorstring'#4A56E2'Primary brand color (hex)
backgroundColorstring'#FFFFFF'Widget background color
textColorstring'#1F2937'Widget text color
launcherStylestring'bubble''bubble', 'bubble-text', 'icon-only', 'pill', 'square'
launcherGradientstring'solid''solid', 'sunset', 'ocean', 'forest', 'candy', 'midnight'
launcherShadowstring'medium''none', 'subtle', 'medium', 'strong', 'glow'
launcherAnimationstring'pulse''none', 'pulse', 'bounce', 'shake', 'glow'
launcherTextTypestring'static''static' (shows 'Rewards') or 'dynamic' (shows points)
animationDelaynumber5Seconds before first animation
animationRepeatnumber30Seconds between repeats (0 = no repeat)
animateOnScrollbooleantrueAnimate when user scrolls 25% down
bannerImagestring''URL to hero banner image
bannerLinkstring''URL the banner links to
bannerLinkNewTabbooleanfalseOpen banner link in new tab
showRewardsbooleantrueShow rewards tab
showReferralbooleantrueShow referral tab
referralBaseUrlstringpage originAbsolute URL the referral share link points at, e.g. 'https://brand.com'. The code is appended as ?ref=CODE. Set it when the widget runs on a subdomain, a staging host or several country domains; the SDK must also be loaded on that page so the ref code is captured.
showGuestMessagebooleantrueShow sign-in prompt for guests
accountUrlstring'/account'"View Account" link destination
loginUrlstring'/account/login'"Sign In" link for guests
registerUrlstring'/account/register'"Create Account" link for guests
translationsobject{}Override any default translation key

#Locale Support

32 built-in locales, set locale to auto-select:

Locale exampleJS
AnchorLoyaltySDK.init({ apiKey: '...', locale: 'de' }); // Deutsch
32 Supported locales
arالعربية
csČeština
daDansk
deDeutsch
elΕλληνικά
enEnglish
esEspañol
fiSuomi
frFrançais
fr-CAFrançais (CA)
heעברית
hiहिन्दी
huMagyar
idBahasa Indonesia
itItaliano
ja日本語
ko한국어
msBahasa Melayu
nlNederlands
noNorsk
plPolski
pt-BRPortuguês (BR)
pt-PTPortuguês (PT)
roRomână
ruРусский
svSvenska
thไทย
trTürkçe
ukУкраїнська
viTiếng Việt
zh-CN简体中文
zh-TW繁體中文

RTL support: Arabic (ar) and Hebrew (he) locales automatically switch the widget layout to right-to-left, including text direction, icon positions, and animations.

Override individual keys:

Override specific keysJS
AnchorLoyaltySDK.init({
  apiKey: '...',
  locale: 'fr',
  translations: { title: 'Mon Programme Fidélité' },
});

#Methods

AnchorLoyaltySDK.init(options)

Initialize the widget. Call once after the script loads.

AnchorLoyaltySDK.destroy()

Remove the widget completely. Cleans up:

  • - Widget DOM element
  • - Injected CSS and JS
  • - All window globals (anchorLoyaltyConfig, anchorLoyaltyTranslations, anchorLoyalty)

Safe for SPA route transitions.

AnchorLoyaltySDK.update(newOptions)

Update options and re-initialize (e.g., after login/logout). Preserves custom translations.

Example: Update after login / logoutJS
// After user logs in:
AnchorLoyaltySDK.update({
  customerId: '12345',
  customerName: 'Jane',
});

// After logout:
AnchorLoyaltySDK.update({ customerId: null });

getCustomerToken passed to init() is kept across update() calls, including logout and the next login. A static customerToken is dropped when update() switches to a different customer without passing a new one.

#Architecture

Your Headless Storefront                    Anchor Loyalty API
(Hydrogen, Next.js, etc.)                   (anchorloyalty.app)
        |                                            |
        |  1. Load SDK                               |
        |  GET /api/sdk.js?key=ak_xxx                |
        |------------------------------------------->|
        |  <-- ~242 KB JS (~70 KB gzipped)           |
        |                                            |
        |  2. API calls (automatic)                  |
        |  GET /api/customer                         |
        |  Header: X-Anchor-Api-Key: ak_xxx          |
        |  Header: X-Anchor-Customer-Token: <token>  |
        |  Params: ?shop=...&customer_id=...         |
        |------------------------------------------->|
        |  <-- JSON (points, rewards, tier, etc.)    |
        |                                            |
        |  3. Redeem reward                          |
        |  POST /api/redeem                          |
        |  Header: X-Anchor-Api-Key: ak_xxx          |
        |  Header: X-Anchor-Customer-Token: <token>  |
        |------------------------------------------->|
        |  <-- JSON (discount code)                  |
Authentication
X-Anchor-Api-Key header, plus X-Anchor-Customer-Token for a logged-in customer (both sent automatically by the SDK)
Signing Secret
Used server-side only for customer token HMAC (never sent to browser)
CORS
Enabled for all origins
Caching
SDK file cached 1 hour (served stale for up to a day while it refreshes); API responses not cached
Rate limits
SDK file 300 req/min per IP and 300 req/min per shop. API, per customer: /api/customer 180, /api/codes 30, /api/redeem 5 req/min
Customer Token
HMAC-signed proof, valid 60 seconds, for every request about a logged-in customer

#Troubleshooting

IssueSolution
SDK returns 403Verify API key is correct and not revoked. Check PRO plan is active.
Widget doesn't appearCheck browser console for errors
"Customer not logged in"Verify customerId is the numeric Shopify ID, not the GID
Wrong languageSet locale parameter (e.g., 'de', 'fr', 'ja')
Widget overlaps UIAdjust position or use custom CSS
CORS errorsVerify you're loading SDK from anchorloyalty.app, not from a local file
403 "Invalid or expired customer token"The token is missing, older than 60 seconds, signed with the wrong secret, or signed for a different customer ID. Pass getCustomerToken so the SDK always sends a fresh token.
"API key required" on redemptionInclude the API key via X-Anchor-Api-Key header or api_key query param alongside the customer token.
401 "Signing secret not configured"The key was created before signing secrets existed. Click "Regenerate Key" in the dashboard: the new API key and signing secret are shown once. Store the signing secret in your backend environment variables and update the API key in your storefront.

Ready to add loyalty to your headless store?

Install Anchor, upgrade to Pro, and drop the SDK into your storefront. Full widget experience in minutes.