Factuarea APIDevelopers

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 como Authorization: Bearer fact_live_….
  • products:read y packs:read para leer el catálogo.
  • invoices:write solo 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ónScope
GET /v1/companies/{company}/packs y GET …/packs/{pack}packs:read
GET …/packs/{pack}/price-breakdownpacks:read
POST /v1/companies/{company}/packs y PATCH …/packs/{pack}packs:write
POST …/packs/{pack}/archive y POST …/packs/{pack}/restorepacks: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

  1. Si no, la respuesta es 422 pack_composition_too_small.

El precio de un pack se define con pricing{mode, value, basis}:

modeSignificado
fixedUn precio fijo para todo el pack
percent_offLa suma de los componentes menos un porcentaje
amount_offLa suma de los componentes menos un importe
sumLa 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=allview=storefront
Qué packsTodos, también los archivadosSolo los activos, con canal web, en vigencia y vendibles
Cualquier otro packSe devuelve con su estadoEl detalle responde 404 pack_not_found y la búsqueda por slug sale vacía
Campos extrastatus, channels, pricing, sellable, availability.limited_by, attentionNinguno
Nunca se devuelveCostes ni ofertas de proveedorCostes, 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_displayQué devuelve el escaparate
statusSolo el estado
low_only (por defecto)El estado, y quantity solo cuando el estado es low
exactEl 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:

  1. Webhooks. Suscríbete a pack.created, pack.updated, pack.archived, pack.restored y pack.deleted (consulta Webhooks). El payload de los cuatro primeros es el pack en view=all. pack.deleted solo lleva {id, object: "pack", deleted: true}, porque el pack ya no se puede leer.
  2. 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.orgCampo del pack
Offer.priceprice.gross
Offer.priceCurrencycurrency
Product.skusku
Product.imagecover.url
Offer.priceValidUntilavailable_until (o promotion.ends_at durante una oferta)
Offer.availabilityInStock, 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

StatuscodeCuándo
404pack_not_foundEl pack no existe, es de otra empresa o está fuera del escaparate
409sku_already_existsEl SKU choca con un producto, una variante u otro pack
409slug_already_existsEl slug lo usa o lo usó otro producto o pack
422pack_in_useBorras un pack que está en documentos
422pack_composition_too_smallEl pack tiene un único componente de cantidad 1
422invalid_pack_component y duplicate_pack_componentUn componente no se admite o está repetido
422invalid_pack_pricing, pack_price_unreachable y pack_price_allocation_impossibleEl precio no se puede fijar o repartir
422invalid_pack_status_transitionArchivar o restaurar no es posible desde el estado actual
422pack_image_limit_reachedUna sexta imagen
402plan_limit_exceededCreas o restauras un pack y la cuenta Esencial ya tiene 5 packs sin archivar (details.resource vale packs)
403feature_not_available_in_planLa 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.

En esta página

¿Te echamos una mano?Contactar con soporte