Skip to main content

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​

HeaderExampleDescription
X-User-Tokenyour-api-tokenAPI token generated in your dashboard. Header-only for security.
Acceptapplication/jsonRequired. 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​

MethodPathDescription
GET/api/v3/bundlesList bundles
POST/api/v3/bundlesCreate 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=external on 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.

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, 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": []
}
}
FieldTypeDescription
idstringBundle identifier
external_idstring | nullYour identifier for the bundle. See Identifying a Bundle
namestringBundle name, max 255 characters
slugstringSlug of the bundle page. Generated from name when you do not send one
descriptionstring | nullDescription, max 2,000 characters
cover_urlstring | nullURL 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
privatebooleanSee States
reserved_stockintegerUnits of each physical product of the bundle held for the bundle alone. 0 when the bundle reserves nothing. See Reserved stock
publishedbooleanWhether the bundle is published. See States
published_atstring | nullRead-only. When the bundle was published, set by the platform. null while it is unpublished
product_urlstring | nullSigned 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_urlstring | nullproduct_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_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last update timestamp
productsarrayIncluded block. The products of the bundle, in order. See Products
pricesarrayIncluded block. The bundle's price in each currency. See Prices
availabilityobjectIncluded 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:

EndpointBlocks in the response
CreateAlways all four
List, get and updateOnly 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:

FieldTypeDescription
idstringProduct ID, the same id the Content API returns
positionintegerPosition in the bundle, starting at 0
namestring | nullProduct name. null when the product can no longer be found
availablebooleanWhether this product can be sold in a bundle right now
rejection_reasonstring | nullWhy it cannot, as one of the rejection reasons. null when available is true
detailed_stockobject | nullWith 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:

CodeThe productError message when you send it
unresolvedis not among the products your store can sell, for example because it was deleted or belongs to another storeProduct [1203] was not found among the products this store can sell (unresolved).
not_on_saleis not on saleProduct [1203] cannot be sold in a bundle: Not on sale (not_on_sale).
freeis free of chargeProduct [1203] cannot be sold in a bundle: Free of charge (free).
subscribers_onlyis available to subscribers onlyProduct [1203] cannot be sold in a bundle: Subscription only (subscribers_only).
out_of_stockis a physical product with no available stock, also when other bundles reserve all of itProduct [1203] cannot be sold in a bundle: Out of stock (out_of_stock).
no_individual_pricehas no price of its own in your store's sale currenciesProduct [1203] cannot be sold in a bundle: No individual sale price (no_individual_price).
untracked_stockis a physical product that does not track its stock, added to a bundle with reserved stockProduct [1203] cannot be sold in a bundle: Does not track stock (untracked_stock).
below_reserved_stockis a physical product with less available stock than the bundle's reserved_stock, added to that bundleProduct [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.

FieldTypeDescription
currency_idstringISO 4217 currency code
amountnumberTotal 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:

CodeThe bundle cannot be sold because
below_minimum_productsit has fewer than 2 products
unresolved_productssome of its products were not found among the products your store can sell
ineligible_productssome 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_currencyits products share no sale currency
zero_totalits 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 buysUnits they can take
A buyer of this bundle30: the total stock less the reserved stock of the other bundles (0)
A buyer of the product alone, or of another bundle with it10: 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​

Whenreserved_stock
You unpublish a published bundleGoes to 0
You delete the bundleGoes 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 deletedGoes to 0
The platform unpublishes the bundle because it was left with fewer than 2 products that can be soldGoes 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 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:

FieldTypeDescription
totalinteger | nullThe product's total stock, the stock_quantity of the Content API. null when the product does not track its stock
reservedintegerUnits reserved by all the bundles that hold the product, this one included
availableinteger | nulltotal 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:

StatepublishedprivateBundle page
UnpublishedfalseanyNot served
PublictruefalseServed, on stores with the block-based storefront
HiddentruetrueServed 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.
  • 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.

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:

WhenLinks already shared
You change the nameKeep working: the slug does not change
You change the slugAnswer 404. The update response carries the new links
The bundle goes from public to hidden, or backKeep working: they are the same links
You unpublish the bundleAnswer 404, and both fields return null. Publishing it again brings back the same links
You delete the bundleAnswer 404
Your store leaves the block-based storefrontAnswer 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:

FieldWhat it takes
cover_urlA public http or https URL. The platform downloads the image and hosts its own copy
coverThe 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​

StatusCondition
401Missing or invalid X-User-Token
403Selling is disabled for the store, or the token does not belong to an administrator
404The bundle does not exist in your store
422Validation 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
429Rate 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​


See Also​