Headless API
Public

Public

Unauthenticated, cacheable storefront data (SSR-friendly)

Earning + spending programs (public catalog)

GET/programs

Feeds joy-ways-to-earn (earning) and the catalog half of joy-rewards-catalog (spending).

Parameters
· objectOptional
· objectOptional
· objectOptional
· objectOptional
programTypequery · stringOptional

Filter to one kind; omit for both.

Responses
200Program list
successbooleanOptional
Example: true
dataProgram[]Optional
Show properties
idstringOptional
programTypestringOptional
keystringOptional

Program event key, e.g. place_order, signup, birthday, social_share.

Example: "place_order"
titlestringOptional
Example: "Place an order"
descriptionstringOptional

Empty string when the merchant wrote none — never null. Text fields fall back to '' so a client can print them unguarded.

iconUrlstringOptional
pointsintegerOptional

Points earned (earning programs).

pointsCostintegerOptional

Points required to redeem (spending programs).

rewardTypestringOptional

Reward kind (spending programs).

rewardValuenumberOptional

Reward amount — currency or percent depending on rewardType.

redeemTypestringOptional

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.

actionUrlstringOptional

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.

Example: "https://www.facebook.com/sharer.php?u=https%3A%2F%2Fyourshop.com"
lineJoinTypestringOptional

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.

rateMoneynumberOptional

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.

Example: 1
conditionsstring[]Optional

The 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.

Example: ["Online store only","Minimum order $50"]
isEarnedbooleanOptional

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.

formobjectOptional

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
headerImagestringOptional
successIconUrlstringOptional
requireImagebooleanOptional

Whether the shopper must attach an image with their submission.

autoApprovebooleanOptional

When true, POST /customer/earn reports status: earned immediately; when false, status: pending until a merchant approves the submission in admin.

instantRewardEnabledbooleanOptional

Whether a smaller reward is granted immediately, ahead of approval.

instantRewardPointsintegerOptional

The instant reward amount, when instantRewardEnabled is true.

reviewobjectOptional

Design + reward settings, write_review only — null for every other event, and always null on a spending program.

Show properties
redirectUrlstringOptional

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).

headerImagestringOptional
extraPointsintegerOptional

Bonus points awarded on top of the base reward for this program.

googleReviewobjectOptional

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
urlLocationstringOptional

The Google Maps location the shopper reviews. Guaranteed http(s) — non-http schemes are dropped server-side.

statusstringOptional

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.

statusTextstringOptional

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.

streakobjectOptional

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
currentDayintegerOptional

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.

totalDaysintegerOptional

The highest streak milestone day configured on this program.

canClaimTodaybooleanOptional

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.

messagestringOptional

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.

birthdayobjectOptional

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
rewardOptionsobject[]Optional

The rewards this shopper may choose from.

Show properties
idstringOptional

Send this value back in POST /customer/earn's context.birthdayRewards (see that endpoint).

labelstringOptional
Example: "Get 10% off for your birthday"
imageUrlstringOptional

Present only for a free-gift reward; null for point/discount rewards.

rewardSelectionstringOptional

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.

dateFormatstringOptional

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).

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
404
No body
429
No body
GET/programs
GET /app/api/v1/headless/programs HTTP/1.1
Host: api.joy.so
Accept: */*
200Program list
{
"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

POST/referral/generate-link

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.

Parameters
· objectOptional
· objectOptional
· objectOptional
Request body
emailstring · emailRequired
Responses
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).
successbooleanOptional
Example: true
dataGuestReferralLinkOptional

Response for POST /referral/generate-link.

Show properties
referralCodestringOptional
urlReferralstringOptional

The guest's ready-to-share referral link.

referralCompleteCountintegerOptional
referralPendingCountintegerOptional
isReferralLimitReachedbooleanOptional
referralLimitintegerOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
404
No body
429
No body
POST/referral/generate-link
POST /app/api/v1/headless/referral/generate-link HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 34
{
"email": "[email protected]"
}
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).
{
"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)

GET/programs/{id}/place-order-scope

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.

Parameters
· objectOptional
· objectOptional
· objectOptional
idpath · stringRequired

A place_order or place_order_subscription program, from /programs.

Responses
200Eligible products/collections
successbooleanOptional
Example: true
dataPlaceOrderScopeOptional

Which 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
scopestringOptional

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.

productsobject[]Optional
Show properties
idstringOptional

Shopify product id, numeric and unprefixed.

handlestringOptional
quantityintegerOptional

Required quantity to earn, when the merchant set one (V2 rule-engine programs only). Null otherwise.

collectionsobject[]Optional
Show properties
titlestringOptional
handlestringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
GET/programs/{id}/place-order-scope
GET /app/api/v1/headless/programs/{id}/place-order-scope HTTP/1.1
Host: api.joy.so
Accept: */*
200Eligible products/collections
{
"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)

GET/tiers

Feeds joy-vip-tiers (SSR ★1) and the frame of joy-membership-card.

Parameters
· objectOptional
· objectOptional
· objectOptional
· objectOptional
Responses
200Tier list
successbooleanOptional
Example: true
dataTier[]Optional
Show properties
idstringOptional
namestringOptional
Example: "Gold"
descriptionstringOptional
iconUrlstringOptional
thresholdnumberOptional

Points or spend required to reach this tier.

perksobject[]Optional
Show properties
idstringOptional
titlestringOptional
descriptionstringOptional
memberCountintegerOptional

Present only when the merchant enables member counts.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
404
No body
429
No body
GET/tiers
GET /app/api/v1/headless/tiers HTTP/1.1
Host: api.joy.so
Accept: */*
200Tier list
{
"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)

GET/shop-config

Consumed by every block for theming defaults, currency format and the point label.

Parameters
· objectOptional
· objectOptional
· objectOptional
· objectOptional
Responses
200Shop config
successbooleanOptional
Example: true
dataShopConfigOptional
Show properties
shopDomainstringOptional
currencystringOptional
Example: "USD"
currencyFormatstringOptional
Example: "${{amount}}"
pointLabelobjectOptional
Show properties
singularstringOptional
Example: "point"
pluralstringOptional
Example: "points"
brandingobjectOptional
Show properties
primaryColorstringOptional

Null when the merchant never picked a brand color.

Example: "#054597"
secondaryColorstringOptional
loyaltyPageUrlstringOptional
programEnabledbooleanOptional
isNewFormatDOBbooleanOptional

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.

optInEnrollmentEnabledbooleanOptional

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.

facebookPageIdstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
404
No body
429
No body
GET/shop-config
GET /app/api/v1/headless/shop-config HTTP/1.1
Host: api.joy.so
Accept: */*
200Shop config
{
"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

GET/loyalty-page/tier-rewards

Loyalty Page V2 parity read for the tier benefit matrix. CDN-cacheable on success only.

Parameters
· objectOptional
· objectOptional
· objectOptional
Responses
200
successbooleanOptional
dataobject[]Optional
pageInfoobjectOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
errorstringOptional

Present on a handled failure (still HTTP 200), with success false.

400
No body
404
No body
429
No body
GET/loyalty-page/tier-rewards
GET /app/api/v1/headless/loyalty-page/tier-rewards HTTP/1.1
Host: api.joy.so
Accept: */*
200Response
{
"success": true,
"data": [
{}
],
"pageInfo": {
"hasNext": true,
"hasPre": true
},
"error": "string"
}

Published Loyalty Page V2 structure (public)

GET/loyalty-page/structure

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.

Parameters
· objectOptional
· objectOptional
· objectOptional
Responses
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).
successbooleanOptional
Example: true
dataobjectOptional
Show properties
structureobjectOptional

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.

faqsobject[]Optional

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
titlestringOptional
contentstringOptional
orderBynumberOptional

Merchant-authored position. Already applied to this array's order.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
404
No body
429
No body
GET/loyalty-page/structure
GET /app/api/v1/headless/loyalty-page/structure HTTP/1.1
Host: api.joy.so
Accept: */*
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).
{
"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

GET/info

Optional auth: Authorization: Bearer <token> from POST /auth/token is OPTIONAL, not required.

  • No/invalid/expired Bearer -> guest: data.customer is absent, only shop-level fields are returned.
  • Valid Bearer -> data.customer is the rich, per-customer payload.
  • A Bearer naming a shopId that does not match the shop resolved from d is a 401 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:

  1. Success — 200 { data: {...} }. No success/timestamp key.
  2. 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).
  3. Internal errors — 200 { error: "<message>" }. No success key 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.

Parameters
dquery · stringRequired

The shop's myshopify domain (alias of shop).

· objectOptional
· objectOptional
lquery · stringOptional

Locale — same meaning as locale on the other endpoints.

dlquery · stringOptional

Detected/buyer locale — takes precedence over l when resolving translations for several fields.

mquery · string[]Optional

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).

scquery · stringOptional

When 'true', include data.storeCredit (per-currency Store Credit balances).

Responses
200
400`BAD_REQUEST` — missing `d`.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401`UNAUTHORIZED` — the Bearer token names a `shopId` that does not match the shop resolved from `d`.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
404
No body
429
No body
GET/info
GET /app/api/v1/headless/info HTTP/1.1
Host: api.joy.so
Accept: */*