Overview
The Joy Subscription Storefront REST API lets a storefront, customer-account UI, or headless portal read and manage a customer's own subscription contracts. It is a RESTful, JSON-over-HTTPS surface (by Avada).
This surface is completely separate from the embedded admin API (/api/v1): different authentication, different middleware, different controllers. Do not mix them.
Endpoint groups
The API is mounted under /storefront-api/v1 and exposes three groups:
| Group | Purpose |
|---|---|
| Read | List and inspect the authenticated customer's subscription contracts, upcoming orders, order history, next-charge preview, and shipping options. |
| Trial | Read trial status for one contract. |
| Management | Mutate a contract: pause/resume/cancel, swap product, change frequency, update shipping address / payment method / shipping option, manage lines and upcoming-order lines, billing-cycle actions, and discounts. |
Key characteristics:
- Two-layer auth — an app credential pair (machine identity) and a customer-account session (end-user identity) are required together.
- Customer-scoped — a request can only ever read or modify contracts owned by the authenticated customer.
- Consistent envelope — every response (success and error) uses the same
{success, …}shape.
Base URL
The host differs per environment. Use the placeholder and substitute your real app domain:
https://<your-app-domain>/storefront-api/v1| Environment | Base URL |
|---|---|
| Staging | https://<your-staging-app-domain>/storefront-api/v1 |
| Production | https://<your-prod-app-domain>/storefront-api/v1 |
Notes:
- The version segment
/v1is part of the path and is hardcoded in the router — there is no separate version header you set on requests. The health probe response carries an informationalX-Joy-Storefront-Api-Version: v1header. - All endpoints in this guide are written relative to the base URL above. For example,
GET /subscriptionsmeansGET https://<your-app-domain>/storefront-api/v1/subscriptions.
Health probe
GET /storefront-api/v1/__healthUnauthenticated. Returns:
{ "success": true, "data": { "version": "v1", "env": "…" } }Use it for uptime checks and version discovery. This is the only endpoint that requires no authentication.
Authentication (two layers)
This is the biggest difference from a single-key API. Every business endpoint (everything except GET /__health) requires both layers on the same request.
| Layer | Header(s) | Identity | Where it comes from |
|---|---|---|---|
| Layer 2 — App credentials | X-Joy-App-Id + X-Joy-Secret-Key | The calling integration/app and its owning shop | Generated in the Developers tab in the Shopify admin of the app |
| Layer 1 — Customer session | X-Session-Id | The end customer | Obtained via the Customer Account OAuth flow |
A combined request carries all three headers:
X-Joy-App-Id: <app-id>
X-Joy-Secret-Key: sk_live_xxx # server-side only
X-Session-Id: <session-id>Rules enforced by the auth middleware:
shopIdis always derived from the customer session — the API never reads a shop id from a request header.- The shop that owns the app credentials must match the shop on the customer session, otherwise the request fails with
403 SHOP_MISMATCH.
The Secret Key is shown once at creation/rotation time, prefixed with sk_live_, and is stored only as a salted HMAC-SHA256 hash — it cannot be recovered later. It is server-side only: never embed it in browser JavaScript, mobile apps, or any client the customer can inspect. Proxy all calls through your own backend.
The customer session (sessionId) is established through Shopify's Customer Account API OAuth (PKCE) flow. Those OAuth endpoints live on the client API surface (/clientApi/customer-account-api/...), not on /storefront-api/v1. See the Authentication page for the full flow.
Authorization (IDOR-safe)
A session identifies exactly one customer. The API enforces ownership on every contract access:
- The contract is loaded scoped to the session's
shopId(multi-tenant isolation). - Ownership is checked:
contract.customerId === session.customerId.
If the contract does not exist or is owned by a different customer, the API returns 404 NOT_FOUND in both cases. This is deliberate: it prevents IDOR (Insecure Direct Object Reference) — a client cannot probe contract ids to discover whether another customer's contract exists.
You never pass a customerId to scope reads. For the dashboard endpoint GET /upcoming-orders, the customer id is taken from the session and injected server-side; any client-supplied value is ignored.
Response envelope
Every Storefront API response uses one consistent envelope.
Success:
{
"success": true,
"data": { /* resource or array */ },
"meta": { /* present only on paginated list endpoints */ }
}Some write endpoints return { "success": true } with no data when there is no meaningful body to return.
Error:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Subscription not found."
}
}Every envelope (success and error) also carries a top-level timestamp field — an ISO-8601 UTC string stamped by middleware on any response body that has a success field. For example:
{
"success": true,
"data": { /* … */ },
"timestamp": "2026-06-25T12:34:56.789Z"
}Pagination
Pagination is not uniform across read endpoints — it reflects the underlying implementation. Use the table below.
| Endpoint | Pagination style | Query params | meta returned |
|---|---|---|---|
GET /subscriptions | None (returns the full list for the customer) | status (CSV), direction (asc | desc) | none |
GET /subscriptions/{id}/upcoming-orders | Page-style (offset under the hood) | limitPerPage (1–50, default 10) | hasNext, hasPre, total |
GET /subscriptions/{id}/history-orders | Cursor | after (cursor string) | hasNext, hasPre, total |
GET /upcoming-orders (all contracts) | Cursor | after, limitPerPage (1–50, default 10), nextCycleOnly (boolean) | hasNext, hasPre, total, nextCursor |
Notes on current state:
meta.nextCursoris currently only returned byGET /upcoming-orders(the all-contracts dashboard endpoint). The per-contracthistory-ordersendpoint accepts anaftercursor, but itsmetaexposes onlyhasNext/hasPre/total— it does not currently echo anextCursor.meta.totalis included when the underlying query provides it.limitPerPageis clamped to the range 1–50 by validation; values outside it are rejected with400 VALIDATION_FAILED.direction(onGET /subscriptions) controlscreatedAtordering.status(onGET /subscriptions) is a comma-separated filter, e.g.status=active,paused.
Error handling
HTTP status codes:
| Status | Meaning |
|---|---|
200 | Success. |
400 | Bad request — validation failed or an invalid path/query parameter. |
401 | Authentication problem (app credentials or session). |
403 | Authenticated but not allowed (shop mismatch or a disabled action). |
404 | Resource not found or not owned by the customer (IDOR-safe). |
422 | Validation failed (currently only the trial endpoint's inline param check). |
429 | Rate limited. |
500 | Unexpected server error (the message is generic; details are logged server-side). |
error.code is a stable enum. The codes the implementation emits:
error.code | Typical status | When |
|---|---|---|
INVALID_APP_CREDENTIALS | 401 | Missing/invalid X-Joy-App-Id / X-Joy-Secret-Key. |
AUTH_REQUIRED | 401 | Missing X-Session-Id (or session context not resolved). |
SESSION_EXPIRED | 401 | Unknown or expired session. |
SHOP_MISMATCH | 403 | App credentials' shop differs from the session shop. |
FORBIDDEN | 403 | Action disabled by merchant settings (e.g. self-granted subscription discount). |
NOT_FOUND | 404 | Contract missing or not owned by the customer. |
VALIDATION_FAILED | 400 / 422 | Body/query/param failed validation. |
INVALID_PARAM | 400 | An invalid path parameter (e.g. non-numeric contractId / lineId). |
RATE_LIMITED | 429 | A rate-limit dimension was exceeded. |
INTERNAL_ERROR | 500 | Unexpected error (generic message). |
BAD_REQUEST / CONFLICT / CLIENT_ERROR | 400 / 409 / 4xx | Status-derived fallback codes from the error handler when an upstream error carries no explicit code. |
For 500 INTERNAL_ERROR, the message is always generic ("An unexpected error occurred. Please try again later."). Raw error details are never returned to the client.
Rate limiting
Rate limiting is a Redis-backed token bucket, evaluated across several dimensions. Exceeding any applicable dimension returns 429 RATE_LIMITED.
| Dimension | Limit | Window (s) | Burst | Scope |
|---|---|---|---|---|
ip | 120 | 60 | 20 | Per client IP (pre-auth shopless; re-evaluated per shop post-auth). |
customer | 60 | 60 | 10 | Per authenticated customer. |
shop | 600 | 60 | 60 | Per shop (aggregate). |
write | 20 | 60 | 5 | Per customer, on POST / PUT / DELETE / PATCH only. |
auth | 10 | 60 | 5 | Per IP, on OAuth/auth routes only (brute-force protection). |
- Capacity of each bucket is
limit + burst; the steady refill rate islimit / windowtokens per second. X-RateLimit-Limitreports the configured per-window limit (not capacity).
On a 429, the response includes the headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds). The body adds retryAfterSeconds and scope:
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry later.",
"retryAfterSeconds": 12,
"scope": "customer"
}
}The message for RATE_LIMITED is a fixed English string. The machine-readable code, scope, and retryAfterSeconds fields are stable — integrate against those, not the message text. If Redis is unavailable the limiter fails open (requests proceed); function-level concurrency/instance limits act as the backstop.
Data scoping
All data access is customer-scoped and IDOR-safe (see Authorization above). Some management endpoints return PII — GET /shipping-address returns name/address/phone, and GET /payment-method returns masked card metadata only (brand/last4/expiry, never PAN/CVV). Treat these as PII: log carefully, store minimally, and comply with your privacy policy. Certain actions are merchant-gated and only succeed if the merchant has opted in (e.g. customer-granted subscription discounts require customerPortal.allowSubscriptionDiscount); a disabled action returns 403 FORBIDDEN.
Recommended integration order
- Generate app credentials (Shopify admin → your Joy Subscription app → Developers tab) and store the secret server-side.
- Implement the Customer Account OAuth flow to obtain a
sessionId. - Call read endpoints (
GET /subscriptions) to confirm auth works end-to-end. - Add management actions behind your own UI.
- Register a webhook receiver and verify HMAC signatures.
Next steps
- Authentication — the two-layer auth model and the full Customer Account OAuth flow.
- Endpoints — the complete read / trial / management reference.
- Errors & rate limits — status codes, error codes, and rate-limit headers.
- Webhooks — outbound topics, delivery, retries, and HMAC verification.
- Examples — cURL, Node, Python, and PHP integration snippets.