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.
Recommended: customer session tokens
Section titled “Recommended: customer session tokens”Your server mints a short-lived token for the signed-in shopper, and the browser or app calls Charm directly with it.
- The shopper signs in to your storefront. Your server takes their Shopify customer id from that session, never from the request.
- Your server calls
POST /customers/{customer_id}/session-tokenswith the API key and{ "scopes": ["read", "write"] }, and returnstokento the page. - The page calls Charm with
Authorization: Bearer chrm_st_…. CORS is allowed for session tokens. - 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.
No server of your own
Section titled “No server of your own”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.
Alternative: proxy every call
Section titled “Alternative: proxy every call”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.
Which scopes
Section titled “Which scopes”Create the key in Settings → API with the read-only preset. For a storefront that only displays loyalty state that is everything you need:
| Scope | Used for |
|---|---|
shop:read | Points name and currency for labels |
customers:read | Balance, tier, referral code |
points:read | Points history |
program:read | Earning rules and tier thresholds |
redemptions:read | The 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.
The calls
Section titled “The calls”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=derewards: the catalog at this customer’s price (tier prices included). Every reward carriescan_redeem.state:redeemable,insufficient_points(withpoints_needed),tier_required(with the tier that unlocks it),not_started(withstarts_at) orcustomer_excluded. Show the button, the gap or the lock straight from it.history: the latest 10 rows, each with thetitlethe 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, ornullwhen 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.
Redeeming from the storefront
Section titled “Redeeming from the storefront”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}/redemptionsIdempotency-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.
What shoppers do for themselves
Section titled “What shoppers do for themselves”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.
| Action | Call | Reasons you may get |
|---|---|---|
| Set the birthday | POST /customers/{id}/birthday { month, day, year? } | change_locked, year_required, invalid_date |
| Add or edit a child | POST /customers/{id}/children { name, birthday, consent } | rule_disabled, consent_required, limit_reached, change_locked |
| Remove a child | DELETE /customers/{id}/children/{child_id} | |
| Add or edit a pet | POST /customers/{id}/pets { name, birthday, species? } | rule_disabled, limit_reached, change_locked |
| Remove a pet | DELETE /customers/{id}/pets/{pet_id} | |
| Claim a way to earn | POST /customers/{id}/claims { rule_id } | already_claimed, consent_required, not_birthday_month, join_required |
| Pull a tier gift | POST /customers/{id}/tier-gifts { gift_id } | gift_unavailable, tiers_disabled |
| Join the program | POST /customers/{id}/enroll | see 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.
Referrals on a headless store
Section titled “Referrals on a headless store”A friend lands on your storefront through a member’s link (?ref=CODE).
From your server, claim the referral:
POST /referrals/claimIdempotency-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.
Keep it fast
Section titled “Keep it fast”- 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.redeemedandtier.changed, and drop the cached entry forpayload.customer_idwhen a delivery arrives. - Cache
/program,/tiersand/rewardsfor longer. They change only when you edit the program in the Charm admin.
Signups
Section titled “Signups”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
writesession token or from your server:POST /customers/{customer_id}/enrollIdempotency-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: falsefor someone who already joined. The key needscustomers:write.