> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.fluveo.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.fluveo.com/_mcp/server.

# Products

> **Status: `contracted_test`.** In the machine-checked test-mode contract
> (`openapi.subset.json`) and the [API Reference](/api-reference): create,
> retrieve, list, update and delete. Served by the `fluveo-billing` sidecar
> (ADR 0019); create/retrieve/list promoted 2026-07-13, update and delete
> 2026-09-15. The `Stripe-Version` header is optional. See
> [API coverage](/api-coverage).

A Product describes something you sell — a plan, a SKU, a service. A product on its
own has no price; you attach one or more [prices](/billing/prices) to it. Products are
the catalog root for [invoices](/billing/invoices) and [subscriptions](/billing/subscriptions).

Served operations: **create**, **retrieve**, **list**, **update** and
**delete** — all five contracted in test mode. Search is not served.

Create and update both take `name`, `description`, `active`, `metadata`,
`statement_descriptor`, `unit_label`, `url`, `shippable`, `tax_code`, `type`,
`package_dimensions`, `images` and `marketing_features`. On update, an
explicitly empty value **clears** a nullable field and an omitted one leaves it
alone — the difference between `description=` and not sending `description` at
all.

## Images

`images` takes up to 8 entries, each either a `file_…` id or an absolute
`https://` URL, and the first is the one dashboards and checkout surfaces draw.

What you store is the **id**; what a read returns is a URL composed from it. A
URL stored at write time dies when the environment changes — a product created
in a sandbox would carry a dead link in production — and a presigned storage URL
expires in minutes, so it was never a durable value.

```bash
curl https://api.devfluveo.com/v1/products \
  -u "$FLUVEO_SECRET_KEY:" \
  -d name="Running shoe" \
  -d "images[0]"=file_4b23e707fcc06465594f9c71
```

The Fluveo dashboard uploads images for you — JPEG, PNG or WEBP up to 2 MB — and
stores the id on the product. There is no public upload for this purpose yet:
`POST /v1/files` proxies to the file service's dispute-evidence door, which
accepts `dispute_evidence` and nothing else. From the API you pass an absolute
`https://` URL, which is stored and returned verbatim.

`images=` clears the list; omitting it leaves it alone.

A product and its first price can be created in one call: `default_price_data`
mints the price alongside the product and points `default_price` at it, so
there is no window where the product exists and the price does not. On update,
`default_price` takes the id of an existing price of that product; a price
belonging to another product, or an archived one, is refused.

Delete removes a product that has no prices. A product any price references is
refused — *"a product with prices cannot be deleted; archive it instead"* —
because deleting it would leave those prices pointing at nothing. Archiving is
`active=false`, and it is what you want in almost every case: archived products
stay readable and keep their prices. A delete emits `product.deleted`.

Lists honor `limit` (1–100, default 10), `starting_after`, `ending_before`,
`active`, `created` and `ids[]`, ordered newest-first. `metadata` is returned on
every read, and the product object carries the full Stripe key set — fields
Fluveo does not populate yet are `null` rather than absent, so the shape does
not change as they land.

`expand`, `id` and `tax_details` are recognised and rejected by name rather
than silently ignored, as is any unknown parameter.