Your store with Factuarea
Show products and packs in your web store, keep prices and availability in sync, and invoice each paid order with the price the customer paid.
This guide shows how a website such as Vivero Los Olivos uses Factuarea as its catalog and its invoicing engine. Your server reads products and packs, shows them in your pages, and when an order is paid it creates the invoice.
Everything runs from your server, never from the browser. Read Packs first for the credential, the views and the cache rules, which are the same for products.
One storefront for products and packs
Products and packs are two resources that share one storefront contract. There is no mixed list: your server synchronizes both resources and sorts, paginates and searches over its own copy. That way each resource keeps its own cursor, filters and cache.
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_…"With view=storefront both resources publish the same fields:
| Field | Product | Pack |
|---|---|---|
| Identity and content | id, object, name, slug, previous_slugs, summary, description, seo, tags, currency, updated_at | The same |
| Price with and without VAT | sale_price{net, tax, gross} | price{net, tax, gross} |
| Availability | availability{status, quantity?} | The same |
| Images | gallery[] with alt, width, height | cover and gallery[] with alt |
| Offer | promotion | promotion |
The product price is called sale_price because price was already a
published text and cannot change its type. Outside the storefront both
resources answer the same: 404 on the detail and an empty list when you
search by slug. A pack component carries product_id and slug, so your
page can link the product (slug is null if that product is not on the web).
Step by step
- Create the credential. An API key
livestored on your server withproducts:read,packs:readand, if you invoice,invoices:write. - Publish the products. A product is not on the web until its
channels.webistrue, which isfalseby default, also for existing products, so that nothing is published without a decision. Publish one by one withPATCH …/products/{product}andchannels: {web: true}, or in bulk withPOST …/products/bulk-channels(see below). Publishing generates the slug when it is missing. - Make the first copy. Page through
GET …/products?view=storefrontandGET …/packs?view=storefrontwith the cursor and store the results. - Keep it up to date. Subscribe to the
product.*andpack.*webhooks, includingproduct.deletedandpack.deleted, and useview=all&updated[gte]=…to recover after an outage. A product that leaves the web shows up there withis_active: falseorchannels.web: false. - Render your pages from your copy, and answer 301 when a request uses a
slug that is now in
previous_slugs.
Publish in bulk
POST /v1/companies/{company}/products/bulk-channels takes
{"ids": [...], "channels": {"web": true}} with up to 50 ids per batch. The
Idempotency-Key header is mandatory and each row succeeds or fails on its own:
if one id belongs to another company, that row fails as not found and the rest
are published, with the answer reporting each row. Use "web": false to
withdraw them.
Prices with and without VAT
Show sale_price.gross (or price.gross) for customers who pay the company's
VAT. The olive tree costs 39,00 € without VAT and 42,90 € with VAT:
{ "price": "39.00", "sale_price": { "net": "39.00", "tax": "3.90", "gross": "42.90" } }price keeps its old meaning. For Canarias, exempt and export customers build
the price from net: the tax of the document is applied on top and
"59,90 € with IGIC" does not exist. The details are in
Packs. A product with
no tax assigned returns tax: "0.00" and gross equal to net. For a free
combination of options, POST …/products/{product}/resolve-selection returns the
sale_price of that combination.
Availability
availability.status is in_stock, low, out_of_stock or untracked
(stock is not controlled, treat it as available). A presentation counts in its
own commercial units, so a box of 6 with 20 units in stock is 3 boxes.
The storefront never gives exact quantities unless the company sets
web_stock_display=exact. The default, low_only, gives the quantity only when
the status is low. Stock never blocks a sale, so do not hide the buy button
on your own for out_of_stock unless that is your policy.
Images
Product images and pack images follow the same rule: public and permanent
absolute URLs, with no credential, signature or expiry, and a UUID name that is
never reused. Link them directly or copy them to your CDN. Every image has an
alt (the product gallery uses the product name) and, for products, width and
height so you can reserve the space before loading it.
Cache
Keep your own cache and rely on webhooks to refresh it. If you still revalidate,
send If-None-Match with the ETag you stored: an unchanged resource answers
304 with no body. Remember that a 304 consumes API quota. Products and packs
use the same headers.
B2B websites with customer price lists
If your web sells to businesses that have their own price list, ask for the price of each customer instead of showing the public one.
1. Ask with client_id. Send the contact of the logged-in customer (the same
client_id an invoice carries) to POST …/price-lists/resolve, or to
…/resolve-many for up to 100 products. Add channel: "web" so that the offers
of the web apply:
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"
}'The price list is chosen as in a document. A price_list_id you send wins (and
null means no price list). Without it, the default price list of the customer
applies, and an inactive one is ignored. The answer carries unit_price, the
source and name of the price list, regular_unit_price and, when an offer
applies, promotion.
2. Show gross, charge the exact amount. The gross of promotion.price
uses the VAT of the product and the regime of the company. It does not apply the
equivalence surcharge, the exemption or the IGIC of the customer. To show or
charge the exact amount, send the lines to POST …/taxes/calculate-totals: it is
the same calculation as the invoice.
3. Keep your cache fresh. Subscribe to the price_list.* webhooks
(created, updated, deleted, item_updated and item_deleted) and refresh
only what changed. After downtime, or when you start, catch up by listing with
updated[gte] set to the last updated_at you stored, in
GET …/price-lists and in GET …/price-lists/{priceList}/items. The
updated_at of a price list also moves when one of its prices changes.
Invoice a paid order
When the payment is confirmed, your server creates the invoice with
POST /v1/companies/{company}/invoices. The order id goes in external_id, and
each line carries the date of the sale and the price actually charged:
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" }
}'| Field | What it does |
|---|---|
external_id | The id of your order. It makes the invoice idempotent |
sold_on | The day of the sale. Validity dates and offers are evaluated with it |
unit_price | The price charged, per pack or per unit. It wins over any current price or offer |
prices_include_tax | Whether that unit_price includes VAT. It works per line and, if omitted, the line takes the one of the document |
channel | web so that an offer only for the web is applied. The default is documents |
payment | Marks the invoice as paid and turns the request into a checkout (see Unattended checkout) |
Rules to follow
- Always send the price charged. Without
unit_price, a pack is valued at its current price when the invoice is created, not at the price the customer saw. If the pack went from 59,90 € to 64,90 € between the page and the payment, sendingunit_price: "59.90"invoices what was paid. - The sum matches to the cent. With a charged price the group of lines is split so that it adds up exactly to what was paid. Three Pack Huerto urbano at 59,90 € add exactly 179,70 €, and if needed a component line is split into two (for example 17 and 1 units of seedlings). The same holds for a product with a VAT-inclusive price: an olive tree charged at 36,47 € becomes a line of 33,15 € plus 3,32 € of VAT.
- A paid sale is always invoiced. If the pack was archived or ended its dates
after the payment, a line with
sold_onandunit_priceis still invoiced, with the current composition of the pack. - A sale at the offer price counts as sold with the offer. If the charged
unit_priceequals the offer price of that channel and date, the line keeps the offer. See Promotions. - Stock moves when the document type moves it, through the normal stock ledger. The pack itself has no stock.
Retries
Two mechanisms work together. The Idempotency-Key header covers the transport
retry of the same request. The external_id identifies the order itself and
never expires:
- With
payment,type: F2oroptions.register_verifactu, repeating the request with the sameexternal_idanswers200with the same invoice andIdempotent-Replayed: true, without a second invoice. A different total or type answers409 unattended_replay_mismatch. - Without them, a repeat updates the existing draft instead of creating another,
and if the document is no longer editable it answers with its
422status error.
Retry timeouts and 5xx with the same key and external_id. Do not retry a
4xx without fixing the request.
Predictable 422 errors
| Status | code | Cause |
|---|---|---|
| 404 | pack_not_found | The pack does not exist or belongs to another company |
| 422 | pack_not_sellable | Archived, inactive, out of its dates on sold_on or with an inactive component. It applies only to lines without unit_price, with the reason and param on the line |
| 422 | quantity | The quantity of packs is not an integer, is below 1, or a resulting line exceeds the limit of its column |
| 422 | pack_print_mode_not_allowed | The print mode is not valid for that document type |
| 422 | pack_lines_must_be_expanded | You sent lines of a pack built by yourself |
| 422 | simplified_invoice_not_allowed | A simplified invoice above its limit |
You cannot build pack lines yourself: send {pack_id, quantity} and the server
expands them. To edit a draft without losing its snapshot, send back
{pack_group_id, quantity}. Editing the price or the quantity of one component
line of a group answers 422 pack_line_locked.
Next steps
- Packs: views, synchronization and schema.org.
- Promotions and previous price: offers and the legal rules for announcing a reduction.
- Webhooks and Idempotency.