Customer
Personalised data — requires a Bearer JWT from /auth/token
Current customer — points, tier, tier progress
Feeds joy-points-balance and the personalised half of joy-membership-card.
200Customer state
trueShow properties
Whether this customer has joined the loyalty program. Combine with ShopConfig.optInEnrollmentEnabled to decide when to show a "Join Program" button — see POST /customer/join-program. A customer who has left the program also reports guest here (no third wire value) — the join endpoint rejects a re-join attempt for that case with NOT_ELIGIBLE rather than silently no-op'ing.
Current spendable balance.
The customer's order count, read live from Shopify when available.
Show properties
Progress toward the next tier; null when already at top tier.
Show properties
0–100.
Entry threshold of the customer's current tier (0 for the base tier).
Entry threshold of the next tier.
Whether this customer has ever synced as subscribed to marketing emails. null when never synced (unknown), distinct from false (synced, not subscribed). Use this ONLY to show opt-in state and disable an already-checked opt-in control — the server can only ever subscribe a customer, never unsubscribe one, so a client must NOT offer an "unsubscribe" affordance driven by this field.
Customer's avatar image URL. null when the customer has none.
Whether this customer has ever synced as subscribed to SMS marketing. Same null-means-unknown contract as marketingConsent above, for the SMS channel.
The shop's global (shop+customer scoped, NOT per-program) earning limit — null when the shop has the feature off. This is a purpose-built shopper-facing field, not the shop's limit configuration (max points, timeframe, tier table); those are never exposed on this contract.
Show properties
Whether the customer has hit the limit for the current period.
Merchant-formatted date the period resets. Empty string for a customer whose tier has an unlimited override.
Server-rendered warning sentence, ready to show as-is. null when reached is false — there is no sentence to show until then.
KNOWN QUIRK, ported faithfully: once set, it can never be changed — the guard's own message says "once per year", but there is no year check or reset job behind it, so it is a ONE-TIME submission in practice. A client should treat a non-null value here as permanently locked, not just locked for this year.
"07/13"'YYYY-MM-DD'. Only ever set on shops asking for a year (ShopConfig.isNewFormatDOB); null on shops that only collect day/month.
"1990-07-13"The customer's phone (Shopify-synced), or null when none is on file. Also echoed by PATCH /customer/profile so a just-saved phone reads back identically. Absent on older payloads.
The tier-progress running total the tier ring is drawn from — the core recomputes the ring off this rather than trusting tierProgress.percentage alone. 0 (not null) when the customer is below the first threshold. Absent on older payloads.
Which social accounts this customer has linked — the state the Instagram / LINE / Facebook earning modals branch on. ALWAYS present (never null): "not linked" is a known state for every customer, so a client can read social.lineLinked unguarded. Raw third-party identifiers are deliberately NOT on this contract — see lineLinked and facebookConnected.
Show properties
The handle the customer entered via POST /customer/social/instagram/link; null when they have never linked one (or unlinked). Unlike the LINE/Facebook fields this IS the raw value, because the modal renders it: it pre-fills the input and prints the current username. Never verified for ownership — Instagram linking is a self-declared string, not OAuth.
"joyshopper"Whether the customer completed LINE Login (POST /customer/social/line/auth-url → OAuth popup). A boolean, not the LINE user id — a client needs only the answer, not the platform identifier. REQUIRED READ before claiming a join_line program whose Program.lineJoinType is follow_oa: POST /customer/earn rejects those with LINE_NOT_LINKED (400) until this is true, so a client must open the OAuth popup instead of attempting the claim.
Whether the customer also follows the shop's LINE Official Account. A SEPARATE step from lineLinked — set by the LINE follow/unfollow webhook, and it can go back to false if they unfollow. Drives whether a "Follow" CTA still shows.
Whether the customer has a Messenger link that is still usable — i.e. they linked one AND it is scoped to the page the shop currently has connected. Resolved server-side; false (not stale-true) after a merchant reconnects a different page, since every previously stored link becomes unreachable. The underlying Page-Scoped User ID is never exposed: a client needs only this boolean to choose between the connect and disconnect branches (POST /customer/social/facebook/disconnect).
Display name Meta returned when the link was made, for a "Connected as {name}" label. Null when facebookConnected is false, and null when Meta sent no name — render a bare "Connected" in that case.
Whether this customer has an Apple Wallet pass on file, created via POST /customer/wallet-pass/apple. Combine with qrCodeDownloadApplePass to decide whether to show an "Add to Apple Wallet" button or a re-download QR code.
PNG data URL (data:image/png;base64,...) of the customer's Apple Wallet pass download link, pre-rendered server-side. null when createdApplePass is false. The SAME value POST /customer/wallet-pass/apple returns as qrUrl on creation — this field is what lets a later GET /customer re-show it without re-generating the pass.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"id": "string","type": "guest","points": 1,"totalOrders": 1,"totalEarned": 1,"tier": {"id": "string","name": "string"},"tierProgress": {"nextTierName": "string","pointsToNext": 1,"percentage": 1,"currentTarget": 1,"nextTarget": 1},"memberSince": "2024-01-15T10:30:00.000Z","marketingConsent": true,"avatarUrl": "string","smsMarketingConsent": true,"earningLimit": {"reached": true,"resetDate": "string","message": "string"},"birthday": "07/13","dateOfBirth": "1990-07-13","phone": "string","tierPoint": 1,"social": {"instagramUsername": "joyshopper","lineLinked": true,"lineFollowedOA": true,"facebookConnected": true,"facebookName": "string"},"createdApplePass": true,"qrCodeDownloadApplePass": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Programs with earn text rendered for THIS customer
The same list the public /programs serves, with one difference that cannot be served publicly: the earn text is rendered for the signed-in shopper. A place-order rule reads their own tier's rate, a birthday rule lists their own tier's rewards.
/programs is public, s-maxage=300 — CDN-shared — so it must carry the shop's default rate instead; putting one shopper's tier copy there would serve it to everyone. This endpoint is private, no-store, which is what makes the personalisation safe.
Use /programs for SSR/guests and this once a token is held.
Filter to one kind; omit for both.
200Program list, personalised
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
401
429
GET /app/api/v1/headless/customer/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"}
Points transaction history (paginated)
Feeds joy-points-history. Cursor pagination via before/after activity ids. Each row's title is a full sentence rendered server-side from the shop's settings + translations ("Placed an order #1042", "Sign up bonus"), so it follows locale like every other text field on this surface.
200Activity page
trueShow properties
Rendered sentence for the row, localised to the served locale. Falls back to the bare program title when the shop's data is too sparse to render one.
"Placed an order"Signed — positive for earn, negative for spend/expire.
The coupon this row minted (spend / referral rows), for a click-through to that coupon's detail. null on every row that issued no coupon — the majority. Absent on older backends.
The Shopify order this row was earned on (numeric id as a string), for the "view order" click-through. null on any row not tied to an order (sign-up, birthday, manual adjust). Absent on older backends.
redeem_point/earn_point.
Point balance immediately before this row.
Point balance immediately after this row.
How the row was created, e.g. user / source_headless.
Earning event key, e.g. sign_up / interact_website.
Custom-program sub-type, when set.
Source program id.
Source program title snapshot.
Embedded program snapshot, when the row carries one.
Points spent (redeem rows).
Minted discount id (redeem rows).
Legacy price-rule id (redeem rows).
This customer's own Joy customer id.
This customer's own email.
The shop id.
This customer's Shopify numeric id.
ISO timestamp of last update.
Show properties
Show properties
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/activities HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","type": "earn","title": "Placed an order","points": 1,"createdAt": "2024-01-15T10:30:00.000Z","couponCode": "string","orderId": "string","rawType": "string","oldPoint": 1,"newPoint": 1,"source": "string","event": "string","typeCustom": "string","programId": "string","programTitle": "string","program": {},"redeemPoint": 1,"discountId": null,"priceRuleId": null,"customerId": "string","email": "string","shopId": "string","shopifyCustomerId": 1,"updatedAt": "string"}],"meta": {"pagination": {"hasNext": true,"hasPre": true}},"timestamp": "2026-07-31T07:27:54.123Z"}
Customer's rewards (redeemable + already redeemed)
Feeds joy-rewards-catalog. Returns TWO lists, not one — the loyalty page renders them as separate blocks and merging them showed a just-redeemed reward twice in the same grid.
available is already filtered to what this customer can actually redeem (schedule window, VIP tier, segment, sold-out limits), so a card never appears with a Redeem button that will fail.
The before/after/limit parameters paginate redeemed only; available is always complete.
couponStatus switches which coupons redeemed holds — everything else in the response is unaffected. available (the default — the response stays byte-identical to omitting the param) is the customer's live, still-usable coupons. meta.pagination.redeemed.hasNext can be true, but it is not pageable: before/after are ignored and there is currently no cursor to fetch a next page. Consumers must not use this flag for a Next control.
Return the page of redeemed coupons BEFORE this rewardId. Ignored when couponStatus=unavailable.
Return the page of redeemed coupons AFTER this rewardId. Ignored when couponStatus=unavailable.
Page size for redeemed. Clamped to 50.
Which coupons redeemed returns. unavailable is not paginated — see above.
200Reward lists
trueShow properties
Show properties
Program event key — the same values as Program.key. Clients map their built-in icons off it; matching Program.key is what keeps the icon stable when the personalised list replaces the SSR'd one.
"amount_discount"The merchant's uploaded icon, when they set one. Built-in per-event icons are not URLs — map those client-side off key.
The reward's fine print, rendered server-side — same rows the matching Program carries. Empty means no restrictions.
["Online store only","Minimum order $50"]When redeemType is fixed, the price. When dynamic, the STEP — how many points buy one rewardValue of discount. Read redeemType before treating this as a price.
Discount gained per pointsCost points.
dynamic means the customer chooses a multiple of pointsCost between minPoints and maxPoints, and POST /customer/redeem REQUIRES points. This is the default for "Amount discount" programs, so it is the common case, not an edge one.
Dynamic only, and only when the merchant set a floor. Null = unbounded.
Dynamic only, and only when the merchant set a ceiling. Null = unbounded.
Whether the merchant capped how much of a cart this reward may discount. Always false when redeemType is fixed — there is no slider to cap.
The cap, as an INTEGER PERCENT of the cart total (1-100) — not an amount. Null when hasLimitCartValue is false. Resolving it needs the cart total, which only the storefront holds, so the ceiling is computed CLIENT-side:
maxPointsNoCap = maxPoints ?? (customerPoints - customerPoints % pointsCost) maxPointsByCart = floor((limitCartValue / 100 * cartTotal) / rewardValue + 0.0001) * pointsCost ceiling = min(maxPointsNoCap, maxPointsByCart)
This is a DISPLAY cap: POST /customer/redeem does not receive a cart total and does not enforce it (no Joy surface does today).
Whether the customer can afford the cheapest redemption this program allows — minPoints when there is one, else pointsCost.
free_product rewards only. one — the customer must pick one gift from giftProducts and send it back as variantId on POST /customer/redeem. all — the reward covers every configured product, nothing to pick. Null for every other reward type.
The gifts to choose between when giftSelectionMode is one. Empty otherwise, and empty for collection-scoped gifts — those select a collection rather than a variant, which a storefront cannot add to a cart.
Show properties
Shopify product id (numeric, unprefixed).
Show properties
Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.
Environmental-impact reward only ("Plant a tree"): the provider that fulfils it. null on every other reward type. Absent on older backends.
Impact reward only — what is created (e.g. tree). null otherwise.
Impact reward only — how many units per redemption. null otherwise.
Impact reward only — the merchant's public impact dashboard link. null otherwise.
Buy-X-Get-Y reward only — the specific products the customer gets. [] otherwise.
Show properties
Shopify product/collection id (numeric, unprefixed).
BXGY only — the collections the customer gets. [] otherwise.
Show properties
Shopify product/collection id (numeric, unprefixed).
BXGY only — how many "buy" items are required. null otherwise.
BXGY only — whether a per-order use cap is set. false otherwise.
BXGY only — the per-order use cap when enableMaximumUsesPerOrder. null otherwise.
Whether the merchant lets a redeemer gift this coupon on to a friend — drives the "Send to a friend" button (backend endpoint already exists). false when off. Absent on older backends.
When the send-to-friend flow routes through an automation, which provider. null when sendRewardToFriend is off or no provider is configured.
Show properties
The coupon's own id — NOT the program's.
The source program's event, same meaning as AvailableReward.key — what a client maps its built-in icon off. Empty string when the coupon's program snapshot never recorded one.
"free_shipping"Merchant-uploaded icon for the source program; null when they set none. Built-in per-event icons are not served — clients map those off key.
How the coupon may be used. The shopper needs this AFTER redeeming too — "Minimum order $50" is why a code looks broken at checkout.
["Expires 30 days after redeeming"]Points actually spent at redemption time.
notYetUsable means the coupon exists but startsAt is in the future — show "Active from …" rather than an Add-to-cart button, which checkout would reject.
Null when the coupon never expires.
Null when it is usable immediately.
The variant that WAS chosen, on a redeemed free_product reward — lets a client still add the gift to the cart later.
Full picked-product info for an already-redeemed free-gift coupon — what the core coupon list needs to render the product row + Add-to-cart (giftVariantId alone is not enough). [] on non-free-gift coupons. Absent on older backends.
Show properties
Shopify product id (numeric, unprefixed).
Show properties
Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.
one | all | null — which free-gift picker the core renders.
The already-chosen variant (same value as giftVariantId).
Collections a collection-scoped free gift hands out (the case freeGiftProducts is empty for). [] on every other coupon.
Show properties
Shopify product/collection id (numeric, unprefixed).
Gift card spent down but still has balance — show the "Partially used" badge. Absent on older backends.
The gift card's real remaining balance. null when the card was never refreshed or this isn't a gift card — use rewardValue (face value) for the amount label instead. Absent on older backends.
The customer's balance, so a dynamic reward's slider can stop at the largest whole step they can afford without a second call to /customer.
Show properties
Show properties
Show properties
"2026-07-31T07:27:54.123Z"400
401
429
GET /app/api/v1/headless/customer/rewards HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"available": [{"programId": "string","key": "amount_discount","title": "string","description": "string","iconUrl": "string","conditions": ["Online store only","Minimum order $50"],"pointsCost": 1,"rewardType": "fixed_amount","rewardValue": 1,"redeemType": "fixed","minPoints": 1,"maxPoints": 1,"hasLimitCartValue": true,"limitCartValue": 1,"affordable": true,"giftSelectionMode": "all","giftProducts": [{"id": "string","title": "string","image": "string","variants": [null]}],"impactProvider": "string","impactType": "string","impactQuantity": 1,"impactDashboardUrl": "string","conditionProductsGets": [{"id": "string","title": "string","image": "string","handle": "string"}],"conditionCollectionsGets": [{"id": "string","title": "string","image": "string","handle": "string"}],"orderReqQuantity": 1,"enableMaximumUsesPerOrder": true,"maximumUsesPerOrder": 1,"sendRewardToFriend": true,"sendRewardToFriendAutomationProvider": "string"}],"redeemed": [{"rewardId": "string","programId": "string","key": "free_shipping","title": "string","description": "string","iconUrl": "string","conditions": ["Expires 30 days after redeeming"],"pointsSpent": 1,"rewardType": "fixed_amount","rewardValue": 1,"couponCode": "string","status": "active","expiresAt": "2024-01-15T10:30:00.000Z","startsAt": "2024-01-15T10:30:00.000Z","giftVariantId": "string","freeGiftProducts": [{"id": "string","title": "string","image": "string","variants": [null]}],"freeGiftMode": "all","freeGiftSelectedVariantId": "string","freeGiftCollections": [{"id": "string","title": "string","image": "string","handle": "string"}],"partiallyUsed": true,"balanceRemaining": 1}],"customerPoints": 1},"meta": {"pagination": {"redeemed": {"hasNext": true,"hasPre": true}}},"timestamp": "2026-07-31T07:27:54.123Z"}
Gifts friends sent this customer ("From friends")
The coupons and gift cards OTHER customers sent this one, which the widget renders as its own "From friends" group under the coupon tab.
A separate list from /customer/rewards because these are gifts received from other customers, not this customer's own redemptions. They spent no points on them (there is no pointsSpent) and they cannot revoke one or pass it on — a client must not offer those actions on a gift.
Only LIVE gifts are returned: used, revoked and expired ones are filtered out server-side, so status is always active. A gift card's balance is re-checked with Shopify on every call, since this is the only place a received card's balance is ever read.
On a multi-shop SUB shop the gifts live under the main shop; the customer is matched there by email automatically. A customer with no twin in the main shop gets an empty list, not an error.
Return the page of gifts BEFORE this rewardId.
Return the page of gifts AFTER this rewardId.
Page size. Clamped to 50.
200One page of received gifts
trueShow properties
Show properties
The gift's own id — NOT the sender's reward id, and NOT the program's. Also the cursor for before/after.
The source program's event, same meaning as AvailableReward.key — what a client maps its built-in icon off.
"percentage_discount"The source program's title, in locale when the merchant translated it. Read off the snapshot the gift carries, so a gift issued from a program the merchant has since deleted still has a title.
The source program's sentence, localized and snapshot-read the same way as title.
Merchant-uploaded icon for the source program; null when they set none. Built-in per-event icons are mapped client-side off key.
MASKED for a gift card — the spendable full code is repo-private and never leaves the server.
Always active in practice — used, revoked and expired gifts are filtered out of the page. Present so a client can render a gift and a redeemed coupon through one card component.
Mirrors the sender's coupon so both lapse together. Null when the gift never expires.
Always null today — the gift record carries no start date.
On a free_product gift, the variant the sender chose.
Full picked-product info for an already-received free-gift gift — same shape and purpose as RedeemedReward.freeGiftProducts. [] on non-free-gift gifts. Absent on older backends.
Show properties
Shopify product id (numeric, unprefixed).
Show properties
Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.
one | all | null — which free-gift picker the core renders.
The variant the sender chose (same value as giftVariantId).
Collections a collection-scoped free gift hands out. [] on every other gift.
Show properties
Shopify product/collection id (numeric, unprefixed).
Who sent it. Empty string when unknown — show the name, falling back to senderEmail.
The sender's email, empty string when unknown. Display-safe by design: it is one of only two sender fields stored on the gift.
The gift's fine print, rendered server-side in the resolved locale — the same rows, from the same builder, as the matching RedeemedReward, so a gift and an own coupon from one program read identically on a shared card. Empty array (never null) when the program carries no restrictions.
True when this is a gift card that has been spent down but still has a balance — show a "Partially used" badge. Stays false for every non-gift-card reward.
The gift card's real remaining balance. Null when the card was never refreshed against Shopify or this reward is not a gift card — a client falls back to rewardValue (the face value) in that case.
Show properties
Show properties
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/received-rewards HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"received": [{"rewardId": "string","programId": "string","key": "percentage_discount","title": "string","description": "string","iconUrl": "string","rewardType": "fixed_amount","rewardValue": 1,"couponCode": "string","status": "active","expiresAt": "2024-01-15T10:30:00.000Z","startsAt": "2024-01-15T10:30:00.000Z","giftVariantId": "string","freeGiftProducts": [{"id": "string","title": "string","image": "string","variants": [null]}],"freeGiftMode": "all","freeGiftSelectedVariantId": "string","freeGiftCollections": [{"id": "string","title": "string","image": "string","handle": "string"}],"senderName": "string","senderEmail": "string","conditions": ["string"],"partiallyUsed": true,"balanceRemaining": 1}]},"meta": {"pagination": {"hasNext": true,"hasPre": true}},"timestamp": "2026-07-31T07:27:54.123Z"}
Redeem points for a reward
Redeems a spending program for the JWT's customer. The customer is taken from the token, never from the body — clients cannot redeem on behalf of anyone else.
The spending program (from /programs, programType=spending).
Points to spend. REQUIRED when the reward's redeemType is dynamic — its pointsCost is the step, not the price, so there is no safe default and the call fails POINTS_REQUIRED. Omit for fixed-cost rewards; the server then uses the program's own cost.
The gift the customer picked, for free_product rewards whose giftSelectionMode is one. Must be one of the variant ids listed in that reward's giftProducts. Omit for every other reward type.
Optional client-generated UUID that makes a retried request safe. Redemption deducts points and issues a coupon atomically with no rollback, so a retry (mobile / flaky network) without this key double-spends. Send the SAME key on a retry and the original result is returned instead of redeeming again. Scoped per customer (a key is bound to the token's shop + customer, so it can never read another customer's result) and retained 24h.
For a gift_card reward: send it to a friend instead of keeping it. Omit to redeem into the redeemer's own account. Ignored on every other rewardType.
Show properties
The recipient's email. Rejected with REDEEM_FAILED (400) when it matches the redeemer's own — a customer cannot send a gift card to themselves.
Recipient display name shown on Shopify's gift-card email.
Personal note shown on Shopify's gift-card email.
When to send the gift-card email. Must be a future ISO date-time; Shopify also caps it at 90 days out — a value beyond that is dropped server-side and the card sends immediately.
200Redemption succeeded — coupon issued
trueShow properties
Issued voucher/discount ID.
Customer balance after redemption. 0 when the redeem did not report a balance — check remainingPointsKnown before repainting.
True when remainingPoints is the balance the redeem actually returned; false when it is the 0 fallback.
Gift-card state, gift_card rewards only (null otherwise). The full card code is never here — couponCode carries what the redeemer may see (masked when sent to a friend).
Show properties
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400Invalid program, or not enough points. `POINTS_REQUIRED` — the reward is `dynamic` and the request carried no `points`. Gift-specific `error.code`s: `GIFT_SELECTION_REQUIRED` (the reward needs a `variantId` — re-show the picker) and `GIFT_UNAVAILABLE` (the chosen gift is not on this reward, or is out of stock — let the customer pick another). For a `gift_card` reward, `recipient.email` matching the redeemer's own address also rejects here (`REDEEM_FAILED`) — a customer cannot send a gift card to themselves. No points are spent in any of these cases.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — the customer does not qualify for this reward (VIP tier, customer segment, or B2B). `/customer/rewards` already hides these, so this means eligibility changed since the list was fetched: re-fetch the list rather than retrying the redeem.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"409`REDEEM_IN_PROGRESS` — another request carrying the same `idempotencyKey` for this customer is still being processed. The first request either succeeds (retry the same key to get its result) or fails (retry the same key to re-attempt). Do not resend without the key — that would risk a double-spend.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/redeem HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 252{"programId": "string","points": 1,"variantId": "string","idempotencyKey": "string","recipient": {"email": "[email protected]","name": "string","message": "string","sendNotificationAt": "2024-01-15T10:30:00.000Z"}}
{"success": true,"data": {"id": "string","couponCode": "string","rewardType": "fixed_amount","rewardValue": 1,"pointsSpent": 1,"remainingPoints": 1,"remainingPointsKnown": true,"giftCard": {"isSentToFriend": true,"recipientEmail": "string","expiresOn": "string"}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Live stock for a free-gift reward's products
Real-time availability for the products of one free_product reward, so a gift picker can grey out what the customer can no longer choose.
Its own endpoint on purpose: this is the only read on this surface that costs a Shopify call, and folding it into /customer/rewards would pay for it on every list render whether or not a picker is ever opened. Call it when the picker opens, not with the list.
A POST that mutates nothing — the body only carries programId; the shop comes from the token.
Join the result onto that reward's giftProducts by id (both sides are strings). Ids absent from the response were not returned by Shopify — treat them as unavailable.
A spending program whose rewardType is free_product. Any other program — including one from another shop — is rejected as INVALID_PROGRAM.
200Stock resolved
trueLive availability for one free-gift reward's products. Availability only — on-hand counts are the merchant's data and are not exposed. A variant is available when it has stock, or when the merchant lets it oversell (continue selling / inventory not tracked).
Show properties
Show properties
Shopify product id (numeric, unprefixed) — the same string as GiftProduct.id, so the two lists join directly.
True when at least one of this product's variants is available.
Show properties
Matches GiftProduct.variants[].id.
Backend-confirmed configured variant ids that no longer exist or belong to a deleted product — lets the client block redeem BEFORE points are spent. Omitted when every configured variant still exists (and on older backends).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — no `programId`. `INVALID_PROGRAM` — the id does not resolve for this shop, or the program is not a free-gift reward.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/free-gift-stock HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 27{"programId": "string"}
{"success": true,"data": {"products": [{"productId": "string","available": true,"variants": [{"variantId": "string","available": true}]}],"missingVariantIds": ["string"]},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Claim an earning program
The one door for every earning family. The customer is taken from the token, never from the body — clients cannot earn on behalf of anyone else — and the program is loaded scoped to that token's shop, so a programId from another store simply does not resolve.
The program's own key (event) decides which family runs; a client can never choose the event it earns for. Which context fields to send:
| key | context | |---|---| | birthday | {birthday: "MM/DD", dateOfBirth?: "YYYY-MM-DD", birthdayRewards?: {}} | | submit_receipt | {images: ["https://…"]} — from /customer/earn/upload | | submit_form, fill_survey | {submittedContent: "…", imageUrls?: ["https://…"]} | | google_maps_review | {urlReview: "https://maps.google.com/…"} | | sign_up, sign_up_newsletter, member_anniversary, interact_website, streak_interact_website, the 13 social events | (none) |
images / imageUrls accept at most 5 https:// URLs each, and only ones this API returned — send base64 to /customer/earn/upload first.
birthday stores the date of birth AND awards the "completed your birthday info" reward, in that order — the same two steps the loyalty widget's birthday modal performs. The annual birthday reward itself is NOT granted here; it fires on the birthday, as it does for every other Joy surface. birthdayRewards, when the program's Program.birthday.rewardOptions is non-empty, is {[year]: [rewardOptions[].id, ...]} — the chosen option ids for the CURRENT year, single or multiple of them per Program.birthday.rewardSelection. It is a BirthdayRewardsMap and is held to exactly the same bounds as on POST /customer/birthday; breaking them answers INVALID_INPUT and writes nothing.
Not claimable through this endpoint, by design: custom_program (awarded by its own trigger), write_review (granted by the review app), Instagram engagement events (granted by the Instagram integration), place_order (granted automatically on the order) and milestone. All five answer INVALID_PROGRAM with a message that says which.
milestone is refused here specifically because this endpoint cannot check entitlement for it. The underlying grant takes the target value from the caller and trusts that the caller already verified the condition — which is true of the Shopify Flow action it was written for, and false of a shopper holding a token. Milestones are claimed through POST /customer/milestones/{programId}/claim, which re-reads the step from the shop's own data and grants only what the shopper has actually reached.
Every guard the loyalty widget applies still applies: opt-in guest, B2B exclusion, test mode, per-program limits, the shop's global earning limit and anti-cheat.
The response is Cache-Control: private, no-store.
The earning program (from /programs, programType=earning).
Per-family input — see the table above. Ignored for families that need none.
200Claim accepted. `status: earned` means the points are already on the balance; `status: pending` means the submission is awaiting merchant approval and `pointsEarned` is what was granted immediately (often 0).
trueShow properties
The earning program that was claimed.
Program event key — same values as Program.key (e.g. follow_instagram, subscribe_youtube). Clients can use this to match the card it came from without holding an id-to-key map.
Points actually added. May be LOWER than the program's advertised points when the shop's global earning limit partially caps the award. Clamped at 0.
Customer balance after the award.
earned — the points are on the balance now. pending — the submission (receipt, survey answer without auto-approve, Google review awaiting moderation) is queued for merchant approval and the points land when it is approved. pointsEarned may still be greater than 0 on a pending claim when the program grants an instant reward.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — no `programId`, or `context` is not an object. `INVALID_PROGRAM` — program not found, in another shop, not an earning program, disabled, or an event this endpoint deliberately does not serve. `INVALID_INPUT` — the family's required `context` field is missing or malformed (`urlReview`, `images`, `submittedContent`, `birthday`), or `context.birthdayRewards` fails `BirthdayRewardsMap`. `LIMIT_REACHED` — a per-program, shop transaction, or global earning limit stopped the award. `ALREADY_SUBMITTED` — already claimed for this period (this covers `streak_interact_website`'s ordinary same-day cooldown too — check `Program.streak.canClaimToday` before calling to avoid it), birthday already on file, review already submitted. `LINE_NOT_LINKED` — the program is `Program.lineJoinType === 'follow_oa'` and this customer has no linked LINE account yet; call `POST /customer/social/line/auth-url` first. `EARN_FAILED` — the claim could not be processed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — the customer does not qualify to earn points (opt-in guest not yet enrolled, B2B account excluded, or earning is disabled in test mode).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/earn HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 44{"programId": "string","context": {}}
{"success": true,"data": {"programId": "string","key": "string","pointsEarned": 1,"totalPoints": 1,"status": "earned"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Store images for an earning claim
Uploads receipt / submission images and returns their public URLs, which the caller passes back to POST /customer/earn as context.images (receipt) or context.imageUrls (submit form / survey). /customer/earn takes URLs, never base64, so this call has to happen first.
Images arrive base64-encoded (raw or as a data URL) — no multipart, so the same call works from a React Native client with no File/FormData.
Validated at the boundary and rejected as a whole batch if ANY image fails: JPEG or PNG only (checked against the file's own bytes, not just the declared type), at most 5MB per image and 3 images per request (the request body itself is capped at 32MB). Split larger batches across calls.
Show properties
Base64 image data, with or without a data:image/png;base64, prefix.
Original file name — used only in error messages.
200Every image stored
trueShow properties
Stored image URLs, in the same order as the request. Pass them to POST /customer/earn as context.images (receipt) or context.imageUrls (submit form / survey).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`INVALID_INPUT` — no images, more than 5, a type other than JPEG/PNG, an image over 10MB, or data that is not a valid image of the declared type. `UPLOAD_FAILED` — the file could not be stored; no image in the batch was kept.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/earn/upload HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 125{"images": [{"imageBase64": "string","imageName": "string","imageType": "image/jpeg"}]}
{"success": true,"data": {"urls": ["string"]},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Earn points for a social action (follow, share, join)
Earns points for the JWT's customer. The customer is taken from the token, never from the body — clients cannot earn on behalf of anyone else.
The server derives the event and earn flag from the program document itself, so the request body carries only the programId. A client cannot pick which event it earns for; the program's event determines that. Only the 13 social earning programs are accepted: follow_instagram, like_facebook, follow_tiktok, share_facebook, subscribe_youtube, follow_twitter, follow_pinterest, share_twitter, join_discord, join_telegram, join_whatsapp, join_line, join_reddit. Note that the Facebook event is like_facebook, not follow_facebook.
A join_line program with Program.lineJoinType === 'follow_oa' only pays out once the customer has a linked LINE account — this endpoint answers LINE_NOT_LINKED until the client has run the OAuth flow via POST /customer/social/line/auth-url. join_link programs (and every other social event) are unaffected — still claim-on-click.
The same anti-cheat and per-program earning limits the loyalty widget enforces apply to this endpoint, so a repeat claim to the same program answers LIMIT_REACHED rather than granting points twice.
The response is Cache-Control: private, no-store — personalised data, like every /customer/* endpoint.
The social earning program (from /programs, programType=earning).
200Points earned — shopper may redeem the balance toward rewards
trueShow properties
The earning program that was claimed.
Program event key — same values as Program.key (e.g. follow_instagram, subscribe_youtube). Clients can use this to match the card it came from without holding an id-to-key map.
Points actually added. May be LOWER than the program's advertised points when the shop's global earning limit partially caps the award. Clamped at 0.
Customer balance after the award.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400Invalid program or limit reached. `BAD_REQUEST` — no `programId` in the request. `INVALID_PROGRAM` — program not found, wrong shop, not an earning program, its event is not in the 13 social events, or program is disabled. `LIMIT_REACHED` — the customer has already earned points for this program (per-program limit), or the shop's global earning limit or transaction limit was reached. `LINE_NOT_LINKED` — a `follow_oa` `join_line` program and this customer has no linked LINE account yet. `EARN_FAILED` — an internal error occurred while processing the claim.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — the customer does not qualify to earn points (opt-in guest not yet enrolled, B2B account excluded, or earning is disabled in test mode).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/earn/social HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 27{"programId": "string"}
{"success": true,"data": {"programId": "string","key": "string","pointsEarned": 1,"totalPoints": 1},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
One milestone program's ladder, rendered for THIS customer
Everything the loyalty page's milestone modal draws, already rendered: the step sentence, the reward lines, the progress percentage and the "how much is left" line.
Fetch this when the shopper OPENS the milestone, not with the program list. The counters behind it are the most expensive reads on this surface — milestone_order is a Shopify Admin API call that paginates the customer's whole order history — so they are computed only for the step types THIS program uses, and cached 60s per customer.
milestone_inactivity programs return steps: [] with supported: false: the storefront has never rendered a ladder for them (the loyalty page has no counter branch for that type either), and pretending to would show every step as locked forever.
The response is Cache-Control: private, no-store.
An earning program whose key is milestone, from /customer/programs.
200The ladder, personalised
trueOne milestone program, with every step rendered for the caller.
Show properties
Program title in the served locale. '' when unset, never null.
"2 / 5 Rewards" — how many steps this customer has already been rewarded for, out of the program's total. Rendered and pluralised server-side; print it as-is.
"2 / 5 Rewards"False for program types with no storefront ladder (milestone_inactivity). steps is empty; render the program's own description instead of an empty track.
"Your order needs to be at least $50" / "You need to order at least 3 items" — already rendered and localised. Only present for order/spend programs (and mixed programs containing such a step) that set one. Null otherwise.
"Your order needs to be at least $50"Show properties
Opaque step identity, echoed back on claim. A raw milestone value on a plain program; {programId}_{stepType}_{value} on milestone_mixed. Also the React key — stable across refetches.
"500"This step's own metric — the program's typeMilestone on a plain program, the step's own on milestone_mixed. Drives which built-in icon the client draws in the circle.
The step sentence: "After spending $500", "After completing 3 orders", or the merchant's own per-step description when they wrote one (their translation for the served locale wins). '' only when the step type has no sentence.
"After spending $500"locked — not reached. current — the step being worked towards; the only one carrying progress. claimable — reached, rewards still to pick: render the radio list and the Claim button. achieved — reached and rewarded. passed_unclaimed — the counter passed this step but no reward was recorded; render note as a tooltip beside the label.
Whether the circle and the connector above it are filled. Separate from state on purpose — on milestone_mixed a step counts as reached by its position in the sequence, not by its own counter.
Tooltip text for passed_unclaimed. Null in every other state.
How many rewards the shopper may pick when state is claimable. 1 unless the merchant raised it. The Claim button stays disabled until exactly this many are selected.
1Present only when state is current.
Show properties
0-100, already clamped — width of the bar.
42"42% completed", localised and lowercased as the loyalty page renders it.
"Need 3 orders" / "Need $250" — the unit is pluralised and localised server-side. Null at 100%.
Show properties
Position in this step's rewards. The value to send in selectedRewardIds — the client never sends the reward itself.
"Receive 100 points", "Receive $5 off", "Plant 2 trees" — or the merchant's own reward description when they wrote one. Localised, print as-is.
"Receive 100 points"A free product whose variant this shopper still has to choose. Render giftProducts as a picker and send the choice in variantIds — without it the coupon would entitle every variant of the product.
Empty unless needsVariantChoice. Same shape as AvailableReward.giftProducts.
Show properties
Shopify product id (numeric, unprefixed).
Show properties
Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`INVALID_PROGRAM` — not found, in another shop, not an earning program, not a milestone program, or disabled.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/milestones/{programId} HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"programId": "string","title": "string","subheading": "2 / 5 Rewards","typeMilestone": "milestone_order","supported": true,"orderRequirement": "Your order needs to be at least $50","steps": [{"claimKey": "500","stepType": "string","label": "After spending $500","state": "locked","reached": true,"note": "string","rewardLogic": "grantAll","maxRewardSelect": 1,"progress": {"percent": 42,"completedText": "string","remainingText": "string"},"rewards": [{"index": 1,"action": "bonus_point","text": "Receive 100 points","needsVariantChoice": true,"giftProducts": [null]}]}]},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Claim the rewards of one achieved milestone step
For a step whose state is claimable — the merchant configured "customer picks" and the shopper has reached it but not yet chosen — and for a granted step that still owes a free-gift variant choice (rewards[].needsVariantChoice).
The customer is taken from the token, never from the body. The milestone and its rewards are re-read on the server and matched by claimKey; selectedRewardIds are INDEXES into the server's own rewards array, so a client can name which reward it wants but never what that reward is.
The step is removed from the customer's pending list atomically, so two taps of the Claim button grant once.
The response is Cache-Control: private, no-store.
The step's claimKey from GET /customer/milestones/{programId}. Opaque — a raw milestone value on a plain program, a compound key on milestone_mixed.
"500"Indexes into the step's rewards array. Exactly maxRewardSelect of them, each in range, no duplicates — anything else is INVALID_SELECTION.
[0,2]Chosen product variant per reward index, for rewards with needsVariantChoice: true. Keys are the index as a string. A variant that is not on that reward's own allow-list is rejected — the coupon would otherwise entitle every variant of the product.
{"0":"44332211"}200Rewards granted
trueShow properties
Points actually added. 0 when no bonus_point reward was picked, and possibly 0 when one was but the shop's global earning limit blocked it — blocked says which.
Customer balance after the grant.
True when every point reward picked was refused by the shop's global earning limit. The step still counts as claimed; tell the shopper rather than showing a silent +0.
Show properties
Opaque step identity, echoed back on claim. A raw milestone value on a plain program; {programId}_{stepType}_{value} on milestone_mixed. Also the React key — stable across refetches.
"500"This step's own metric — the program's typeMilestone on a plain program, the step's own on milestone_mixed. Drives which built-in icon the client draws in the circle.
The step sentence: "After spending $500", "After completing 3 orders", or the merchant's own per-step description when they wrote one (their translation for the served locale wins). '' only when the step type has no sentence.
"After spending $500"locked — not reached. current — the step being worked towards; the only one carrying progress. claimable — reached, rewards still to pick: render the radio list and the Claim button. achieved — reached and rewarded. passed_unclaimed — the counter passed this step but no reward was recorded; render note as a tooltip beside the label.
Whether the circle and the connector above it are filled. Separate from state on purpose — on milestone_mixed a step counts as reached by its position in the sequence, not by its own counter.
Tooltip text for passed_unclaimed. Null in every other state.
How many rewards the shopper may pick when state is claimable. 1 unless the merchant raised it. The Claim button stays disabled until exactly this many are selected.
1Present only when state is current.
Show properties
0-100, already clamped — width of the bar.
42"42% completed", localised and lowercased as the loyalty page renders it.
"Need 3 orders" / "Need $250" — the unit is pluralised and localised server-side. Null at 100%.
Show properties
Position in this step's rewards. The value to send in selectedRewardIds — the client never sends the reward itself.
"Receive 100 points", "Receive $5 off", "Plant 2 trees" — or the merchant's own reward description when they wrote one. Localised, print as-is.
"Receive 100 points"A free product whose variant this shopper still has to choose. Render giftProducts as a picker and send the choice in variantIds — without it the coupon would entitle every variant of the product.
Empty unless needsVariantChoice. Same shape as AvailableReward.giftProducts.
Show properties
Shopify product id (numeric, unprefixed).
Show properties
Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `claimKey` or `selectedRewardIds` missing/malformed. `INVALID_PROGRAM` — program not found, in another shop, or not a milestone program. `INVALID_STEP` — no step with that `claimKey`. `NOT_CLAIMABLE` — the step is not pending for this customer (not yet reached, or already claimed). `INVALID_SELECTION` — wrong number of rewards, an index out of range, or a duplicate. `GIFT_SELECTION_REQUIRED` — a chosen reward offers several variants and none was sent. `GIFT_UNAVAILABLE` — the sent variant is not on the reward's allow-list. `CLAIM_FAILED` — the grant could not be processed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — earning is disabled for this customer (test mode, B2B exclusion, opt-in guest not yet enrolled).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/milestones/{programId}/claim HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 109{"claimKey": "500","selectedRewardIds": [0,2],"variantIds": {"0": "44332211"}}
{"success": true,"data": {"claimedRewardIds": [1],"pointsEarned": 1,"totalPoints": 1,"blocked": true,"step": {"claimKey": "500","stepType": "string","label": "After spending $500","state": "locked","reached": true,"note": "string","rewardLogic": "grantAll","maxRewardSelect": 1,"progress": {"percent": 42,"completedText": "string","remainingText": "string"},"rewards": [{"index": 1,"action": "bonus_point","text": "Receive 100 points","needsVariantChoice": true,"giftProducts": [{"id": null,"title": null,"image": null,"variants": null}]}]}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's milestone progress (flat counters map)
A flat {[typeMilestone]: number} map of this customer's current metric across every ACTIVE milestone program (order, spent_amount, earned_point, reviews, referral, subscription; a mixed program keys by its own step types) — COUNTERS ONLY, no step thresholds or configuration. Pair with GET /customer/milestones/{programId} for the rendered ladder of a single program. Empty object when the shop runs no active milestone program.
The response is Cache-Control: private, no-store.
Narrow the map to one milestone typeMilestone. Omit for every active program.
200Milestone progress counters
trueResponse for GET /customer/milestone. A flat map, keyed by typeMilestone (order, spent_amount, earned_point, reviews, referral, subscription; a mixed program keys by its own step types), of this customer's current metric for every ACTIVE milestone program. COUNTERS ONLY — no step thresholds or configuration; pair with GET /customer/milestones/{programId} for the rendered ladder. Empty object when the shop runs no active milestone program.
{"order":3,"spent_amount":128.5}Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `type` sent more than once (not a single string).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/milestone HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"order": 3,"spent_amount": 128.5},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Claim a reached milestone step
The shopper pressing "Claim" on a reached milestone step in the V4 widget's milestone timeline (joy-milestone-claim). The customer and shop are ALWAYS the JWT claims — a client can never claim on behalf of anyone else.
SELECTIONS ONLY. The body carries identifiers and choices, NEVER reward definitions. selectedRewardIds are INDEXES into the server's own step.rewards array; variantIdsSelected names a chosen variant per free-gift reward index, validated against that reward's own allow-list.
The step must already be reserved as pending on the customer (i.e. actually reached and not yet claimed), else NOT_CLAIMABLE. Pending removal and the reward grant run together, so a double tap grants once.
The response is Cache-Control: private, no-store.
A milestone earning program, from /customer/programs.
The raw step value (for a mixed program, still the raw step value, not the composite key). Echo the program's own milestone value verbatim so it matches server-side.
500Indexes into the SERVER-resolved step.rewards. Absent or empty claims the whole step.
[0]Mixed programs only — the step's type (e.g. spent_amount).
Mixed programs only.
Whether a variant was picked for a free-gift reward.
{rewardIndex-${i}: variantId} — the chosen variant per free-gift reward index. A variant not on that reward's own allow-list is rejected (GIFT_UNAVAILABLE); the coupon would otherwise entitle every variant of the product.
{"rewardIndex-0":"44332211"}200The step was claimed
trueResponse for POST /customer/milestone/claim. The widget reads only point (the post-claim balance) to repaint the balance. The response also echoes the customer's updated pending-milestone array under its type-specific key (pendingMilestoneOrder, pendingMilestoneMixed, pendingMilestoneReferral, pendingMilestoneSpentAmount, pendingMilestoneEarnPoint, pendingMilestoneReviews, pendingMilestoneSubscription, or pendingMilestoneInactivity) with the claimed key removed; the widget re-derives its timeline from its own refresh, so that key is not pinned here.
Show properties
The customer's spendable balance after the claim.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `programId`/`milestoneValue` missing or malformed. `INVALID_PROGRAM` — not a milestone program. `INVALID_STEP` — no step with that value. `NOT_CLAIMABLE` — the step is not pending for this customer (not yet reached, or already claimed). `GIFT_SELECTION_REQUIRED` — a chosen free-gift reward offers several variants and none was sent. `GIFT_UNAVAILABLE` — the sent variant is not on the reward's allow-list.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — claiming is disabled for this customer (the shop is in test-earning mode and the customer is not white-listed).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"404`INVALID_PROGRAM` — program not found or in another shop.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/milestone/claim HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 238{"programId": "string","milestoneValue": 500,"selectedRewardIds": [0],"stepType": "string","isMixed": true,"isSelectVariantFreeGiftMilestone": true,"variantIdsSelected": {"rewardIndex-0": "44332211"}}
{"success": true,"data": {"point": 1},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's environmental-impact redemption history + totals
This customer's environmental-impact redemptions (tree-planting / plastic-removal), paginated, plus the reconciled cumulative GoodAPI totals.
connected: false means totals are the last-known CACHED values (a GoodAPI reconciliation failure), not that the customer has no impact — the reconciliation read is failure-isolated so an outage never blanks the history list.
The response is Cache-Control: private, no-store.
Page size (default 20, clamped to 50).
200Impact history page
trueResponse for GET /customer/impact-history.
Show properties
Whether the shop's GoodAPI integration answered live for this request. false means totals are the last-known cached values, not that the customer has no impact.
Show properties
Show properties
Units funded (trees planted / bottles removed).
The redemption's impact lifecycle state. confirmed is the default; refunded is a failed event whose points were returned. Other raw provider statuses pass through unchanged.
Show properties
Show properties
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/impact-history HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"connected": true,"totals": {"trees": 1,"plasticBottles": 1},"events": [{"id": "string","type": "tree","quantity": 1,"status": "string","createdAt": "string"}]},"meta": {"pagination": {"hasNext": true,"hasPre": true}},"timestamp": "2026-07-31T07:27:54.123Z"}
Save a tier-privilege perk's variant selection
The shopper confirming a choice-mode tier-PRIVILEGE perk in the V4 widget (joy-tier-perk-picker, claimKind: privilege). Persists the picked variants to the customer's tierData AND the Shopify vipTier metafield the product-discount Rust function reads at checkout. The customer and shop are ALWAYS the JWT claims — the body carries only the tier/program/variant selection, never identity.
Response data.tierData is the customer's updated tier data the widget merges to flip the picker to the confirmed-selection view.
The response is Cache-Control: private, no-store.
The tier the perk belongs to, from /customer/tier-rewards.
The tier-privilege program (perk), from /customer/tier-rewards.
The picked variant ids. Every id must be on the perk's own configured pool, else PERK_VARIANT_UNAVAILABLE.
["44332211","44332212"]200The selection was saved
trueShow properties
The customer's tierData after the save, keyed [tierId][programId].variantIdsSelected.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `tierId`/`programId` missing, or `variantIds` not a non-empty array. `PERK_VARIANT_UNAVAILABLE` — a submitted variant is not on the perk's configured pool.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
404`INVALID_PROGRAM` — program not found or in another shop.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/perk/save-variant-selection HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 103{"tierId": "string","programId": "string","variantIds": ["44332211","44332212"]}
{"success": true,"data": {"tierData": {}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's VIP tiers with their assigned rewards (perks)
This customer's VIP tiers, filtered to the ones they can see, each carrying its assigned tier-rewards (spending-program perks) in TWO shapes on the same Tier object:
200The customer's visible tiers, with rewards
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"401
429
GET /app/api/v1/headless/customer/tier-rewards 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"}
This customer's own unclaimed tier-up rewards
This customer's OWN pending tier-reward claims — tier-up rewards they still have to claim by picking a product/variant. Each entry's id is what POST /customer/pending-rewards/{id}/claim targets, and products carries prefetched product/variant views for that claim's picker (empty for allProducts / product_selection docs).
The response is Cache-Control: private, no-store.
200Pending tier-reward claims
trueShow properties
The claim's own id — what POST /customer/pending-rewards/{id}/claim targets.
The tier-reward program it came from; null on docs issued before that was recorded.
The tier that granted it; null on docs issued before that was recorded.
The tier name the reward came from — rendered as the claim's title.
product_selection (pick from a fixed list), variant_price_difference (a discount toward a chosen variant), or null on older docs.
Lifecycle status — pending, or claimed for a completed product_selection.
Whether the reward covers every product.
Product ids the reward is scoped to; empty when allProducts is true.
The source program's event; null when not recorded.
How many times this pending doc may be claimed.
How many times it has already been claimed.
Prefetched product/variant views for the claim's product picker; empty for allProducts / product_selection docs.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/pending-rewards HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","programId": "string","tierId": "string","title": "string","claimType": "string","status": "string","allProducts": true,"selectedProductIds": ["string"],"rewardProductQuantity": 1,"event": "string","maxClaims": 1,"claimedCount": 1,"expiredAt": "string","createdAt": "string","products": [{}]}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Claim an entry-reward pending doc
The shopper confirming a choice-mode ENTRY-reward in the V4 widget (joy-tier-perk-picker, claimKind: entry-pending). Mints the coupon NOW for the picked product/variant and consumes the pending doc. The customer and shop are ALWAYS the JWT claims.
Only variantIds is read off the body — no productId (the picker sends variants only; the product is resolved from them).
The response is Cache-Control: private, no-store.
The pending reward id.
The picked variant ids. Required in practice for a product_selection reward (VARIANT_IDS_REQUIRED if omitted); each must be on the reward's pool.
["44332211"]200The reward was claimed
trueShow properties
The minted coupon code, or null when the reward mints none.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `variantIds` not an array. `VARIANT_IDS_REQUIRED` — no variant selected. `EXCEEDS_REWARD_QUANTITY` — more variants than the reward allows. `VARIANT_NOT_IN_POOL` / `PRODUCT_NOT_IN_LIST` — a selected variant/product is not on the reward.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`FORBIDDEN` — the pending doc is not this customer's.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"404`NOT_FOUND` — no pending reward with that id. `PROGRAM_NOT_FOUND` — the reward's program is gone.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/pending-rewards/{id}/claim HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 40{"variantIds": ["44332211"]}
{"success": true,"data": {"couponCode": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Loyalty Page V2 — the shopper-specific half of the page
Returns the signed-in shopper's loyalty-page state — points, tier, birthday eligibility, earning-limit status, milestone progress and Shopify customer tags — plus primaryTranslation, the page's text in the requested locale.
The customer comes from the token. If it no longer resolves to a Joy customer, data.customer is {} (the guest shape) rather than an error.
The shopper's locale (e.g. fr) — data.primaryTranslation is served in it. Omit for the shop's primary language.
'true' (exact string) opts this page load into the sign-up earn promotion.
200Loyalty page state for the signed-in shopper.
trueData for GET /customer/loyalty-page/home. A different shape from Customer (GET /customer).
Show properties
The signed-in shopper's loyalty-page state, or {} when the token's customer is no longer a Joy customer. Additional earn-status fields may be present.
Show properties
Shopify numeric customer id.
The customer's Shopify tags (used for program eligibility on the page).
Current spendable balance (note the singular name on this route).
Whether the customer can still set a birthday for the birthday program.
Earning-limit status; null when the shop has no global earning limit.
The page's text, resolved for the requested locale (merchant overrides applied).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/loyalty-page/home HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"customer": {"id": "string","shopifyCustomerId": 1,"email": "string","firstName": "string","lastName": "string","type": "guest","tags": ["string"],"point": 1,"pointExpiredAt": "string","tierId": "string","tierPoint": 1,"tierData": {},"birthday": "string","canJoinBirthday": true,"earningLimitStatus": {},"tierAssessmentStatus": {}},"primaryTranslation": {}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Loyalty Page V2 — Apple/Google wallet pass config
Split out rather than folded into home because the wallet block sits far below the fold and its enable flags already travel inline — the page fetches this lazily, if at all.
Shop-scoped only; performs no customer read.
200Wallet pass settings
trueShow properties
Empty object when the shop has no pass configured.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/loyalty-page/wallet-state HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"applePassWalletSetting": {}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Per-program canEarn for custom-event earning programs
Feeds the Ways-to-Earn section: without it, every event: 'custom' earning card renders as claimable regardless of whether this shopper has already claimed it.
The customer is ALWAYS the JWT claim, and every read is scoped to the JWT's shop.
200Map of program id to whether this customer may still earn it.
trueKeyed by program id.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/programs/custom-earn-status HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Revoke (self-refund) an issued coupon
Id of the reward to revoke.
200Coupon revoked and points refunded.
trueShow properties
Points returned to the customer's balance.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400Revoke refused. `error` carries the reason code — `REVOKE_LIMIT_EXCEEDED` (shop's revoke cap reached) or `REVOKE_FAILED` (the refund itself failed).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
404`REWARD_NOT_FOUND` — no such reward in this shop, or it belongs to another customer. The two cases are deliberately indistinguishable.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/rewards/{rewardId}/revoke HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"rewardId": "string","refundedPoints": 1,"status": "revoked"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Log out this device (revoke the calling token)
Revokes only the Bearer token used for this call; the customer's other devices stay logged in. The customer comes from the token — the request has no body. If the token cannot be revoked individually the response is revoked: false, and the token simply expires within 60 minutes. After a successful call the same token gets 401 on every /customer/* route.
200Token revoked.
trueShow properties
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/auth/logout HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"revoked": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Customer referral link + stats
Feeds joy-referral (share link, reward terms, counts).
200Referral data
trueShow properties
Null for guests or when referral is disabled.
Human-readable reward the referred friend receives.
Show properties
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/referral HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"referralLink": "string","referralCode": "string","rewardForFriend": "string","rewardForReferrer": "string","stats": {"completed": 1,"pending": 1,"pointsEarned": 1}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's OWN referred-friend history (paginated)
This customer's referred-friend rows, 5 per page (fixed — not caller-controlled), newest first, keyed on the resolved Joy customer's OWN email; a client can never read another referrer's history.
200Referred-friend history page
trueShow properties
The referred friend's email, OBFUSCATED server-side (j******@example.com) — a referrer sees who they referred, never the friend's full address.
Show properties
Show properties
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/referral-history HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","email": "string","status": "complete","createdAt": "string"}],"meta": {"pagination": {"hasNext": true,"hasPre": true}},"timestamp": "2026-07-31T07:27:54.123Z"}
Send this customer's referral invite to a friend by email
Automated-sharing-only: sends via whichever provider the shop's referral program has configured (Shopify Flow, Klaviyo, or Joy's own notification). This endpoint reads only refereeEmail off the body; a customerId/emailCustomer sent alongside it is ignored.
The friend's email address.
200Invite sent
trueResponse for POST /customer/referral/send-email.
Show properties
Always true on success — a declined send rejects instead of resolving false.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`INVALID_EMAIL_FORMAT` — `refereeEmail` missing or not a valid email. `REFERRAL_SELF_REFERRAL` — `refereeEmail` is the caller's own address. `REFERRAL_EMAIL_SHARING_DISABLED` — the shop's referral program is not on automated email sharing (or none is configured). `REFERRAL_EMAIL_NOTIFICATION_DISABLED` — the shop's referral email notification is turned off.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429`REFERRAL_EMAIL_COOLDOWN` — this referrer already invited this same `refereeEmail` in the last 24h. `REFERRAL_EMAIL_LIMIT_REACHED` — the shop's sharing rate limit was hit.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/referral/send-email HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 30{"refereeEmail": "string"}
{"success": true,"data": {"sent": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Gift a coupon this customer redeemed to a friend
Gifts a coupon THIS customer already redeemed to a friend by email.
The coupon must be one THIS customer redeemed from programId (ownership gate against getRewardByCouponCode) — otherwise any code in the shop could be gifted to an outsider via the discount update. A customer cannot gift to themselves, nor re-gift the exact same coupon to a friend who already received it.
Show properties
The friend's email address.
ISO date-time to defer the notification to. Accepted for forward compatibility but not yet acted on — the notification is always sent immediately.
The redeemed coupon to gift — must be one THIS customer redeemed from programId.
The spending program the coupon came from.
200Reward sent
trueResponse for POST /customer/send-reward-to-friend.
Show properties
Always true on success — the call has no partial outcome to report.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `programId`/`couponCode` missing, `recipient` missing/malformed, `recipient.email` missing or not a valid email, `recipient.name`/`recipient.message` not a string. `SEND_REWARD_DISABLED` — the program's send-to-friend feature is off. `SEND_REWARD_SELF_SEND` — the recipient is the caller's own email. `SEND_REWARD_NOTIFICATION_DISABLED` — the notification is turned off. `SEND_REWARD_RECIPIENT_ALREADY_SENT` — this friend already received this exact coupon.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`SEND_REWARD_COUPON_NOT_OWNED` — the coupon is not a reward this customer redeemed from this program (or was revoked).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"404`PROGRAM_NOT_FOUND` — the program is unknown or in another shop.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429`SEND_REWARD_COOLDOWN` / `SEND_REWARD_LIMIT_REACHED` — rate limits on this gifting action.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"502`SEND_REWARD_DISCOUNT_UPDATE_FAILED` — the Shopify discount update failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/send-reward-to-friend HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 180{"recipient": {"email": "string","name": "string","message": "string","sendNotificationAt": "string"},"couponCode": "string","programId": "string"}
{"success": true,"data": {"sent": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Shopify-segment eligibility for segment-gated spending programs
Answers "is this customer inside the Shopify segments that gate these spending programs?" as a {programId: boolean} map, so a client can drop a reward it would only fail to redeem.
Send ONLY the programs that are actually gated — a spending program whose userAvailability is user_in_segment with a non-empty appliedSegments. Everything else needs no lookup: each distinct segment costs a Shopify Admin membership query. At most 100 entries and 250 unique segment ids per request.
Fail-closed. A program the server could not resolve membership for comes back false. An unproven membership on a gated reward must hide it, never reveal it.
The customer is ALWAYS taken from the token. The segment IDs and names are never echoed back: they are merchant marketing data about the shopper, not shopper data.
POST rather than GET because the program/segment pairs are a body-sized list, and the answer is per-customer — it must never be cached. The response is Cache-Control: private, no-store.
Show properties
The program's own appliedSegments; ANY match means eligible.
200Eligibility per requested program.
true{programId: boolean} — true means the customer is inside at least one of that program's gating segments. Every requested program is present; an unresolved one is false, never omitted.
{"prog_vip_only":true,"prog_winback":false}Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `segmentPrograms` is not an array, holds more than 100 entries or 250 unique segment ids, an entry is missing `programId` / `segmentIds`, or a segment id is not a Shopify id.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/segment-program HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 118{"segmentPrograms": [{"programId": "string","segmentIds": ["string"]}]}
{"success": true,"data": {"prog_vip_only": true,"prog_winback": false},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Self-enrol an opt-in guest into the loyalty program
The headless door for the storefront's opt-in "Join Program" button. Only meaningful when ShopConfig.optInEnrollmentEnabled is true AND Customer.type is guest — a client checks both before showing the button — but this endpoint re-checks both server-side rather than trusting the client's last fetch.
Idempotent: a customer who is already a member gets back the same 200 + Customer shape as a fresh join, not an error, so a client does not need to guard the call behind its own Customer.type check.
The customer is taken from the token, never from the body — clients cannot enrol anyone else. The response is Cache-Control: private, no-store.
Attribution surface. Defaults to widget when omitted or not one of the listed values.
200Enrolled (or already enrolled) — the customer's fresh state.
trueShow properties
Whether this customer has joined the loyalty program. Combine with ShopConfig.optInEnrollmentEnabled to decide when to show a "Join Program" button — see POST /customer/join-program. A customer who has left the program also reports guest here (no third wire value) — the join endpoint rejects a re-join attempt for that case with NOT_ELIGIBLE rather than silently no-op'ing.
Current spendable balance.
The customer's order count, read live from Shopify when available.
Show properties
Progress toward the next tier; null when already at top tier.
Show properties
0–100.
Entry threshold of the customer's current tier (0 for the base tier).
Entry threshold of the next tier.
Whether this customer has ever synced as subscribed to marketing emails. null when never synced (unknown), distinct from false (synced, not subscribed). Use this ONLY to show opt-in state and disable an already-checked opt-in control — the server can only ever subscribe a customer, never unsubscribe one, so a client must NOT offer an "unsubscribe" affordance driven by this field.
Customer's avatar image URL. null when the customer has none.
Whether this customer has ever synced as subscribed to SMS marketing. Same null-means-unknown contract as marketingConsent above, for the SMS channel.
The shop's global (shop+customer scoped, NOT per-program) earning limit — null when the shop has the feature off. This is a purpose-built shopper-facing field, not the shop's limit configuration (max points, timeframe, tier table); those are never exposed on this contract.
Show properties
Whether the customer has hit the limit for the current period.
Merchant-formatted date the period resets. Empty string for a customer whose tier has an unlimited override.
Server-rendered warning sentence, ready to show as-is. null when reached is false — there is no sentence to show until then.
KNOWN QUIRK, ported faithfully: once set, it can never be changed — the guard's own message says "once per year", but there is no year check or reset job behind it, so it is a ONE-TIME submission in practice. A client should treat a non-null value here as permanently locked, not just locked for this year.
"07/13"'YYYY-MM-DD'. Only ever set on shops asking for a year (ShopConfig.isNewFormatDOB); null on shops that only collect day/month.
"1990-07-13"The customer's phone (Shopify-synced), or null when none is on file. Also echoed by PATCH /customer/profile so a just-saved phone reads back identically. Absent on older payloads.
The tier-progress running total the tier ring is drawn from — the core recomputes the ring off this rather than trusting tierProgress.percentage alone. 0 (not null) when the customer is below the first threshold. Absent on older payloads.
Which social accounts this customer has linked — the state the Instagram / LINE / Facebook earning modals branch on. ALWAYS present (never null): "not linked" is a known state for every customer, so a client can read social.lineLinked unguarded. Raw third-party identifiers are deliberately NOT on this contract — see lineLinked and facebookConnected.
Show properties
The handle the customer entered via POST /customer/social/instagram/link; null when they have never linked one (or unlinked). Unlike the LINE/Facebook fields this IS the raw value, because the modal renders it: it pre-fills the input and prints the current username. Never verified for ownership — Instagram linking is a self-declared string, not OAuth.
"joyshopper"Whether the customer completed LINE Login (POST /customer/social/line/auth-url → OAuth popup). A boolean, not the LINE user id — a client needs only the answer, not the platform identifier. REQUIRED READ before claiming a join_line program whose Program.lineJoinType is follow_oa: POST /customer/earn rejects those with LINE_NOT_LINKED (400) until this is true, so a client must open the OAuth popup instead of attempting the claim.
Whether the customer also follows the shop's LINE Official Account. A SEPARATE step from lineLinked — set by the LINE follow/unfollow webhook, and it can go back to false if they unfollow. Drives whether a "Follow" CTA still shows.
Whether the customer has a Messenger link that is still usable — i.e. they linked one AND it is scoped to the page the shop currently has connected. Resolved server-side; false (not stale-true) after a merchant reconnects a different page, since every previously stored link becomes unreachable. The underlying Page-Scoped User ID is never exposed: a client needs only this boolean to choose between the connect and disconnect branches (POST /customer/social/facebook/disconnect).
Display name Meta returned when the link was made, for a "Connected as {name}" label. Null when facebookConnected is false, and null when Meta sent no name — render a bare "Connected" in that case.
Whether this customer has an Apple Wallet pass on file, created via POST /customer/wallet-pass/apple. Combine with qrCodeDownloadApplePass to decide whether to show an "Add to Apple Wallet" button or a re-download QR code.
PNG data URL (data:image/png;base64,...) of the customer's Apple Wallet pass download link, pre-rendered server-side. null when createdApplePass is false. The SAME value POST /customer/wallet-pass/apple returns as qrUrl on creation — this field is what lets a later GET /customer re-show it without re-generating the pass.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
403`OPT_IN_NOT_ENABLED` — the shop is not running opt-in enrollment (manual assignment, or below Premium). `NOT_ELIGIBLE` — the customer has left the loyalty program, or is excluded as a B2B account.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/join-program HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 26{"joinFrom": "widget"}
{"success": true,"data": {"id": "string","type": "guest","points": 1,"totalOrders": 1,"totalEarned": 1,"tier": {"id": "string","name": "string"},"tierProgress": {"nextTierName": "string","pointsToNext": 1,"percentage": 1,"currentTarget": 1,"nextTarget": 1},"memberSince": "2024-01-15T10:30:00.000Z","marketingConsent": true,"avatarUrl": "string","smsMarketingConsent": true,"earningLimit": {"reached": true,"resetDate": "string","message": "string"},"birthday": "07/13","dateOfBirth": "1990-07-13","phone": "string","tierPoint": 1,"social": {"instagramUsername": "joyshopper","lineLinked": true,"lineFollowedOA": true,"facebookConnected": true,"facebookName": "string"},"createdApplePass": true,"qrCodeDownloadApplePass": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
GET /customer/loyalty-page/rewards
Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/rewards for any other client.
Feature flag for Shopify "extra coupons" (the saved allowlist is re-read server-side).
200
Show properties
Present on a handled failure (still HTTP 200), with success false.
401
429
GET /app/api/v1/headless/customer/loyalty-page/rewards HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{}],"pageInfo": {"hasNext": true,"hasPre": true},"error": "string"}
GET /customer/loyalty-page/activities
Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/activities for any other client.
200
Show properties
Present on a handled failure (still HTTP 200), with success false.
401
429
GET /app/api/v1/headless/customer/loyalty-page/activities HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{}],"pageInfo": {"hasNext": true,"hasPre": true},"error": "string"}
GET /customer/loyalty-page/referral-history
Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/referral-history for any other client.
200
Show properties
Present on a handled failure (still HTTP 200), with success false.
401
429
GET /app/api/v1/headless/customer/loyalty-page/referral-history HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{}],"pageInfo": {"hasNext": true,"hasPre": true},"error": "string"}
Link (or unlink) an Instagram username
Links the given username to the JWT's customer, self-reported — this does NOT verify ownership of the Instagram account. Send an empty/omitted instagramUsername to unlink.
Grants the shop's one-time "link your Instagram" reward exactly once per customer, atomically with the link write. The customer is taken from the token, never from the body — clients cannot link an account to anyone else. The response is Cache-Control: private, no-store.
Empty or omitted unlinks the current username.
200Linked (or unlinked).
trueShow properties
The linked username, normalized (trimmed, lowercased). Empty string when unlinked (an empty/omitted instagramUsername on the request).
Whether the shop's one-time "link your Instagram" reward was granted by THIS call. Always false on unlink, and false on every call after the first successful link for a customer.
Points granted by this call. 0 when rewardEarned is false.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — the customer has not joined the loyalty program (opt-in guest).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"409`USERNAME_TAKEN` — this Instagram username is already linked to a DIFFERENT customer on this shop.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/social/instagram/link HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 35{"instagramUsername": "string"}
{"success": true,"data": {"instagramUsername": "string","rewardEarned": true,"pointsEarned": 1},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Disconnect Facebook
Clears the JWT's customer's Facebook link. Takes no input. The customer is taken from the token, never from the body. The response is Cache-Control: private, no-store.
200Disconnected.
trueShow properties
Always true on success.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/social/facebook/disconnect HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"disconnected": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Start LINE Login for the current customer
Required before a join_line program configured Program.lineJoinType === 'follow_oa' will pay out (see POST /customer/earn/social) — join_link programs do not need this, they stay claim-on-click.
The OAuth callback itself is a separate, PUBLIC, unauthenticated endpoint on a fixed Joy-owned domain (no platform here supports a redirect URI per merchant domain); it is protected by a single-use CSRF state token minted by this call, not by a bearer token. It links lineUserId to the customer and, for a follow_oa program, awards the join_line points at that point — a client does not need to call /customer/earn/social again afterwards.
The customer is taken from the token, never from the body. The response is Cache-Control: private, no-store.
200Auth URL built.
trueShow properties
LINE Login OAuth URL — open it in a popup. LINE redirects back to a fixed Joy-owned callback URL (not this client's own domain; no platform here registers a redirect URI per merchant) which links the account and closes itself; the opener is not expected to navigate anywhere.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`LINE_NOT_CONNECTED` — this shop has not connected LINE.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
POST /app/api/v1/headless/customer/social/line/auth-url HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"authUrl": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Create (or regenerate) this customer's Apple Wallet pass
Renders the customer's Apple Wallet .pkpass, uploads it, and returns its public download link plus a QR data URL of that link. Takes no input; the customer is taken from the token, never from the body.
The response is Cache-Control: private, no-store.
200Pass created.
trueResponse for POST /customer/wallet-pass/apple.
Show properties
Public download link for the generated .pkpass file.
PNG data URL (data:image/png;base64,...) of url, for surfaces (e.g. in-store) that show a scannable code instead of a tap link.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
502`APPLE_PASS_GENERATION_FAILED` — pass rendering/upload failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/wallet-pass/apple HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"url": "string","qrUrl": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Verify this customer's Apple Wallet pass still exists
Confirms whether the pass document behind Customer.createdApplePass still exists. Self-healing: when it does not (e.g. deleted after a device unregister), this clears the stale createdApplePass/pass-URL fields on the customer, so a later GET /customer stops reporting a pass that no longer exists. The customer is taken from the token. The response is Cache-Control: private, no-store.
200Verified.
trueResponse for GET /customer/wallet-pass/apple/verify.
Show properties
Whether the pass document behind Customer.createdApplePass still exists. false means the customer's createdApplePass/ pass-URL fields were just cleared server-side (self-heal) — treat it the same as a customer who never created a pass.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/wallet-pass/apple/verify HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"exists": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Build this customer's "Add to Google Wallet" link
Builds the Google Wallet save link for the customer's loyalty pass. Takes no input; the customer is taken from the token, never from the body. The response is Cache-Control: private, no-store.
200Link built.
trueResponse for GET /customer/wallet-pass/google.
Show properties
Google Wallet "Add to Google Wallet" save link.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
502`GOOGLE_PASS_GENERATION_FAILED` — pass/link generation failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"GET /app/api/v1/headless/customer/wallet-pass/google HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"link": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's Shopify orders
Feeds the widget's Orders tab / order-detail view. Always returns a LIST — orderId narrows it to that one order (0 or 1 items) rather than changing the response shape, so a client never has to branch on whether it asked for one order or many.
This endpoint does not build either URL — id and lineItems[].variantId/productHandle stay in the response as raw data so a headless host can construct its own navigation (e.g. Hydrogen's own /cart route).
Numeric Shopify order id. When present, narrows the list to that one order.
200Order list (0-1 items when `orderId` was sent)
trueShow properties
Numeric Shopify order id.
"#1042"Lowercased Shopify financial status (paid, pending, refunded, …).
Null means unfulfilled — no separate unfulfilled value.
Presentment currency — what the customer actually paid in, not the shop's home currency.
Cents, presentment currency.
Shopify's own hosted, signed order-status page.
Show properties
Show properties
Coupon code, or the discount's title when it has no code (automatic/manual).
Raw Shopify DiscountApplicationAllocationMethod (e.g. ACROSS, EACH).
Raw Shopify DiscountApplicationTargetType (e.g. LINE_ITEM, SHIPPING_LINE).
Discount amount in cents, in the order's presentment currency. 0 for a percentage discount.
Set only for a percentage-off discount; null otherwise.
Show properties
Line total in cents, in the order's presentment currency.
Raw Shopify variant id, for a host to build its own cart link — this API does not assemble a "Buy again" URL itself (see Order description).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `orderId` was sent but is not a numeric Shopify order id.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
502`ORDERS_FETCH_FAILED` — the Shopify Admin API call failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"GET /app/api/v1/headless/customer/orders HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": 1,"name": "#1042","createdAt": "2024-01-15T10:30:00.000Z","financialStatus": "string","fulfillmentStatus": "fulfilled","currencyCode": "string","totalPrice": 1,"subtotalPrice": 1,"totalTax": 1,"totalDiscounts": 1,"totalShipping": 1,"orderStatusUrl": "string","paymentGatewayNames": ["string"],"customer": {"email": "string","firstName": "string","lastName": "string"},"shippingMethod": "string","discounts": [{"code": "string","type": "string","targetType": "string","amount": 1,"percentage": 1}],"lineItemCount": 1,"lineItems": [{"title": "string","quantity": 1,"price": 1,"image": "string","variantTitle": "string","variantId": "string","productId": 1,"productHandle": "string"}],"shippingAddress": {},"billingAddress": {}}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Joy point activities tied to one order
Feeds the widget's order-detail view. Same visible-activity filter as /customer/activities (customer-facing types only, adjust_point dropped when the shop hides it), additionally scoped to the JWT's own customer — orderId is caller-controlled input, so this never returns another customer's order activity.
Numeric Shopify order id.
200Activities for this order (possibly empty)
trueShow properties
Rendered sentence for the row, localised to the served locale. Falls back to the bare program title when the shop's data is too sparse to render one.
"Placed an order"Signed — positive for earn, negative for spend/expire.
The coupon this row minted (spend / referral rows), for a click-through to that coupon's detail. null on every row that issued no coupon — the majority. Absent on older backends.
The Shopify order this row was earned on (numeric id as a string), for the "view order" click-through. null on any row not tied to an order (sign-up, birthday, manual adjust). Absent on older backends.
redeem_point/earn_point.
Point balance immediately before this row.
Point balance immediately after this row.
How the row was created, e.g. user / source_headless.
Earning event key, e.g. sign_up / interact_website.
Custom-program sub-type, when set.
Source program id.
Source program title snapshot.
Embedded program snapshot, when the row carries one.
Points spent (redeem rows).
Minted discount id (redeem rows).
Legacy price-rule id (redeem rows).
This customer's own Joy customer id.
This customer's own email.
The shop id.
This customer's Shopify numeric id.
ISO timestamp of last update.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — missing `orderId`.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/activities-by-order HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": "string","type": "earn","title": "Placed an order","points": 1,"createdAt": "2024-01-15T10:30:00.000Z","couponCode": "string","orderId": "string","rawType": "string","oldPoint": 1,"newPoint": 1,"source": "string","event": "string","typeCustom": "string","programId": "string","programTitle": "string","program": {},"redeemPoint": 1,"discountId": null,"priceRuleId": null,"customerId": "string","email": "string","shopId": "string","shopifyCustomerId": 1,"updatedAt": "string"}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's own Shopify store-credit transaction history
Feeds a headless host's store-credit / wallet history view.
The Shopify store-credit account paged is resolved SERVER-SIDE from the JWT's own customer (customer(id: ...) { storeCreditAccounts }) — this endpoint never accepts an account id from the caller.
Opaque Shopify cursor from the previous page's meta.pagination.startCursor — fetch the page before it.
Opaque Shopify cursor from the previous page's meta.pagination.endCursor — fetch the page after it.
200Store-credit transaction page (possibly empty)
trueShow properties
Shopify's transaction __typename, e.g. StoreCreditAccountCreditTransaction or StoreCreditAccountDebitTransaction.
Shopify's system event for this entry (ADJUSTMENT, ORDER_PAYMENT, ORDER_REFUND, …).
The order this entry originated from, when it has one (e.g. #1042).
Set only for credit entries that expire.
Transaction amount, in currencyCode, NOT cents.
Account balance immediately after this entry, in currencyCode, NOT cents.
Show properties
Relay-style Shopify cursor pagination — distinct from PageInfo (the before/after cursors used by the other lists on this surface) because the data comes from a Shopify GraphQL connection.
Show properties
Pass as ?before= to fetch the page before this one.
Pass as ?after= to fetch the page after this one.
"2026-07-31T07:27:54.123Z"401
429
GET /app/api/v1/headless/customer/store-credit/transactions HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"type": "string","event": "string","orderName": "string","createdAt": "2024-01-15T10:30:00.000Z","expiresAt": "2024-01-15T10:30:00.000Z","amount": 1,"currencyCode": "string","balanceAfter": 1}],"meta": {"pagination": {"hasNextPage": true,"hasPrevPage": true,"startCursor": "string","endCursor": "string"}},"timestamp": "2026-07-31T07:27:54.123Z"}
This customer's saved Shopify addresses
Feeds a headless host's address book / checkout-prefill view.
200This customer's saved addresses (possibly empty)
trueShow properties
Shopify pass-through id — string or integer depending on the fetch path (numeric REST id vs GID string), not a guaranteed integer.
Whether this is the customer's default address.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
502`ADDRESSES_FETCH_FAILED` — the Shopify Admin API call failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"GET /app/api/v1/headless/customer/addresses HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": [{"id": null,"firstName": "string","lastName": "string","company": "string","address1": "string","address2": "string","city": "string","province": "string","provinceCode": "string","zip": "string","country": "string","phone": "string","isDefault": true}],"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Create or update one of this customer's addresses
Upsert: address.id present -> update that address, absent -> create a new one. address.isDefault: true also sets it as the customer's default address.
OWNERSHIP: the customer is ALWAYS resolved from the JWT, never the body — a client can never save an address on behalf of anyone else. When address.id is present it is passed straight through to Shopify's nested customers/{customerId}/addresses/{addressId} REST resource, scoped by the JWT-resolved customer's OWN Shopify id. Shopify itself rejects (404 -> ADDRESS_NOT_FOUND) an address.id that is not a child of that customer — a client naming another customer's address id can never mutate it.
Show properties
Existing Shopify address id — present to UPDATE that address, absent to CREATE a new one. Must belong to the JWT's own customer; see POST /customer/addresses description. Shopify pass-through id — string or integer depending on the fetch path.
Set to also make this the customer's default address.
200Address saved
trueA Shopify customer address.
Show properties
Shopify pass-through id — string or integer depending on the fetch path (numeric REST id vs GID string), not a guaranteed integer.
Whether this is the customer's default address.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — missing `address`, missing/non-string `address1`/`city`/`country`, or a malformed optional field.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
404`CUSTOMER_NOT_LINKED` — the resolved customer has no Shopify customer id to attach an address to. `ADDRESS_NOT_FOUND` — `address.id` was sent but does not belong to this customer (or does not exist).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
502`ADDRESS_SAVE_FAILED` — the Shopify Admin API call failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/addresses HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 337{"address": {"id": null,"firstName": "string","lastName": "string","company": "string","address1": "string","address2": "string","city": "string","province": "string","provinceCode": "string","zip": "string","country": "string","phone": "string","isDefault": true}}
{"success": true,"data": {"id": null,"firstName": "string","lastName": "string","company": "string","address1": "string","address2": "string","city": "string","province": "string","provinceCode": "string","zip": "string","country": "string","phone": "string","isDefault": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Delete one of this customer's addresses
OWNERSHIP: same nested-REST guarantee as POST /customer/addresses above — the delete call is scoped by the JWT-resolved customer's OWN Shopify id, and Shopify itself rejects (404 -> ADDRESS_NOT_FOUND) an addressId that is not a child of that customer.
The Shopify address id to delete (the id from a previous GET /customer/addresses entry).
200Address deleted
trueShow properties
trueOptional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — missing `addressId`.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
404`CUSTOMER_NOT_LINKED` — the resolved customer has no Shopify customer id. `ADDRESS_NOT_FOUND` — `addressId` does not belong to this customer (or does not exist).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
502`ADDRESS_DELETE_FAILED` — the Shopify Admin API call failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/addresses/delete HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 27{"addressId": "string"}
{"success": true,"data": {"deleted": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Update this customer's name, avatar, phone and/or marketing consent
At least one of firstName, lastName, avatarBase64, phone, acceptsMarketing, acceptsSmsMarketing is required. An omitted field is left unchanged; any one may be sent alone — e.g. a marketing-consent-only toggle never touches the name/avatar.
Empty string clears the stored first name.
Empty string clears the stored last name.
A data URL (data:image/png;base64,...) or raw base64 image, up to 10MB decoded.
Empty string clears the stored phone. Validated/synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.
Email marketing consent. Synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.
SMS marketing consent. Synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.
200Profile updated
trueResponse for POST /customer/profile.
Show properties
The saved phone, echoed back so a client can render it after a save without a second round-trip. null (not '') when the customer has none — matching Customer.phone so both endpoints read the field identically.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — none of `firstName`/`lastName`/`avatarBase64`/ `phone`/`acceptsMarketing`/`acceptsSmsMarketing` given, a non-string `firstName`/`lastName`/`phone`, a missing/empty/ over-10MB `avatarBase64`, or a non-boolean `acceptsMarketing`/`acceptsSmsMarketing`. `PROFILE_UPDATE_FAILED` — the update was declined (e.g. Shopify rejected the phone number).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
429
502`PROFILE_UPDATE_FAILED` — an unexpected error while saving.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/profile HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 159{"firstName": "string","lastName": "string","avatarBase64": "string","phone": "string","acceptsMarketing": true,"acceptsSmsMarketing": true}
{"success": true,"data": {"firstName": "string","lastName": "string","avatarUrl": "string","phone": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Set this customer's birthday (once)
Once-per-lifetime FOR THE DATE: a customer who already has a birthday on file gets ALREADY_SUBMITTED, not a silent overwrite. That refusal covers the whole request, birthdayRewards included — so this is only ever the FIRST write of a reward selection. Changing the selection afterwards goes through POST /customer/birthday-rewards, which is repeatable.
Distinct from claiming a birthday-event EARNING program via POST /customer/earn (which requires a programId and only works when such a program is configured) — this endpoint has no programId and works whether or not a birthday earning program exists at all. It also does NOT grant the annual birthday reward; that is POST /customer/birthday/claim.
MM/DD.
YYYY-MM-DD. Optional regardless of the shop's ShopConfig.isNewFormatDOB setting — send it when the shop's birthday form collects a year.
A birthday reward selection, keyed by 4-digit year, each value an array of Program.birthday[programId].rewardOptions[].id values from GET /customer/programs. The whole map is stored and replaced as a unit, so send previous years back unchanged; only the CURRENT year is ever read when a reward is granted. Unknown ids are inert rather than rejected — every consumer resolves them against the program and drops anything that is not a configured reward, and a year whose ids all fail to resolve falls back to the program's default reward. The bounds below exist because the map lands in the customer document that GET /info re-reads on every request, not because an id is dangerous.
{"2026":["reward_discount_1"]}200Birthday saved
trueResponse for POST /customer/birthday.
Show properties
MM/DD, as saved.
YYYY-MM-DD, when sent.
Whether the "completed your birthday info" bonus was granted.
Points granted by that bonus (0 when it declined).
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — missing/malformed `birthday` (not MM/DD), malformed `dateOfBirth` (not YYYY-MM-DD), or a `birthdayRewards` that fails `BirthdayRewardsMap`. `ALREADY_SUBMITTED` — this customer already has a `birthday` on file.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
POST /app/api/v1/headless/customer/birthday HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 127{"birthday": "string","dateOfBirth": "string","birthdayRewards": {"2026": ["reward_discount_1"]}}
{"success": true,"data": {"birthday": "string","dateOfBirth": "string","earnedCompleteBirthday": true,"earnPoint": 1},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Save which birthday reward(s) this customer picked
Records the shopper's reward selection for a birthday program that offers a choice (Program.birthday[programId].rewardOptions on GET /customer/programs). It CLAIMS NOTHING — the selection is resolved against the program later, when the annual reward is sent or when POST /customer/birthday/claim runs.
Its own endpoint rather than a field on POST /customer/birthday because that one is once-per-lifetime and refuses every later write; re-picking a reward has to stay repeatable. Send the WHOLE map, including previous years — it replaces the stored value rather than merging into it.
A birthday reward selection, keyed by 4-digit year, each value an array of Program.birthday[programId].rewardOptions[].id values from GET /customer/programs. The whole map is stored and replaced as a unit, so send previous years back unchanged; only the CURRENT year is ever read when a reward is granted. Unknown ids are inert rather than rejected — every consumer resolves them against the program and drops anything that is not a configured reward, and a year whose ids all fail to resolve falls back to the program's default reward. The bounds below exist because the map lands in the customer document that GET /info re-reads on every request, not because an id is dangerous.
{"2026":["reward_discount_1"]}200Selection saved
trueResponse for POST /customer/birthday-rewards. Echoes the stored map back so a client can reconcile without a re-fetch.
Show properties
A birthday reward selection, keyed by 4-digit year, each value an array of Program.birthday[programId].rewardOptions[].id values from GET /customer/programs. The whole map is stored and replaced as a unit, so send previous years back unchanged; only the CURRENT year is ever read when a reward is granted. Unknown ids are inert rather than rejected — every consumer resolves them against the program and drops anything that is not a configured reward, and a year whose ids all fail to resolve falls back to the program's default reward. The bounds below exist because the map lands in the customer document that GET /info re-reads on every request, not because an id is dangerous.
{"2026":["reward_discount_1"]}Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `birthdayRewards` missing, or failing `BirthdayRewardsMap` (not a plain object, a key that is not a 4-digit year, a value that is not an array of non-empty strings, or over the size bounds).
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
502`BIRTHDAY_REWARDS_UPDATE_FAILED` — the write itself failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/birthday-rewards HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 76{"birthdayRewards": {"2026": ["reward_discount_1"]}}
{"success": true,"data": {"birthdayRewards": {"2026": ["reward_discount_1"]}},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Claim this customer's annual birthday reward
Grants the birthday reward for a program configured with MANUAL delivery (Program.typeDeliveryMethod) — the shopper pressing "Claim" during their birthday window.
Not the same as POST /customer/birthday (which records the DATE and pays the separate "completed your birthday info" bonus), and NOT reachable through POST /customer/earn: that endpoint routes a birthday program to the same two-step as POST /customer/birthday — it saves the date and pays the completion bonus, and refuses on purpose to mint the ANNUAL reward, because the underlying earn has no year guard of its own. This endpoint is the only door to that reward.
Eligibility is decided server-side and a refusal is not itemised (a client cannot use this endpoint to probe a shop's birthday-program configuration). A claim is declined when: no active, unexpired birthday program exists; the program is on AUTOMATIC delivery; the customer's VIP tier has the birthday reward disabled; the reward was already taken this year (or ever, for a lifetime limit); the birthday was edited too recently (ShopConfig.dayAllowBirthday); or today falls outside the program's claim window (exact date / birth month / around-birthday range).
The reward is minted asynchronously, so a 200 means the claim was ACCEPTED, not that a coupon already exists. Re-fetch GET /customer and GET /customer/rewards for the result.
200Claim accepted
trueResponse for POST /customer/birthday/claim. Carries no points/coupon detail because the reward is issued asynchronously. Re-fetch GET /customer and GET /customer/rewards for the outcome.
Show properties
Always true — a declined claim is an error response, not false.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"400`BAD_REQUEST` — `birthdayRewards` failed `BirthdayRewardsMap`. `CLAIM_UNAVAILABLE` — the claim was declined; see the reasons above. Distinct from the `NOT_ELIGIBLE` below on purpose: a guest can fix their case by joining the program, a declined claim cannot be fixed by the client at all. Neither is worth retrying.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"401
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"429
502`CLAIM_FAILED` — the claim could not be processed (a datastore or Shopify fault, not a decision about this customer). Unlike the 4xx above this one IS worth retrying; the reason is logged server-side and never returned.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/birthday/claim HTTP/1.1Host: api.joy.soContent-Type: application/jsonAccept: */*Content-Length: 27{"birthdayRewards": {}}
{"success": true,"data": {"claimed": true},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}
Render this customer's in-store scan code
Returns the QR code a POS scanner reads to identify this customer, as a PNG data URL.
Takes NO input.
GET /info echoes the same value back as customer.qrImageUrl on every later fetch, once a code has been generated at least once — so a client that already calls /info does not need this endpoint to re-render it.
200QR code rendered
trueResponse for POST /customer/qrcode. Only the rendered image — never the encoded payload or any field of the customer document.
Show properties
PNG data URL (data:image/png;base64,...), 1000px square. The same value GET /info reports as customer.qrImageUrl.
Optional metadata (pagination, counts).
"2026-07-31T07:27:54.123Z"401
429
502`QR_GENERATION_FAILED` — encoding failed.
falseShow properties
"Unauthorized""UNAUTHORIZED"401"2026-07-31T07:27:54.123Z"POST /app/api/v1/headless/customer/qrcode HTTP/1.1Host: api.joy.soAccept: */*
{"success": true,"data": {"qrCode": "string"},"meta": {},"timestamp": "2026-07-31T07:27:54.123Z"}