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
- 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 - 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.
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 |
POST | /api/v3/bundles | Create a bundle |
GET | /api/v3/bundles/{bundle} | Get a bundle |
PUT | /api/v3/bundles/{bundle} | Update a bundle |
DELETE | /api/v3/bundles/{bundle} | Delete a bundle |
{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:
{
"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=externalon get, update and delete - find it with
filter[external_id]on list, 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
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:
{
"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:
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 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.
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, as ?include=products,prices,availability,detailed_stock returns it:
{
"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 |
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 |
private | boolean | See 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 |
published | boolean | Whether the bundle is published. See 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 |
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 |
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 |
prices | array | Included block. The bundle's price in each currency. See Prices |
availability | object | Included block. available (boolean) and reasons (array of codes), whether the bundle can be sold right now. See Availability |
Included Blocks
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 | Always all four |
| List, get and update | 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:
{
"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
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 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. 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 |
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.
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
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 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_stockgoes to 18, and the product's total stock to 28. - 25 copies sold: the total stock goes from 30 to 5, and
reserved_stockstops 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 | reserved_stock |
|---|---|
| You unpublish a published bundle | Goes to 0 |
| You delete 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
0is always allowed. Loweringreserved_stockto another value is allowed while the bundle keeps a physical product.
See Reserved stock on update for the errors. The Content API does not lower a product's stock_quantity below the units its bundles reserve.
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
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: truepublishes the bundle, and only succeeds when the bundle can be sold. On a bundle that is already published,published: truewithoutproductskeeps it published as it is.published: falseunpublishes it. See Publishing.- The platform sets
published_at. Sending it is rejected with422.
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
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_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
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 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. 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.
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.
See the API Overview for full details.
Next Steps
- Create a Bundle: build a bundle from your products
- List Bundles: page through your bundles or find one by
external_id - Get a Bundle: read a bundle with its price and availability
- Update a Bundle: change its details or products, and publish it
- Delete a Bundle: remove a bundle for good
See Also
- API Authentication
- Content API: the products a bundle references