Headless API
Customer

Customer

Personalised data — requires a Bearer JWT from /auth/token

Current customer — points, tier, tier progress

GET/customer

Feeds joy-points-balance and the personalised half of joy-membership-card.

Parameters
· objectOptional
Responses
200Customer state
successbooleanOptional
Example: true
dataCustomerOptional
Show properties
idstringOptional
typestringOptional

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.

pointsintegerOptional

Current spendable balance.

totalOrdersintegerOptional

The customer's order count, read live from Shopify when available.

totalEarnedintegerOptional
tierobjectOptional
Show properties
idstringOptional
namestringOptional
tierProgressobjectOptional

Progress toward the next tier; null when already at top tier.

Show properties
nextTierNamestringOptional
pointsToNextnumberOptional
percentagenumberOptional

0–100.

currentTargetnumberOptional

Entry threshold of the customer's current tier (0 for the base tier).

nextTargetnumberOptional

Entry threshold of the next tier.

memberSincestring · date-timeOptional
marketingConsentbooleanOptional

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.

avatarUrlstringOptional

Customer's avatar image URL. null when the customer has none.

smsMarketingConsentbooleanOptional

Whether this customer has ever synced as subscribed to SMS marketing. Same null-means-unknown contract as marketingConsent above, for the SMS channel.

earningLimitobjectOptional

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
reachedbooleanOptional

Whether the customer has hit the limit for the current period.

resetDatestringOptional

Merchant-formatted date the period resets. Empty string for a customer whose tier has an unlimited override.

messagestringOptional

Server-rendered warning sentence, ready to show as-is. null when reached is false — there is no sentence to show until then.

birthdaystringOptional

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.

Example: "07/13"
dateOfBirthstringOptional

'YYYY-MM-DD'. Only ever set on shops asking for a year (ShopConfig.isNewFormatDOB); null on shops that only collect day/month.

Example: "1990-07-13"
phonestringOptional

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.

tierPointnumberOptional

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.

socialobjectOptional

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
instagramUsernamestringOptional

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.

Example: "joyshopper"
lineLinkedbooleanOptional

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.

lineFollowedOAbooleanOptional

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.

facebookConnectedbooleanOptional

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

facebookNamestringOptional

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.

createdApplePassbooleanOptional

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.

qrCodeDownloadApplePassstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

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

GET/customer/programs

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.

Parameters
programTypequery · stringOptional

Filter to one kind; omit for both.

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

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

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

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

iconUrlstringOptional
pointsintegerOptional

Points earned (earning programs).

pointsCostintegerOptional

Points required to redeem (spending programs).

rewardTypestringOptional

Reward kind (spending programs).

rewardValuenumberOptional

Reward amount — currency or percent depending on rewardType.

redeemTypestringOptional

How to read pointsCost (spending programs; null for earning). On dynamic it is the STEP — the customer chooses a multiple of it — so a card must read "from 500 points", not "500 points". On fixed it is the price. The full slider bounds live on AvailableReward; this field exists so a signed-out card renders the same wording as the signed-in one.

actionUrlstringOptional

Where the customer goes to perform the action, resolved server-side per event. Earning programs only; always null for spending. Guaranteed http(s) — non-http schemes are dropped server-side. For the 3 social events that need it (share_facebook, share_twitter, follow_twitter) this is a ready-to-open share/follow intent URL (e.g. a facebook.com/sharer.php or twitter.com/intent link), not the merchant-entered profile URL itself — the other 10 social events, plus survey/custom-program/Google-review links and the merchant's own "Add URL on click" override, resolve to that destination unchanged.

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

How a join_line program credits points — null for every other event, and always null on a spending program. follow_oa only pays out through POST /customer/social/line/auth-url's OAuth popup (lineUserId must be set first); join_line is claim-on-click via actionUrl / POST /customer/earn/social, same as every other social event. A missing/legacy value (predates LINE OAuth) reads as join_link.

rateMoneynumberOptional

Spend-rate programs earn points per this amount of shop currency (see ShopConfig.currency / currencyFormat), e.g. points=1 with rateMoney=1 means "1 point per $1". Null for flat-rate programs and for spending programs. This is the program's BASE rate — per-tier overrides are not reflected, as the public contract has no customer context.

Example: 1
conditionsstring[]Optional

The fine print a shopper needs BEFORE spending points, rendered server-side: sale channel, which products it applies to, usable-from date, minimum order, expiry, usage limit. Always present — empty means "no restrictions", and earning programs never carry any.

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

Whether the current customer has already completed this (non-repeatable) earning program. Only present on the personalised GET /customer/programs response — omitted entirely (the field is absent, not null) from the public, CDN-shared GET /programs response, and from every spending program. Repeatable earning events (place_order, write_review, submit_receipt, submit_form, fill_survey, milestone) always report false — there is no persistent "earned" state to track for those.

formobjectOptional

Design + approval settings, submit_form and fill_survey only — null for every other event, and always null on a spending program. autoApprove matters beyond display: POST /customer/earn for these two events already reports status: earned vs pending based on this exact flag, so without it here that switch is opaque to a headless client.

Show properties
headerImagestringOptional
successIconUrlstringOptional
requireImagebooleanOptional

Whether the shopper must attach an image with their submission.

autoApprovebooleanOptional

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

instantRewardEnabledbooleanOptional

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

instantRewardPointsintegerOptional

The instant reward amount, when instantRewardEnabled is true.

reviewobjectOptional

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

Show properties
redirectUrlstringOptional

Where to send the shopper back to AFTER they finish writing the review — NOT a CTA (actionUrl is always null for write_review; this is a distinct field). Guaranteed either an absolute http(s) url or a root-relative path (/collections/all style) — non-http(s) schemes and protocol-relative (//host) values are dropped server-side. Null when the merchant left it blank — pick your own fallback (for example /collections/all).

headerImagestringOptional
extraPointsintegerOptional

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

googleReviewobjectOptional

google_maps_review only — null for every other event, and always null on a spending program. LIMITATION: the backend stores review state on the customer (customers/{id}.statusGoogleMapsReview), not per-program, so it assumes at most one active google_maps_review program per shop — every such program reports the SAME customer state. status/statusText are omitted entirely (not null-filled) on the public, CDN-shared GET /programs response, the same presence rule as isEarned; urlLocation is program-level and present on both.

Show properties
urlLocationstringOptional

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

statusstringOptional

The shopper's own review, if any. null means never submitted — the only one of the 4 states with no persistent record on the customer. rejected is the only state the shopper can act on again (resubmit); pending and approved are dead ends. Personalised path only — see the description above.

statusTextstringOptional

null when status is null: "never submitted" is a 3-step guided form, not a single sentence, and stays entirely client-owned UI. Personalised path only, same rule as status.

streakobjectOptional

streak_interact_website only — null for every other event, and always null on a spending program. UNLIKE googleReview, the ENTIRE object (including totalDays, which is program config) is personalised — present only on GET /customer/programs, and null (not a partial object) on the public, CDN-shared GET /programs response. isEarned on this same event answers a DIFFERENT question ("has the highest milestone ever been reached") — this object answers "what day is the shopper on right now, and can they claim today".

Show properties
currentDayintegerOptional

The streak day the shopper is currently on (0 before their first claim). Resets to 0 after a missed day or when returning to this program from a different one.

totalDaysintegerOptional

The highest streak milestone day configured on this program.

canClaimTodaybooleanOptional

Whether calling POST /customer/earn for this program right now would award points. false covers two different terminal states for today: an ordinary same-day cooldown (already claimed, come back tomorrow) and a program that can no longer be completed at all (not enough days left before it ends) — message is the only field that tells them apart.

messagestringOptional

Non-null ONLY when the program can no longer be completed (not enough days remain before its endDate to reach totalDays) — every other state, including an ordinary same-day cooldown, has nothing extra to say and reports null.

birthdayobjectOptional

birthday only — null for every other event, and always null on a spending program. Like streak, the ENTIRE object is personalised — present only on GET /customer/programs, and null (not a partial object) on the public, CDN-shared GET /programs response. Two reasons: the birthday-entry modal this field exists for always calls GET /customer/programs anyway (it also needs Customer.birthday), and rewardOptions for a VIP-tier-scoped program can only resolve against a customer's own tier, which the public path never has. KNOWN LIMITATION: rewardOptions: [] is reported both when the program has no reward picker at all (an older layout — a reward is still granted on save, just not through a picker) AND when the picker is on but the shopper's OWN tier has zero rewards configured for it — a client cannot distinguish the two from this field alone.

Show properties
rewardOptionsobject[]Optional

The rewards this shopper may choose from.

Show properties
idstringOptional

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

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

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

rewardSelectionstringOptional

Whether the shopper may pick exactly one reward (render radio buttons) or several (checkboxes). Independent of whether rewardOptions is empty — still reflects the merchant's configured mode even with nothing to pick.

dateFormatstringOptional

Day/month display ORDER for the birthday-entry modal's date input — NOT whether a year is collected at all (see ShopConfig.isNewFormatDOB for that).

metaobjectOptional

Optional metadata (pagination, counts).

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

GET/customer/activities

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.

Parameters
· objectOptional
· objectOptional
· objectOptional
· objectOptional
Responses
200Activity page
successbooleanOptional
Example: true
dataActivity[]Optional
Show properties
idstringOptional
typestringOptional
titlestringOptional

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.

Example: "Placed an order"
pointsintegerOptional

Signed — positive for earn, negative for spend/expire.

createdAtstring · date-timeOptional
couponCodestringOptional

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.

orderIdstringOptional

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.

rawTypestringOptional

redeem_point/earn_point.

oldPointnumberOptional

Point balance immediately before this row.

newPointnumberOptional

Point balance immediately after this row.

sourcestringOptional

How the row was created, e.g. user / source_headless.

eventstringOptional

Earning event key, e.g. sign_up / interact_website.

typeCustomstringOptional

Custom-program sub-type, when set.

programIdstringOptional

Source program id.

programTitlestringOptional

Source program title snapshot.

programobjectOptional

Embedded program snapshot, when the row carries one.

redeemPointnumberOptional

Points spent (redeem rows).

discountIdobjectOptional

Minted discount id (redeem rows).

priceRuleIdobjectOptional

Legacy price-rule id (redeem rows).

customerIdstringOptional

This customer's own Joy customer id.

emailstringOptional

This customer's own email.

shopIdstringOptional

The shop id.

shopifyCustomerIdnumberOptional

This customer's Shopify numeric id.

updatedAtstringOptional

ISO timestamp of last update.

metaPaginationMetaOptional
Show properties
paginationPageInfoOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/activities
GET /app/api/v1/headless/customer/activities HTTP/1.1
Host: api.joy.so
Accept: */*
200Activity page
{
"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)

GET/customer/rewards

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.

Parameters
beforequery · stringOptional

Return the page of redeemed coupons BEFORE this rewardId. Ignored when couponStatus=unavailable.

afterquery · stringOptional

Return the page of redeemed coupons AFTER this rewardId. Ignored when couponStatus=unavailable.

limitquery · integerOptional

Page size for redeemed. Clamped to 50.

couponStatusquery · stringOptional

Which coupons redeemed returns. unavailable is not paginated — see above.

· objectOptional
Responses
200Reward lists
successbooleanOptional
Example: true
dataobjectOptional
Show properties
availableAvailableReward[]Optional
Show properties
programIdstringOptional
keystringOptional

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.

Example: "amount_discount"
titlestringOptional
descriptionstringOptional
iconUrlstringOptional

The merchant's uploaded icon, when they set one. Built-in per-event icons are not URLs — map those client-side off key.

conditionsstring[]Optional

The reward's fine print, rendered server-side — same rows the matching Program carries. Empty means no restrictions.

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

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.

rewardTypestringOptional
rewardValuenumberOptional

Discount gained per pointsCost points.

redeemTypestringOptional

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.

minPointsintegerOptional

Dynamic only, and only when the merchant set a floor. Null = unbounded.

maxPointsintegerOptional

Dynamic only, and only when the merchant set a ceiling. Null = unbounded.

hasLimitCartValuebooleanOptional

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.

limitCartValueintegerOptional

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

affordablebooleanOptional

Whether the customer can afford the cheapest redemption this program allows — minPoints when there is one, else pointsCost.

giftSelectionModestringOptional

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.

giftProductsGiftProduct[]Optional

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
idstringOptional

Shopify product id (numeric, unprefixed).

titlestringOptional
imagestringOptional
variantsobject[]Optional
Show properties
idstringOptional

Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.

titlestringOptional
impactProviderstringOptional

Environmental-impact reward only ("Plant a tree"): the provider that fulfils it. null on every other reward type. Absent on older backends.

impactTypestringOptional

Impact reward only — what is created (e.g. tree). null otherwise.

impactQuantitynumberOptional

Impact reward only — how many units per redemption. null otherwise.

impactDashboardUrlstringOptional

Impact reward only — the merchant's public impact dashboard link. null otherwise.

conditionProductsGetsRewardRef[]Optional

Buy-X-Get-Y reward only — the specific products the customer gets. [] otherwise.

Show properties
idstringOptional

Shopify product/collection id (numeric, unprefixed).

titlestringOptional
imagestringOptional
handlestringOptional
conditionCollectionsGetsRewardRef[]Optional

BXGY only — the collections the customer gets. [] otherwise.

Show properties
idstringOptional

Shopify product/collection id (numeric, unprefixed).

titlestringOptional
imagestringOptional
handlestringOptional
orderReqQuantitynumberOptional

BXGY only — how many "buy" items are required. null otherwise.

enableMaximumUsesPerOrderbooleanOptional

BXGY only — whether a per-order use cap is set. false otherwise.

maximumUsesPerOrdernumberOptional

BXGY only — the per-order use cap when enableMaximumUsesPerOrder. null otherwise.

sendRewardToFriendbooleanOptional

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.

sendRewardToFriendAutomationProviderstringOptional

When the send-to-friend flow routes through an automation, which provider. null when sendRewardToFriend is off or no provider is configured.

redeemedRedeemedReward[]Optional
Show properties
rewardIdstringOptional

The coupon's own id — NOT the program's.

programIdstringOptional
keystringOptional

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.

Example: "free_shipping"
titlestringOptional
descriptionstringOptional
iconUrlstringOptional

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.

conditionsstring[]Optional

How the coupon may be used. The shopper needs this AFTER redeeming too — "Minimum order $50" is why a code looks broken at checkout.

Example: ["Expires 30 days after redeeming"]
pointsSpentintegerOptional

Points actually spent at redemption time.

rewardTypestringOptional
rewardValuenumberOptional
couponCodestringOptional
statusstringOptional

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.

expiresAtstring · date-timeOptional

Null when the coupon never expires.

startsAtstring · date-timeOptional

Null when it is usable immediately.

giftVariantIdstringOptional

The variant that WAS chosen, on a redeemed free_product reward — lets a client still add the gift to the cart later.

freeGiftProductsGiftProduct[]Optional

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
idstringOptional

Shopify product id (numeric, unprefixed).

titlestringOptional
imagestringOptional
variantsobject[]Optional
Show properties
idstringOptional

Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.

titlestringOptional
freeGiftModestringOptional

one | all | null — which free-gift picker the core renders.

freeGiftSelectedVariantIdstringOptional

The already-chosen variant (same value as giftVariantId).

freeGiftCollectionsRewardRef[]Optional

Collections a collection-scoped free gift hands out (the case freeGiftProducts is empty for). [] on every other coupon.

Show properties
idstringOptional

Shopify product/collection id (numeric, unprefixed).

titlestringOptional
imagestringOptional
handlestringOptional
partiallyUsedbooleanOptional

Gift card spent down but still has balance — show the "Partially used" badge. Absent on older backends.

balanceRemainingnumberOptional

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.

customerPointsintegerOptional

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.

metaobjectOptional
Show properties
paginationobjectOptional
Show properties
redeemedPageInfoOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400
No body
401
No body
429
No body
GET/customer/rewards
GET /app/api/v1/headless/customer/rewards HTTP/1.1
Host: api.joy.so
Accept: */*
200Reward lists
{
"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")

GET/customer/received-rewards

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.

Parameters
beforequery · stringOptional

Return the page of gifts BEFORE this rewardId.

afterquery · stringOptional

Return the page of gifts AFTER this rewardId.

limitquery · integerOptional

Page size. Clamped to 50.

· objectOptional
Responses
200One page of received gifts
successbooleanOptional
Example: true
dataobjectOptional
Show properties
receivedReceivedReward[]Optional
Show properties
rewardIdstringOptional

The gift's own id — NOT the sender's reward id, and NOT the program's. Also the cursor for before/after.

programIdstringOptional
keystringOptional

The source program's event, same meaning as AvailableReward.key — what a client maps its built-in icon off.

Example: "percentage_discount"
titlestringOptional

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.

descriptionstringOptional

The source program's sentence, localized and snapshot-read the same way as title.

iconUrlstringOptional

Merchant-uploaded icon for the source program; null when they set none. Built-in per-event icons are mapped client-side off key.

rewardTypestringOptional
rewardValuenumberOptional
couponCodestringOptional

MASKED for a gift card — the spendable full code is repo-private and never leaves the server.

statusstringOptional

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.

expiresAtstring · date-timeOptional

Mirrors the sender's coupon so both lapse together. Null when the gift never expires.

startsAtstring · date-timeOptional

Always null today — the gift record carries no start date.

giftVariantIdstringOptional

On a free_product gift, the variant the sender chose.

freeGiftProductsGiftProduct[]Optional

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
idstringOptional

Shopify product id (numeric, unprefixed).

titlestringOptional
imagestringOptional
variantsobject[]Optional
Show properties
idstringOptional

Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.

titlestringOptional
freeGiftModestringOptional

one | all | null — which free-gift picker the core renders.

freeGiftSelectedVariantIdstringOptional

The variant the sender chose (same value as giftVariantId).

freeGiftCollectionsRewardRef[]Optional

Collections a collection-scoped free gift hands out. [] on every other gift.

Show properties
idstringOptional

Shopify product/collection id (numeric, unprefixed).

titlestringOptional
imagestringOptional
handlestringOptional
senderNamestringOptional

Who sent it. Empty string when unknown — show the name, falling back to senderEmail.

senderEmailstringOptional

The sender's email, empty string when unknown. Display-safe by design: it is one of only two sender fields stored on the gift.

conditionsstring[]Optional

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.

partiallyUsedbooleanOptional

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.

balanceRemainingnumberOptional

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.

metaobjectOptional
Show properties
paginationPageInfoOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/received-rewards
GET /app/api/v1/headless/customer/received-rewards HTTP/1.1
Host: api.joy.so
Accept: */*
200One page of received gifts
{
"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

POST/customer/redeem

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.

Request body
programIdstringRequired

The spending program (from /programs, programType=spending).

pointsintegerOptional

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.

variantIdstringOptional

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.

idempotencyKeystringOptional

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.

recipientRedeemRecipientOptional

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
emailstring · emailRequired

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.

namestringOptional

Recipient display name shown on Shopify's gift-card email.

messagestringOptional

Personal note shown on Shopify's gift-card email.

sendNotificationAtstring · date-timeOptional

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.

Responses
200Redemption succeeded — coupon issued
successbooleanOptional
Example: true
dataRedeemResultOptional
Show properties
idstringOptional

Issued voucher/discount ID.

couponCodestringOptional
rewardTypestringOptional
rewardValuenumberOptional
pointsSpentintegerOptional
remainingPointsintegerOptional

Customer balance after redemption. 0 when the redeem did not report a balance — check remainingPointsKnown before repainting.

remainingPointsKnownbooleanOptional

True when remainingPoints is the balance the redeem actually returned; false when it is the 0 fallback.

giftCardobjectOptional

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
isSentToFriendbooleanOptional
recipientEmailstringOptional
expiresOnstringOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/redeem
POST /app/api/v1/headless/customer/redeem HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
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"
}
}
200Redemption succeeded — coupon issued
{
"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

POST/customer/free-gift-stock

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.

Request body
programIdstringRequired

A spending program whose rewardType is free_product. Any other program — including one from another shop — is rejected as INVALID_PROGRAM.

Responses
200Stock resolved
successbooleanOptional
Example: true
dataFreeGiftStockOptional

Live 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
productsobject[]Optional
Show properties
productIdstringOptional

Shopify product id (numeric, unprefixed) — the same string as GiftProduct.id, so the two lists join directly.

availablebooleanOptional

True when at least one of this product's variants is available.

variantsobject[]Optional
Show properties
variantIdstringOptional

Matches GiftProduct.variants[].id.

availablebooleanOptional
missingVariantIdsstring[]Optional

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

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/free-gift-stock
POST /app/api/v1/headless/customer/free-gift-stock HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 27
{
"programId": "string"
}
200Stock resolved
{
"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

POST/customer/earn

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.

Request body
programIdstringRequired

The earning program (from /programs, programType=earning).

contextobjectOptional

Per-family input — see the table above. Ignored for families that need none.

Responses
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).
successbooleanOptional
Example: true
dataEarnResultOptional
Show properties
programIdstringOptional

The earning program that was claimed.

keystringOptional

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.

pointsEarnedintegerOptional

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.

totalPointsintegerOptional

Customer balance after the award.

statusstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/earn
POST /app/api/v1/headless/customer/earn HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 44
{
"programId": "string",
"context": {}
}
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).
{
"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

POST/customer/earn/upload

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.

Request body
imagesobject[]Required
Show properties
imageBase64stringRequired

Base64 image data, with or without a data:image/png;base64, prefix.

imageNamestringOptional

Original file name — used only in error messages.

imageTypestringRequired
Responses
200Every image stored
successbooleanOptional
Example: true
dataEarnUploadResultOptional
Show properties
urlsstring[]Optional

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

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/earn/upload
POST /app/api/v1/headless/customer/earn/upload HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 125
{
"images": [
{
"imageBase64": "string",
"imageName": "string",
"imageType": "image/jpeg"
}
]
}
200Every image stored
{
"success": true,
"data": {
"urls": [
"string"
]
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Earn points for a social action (follow, share, join)

POST/customer/earn/social

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.

Request body
programIdstringRequired

The social earning program (from /programs, programType=earning).

Responses
200Points earned — shopper may redeem the balance toward rewards
successbooleanOptional
Example: true
dataSocialEarnResultOptional
Show properties
programIdstringOptional

The earning program that was claimed.

keystringOptional

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.

pointsEarnedintegerOptional

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.

totalPointsintegerOptional

Customer balance after the award.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/earn/social
POST /app/api/v1/headless/customer/earn/social HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 27
{
"programId": "string"
}
200Points earned — shopper may redeem the balance toward rewards
{
"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

GET/customer/milestones/{programId}

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.

Parameters
programIdpath · stringRequired

An earning program whose key is milestone, from /customer/programs.

· objectOptional
Responses
200The ladder, personalised
successbooleanOptional
Example: true
dataMilestoneLadderOptional

One milestone program, with every step rendered for the caller.

Show properties
programIdstringOptional
titlestringOptional

Program title in the served locale. '' when unset, never null.

subheadingstringOptional

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

Example: "2 / 5 Rewards"
typeMilestonestringOptional
supportedbooleanOptional

False for program types with no storefront ladder (milestone_inactivity). steps is empty; render the program's own description instead of an empty track.

orderRequirementstringOptional

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

Example: "Your order needs to be at least $50"
stepsMilestoneStep[]Optional
Show properties
claimKeystringOptional

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.

Example: "500"
stepTypestringOptional

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.

labelstringOptional

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.

Example: "After spending $500"
statestringOptional

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.

reachedbooleanOptional

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.

notestringOptional

Tooltip text for passed_unclaimed. Null in every other state.

rewardLogicstringOptional
maxRewardSelectintegerOptional

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.

Example: 1
progressobjectOptional

Present only when state is current.

Show properties
percentintegerOptional

0-100, already clamped — width of the bar.

Example: 42
completedTextstringOptional

"42% completed", localised and lowercased as the loyalty page renders it.

remainingTextstringOptional

"Need 3 orders" / "Need $250" — the unit is pluralised and localised server-side. Null at 100%.

rewardsMilestoneReward[]Optional
Show properties
indexintegerOptional

Position in this step's rewards. The value to send in selectedRewardIds — the client never sends the reward itself.

actionstringOptional
textstringOptional

"Receive 100 points", "Receive $5 off", "Plant 2 trees" — or the merchant's own reward description when they wrote one. Localised, print as-is.

Example: "Receive 100 points"
needsVariantChoicebooleanOptional

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.

giftProductsGiftProduct[]Optional

Empty unless needsVariantChoice. Same shape as AvailableReward.giftProducts.

Show properties
idstringOptional

Shopify product id (numeric, unprefixed).

titlestringOptional
imagestringOptional
variantsobject[]Optional
Show properties
idstringOptional

Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.

titlestringOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`INVALID_PROGRAM` — not found, in another shop, not an earning program, not a milestone program, or disabled.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/milestones/{programId}
GET /app/api/v1/headless/customer/milestones/{programId} HTTP/1.1
Host: api.joy.so
Accept: */*
200The ladder, personalised
{
"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

POST/customer/milestones/{programId}/claim

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.

Parameters
programIdpath · stringRequired
Request body
claimKeystringRequired

The step's claimKey from GET /customer/milestones/{programId}. Opaque — a raw milestone value on a plain program, a compound key on milestone_mixed.

Example: "500"
selectedRewardIdsinteger[]Required

Indexes into the step's rewards array. Exactly maxRewardSelect of them, each in range, no duplicates — anything else is INVALID_SELECTION.

Example: [0,2]
variantIdsobjectOptional

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.

Example: {"0":"44332211"}
Responses
200Rewards granted
successbooleanOptional
Example: true
dataMilestoneClaimResultOptional
Show properties
claimedRewardIdsinteger[]Optional
pointsEarnedintegerOptional

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.

totalPointsintegerOptional

Customer balance after the grant.

blockedbooleanOptional

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.

stepobjectOptional
Show properties
claimKeystringOptional

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.

Example: "500"
stepTypestringOptional

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.

labelstringOptional

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.

Example: "After spending $500"
statestringOptional

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.

reachedbooleanOptional

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.

notestringOptional

Tooltip text for passed_unclaimed. Null in every other state.

rewardLogicstringOptional
maxRewardSelectintegerOptional

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.

Example: 1
progressobjectOptional

Present only when state is current.

Show properties
percentintegerOptional

0-100, already clamped — width of the bar.

Example: 42
completedTextstringOptional

"42% completed", localised and lowercased as the loyalty page renders it.

remainingTextstringOptional

"Need 3 orders" / "Need $250" — the unit is pluralised and localised server-side. Null at 100%.

rewardsMilestoneReward[]Optional
Show properties
indexintegerOptional

Position in this step's rewards. The value to send in selectedRewardIds — the client never sends the reward itself.

actionstringOptional
textstringOptional

"Receive 100 points", "Receive $5 off", "Plant 2 trees" — or the merchant's own reward description when they wrote one. Localised, print as-is.

Example: "Receive 100 points"
needsVariantChoicebooleanOptional

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.

giftProductsGiftProduct[]Optional

Empty unless needsVariantChoice. Same shape as AvailableReward.giftProducts.

Show properties
idstringOptional

Shopify product id (numeric, unprefixed).

titlestringOptional
imagestringOptional
variantsobject[]Optional
Show properties
idstringOptional

Shopify product-variant id (numeric, unprefixed). Send this as variantId when redeeming; prefix it with gid://shopify/ProductVariant/ for a Storefront cart call.

titlestringOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — earning is disabled for this customer (test mode, B2B exclusion, opt-in guest not yet enrolled).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/milestones/{programId}/claim
POST /app/api/v1/headless/customer/milestones/{programId}/claim HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 109
{
"claimKey": "500",
"selectedRewardIds": [
0,
2
],
"variantIds": {
"0": "44332211"
}
}
200Rewards granted
{
"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)

GET/customer/milestone

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.

Parameters
typequery · stringOptional

Narrow the map to one milestone typeMilestone. Omit for every active program.

Responses
200Milestone progress counters
successbooleanOptional
Example: true
dataMilestoneProgressOptional

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

Example: {"order":3,"spent_amount":128.5}
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`BAD_REQUEST` — `type` sent more than once (not a single string).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/milestone
GET /app/api/v1/headless/customer/milestone HTTP/1.1
Host: api.joy.so
Accept: */*
200Milestone progress counters
{
"success": true,
"data": {
"order": 3,
"spent_amount": 128.5
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Claim a reached milestone step

POST/customer/milestone/claim

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.

Request body
programIdstringRequired

A milestone earning program, from /customer/programs.

milestoneValueobjectRequired

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.

Example: 500
selectedRewardIdsinteger[]Optional

Indexes into the SERVER-resolved step.rewards. Absent or empty claims the whole step.

Example: [0]
stepTypestringOptional

Mixed programs only — the step's type (e.g. spent_amount).

isMixedbooleanOptional

Mixed programs only.

isSelectVariantFreeGiftMilestonebooleanOptional

Whether a variant was picked for a free-gift reward.

variantIdsSelectedobjectOptional

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

Example: {"rewardIndex-0":"44332211"}
Responses
200The step was claimed
successbooleanOptional
Example: true
dataMilestoneClaimResponseOptional

Response 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
pointintegerRequired

The customer's spendable balance after the claim.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — claiming is disabled for this customer (the shop is in test-earning mode and the customer is not white-listed).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
404`INVALID_PROGRAM` — program not found or in another shop.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/milestone/claim
POST /app/api/v1/headless/customer/milestone/claim HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 238
{
"programId": "string",
"milestoneValue": 500,
"selectedRewardIds": [
0
],
"stepType": "string",
"isMixed": true,
"isSelectVariantFreeGiftMilestone": true,
"variantIdsSelected": {
"rewardIndex-0": "44332211"
}
}
200The step was claimed
{
"success": true,
"data": {
"point": 1
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

This customer's environmental-impact redemption history + totals

GET/customer/impact-history

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.

Parameters
· objectOptional
· objectOptional
limitquery · integerOptional

Page size (default 20, clamped to 50).

Responses
200Impact history page
successbooleanOptional
Example: true
dataImpactHistoryOptional

Response for GET /customer/impact-history.

Show properties
connectedbooleanOptional

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.

totalsobjectOptional
Show properties
treesnumberOptional
plasticBottlesnumberOptional
eventsImpactEvent[]Optional
Show properties
idstringOptional
typestringOptional
quantitynumberOptional

Units funded (trees planted / bottles removed).

statusstringOptional

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.

createdAtstringOptional
metaPaginationMetaOptional
Show properties
paginationPageInfoOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/impact-history
GET /app/api/v1/headless/customer/impact-history HTTP/1.1
Host: api.joy.so
Accept: */*
200Impact history page
{
"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

POST/customer/perk/save-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.

Request body
tierIdstringRequired

The tier the perk belongs to, from /customer/tier-rewards.

programIdstringRequired

The tier-privilege program (perk), from /customer/tier-rewards.

variantIdsstring[]Required

The picked variant ids. Every id must be on the perk's own configured pool, else PERK_VARIANT_UNAVAILABLE.

Example: ["44332211","44332212"]
Responses
200The selection was saved
successbooleanOptional
Example: true
dataobjectOptional
Show properties
tierDataobjectOptional

The customer's tierData after the save, keyed [tierId][programId].variantIdsSelected.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
404`INVALID_PROGRAM` — program not found or in another shop.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/perk/save-variant-selection
POST /app/api/v1/headless/customer/perk/save-variant-selection HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 103
{
"tierId": "string",
"programId": "string",
"variantIds": [
"44332211",
"44332212"
]
}
200The selection was saved
{
"success": true,
"data": {
"tierData": {}
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

This customer's VIP tiers with their assigned rewards (perks)

GET/customer/tier-rewards

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:

Parameters
· objectOptional
Responses
200The customer's visible tiers, with rewards
successbooleanOptional
Example: true
dataTier[]Optional
Show properties
idstringOptional
namestringOptional
Example: "Gold"
descriptionstringOptional
iconUrlstringOptional
thresholdnumberOptional

Points or spend required to reach this tier.

perksobject[]Optional
Show properties
idstringOptional
titlestringOptional
descriptionstringOptional
memberCountintegerOptional

Present only when the merchant enables member counts.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/tier-rewards
GET /app/api/v1/headless/customer/tier-rewards HTTP/1.1
Host: api.joy.so
Accept: */*
200The customer's visible tiers, with rewards
{
"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

GET/customer/pending-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.

Parameters
includeExpiredquery · booleanOptional
Responses
200Pending tier-reward claims
successbooleanOptional
Example: true
dataPendingTierRewardClaim[]Optional
Show properties
idstringOptional

The claim's own id — what POST /customer/pending-rewards/{id}/claim targets.

programIdstringOptional

The tier-reward program it came from; null on docs issued before that was recorded.

tierIdstringOptional

The tier that granted it; null on docs issued before that was recorded.

titlestringOptional

The tier name the reward came from — rendered as the claim's title.

claimTypestringOptional

product_selection (pick from a fixed list), variant_price_difference (a discount toward a chosen variant), or null on older docs.

statusstringOptional

Lifecycle status — pending, or claimed for a completed product_selection.

allProductsbooleanOptional

Whether the reward covers every product.

selectedProductIdsstring[]Optional

Product ids the reward is scoped to; empty when allProducts is true.

rewardProductQuantityintegerOptional
eventstringOptional

The source program's event; null when not recorded.

maxClaimsintegerOptional

How many times this pending doc may be claimed.

claimedCountintegerOptional

How many times it has already been claimed.

expiredAtstringOptional
createdAtstringOptional
productsobject[]Optional

Prefetched product/variant views for the claim's product picker; empty for allProducts / product_selection docs.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/pending-rewards
GET /app/api/v1/headless/customer/pending-rewards HTTP/1.1
Host: api.joy.so
Accept: */*
200Pending tier-reward claims
{
"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

POST/customer/pending-rewards/{id}/claim

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.

Parameters
idpath · stringRequired

The pending reward id.

Request body
variantIdsstring[]Optional

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.

Example: ["44332211"]
Responses
200The reward was claimed
successbooleanOptional
Example: true
dataobjectOptional
Show properties
couponCodestringOptional

The minted coupon code, or null when the reward mints none.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`FORBIDDEN` — the pending doc is not this customer's.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
404`NOT_FOUND` — no pending reward with that id. `PROGRAM_NOT_FOUND` — the reward's program is gone.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/pending-rewards/{id}/claim
POST /app/api/v1/headless/customer/pending-rewards/{id}/claim HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 40
{
"variantIds": [
"44332211"
]
}
200The reward was claimed
{
"success": true,
"data": {
"couponCode": "string"
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Loyalty Page V2 — the shopper-specific half of the page

GET/customer/loyalty-page/home

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.

Parameters
localequery · stringOptional

The shopper's locale (e.g. fr) — data.primaryTranslation is served in it. Omit for the shop's primary language.

isEarnSignupquery · stringOptional

'true' (exact string) opts this page load into the sign-up earn promotion.

Responses
200Loyalty page state for the signed-in shopper.
successbooleanOptional
Example: true
dataLoyaltyPageHomeOptional

Data for GET /customer/loyalty-page/home. A different shape from Customer (GET /customer).

Show properties
customerobjectOptional

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
idstringOptional
shopifyCustomerIdnumberOptional

Shopify numeric customer id.

emailstringOptional
firstNamestringOptional
lastNamestringOptional
typestringOptional
tagsstring[]Optional

The customer's Shopify tags (used for program eligibility on the page).

pointintegerOptional

Current spendable balance (note the singular name on this route).

pointExpiredAtstringOptional
tierIdstringOptional
tierPointnumberOptional
tierDataobjectOptional
birthdaystringOptional
canJoinBirthdaybooleanOptional

Whether the customer can still set a birthday for the birthday program.

earningLimitStatusobjectOptional

Earning-limit status; null when the shop has no global earning limit.

tierAssessmentStatusobjectOptional
primaryTranslationobjectOptional

The page's text, resolved for the requested locale (merchant overrides applied).

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/loyalty-page/home
GET /app/api/v1/headless/customer/loyalty-page/home HTTP/1.1
Host: api.joy.so
Accept: */*
200Loyalty page state for the signed-in shopper.
{
"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

GET/customer/loyalty-page/wallet-state

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.

Responses
200Wallet pass settings
successbooleanOptional
Example: true
dataobjectOptional
Show properties
applePassWalletSettingobjectOptional

Empty object when the shop has no pass configured.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/loyalty-page/wallet-state
GET /app/api/v1/headless/customer/loyalty-page/wallet-state HTTP/1.1
Host: api.joy.so
Accept: */*
200Wallet pass settings
{
"success": true,
"data": {
"applePassWalletSetting": {}
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Per-program canEarn for custom-event earning programs

GET/customer/programs/custom-earn-status

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.

Responses
200Map of program id to whether this customer may still earn it.
successbooleanOptional
Example: true
dataobjectOptional

Keyed by program id.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/programs/custom-earn-status
GET /app/api/v1/headless/customer/programs/custom-earn-status HTTP/1.1
Host: api.joy.so
Accept: */*
200Map of program id to whether this customer may still earn it.
{
"success": true,
"data": {},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Revoke (self-refund) an issued coupon

POST/customer/rewards/{rewardId}/revoke
Parameters
rewardIdpath · stringRequired

Id of the reward to revoke.

Responses
200Coupon revoked and points refunded.
successbooleanOptional
Example: true
dataobjectOptional
Show properties
rewardIdstringRequired
refundedPointsintegerRequired

Points returned to the customer's balance.

statusstringRequired
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
404`REWARD_NOT_FOUND` — no such reward in this shop, or it belongs to another customer. The two cases are deliberately indistinguishable.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/rewards/{rewardId}/revoke
POST /app/api/v1/headless/customer/rewards/{rewardId}/revoke HTTP/1.1
Host: api.joy.so
Accept: */*
200Coupon revoked and points refunded.
{
"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)

POST/customer/auth/logout

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.

Responses
200Token revoked.
successbooleanOptional
Example: true
dataobjectOptional
Show properties
revokedbooleanOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/auth/logout
POST /app/api/v1/headless/customer/auth/logout HTTP/1.1
Host: api.joy.so
Accept: */*
200Token revoked.
{
"success": true,
"data": {
"revoked": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Customer referral link + stats

GET/customer/referral

Feeds joy-referral (share link, reward terms, counts).

Responses
200Referral data
successbooleanOptional
Example: true
dataReferralOptional
Show properties
referralLinkstringOptional

Null for guests or when referral is disabled.

referralCodestringOptional
rewardForFriendstringOptional

Human-readable reward the referred friend receives.

rewardForReferrerstringOptional
statsobjectOptional
Show properties
completedintegerOptional
pendingintegerOptional
pointsEarnedintegerOptional
metaobjectOptional

Optional metadata (pagination, counts).

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

GET/customer/referral-history

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.

Parameters
· objectOptional
· objectOptional
Responses
200Referred-friend history page
successbooleanOptional
Example: true
dataReferralHistoryEntry[]Optional
Show properties
idstringOptional
emailstringOptional

The referred friend's email, OBFUSCATED server-side (j******@example.com) — a referrer sees who they referred, never the friend's full address.

statusstringOptional
createdAtstringOptional
metaPaginationMetaOptional
Show properties
paginationPageInfoOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/referral-history
GET /app/api/v1/headless/customer/referral-history HTTP/1.1
Host: api.joy.so
Accept: */*
200Referred-friend history page
{
"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

POST/customer/referral/send-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.

Request body
refereeEmailstringRequired

The friend's email address.

Responses
200Invite sent
successbooleanOptional
Example: true
dataReferralEmailResultOptional

Response for POST /customer/referral/send-email.

Show properties
sentbooleanOptional

Always true on success — a declined send rejects instead of resolving false.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/referral/send-email
POST /app/api/v1/headless/customer/referral/send-email HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 30
{
"refereeEmail": "string"
}
200Invite sent
{
"success": true,
"data": {
"sent": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Gift a coupon this customer redeemed to a friend

POST/customer/send-reward-to-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.

Request body
recipientobjectRequired
Show properties
emailstringRequired

The friend's email address.

namestringOptional
messagestringOptional
sendNotificationAtstringOptional

ISO date-time to defer the notification to. Accepted for forward compatibility but not yet acted on — the notification is always sent immediately.

couponCodestringRequired

The redeemed coupon to gift — must be one THIS customer redeemed from programId.

programIdstringRequired

The spending program the coupon came from.

Responses
200Reward sent
successbooleanOptional
Example: true
dataSendRewardToFriendResultOptional

Response for POST /customer/send-reward-to-friend.

Show properties
sentbooleanOptional

Always true on success — the call has no partial outcome to report.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`SEND_REWARD_COUPON_NOT_OWNED` — the coupon is not a reward this customer redeemed from this program (or was revoked).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
404`PROGRAM_NOT_FOUND` — the program is unknown or in another shop.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429`SEND_REWARD_COOLDOWN` / `SEND_REWARD_LIMIT_REACHED` — rate limits on this gifting action.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
502`SEND_REWARD_DISCOUNT_UPDATE_FAILED` — the Shopify discount update failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/send-reward-to-friend
POST /app/api/v1/headless/customer/send-reward-to-friend HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 180
{
"recipient": {
"email": "string",
"name": "string",
"message": "string",
"sendNotificationAt": "string"
},
"couponCode": "string",
"programId": "string"
}
200Reward sent
{
"success": true,
"data": {
"sent": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Shopify-segment eligibility for segment-gated spending programs

POST/customer/segment-program

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.

Request body
segmentProgramsobject[]Required
Show properties
programIdstringRequired
segmentIdsstring[]Required

The program's own appliedSegments; ANY match means eligible.

Responses
200Eligibility per requested program.
successbooleanOptional
Example: true
dataSegmentEligibilityOptional

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

Example: {"prog_vip_only":true,"prog_winback":false}
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/segment-program
POST /app/api/v1/headless/customer/segment-program HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 118
{
"segmentPrograms": [
{
"programId": "string",
"segmentIds": [
"string"
]
}
]
}
200Eligibility per requested program.
{
"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

POST/customer/join-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.

Request body
joinFromstringOptional

Attribution surface. Defaults to widget when omitted or not one of the listed values.

Responses
200Enrolled (or already enrolled) — the customer's fresh state.
successbooleanOptional
Example: true
dataCustomerOptional
Show properties
idstringOptional
typestringOptional

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.

pointsintegerOptional

Current spendable balance.

totalOrdersintegerOptional

The customer's order count, read live from Shopify when available.

totalEarnedintegerOptional
tierobjectOptional
Show properties
idstringOptional
namestringOptional
tierProgressobjectOptional

Progress toward the next tier; null when already at top tier.

Show properties
nextTierNamestringOptional
pointsToNextnumberOptional
percentagenumberOptional

0–100.

currentTargetnumberOptional

Entry threshold of the customer's current tier (0 for the base tier).

nextTargetnumberOptional

Entry threshold of the next tier.

memberSincestring · date-timeOptional
marketingConsentbooleanOptional

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.

avatarUrlstringOptional

Customer's avatar image URL. null when the customer has none.

smsMarketingConsentbooleanOptional

Whether this customer has ever synced as subscribed to SMS marketing. Same null-means-unknown contract as marketingConsent above, for the SMS channel.

earningLimitobjectOptional

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
reachedbooleanOptional

Whether the customer has hit the limit for the current period.

resetDatestringOptional

Merchant-formatted date the period resets. Empty string for a customer whose tier has an unlimited override.

messagestringOptional

Server-rendered warning sentence, ready to show as-is. null when reached is false — there is no sentence to show until then.

birthdaystringOptional

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.

Example: "07/13"
dateOfBirthstringOptional

'YYYY-MM-DD'. Only ever set on shops asking for a year (ShopConfig.isNewFormatDOB); null on shops that only collect day/month.

Example: "1990-07-13"
phonestringOptional

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.

tierPointnumberOptional

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.

socialobjectOptional

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
instagramUsernamestringOptional

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.

Example: "joyshopper"
lineLinkedbooleanOptional

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.

lineFollowedOAbooleanOptional

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.

facebookConnectedbooleanOptional

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

facebookNamestringOptional

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.

createdApplePassbooleanOptional

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.

qrCodeDownloadApplePassstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/join-program
POST /app/api/v1/headless/customer/join-program HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 26
{
"joinFrom": "widget"
}
200Enrolled (or already enrolled) — the customer's fresh state.
{
"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

GET/customer/loyalty-page/rewards

Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/rewards for any other client.

Parameters
beforequery · stringOptional
afterquery · stringOptional
limitquery · integerOptional
isAvailableCouponquery · stringOptional
extraCouponIdsquery · stringOptional

Feature flag for Shopify "extra coupons" (the saved allowlist is re-read server-side).

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

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

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

GET /customer/loyalty-page/activities

GET/customer/loyalty-page/activities

Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/activities for any other client.

Parameters
beforequery · stringOptional
afterquery · stringOptional
limitquery · integerOptional
Responses
200
successbooleanOptional
dataobject[]Optional
pageInfoobjectOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
errorstringOptional

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

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

GET /customer/loyalty-page/referral-history

GET/customer/loyalty-page/referral-history

Loyalty Page V2 parity read. Cache-Control: private, no-store. Prefer /customer/referral-history for any other client.

Parameters
beforequery · stringOptional
afterquery · stringOptional
limitquery · integerOptional
Responses
200
successbooleanOptional
dataobject[]Optional
pageInfoobjectOptional
Show properties
hasNextbooleanOptional
hasPrebooleanOptional
errorstringOptional

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

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

Link (or unlink) an Instagram username

POST/customer/social/instagram/link

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.

Request body
instagramUsernamestringOptional

Empty or omitted unlinks the current username.

Responses
200Linked (or unlinked).
successbooleanOptional
Example: true
dataInstagramLinkResultOptional
Show properties
instagramUsernamestringOptional

The linked username, normalized (trimmed, lowercased). Empty string when unlinked (an empty/omitted instagramUsername on the request).

rewardEarnedbooleanOptional

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.

pointsEarnedintegerOptional

Points granted by this call. 0 when rewardEarned is false.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — the customer has not joined the loyalty program (opt-in guest).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
409`USERNAME_TAKEN` — this Instagram username is already linked to a DIFFERENT customer on this shop.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/social/instagram/link
POST /app/api/v1/headless/customer/social/instagram/link HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 35
{
"instagramUsername": "string"
}
200Linked (or unlinked).
{
"success": true,
"data": {
"instagramUsername": "string",
"rewardEarned": true,
"pointsEarned": 1
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Disconnect Facebook

POST/customer/social/facebook/disconnect

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.

Responses
200Disconnected.
successbooleanOptional
Example: true
dataFacebookDisconnectResultOptional
Show properties
disconnectedbooleanOptional

Always true on success.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/social/facebook/disconnect
POST /app/api/v1/headless/customer/social/facebook/disconnect HTTP/1.1
Host: api.joy.so
Accept: */*
200Disconnected.
{
"success": true,
"data": {
"disconnected": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Start LINE Login for the current customer

POST/customer/social/line/auth-url

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.

Responses
200Auth URL built.
successbooleanOptional
Example: true
dataLineAuthUrlResultOptional
Show properties
authUrlstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`LINE_NOT_CONNECTED` — this shop has not connected LINE.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
POST/customer/social/line/auth-url
POST /app/api/v1/headless/customer/social/line/auth-url HTTP/1.1
Host: api.joy.so
Accept: */*
200Auth URL built.
{
"success": true,
"data": {
"authUrl": "string"
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Create (or regenerate) this customer's Apple Wallet pass

POST/customer/wallet-pass/apple

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.

Responses
200Pass created.
successbooleanOptional
Example: true
dataAppleWalletPassResultOptional

Response for POST /customer/wallet-pass/apple.

Show properties
urlstringOptional

Public download link for the generated .pkpass file.

qrUrlstringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`APPLE_PASS_GENERATION_FAILED` — pass rendering/upload failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/wallet-pass/apple
POST /app/api/v1/headless/customer/wallet-pass/apple HTTP/1.1
Host: api.joy.so
Accept: */*
200Pass created.
{
"success": true,
"data": {
"url": "string",
"qrUrl": "string"
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Verify this customer's Apple Wallet pass still exists

GET/customer/wallet-pass/apple/verify

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.

Responses
200Verified.
successbooleanOptional
Example: true
dataVerifyApplePassResultOptional

Response for GET /customer/wallet-pass/apple/verify.

Show properties
existsbooleanOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/wallet-pass/apple/verify
GET /app/api/v1/headless/customer/wallet-pass/apple/verify HTTP/1.1
Host: api.joy.so
Accept: */*
200Verified.
{
"success": true,
"data": {
"exists": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Build this customer's "Add to Google Wallet" link

GET/customer/wallet-pass/google

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.

Responses
200Link built.
successbooleanOptional
Example: true
dataGoogleWalletPassResultOptional

Response for GET /customer/wallet-pass/google.

Show properties
linkstringOptional

Google Wallet "Add to Google Wallet" save link.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`GOOGLE_PASS_GENERATION_FAILED` — pass/link generation failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
GET/customer/wallet-pass/google
GET /app/api/v1/headless/customer/wallet-pass/google HTTP/1.1
Host: api.joy.so
Accept: */*
200Link built.
{
"success": true,
"data": {
"link": "string"
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

This customer's Shopify orders

GET/customer/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).

Parameters
orderIdquery · stringOptional

Numeric Shopify order id. When present, narrows the list to that one order.

Responses
200Order list (0-1 items when `orderId` was sent)
successbooleanOptional
Example: true
dataOrder[]Optional
Show properties
idintegerOptional

Numeric Shopify order id.

namestringOptional
Example: "#1042"
createdAtstring · date-timeOptional
financialStatusstringOptional

Lowercased Shopify financial status (paid, pending, refunded, …).

fulfillmentStatusstringOptional

Null means unfulfilled — no separate unfulfilled value.

currencyCodestringOptional

Presentment currency — what the customer actually paid in, not the shop's home currency.

totalPriceintegerOptional

Cents, presentment currency.

subtotalPriceintegerOptional
totalTaxintegerOptional
totalDiscountsintegerOptional
totalShippingintegerOptional
orderStatusUrlstringOptional

Shopify's own hosted, signed order-status page.

paymentGatewayNamesstring[]Optional
customerobjectOptional
Show properties
emailstringOptional
firstNamestringOptional
lastNamestringOptional
shippingMethodstringOptional
discountsOrderDiscount[]Optional
Show properties
codestringOptional

Coupon code, or the discount's title when it has no code (automatic/manual).

typestringOptional

Raw Shopify DiscountApplicationAllocationMethod (e.g. ACROSS, EACH).

targetTypestringOptional

Raw Shopify DiscountApplicationTargetType (e.g. LINE_ITEM, SHIPPING_LINE).

amountintegerOptional

Discount amount in cents, in the order's presentment currency. 0 for a percentage discount.

percentagenumberOptional

Set only for a percentage-off discount; null otherwise.

lineItemCountintegerOptional
lineItemsOrderLineItem[]Optional
Show properties
titlestringOptional
quantityintegerOptional
priceintegerOptional

Line total in cents, in the order's presentment currency.

imagestringOptional
variantTitlestringOptional
variantIdstringOptional

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

productIdintegerOptional
productHandlestringOptional
shippingAddressobjectOptional
billingAddressobjectOptional
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`BAD_REQUEST` — `orderId` was sent but is not a numeric Shopify order id.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`ORDERS_FETCH_FAILED` — the Shopify Admin API call failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
GET/customer/orders
GET /app/api/v1/headless/customer/orders HTTP/1.1
Host: api.joy.so
Accept: */*
200Order list (0-1 items when `orderId` was sent)
{
"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

GET/customer/activities-by-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.

Parameters
orderIdquery · stringRequired

Numeric Shopify order id.

Responses
200Activities for this order (possibly empty)
successbooleanOptional
Example: true
dataActivity[]Optional
Show properties
idstringOptional
typestringOptional
titlestringOptional

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.

Example: "Placed an order"
pointsintegerOptional

Signed — positive for earn, negative for spend/expire.

createdAtstring · date-timeOptional
couponCodestringOptional

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.

orderIdstringOptional

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.

rawTypestringOptional

redeem_point/earn_point.

oldPointnumberOptional

Point balance immediately before this row.

newPointnumberOptional

Point balance immediately after this row.

sourcestringOptional

How the row was created, e.g. user / source_headless.

eventstringOptional

Earning event key, e.g. sign_up / interact_website.

typeCustomstringOptional

Custom-program sub-type, when set.

programIdstringOptional

Source program id.

programTitlestringOptional

Source program title snapshot.

programobjectOptional

Embedded program snapshot, when the row carries one.

redeemPointnumberOptional

Points spent (redeem rows).

discountIdobjectOptional

Minted discount id (redeem rows).

priceRuleIdobjectOptional

Legacy price-rule id (redeem rows).

customerIdstringOptional

This customer's own Joy customer id.

emailstringOptional

This customer's own email.

shopIdstringOptional

The shop id.

shopifyCustomerIdnumberOptional

This customer's Shopify numeric id.

updatedAtstringOptional

ISO timestamp of last update.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`BAD_REQUEST` — missing `orderId`.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/activities-by-order
GET /app/api/v1/headless/customer/activities-by-order HTTP/1.1
Host: api.joy.so
Accept: */*
200Activities for this order (possibly empty)
{
"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

GET/customer/store-credit/transactions

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.

Parameters
beforequery · stringOptional

Opaque Shopify cursor from the previous page's meta.pagination.startCursor — fetch the page before it.

afterquery · stringOptional

Opaque Shopify cursor from the previous page's meta.pagination.endCursor — fetch the page after it.

· objectOptional
Responses
200Store-credit transaction page (possibly empty)
successbooleanOptional
Example: true
dataStoreCreditTransaction[]Optional
Show properties
typestringOptional

Shopify's transaction __typename, e.g. StoreCreditAccountCreditTransaction or StoreCreditAccountDebitTransaction.

eventstringOptional

Shopify's system event for this entry (ADJUSTMENT, ORDER_PAYMENT, ORDER_REFUND, …).

orderNamestringOptional

The order this entry originated from, when it has one (e.g. #1042).

createdAtstring · date-timeOptional
expiresAtstring · date-timeOptional

Set only for credit entries that expire.

amountnumberOptional

Transaction amount, in currencyCode, NOT cents.

currencyCodestringOptional
balanceAfternumberOptional

Account balance immediately after this entry, in currencyCode, NOT cents.

metaobjectOptional
Show properties
paginationStoreCreditPageInfoOptional

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
hasNextPagebooleanOptional
hasPrevPagebooleanOptional
startCursorstringOptional

Pass as ?before= to fetch the page before this one.

endCursorstringOptional

Pass as ?after= to fetch the page after this one.

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
GET/customer/store-credit/transactions
GET /app/api/v1/headless/customer/store-credit/transactions HTTP/1.1
Host: api.joy.so
Accept: */*
200Store-credit transaction page (possibly empty)
{
"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

GET/customer/addresses

Feeds a headless host's address book / checkout-prefill view.

Responses
200This customer's saved addresses (possibly empty)
successbooleanOptional
Example: true
dataAddress[]Optional
Show properties
idobjectOptional

Shopify pass-through id — string or integer depending on the fetch path (numeric REST id vs GID string), not a guaranteed integer.

firstNamestringOptional
lastNamestringOptional
companystringOptional
address1stringOptional
address2stringOptional
citystringOptional
provincestringOptional
provinceCodestringOptional
zipstringOptional
countrystringOptional
phonestringOptional
isDefaultbooleanOptional

Whether this is the customer's default address.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`ADDRESSES_FETCH_FAILED` — the Shopify Admin API call failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
GET/customer/addresses
GET /app/api/v1/headless/customer/addresses HTTP/1.1
Host: api.joy.so
Accept: */*
200This customer's saved addresses (possibly empty)
{
"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

POST/customer/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.

Request body
addressAddressInputRequired
Show properties
idobjectOptional

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.

firstNamestringOptional
lastNamestringOptional
companystringOptional
address1stringRequired
address2stringOptional
citystringRequired
provincestringOptional
provinceCodestringOptional
zipstringOptional
countrystringRequired
phonestringOptional
isDefaultbooleanOptional

Set to also make this the customer's default address.

Responses
200Address saved
successbooleanOptional
Example: true
dataAddressOptional

A Shopify customer address.

Show properties
idobjectOptional

Shopify pass-through id — string or integer depending on the fetch path (numeric REST id vs GID string), not a guaranteed integer.

firstNamestringOptional
lastNamestringOptional
companystringOptional
address1stringOptional
address2stringOptional
citystringOptional
provincestringOptional
provinceCodestringOptional
zipstringOptional
countrystringOptional
phonestringOptional
isDefaultbooleanOptional

Whether this is the customer's default address.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`BAD_REQUEST` — missing `address`, missing/non-string `address1`/`city`/`country`, or a malformed optional field.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
502`ADDRESS_SAVE_FAILED` — the Shopify Admin API call failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/addresses
POST /app/api/v1/headless/customer/addresses HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
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
}
}
200Address saved
{
"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

POST/customer/addresses/delete

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.

Request body
addressIdstringRequired

The Shopify address id to delete (the id from a previous GET /customer/addresses entry).

Responses
200Address deleted
successbooleanOptional
Example: true
dataobjectOptional
Show properties
deletedbooleanOptional
Example: true
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
400`BAD_REQUEST` — missing `addressId`.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
502`ADDRESS_DELETE_FAILED` — the Shopify Admin API call failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/addresses/delete
POST /app/api/v1/headless/customer/addresses/delete HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 27
{
"addressId": "string"
}
200Address deleted
{
"success": true,
"data": {
"deleted": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Update this customer's name, avatar, phone and/or marketing consent

POST/customer/profile

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.

Request body
firstNamestringOptional

Empty string clears the stored first name.

lastNamestringOptional

Empty string clears the stored last name.

avatarBase64stringOptional

A data URL (data:image/png;base64,...) or raw base64 image, up to 10MB decoded.

phonestringOptional

Empty string clears the stored phone. Validated/synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.

acceptsMarketingbooleanOptional

Email marketing consent. Synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.

acceptsSmsMarketingbooleanOptional

SMS marketing consent. Synced to Shopify for a customer linked to a Shopify account; kept locally for a guest.

Responses
200Profile updated
successbooleanOptional
Example: true
dataProfileResultOptional

Response for POST /customer/profile.

Show properties
firstNamestringOptional
lastNamestringOptional
avatarUrlstringOptional
phonestringOptional

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.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`PROFILE_UPDATE_FAILED` — an unexpected error while saving.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/profile
POST /app/api/v1/headless/customer/profile HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 159
{
"firstName": "string",
"lastName": "string",
"avatarBase64": "string",
"phone": "string",
"acceptsMarketing": true,
"acceptsSmsMarketing": true
}
200Profile updated
{
"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)

POST/customer/birthday

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.

Request body
birthdaystringRequired

MM/DD.

dateOfBirthstringOptional

YYYY-MM-DD. Optional regardless of the shop's ShopConfig.isNewFormatDOB setting — send it when the shop's birthday form collects a year.

birthdayRewardsBirthdayRewardsMapOptional

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.

Example: {"2026":["reward_discount_1"]}
Responses
200Birthday saved
successbooleanOptional
Example: true
dataBirthdayResultOptional

Response for POST /customer/birthday.

Show properties
birthdaystringOptional

MM/DD, as saved.

dateOfBirthstringOptional

YYYY-MM-DD, when sent.

earnedCompleteBirthdaybooleanOptional

Whether the "completed your birthday info" bonus was granted.

earnPointintegerOptional

Points granted by that bonus (0 when it declined).

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
POST/customer/birthday
POST /app/api/v1/headless/customer/birthday HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 127
{
"birthday": "string",
"dateOfBirth": "string",
"birthdayRewards": {
"2026": [
"reward_discount_1"
]
}
}
200Birthday saved
{
"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

POST/customer/birthday-rewards

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.

Request body
birthdayRewardsBirthdayRewardsMapRequired

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.

Example: {"2026":["reward_discount_1"]}
Responses
200Selection saved
successbooleanOptional
Example: true
dataBirthdayRewardsResultOptional

Response for POST /customer/birthday-rewards. Echoes the stored map back so a client can reconcile without a re-fetch.

Show properties
birthdayRewardsBirthdayRewardsMapOptional

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.

Example: {"2026":["reward_discount_1"]}
metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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).
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
502`BIRTHDAY_REWARDS_UPDATE_FAILED` — the write itself failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/birthday-rewards
POST /app/api/v1/headless/customer/birthday-rewards HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 76
{
"birthdayRewards": {
"2026": [
"reward_discount_1"
]
}
}
200Selection saved
{
"success": true,
"data": {
"birthdayRewards": {
"2026": [
"reward_discount_1"
]
}
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Claim this customer's annual birthday reward

POST/customer/birthday/claim

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.

Request body
birthdayRewardsobjectOptional
Responses
200Claim accepted
successbooleanOptional
Example: true
dataBirthdayClaimResultOptional

Response 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
claimedbooleanOptional

Always true — a declined claim is an error response, not false.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
403`NOT_ELIGIBLE` — opt-in guest who has not joined the loyalty program.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
429
No body
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.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/birthday/claim
POST /app/api/v1/headless/customer/birthday/claim HTTP/1.1
Host: api.joy.so
Content-Type: application/json
Accept: */*
Content-Length: 27
{
"birthdayRewards": {}
}
200Claim accepted
{
"success": true,
"data": {
"claimed": true
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}

Render this customer's in-store scan code

POST/customer/qrcode

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.

Responses
200QR code rendered
successbooleanOptional
Example: true
dataQrCodeResultOptional

Response for POST /customer/qrcode. Only the rendered image — never the encoded payload or any field of the customer document.

Show properties
qrCodestringOptional

PNG data URL (data:image/png;base64,...), 1000px square. The same value GET /info reports as customer.qrImageUrl.

metaobjectOptional

Optional metadata (pagination, counts).

timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
401
No body
429
No body
502`QR_GENERATION_FAILED` — encoding failed.
successbooleanOptional
Example: false
errorobjectOptional
Show properties
messagestringOptional
Example: "Unauthorized"
codestringOptional
Example: "UNAUTHORIZED"
statusCodeintegerOptional
Example: 401
timestampstring · date-timeOptional
Example: "2026-07-31T07:27:54.123Z"
POST/customer/qrcode
POST /app/api/v1/headless/customer/qrcode HTTP/1.1
Host: api.joy.so
Accept: */*
200QR code rendered
{
"success": true,
"data": {
"qrCode": "string"
},
"meta": {},
"timestamp": "2026-07-31T07:27:54.123Z"
}