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

# Create a Bundle

Create a bundle from 2 to 100 of your products, in a single request.

---

## Endpoint

```
POST /api/v3/bundles
```

---

## Request Body

| Field              | Type    | Required | Description                                                                                                                                         |
| ------------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`             | string  | Yes      | Max 255 characters                                                                                                                                  |
| `products`         | array   | Yes      | Product IDs, in the order the bundle shows them. From 2 to 100, each once. See [Products](#products)                                                |
| `products_id_type` | string  | No       | `internal` (default): `products` holds product IDs. `external`: it holds product identifiers. See [Products by identifier](#products-by-identifier) |
| `description`      | string  | No       | Max 2,000 characters                                                                                                                                |
| `slug`             | string  | No       | Slug of the bundle page, max 255 characters. Generated from `name` when omitted                                                                     |
| `external_id`      | string  | No       | Your identifier for the bundle, max 64 characters, unique in your store. See [Retries](./bundles-v3-overview.md#retries)                            |
| `published`        | boolean | No       | `true` publishes the bundle in the same request. See [Publishing on create](#publishing-on-create). Defaults to unpublished                         |
| `private`          | boolean | No       | Defaults to `false`. See [States](./bundles-v3-overview.md#states)                                                                                  |
| `reserved_stock`   | integer | No       | Units of each physical product held for the bundle, min `0`. Defaults to `0`. See [Reserved stock](./bundles-v3-overview.md#reserved-stock)         |
| `cover_url`        | string  | No       | Public URL of the cover image to download. Not accepted together with `cover`. See [Covers](./bundles-v3-overview.md#covers)                        |
| `cover`            | string  | No       | Storage path of an uploaded cover image. Not accepted together with `cover_url`. See [Covers](./bundles-v3-overview.md#covers)                      |

Add `?fields=` to the URL to trim the response to the fields you need, as on [get](./bundles-v3-get.md#query-parameters).

:::warning published_at is not accepted
The platform sets `published_at` when the bundle is published. Sending it returns `422`, also when its value is `null`:

```json
{
  "message": "The system sets published_at. Send published: true to publish the bundle.",
  "errors": {
    "published_at": [
      "The system sets published_at. Send published: true to publish the bundle."
    ]
  }
}
```

:::

---

## Products {#products}

Every create carries the whole composition: **from 2 to 100 products**, each one once, all of them products your store can sell. This holds for an unpublished bundle too. The API takes a bundle complete, in one request, so there is no draft with fewer products to fill in later: a create with a single product answers `422` under `products`, published or not.

:::info The dashboard is different
The dashboard lets an administrator save a draft bundle with a single product and add the rest later. That draft is a dashboard feature only; the API does not create one.
:::

- **IDs**: the product `id` of the [Content API](../content/content-v3-get.md), sent as numbers or as numeric strings. To send identifiers such as ISBNs instead, see [Products by identifier](#products-by-identifier).
- **Order**: the order of the array is the order the bundle shows its products. The first one gets `position` `0`.
- **Sellable products**: each product must be one your store can sell in a bundle: on sale, not free, not for subscribers only, in stock, and with a price of its own in a sale currency of your store. Each [rejection reason](./bundles-v3-overview.md#rejection-reasons) maps to one of these rules.

### Errors by product {#errors-by-product}

A product that fails is named by its position in the array, under `products.{position}`, with its ID and its rejection code between parentheses. Every failing product gets its own entry:

```json
{
  "message": "Product [9999] was not found among the products this store can sell (unresolved). (and 1 more error)",
  "errors": {
    "products.1": [
      "Product [9999] was not found among the products this store can sell (unresolved)."
    ],
    "products.2": [
      "Product [1203] cannot be sold in a bundle: Not on sale (not_on_sale)."
    ]
  }
}
```

A repeated product is named at every position it holds:

```json
{
  "message": "Product [1201] appears more than once (duplicate). (and 1 more error)",
  "errors": {
    "products.0": ["Product [1201] appears more than once (duplicate)."],
    "products.2": ["Product [1201] appears more than once (duplicate)."]
  }
}
```

The codes and their messages are listed under [Rejection reasons](./bundles-v3-overview.md#rejection-reasons).

### Products by identifier {#products-by-identifier}

Send `products_id_type: external` to list the products by identifier instead of by ID. Each value is matched against the identifiers of your store's products, such as the ISBN or the `external_id`. Identifiers are compared ignoring case and any character that is not a letter or a digit, so `978-950-123-456-7` matches `9789501234567`.

```bash
curl -X POST "https://yourstore.publica.la/api/v3/bundles" \
  -H "X-User-Token: your-api-token" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "Rock Icons",
    "products_id_type": "external",
    "products": ["9789501234567", "ROCK-VOL-2"]
  }'
```

In this mode every value of `products` is read as an identifier, never as a product ID. Responses still list the products by their `id`, in the order you sent them.

The product rules do not change, and every error names the product by the identifier you sent. Two cases are specific to identifiers:

- An identifier that matches more than one product of your store answers `ambiguous`. Send a more specific identifier instead, such as the product's `external_id`.
- Two identifiers of the same product, such as its ISBN with and without hyphens, are a duplicate.

```json
{
  "message": "Product [EXT-404] was not found among the products this store can sell (unresolved). (and 3 more errors)",
  "errors": {
    "products.0": [
      "Product [EXT-404] was not found among the products this store can sell (unresolved)."
    ],
    "products.1": [
      "Product [9789509876543] matches more than one product this store can sell (ambiguous)."
    ],
    "products.2": [
      "Product [9789501234567] appears more than once (duplicate)."
    ],
    "products.3": [
      "Product [978-950-123-456-7] appears more than once (duplicate)."
    ]
  }
}
```

`ambiguous` only appears in these errors. A bundle's `products[].rejection_reason` never carries it, because a saved bundle always holds product IDs.

### Errors come in rounds

The products are checked in three rounds, and each round runs only when the previous one passed:

1. **Shape**: `products` is a list of 2 to 100 entries with no duplicates, made of integer IDs or, [by identifier](#products-by-identifier), of identifiers that each match one product. Errors land under `products` or `products.{position}`.
2. **Each product**: every product is one your store can sell in a bundle. Errors land under `products.{position}`.
3. **The bundle**: when the request sends `published: true`, the bundle as a whole can be sold. Errors land under `published`.

So a `422` that names a product says nothing about publishing the bundle yet. Fix what it names and the next attempt can report a later round. Errors on other fields, such as `name` or `external_id`, are reported alongside, in the same `422`.

---

## Publishing on Create {#publishing-on-create}

`published: true` creates the bundle already published, as long as it can be sold. When it cannot, the request answers `422` under `published`, naming each reason with its [availability code](./bundles-v3-overview.md#availability), and nothing is saved:

```json
{
  "message": "The bundle cannot be published: its products share no sale currency (no_shared_currency).",
  "errors": {
    "published": [
      "The bundle cannot be published: its products share no sale currency (no_shared_currency)."
    ]
  }
}
```

Without `published`, the same bundle is created unpublished (`201`), and its `availability.reasons` says why it cannot be sold yet. Fix its products and publish it later with an [update](./bundles-v3-update.md#publishing).

---

## Request Examples

### Minimal

```bash
curl -X POST "https://yourstore.publica.la/api/v3/bundles" \
  -H "X-User-Token: your-api-token" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "Rock Icons",
    "products": [1201, 1202]
  }'
```

The bundle is created unpublished, not private, with the slug `rock-icons` and the collage of its products as cover.

### Published, With Your Identifier and a Cover

```bash
curl -X POST "https://yourstore.publica.la/api/v3/bundles" \
  -H "X-User-Token: your-api-token" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "name": "Rock Icons",
    "description": "The classics, together",
    "external_id": "rock-icons",
    "products": [1201, 1202, 1205],
    "cover_url": "https://images.example.com/rock-icons.png",
    "published": true
  }'
```

---

## Response

`201 Created` with the [bundle object](./bundles-v3-overview.md#the-bundle-object) under `data`. A create returns its `products`, `prices`, `availability` and `products[].detailed_stock` with no `include` needed. With `?fields=`, name them in `fields` to keep them:

```json
{
  "data": {
    "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": 0,
    "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:05: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": 0, "available": 30 }
      },
      {
        "id": "1202",
        "position": 1,
        "name": "Rock Icons, Volume 2",
        "available": true,
        "rejection_reason": null,
        "detailed_stock": null
      },
      {
        "id": "1205",
        "position": 2,
        "name": "Rock Icons, Volume 3",
        "available": true,
        "rejection_reason": null,
        "detailed_stock": null
      }
    ],
    "prices": [{ "currency_id": "USD", "amount": 22.5 }],
    "availability": { "available": true, "reasons": [] }
  }
}
```

---

## Errors

| Status | Condition                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Missing or invalid `X-User-Token`                                                                                                                                                                                                                                                                                                                                                                                                               |
| `403`  | Selling is disabled for the store (`Selling is disabled for this store.`), or the token does not belong to an administrator                                                                                                                                                                                                                                                                                                                     |
| `422`  | Validation failure: `name` or `products` missing, fewer than 2 or more than 100 products, `products` not a list, a product repeated, not found, ambiguous or not sellable in a bundle, `published: true` on a bundle that cannot be sold, a `reserved_stock` the products cannot hold, `published_at` sent, an `external_id` already in use, `cover` and `cover_url` together, or a cover that cannot be downloaded or is not a supported image |
| `429`  | Rate limit exceeded (20 requests per minute per token for create and update, since each may download a cover)                                                                                                                                                                                                                                                                                                                                   |

---

## See Also

- [Bundles API Overview](./bundles-v3-overview.md): the bundle object, states, covers and retries
- [Update a Bundle](./bundles-v3-update.md): change a bundle and publish it
- [Get a Bundle](./bundles-v3-get.md): read a bundle back
