Public
Unauthenticated, cacheable storefront data (SSR-friendly)
Earning + spending programs (public catalog)
Feeds joy-ways-to-earn (earning) and the catalog half of joy-rewards-catalog (spending).
Filter to one kind; omit for both.
200Program list
trueShow properties
Program event key, e.g. place_order, signup, birthday, social_share.
"place_order""Place an order"Empty string when the merchant wrote none — never null. Text fields fall back to '' so a client can print them unguarded.
Points earned (earning programs).
Points required to redeem (spending programs).
Reward kind (spending programs).
Reward amount — currency or percent depending on rewardType.
How to read pointsCost (spending programs; null for earning). On dynamic it is the STEP — the customer chooses a multiple of it — so a card must read "from 500 points", not "500 points". On fixed it is the price. The full slider bounds live on AvailableReward; this field exists so a signed-out card renders the same wording as the signed-in one.
Where the customer goes to perform the action, resolved server-side per event. Earning programs only; always null for spending. Guaranteed http(s) — non-http schemes are dropped server-side. For the 3 social events that need it (share_facebook, share_twitter, follow_twitter) this is a ready-to-open share/follow intent URL (e.g. a facebook.com/sharer.php or twitter.com/intent link), not the merchant-entered profile URL itself — the other 10 social events, plus survey/custom-program/Google-review links and the merchant's own "Add URL on click" override, resolve to that destination unchanged.
"https://www.facebook.com/sharer.php?u=https%3A%2F%2Fyourshop.com"How a join_line program credits points — null for every other event, and always null on a spending program. follow_oa only pays out through POST /customer/social/line/auth-url's OAuth popup (lineUserId must be set first); join_line is claim-on-click via actionUrl / POST /customer/earn/social, same as every other social event. A missing/legacy value (predates LINE OAuth) reads as join_link.
Spend-rate programs earn points per this amount of shop currency (see ShopConfig.currency / currencyFormat), e.g. points=1 with rateMoney=1 means "1 point per $1". Null for flat-rate programs and for spending programs. This is the program's BASE rate — per-tier overrides are not reflected, as the public contract has no customer context.
1The fine print a shopper needs BEFORE spending points, rendered server-side: sale channel, which products it applies to, usable-from date, minimum order, expiry, usage limit. Always present — empty means "no restrictions", and earning programs never carry any.
["Online store only","Minimum order $50"]Whether the current customer has already completed this (non-repeatable) earning program. Only present on the personalised GET /customer/programs response — omitted entirely (the field is absent, not null) from the public, CDN-shared GET /programs response, and from every spending program. Repeatable earning events (place_order, write_review, submit_receipt, submit_form, fill_survey, milestone) always report false — there is no persistent "earned" state to track for those.
Design + approval settings, submit_form and fill_survey only — null for every other event, and always null on a spending program. autoApprove matters beyond display: POST /customer/earn for these two events already reports status: earned vs pending based on this exact flag, so without it here that switch is opaque to a headless client.
Show properties
Whether the shopper must attach an image with their submission.
When true, POST /customer/earn reports status: earned immediately; when false, status: pending until a merchant approves the submission in admin.
Whether a smaller reward is granted immediately, ahead of approval.
The instant reward amount, when instantRewardEnabled is true.
Design + reward settings, write_review only — null for every other event, and always null on a spending program.
Show properties
Where to send the shopper back to AFTER they finish writing the review — NOT a CTA (actionUrl is always null for write_review; this is a distinct field). Guaranteed either an absolute http(s) url or a root-relative path (/collections/all style) — non-http(s) schemes and protocol-relative (//host) values are dropped server-side. Null when the merchant left it blank — pick your own fallback (for example /collections/all).
Bonus points awarded on top of the base reward for this program.
google_maps_review only — null for every other event, and always null on a spending program. LIMITATION: the backend stores review state on the customer (customers/{id}.statusGoogleMapsReview), not per-program, so it assumes at most one active google_maps_review program per shop — every such program reports the SAME customer state. status/statusText are omitted entirely (not null-filled) on the public, CDN-shared GET /programs response, the same presence rule as isEarned; urlLocation is program-level and present on both.
Show properties
The Google Maps location the shopper reviews. Guaranteed http(s) — non-http schemes are dropped server-side.
The shopper's own review, if any. null means never submitted — the only one of the 4 states with no persistent record on the customer. rejected is the only state the shopper can act on again (resubmit); pending and approved are dead ends. Personalised path only — see the description above.
null when status is null: "never submitted" is a 3-step guided form, not a single sentence, and stays entirely client-owned UI. Personalised path only, same rule as status.
streak_interact_website only — null for every other event, and always null on a spending program. UNLIKE googleReview, the ENTIRE object (including totalDays, which is program config) is personalised — present only on GET /customer/programs, and null (not a partial object) on the public, CDN-shared GET /programs response. isEarned on this same event answers a DIFFERENT question ("has the highest milestone ever been reached") — this object answers "what day is the shopper on right now, and can they claim today".
Show properties
The streak day the shopper is currently on (0 before their first claim). Resets to 0 after a missed day or when returning to this program from a different one.
The highest streak milestone day configured on this program.
Whether calling POST /customer/earn for this program right now would award points. false covers two different terminal states for today: an ordinary same-day cooldown (already claimed, come back tomorrow) and a program that can no longer be completed at all (not enough days left before it ends) — message is the only field that tells them apart.
Non-null ONLY when the program can no longer be completed (not enough days remain before its endDate to reach totalDays) — every other state, including an ordinary same-day cooldown, has nothing extra to say and reports null.
birthday only — null for every other event, and always null on a spending program. Like streak, the ENTIRE object is personalised — present only on GET /customer/programs, and null (not a partial object) on the public, CDN-shared GET /programs response. Two reasons: the birthday-entry modal this field exists for always calls GET /customer/programs anyway (it also needs Customer.birthday), and rewardOptions for a VIP-tier-scoped program can only resolve against a customer's own tier, which the public path never has. KNOWN LIMITATION: rewardOptions: [] is reported both when the program has no reward picker at all (an older layout — a reward is still granted on save, just not through a picker) AND when the picker is on but the shopper's OWN tier has zero rewards configured for it — a client cannot distinguish the two from this field alone.
Show properties
The rewards this shopper may choose from.
Show properties
Send this value back in POST /customer/earn's context.birthdayRewards (see that endpoint).
"Get 10% off for your birthday"Present only for a free-gift reward; null for point/discount rewards.
Whether the shopper may pick exactly one reward (render radio buttons) or several (checkboxes). Independent of whether rewardOptions is empty — still reflects the merchant's configured mode even with nothing to pick.
Day/month display ORDER for the birthday-entry modal's date input — NOT whether a year is collected at all (see ShopConfig.isNewFormatDOB for that).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400
404
429
GET /app/api/v1/headless/programs HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","programType": "earning","key": "place_order","title": "Place an order","description": "string","iconUrl": "string","points": 1,"pointsCost": 1,"rewardType": "fixed_amount","rewardValue": 1,"redeemType": "fixed","actionUrl": "https://www.facebook.com/sharer.php?u=https%3A%2F%2Fyourshop.com","lineJoinType": "follow_oa","rateMoney": 1,"conditions": ["Online store only","Minimum order $50"],"isEarned": true,"form": {"headerImage": "string","successIconUrl": "string","requireImage": true,"autoApprove": true,"instantRewardEnabled": true,"instantRewardPoints": 1},"review": {"redirectUrl": "string","headerImage": "string","extraPoints": 1},"googleReview": {"urlLocation": "string","status": "pending","statusText": "string"},"streak": {"currentDay": 1,"totalDays": 1,"canClaimToday": true,"message": "string"},"birthday": {"rewardOptions": [{"id": "string","label": "Get 10% off for your birthday","imageUrl": "string"}],"rewardSelection": "single","dateFormat": "MM/DD/YYYY"}}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
A guest's own referral link, by email
Feeds joy-referral for a shopper with no JWT yet (not signed in) — they hand over an email, get their own referral link back. No referral logic is duplicated here.
Not CDN-cacheable, unlike the other Public-tagged endpoints above — this response carries the caller's own referral link/code, keyed by the email they submitted.
200Referral link generated (or already existed for this email). An unknown email still answers 200 with empty/zero values (no customer is created and existence is not disclosed).
trueResponse for POST /referral/generate-link.
Show properties
The guest's ready-to-share referral link.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — missing/malformed `email`. `REFERRAL_LINK_UNAVAILABLE` — the referral program isn't available for this shop, or this guest isn't eligible (e.g. access restricted, opt-in enrollment not joined).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"404
429
POST /app/api/v1/headless/referral/generate-link HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 34{"email": "[email protected]"}
{"success": true,"data": {"referralCode": "string","urlReferral": "string","referralCompleteCount": 1,"referralPendingCount": 1,"isReferralLimitReached": true,"referralLimit": 1},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Eligible products/collections for one place_order program (public)
Feeds the "which products earn points" browser on a place_order / place_order_subscription program's earn card — a product/collection browser, not an earn form (place_order can never be claimed by a click). Returns IDENTIFIERS only (id/handle), not product cards: a Hydrogen storefront already has its own product data (fresher, market-priced, inventory-aware, its own components) via the Storefront API, so this only says WHICH products/collections qualify.
Shop-scoped, not customer-scoped — every shopper on the storefront can share one cached response, same Cache-Control as /programs.
Fetch this lazily when the shopper opens the product browser, not with the program list — resolving a collection condition can need a live Shopify lookup.
scope: 'all' means no restriction — every product qualifies, and products/collections are both empty; render a generic "browse everything" CTA rather than any specific list.
A place_order or place_order_subscription program, from /programs.
200Eligible products/collections
trueWhich products/collections earn points on a place_order / place_order_subscription program — identifiers only (id/handle), not product cards. See the /programs/{id}/place-order-scope path description for why.
Show properties
all — every product qualifies; products/collections are both empty and the client should render a generic "browse everything" CTA. select — only the listed products/collections qualify.
Show properties
Shopify product id, numeric and unprefixed.
Required quantity to earn, when the merchant set one (V2 rule-engine programs only). Null otherwise.
Show properties
Resolved live from Shopify by title. Null when the collection was renamed/deleted since the merchant configured this program, or the title otherwise didn't resolve — the shopper still sees title.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400
404`SHOP_NOT_FOUND` — the `shop` domain did not resolve. `INVALID_PROGRAM` — the `id` is not a `place_order`/`place_order_subscription` program of that shop.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
GET /app/api/v1/headless/programs/{id}/place-order-scope HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"scope": "all","products": [{"id": "string","handle": "string","quantity": 1}],"collections": [{"title": "string","handle": "string"}]},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
VIP tiers + perks (public)
Feeds joy-vip-tiers (SSR ★1) and the frame of joy-membership-card.
200Tier list
trueShow properties
"Gold"Points or spend required to reach this tier.
Show properties
Present only when the merchant enables member counts.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400
404
429
GET /app/api/v1/headless/tiers HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","name": "Gold","description": "string","iconUrl": "string","threshold": 1,"perks": [{"id": "string","title": "string","description": "string"}],"memberCount": 1}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Branding, currency, point label (public)
Consumed by every block for theming defaults, currency format and the point label.
200Shop config
trueShow properties
"USD""${{amount}}"Show properties
"point""points"Show properties
Null when the merchant never picked a brand color.
"#054597"Whether the birthday-entry modal must also collect a YEAR (not just day/month). Shop-level — shared by every date-of-birth-collecting event — derived from the birthday program's dateFormat at save time. Program.birthday.dateFormat is a different, per-program setting: it only reorders day/month for display, never adds or removes the year field.
Whether the shop is running the self-serve "opt-in" enrollment flow (Premium plan + not manual-assignment-only + the toggle is on). Combine with Customer.type to decide when to show a "Join Program" button — see POST /customer/join-program. Rides this endpoint's own 5-minute CDN cache, so a shop that just upgraded plan or flipped the toggle can take up to 5 minutes to see it reflect here.
The Facebook Page the shop currently has connected; null when it has none. Needed to build the m.me/{facebookPageId}?ref=... Messenger link that links a shopper's Facebook account — a client constructs that link itself, no signing endpoint required: the ref payload is signed with the shop's own id as the HMAC key, which an authenticated caller already holds as a plaintext claim in its JWT. This is shop CONFIGURATION, not link state: whether the current shopper is connected is Customer.social.facebookConnected. Rides this endpoint's 5-minute CDN cache, same staleness as optInEnrollmentEnabled.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400
404
429
GET /app/api/v1/headless/shop-config HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"shopDomain": "string","currency": "USD","currencyFormat": "${{amount}}","pointLabel": {"singular": "point","plural": "points"},"branding": {"primaryColor": "#054597","secondaryColor": "string"},"loyaltyPageUrl": "string","programEnabled": true,"isNewFormatDOB": true,"optInEnrollmentEnabled": true,"facebookPageId": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
GET /loyalty-page/tier-rewards
Loyalty Page V2 parity read for the tier benefit matrix. CDN-cacheable on success only.
200
Show properties
Present on a handled failure (still HTTP 200), with success false.
400
404
429
GET /app/api/v1/headless/loyalty-page/tier-rewards HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{}],"pageInfo": {"hasNext": true,"hasPre": true},"error": "string"}
Published Loyalty Page V2 structure (public)
The section list, per-section theme and layout a merchant published from the Joy admin editor — everything the page needs to paint that is NOT specific to one shopper.
Returns the published version only, never the draft. Public and CDN-cacheable — it carries nothing that is not already public on the rendered page. Cached SHORTER than the other public reads (public, s-maxage=30, stale-while-revalidate=300) so a publish reaches headless storefronts within ~30s.
data.faqs lists the shop's FAQs — an empty array (never null) when there are none.
200Published structure + FAQs. `data.structure` is `null` on a shop that has never published; `data.faqs` is `[]` when the shop has none (or none that are enabled).
trueShow properties
The published structure document. Shape is owned by the admin editor and the storefront renderer jointly — sections[] (id + enabled + order) plus content, theme and layout keyed by section id.
Already sorted; a consumer should render it as given. orderBy still travels so the two hosts emit the same shape, and so a consumer that does sort has the field to sort on rather than an undefined.
Show properties
Merchant-authored position. Already applied to this array's order.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400
404
429
GET /app/api/v1/headless/loyalty-page/structure HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"structure": {},"faqs": [{"title": "string","content": "string","orderBy": 1}]},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Full widget payload — shop + (optionally) rich customer state
Optional auth: Authorization: Bearer <token> from POST /auth/token is OPTIONAL, not required.
- No/invalid/expired Bearer -> guest:
data.customeris absent, only shop-level fields are returned. - Valid Bearer ->
data.customeris the rich, per-customer payload. - A Bearer naming a
shopIdthat does not match the shop resolved fromdis a401 UNAUTHORIZED— it is never silently downgraded to a guest response (a proven identity for a different shop is rejected, not ignored).
Response shape — not the standard envelope. Three distinct shapes:
- Success —
200 { data: {...} }. Nosuccess/timestampkey. - Validation/auth errors (missing
d, unknown shop, wrong-shop Bearer) — the standard error envelope{success: false, error: {message, code, statusCode}}with the real HTTP status (400/404/401). - Internal errors —
200 { error: "<message>" }. Nosuccesskey and no HTTP error status.
An error response on the guest path (shape 2 or 3 above) is NEVER cached, so a transient failure cannot get stuck shop-wide for the cache window. Authenticated responses are always Cache-Control: private, no-store and rate-limited the same as /customer/* — never CDN-cacheable.
The shop's myshopify domain (alias of shop).
Locale — same meaning as locale on the other endpoints.
Detected/buyer locale — takes precedence over l when resolving translations for several fields.
Skip-list of top-level response fields the caller already has from elsewhere (e.g. m=settings when the caller reads settings from the metafield instead) — those fields are not returned. Repeat the param per value (m=settings&m=shop).
When 'true', include data.storeCredit (per-currency Store Credit balances).
200
400`BAD_REQUEST` — missing `d`.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401`UNAUTHORIZED` — the Bearer token names a `shopId` that does not match the shop resolved from `d`.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"404
429
GET /app/api/v1/headless/info HTTP/1.1Host: api.joy.soAccept: */*