Tu tienda con Factuarea
Muestra productos y packs en tu tienda web, mantén precios y disponibilidad sincronizados y factura cada pedido pagado con el precio que pagó el cliente.
Esta guía muestra cómo una web como Vivero Los Olivos usa Factuarea como su catálogo y su motor de facturación. Tu servidor lee productos y packs, los muestra en tus páginas y, cuando un pedido se paga, crea la factura.
Todo se ejecuta desde tu servidor, nunca desde el navegador. Lee antes Packs para la credencial, las vistas y las reglas de caché, que son las mismas para los productos.
Un escaparate para productos y packs
Productos y packs son dos recursos que comparten un contrato de escaparate. No existe un listado mixto: tu servidor sincroniza los dos recursos y ordena, pagina y busca sobre su propia copia. Así cada recurso conserva su cursor, sus filtros y su caché.
curl "https://api.factuarea.com/v1/companies/$COMPANY_ID/products?view=storefront&tag=huerto" \
-H "Authorization: Bearer fact_live_…"
curl "https://api.factuarea.com/v1/companies/$COMPANY_ID/packs?view=storefront&tag=huerto" \
-H "Authorization: Bearer fact_live_…"Con view=storefront los dos recursos publican los mismos campos:
| Campo | Producto | Pack |
|---|---|---|
| Identidad y contenido | id, object, name, slug, previous_slugs, summary, description, seo, tags, currency, updated_at | Los mismos |
| Precio con y sin IVA | sale_price{net, tax, gross} | price{net, tax, gross} |
| Disponibilidad | availability{status, quantity?} | La misma |
| Imágenes | gallery[] con alt, width, height | cover y gallery[] con alt |
| Oferta | promotion | promotion |
El precio del producto se llama sale_price porque price ya era un texto
publicado y no puede cambiar de tipo. Fuera del escaparate los dos recursos
responden igual: 404 en el detalle y listado vacío al buscar por slug. Un
componente de pack lleva product_id y slug, para que tu página enlace el
producto (slug es null si ese producto no está en la web).
Paso a paso
- Crea la credencial. Una API key
liveguardada en tu servidor conproducts:read,packs:ready, si facturas,invoices:write. - Publica los productos. Un producto no está en la web hasta que su
channels.webestrue. Valefalsepor defecto, también en los productos existentes, para que nada se publique sin una decisión. Publica uno a uno conPATCH …/products/{product}ychannels: {web: true}, o en bloque conPOST …/products/bulk-channels(ver más abajo). Al publicar se genera el slug si falta. - Haz la primera copia. Recorre
GET …/products?view=storefrontyGET …/packs?view=storefrontcon el cursor y guarda los resultados. - Mantenla al día. Suscríbete a los webhooks
product.*ypack.*, incluidosproduct.deletedypack.deleted, y usaview=all&updated[gte]=…para recuperarte tras una caída. Un producto que sale de la web aparece ahí conis_active: falseochannels.web: false. - Pinta tus páginas desde tu copia, y responde 301 cuando una petición use
un slug que ahora está en
previous_slugs.
Publicar en bloque
POST /v1/companies/{company}/products/bulk-channels recibe
{"ids": [...], "channels": {"web": true}} con hasta 50 ids por lote. La
cabecera Idempotency-Key es obligatoria y cada fila acierta o falla por su
cuenta: si un id es de otra empresa, esa fila falla como inexistente y el resto
se publica, y la respuesta lo indica por fila. Usa "web": false para
retirarlos.
Precios con y sin IVA
Muestra sale_price.gross (o price.gross) a los clientes que pagan el IVA de
la empresa. El olivo cuesta 39,00 € sin IVA y 42,90 € con IVA:
{ "price": "39.00", "sale_price": { "net": "39.00", "tax": "3.90", "gross": "42.90" } }price conserva su significado de siempre. Para clientes de Canarias, exentos y
de exportación, construye el precio a partir de net: el impuesto del documento
se aplica encima y «59,90 € con IGIC» no existe. Los detalles están en
Packs. Un producto sin impuesto asignado devuelve tax: "0.00"
y gross igual a net. Para una combinación libre de opciones,
POST …/products/{product}/resolve-selection devuelve el sale_price de esa
combinación.
Disponibilidad
availability.status vale in_stock, low, out_of_stock o untracked (no se
controla el stock, trátalo como disponible). Una presentación cuenta en sus
propias unidades comerciales: una caja de 6 con 20 unidades en stock son 3 cajas.
El escaparate nunca da cantidades exactas salvo que la empresa ponga
web_stock_display=exact. El valor por defecto, low_only, da la cantidad solo
cuando el estado es low. El stock nunca bloquea una venta, así que no ocultes
por tu cuenta el botón de compra en out_of_stock salvo que sea tu política.
Imágenes
Las imágenes de producto y de pack siguen la misma regla: URL absolutas públicas
y permanentes, sin credencial, firma ni caducidad, con un nombre UUID que no se
reutiliza. Enlázalas directamente o cópialas a tu CDN. Toda imagen lleva un alt
(la galería de producto usa el nombre del producto) y, en productos, width y
height para reservar el espacio antes de cargarla.
Caché
Mantén tu propia caché y apóyate en los webhooks para refrescarla. Si aun así
revalidas, envía If-None-Match con el ETag que guardaste: un recurso sin
cambios responde 304 sin cuerpo. Recuerda que un 304 consume cuota de la API.
Productos y packs usan las mismas cabeceras.
Webs B2B con tarifas de cliente
Si tu web vende a empresas que tienen su propia tarifa, pide el precio de cada cliente en lugar de mostrar el público.
1. Pide con client_id. Envía el contacto del cliente que ha iniciado
sesión (el mismo client_id que lleva una factura) a
POST …/price-lists/resolve, o a …/resolve-many para hasta 100 productos.
Añade channel: "web" para que se apliquen las ofertas de la web:
curl -X POST https://api.factuarea.com/v1/companies/$COMPANY_ID/price-lists/resolve \
-H "Authorization: Bearer fact_live_…" \
-H "Content-Type: application/json" \
-d '{
"product_id": "019f1b77-3e90-7c24-8a1d-5f0b2e6c9d48",
"client_id": "019f2a0c-1d52-7b8e-a4f0-3c6d9e2b5a17",
"channel": "web"
}'La tarifa se elige como en un documento. Un price_list_id que envíes manda (y
null significa sin tarifa). Si no lo envías, rige la tarifa predeterminada del
cliente, y se ignora si está inactiva. La respuesta incluye unit_price, el
source y el nombre de la tarifa, regular_unit_price y, cuando se aplica una
oferta, promotion.
2. Muestra gross y cobra el importe exacto. El gross de promotion.price
usa el IVA del producto y el régimen de la empresa. No aplica el recargo de
equivalencia, la exención ni el IGIC del cliente. Para mostrar o cobrar el
importe exacto, envía las líneas a POST …/taxes/calculate-totals: es el mismo
cálculo que hace la factura.
3. Mantén tu caché al día. Suscríbete a los webhooks price_list.*
(created, updated, deleted, item_updated e item_deleted) y refresca solo
lo que ha cambiado. Tras una caída, o al empezar, ponte al día listando con
updated[gte] igual al último updated_at que guardaste, en GET …/price-lists
y en GET …/price-lists/{priceList}/items. El updated_at de una tarifa también
se mueve cuando cambia uno de sus precios.
Factura un pedido pagado
Cuando el pago se confirma, tu servidor crea la factura con
POST /v1/companies/{company}/invoices. El id del pedido va en external_id y
cada línea lleva la fecha de la venta y el precio realmente cobrado:
curl -X POST https://api.factuarea.com/v1/companies/$COMPANY_ID/invoices \
-H "Authorization: Bearer fact_live_…" \
-H "Idempotency-Key: 019f2a10-5c3e-7d41-8b20-6a9e1c4d7f33" \
-H "Content-Type: application/json" \
-d '{
"client_id": "019f2a0c-1d52-7b8e-a4f0-3c6d9e2b5a17",
"issued_on": "2026-06-30",
"due_on": "2026-06-30",
"external_id": "WEB-1042",
"prices_include_tax": true,
"lines": [
{ "pack_id": "019f1c2e-7b40-7a11-9c35-2d4e6f8a0b12", "quantity": 2,
"sold_on": "2026-06-30", "channel": "web",
"unit_price": "59.90", "prices_include_tax": true },
{ "product_id": "019f1b77-3e90-7c24-8a1d-5f0b2e6c9d48", "quantity": 1,
"sold_on": "2026-06-30", "channel": "web",
"unit_price": "42.90", "prices_include_tax": true }
],
"payment": { "method": "credit_card", "reference": "WEB-1042-PAY" }
}'| Campo | Qué hace |
|---|---|
external_id | El id de tu pedido. Hace la factura idempotente |
sold_on | El día de la venta. Con él se evalúan la vigencia y las ofertas |
unit_price | El precio cobrado, por pack o por unidad. Gana a cualquier precio u oferta vigente |
prices_include_tax | Si ese unit_price lleva IVA. Funciona por línea y, si se omite, la línea toma el del documento |
channel | web para que se aplique una oferta solo web. Por defecto es documents |
payment | Marca la factura como pagada y convierte la petición en un cobro (ver Cobro desatendido) |
Reglas que debes seguir
- Envía siempre el precio cobrado. Sin
unit_price, un pack se valora al precio vigente cuando se crea la factura, no al que vio el cliente. Si el pack pasó de 59,90 € a 64,90 € entre la página y el pago, enviarunit_price: "59.90"factura lo que se pagó. - La suma cuadra al céntimo. Con precio cobrado, el grupo de líneas se reparte para que sume exactamente lo pagado. Tres Pack Huerto urbano a 59,90 € suman exactamente 179,70 € y, si hace falta, una línea de componente se parte en dos (por ejemplo 17 y 1 unidades de plantones). Lo mismo vale para un producto con precio con IVA: un olivo cobrado a 36,47 € queda en una línea de 33,15 € más 3,32 € de IVA.
- Una venta cobrada siempre se factura. Si el pack se archivó o terminó su
vigencia después del pago, una línea con
sold_onyunit_pricese factura igualmente, con la composición actual del pack. - Una venta al precio de oferta cuenta como vendida con la oferta. Si el
unit_pricecobrado coincide con el precio de oferta de ese canal y esa fecha, la línea conserva la oferta. Consulta Ofertas. - El stock se mueve cuando lo mueve el tipo de documento, por el libro de stock normal. El pack no tiene stock propio.
Reintentos
Dos mecanismos trabajan juntos. La cabecera Idempotency-Key cubre el reintento
de transporte de la misma petición. El external_id identifica el pedido y no
caduca nunca:
- Con
payment,type: F2uoptions.register_verifactu, repetir la petición con el mismoexternal_idresponde200con la misma factura eIdempotent-Replayed: true, sin una segunda factura. Otro total o tipo responde409 unattended_replay_mismatch. - Sin ellos, un reenvío actualiza el borrador existente en vez de crear otro, y si
el documento ya no es editable responde con su error
422de estado.
Reintenta los timeouts y los 5xx con la misma clave y el mismo external_id. No
reintentes un 4xx sin corregir la petición.
Errores 422 previsibles
| Status | code | Causa |
|---|---|---|
| 404 | pack_not_found | El pack no existe o es de otra empresa |
| 422 | pack_not_sellable | Archivado, inactivo, fuera de vigencia en sold_on o con un componente inactivo. Solo afecta a líneas sin unit_price, con el motivo y param en la línea |
| 422 | quantity | La cantidad de packs no es entera, es menor que 1, o una línea resultante supera el techo de su columna |
| 422 | pack_print_mode_not_allowed | El modo de impresión no es válido para ese tipo de documento |
| 422 | pack_lines_must_be_expanded | Enviaste líneas de un pack construidas por ti |
| 422 | simplified_invoice_not_allowed | Una factura simplificada por encima de su tope |
No puedes construir las líneas de un pack por tu cuenta: envía
{pack_id, quantity} y el servidor las expande. Para editar un borrador sin
perder su fotografía, reenvía {pack_group_id, quantity}. Editar el precio o la
cantidad de una línea de componente de un grupo responde 422 pack_line_locked.
Próximos pasos
- Packs: vistas, sincronización y schema.org.
- Ofertas y precio anterior: ofertas y las reglas legales para anunciar una rebaja.
- Webhooks e Idempotencia.