Developer Docs
Storefront API
Overview

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:

GroupPurpose
ReadList and inspect the authenticated customer's subscription contracts, upcoming orders, order history, next-charge preview, and shipping options.
TrialRead trial status for one contract.
ManagementMutate 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
EnvironmentBase URL
Staginghttps://<your-staging-app-domain>/storefront-api/v1
Productionhttps://<your-prod-app-domain>/storefront-api/v1

Notes:

  • The version segment /v1 is 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 informational X-Joy-Storefront-Api-Version: v1 header.
  • All endpoints in this guide are written relative to the base URL above. For example, GET /subscriptions means GET https://<your-app-domain>/storefront-api/v1/subscriptions.

Health probe

GET /storefront-api/v1/__health

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

LayerHeader(s)IdentityWhere it comes from
Layer 2 — App credentialsX-Joy-App-Id + X-Joy-Secret-KeyThe calling integration/app and its owning shopGenerated in the Developers tab in the Shopify admin of the app
Layer 1 — Customer sessionX-Session-IdThe end customerObtained 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:

  • shopId is 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:

  1. The contract is loaded scoped to the session's shopId (multi-tenant isolation).
  2. 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.

EndpointPagination styleQuery paramsmeta returned
GET /subscriptionsNone (returns the full list for the customer)status (CSV), direction (asc | desc)none
GET /subscriptions/{id}/upcoming-ordersPage-style (offset under the hood)limitPerPage (1–50, default 10)hasNext, hasPre, total
GET /subscriptions/{id}/history-ordersCursorafter (cursor string)hasNext, hasPre, total
GET /upcoming-orders (all contracts)Cursorafter, limitPerPage (1–50, default 10), nextCycleOnly (boolean)hasNext, hasPre, total, nextCursor

Notes on current state:

  • meta.nextCursor is currently only returned by GET /upcoming-orders (the all-contracts dashboard endpoint). The per-contract history-orders endpoint accepts an after cursor, but its meta exposes only hasNext / hasPre / total — it does not currently echo a nextCursor.
  • meta.total is included when the underlying query provides it.
  • limitPerPage is clamped to the range 1–50 by validation; values outside it are rejected with 400 VALIDATION_FAILED.
  • direction (on GET /subscriptions) controls createdAt ordering.
  • status (on GET /subscriptions) is a comma-separated filter, e.g. status=active,paused.

Error handling

HTTP status codes:

StatusMeaning
200Success.
400Bad request — validation failed or an invalid path/query parameter.
401Authentication problem (app credentials or session).
403Authenticated but not allowed (shop mismatch or a disabled action).
404Resource not found or not owned by the customer (IDOR-safe).
422Validation failed (currently only the trial endpoint's inline param check).
429Rate limited.
500Unexpected server error (the message is generic; details are logged server-side).

error.code is a stable enum. The codes the implementation emits:

error.codeTypical statusWhen
INVALID_APP_CREDENTIALS401Missing/invalid X-Joy-App-Id / X-Joy-Secret-Key.
AUTH_REQUIRED401Missing X-Session-Id (or session context not resolved).
SESSION_EXPIRED401Unknown or expired session.
SHOP_MISMATCH403App credentials' shop differs from the session shop.
FORBIDDEN403Action disabled by merchant settings (e.g. self-granted subscription discount).
NOT_FOUND404Contract missing or not owned by the customer.
VALIDATION_FAILED400 / 422Body/query/param failed validation.
INVALID_PARAM400An invalid path parameter (e.g. non-numeric contractId / lineId).
RATE_LIMITED429A rate-limit dimension was exceeded.
INTERNAL_ERROR500Unexpected error (generic message).
BAD_REQUEST / CONFLICT / CLIENT_ERROR400 / 409 / 4xxStatus-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.

DimensionLimitWindow (s)BurstScope
ip1206020Per client IP (pre-auth shopless; re-evaluated per shop post-auth).
customer606010Per authenticated customer.
shop6006060Per shop (aggregate).
write20605Per customer, on POST / PUT / DELETE / PATCH only.
auth10605Per IP, on OAuth/auth routes only (brute-force protection).
  • Capacity of each bucket is limit + burst; the steady refill rate is limit / window tokens per second.
  • X-RateLimit-Limit reports 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

  1. Generate app credentials (Shopify admin → your Joy Subscription app → Developers tab) and store the secret server-side.
  2. Implement the Customer Account OAuth flow to obtain a sessionId.
  3. Call read endpoints (GET /subscriptions) to confirm auth works end-to-end.
  4. Add management actions behind your own UI.
  5. 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.
Product
Install on ShopifyWebsitePricing
Resources
DocumentationGetting StartedFAQsIntegrations
Company
Avada GroupContact Support
© 2026 Avada Group. All rights reserved.