Factuarea APIDevelopers

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 live environment, stored only on your server and sent as Authorization: Bearer fact_live_….
  • products:read and packs:read to read the catalog.
  • invoices:write only 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

OperationScope
GET /v1/companies/{company}/packs and GET …/packs/{pack}packs:read
GET …/packs/{pack}/price-breakdownpacks:read
POST /v1/companies/{company}/packs and PATCH …/packs/{pack}packs:write
POST …/packs/{pack}/archive and POST …/packs/{pack}/restorepacks: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}:

modeMeaning
fixedA fixed price for the whole pack
percent_offThe sum of the components minus a percentage
amount_offThe sum of the components minus an amount
sumThe 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=allview=storefront
Which packsAll of them, including archived onesOnly active, in the web channel, within their dates and sellable
Any other packReturned with its statusDetail answers 404 pack_not_found, a slug search comes back empty
Extra fieldsstatus, channels, pricing, sellable, availability.limited_by, attentionNone
Never returnedCosts and supplier offersCosts, 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_displayWhat the storefront returns
statusOnly the status
low_only (default)The status, and quantity only when the status is low
exactThe 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:

  1. Webhooks. Subscribe to pack.created, pack.updated, pack.archived, pack.restored and pack.deleted (see Webhooks). The payload of the first four is the pack in view=all. pack.deleted carries only {id, object: "pack", deleted: true} because the pack can no longer be read.
  2. 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.orgPack field
Offer.priceprice.gross
Offer.priceCurrencycurrency
Product.skusku
Product.imagecover.url
Offer.priceValidUntilavailable_until (or promotion.ends_at during an offer)
Offer.availabilityInStock, 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

StatuscodeWhen
404pack_not_foundThe pack does not exist, belongs to another company or is outside the storefront
409sku_already_existsThe SKU clashes with a product, a variant or another pack
409slug_already_existsThe slug is used or was used by another product or pack
422pack_in_useYou delete a pack that is in documents
422pack_composition_too_smallThe pack has a single component of quantity 1
422invalid_pack_component and duplicate_pack_componentA component is not allowed or is repeated
422invalid_pack_pricing, pack_price_unreachable and pack_price_allocation_impossibleThe price cannot be set or split
422invalid_pack_status_transitionArchive or restore is not possible from the current status
422pack_image_limit_reachedA sixth image
402plan_limit_exceededYou create or restore a pack and the Esencial account already has 5 packs that are not archived (details.resource is packs)
403feature_not_available_in_planThe 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.

On this page

Need a hand?Contact support