Bundles API
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 - 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 three included blocks, as ?include=products,prices,availability 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,
"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
},
{
"id": "1202",
"position": 1,
"name": "Rock Icons, Volume 2",
"available": true,
"rejection_reason": 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 |
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 and availability 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 three |
| List, get and update | Only the ones named in ?include= |
include takes any combination of products, prices and availability, separated by commas: ?include=prices,availability returns those two blocks and not products. 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",
"errors": {
"include": [
"Invalid include: cover. Allowed: products, prices, availability"
]
}
}
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 |
Rejection Reasons
A product that cannot be sold in a bundle carries one of these codes. The same code closes the error message when a create or an update adds that product. 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 out of stock | 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). |
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.
| 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; 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.
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 bundle has a physical product, 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