REST API v2
Mobile App Engagement (Custom Actions)

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 eventId and 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-01JABC123

The 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

FieldRequiredMeaning
Idempotency-Key headerYesMust equal eventId. Missing → 400 VALIDATION_ERROR (details.field: "Idempotency-Key"); different → 400 IDEMPOTENCY_KEY_MISMATCH
eventIdYes1–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)
actionNameYesA 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
occurredAtYesISO 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.shopifyCustomerIdYesShopify 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.myshopifyDomainRecommendedUp to 255 characters. If sent and it does not match the keys (case-insensitive) → 403 SHOP_MISMATCH
context.providerYesPartner name, a-z0-9_-, 2–32 characters, e.g. shopney. Self-declared, audit only
context.platformYesios, android or other
context.permissionStatusConditionalRequired 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.appVersionNoUp to 64 characters, for troubleshooting
context.installIdNoUp 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

actionNameMeaningRewarded when
mobile_app_first_authenticated_sessionFirst logged-in session in the appAlways 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_grantedThe customer turned on notificationscontext.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.

statusMeaningWhat the partner should do
awardedReward 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 currencyDone. May notify the customer
already_awardedThe customer already received this reward. No reward fieldDone, do not resend
not_eligibleNo reward; the cause is in reasonSee 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

reasonMeaningResend?
earning_rule_not_availableThe store has no program turned on for this actionDo not retry this event; the next qualifying event (e.g. next login) is evaluated again
rule_not_started / rule_expiredThe program has not started or has endedDo not retry this event; the next qualifying event (e.g. next login) is evaluated again
plan_not_eligibleThe store's plan does not include this featureNo
integration_disabledApp rewards are turned off for the storeNo
test_modeThe store is in test mode and the customer is not a test customerNo
customer_not_enrolledThe customer has not joined the loyalty program (stores that require sign-up)Yes, with a new eventId after the customer joins
customer_excluded / b2b_customerThe customer is excluded or is a B2B customerNo
permission_not_grantedThe customer has not actually turned on notificationsYes, with a new eventId once they do
global_earning_limit_reachedThe customer hit the store's overall earning limitNo
multi_shop_unsupportedA linked sub-store, which is not supportedNo

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"}.
HTTPCodeWhenWhat the partner should do
400VALIDATION_ERROR
IDEMPOTENCY_KEY_MISMATCH
OCCURRED_AT_OUT_OF_RANGE
INVALID_CUSTOMER_ID
Missing field, wrong format or too long (details.field names it), Idempotency-Key missing or different from eventIdFix the request, then resend with the same eventId
401(string error)Wrong App ID or Secret Key, or the merchant regenerated the keyStop sending, ask the merchant to update the key, then resend with the same eventId
402plan_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 grantStop sending for this store
403SHOP_MISMATCHshop.myshopifyDomain does not match the keysDo not resend
403(string error)Read-only (ro_) keyUse the full-access key pair
404ACTION_NOT_FOUNDactionName is not in the listFix actionName, then resend with the same eventId
404CUSTOMER_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
409IDEMPOTENCY_KEY_REUSEDAn existing eventId was reused with different content (actionName, customer or permissionStatus). No Retry-AfterNever retry; check how eventId is generated
409IDEMPOTENCY_IN_PROGRESSA 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 processedResend 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 fastWait 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
500INTERNAL_ERRORJoy 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
503CUSTOM_ACTIONS_TEMPORARILY_DISABLEDJoy turned the feature off temporarily. Retry-After: 60 is sentSee the retry table below
200SHOP_UNINSTALLEDThe store uninstalled JoyStop 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.

ResponseWhat to do
200 without success: true and a statusTreat it like a 5xx and resend with the same eventId (except SHOP_UNINSTALLED: stop)
Timeout, connection error, or a body you cannot parseResend with the same eventId, using the 5xx schedule below
500, 503Wait 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
429Wait 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_PROGRESSWait Retry-After, then resend. After 5 attempts stop, treat the event as failed and contact Joy
409 IDEMPOTENCY_KEY_REUSEDNever retry. Check how eventId is generated
400, 401, 403 SHOP_MISMATCH or read-only key, 404 ACTION_NOT_FOUNDNothing was recorded. Fix the cause (request, keys, actionName), then resend with the same eventId
402, 200 SHOP_UNINSTALLEDStop sending for this store
404 CUSTOMER_NOT_FOUNDResend with the same eventId after 1 min, 5 min, 30 min, 2 h, then stop
not_eligibleFinal for that event. Do not retry; the next qualifying event (e.g. next login) is evaluated again

Idempotency

  1. eventId is required, and Idempotency-Key must equal it.
  2. A first request records the event and processes it.
  3. The same eventId with the same content returns the stored result with replayed: true and never rewards again. The content that counts is actionName, the customer and permissionStatus; occurredAt and appVersion are not compared.
  4. The same eventId with different content returns 409 IDEMPOTENCY_KEY_REUSED.
  5. A request that is still running returns 409 IDEMPOTENCY_IN_PROGRESS with Retry-After: 2. Resend the same eventId after it, at most 5 attempts, then stop and contact Joy.
  6. Joy remembers an eventId for 24 hours. Retry within that window; after it, a resend of an already rewarded event answers already_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:

PlanBurst limitDaily quota
Ultimate (Enterprise)100 requests per 10 secondsexempt

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_DISABLED as 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"
}
FieldMeaning
actionsEvery action in the list, always, enabled or not
actions[].enabledtrue 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, endDatePresent 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
versionThe 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.

TopicSent when
program/createdA program is created
program/updatedA field of the public program shape changes. Reordering programs (priority) and internal counters send no event
program/deletedA 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"
  }
}
  • data is the REST v2 Program shape (the same as GET /rest_api/v2/programs/{id}). actionName is present only on Mobile app action programs (typeCustom starting with custom_action_).
  • Headers: X-Joy-Loyalty-Topic, X-Joy-Loyalty-Shop-Source-Id, and X-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 of program/created or program/updated is 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 triggeredAt you processed for each data.id, and ignore an event whose triggeredAt is older.
  • Re-fetch the read model. On any program/* event whose data.typeCustom starts with custom_action_, call GET /rest_api/v2/custom-actions instead of deriving enabled from the payload. enabled also changes without any event: when a startDate or endDate passes, 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/earned webhook as any other earning.

API reference

List custom actions

GET/rest_api/v2/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.

Authorizations
X-Joy-Loyalty-App-KeystringRequired
X-Joy-Loyalty-Secret-KeystringRequired
Responses
200Action list
successbooleanRequired
dataobjectRequired
Show properties
versionstring · date-timeRequired
actionsobject[]Required
Show properties
actionNamestringRequired
enabledbooleanRequired
programIdstringOptional
rewardobjectOptional
Show properties
typestringOptional
amountnumberOptional
currencystringOptional
startDatestring · date-timeOptional
endDatestring · date-timeOptional
metaobjectOptional
timestampstring · date-timeOptional
401The API keys are missing, invalid, or could not be verified. The body is NOT the standard error envelope: `error` is a string.
successbooleanRequired
Example: false
errorstringRequired
Example: "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.
successbooleanRequired
errorstringRequired
messagestringRequired
Example: "The REST API is not included in the free plan. Upgrade to Pro or higher to enable API and MCP access."
upgrade_urlstring · uriOptional

Deep link to the merchant's own subscription page. Omitted only when the shop has no domain to build a link for.

Example: "https://admin.shopify.com/store/example/apps/joy-loyalty/embed/subscription"
429Burst limit exceeded. Wait `Retry-After` seconds.
500`INTERNAL_ERROR`: retry later.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "2023-07-28T07:27:54.123Z"
503`CUSTOM_ACTIONS_TEMPORARILY_DISABLED`: retry after `Retry-After` seconds.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "2023-07-28T07:27:54.123Z"
GET/rest_api/v2/custom-actions
GET /rest_api/v2/custom-actions HTTP/1.1
Host: dev-api.joy.so
X-Joy-Loyalty-App-Key: YOUR_API_KEY
X-Joy-Loyalty-Secret-Key: YOUR_API_KEY
Accept: */*
200Action list
{
"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

POST/rest_api/v2/custom-actions

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.

Authorizations
X-Joy-Loyalty-App-KeystringRequired
X-Joy-Loyalty-Secret-KeystringRequired
Parameters
Idempotency-Keyheader · stringRequired

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.

Request body
eventIdstringRequired

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.

Example: "shopney-evt-01JABC123"
actionNamestringRequired

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.

Example: "mobile_push_permission_granted"
occurredAtstring · date-timeRequired

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.

Example: "2026-10-26T12:05:00Z"
shopobjectOptional

Optional but recommended. Protects against rewarding the wrong store.

Show properties
myshopifyDomainstringOptional

The store domain. If sent and it does not match the keys (case-insensitive), the request is rejected with 403 SHOP_MISMATCH.

Example: "example.myshopify.com"
customerobjectRequired
Show properties
shopifyCustomerIdstringRequired

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.

Example: "gid://shopify/Customer/123456789"
contextobjectRequired
Show properties
providerstringRequired

Partner name. Self-declared and used for audit only; it does not identify or authenticate the partner.

Example: "shopney"
platformstringRequired
Example: "ios"
permissionStatusstringOptional

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.

Example: "authorized"
appVersionstringOptional

App version, for troubleshooting.

Example: "5.2.0"
installIdstringOptional

App install ID. Joy hashes it (sha256) before storing it.

Responses
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`.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "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.
successbooleanRequired
Example: false
errorstringRequired
Example: "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.
successbooleanRequired
errorstringRequired
messagestringRequired
Example: "The REST API is not included in the free plan. Upgrade to Pro or higher to enable API and MCP access."
upgrade_urlstring · uriOptional

Deep link to the merchant's own subscription page. Omitted only when the shop has no domain to build a link for.

Example: "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`).
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "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`.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "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.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "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.
successbooleanRequired
Example: false
errorobjectRequired
Show properties
messagestringOptional
Example: "Resource not found"
codestringOptional
Example: "NOT_FOUND"
statusCodeintegerOptional
Example: 404
detailsobjectOptional

Extra context. An object on most errors; an array on the loyalty-page and loyalty-hub text errors (the skipped changes).

timestampstring · date-timeOptional
Example: "2023-07-28T07:27:54.123Z"
POST/rest_api/v2/custom-actions
POST /rest_api/v2/custom-actions HTTP/1.1
Host: dev-api.joy.so
X-Joy-Loyalty-App-Key: YOUR_API_KEY
X-Joy-Loyalty-Secret-Key: YOUR_API_KEY
Content-Type: application/json
Accept: */*
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"
}
}
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`.
{
"success": true,
"status": "awarded",
"reason": null,
"eventId": "shopney-evt-01JABC123",
"actionName": "mobile_push_permission_granted",
"reward": {
"type": "points",
"amount": 100
},
"replayed": false
}