A **rolling subscription** ships a different box every cycle — "October: Ethiopia Guji + Colombia Huila". Each box is a subscription cycle: a title, a start date, and the list of items that ship in it. A cycle is the current one for exactly one step of its subscription plan's cadence (`startsAt <= now < endsAt`), and `endsAt` is derived from that cadence — it is returned but never accepted as input.

Cycles belong to a product whose `subscriptionMode` is `rolling`. Create the programme in the dashboard under **Subscriptions → Rolling**, then curate its calendar here. See [Subscription Plans](/docs/api-reference/subscription-plans) for the billing side, and [Products](/docs/api-reference/products#rolling-subscriptions) for how the current and upcoming box appear on a product read.

## Draft visibility

`draft` cycles are the merchant's unannounced boxes. They are returned only to a key that also holds the `catalog:write` scope (or to an unscoped key). A `storefront:read` key sees published cycles only — a draft is a `404` on the single read and is filtered out of the list, even when `?status=draft` is requested.

---

## List Subscription Cycles

```
GET /api/v1/subscription-cycles
```

Returns the boxes of one rolling subscription, or of every rolling subscription in the store, newest start date first.

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `productId` | `string` | — | Only the boxes of this rolling subscription (its product UUID). Omit for every rolling subscription in the store. |
| `status` | `string` | — | `draft` or `published`. Omit for both. |
| `from` | `string` | — | Only boxes starting on or after this ISO 8601 date |
| `to` | `string` | — | Only boxes starting on or before this ISO 8601 date |
| `limit` | `number` | 50 | Boxes per page (1-100) |
| `offset` | `number` | 0 | Boxes to skip |

```bash
curl -H "Authorization: Bearer your_api_key" \
  "https://your-store.yns.store/api/v1/subscription-cycles?productId=0191abc0-0000-7000-8000-000000000200"
```

### Response (200)

`meta.count` is how many boxes matched the filters, not the store total.

```json
{
  "data": [
    {
      "id": "0191abc0-1234-7def-8000-000000000001",
      "productId": "0191abc0-0000-7000-8000-000000000200",
      "title": "October 2026",
      "description": "Two washed lots picked for filter brewing.",
      "image": "https://cdn.example.com/october-box.jpg",
      "startsAt": "2026-10-01T00:00:00.000Z",
      "endsAt": "2026-11-01T00:00:00.000Z",
      "status": "published",
      "items": [
        {
          "productVariantId": "0191abc0-0000-7000-8000-000000000100",
          "productId": "0191abc0-0000-7000-8000-000000000300",
          "name": "Ethiopia Guji",
          "variantLabel": "250g / Whole bean",
          "sku": "ETH-GUJI-250",
          "image": "https://cdn.example.com/eth-guji.jpg",
          "quantity": 1
        }
      ],
      "createdAt": "2026-09-01T10:30:00.000Z",
      "updatedAt": "2026-09-01T10:30:00.000Z"
    }
  ],
  "meta": {
    "count": 1,
    "offset": 0,
    "limit": 50
  }
}
```

Items are returned as a denormalized snapshot — the same shape the packing list and order emails use. Prices, stock and other catalog fields are deliberately not exposed: a box may contain subscription-only products (`status: "hidden"`) that are never listed on their own.

---

## Get Subscription Cycle

```
GET /api/v1/subscription-cycles/{id}
```

Returns a single box by UUID. Same shape as one entry of the list `data`.

```bash
curl -H "Authorization: Bearer your_api_key" \
  https://your-store.yns.store/api/v1/subscription-cycles/0191abc0-1234-7def-8000-000000000001
```

### Errors

| Status | Meaning |
|--------|---------|
| `404` | No such box in this store — or the box is a `draft` and the key cannot see drafts |

---

## Create Subscription Cycle

```
POST /api/v1/subscription-cycles
```

Adds a box to a rolling subscription's calendar.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `productId` | `string` | Yes | UUID of the rolling subscription's product (`subscriptionMode` must be `rolling`) |
| `title` | `string` | Yes | What subscribers see this box called (1-120 chars), e.g. `"October 2026"` |
| `startsAt` | `string` | Yes | ISO 8601 date or date-time the box starts shipping |
| `description` | `string \| null` | No | A few lines about the box, shown under the title |
| `image` | `string \| null` | No | Absolute URL of the box photo. Storefronts fall back to the item photos when unset. |
| `status` | `string` | No | `draft` (default) or `published` |
| `items` | `object[]` | No | What ships in the box (max 20 entries) |

Each entry of `items`:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `productVariantId` | `string` | Yes | Variant UUID of an item that ships in this box |
| `quantity` | `number` | No | How many of this item ship in the box (1-99, default `1`) |

A variant may appear only once — raise its `quantity` instead. Subscription-only items are ordinary products set to `status: "hidden"` (sellable but unlisted), so a box never needs a separate catalog.

`endsAt` is not accepted: it is derived from the subscription plan's cadence and moves with `startsAt`.

```bash
curl -X POST \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "productId": "0191abc0-0000-7000-8000-000000000200",
    "title": "October 2026",
    "startsAt": "2026-10-01",
    "status": "published",
    "items": [
      { "productVariantId": "0191abc0-0000-7000-8000-000000000100", "quantity": 1 }
    ]
  }' \
  https://your-store.yns.store/api/v1/subscription-cycles
```

### Response (201)

Returns the created cycle (same shape as Get Subscription Cycle).

### Errors

| Status | Meaning |
|--------|---------|
| `400` | The product is not a rolling subscription, an item variant is not in this store, an item appears twice, or the box is `published` with no items — or with an item whose own product is still a `draft` |
| `404` | No such product in this store |
| `409` | Another box of this subscription already starts on that date, or the derived window overlaps another **published** box |

Error bodies carry `error` plus, where one applies, a `hint` describing the next step:

```json
{
  "error": "[POST /api/v1/subscription-cycles] ApiError: A published cycle must contain at least one item",
  "hint": "Add the items this box ships, or keep the cycle as a draft."
}
```

---

## Update Subscription Cycle

```
PATCH /api/v1/subscription-cycles/{id}
```

Partially updates a box. Every field from Create is accepted and optional, except `productId` — a box cannot move between subscriptions.

Sending `items` **replaces the whole list**, so send the full box. Omitting it leaves the items untouched. Moving `startsAt` moves the derived `endsAt` with it.

The publish rules are evaluated against the resulting box: flipping an empty box to `published` without also sending its items is rejected with `400`.

```bash
curl -X PATCH \
  -H "Authorization: Bearer your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"status": "published", "title": "October 2026 — Harvest box"}' \
  https://your-store.yns.store/api/v1/subscription-cycles/0191abc0-1234-7def-8000-000000000001
```

### Response (200)

Returns the updated cycle.

### Errors

Same as Create, plus `404` when the box does not exist in this store.

---

## Delete Subscription Cycle

```
DELETE /api/v1/subscription-cycles/{id}
```

Removes a box from the calendar. Orders keep their own snapshot of what shipped, so deleting a box never erases a packing list.

```bash
curl -X DELETE \
  -H "Authorization: Bearer your_api_key" \
  https://your-store.yns.store/api/v1/subscription-cycles/0191abc0-1234-7def-8000-000000000001
```

### Response (200)

```json
{
  "ok": true,
  "deleted": 1
}
```

### Errors

| Status | Meaning |
|--------|---------|
| `404` | No such box in this store |