Mobile App Engagement (Custom Actions)
One shared, server-to-server endpoint for partner apps (for example Shopney). The partner reports that a customer completed an action in its app. Joy maps the action to the Mobile app action program the merchant turned on and rewards the customer once.
context.provider is a self-declared label used for audit only; there is no partner-specific behaviour.
Plan: the store must be on the Ultimate plan. Other paid plans get HTTP 200 with status: "not_eligible" and reason: "plan_not_eligible"; the Free plan gets 402.
Key points
- Server only. Call it from the partner's server with the store's App ID and Secret Key. Never ship the Secret Key inside a mobile app or put it in a WebView URL.
- Business results are HTTP 200. Read
status(awarded,already_awarded,not_eligible). Only infrastructure problems return 5xx. - Safe to retry. Resend the same
eventIdand Joy never rewards twice (see Retry guidance). - Once per customer. Sending the same action again on the next login returns
already_awarded. - Exempt from the daily quota, with its own burst limit (see Rate limits).
Authentication
POST https://api.joy.so/rest_api/v2/custom-actions
Content-Type: application/json
X-Joy-Loyalty-App-Key: <store App ID>
X-Joy-Loyalty-Secret-Key: <store Secret Key>
Idempotency-Key: shopney-evt-01JABC123The keys are the pair the merchant copies from Settings → Developers. Read-only (ro_) keys are rejected with 403 because this is a POST. Use https://dev-api.joy.so only when you were told to use staging.
Request
| Field | Required | Meaning |
|---|---|---|
Idempotency-Key header | Yes | Must equal eventId. Missing → 400 VALIDATION_ERROR (details.field: "Idempotency-Key"); different → 400 IDEMPOTENCY_KEY_MISMATCH |
eventId | Yes | 1–255 characters, ^[A-Za-z0-9._:-]+$. Created by the partner when the event happens, with a unique prefix of your choice (e.g. shopney-). Joy remembers an eventId for 24 hours. Reuse it on retries; create a new one for a new event (the next login) |
actionName | Yes | A string of at most 64 characters: mobile_app_first_authenticated_session (first login in the app) or mobile_push_permission_granted (turned on notifications). Any other value → 404 ACTION_NOT_FOUND |
occurredAt | Yes | ISO 8601 date-time with a time and a timezone (Z or an offset such as +07:00), e.g. 2026-10-26T12:05:00Z; any other format → 400 VALIDATION_ERROR. No more than 5 minutes in the future and no older than 30 days, otherwise 400 OCCURRED_AT_OUT_OF_RANGE. Audit only; eligibility is checked when Joy receives the request |
customer.shopifyCustomerId | Yes | Shopify customer ID, either gid://shopify/Customer/123 or 123 (a positive integer of up to 20 digits, no leading zero). This is the only REST v2 endpoint that accepts a GID. Anything else → 400 INVALID_CUSTOMER_ID; a missing value → 400 VALIDATION_ERROR |
shop.myshopifyDomain | Recommended | Up to 255 characters. If sent and it does not match the keys (case-insensitive) → 403 SHOP_MISMATCH |
context.provider | Yes | Partner name, a-z0-9_-, 2–32 characters, e.g. shopney. Self-declared, audit only |
context.platform | Yes | ios, android or other |
context.permissionStatus | Conditional | Required for mobile_push_permission_granted; send null or omit it for the first-login action. Up to 32 characters, the OS notification permission (authorized, granted, provisional, denied…). Only authorized and granted are rewarded; any other value → not_eligible / permission_not_granted. Missing, null or empty for mobile_push_permission_granted → 400 VALIDATION_ERROR (details.field: "context.permissionStatus") |
context.appVersion | No | Up to 64 characters, for troubleshooting |
context.installId | No | Up to 255 characters. Joy hashes it (sha256) before storing |
eventId, actionName, occurredAt, customer and context are required (so are customer.shopifyCustomerId, context.provider and context.platform). Fields that are not listed are ignored. Joy returns the first validation problem it finds, as 400 VALIDATION_ERROR with the field name in details.field.
{
"eventId": "shopney-evt-01JABC123",
"actionName": "mobile_push_permission_granted",
"occurredAt": "2026-10-26T12:05:00Z",
"shop": {"myshopifyDomain": "example.myshopify.com"},
"customer": {"shopifyCustomerId": "gid://shopify/Customer/123456789"},
"context": {
"provider": "shopney",
"platform": "ios",
"appVersion": "5.2.0",
"permissionStatus": "authorized"
}
}Actions
actionName | Meaning | Rewarded when |
|---|---|---|
mobile_app_first_authenticated_session | First logged-in session in the app | Always send it on every login. Joy rewards the first one, then answers already_awarded. Customers who installed the app before the merchant turned the program on are rewarded at their next login |
mobile_push_permission_granted | The customer turned on notifications | context.permissionStatus is authorized or granted. provisional / ephemeral are not rewarded. Permission events are not rewarded retroactively: if the customer turns notifications on later, the partner must send a new event (new eventId) with authorized or granted |
Response
Every business result is HTTP 200, with status at the top level (there is no data envelope):
{
"success": true,
"status": "awarded",
"reason": null,
"eventId": "shopney-evt-01JABC123",
"actionName": "mobile_push_permission_granted",
"reward": {"type": "points", "amount": 100},
"replayed": false
}Treat the event as done only when success is true and status is present.
status | Meaning | What the partner should do |
|---|---|---|
awarded | Reward granted. reward has the type (points or store_credit), the amount actually credited (the shop's global earning limit can lower it) and, for store_credit, the currency | Done. May notify the customer |
already_awarded | The customer already received this reward. No reward field | Done, do not resend |
not_eligible | No reward; the cause is in reason | See the table below |
replayed: true (and the response header Idempotent-Replayed: true) means the eventId was already processed and this is the original result.
reason when not_eligible
reason | Meaning | Resend? |
|---|---|---|
earning_rule_not_available | The store has no program turned on for this action | Do not retry this event; the next qualifying event (e.g. next login) is evaluated again |
rule_not_started / rule_expired | The program has not started or has ended | Do not retry this event; the next qualifying event (e.g. next login) is evaluated again |
plan_not_eligible | The store's plan does not include this feature | No |
integration_disabled | App rewards are turned off for the store | No |
test_mode | The store is in test mode and the customer is not a test customer | No |
customer_not_enrolled | The customer has not joined the loyalty program (stores that require sign-up) | Yes, with a new eventId after the customer joins |
customer_excluded / b2b_customer | The customer is excluded or is a B2B customer | No |
permission_not_granted | The customer has not actually turned on notifications | Yes, with a new eventId once they do |
global_earning_limit_reached | The customer hit the store's overall earning limit | No |
multi_shop_unsupported | A linked sub-store, which is not supported | No |
Errors
There are two error shapes. Branch on the HTTP status and on success.
- Errors from Joy's API gateway (401, 402, 429, and 403 for a read-only key) return
{"success": false, "error": "<string>"}, and an uninstalled store is HTTP 200 with{"error": "shop uninstalled", "code": "SHOP_UNINSTALLED"}. - Every other error uses the standard envelope
{"success": false, "error": {"message", "code", "statusCode", "details?"}, "timestamp"}.
| HTTP | Code | When | What the partner should do |
|---|---|---|---|
| 400 | VALIDATION_ERRORIDEMPOTENCY_KEY_MISMATCHOCCURRED_AT_OUT_OF_RANGEINVALID_CUSTOMER_ID | Missing field, wrong format or too long (details.field names it), Idempotency-Key missing or different from eventId | Fix the request, then resend with the same eventId |
| 401 | (string error) | Wrong App ID or Secret Key, or the merchant regenerated the key | Stop sending, ask the merchant to update the key, then resend with the same eventId |
| 402 | plan_upgrade_required (string error) | The store's plan has no API access (Free, shown as Starter in the Shopify admin, and any unrecognised plan), unless the shop has a manual API grant | Stop sending for this store |
| 403 | SHOP_MISMATCH | shop.myshopifyDomain does not match the keys | Do not resend |
| 403 | (string error) | Read-only (ro_) key | Use the full-access key pair |
| 404 | ACTION_NOT_FOUND | actionName is not in the list | Fix actionName, then resend with the same eventId |
| 404 | CUSTOMER_NOT_FOUND( details.retryable: true) | The customer is not in Joy yet (account just created) | Resend with the same eventId after 1 min, 5 min, 30 min, 2 h, then stop |
| 409 | IDEMPOTENCY_KEY_REUSED | An existing eventId was reused with different content (actionName, customer or permissionStatus). No Retry-After | Never retry; check how eventId is generated |
| 409 | IDEMPOTENCY_IN_PROGRESS | A previous request with the same eventId is still being processed (Retry-After: 2 is sent with this code only). Also returned while another request for the same customer and action is still being processed | Resend the same eventId after Retry-After, at most 5 attempts, then stop, treat the event as failed and contact Joy |
| 429 | (string error) | Sending too fast | Wait the seconds in the Retry-After header, then resend with the same eventId. If it keeps happening, back off exponentially from 1 s and give up after about 24 h |
| 500 | INTERNAL_ERROR | Joy failed while processing. The event is not recorded as done; a reward may or may not have been issued, so always resend with the same eventId (never a new one) | See the retry table below |
| 503 | CUSTOM_ACTIONS_TEMPORARILY_DISABLED | Joy turned the feature off temporarily. Retry-After: 60 is sent | See the retry table below |
| 200 | SHOP_UNINSTALLED | The store uninstalled Joy | Stop sending for this store |
Note the difference from /transactions/points/award: a reused idempotency key is 409 IDEMPOTENCY_KEY_REUSED here, but 422 there.
Requests rejected before the event is recorded (400, 403, 404 ACTION_NOT_FOUND, 503, and the gateway errors) leave no trace, so they can be resent freely once fixed.
Retry guidance
Always resend with the same eventId (and Idempotency-Key), so a retry can never reward twice. If a 5xx is returned, a reward may or may not have been issued; the same eventId is the way to find out, because the retry answers awarded, already_awarded or the stored result.
| Response | What to do |
|---|---|
200 without success: true and a status | Treat it like a 5xx and resend with the same eventId (except SHOP_UNINSTALLED: stop) |
| Timeout, connection error, or a body you cannot parse | Resend with the same eventId, using the 5xx schedule below |
| 500, 503 | Wait max(Retry-After, backoff) where backoff is exponential from 1 s (1 s, 2 s, 4 s…) and capped at 5 minutes, plus random jitter; keep trying for about 24 hours, then stop. Retry-After is 60 s on 503 and is not sent on 500 |
| 429 | Wait the seconds in Retry-After, then resend. If it persists, back off exponentially from 1 s and give up after about 24 hours |
409 IDEMPOTENCY_IN_PROGRESS | Wait Retry-After, then resend. After 5 attempts stop, treat the event as failed and contact Joy |
409 IDEMPOTENCY_KEY_REUSED | Never retry. Check how eventId is generated |
400, 401, 403 SHOP_MISMATCH or read-only key, 404 ACTION_NOT_FOUND | Nothing was recorded. Fix the cause (request, keys, actionName), then resend with the same eventId |
402, 200 SHOP_UNINSTALLED | Stop sending for this store |
404 CUSTOMER_NOT_FOUND | Resend with the same eventId after 1 min, 5 min, 30 min, 2 h, then stop |
not_eligible | Final for that event. Do not retry; the next qualifying event (e.g. next login) is evaluated again |
Idempotency
eventIdis required, andIdempotency-Keymust equal it.- A first request records the event and processes it.
- The same
eventIdwith the same content returns the stored result withreplayed: trueand never rewards again. The content that counts isactionName, the customer andpermissionStatus;occurredAtandappVersionare not compared. - The same
eventIdwith different content returns 409IDEMPOTENCY_KEY_REUSED. - A request that is still running returns 409
IDEMPOTENCY_IN_PROGRESSwithRetry-After: 2. Resend the sameeventIdafter it, at most 5 attempts, then stop and contact Joy. - Joy remembers an
eventIdfor 24 hours. Retry within that window; after it, a resend of an already rewarded event answersalready_awarded(no second reward).
A new event, such as the next login, needs a new eventId. It does not reward again: the customer already has a reward claim for the program, so Joy answers already_awarded.
Rate limits
This path is exempt from the daily quota (so a login wave does not use up the quota shared with the other REST and MCP calls) and has its own burst limit:
| Plan | Burst limit | Daily quota |
|---|---|---|
| Ultimate (Enterprise) | 100 requests per 10 seconds | exempt |
Rewards need the Ultimate plan. On other paid plans the request is accepted (Pro 50, Advanced 100 requests per 10 seconds) but answers not_eligible / plan_not_eligible; the Free plan (Starter in the Shopify admin) gets 402.
An overall limit of 120 requests per 10 seconds still applies to every request on every plan, and is shared with the rest of the API. Successful responses carry no X-RateLimit-* headers. On 429 wait for Retry-After.
Which actions are on (GET /rest_api/v2/custom-actions)
Send POST /custom-actions only for actions this endpoint reports as enabled.
GET https://api.joy.so/rest_api/v2/custom-actions
X-Joy-Loyalty-App-Key: <store App ID>
X-Joy-Loyalty-Secret-Key: <store Secret Key or read-only key>- Keys: the same pair as the POST. Read-only (
ro_) keys are accepted. - Quota: exempt from the daily quota, with its own burst limit (the same numbers as the POST, counted separately, so polling never limits your event POSTs). The overall 120 requests per 10 seconds still applies. Same 402 and 503
CUSTOM_ACTIONS_TEMPORARILY_DISABLEDas the POST. - When to call: when the store connects, about once a day, and after the webhooks below.
{
"success": true,
"data": {
"version": "2026-10-30T08:00:00.000Z",
"actions": [
{
"actionName": "mobile_push_permission_granted",
"enabled": true,
"programId": "9cN2xkq0",
"reward": {"type": "points", "amount": 100},
"startDate": null,
"endDate": null
},
{"actionName": "mobile_app_first_authenticated_session", "enabled": false}
]
},
"meta": {},
"timestamp": "2026-10-30T08:00:00.123Z"
}| Field | Meaning |
|---|---|
actions | Every action in the list, always, enabled or not |
actions[].enabled | true when a POST for this action can reward a customer right now: the store has an active Mobile app action program for it that has started and not ended, and the store is eligible (main store, a plan with custom actions, app rewards not turned off). Customer checks (enrolled, excluded, permission…) still happen on the POST |
actions[].programId, reward, startDate, endDate | Present whenever a program exists for the action, even when it is turned off; omitted when there is none. reward is {type: "points", amount} or {type: "store_credit", amount, currency}, or null for another reward type. Dates are ISO 8601 or null |
version | The latest updatedAt of the store's Mobile app action programs (ISO 8601), or null when there are none. Compare it with the value you stored to see whether a program changed |
Program webhooks (program/created, program/updated, program/deleted)
Subscribe to these topics through the Webhook API, like points/earned. They cover earning and spending programs, including Mobile app action programs.
| Topic | Sent when |
|---|---|
program/created | A program is created |
program/updated | A field of the public program shape changes. Reordering programs (priority) and internal counters send no event |
program/deleted | A program is deleted. data is the program as it was before the delete, plus deletedAt |
{
"webhookId": "0b9f3c1e-6a43-4c55-9d0e-2f5d1c7a8b10",
"topic": "program/updated",
"triggeredAt": "2026-10-30T08:00:00.000Z",
"data": {
"id": "9cN2xkq0",
"type": "earning",
"event": "custom_program",
"typeCustom": "custom_action_9cN2xkq0",
"actionName": "mobile_push_permission_granted",
"title": "Turn on notifications",
"status": false,
"earnPoint": 100,
"updatedAt": "2026-10-30T07:59:59.000Z"
}
}datais the REST v2Programshape (the same asGET /rest_api/v2/programs/{id}).actionNameis present only on Mobile app action programs (typeCustomstarting withcustom_action_).- Headers:
X-Joy-Loyalty-Topic,X-Joy-Loyalty-Shop-Source-Id, andX-Joy-Loyalty-Hmac-Sha256: the base64 HMAC-SHA256 of the raw request body, keyed with the store's Secret Key. Verify it against the raw bytes before parsing. - Delivery is at least once. Answer 2xx within 10 seconds. Any other answer, or none, is retried after 1 min, 10 min, 1 h, 6 h and 16 h (6 attempts over about 23 hours), then dropped. The same event can arrive more than once: dedupe on
webhookId. A retry ofprogram/createdorprogram/updatedis dropped when the program was deleted or changed again in the meantime, because a newer event carries the newer state. - Order is not guaranteed. Keep the latest
triggeredAtyou processed for eachdata.id, and ignore an event whosetriggeredAtis older. - Re-fetch the read model. On any
program/*event whosedata.typeCustomstarts withcustom_action_, callGET /rest_api/v2/custom-actionsinstead of derivingenabledfrom the payload.enabledalso changes without any event: when astartDateorendDatepasses, when the store's plan changes, or when app rewards are turned off for the store. Keep the daily resync for those cases.
Examples
Reward for turning on notifications:
curl -X POST "https://api.joy.so/rest_api/v2/custom-actions" \
-H "X-Joy-Loyalty-App-Key: $JOY_APP_KEY" \
-H "X-Joy-Loyalty-Secret-Key: $JOY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: shopney-evt-01JABC123" \
-d '{
"eventId": "shopney-evt-01JABC123",
"actionName": "mobile_push_permission_granted",
"occurredAt": "2026-10-26T12:05:00Z",
"shop": {"myshopifyDomain": "example.myshopify.com"},
"customer": {"shopifyCustomerId": "gid://shopify/Customer/123456789"},
"context": {"provider": "shopney", "platform": "ios", "permissionStatus": "authorized"}
}'Already rewarded (the next login, with a new eventId):
{
"success": true,
"status": "already_awarded",
"reason": null,
"eventId": "shopney-evt-01JABC124",
"actionName": "mobile_app_first_authenticated_session",
"replayed": false
}Not eligible:
{
"success": true,
"status": "not_eligible",
"reason": "permission_not_granted",
"eventId": "shopney-evt-01JABC125",
"actionName": "mobile_push_permission_granted",
"replayed": false
}Customer not in Joy yet (retry with the same eventId):
{
"success": false,
"error": {
"message": "Customer not found",
"code": "CUSTOMER_NOT_FOUND",
"statusCode": 404,
"details": {"retryable": true}
},
"timestamp": "2026-10-26T12:05:00.123Z"
}Same eventId, different content:
{
"success": false,
"error": {
"message": "This eventId was already used with different content",
"code": "IDEMPOTENCY_KEY_REUSED",
"statusCode": 409
},
"timestamp": "2026-10-26T12:05:00.123Z"
}How merchants turn it on
The merchant creates a Mobile app action program in the Joy admin (Custom program → Trigger action → Mobile app action), picks the action and the reward, and turns it on. The program can only be created in the admin: REST v2 program creation rejects the custom_action type, and the storefront triggerActivity rejects it too.
- First login in the app shows on the website's Ways to earn as an invitation to download the app (App Store / Google Play links). A click only opens the link and never earns.
- Turn on notifications is hidden on the website. Points are not taken back if the customer later turns notifications off.
- The once-per-customer record belongs to the program: if the merchant deletes the program and creates a new one, customers can be rewarded again by the new program.
- Each reward goes through the same emails, tier progress and
points/earnedwebhook as any other earning.
API reference
List custom actions
Returns every action in the custom action list, enabled or not, so a partner only sends POST /custom-actions for actions the merchant has turned on. Call it when the store connects and resync about once a day. Program changes arrive as program/created, program/updated and program/deleted webhooks: on any of them whose data.typeCustom starts with custom_action_, call this endpoint again. enabled can also change without any webhook (a startDate or endDate passing, the store's plan, app rewards turned off for the store), which the daily resync covers.
Authentication: X-Joy-Loyalty-App-Key and X-Joy-Loyalty-Secret-Key. Read-only (ro_) keys are accepted.
enabled is true when the store has an active "Mobile app action" program for the action that has started and not ended, and the store is eligible (main store, plan with custom actions). It answers whether a POST for that action can reward a customer right now. programId, reward, startDate and endDate are present whenever a program exists for the action, even when it is turned off. reward.type is points or store_credit (with currency).
version is the latest updatedAt of the store's App action programs (ISO 8601), or null when there are none. Compare it with the value you stored to know whether anything changed.
Quota: exempt from the daily quota, with its own burst limit (same numbers as POST /custom-actions, counted separately so polling never limits your event POSTs). Same 503 CUSTOM_ACTIONS_TEMPORARILY_DISABLED as the POST.
200Action list
Show properties
Show properties
Show properties
401The API keys are missing, invalid, or could not be verified. The body is NOT the standard error envelope: `error` is a string.
false"Unauthorized access"402The shop's plan does not include REST API / MCP access. This is NOT a 403: the entitlement is recoverable by upgrading, and `upgrade_url` links to the merchant's subscription page. Clients should surface the upgrade path and stop retrying — the response will not change until the plan does.
"The REST API is not included in the free plan. Upgrade to Pro or higher to enable API and MCP access."Deep link to the merchant's own subscription page. Omitted only when the shop has no domain to build a link for.
"https://admin.shopify.com/store/example/apps/joy-loyalty/embed/subscription"429Burst limit exceeded. Wait `Retry-After` seconds.
500`INTERNAL_ERROR`: retry later.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"503`CUSTOM_ACTIONS_TEMPORARILY_DISABLED`: retry after `Retry-After` seconds.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"GET /rest_api/v2/custom-actions HTTP/1.1Host: dev-api.joy.soX-Joy-Loyalty-App-Key: YOUR_API_KEYX-Joy-Loyalty-Secret-Key: YOUR_API_KEYAccept: */*
{"success": true,"data": {"version": "2026-10-30T08:00:00.000Z","actions": [{"actionName": "mobile_push_permission_granted","enabled": true,"programId": "9cN2xkq0","reward": {"type": "points","amount": 100},"startDate": null,"endDate": null},{"actionName": "mobile_app_first_authenticated_session","enabled": false}]},"meta": {},"timestamp": "2026-10-30T08:00:00.123Z"}
Report a customer action
Server-to-server endpoint for partners (Shopney, Tapcart, a merchant's own server). The partner reports that a customer completed an action in the app, and Joy rewards the customer once according to the "Mobile app action" program the merchant turned on.
Server only: call this from the partner's server with the store's App ID and Secret Key. Never ship the Secret Key in a mobile app.
Authentication: X-Joy-Loyalty-App-Key and X-Joy-Loyalty-Secret-Key. Read-only (ro_) keys are rejected with 403 because this is a POST.
Idempotency: eventId is required and the Idempotency-Key header must equal it. Resending an existing eventId with the same content returns the original result with replayed: true and never rewards twice. The same eventId with different content returns 409 IDEMPOTENCY_KEY_REUSED. A previous request that is still running returns 409 IDEMPOTENCY_IN_PROGRESS.
Once per customer: each customer is rewarded once per program. mobile_app_first_authenticated_session is sent on every login: Joy rewards the first one and answers already_awarded after that, so customers who installed the app before the merchant turned the program on are rewarded at their next login.
Results: every business result is HTTP 200 and read from status (awarded, already_awarded, not_eligible with a reason). Only infrastructure problems return 5xx.
Order of checks: authentication, plan and burst limit (the API gateway, see the API description), then 503 CUSTOM_ACTIONS_TEMPORARILY_DISABLED when Joy has switched the feature off, then request validation in this order (400 VALIDATION_ERROR, 400 IDEMPOTENCY_KEY_MISMATCH, 400 OCCURRED_AT_OUT_OF_RANGE, 400 INVALID_CUSTOMER_ID, 404 ACTION_NOT_FOUND, 403 SHOP_MISMATCH, then 400 VALIDATION_ERROR for a missing permissionStatus), then the event is recorded and eligibility is evaluated. Requests rejected before the event is recorded leave no trace, so they can be resent freely once fixed.
Retry guidance: always resend with the same eventId. If a 5xx is returned a reward may or may not have been issued; the same eventId is how you find out, so never use a new one. On 500/503, a timeout, a connection error, an unparseable body, or a 200 without success: true and a status (other than SHOP_UNINSTALLED): wait max(Retry-After, backoff), where backoff is exponential from 1 second (1 s, 2 s, 4 s…) capped at 5 minutes, plus random jitter, and give up after about 24 hours. Retry-After is 60 on 503 and is not sent on 500. On 429 wait for Retry-After, then resend; if it persists, back off exponentially from 1 second and give up after about 24 hours. On 409 IDEMPOTENCY_IN_PROGRESS resend after Retry-After; after 5 attempts stop, treat the event as failed and contact Joy. Never retry 409 IDEMPOTENCY_KEY_REUSED. On 400, 401, 403 SHOP_MISMATCH or a read-only key, and 404 ACTION_NOT_FOUND, nothing was recorded: fix the cause, then resend with the same eventId. Stop sending for the store on 402 and on 200 SHOP_UNINSTALLED. 404 CUSTOMER_NOT_FOUND means the customer is not in Joy yet (the account was just created): resend after 1 minute, 5 minutes, 30 minutes and 2 hours, then stop; the response carries details.retryable: true. A not_eligible result is final for that event; do not retry it, the next qualifying event (for example the next login) is evaluated again.
Two error shapes. Joy's API gateway (401, 402, 429 from the rate limiter, 403 for a read-only key) returns { "success": false, "error": "<string>" }, and the uninstalled-store case is HTTP 200 with code: "SHOP_UNINSTALLED". Every other error uses the standard envelope { "success": false, "error": { "message", "code", "statusCode", "details?" }, "timestamp" }.
Quota: this operation is exempt from the daily quota and has its own burst limit; see the API description.
Required on POST /custom-actions. Must be identical to the eventId in the body: a missing header returns 400 VALIDATION_ERROR and a different value returns 400 IDEMPOTENCY_KEY_MISMATCH. Reuse it when you retry the same event; create a new eventId (and key) for a new event, such as the next login.
Created by the partner when the event happens, with a unique prefix of your choice (e.g. shopney-). Joy remembers an eventId for 24 hours (retry within that window). Reuse it on retries; create a new one for a new event. Must equal the Idempotency-Key header.
"shopney-evt-01JABC123"What the customer did. mobile_app_first_authenticated_session is the first login in the app; send it on every login, Joy rewards the first one and answers already_awarded after that. mobile_push_permission_granted is turning on notifications. Any other value (up to 64 characters) returns 404 ACTION_NOT_FOUND.
"mobile_push_permission_granted"When the customer did it (ISO 8601 date-time with a time and a timezone, Z or an offset such as +07:00; any other format returns 400 VALIDATION_ERROR). No more than 5 minutes in the future and no older than 30 days, otherwise 400 OCCURRED_AT_OUT_OF_RANGE. Used for audit only; eligibility is checked at the time Joy receives the request.
"2026-10-26T12:05:00Z"Optional but recommended. Protects against rewarding the wrong store.
Show properties
The store domain. If sent and it does not match the keys (case-insensitive), the request is rejected with 403 SHOP_MISMATCH.
"example.myshopify.com"Show properties
Shopify customer ID, either the GID (gid://shopify/Customer/123)
numeric ID (123, a positive integer of up to 20 digits with
no leading zero). This is a deliberate exception to the numeric-only rule of the other operations. A malformed value returns 400 INVALID_CUSTOMER_ID; a missing value returns 400 VALIDATION_ERROR.
"gid://shopify/Customer/123456789"Show properties
Partner name. Self-declared and used for audit only; it does not identify or authenticate the partner.
"shopney""ios"Required for mobile_push_permission_granted (missing, null or empty returns 400 VALIDATION_ERROR): the OS notification permission (authorized, granted, provisional, denied, …). Only authorized and granted are rewarded; any other value returns not_eligible with permission_not_granted. Send null (or omit it) for mobile_app_first_authenticated_session.
"authorized"App version, for troubleshooting.
"5.2.0"App install ID. Joy hashes it (sha256) before storing it.
200Business result (`awarded`, `already_awarded` or `not_eligible`), or the shared `SHOP_UNINSTALLED` response when the store uninstalled Joy. Replays of an existing `eventId` return the original result with `replayed: true`.
400Request rejected before anything was recorded: a missing, malformed or too long field, a missing `Idempotency-Key` header, or a missing `permissionStatus` for `mobile_push_permission_granted` (`VALIDATION_ERROR`, with the offending field in `details.field`), an `Idempotency-Key` that differs from `eventId` (`IDEMPOTENCY_KEY_MISMATCH`), an `occurredAt` outside the last 30 days to 5 minutes ahead (`OCCURRED_AT_OUT_OF_RANGE`), or a customer ID that is neither a GID nor a number (`INVALID_CUSTOMER_ID`). Fix the request, then resend with the same `eventId`.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"401The API keys are missing, invalid, or could not be verified. The body is NOT the standard error envelope: `error` is a string.
false"Unauthorized access"402The shop's plan does not include REST API / MCP access. This is NOT a 403: the entitlement is recoverable by upgrading, and `upgrade_url` links to the merchant's subscription page. Clients should surface the upgrade path and stop retrying — the response will not change until the plan does.
"The REST API is not included in the free plan. Upgrade to Pro or higher to enable API and MCP access."Deep link to the merchant's own subscription page. Omitted only when the shop has no domain to build a link for.
"https://admin.shopify.com/store/example/apps/joy-loyalty/embed/subscription"403One of: a read-only (`ro_`) key on this POST (gateway shape, `error` is a string); `SHOP_MISMATCH` (`shop.myshopifyDomain` does not match the keys). Do not resend `SHOP_MISMATCH` or a read-only-key request as is.
404`ACTION_NOT_FOUND`: `actionName` is not in the action list. Fix it, then resend with the same `eventId`. `CUSTOMER_NOT_FOUND`: the customer is not in Joy yet. Resend with the same `eventId` after 1 minute, 5 minutes, 30 minutes and 2 hours, then stop (`details.retryable` is `true`).
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"409An `eventId` conflict on `POST /custom-actions`. Unlike `/transactions/points/award`, a reused key is a **409**, not a 422. `IDEMPOTENCY_KEY_REUSED`: this `eventId` was already used with a different `actionName`, customer or `permissionStatus`. Do not resend; check how your `eventId` is generated. `IDEMPOTENCY_IN_PROGRESS`: a request with the same `eventId` (or another request for the same customer and action) is still being processed. Resend with the SAME `eventId` after the number of seconds in `Retry-After`, at most 5 attempts; after that stop, treat the event as failed and contact Joy. `Retry-After` (2 seconds) is sent only with `IDEMPOTENCY_IN_PROGRESS`; never retry `IDEMPOTENCY_KEY_REUSED`.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"429Sending too fast: the burst limit was exceeded. Wait the number of seconds in `Retry-After`, then resend with the same `eventId`.
500`INTERNAL_ERROR`: Joy failed while processing the event. The event is not recorded as done, and a reward may or may not have been issued, so resend with the same `eventId` (never a new one). Wait `max(Retry-After, backoff)`, with backoff exponential from 1 second capped at 5 minutes plus jitter, and give up after about 24 hours. `Retry-After` is not sent on 500.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"503`CUSTOM_ACTIONS_TEMPORARILY_DISABLED`: Joy turned the feature off temporarily. `Retry-After` is `60`. Resend with the same `eventId`, waiting `max(Retry-After, backoff)` with backoff exponential from 1 second capped at 5 minutes plus jitter, and give up after about 24 hours.
falseShow properties
"Resource not found""NOT_FOUND"404Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).
"2023-07-28T07:27:54.123Z"POST /rest_api/v2/custom-actions HTTP/1.1Host: dev-api.joy.soX-Joy-Loyalty-App-Key: YOUR_API_KEYX-Joy-Loyalty-Secret-Key: YOUR_API_KEYContent-Type: application/jsonAccept: */*Content-Length: 409{"eventId": "shopney-evt-01JABC123","actionName": "mobile_push_permission_granted","occurredAt": "2026-10-26T12:05:00Z","shop": {"myshopifyDomain": "example.myshopify.com"},"customer": {"shopifyCustomerId": "gid://shopify/Customer/123456789"},"context": {"provider": "shopney","platform": "ios","appVersion": "5.2.0","permissionStatus": "authorized"}}
{"success": true,"status": "awarded","reason": null,"eventId": "shopney-evt-01JABC123","actionName": "mobile_push_permission_granted","reward": {"type": "points","amount": 100},"replayed": false}