> ## Documentation Index
> Fetch the complete documentation index at: https://docs.publica.la/llms.txt
> Use this file to discover all available pages before exploring further.

# Bundles API Overview

Create and manage your store's bundles: curated sets of your products sold together as a single unit. A bundle only references its products, so its price and availability are computed live from them and never stored.

## Features

- **Create, read, list, update and delete** the bundles of your store
- **The whole composition in one request**: send every product of the bundle at once, in the order the bundle shows them, by product ID or by identifier (ISBN, `external_id`)
- **Publish and unpublish** with the same availability rules as the dashboard
- **Your own identifier**: tag each bundle with an `external_id`, then read, update or find it by that identifier
- **Reserved stock**: set aside units of the bundle's physical products for the bundle alone. See [Reserved stock](#reserved-stock)
- **Live price and availability**: ask for the bundle's price per currency, the state of each product, and the reason when the bundle cannot be sold, through [`include`](#includes)
- **Cursor-based pagination** of up to 500 bundles per page, filtering by `external_id`, sorting and sparse fieldsets

---

## Authentication

| Header           | Example            | Description                                                                                                |
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------- |
| **X-User-Token** | `your-api-token`   | API token generated in your dashboard. **Header-only for security.**                                       |
| **Accept**       | `application/json` | Required. Without it, errors come back as redirects or HTML pages instead of the status codes on this page |

All requests **must** be performed over HTTPS.

```bash
curl -X GET "https://yourstore.publica.la/api/v3/bundles" \
  -H "X-User-Token: your-api-token" \
  -H "Accept: application/json"
```

---

## Requirements

### A store that sells

Bundles are sold, so this API is only available to stores with selling enabled. On a store with selling disabled, every endpoint answers `403 Forbidden` with the message `Selling is disabled for this store.`

### Administrator token

The token must belong to an **administrator** of the store. Tokens of regular users are rejected with `403 Forbidden`.

### Order of the checks

A request is checked in this order: the token (`401`), the store and the token's role (`403`), the bundle in the URL (`404`), and then the request itself (`422`). So a token without access gets `403` even for a bundle ID that does not exist, and a bundle that does not exist answers `404` before its payload is validated.

---

## Endpoints

| Method   | Path                       | Description                               |
| -------- | -------------------------- | ----------------------------------------- |
| `GET`    | `/api/v3/bundles`          | [List bundles](./bundles-v3-list.md)      |
| `POST`   | `/api/v3/bundles`          | [Create a bundle](./bundles-v3-create.md) |
| `GET`    | `/api/v3/bundles/{bundle}` | [Get a bundle](./bundles-v3-get.md)       |
| `PUT`    | `/api/v3/bundles/{bundle}` | [Update a bundle](./bundles-v3-update.md) |
| `DELETE` | `/api/v3/bundles/{bundle}` | [Delete a bundle](./bundles-v3-delete.md) |

`{bundle}` is the bundle's `id` by default, or its `external_id` with `?id_type=external`. A bundle of another store answers `404`, never `403`:

```json
{
  "message": "Not found"
}
```

---

## Identifying a Bundle

### `external_id`

`external_id` is your own identifier for the bundle, such as a code from your catalog or ERP, up to 64 characters. It is unique within your store and compared without case, so `ROCK-ICONS` and `rock-icons` are the same identifier. Another store can use the same value.

With it you can:

- read, update or delete the bundle with `?id_type=external` on [get](./bundles-v3-get.md), [update](./bundles-v3-update.md) and [delete](./bundles-v3-delete.md)
- find it with `filter[external_id]` on [list](./bundles-v3-list.md), which matches the whole value without case, commas included

Always send `?id_type=external` with an `external_id`. Without it, `{bundle}` is read as an `id`: an `external_id` made only of digits, such as `318`, reaches the bundle whose `id` is `318`, and any other `external_id` answers `404`.

### Retries {#retries}

Send an `external_id` on every create your integration may retry: it is what lets the platform refuse a duplicate. Repeating a `POST` with an `external_id` your store already uses answers `422` and does **not** create a second bundle:

```json
{
  "message": "A bundle with this external_id already exists.",
  "errors": {
    "external_id": ["A bundle with this external_id already exists."]
  }
}
```

The retry never returns the bundle the first request created. When a create times out or its response is lost, look the bundle up before sending it again:

```bash
curl -X GET "https://yourstore.publica.la/api/v3/bundles/rock-icons?id_type=external" \
  -H "X-User-Token: your-api-token" \
  -H "Accept: application/json"
```

The same lookup works as a list filter: `GET /api/v3/bundles?filter[external_id]=rock-icons`. If the bundle is there, the first request went through: continue with an [update](./bundles-v3-update.md) rather than another create.

The `external_id` is checked again when the bundle is saved, after its cover is downloaded. A request whose `external_id` another request saved first, while its cover was downloading, answers the same `422`, discards the cover it downloaded and saves nothing.

:::warning Retry one request at a time
The `external_id` check guards retries that follow each other, not requests sent at the same moment. Send the creates for one `external_id` one after another, and never in parallel.
:::

---

## The Bundle Object

A bundle with its [included blocks](#includes), as `?include=products,prices,availability,detailed_stock` returns it:

```json
{
  "id": "318",
  "external_id": "rock-icons",
  "name": "Rock Icons",
  "slug": "rock-icons",
  "description": "The classics, together",
  "cover_url": "https://assets.publica.la/yourstore/bundles/cover_1791295500_k3x9qz2a.png",
  "private": false,
  "reserved_stock": 20,
  "published": true,
  "published_at": "2026-10-06T14:05:00.000000Z",
  "product_url": "https://yourstore.publica.la/library/bundle/rock-icons/318?signature=9f2c4e7a…",
  "buy_button_url": "https://yourstore.publica.la/library/bundle/rock-icons/318?signature=9f2c4e7a…&automatically_open_checkout=1",
  "created_at": "2026-10-06T14:00:00.000000Z",
  "updated_at": "2026-10-06T14:05:00.000000Z",
  "products": [
    {
      "id": "1201",
      "position": 0,
      "name": "Rock Icons, Volume 1",
      "available": true,
      "rejection_reason": null,
      "detailed_stock": { "total": 30, "reserved": 20, "available": 10 }
    },
    {
      "id": "1202",
      "position": 1,
      "name": "Rock Icons, Volume 2",
      "available": true,
      "rejection_reason": null,
      "detailed_stock": null
    }
  ],
  "prices": [
    { "currency_id": "USD", "amount": 15 },
    { "currency_id": "ARS", "amount": 10000 }
  ],
  "availability": {
    "available": true,
    "reasons": []
  }
}
```

| Field            | Type           | Description                                                                                                                                                                  |
| ---------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | string         | Bundle identifier                                                                                                                                                            |
| `external_id`    | string \| null | Your identifier for the bundle. See [Identifying a Bundle](#identifying-a-bundle)                                                                                            |
| `name`           | string         | Bundle name, max 255 characters                                                                                                                                              |
| `slug`           | string         | Slug of the bundle page. Generated from `name` when you do not send one                                                                                                      |
| `description`    | string \| null | Description, max 2,000 characters                                                                                                                                            |
| `cover_url`      | string \| null | URL of the bundle's own cover, hosted by the platform. `null` when the bundle has no cover of its own and shows the collage of its products. See [Covers](#covers)           |
| `private`        | boolean        | See [States](#states)                                                                                                                                                        |
| `reserved_stock` | integer        | Units of each physical product of the bundle held for the bundle alone. `0` when the bundle reserves nothing. See [Reserved stock](#reserved-stock)                          |
| `published`      | boolean        | Whether the bundle is published. See [States](#states)                                                                                                                       |
| `published_at`   | string \| null | Read-only. When the bundle was published, set by the platform. `null` while it is unpublished                                                                                |
| `product_url`    | string \| null | Signed URL of the bundle page. `null` while that page is not served: the bundle is unpublished, or your store does not run the block-based storefront. See [States](#states) |
| `buy_button_url` | string \| null | `product_url` with `automatically_open_checkout=1` appended. Use it as the target of a buy button. `null` in the same cases as `product_url`. See [Buy Button](#buy-button)  |
| `created_at`     | string         | ISO 8601 creation timestamp                                                                                                                                                  |
| `updated_at`     | string         | ISO 8601 last update timestamp                                                                                                                                               |
| `products`       | array          | Included block. The products of the bundle, in order. See [Products](#products)                                                                                              |
| `prices`         | array          | Included block. The bundle's price in each currency. See [Prices](#prices)                                                                                                   |
| `availability`   | object         | Included block. `available` (boolean) and `reasons` (array of codes), whether the bundle can be sold right now. See [Availability](#availability)                            |

### Included Blocks {#includes}

`products`, `prices`, `availability` and `detailed_stock` are computed from the bundle's products at the moment of the request, so a response carries them only when they are asked for:

| Endpoint                                                                                      | Blocks in the response             |
| --------------------------------------------------------------------------------------------- | ---------------------------------- |
| [Create](./bundles-v3-create.md)                                                              | Always all four                    |
| [List](./bundles-v3-list.md), [get](./bundles-v3-get.md) and [update](./bundles-v3-update.md) | Only the ones named in `?include=` |

`include` takes any combination of `products`, `prices`, `availability` and `detailed_stock`, separated by commas: `?include=prices,availability` returns those two blocks and not `products`. `detailed_stock` adds a field to each entry of `products`, so it needs `products` too: `?include=products,detailed_stock`. Alone it adds nothing. Without `include`, the bundle carries only the fields from `id` to `updated_at`.

`fields` applies after `include`. It can leave out a block you included, so `?include=prices,availability&fields=id,prices` returns only `id` and `prices`. It never adds a block you did not include, so `?fields=id,prices` alone returns only `id`.

On list, get and update, any other value answers `422` under `include`. Create ignores `include`:

```json
{
  "message": "Invalid include: cover. Allowed: products, prices, availability, detailed_stock",
  "errors": {
    "include": [
      "Invalid include: cover. Allowed: products, prices, availability, detailed_stock"
    ]
  }
}
```

`include` is a single comma-separated value. Sent as an array, such as `include[]=products`, it answers `422` with `The include must be a string.`

### Products {#products}

Each entry of `products` (with `include=products`) describes one product of the bundle as it stands right now:

| Field              | Type           | Description                                                                                                                                  |
| ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`               | string         | Product ID, the same `id` the [Content API](../content/content-v3-get.md) returns                                                            |
| `position`         | integer        | Position in the bundle, starting at `0`                                                                                                      |
| `name`             | string \| null | Product name. `null` when the product can no longer be found                                                                                 |
| `available`        | boolean        | Whether this product can be sold in a bundle right now                                                                                       |
| `rejection_reason` | string \| null | Why it cannot, as one of the [rejection reasons](#rejection-reasons). `null` when `available` is true                                        |
| `detailed_stock`   | object \| null | With `include=products,detailed_stock`. The stock of a physical product. `null` for any other product. See [Detailed stock](#detailed-stock) |

### Rejection Reasons {#rejection-reasons}

A product that cannot be sold in a bundle carries one of these codes in `products[].rejection_reason`, and the same code closes the error message when a create or an update adds that product. The last two, `untracked_stock` and `below_reserved_stock`, only answer a create or an update and never show in `products[].rejection_reason`. An update does not check again a product the bundle already holds:

| Code                   | The product                                                                                                   | Error message when you send it                                                                                     |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `unresolved`           | is not among the products your store can sell, for example because it was deleted or belongs to another store | `Product [1203] was not found among the products this store can sell (unresolved).`                                |
| `not_on_sale`          | is not on sale                                                                                                | `Product [1203] cannot be sold in a bundle: Not on sale (not_on_sale).`                                            |
| `free`                 | is free of charge                                                                                             | `Product [1203] cannot be sold in a bundle: Free of charge (free).`                                                |
| `subscribers_only`     | is available to subscribers only                                                                              | `Product [1203] cannot be sold in a bundle: Subscription only (subscribers_only).`                                 |
| `out_of_stock`         | is a physical product with no available stock, also when other bundles reserve all of it                      | `Product [1203] cannot be sold in a bundle: Out of stock (out_of_stock).`                                          |
| `no_individual_price`  | has no price of its own in your store's sale currencies                                                       | `Product [1203] cannot be sold in a bundle: No individual sale price (no_individual_price).`                       |
| `untracked_stock`      | is a physical product that does not track its stock, added to a bundle with reserved stock                    | `Product [1203] cannot be sold in a bundle: Does not track stock (untracked_stock).`                               |
| `below_reserved_stock` | is a physical product with less available stock than the bundle's `reserved_stock`, added to that bundle      | `Product [1203] cannot be sold in a bundle: Less available stock than the bundle reserves (below_reserved_stock).` |

When a request sends its products by identifier, the message names the identifier it sent instead of the product ID. See [Products by identifier](./bundles-v3-create.md#products-by-identifier).

### Prices {#prices}

`prices` (with `include=prices`) holds the bundle's price in each sale currency of your store that **all** of its products have, as the sum of their prices. Your store's main currency comes first.

The sum covers every product of the bundle, an out of stock physical product included. The checkout charges a buyer only for the products in stock that they do not own yet and that are sold in their country, so it can charge less than `prices`.

| Field         | Type   | Description                                                       |
| ------------- | ------ | ----------------------------------------------------------------- |
| `currency_id` | string | ISO 4217 currency code                                            |
| `amount`      | number | Total in the currency's main unit: `15` means 15.00, not 15 cents |

`prices` can be empty while the bundle is unavailable. Include `availability` to know why.

### Availability {#availability}

With `include=availability`, `availability.available` tells whether the bundle can be sold right now, and `availability.reasons` lists every reason it cannot, always in the order of this table:

| Code                     | The bundle cannot be sold because                                                                                                         |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `below_minimum_products` | it has fewer than 2 products                                                                                                              |
| `unresolved_products`    | some of its products were not found among the products your store can sell                                                                |
| `ineligible_products`    | some of its products cannot be sold in a bundle, or all of its products are out of stock; each one names its reason in `rejection_reason` |
| `no_shared_currency`     | its products share no sale currency                                                                                                       |
| `zero_total`             | its total price is zero                                                                                                                   |

Availability is computed on every request that includes it, from the products as they are at that moment. A bundle that was available when you published it becomes unavailable if one of its products is withdrawn later, and `products[].rejection_reason` points at the product.

An out of stock physical product is the exception: its entry in `products` shows `available: false` and `rejection_reason: out_of_stock`, and the bundle stays available while at least one of its other products is in stock. The bundle sells without that product until it is back in stock.

---

## Reserved Stock {#reserved-stock}

`reserved_stock` sets aside units of the bundle's physical products for the buyers of that bundle. The same number applies to each physical product of the bundle, and digital products are not affected. A bundle with `reserved_stock: 0` reserves nothing.

Take a physical product with a total stock of 30, in a bundle with `reserved_stock: 20`:

| Who buys                                                   | Units they can take                                                  |
| ---------------------------------------------------------- | -------------------------------------------------------------------- |
| A buyer of this bundle                                     | 30: the total stock less the reserved stock of the other bundles (0) |
| A buyer of the product alone, or of another bundle with it | 10: the total stock less the reserved stock (20)                     |

### Sales

Each copy of the bundle sold with at least one of its physical products takes one unit of `reserved_stock`:

- 2 copies sold: `reserved_stock` goes to 18, and the product's total stock to 28.
- 25 copies sold: the total stock goes from 30 to 5, and `reserved_stock` stops at 0. The copies past the reserved stock take only the total stock.

When the buyer already owns one of the bundle's physical products, the order does not sell it: that product keeps its total stock of 30 while `reserved_stock` goes to 19, so one more unit is available to every buyer.

### When reserved stock goes to 0 {#when-the-reserve-goes-to-0}

| When                                                                                                                                                         | `reserved_stock` |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
| You [unpublish](./bundles-v3-update.md#publishing) a published bundle                                                                                        | Goes to `0`      |
| You [delete](./bundles-v3-delete.md) the bundle                                                                                                              | Goes to `0`      |
| The bundle is left without physical products: an update of `products` drops them, or the platform removes them from the bundle, as when a product is deleted | Goes to `0`      |
| The platform unpublishes the bundle because it was left with fewer than 2 products that can be sold                                                          | Goes to `0`      |

Publishing the bundle again does not bring the reserved stock back: send `reserved_stock` again. A request that unpublishes and sends `reserved_stock` keeps the value it sends: the bundle becomes a draft with that reserved stock. Any other unpublished bundle keeps the reserved stock set on it, and it already counts against the other buyers.

### Rules

- A bundle can only reserve stock when it has at least one physical product, and every physical product of the bundle tracks its stock.
- Each physical product needs as much available stock as `reserved_stock`. The bundle's own reserved stock does not count against it.
- Sending `0` is always allowed. Lowering `reserved_stock` to another value is allowed while the bundle keeps a physical product.

See [Reserved stock on update](./bundles-v3-update.md#reserved-stock) for the errors. The [Content API](../content/content-v3-update.md#physical-product-fields) does not lower a product's `stock_quantity` below the units its bundles reserve.

### Detailed Stock {#detailed-stock}

With `include=products,detailed_stock`, each physical product of `products` carries its stock as a buyer of the product alone sees it:

| Field       | Type            | Description                                                                                                          |
| ----------- | --------------- | -------------------------------------------------------------------------------------------------------------------- |
| `total`     | integer \| null | The product's total stock, the `stock_quantity` of the Content API. `null` when the product does not track its stock |
| `reserved`  | integer         | Units reserved by all the bundles that hold the product, this one included                                           |
| `available` | integer \| null | `total` less `reserved`, never below `0`. `null` when `total` is `null`                                              |

`available` is not what a buyer of this bundle can take. For the product in the example above, `detailed_stock` is `{ "total": 30, "reserved": 20, "available": 10 }`, and a buyer of the bundle can take 30.

---

## States {#states}

`published` and `private` combine into three states:

| State       | `published` | `private` | Bundle page                                                                   |
| ----------- | ----------- | --------- | ----------------------------------------------------------------------------- |
| Unpublished | `false`     | any       | Not served                                                                    |
| Public      | `true`      | `false`   | Served, on stores with the block-based storefront                             |
| Hidden      | `true`      | `true`    | Served through its signed URL only, on stores with the block-based storefront |

- A new bundle is unpublished and not private unless the create says otherwise.
- `published: true` publishes the bundle, and only succeeds when the bundle can be sold. On a bundle that is already published, `published: true` without `products` keeps it published as it is. `published: false` unpublishes it. See [Publishing](./bundles-v3-update.md#publishing).
- The platform sets `published_at`. Sending it is rejected with `422`.

`product_url` follows the last column: it is the signed URL of the bundle page while that page is served, and `null` otherwise. The page of a public bundle opens with or without the signature. The page of a hidden bundle opens only with a valid one; without it, the visitor is redirected to the store's library. The signature does not expire, and query parameters you append to `product_url` keep it valid.

### Links {#links}

`product_url` and `buy_button_url` depend only on the bundle's `slug` and `id`, so you can store them and share them. This is what happens to links already shared:

| When                                           | Links already shared                                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------- |
| You change the `name`                          | Keep working: the `slug` does not change                                                    |
| You change the `slug`                          | Answer `404`. The update response carries the new links                                     |
| The bundle goes from public to hidden, or back | Keep working: they are the same links                                                       |
| You unpublish the bundle                       | Answer `404`, and both fields return `null`. Publishing it again brings back the same links |
| You delete the bundle                          | Answer `404`                                                                                |
| Your store leaves the block-based storefront   | Answer `404`, and both fields return `null`                                                 |

A `slug` you change or a bundle you delete leaves its old `slug` free. If a public bundle takes that `slug` later, the old links open that bundle. If a hidden one takes it, they send the visitor to the store's library.

### Buy Button {#buy-button}

`buy_button_url` opens the checkout with the bundle in the cart, without stopping at the bundle page:

- It replaces everything the visitor had in the cart with the bundle's products.
- A visitor who is not signed in signs in before paying and keeps the bundle in the cart.
- When the reader pays for a physical product of the bundle, the checkout opens at the shipping step. Otherwise it opens at the payment.
- A reader who already owns some of the bundle's products pays only for the rest.
- A reader who already owns all of them, or a bundle that cannot be sold right now, gets the bundle page, and the cart does not change.
- Link previews, such as the ones messaging apps and social networks build, get the bundle page and fill no cart.

Append `coupon` or `currency` to `buy_button_url` to pass them on to the checkout. A `currency` the bundle is not sold in is ignored.

---

## Covers {#covers}

A bundle shows its own cover, or a collage of its products' covers when it has none. Set its own cover with one of these fields, never both:

| Field       | What it takes                                                                                                                                                                                        |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cover_url` | A public `http` or `https` URL. The platform downloads the image and hosts its own copy                                                                                                              |
| `cover`     | The path of an image already uploaded to the platform's temporary storage, starting with `tmp/`. The same kind of path the [Content API](../content/content-v3-create.md) takes in its `cover` field |

The image must be a JPEG, PNG or WebP file of at most 10 MB. An image downloaded from `cover_url` must also be at most 25 megapixels. A `cover` path that does not exist or does not meet these rules answers `422` with `Invalid file.` under `cover`. A `cover_url` that cannot be downloaded or is not a supported image answers `422` under `cover_url`. Either way the whole request fails and nothing is saved.

In responses, `cover_url` is the URL of the platform's copy, not the URL you sent.

To drop the bundle's own cover, send `cover_url: null` or `cover: null` in an [update](./bundles-v3-update.md#cover). The bundle goes back to the collage of its products and `cover_url` returns `null`.

---

## Errors

| Status | Condition                                                                                                                                                   |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid `X-User-Token`                                                                                                                           |
| `403`  | Selling is disabled for the store, or the token does not belong to an administrator                                                                         |
| `404`  | The bundle does not exist in your store                                                                                                                     |
| `422`  | Validation failure. `message` is the first error, followed by `(and N more errors)` when there are several, and `errors` carries all of them keyed by field |
| `429`  | Rate limit exceeded                                                                                                                                         |

A `422` that names products keys each error by the product's position in the request, such as `products.1`. See [Errors by product](./bundles-v3-create.md#errors-by-product).

---

## Rate Limiting

Two limits apply: a per-token burst of **60 requests/minute** and a **daily read quota** of 2,000 reads per token and 5,000 per store, whichever runs out first (`GET`/`HEAD` only; writes have no daily quota). Exceeding either returns `429 Too Many Requests` with a `Retry-After` header.

Create and update are also limited to **20 requests per minute** per token, because each may download a cover. That budget is shared with the create and update endpoints of the [Add-ons API](../add-ons/add-ons-v3-overview.md).

See the [API Overview](../../api-overview.md#rate-limiting-and-flood-protection) for full details.

---

## Next Steps

- **[Create a Bundle](./bundles-v3-create.md)**: build a bundle from your products
- **[List Bundles](./bundles-v3-list.md)**: page through your bundles or find one by `external_id`
- **[Get a Bundle](./bundles-v3-get.md)**: read a bundle with its price and availability
- **[Update a Bundle](./bundles-v3-update.md)**: change its details or products, and publish it
- **[Delete a Bundle](./bundles-v3-delete.md)**: remove a bundle for good

---

## See Also

- [API Authentication](../../api-overview.md)
- [Content API](../content/content-v3-overview.md): the products a bundle references
