Factuarea APIDevelopers

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:

CampoProductoPack
Identidad y contenidoid, object, name, slug, previous_slugs, summary, description, seo, tags, currency, updated_atLos mismos
Precio con y sin IVAsale_price{net, tax, gross}price{net, tax, gross}
Disponibilidadavailability{status, quantity?}La misma
Imágenesgallery[] con alt, width, heightcover y gallery[] con alt
Ofertapromotionpromotion

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

  1. Crea la credencial. Una API key live guardada en tu servidor con products:read, packs:read y, si facturas, invoices:write.
  2. Publica los productos. Un producto no está en la web hasta que su channels.web es true. Vale false por defecto, también en los productos existentes, para que nada se publique sin una decisión. Publica uno a uno con PATCH …/products/{product} y channels: {web: true}, o en bloque con POST …/products/bulk-channels (ver más abajo). Al publicar se genera el slug si falta.
  3. Haz la primera copia. Recorre GET …/products?view=storefront y GET …/packs?view=storefront con el cursor y guarda los resultados.
  4. Mantenla al día. Suscríbete a los webhooks product.* y pack.*, incluidos product.deleted y pack.deleted, y usa view=all&updated[gte]=… para recuperarte tras una caída. Un producto que sale de la web aparece ahí con is_active: false o channels.web: false.
  5. 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" }
      }'
CampoQué hace
external_idEl id de tu pedido. Hace la factura idempotente
sold_onEl día de la venta. Con él se evalúan la vigencia y las ofertas
unit_priceEl precio cobrado, por pack o por unidad. Gana a cualquier precio u oferta vigente
prices_include_taxSi ese unit_price lleva IVA. Funciona por línea y, si se omite, la línea toma el del documento
channelweb para que se aplique una oferta solo web. Por defecto es documents
paymentMarca 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, enviar unit_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_on y unit_price se factura igualmente, con la composición actual del pack.
  • Una venta al precio de oferta cuenta como vendida con la oferta. Si el unit_price cobrado 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: F2 u options.register_verifactu, repetir la petición con el mismo external_id responde 200 con la misma factura e Idempotent-Replayed: true, sin una segunda factura. Otro total o tipo responde 409 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 422 de 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

StatuscodeCausa
404pack_not_foundEl pack no existe o es de otra empresa
422pack_not_sellableArchivado, 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
422quantityLa cantidad de packs no es entera, es menor que 1, o una línea resultante supera el techo de su columna
422pack_print_mode_not_allowedEl modo de impresión no es válido para ese tipo de documento
422pack_lines_must_be_expandedEnviaste líneas de un pack construidas por ti
422simplified_invoice_not_allowedUna 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

En esta página

¿Te echamos una mano?Contactar con soporte