Factuarea APIDevelopers

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:

FieldProductPack
Identity and contentid, object, name, slug, previous_slugs, summary, description, seo, tags, currency, updated_atThe same
Price with and without VATsale_price{net, tax, gross}price{net, tax, gross}
Availabilityavailability{status, quantity?}The same
Imagesgallery[] with alt, width, heightcover and gallery[] with alt
Offerpromotionpromotion

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

  1. Create the credential. An API key live stored on your server with products:read, packs:read and, if you invoice, invoices:write.
  2. Publish the products. A product is not on the web until its channels.web is true, which is false by default, also for existing products, so that nothing is published without a decision. Publish one by one with PATCH …/products/{product} and channels: {web: true}, or in bulk with POST …/products/bulk-channels (see below). Publishing generates the slug when it is missing.
  3. Make the first copy. Page through GET …/products?view=storefront and GET …/packs?view=storefront with the cursor and store the results.
  4. Keep it up to date. Subscribe to the product.* and pack.* webhooks, including product.deleted and pack.deleted, and use view=all&updated[gte]=… to recover after an outage. A product that leaves the web shows up there with is_active: false or channels.web: false.
  5. 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" }
      }'
FieldWhat it does
external_idThe id of your order. It makes the invoice idempotent
sold_onThe day of the sale. Validity dates and offers are evaluated with it
unit_priceThe price charged, per pack or per unit. It wins over any current price or offer
prices_include_taxWhether that unit_price includes VAT. It works per line and, if omitted, the line takes the one of the document
channelweb so that an offer only for the web is applied. The default is documents
paymentMarks 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, sending unit_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_on and unit_price is 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_price equals 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: F2 or options.register_verifactu, repeating the request with the same external_id answers 200 with the same invoice and Idempotent-Replayed: true, without a second invoice. A different total or type answers 409 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 422 status error.

Retry timeouts and 5xx with the same key and external_id. Do not retry a 4xx without fixing the request.

Predictable 422 errors

StatuscodeCause
404pack_not_foundThe pack does not exist or belongs to another company
422pack_not_sellableArchived, 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
422quantityThe quantity of packs is not an integer, is below 1, or a resulting line exceeds the limit of its column
422pack_print_mode_not_allowedThe print mode is not valid for that document type
422pack_lines_must_be_expandedYou sent lines of a pack built by yourself
422simplified_invoice_not_allowedA 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

On this page

Need a hand?Contact support