Skip to main content

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​

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 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": []
}
}
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
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 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:

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

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

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:

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 out of stockProduct [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).

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.

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


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 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:

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​

X

Graph View