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_id_type | string | No | internal (default): products holds product IDs. external: it holds product identifiers. See 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 |
published | boolean | No | true publishes the bundle in the same request. See Publishing on create. Defaults to unpublished |
private | boolean | No | Defaults to false. See States |
cover_url | string | No | Public URL of the cover image to download. Not accepted together with cover. See Covers |
cover | string | No | Storage path of an uploaded cover image. Not accepted together with cover_url. See Covers |
Add ?fields= to the URL to trim the response to the fields you need, as on get.
The platform sets published_at when the bundle is published. Sending it returns 422, also when its value is null:
{
"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
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.
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
idof the Content API, sent as numbers or as numeric strings. To send identifiers such as ISBNs instead, see Products by identifier. - Order: the order of the array is the order the bundle shows its products. The first one gets
position0. - 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 maps to one of these rules.
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:
{
"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:
{
"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.
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.
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'sexternal_id. - Two identifiers of the same product, such as its ISBN with and without hyphens, are a duplicate.
{
"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:
- Shape:
productsis a list of 2 to 100 entries with no duplicates, made of integer IDs or, by identifier, of identifiers that each match one product. Errors land underproductsorproducts.{position}. - Each product: every product is one your store can sell in a bundle. Errors land under
products.{position}. - The bundle: when the request sends
published: true, the bundle as a whole can be sold. Errors land underpublished.
So a 422 that names a product says nothing about the publication 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
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, and nothing is saved:
{
"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.
Request Examples
Minimal
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
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 under data. A create returns its products, prices and availability with no include needed. With ?fields=, name them in fields to keep them:
{
"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,
"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
},
{
"id": "1202",
"position": 1,
"name": "Rock Icons, Volume 2",
"available": true,
"rejection_reason": null
},
{
"id": "1205",
"position": 2,
"name": "Rock Icons, Volume 3",
"available": true,
"rejection_reason": 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, 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: the bundle object, states, covers and retries
- Update a Bundle: change a bundle and publish it
- Get a Bundle: read a bundle back