Packs
Sell several products or services as one item. Read the catalog from your server, sync it with webhooks, show prices with and without VAT and map it to schema.org.
A pack groups two or more products or services under one name and one price. The pack has no stock of its own: its availability comes from its components, and when it is invoiced it becomes a group of normal fiscal lines. Packs are available on every plan that includes products, from Esencial. On Esencial an account keeps up to 5 packs that are not archived; the other plans have no cap.
This page covers the read side that a website needs. To sell a pack, read Your store with Factuarea. To put a pack on offer, read Promotions and previous price.
Server-to-server use
The packs API is meant to be called from your website's server (the backend that renders your pages), never from the browser. There are no publishable keys: a browser call with an API key is rejected by the CORS preflight, and a key in your JavaScript bundle would expose your whole catalog scope.
Create one credential per store with the minimum scopes:
- An API key of the
liveenvironment, stored only on your server and sent asAuthorization: Bearer fact_live_…. products:readandpacks:readto read the catalog.invoices:writeonly if the same server also invoices the orders.- The company goes in the path (
/v1/companies/{company}/…), never in a header.
A read-only key that tries to change a pack gets 403 insufficient_scope and
the pack does not change. See API keys and
Scopes and irreversibility.
Operations
| Operation | Scope |
|---|---|
GET /v1/companies/{company}/packs and GET …/packs/{pack} | packs:read |
GET …/packs/{pack}/price-breakdown | packs:read |
POST /v1/companies/{company}/packs and PATCH …/packs/{pack} | packs:write |
POST …/packs/{pack}/archive and POST …/packs/{pack}/restore | packs:write |
POST …/packs/{pack}/images, PATCH …/images/{image} and DELETE …/images/{image} | packs:write |
DELETE …/packs/{pack} | packs:delete |
Lists use cursor pagination (limit, starting_after, ending_before) and
filter by search, slug, sku, tag, channel, status,
availability[in] and updated[gte]. sort accepts created, name and
price. A pack has at least two components or one component with a quantity
above 1, otherwise the answer is 422 pack_composition_too_small.
A pack is priced with pricing{mode, value, basis}:
mode | Meaning |
|---|---|
fixed | A fixed price for the whole pack |
percent_off | The sum of the components minus a percentage |
amount_off | The sum of the components minus an amount |
sum | The plain sum of the components |
basis says whether value is net or gross (with VAT). On PATCH,
components and pricing replace the previous value atomically.
Two views: all and storefront
Every read accepts view=all (the default) or view=storefront.
view=all | view=storefront | |
|---|---|---|
| Which packs | All of them, including archived ones | Only active, in the web channel, within their dates and sellable |
| Any other pack | Returned with its status | Detail answers 404 pack_not_found, a slug search comes back empty |
| Extra fields | status, channels, pricing, sellable, availability.limited_by, attention | None |
| Never returned | Costs and supplier offers | Costs, supplier offers, limited_by, attention and metadata |
Any other value of view answers 400 parameter_invalid_enum with
param: "view". Use storefront to render pages and all to synchronize.
Availability and exact quantities
availability.status is one of in_stock, low, out_of_stock or
untracked. untracked means that no component controls stock and your web
should treat the pack as available. Stock never blocks a sale: an
out_of_stock pack can still be invoiced.
The storefront view never gives exact quantities unless the company chose
it. The company setting web_stock_display decides:
web_stock_display | What the storefront returns |
|---|---|
status | Only the status |
low_only (default) | The status, and quantity only when the status is low |
exact | The status and quantity always |
The setting is shared with products, variants and presentations. The component that limits the stock is never exposed to the web.
Price with and without VAT
price is the pack price with the base price list, in the company's tax
regime and for today. It carries every amount you need to print a price tag:
{
"id": "019f1c2e-7b40-7a11-9c35-2d4e6f8a0b12",
"object": "pack",
"name": "Pack Huerto urbano",
"slug": "pack-huerto-urbano",
"sku": "PACK-HUERTO",
"currency": "EUR",
"price": { "net": "50.53", "tax": "9.37", "gross": "59.90" },
"tax_breakdown": [
{ "rate": 21, "base": "39.27", "tax": "8.24" },
{ "rate": 10, "base": "11.26", "tax": "1.13" }
],
"separately": { "gross": "72.91" },
"savings": { "gross": "13.01", "percent": 17.8 },
"availability": { "status": "in_stock" },
"promotion": null
}The example is trimmed to the price fields. Amounts are decimal strings with
two decimals, percentages and tax rates are numbers. separately is what the
components cost if bought one by one. When there is no saving (a fixed price
above the sum, or components on offer) savings is null and your web should
not show a saving.
Canarias and exempt operations: use net
price.gross is the price with the company's VAT. It is only the right
price tag for a customer taxed with that VAT. For other regimes the pack keeps
its net amounts and the tax of the document applies on top. In Canarias, the
Pack Huerto urbano costs 50,53 € plus IGIC, never "59,90 € with IGIC". The same
goes for intra-community, export and reverse-charge sales, where the tax is 0.
If your store sells to those customers, build the price from price.net and
your own tax rule, and show it as "plus tax" where it applies. See
IVA, IGIC and IPSI.
Price in another context
GET …/packs/{pack}/price-breakdown accepts price_list_id, customer_id and
date and returns the "separately" amount, the price, the saving, the
tax_breakdown and the part of each component with that price list. It is
meant for B2B sites with customer price lists.
Images
A pack has up to 5 images. Each one has id, url, thumb_url (480 px),
alt (up to 300 characters), width, height, position and content_type.
cover is resolved on the server: the first image of the pack, else the first
image of the first component that has one, else null, with
source: "own" or "component". Each component exposes image{url, alt} so
you can build a mosaic yourself.
Image URLs are public and permanent: absolute, WebP, without EXIF data,
downloadable over HTTPS without credentials, not signed and never expiring.
Their name is an unguessable UUID that is never reused, so you can link them
directly or copy them to your CDN and keep them forever. Replacing an image
creates a new URL and deleting one makes its URL answer 404. Downloading them
does not use your API quota, and they are served with
Cache-Control: public, max-age=31536000, immutable.
To upload, send multipart/form-data with the field photo (3 MB maximum, the
same formats as the product gallery) to POST …/packs/{pack}/images. The
Idempotency-Key header is mandatory here: without it the answer is
422 idempotency_key_required. A sixth image answers
422 pack_image_limit_reached.
Keep your copy in sync
Do not read the whole list in a loop. Keep a copy of the catalog on your server and update it with two mechanisms:
- Webhooks. Subscribe to
pack.created,pack.updated,pack.archived,pack.restoredandpack.deleted(see Webhooks). The payload of the first four is the pack inview=all.pack.deletedcarries only{id, object: "pack", deleted: true}because the pack can no longer be read. updated[gte]as the safety net after an outage:GET …/packs?view=all&updated[gte]=2026-10-25T00:00:00Z.
Use view=all to synchronize, not storefront: the storefront lists only
what is on sale today, so it cannot tell you what just left it. A pack that
leaves the storefront appears with sellable.web: false and its reason, and
your web removes it. A deleted pack is known only through pack.deleted or the
404 of its read.
updated_at changes with anything that alters the response, such as data,
composition, derived price, images, availability status, a component that stops
being sellable, and the pack entering or leaving its dates. A midnight
scheduler in the company's time zone covers the date changes, so a pack that
ends its season on 30 June emits pack.updated on 1 July without anyone
editing it.
The available quantity is the exception. A sale that lowers the quantity
without changing the status does not move updated_at and does not emit
pack.updated. If you show exact quantities, read the detail of the pack when
you render it.
When you rename a slug, the old one goes to previous_slugs (up to 10) and a
slug search also finds it, so your web can answer a 301 to the new address.
Slugs are unique across products and packs, so both can live under the same
route.
Conditional reads and the cost of a 304
The reads of a pack, the list and the breakdown answer with a strong ETag
(sha1 of the body served), Cache-Control: private, no-cache and
Vary: Accept-Language, Authorization. Send it back in If-None-Match and an
unchanged resource answers 304 with no body. view=all and view=storefront
have different ETag values.
A 304 consumes API quota like any other request, so it is a way to save
bandwidth, not requests. To save requests, rely on webhooks and updated[gte]
and serve your pages from your own cache. See Rate limits.
Content and SEO
A pack has summary (plain text, up to 200), description (plain text with
line breaks, up to 5,000, never HTML) and seo{title, description} (up to 70
and 160). Empty SEO fields are resolved with the name and the summary. The API
does not return sentences built on the server such as "Includes…": it returns
components[].name, quantity and unit{code, label} so that you compose the
text in your own language. Each component also carries product_id and slug
(null if that product is not on the web), to link its page from the pack.
schema.org
Map a pack to a Product with an Offer, and the list to an ItemList:
| schema.org | Pack field |
|---|---|
Offer.price | price.gross |
Offer.priceCurrency | currency |
Product.sku | sku |
Product.image | cover.url |
Offer.priceValidUntil | available_until (or promotion.ends_at during an offer) |
Offer.availability | InStock, LimitedAvailability or OutOfStock from availability.status |
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Pack Huerto urbano",
"sku": "PACK-HUERTO",
"image": "https://app.factuarea.com/uploads/packs/01931b3e-1111-7a2e-9a8b-3c5d6e7f8a01/019f1c2e-7b40-7a11-9c35-2d4e6f8a0b12/019f1c31-aa10-7c02-8d4f-0e1b2c3d4e5f.webp",
"offers": {
"@type": "Offer",
"price": "59.90",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
}
}untracked maps to InStock. Use the VAT-inclusive price only for customers
taxed with the company's VAT, as explained above.
Errors
| Status | code | When |
|---|---|---|
| 404 | pack_not_found | The pack does not exist, belongs to another company or is outside the storefront |
| 409 | sku_already_exists | The SKU clashes with a product, a variant or another pack |
| 409 | slug_already_exists | The slug is used or was used by another product or pack |
| 422 | pack_in_use | You delete a pack that is in documents |
| 422 | pack_composition_too_small | The pack has a single component of quantity 1 |
| 422 | invalid_pack_component and duplicate_pack_component | A component is not allowed or is repeated |
| 422 | invalid_pack_pricing, pack_price_unreachable and pack_price_allocation_impossible | The price cannot be set or split |
| 422 | invalid_pack_status_transition | Archive or restore is not possible from the current status |
| 422 | pack_image_limit_reached | A sixth image |
| 402 | plan_limit_exceeded | You create or restore a pack and the Esencial account already has 5 packs that are not archived (details.resource is packs) |
| 403 | feature_not_available_in_plan | The company has the products module disabled |
The errors of the document lines (pack_not_sellable, pack_snapshot_invalid,
pack_lines_must_be_expanded, pack_line_locked and
pack_print_mode_not_allowed) are explained in
Your store with Factuarea. The MCP server mirrors every
operation with the same errors: list_packs, get_pack, create_pack,
update_pack, archive_pack, restore_pack, delete_pack,
get_pack_price_breakdown, upload_pack_image, update_pack_image and
delete_pack_image.