Skip to content

Headless storefronts

The Charm storefront widget and Loyalty Hub run inside Shopify themes. A headless storefront has no theme, so it reads the same data through the Charm API instead. This page is the recipe: what to call, where the key lives, and how to keep it fast.

The one rule: the API key stays on your server

Section titled “The one rule: the API key stays on your server”

API keys read every customer and can redeem on anyone’s behalf, so they never go into a browser, a frontend bundle, a mobile app or a theme. Responses to API-key requests carry no CORS headers, on purpose. You have two ways to build on top of that rule.

Your server mints a short-lived token for the signed-in shopper, and the browser or app calls Charm directly with it.

  1. The shopper signs in to your storefront. Your server takes their Shopify customer id from that session, never from the request.
  2. Your server calls POST /customers/{customer_id}/session-tokens with the API key and { "scopes": ["read", "write"] }, and returns token to the page.
  3. The page calls Charm with Authorization: Bearer chrm_st_…. CORS is allowed for session tokens.
  4. On 401 token_expired, fetch a fresh token from your server and retry once.
// in the browser, after your server returned `token`
const res = await fetch(
`https://charm.appfleece.app/api/v1/customers/${customerId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);

A token acts for its own customer only, can never do more than the key that minted it, and stops working the moment you revoke that key. Grant read alone for pages that only display, so a copied token cannot redeem. One minting call per sign-in (or per hour) is all your backend has to do.

Two kinds of store can get a session token without writing any backend.

Theme stores adding their own loyalty UI in Liquid and JavaScript fetch one from their own storefront. Shopify signs the request and names the signed-in shopper, so no key is involved:

const res = await fetch("/apps/charm/session-token"); // read only
// const res = await fetch("/apps/charm/session-token?scope=write");
const { token, customer_id, expires_at } = await res.json();

It answers 401 when nobody is signed in. The token is the same as a minted one: one hour, this shopper only.

Headless apps with no server (a Lovable or other browser-only storefront, a mobile app) sign shoppers in with Shopify’s Customer Account API. The app then trades the shopper’s Customer Account API access token for a Charm session token, straight from the browser or phone:

const res = await fetch("https://charm.appfleece.app/api/v1/session-tokens/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
shop: "your-store.myshopify.com",
customer_access_token: customerAccessToken,
scopes: ["read", "write"],
}),
});
const { token } = await res.json();

Charm asks Shopify who the access token belongs to and mints for that customer, so the id cannot be forged. Tokens from Shopify’s older Storefront API sign-in (customerAccessToken) are not accepted. The exchange allows 20 calls per minute per IP address: call it from the shopper’s device, once per sign-in or per hour, not from a shared server. A server holds an API key and uses POST /customers/{customer_id}/session-tokens instead.

Both routes mint through a key Charm keeps for your store, listed in Settings → API as Storefront session tokens (Charm). Revoking it signs every storefront token out at once; the next sign-in creates a fresh one. Both need API access to be enabled for the store.

If you prefer to keep Charm out of the browser entirely, add a small endpoint of your own (a Next.js route handler, a Hydrogen loader, a Supabase edge function) that checks the shopper is signed in, takes their Shopify id from the session, calls Charm with the key, and returns only what the page needs.

Either way, the Shopify customer id must come from your own sign-in. If your storefront signs customers in with Shopify’s Customer Account API, the id is in the session already. If you run your own accounts, store the Shopify customer id next to the user when the account is created.

Create the key in Settings → API with the read-only preset. For a storefront that only displays loyalty state that is everything you need:

ScopeUsed for
shop:readPoints name and currency for labels
customers:readBalance, tier, referral code
points:readPoints history
program:readEarning rules and tier thresholds
redemptions:readThe reward catalog

Add redemptions:write only if customers redeem rewards from your storefront (below). It mints real discount codes, so it carries the tightest limits.

All paths are under https://charm.appfleece.app/api/v1, with Authorization: Bearer chrm_live_... on every request.

Header and account area. One call gives you the balance, the tier and the referral code:

GET /customers/{customer_id}

{customer_id} is the Shopify customer id, numeric or as a GID. The response has points_balance, pending_points, tier.name, referral_code and next_tier: the tier the customer reaches next with the gap to it (gap.value in gap.unit, points or money in the shop currency), ready for a “120 points to Gold” line without any arithmetic on your side.

A whole loyalty page in one call. Add include and the same request also returns the rewards, the latest history, the program and the referral link:

GET /customers/{customer_id}?include=rewards,history,program,referral&locale=de
  • rewards: the catalog at this customer’s price (tier prices included). Every reward carries can_redeem.state: redeemable, insufficient_points (with points_needed), tier_required (with the tier that unlocks it), not_started (with starts_at) or customer_excluded. Show the button, the gap or the lock straight from it.
  • history: the latest 10 rows, each with the title the shopper reads, in their language.
  • program: earning rules, reward settings and tiers, for a “ways to earn” block.
  • referral: the customer’s code and share link, or null when referrals are off.

Ask only for what the page shows: each part costs a little, and each needs its read scope (a read session token has them all).

Longer history. GET /customers/{customer_id}/transactions pages through everything, with a cursor.

“Earn X points” on a product page. One call returns the same number the theme widget would show, with the shop’s tax handling, exclusions, campaigns and the customer’s tier applied:

GET /products/{product_id}/points?price=49.90&customer_id={customer_id}

Send the unit price in the shop currency (your storefront already has it from the Storefront API) and, if your program excludes collections or tags, the product’s collection_ids and tags. Charm never looks the product up in Shopify for this call, so it costs nothing against your store’s API budget. Leave customer_id out for visitors who are not signed in. A response with enabled: false names the reason, for example product_excluded.

If customers redeem in your storefront rather than at Shopify checkout, the page calls this with a write session token (or your server does, with the key):

POST /customers/{customer_id}/redemptions
Idempotency-Key: <uuid>
{ "reward_id": "..." }

The response carries discount_code. Apply it to the cart through the Shopify Storefront API (cartDiscountCodesUpdate) so the customer never has to type it. Every POST needs an idempotency key, so a retried request never mints a second code.

A free product into the cart. For a product reward, send the gift variant the shopper picked as variant_id. The response then carries line_attribute: add that variant to the cart with the line property (cartLinesAdd with attributes: [{ key, value }]) and apply discount_code. The discount is bound to that one line, exactly as the widget’s gift-to-cart does.

Everything the widget and the Loyalty Hub let a shopper do, a write session token can do for its own customer (or your server, with the key). Refusals answer 422 with a stable details.reason you can turn into your own message.

ActionCallReasons you may get
Set the birthdayPOST /customers/{id}/birthday { month, day, year? }change_locked, year_required, invalid_date
Add or edit a childPOST /customers/{id}/children { name, birthday, consent }rule_disabled, consent_required, limit_reached, change_locked
Remove a childDELETE /customers/{id}/children/{child_id}
Add or edit a petPOST /customers/{id}/pets { name, birthday, species? }rule_disabled, limit_reached, change_locked
Remove a petDELETE /customers/{id}/pets/{pet_id}
Claim a way to earnPOST /customers/{id}/claims { rule_id }already_claimed, consent_required, not_birthday_month, join_required
Pull a tier giftPOST /customers/{id}/tier-gifts { gift_id }gift_unavailable, tiers_disabled
Join the programPOST /customers/{id}/enrollsee Signups below

Claims follow the rules the merchant made claimable: newsletter and SMS signups are checked against the customer’s Shopify marketing consent (send marketing_consent: true to subscribe them, and phone for SMS), the birthday reward waits for the birthday month, and the other claimable rules pay once per customer.

A friend lands on your storefront through a member’s link (?ref=CODE). From your server, claim the referral:

POST /referrals/claim
Idempotency-Key: <uuid>
{ "referral_code": "CODE", "customer_id": "<friend's id, if they have an account>" }

The answer tells you what to show the friend (discount: the code, its value and minimum; friend_rewards: points or a gift on their first order) and gives you cart_attribute. Set it on the Storefront API cart (cartAttributesUpdate) and the order is attributed to the member even if the friend never types the code. With customer_id, the referral is attributed right away, through the same checks as the widget: no self-referral, no returning customers, one referral per friend.

When a member shares their link from your app or site, record it so the merchant sees referral reach:

POST /customers/{customer_id}/referral-shares
{ "channel": "whatsapp" }

The member’s total appears in GET /customers/{id}?include=referral as shares.

  • Session tokens are limited per customer, so each shopper gets their own read budget (10 per second, 600 per minute) and a busy page never throttles the rest of the store. Reuse one token for the whole visit instead of minting per request.
  • Proxying instead? Cache the customer read on your server for a minute or so, keyed by customer id: all proxied traffic shares the key’s budget.
  • Invalidate on change instead of guessing. Subscribe a webhook to points.earned, points.adjusted, reward.redeemed and tier.changed, and drop the cached entry for payload.customer_id when a delivery arrives.
  • Cache /program, /tiers and /rewards for longer. They change only when you edit the program in the Charm admin.

How a customer becomes a member depends on one setting in Charm:

  • No join gate (the default): every Shopify customer is a member. Charm enrols them when the Shopify account is created or the first order lands, and pays the welcome bonus on the trigger your signup rule names. A headless storefront that creates customers in Shopify needs no extra call.

  • Join gate on: joining is an explicit action. Your storefront’s “Join” button calls, with a write session token or from your server:

    POST /customers/{customer_id}/enroll
    Idempotency-Key: <uuid>
    { "locale": "en" }

    It runs the same path as the widget’s Join button, credits the welcome bonus at that moment and answers changed: false for someone who already joined. The key needs customers:write.