Packs de productos
Vende varios productos o servicios como un solo artículo. Lee el catálogo desde tu servidor, sincronízalo con webhooks, muestra precios con y sin IVA y mapéalo a schema.org.
Un pack agrupa dos o más productos o servicios bajo un nombre y un precio. El pack no tiene stock propio: su disponibilidad sale de sus componentes, y al facturarlo se convierte en un grupo de líneas fiscales normales. Los packs están disponibles en todos los planes que incluyen productos, desde Esencial. En Esencial una cuenta admite hasta 5 packs sin archivar y el resto de planes no tiene tope.
Esta página cubre la lectura que necesita una web. Para vender un pack, lee Tu tienda con Factuarea. Para ponerlo en oferta, lee Ofertas y precio anterior.
Uso de servidor a servidor
La API de packs está pensada para llamarse desde el servidor de tu web (el backend que pinta las páginas), nunca desde el navegador. No existen claves publicables: una llamada desde el navegador con una API key la rechaza el preflight de CORS, y una clave dentro de tu JavaScript expondría todo el alcance de tu catálogo.
Crea una credencial por tienda con los scopes mínimos:
- Una API key del entorno
live, guardada solo en tu servidor y enviada comoAuthorization: Bearer fact_live_…. products:readypacks:readpara leer el catálogo.invoices:writesolo si ese mismo servidor factura los pedidos.- La empresa va en el camino (
/v1/companies/{company}/…), nunca en una cabecera.
Una clave de solo lectura que intenta cambiar un pack recibe
403 insufficient_scope y el pack no cambia. Consulta
API keys y
Scopes e irreversibilidad.
Operaciones
| Operación | Scope |
|---|---|
GET /v1/companies/{company}/packs y GET …/packs/{pack} | packs:read |
GET …/packs/{pack}/price-breakdown | packs:read |
POST /v1/companies/{company}/packs y PATCH …/packs/{pack} | packs:write |
POST …/packs/{pack}/archive y POST …/packs/{pack}/restore | packs:write |
POST …/packs/{pack}/images, PATCH …/images/{image} y DELETE …/images/{image} | packs:write |
DELETE …/packs/{pack} | packs:delete |
Los listados usan paginación por cursor (limit, starting_after,
ending_before) y filtran por search, slug, sku, tag, channel,
status, availability[in] y updated[gte]. sort admite created, name y
price. Un pack lleva al menos dos componentes, o uno con cantidad superior a
- Si no, la respuesta es
422 pack_composition_too_small.
El precio de un pack se define con pricing{mode, value, basis}:
mode | Significado |
|---|---|
fixed | Un precio fijo para todo el pack |
percent_off | La suma de los componentes menos un porcentaje |
amount_off | La suma de los componentes menos un importe |
sum | La suma simple de los componentes |
basis indica si value es net (sin IVA) o gross (con IVA). En PATCH,
components y pricing sustituyen el valor anterior de forma atómica.
Dos vistas: all y storefront
Toda lectura admite view=all (por defecto) o view=storefront.
view=all | view=storefront | |
|---|---|---|
| Qué packs | Todos, también los archivados | Solo los activos, con canal web, en vigencia y vendibles |
| Cualquier otro pack | Se devuelve con su estado | El detalle responde 404 pack_not_found y la búsqueda por slug sale vacía |
| Campos extra | status, channels, pricing, sellable, availability.limited_by, attention | Ninguno |
| Nunca se devuelve | Costes ni ofertas de proveedor | Costes, ofertas de proveedor, limited_by, attention ni metadata |
Cualquier otro valor de view responde 400 parameter_invalid_enum con
param: "view". Usa storefront para pintar páginas y all para sincronizar.
Disponibilidad y cantidades exactas
availability.status vale in_stock, low, out_of_stock o untracked.
untracked significa que ningún componente controla stock y tu web debe tratar
el pack como disponible. El stock nunca bloquea una venta: un pack
out_of_stock se puede facturar igualmente.
El escaparate nunca da cantidades exactas salvo que la empresa lo elija. Lo
decide el ajuste de empresa web_stock_display:
web_stock_display | Qué devuelve el escaparate |
|---|---|
status | Solo el estado |
low_only (por defecto) | El estado, y quantity solo cuando el estado es low |
exact | El estado y quantity siempre |
El ajuste es el mismo para productos, variantes y presentaciones. El componente que limita el stock no se expone nunca a la web.
Precio con y sin IVA
price es el precio del pack con la tarifa base, en el régimen de la empresa y
para hoy. Lleva todos los importes que necesitas para una etiqueta de precio:
{
"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
}El ejemplo está recortado a los campos de precio. Los importes son texto
decimal con dos decimales, y los porcentajes y tipos de IVA son números.
separately es lo que cuestan los componentes comprados uno a uno. Cuando no
hay ahorro (un precio fixed por encima de la suma, o componentes en oferta),
savings vale null y tu web no debe mostrar ahorro.
Canarias y operaciones exentas: usa net
price.gross es el precio con el IVA de la empresa. Solo es la etiqueta
correcta para un cliente al que se aplica ese IVA. En otros regímenes el pack
conserva sus importes netos y el impuesto del documento se aplica encima. En
Canarias, el Pack Huerto urbano cuesta 50,53 € más IGIC, nunca «59,90 € con
IGIC». Lo mismo vale para ventas intracomunitarias, exportaciones e inversión
del sujeto pasivo, donde el impuesto es 0.
Si tu tienda vende a esos clientes, construye el precio a partir de
price.net y tu propia regla de impuestos, y muéstralo como «más impuestos»
cuando corresponda. Consulta IVA, IGIC e IPSI.
Precio en otro contexto
GET …/packs/{pack}/price-breakdown admite price_list_id, customer_id y
date, y devuelve el importe «por separado», el precio, el ahorro, el
tax_breakdown y la parte de cada componente con esa tarifa. Sirve para webs
B2B con tarifas de cliente.
Imágenes
Un pack tiene hasta 5 imágenes. Cada una lleva id, url, thumb_url
(480 px), alt (hasta 300 caracteres), width, height, position y
content_type. cover se resuelve en el servidor: la primera imagen del pack,
si no la primera imagen del primer componente que tenga, y si tampoco null,
con source: "own" o "component". Cada componente expone image{url, alt}
para que compongas un mosaico por tu cuenta.
Las URL de las imágenes son públicas y permanentes: absolutas, en WebP, sin
datos EXIF, descargables por HTTPS sin credenciales, sin firma y sin caducidad.
Su nombre es un UUID imposible de adivinar que no se reutiliza, así que puedes
enlazarlas directamente o copiarlas a tu CDN y guardarlas para siempre. Sustituir
una imagen crea una URL nueva y borrarla hace que su URL responda 404. Bajarlas
no consume cuota de la API, y se sirven con
Cache-Control: public, max-age=31536000, immutable.
Para subir una, envía multipart/form-data con el campo photo (máximo 3 MB,
los mismos formatos que la galería de producto) a POST …/packs/{pack}/images.
La cabecera Idempotency-Key es obligatoria: sin ella la respuesta es
422 idempotency_key_required. Una sexta imagen responde
422 pack_image_limit_reached.
Mantén tu copia sincronizada
No leas el listado entero en bucle. Guarda una copia del catálogo en tu servidor y actualízala con dos mecanismos:
- Webhooks. Suscríbete a
pack.created,pack.updated,pack.archived,pack.restoredypack.deleted(consulta Webhooks). El payload de los cuatro primeros es el pack enview=all.pack.deletedsolo lleva{id, object: "pack", deleted: true}, porque el pack ya no se puede leer. updated[gte]como red de seguridad tras una caída:GET …/packs?view=all&updated[gte]=2026-10-25T00:00:00Z.
Sincroniza con view=all, no con storefront: el escaparate solo lista lo que
hoy está a la venta, así que no te dice qué acaba de salir. Un pack que sale del
escaparate aparece con sellable.web: false y su motivo, y tu web lo retira. Un
pack borrado solo se conoce por pack.deleted o por el 404 de su lectura.
updated_at cambia ante cualquier cambio que altere la respuesta: datos,
composición, precio derivado, imágenes, estado de disponibilidad, un componente
que deja de ser vendible, y la entrada o salida de la vigencia. Un programador
de medianoche, en la zona horaria de la empresa, cubre los cambios por fecha:
un pack que acaba su temporada el 30 de junio emite pack.updated el 1 de julio
sin que nadie lo edite.
La cantidad disponible es la excepción. Una venta que baja la cantidad sin
cambiar el estado no mueve updated_at ni emite pack.updated. Si muestras
cantidades exactas, lee el detalle del pack al pintarlo.
Si renombras un slug, el anterior pasa a previous_slugs (hasta 10) y una
búsqueda por slug también lo encuentra, para que tu web responda un 301 a la
dirección nueva. Los slugs son únicos entre productos y packs, así que los dos
pueden vivir bajo la misma ruta.
Lecturas condicionales y coste de un 304
Las lecturas de un pack, del listado y del desglose responden con un ETag
fuerte (sha1 del cuerpo servido), Cache-Control: private, no-cache y
Vary: Accept-Language, Authorization. Envíalo de vuelta en If-None-Match y
un recurso sin cambios responde 304 sin cuerpo. view=all y
view=storefront tienen ETag distintos.
Un 304 consume cuota de la API como cualquier otra petición, así que
ahorra ancho de banda, no peticiones. Para ahorrar peticiones, apóyate en los
webhooks y en updated[gte], y sirve tus páginas desde tu propia caché.
Consulta Límites de peticiones.
Contenido y SEO
Un pack tiene summary (texto plano, hasta 200), description (texto plano con
saltos de línea, hasta 5.000, nunca HTML) y seo{title, description} (hasta 70
y 160). Los campos SEO vacíos se resuelven con el nombre y el resumen. La API no
devuelve frases montadas en el servidor como «Incluye…»: devuelve
components[].name, quantity y unit{code, label} para que compongas el
texto en tu idioma. Cada componente lleva además product_id y slug (null
si ese producto no está en la web), para enlazar su ficha desde el pack.
schema.org
Mapea un pack a un Product con una Offer, y el listado a un ItemList:
| schema.org | Campo del pack |
|---|---|
Offer.price | price.gross |
Offer.priceCurrency | currency |
Product.sku | sku |
Product.image | cover.url |
Offer.priceValidUntil | available_until (o promotion.ends_at durante una oferta) |
Offer.availability | InStock, LimitedAvailability u OutOfStock según 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 se mapea a InStock. Usa el precio con IVA solo para clientes a los
que se aplica el IVA de la empresa, como se explica arriba.
Errores
| Status | code | Cuándo |
|---|---|---|
| 404 | pack_not_found | El pack no existe, es de otra empresa o está fuera del escaparate |
| 409 | sku_already_exists | El SKU choca con un producto, una variante u otro pack |
| 409 | slug_already_exists | El slug lo usa o lo usó otro producto o pack |
| 422 | pack_in_use | Borras un pack que está en documentos |
| 422 | pack_composition_too_small | El pack tiene un único componente de cantidad 1 |
| 422 | invalid_pack_component y duplicate_pack_component | Un componente no se admite o está repetido |
| 422 | invalid_pack_pricing, pack_price_unreachable y pack_price_allocation_impossible | El precio no se puede fijar o repartir |
| 422 | invalid_pack_status_transition | Archivar o restaurar no es posible desde el estado actual |
| 422 | pack_image_limit_reached | Una sexta imagen |
| 402 | plan_limit_exceeded | Creas o restauras un pack y la cuenta Esencial ya tiene 5 packs sin archivar (details.resource vale packs) |
| 403 | feature_not_available_in_plan | La empresa tiene desactivado el módulo de productos |
Los errores de las líneas de documento (pack_not_sellable,
pack_snapshot_invalid, pack_lines_must_be_expanded, pack_line_locked y
pack_print_mode_not_allowed) se explican en
Tu tienda con Factuarea. El servidor MCP replica cada
operación con los mismos errores: list_packs, get_pack, create_pack,
update_pack, archive_pack, restore_pack, delete_pack,
get_pack_price_breakdown, upload_pack_image, update_pack_image y
delete_pack_image.