# Joy Subscription — Storefront REST API (v1)

Developer guide for integrating with the **Joy Subscription Storefront REST API**
(by Avada). This API lets a storefront, customer-account UI, or headless portal
read and manage a **customer's own** subscription contracts.

> This surface is completely separate from the embedded admin API (`/api/v1`):
> different authentication, different middleware, different controllers. Do not
> mix them.

This single document is self-contained: it covers authentication, the response
envelope, pagination, errors, rate limits, enums, the **complete endpoint
reference**, and integration examples. For outbound webhooks, see the companion
**Webhook Guide**.

---

## Table of contents

1. [Overview](#1-overview)
2. [Base URLs](#2-base-urls)
3. [Authentication (two layers)](#3-authentication-two-layers)
4. [Authorization (customer-scoped / IDOR-safe)](#4-authorization-customer-scoped--idor-safe)
5. [Response format](#5-response-format)
6. [Pagination & query parameters](#6-pagination--query-parameters)
7. [Error handling](#7-error-handling)
8. [Rate limiting](#8-rate-limiting)
9. [Data types & enums](#9-data-types--enums)
10. [Security](#10-security)
11. [Endpoint reference](#11-endpoint-reference)
12. [Integration examples](#12-integration-examples)
13. [Support & next steps](#13-support--next-steps)

---

## 1. Overview

The Storefront API is a RESTful, JSON-over-HTTPS surface mounted under
`/storefront-api/v1`. It exposes three endpoint groups:

| Group | Purpose |
|-------|---------|
| **Read** | List & inspect the authenticated customer's subscription contracts, upcoming orders, order history, next-charge preview, 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 & upcoming-order lines, billing-cycle actions, 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.

---

## 2. Base URLs

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 `/v1` is part of the path and is **hardcoded** in the
  router — there is no separate version header you set on requests. Responses do
  carry an informational `X-Joy-Storefront-Api-Version: v1` header on the health
  probe.
- 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.

---

## 3. 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 (below) |

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` (e.g. an app key
  from shop A used with a session from shop B).

### 3.1 Layer 2 — App credentials (server-side only)

1. Open your app's **Developers** tab in the Shopify admin.
2. Generate (or rotate) an App ID + Secret Key pair.
3. The **App ID** is a public, URL-safe identifier (safe to expose).
4. The **Secret Key** is shown **once** at creation/rotation time, prefixed with
   `sk_live_`. It is stored only as a salted HMAC-SHA256 hash — it cannot be
   recovered later. Store it in your server-side secret manager.

> **The secret key is server-side only.** Never embed it in browser JavaScript,
> mobile apps, or any client the customer can inspect. All Storefront API calls
> must be proxied through your own backend that attaches these headers.

> The API is server-to-server (authenticated with your App secret); CORS does not
> apply, and there is no per-shop allowed-origins configuration.

Send on every request:

```
X-Joy-App-Id: <your-app-id>
X-Joy-Secret-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

### 3.2 Layer 1 — Customer session (OAuth via Customer Account API)

A customer session is established through Shopify's Customer Account API OAuth
(PKCE) flow. These OAuth endpoints live on the **client API** surface
(`/clientApi/...`), not on `/storefront-api/v1`. The flow yields a `sessionId`
you then pass as `X-Session-Id`.

**Flow:**

1. **Initiate** — redirect the customer's browser to:

   ```
   GET /clientApi/customer-account-api/auth?shopDomain=<shop>.myshopify.com&returnUrl=<your-return-url>
   ```

   This builds the PKCE authorization URL and redirects the customer to Shopify's
   Customer Account login.

2. **Callback** — Shopify redirects back to:

   ```
   GET /clientApi/customer-account-api/callback?code=...&state=...
   ```

   The server exchanges the code, creates a session, and redirects to your
   `returnUrl` with a **one-time** `authToken` query parameter:
   `…?authToken=<one-time-token>`.

3. **Exchange** — your client/server exchanges the one-time token for a durable
   session id:

   ```
   POST /clientApi/customer-account-api/exchange-token
   Content-Type: application/json

   { "authToken": "<one-time-token>" }
   ```

   Response:

   ```json
   {
     "success": true,
     "data": {
       "sessionId": "…",
       "customerId": 123456789,
       "shopId": "…",
       "customer": { /* … or null */ }
     }
   }
   ```

4. **Use** — send the `sessionId` as `X-Session-Id` on every Storefront API call.

**Supporting OAuth endpoints** (same `/clientApi/customer-account-api/` prefix):

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/verify` | Verify a `sessionId` is still valid and fetch customer data. Body `{ "sessionId": "…" }`. |
| `POST` | `/logout` | Invalidate a session. Body `{ "sessionId": "…" }`. |

> These OAuth endpoints use the legacy client-API envelope
> (`{success, data}` / `{success, error: "<message string>"}`), **not** the
> structured `error: {code, message}` envelope of the Storefront API. They are
> documented here only because they produce the `sessionId` the Storefront API
> consumes.

### 3.3 Combined request shape

```
X-Joy-App-Id: <app-id>
X-Joy-Secret-Key: sk_live_xxx        # server-side only
X-Session-Id: <session-id>
```

Authentication failure modes:

| Condition | Status | `error.code` |
|-----------|--------|--------------|
| Missing/invalid `X-Joy-App-Id` or `X-Joy-Secret-Key` | `401` | `INVALID_APP_CREDENTIALS` |
| Missing `X-Session-Id` | `401` | `AUTH_REQUIRED` |
| Unknown/expired session | `401` | `SESSION_EXPIRED` |
| App shop ≠ session shop | `403` | `SHOP_MISMATCH` |

---

## 4. Authorization (customer-scoped / 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.

---

## 5. Response format

Every Storefront API response uses one consistent envelope.

**Success:**

```jsonc
{
  "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:**

```jsonc
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Subscription not found."
  }
}
```

> Responses do **not** include a top-level `timestamp` field. If your integration
> needs request timing, use your own client clock or the standard HTTP `Date`
> response header.

---

## 6. Pagination & query parameters

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:

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

---

## 7. 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 codes

`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 ≠ 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.

---

## 8. 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 is
  `limit / window` tokens per second.
- `X-RateLimit-Limit` reports the configured per-window **limit** (not capacity).

### 429 response headers

| Header | Meaning |
|--------|---------|
| `Retry-After` | Seconds until at least one token is available. |
| `X-RateLimit-Limit` | The configured per-window limit for the exceeded dimension. |
| `X-RateLimit-Remaining` | Tokens remaining in the bucket. |
| `X-RateLimit-Reset` | Unix epoch (seconds) when a token becomes available. |

429 body:

```json
{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Please retry later.",
    "retryAfterSeconds": 12,
    "scope": "customer"
  }
}
```

> The machine-readable `code`, `scope`, and `retryAfterSeconds` fields are
> stable — integrate against those, not the message text.

**Resilience:** if Redis is unavailable the limiter **fails open** (requests
proceed); function-level concurrency/instance limits act as the backstop.

---

## 9. Data types & enums

### Subscription contract status

Contract `status` is returned in **uppercase**:

| Value | Meaning |
|-------|---------|
| `ACTIVE` | Active, billing on schedule. |
| `PAUSED` | Paused by customer/merchant. |
| `CANCELLED` | Cancelled. |
| `EXPIRED` | Reached its end / max cycles. |
| `FAILED` | Billing failure state. |
| `PENDING` | Not yet active. |
| `DECLINED` | Declined. |

### Status change actions (request input)

`PUT /subscriptions/{id}/status` accepts a lowercase `status` **action**:

| Action | Effect |
|--------|--------|
| `pause` | Pause the contract. |
| `resume` | Resume a paused contract. |
| `cancel` | Cancel the contract. |

### Delivery / billing interval

`frequency` updates use an **uppercase** interval enum plus a positive count:

| `interval` | `intervalCount` |
|------------|-----------------|
| `DAY` / `WEEK` / `MONTH` / `YEAR` | positive integer |

### Billing-cycle action types

`PUT /subscriptions/{id}/billing-cycle` `type`:

| `type` | Meaning |
|--------|---------|
| `skip` | Skip a cycle. |
| `reschedule` | Move a cycle's billing date (requires `newBillingDate`). |
| `resume` | Un-skip a cycle. |
| `order_now` | Bill the cycle immediately. |

### Discount type

`PUT /subscriptions/{id}/subscription-discount` `discountType`:

| Value |
|-------|
| `PERCENTAGE` |
| `FIXED_AMOUNT` |

### Cancellation reason codes

`POST /subscriptions/{id}/cancel` `reasonCode`:

| Value |
|-------|
| `too_expensive` |
| `no_longer_needed` |
| `switching` |
| `quality_issue` |
| `other` |

### Trial status (response projection)

`GET /subscriptions/{id}/trial` returns:

| Field | Type | Notes |
|-------|------|-------|
| `contractId` | number | |
| `hasTrial` | boolean | |
| `isInTrial` | boolean | |
| `trialEndsAt` | string \| null | ISO 8601 |
| `daysRemaining` | number \| null | |
| `trialProcessed` | boolean | |
| `afterAction` | `pause` \| `continue` \| null | What happens when the trial ends. |
| `status` | string \| null | Contract status. |

---

## 10. Security

- **Secret key is server-side only.** Never expose `X-Joy-Secret-Key` to a
  browser, mobile app, or any client-inspectable surface. Proxy all calls through
  your backend.
- **HTTPS only.** All requests must be over TLS.
- **Customer scoping / IDOR.** The API enforces ownership; do not build logic
  that assumes you can read another customer's contract — you will get `404`.
- **PII handling.** Some management endpoints return personal data
  (`GET /shipping-address` returns name/address/phone; `GET /payment-method`
  returns masked card metadata only — brand/last4/expiry, never PAN/CVV). Treat
  these as PII: log carefully, store minimally, comply with your privacy policy.
- **Merchant-gated actions.** Certain actions are disabled by default and only
  succeed if the merchant has opted in (e.g. customer-granted subscription
  discounts require `customerPortal.allowSubscriptionDiscount`). A disabled
  action returns `403 FORBIDDEN`.
- **Rotate credentials** if a secret is ever exposed (Developers tab → rotate).
  Rotation revokes the previous active key.

---

## 11. Endpoint reference

All paths are relative to the base URL `https://<your-app-domain>/storefront-api/v1`.
Unless stated otherwise, **every endpoint requires both auth layers**:

```
X-Joy-App-Id: <app-id>
X-Joy-Secret-Key: sk_live_xxx     # server-side only
X-Session-Id: <session-id>
```

`{contractId}` is the numeric `subscriptionContractId`. Accessing a contract not
owned by the authenticated customer returns `404 NOT_FOUND`.

### Endpoint index

#### Health

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/__health` | none | Uptime / version probe. |

#### Read group

| Method | Path | Description |
|--------|------|-------------|
| GET | `/upcoming-orders` | All upcoming orders across the customer's contracts. |
| GET | `/subscriptions` | List the customer's subscription contracts. |
| GET | `/subscriptions/{contractId}` | Full detail of one contract. |
| GET | `/subscriptions/{contractId}/upcoming-orders` | Upcoming orders of one contract. |
| GET | `/subscriptions/{contractId}/history-orders` | Past (billed) orders of one contract. |
| GET | `/subscriptions/{contractId}/next-charge` | Preview the next charge. |
| GET | `/subscriptions/{contractId}/shipping-options` | Available shipping options. |

#### Trial group

| Method | Path | Description |
|--------|------|-------------|
| GET | `/subscriptions/{contractId}/trial` | Trial status of one contract. |

#### Management group

| Method | Path | Description |
|--------|------|-------------|
| PUT | `/subscriptions/{contractId}/status` | Pause / resume / cancel. |
| POST | `/subscriptions/{contractId}/cancel` | Cancel with a reason code. |
| PUT | `/subscriptions/{contractId}/product` | Swap product / variant. |
| PUT | `/subscriptions/{contractId}/frequency` | Change delivery frequency. |
| GET | `/subscriptions/{contractId}/shipping-address` | Get current shipping address. |
| PUT | `/subscriptions/{contractId}/shipping-address` | Update shipping address. |
| GET | `/subscriptions/{contractId}/payment-method` | Get masked payment metadata. |
| PUT | `/subscriptions/{contractId}/payment-method` | Update payment method. |
| PUT | `/subscriptions/{contractId}/shipping-option` | Change shipping rate. |
| PUT | `/subscriptions/{contractId}/subscription-discount` | Apply a subscription discount. |
| POST | `/subscriptions/{contractId}/lines` | Add a recurring line. |
| DELETE | `/subscriptions/{contractId}/lines/{lineId}` | Remove a recurring line. |
| PUT | `/subscriptions/{contractId}/billing-cycle` | Billing-cycle action. |
| PUT | `/subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}` | Update an upcoming-order line. |
| DELETE | `/subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}` | Remove an upcoming-order line. |
| POST | `/subscriptions/{contractId}/upcoming-orders/products` | Add a one-off product to an upcoming order. |
| POST | `/subscriptions/{contractId}/discount` | Apply a discount code. |
| DELETE | `/subscriptions/{contractId}/discount` | Remove an applied discount. |

---

### Read group — details

#### GET `/__health`

No authentication.

```json
{ "success": true, "data": { "version": "v1", "env": "production" } }
```

#### GET `/subscriptions`

List all subscription contracts owned by the authenticated customer. Each
contract is enriched with its current cycle's `nextOrderDate`.

**Query parameters**

| Name | Type | Notes |
|------|------|-------|
| `status` | string (CSV) | Filter by status, e.g. `active,paused`. |
| `direction` | `asc` \| `desc` | Order by `createdAt`. |

**Pagination:** none — returns the full list. No `meta`.

```jsonc
{
  "success": true,
  "data": [
    {
      "subscriptionContractId": 123456789,
      "status": "ACTIVE",
      "currentBillingCycle": 4,
      "nextOrderDate": "2026-07-01T00:00:00.000Z",
      "lines": [ /* … */ ]
    }
  ]
}
```

#### GET `/subscriptions/{contractId}`

Full detail of a single contract, including the upcoming order (merged cycle
line prices, discounts, shipping/delivery price, estimated tax) and the upcoming
fulfillment order.

**Path:** `contractId` (positive integer).

```jsonc
{
  "success": true,
  "data": {
    "subscriptionContractId": 123456789,
    "status": "ACTIVE",
    "upcomingOrder": {
      "cycleIndex": 5,
      "lines": [ /* merged */ ],
      "discounts": [ /* … */ ],
      "shippingPrice": "5.00",
      "deliveryPrice": "5.00",
      "estimatedTax": "1.20",
      "billingAttemptExpectedDate": "2026-07-01T00:00:00.000Z"
    },
    "upcomingFulfillmentOrder": {
      "currentOrder": { /* … */ },
      "fulfillmentOrders": [ /* … */ ],
      "scheduledFulfill": { /* … */ }
    }
  }
}
```

#### GET `/subscriptions/{contractId}/upcoming-orders`

Upcoming orders / billing cycles for one contract, each with `estimatedTax`.

**Query parameters**

| Name | Type | Default | Notes |
|------|------|---------|-------|
| `limitPerPage` | integer | 10 | 1–50. |

**Pagination:** page-style. `meta`: `{ hasNext, hasPre, total }`.

```jsonc
{
  "success": true,
  "data": [ { "cycleIndex": 5, "lines": [ /* … */ ], "estimatedTax": "1.20" } ],
  "meta": { "hasNext": true, "hasPre": false, "total": 12 }
}
```

#### GET `/subscriptions/{contractId}/history-orders`

Past (billed) orders for the contract, with Shopify order links
(`hashedOrderId`, `shopGraphqlId`).

**Query parameters**

| Name | Type | Notes |
|------|------|-------|
| `after` | string | Cursor for the next page. |

**Pagination:** cursor (via `after`). `meta`: `{ hasNext, hasPre, total }`.

> This endpoint accepts an `after` cursor but its `meta` does **not** currently
> return a `nextCursor` field — use `hasNext` to decide whether to keep paging.
> (Only the all-contracts `GET /upcoming-orders` endpoint returns
> `meta.nextCursor`.)

```jsonc
{
  "success": true,
  "data": [
    {
      "orderId": 5544332211,
      "billingAttemptExpectedDate": "2026-06-01T00:00:00.000Z",
      "shopGraphqlId": "12345678",
      "hashedOrderId": "abc123…",
      "lines": [ /* … */ ]
    }
  ],
  "meta": { "hasNext": true, "hasPre": false, "total": 24 }
}
```

#### GET `/upcoming-orders`

All upcoming orders across **every** contract of the authenticated customer
(dashboard view). The customer id is taken from the session — it is never client
controlled.

**Query parameters**

| Name | Type | Default | Notes |
|------|------|---------|-------|
| `nextCycleOnly` | boolean | — | If true, only the next (current) cycle per contract. |
| `after` | string | — | Cursor for the next page. |
| `limitPerPage` | integer | 10 | 1–50. |

**Pagination:** cursor. `meta`: `{ hasNext, hasPre, total, nextCursor }`.

```jsonc
{
  "success": true,
  "data": [
    {
      "subscriptionContractId": 123456789,
      "cycleIndex": 5,
      "lines": [ /* … */ ],
      "estimatedTax": "1.20"
    }
  ],
  "meta": { "hasNext": true, "hasPre": false, "total": 8, "nextCursor": "…" }
}
```

#### GET `/subscriptions/{contractId}/next-charge`

Preview the next charge of a contract at a point in time — merged cycle prices,
discounts, shipping/delivery price, `estimatedTax`.

**Query parameters**

| Name | Type | Default | Notes |
|------|------|---------|-------|
| `date` | string (ISO 8601) | now | Preview the cycle effective at this date. |

```jsonc
{
  "success": true,
  "data": {
    "cycleIndex": 5,
    "lines": [ /* merged */ ],
    "discounts": [ /* … */ ],
    "shippingPrice": "5.00",
    "deliveryPrice": "5.00",
    "estimatedTax": "1.20"
  }
}
```

#### GET `/subscriptions/{contractId}/shipping-options`

Available shipping/delivery options for the contract (based on current lines +
delivery address).

```jsonc
{ "success": true, "data": [ { "handle": "…", "title": "Standard", "price": "5.00" } ] }
```

---

### Trial group — details

#### GET `/subscriptions/{contractId}/trial`

Read-only trial status. Carries no PII.

> `contractId` is validated **inline** in the controller. A non-numeric / missing
> id returns `422 VALIDATION_FAILED` (the only endpoint that uses 422).

```jsonc
{
  "success": true,
  "data": {
    "contractId": 123456789,
    "hasTrial": true,
    "isInTrial": true,
    "trialEndsAt": "2026-07-10T00:00:00.000Z",
    "daysRemaining": 16,
    "trialProcessed": false,
    "afterAction": "continue",
    "status": "ACTIVE"
  }
}
```

---

### Management group — details

All management endpoints enforce ownership (`404 NOT_FOUND` on mismatch). Write
methods (`POST`/`PUT`/`DELETE`) are subject to the `write` rate-limit dimension.
Many of these return `{ "success": true }` with no `data` on success.

#### PUT `/subscriptions/{contractId}/status`

Pause / resume / cancel.

| Field | Type | Required | Values |
|-------|------|----------|--------|
| `status` | string | yes | `pause` \| `resume` \| `cancel` |

```json
{ "status": "pause" }
```

Response: `{ "success": true }`.

#### POST `/subscriptions/{contractId}/cancel`

Cancel with a structured reason. `reasonCode` is threaded into the outbound
`subscription_contracts/cancel` webhook payload.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `reasonCode` | string | yes | `too_expensive` \| `no_longer_needed` \| `switching` \| `quality_issue` \| `other` |
| `comment` | string | no | Max 500 chars. |
| `retentionOfferAccepted` | boolean | no | |

```json
{ "reasonCode": "too_expensive", "comment": "Found a cheaper option" }
```

Response:

```json
{ "success": true, "data": { "contractId": 123456789, "status": "cancelled" } }
```

#### PUT `/subscriptions/{contractId}/product`

Swap variant / product. Extra `planData` fields are forwarded verbatim to the
swap service.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `newVariantId` | integer | yes | Target variant. |
| `lineId` | string | no | Line to swap. |
| `currentVariantId` | integer | no | |
| `newProductId` | integer | no | |
| `sellingPlanId` | string | no | |
| `planData` | object | no | Plan config; `discountConfig.enabled` normalized server-side. |

Response: `{ "success": true }`.

#### PUT `/subscriptions/{contractId}/frequency`

Change delivery interval.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `input.interval` | string | yes | `DAY` \| `WEEK` \| `MONTH` \| `YEAR` |
| `input.intervalCount` | integer | yes | Positive. |
| `previousPlan` | object | no | |
| `initPlan` | object | no | |
| `baseCurrency` | string | no | |
| `returnedCurrency` | string | no | |

```json
{ "input": { "interval": "MONTH", "intervalCount": 2 } }
```

Response: `{ "success": true, "data": { /* updated data */ } }`.

#### GET `/subscriptions/{contractId}/shipping-address`

Current delivery address (PII projection).

```jsonc
{
  "success": true,
  "data": {
    "firstName": "…", "lastName": "…",
    "address1": "…", "address2": null,
    "city": "…", "province": "…", "provinceCode": "…",
    "country": "…", "countryCode": "…",
    "zip": "…", "phone": "…", "company": null
  }
}
```

#### PUT `/subscriptions/{contractId}/shipping-address`

Update delivery address.

| Field | Type | Required |
|-------|------|----------|
| `input.address1` | string | yes |
| `input.city` | string | yes |
| `input.zip` | string | yes |
| `input.firstName`, `input.lastName`, `input.address2`, `input.province`, `input.provinceCode`, `input.country`, `input.countryCode`, `input.phone`, `input.company` | string | no |

```json
{ "input": { "address1": "123 Main St", "city": "Hanoi", "zip": "100000" } }
```

Response: `{ "success": true }`.

#### GET `/subscriptions/{contractId}/payment-method`

Masked payment metadata only — never PAN/CVV.

```json
{
  "success": true,
  "data": { "id": "…", "brand": "visa", "last4": "4242", "expiryMonth": 12, "expiryYear": 2027 }
}
```

#### PUT `/subscriptions/{contractId}/payment-method`

Assign a tokenized payment method.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `input.paymentMethodId` | string | yes | Tokenized payment method id. |
| `customerPaymentMethod` | object | no | `{ brand, last4, expiryMonth, expiryYear }` for display mirroring. |

Response: `{ "success": true }`.

#### PUT `/subscriptions/{contractId}/shipping-option`

Change the shipping rate.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `shippingOption.handle` | string | yes | |
| `shippingOption.title`, `shippingOption.price`, `shippingOption.code` | mixed | no | |

```json
{ "shippingOption": { "handle": "standard", "title": "Standard", "price": "5.00" } }
```

Response: `{ "success": true }`.

#### PUT `/subscriptions/{contractId}/subscription-discount`

Apply a subscription-level discount.

> **Merchant-gated:** returns `403 FORBIDDEN` unless the merchant enabled
> `customerPortal.allowSubscriptionDiscount` (disabled by default).

| Field | Type | Required | Values |
|-------|------|----------|--------|
| `discountType` | string | yes | `PERCENTAGE` \| `FIXED_AMOUNT` |
| `discountValue` | number | yes | Positive. |
| `applyToOrders` | mixed | no | Scope hint. |

Response: `{ "success": true }`.

#### POST `/subscriptions/{contractId}/lines`

Add a recurring line item.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `lines` | array | yes | ≥1 line `{ variantId, quantity, productId?, sellingPlanId? }`. |
| `input` | object | no | |
| `currentLines` | array | no | Used for email diffing. |
| `giftMeta` | mixed | no | |

```json
{ "lines": [ { "variantId": 4455667788, "quantity": 1 } ] }
```

Response: `{ "success": true, "data": { /* updated subscription */ } }`.

#### DELETE `/subscriptions/{contractId}/lines/{lineId}`

Remove a recurring line. `lineId` is a path param; the body carries optional
metadata.

| Field | Type | Notes |
|-------|------|-------|
| `sellingPlanId` | string | |
| `input` | object | |
| `currentLines` | array | |

Response: `{ "success": true, "data": { /* updated subscription */ } }`.

#### PUT `/subscriptions/{contractId}/billing-cycle`

Skip / reschedule / resume / order-now a billing cycle.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `type` | string | yes | `skip` \| `reschedule` \| `resume` \| `order_now` |
| `cycleIndex` | integer | no | Target cycle (≥0). |
| `newBillingDate` | string | conditionally | Required when `type` = `reschedule`. |

> An unsupported `type` returns `400 INVALID_PARAM`.

Response: `{ "success": true, "data": { /* … */ } }`.

#### PUT `/subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}`

Edit a line on a specific upcoming order/cycle.

**Path:** `contractId`, `cycleIndex` (integer), `lineId`.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `input.quantity` | integer | no | Positive. |
| `input.sellingPlanId` | string | no | |
| `input.variantId` | integer | no | |

Response: `{ "success": true, "data": { /* … */ } }`.

#### DELETE `/subscriptions/{contractId}/upcoming-orders/{cycleIndex}/lines/{lineId}`

Remove a line from a specific upcoming order/cycle. No body.

Response: `{ "success": true }`.

#### POST `/subscriptions/{contractId}/upcoming-orders/products`

Add one-off product(s) to a specific upcoming order.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `cycleIndex` | integer | yes | Target cycle (≥0). |
| `lines` | array | yes | ≥1 line `{ variantId, quantity, productId?, sellingPlanId? }`. |

Response: `{ "success": true, "data": { /* updated order */ } }`.

#### POST `/subscriptions/{contractId}/discount`

Apply a discount code to the contract.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `discountCode` | string | yes | The code to redeem. |
| `applyToOrders` | mixed | no | Scope hint (`all` \| `next`). |
| `isManual` | boolean | no | Manual vs automatic apply. |

```json
{ "discountCode": "SAVE10" }
```

Response: `{ "success": true, "data": { /* updated contract data */ } }`.

#### DELETE `/subscriptions/{contractId}/discount`

Remove an applied discount.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `discountId` | string | no | Which applied discount to remove. |
| `isManual` | boolean | no | |

Response: `{ "success": true }`.

---

## 12. Integration examples

> In all examples, substitute your real base URL, app id, secret key, and
> session id. The secret key must be attached **server-side**.

### cURL

```bash
# List the customer's subscriptions
curl -sS "https://<your-app-domain>/storefront-api/v1/subscriptions" \
  -H "X-Joy-App-Id: $JOY_APP_ID" \
  -H "X-Joy-Secret-Key: $JOY_SECRET_KEY" \
  -H "X-Session-Id: $SESSION_ID"

# Pause a contract
curl -sS -X PUT \
  "https://<your-app-domain>/storefront-api/v1/subscriptions/123456789/status" \
  -H "X-Joy-App-Id: $JOY_APP_ID" \
  -H "X-Joy-Secret-Key: $JOY_SECRET_KEY" \
  -H "X-Session-Id: $SESSION_ID" \
  -H "Content-Type: application/json" \
  -d '{"status":"pause"}'
```

### JavaScript (Node, native `fetch`)

```js
// Run server-side: the secret key must never reach the browser.
const BASE = 'https://<your-app-domain>/storefront-api/v1';

async function joyFetch(path, {method = 'GET', body, sessionId} = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      'X-Joy-App-Id': process.env.JOY_APP_ID,
      'X-Joy-Secret-Key': process.env.JOY_SECRET_KEY, // server-side only
      'X-Session-Id': sessionId,
      ...(body ? {'Content-Type': 'application/json'} : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });

  const json = await res.json();
  if (!json.success) {
    throw new Error(`${json.error.code}: ${json.error.message}`);
  }
  return json;
}

// List subscriptions
const {data} = await joyFetch('/subscriptions', {sessionId});

// Change frequency
await joyFetch('/subscriptions/123456789/frequency', {
  method: 'PUT',
  sessionId,
  body: {input: {interval: 'MONTH', intervalCount: 2}}
});
```

### Python (`requests`)

```python
import os
import requests

BASE = "https://<your-app-domain>/storefront-api/v1"

def joy_request(path, method="GET", json_body=None, session_id=None):
    headers = {
        "X-Joy-App-Id": os.environ["JOY_APP_ID"],
        "X-Joy-Secret-Key": os.environ["JOY_SECRET_KEY"],  # server-side only
        "X-Session-Id": session_id,
    }
    resp = requests.request(method, f"{BASE}{path}", headers=headers, json=json_body, timeout=60)
    payload = resp.json()
    if not payload.get("success"):
        err = payload.get("error", {})
        raise RuntimeError(f'{err.get("code")}: {err.get("message")}')
    return payload

subs = joy_request("/subscriptions", session_id=session_id)
joy_request(
    "/subscriptions/123456789/status",
    method="PUT",
    json_body={"status": "resume"},
    session_id=session_id,
)
```

### PHP (cURL)

```php
<?php
// Server-side only — never expose the secret key to the client.
$base = 'https://<your-app-domain>/storefront-api/v1';

function joy_request($path, $method = 'GET', $body = null, $sessionId = null) {
    global $base;
    $headers = [
        'X-Joy-App-Id: ' . getenv('JOY_APP_ID'),
        'X-Joy-Secret-Key: ' . getenv('JOY_SECRET_KEY'),
        'X-Session-Id: ' . $sessionId,
    ];
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
    }

    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $body !== null ? json_encode($body) : null,
    ]);
    $response = curl_exec($ch);
    curl_close($ch);

    $payload = json_decode($response, true);
    if (empty($payload['success'])) {
        throw new Exception($payload['error']['code'] . ': ' . $payload['error']['message']);
    }
    return $payload;
}

$subs = joy_request('/subscriptions', 'GET', null, $sessionId);
joy_request('/subscriptions/123456789/status', 'PUT', ['status' => 'cancel'], $sessionId);
```

---

## 13. Support & next steps

- **Get credentials:** Shopify admin → your Joy Subscription app → **Developers**
  tab → generate App ID + Secret Key.
- **Outbound webhooks:** see the companion **Joy Subscription — Outbound Webhook
  Guide**.

Recommended integration order:

1. Generate app credentials (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 (see the Webhook Guide).
