---
title: "Products"
description: "6 operations, with parameters and examples."
url: https://docs.fluveo.com/api/products/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/api/products.curl.md (cURL), https://docs.fluveo.com/api/products.node.md (Node.js), https://docs.fluveo.com/api/products.python.md (Python), https://docs.fluveo.com/api/products.php.md (PHP).

# Products

6 operations, with parameters and examples.

A Product is something the store sells, such as a T-shirt or a service. Its Prices say what it costs (see Prices). Products are catalog: they move no money, so every store has them in test mode and live mode from the start. Find products by their fields with the search route below. Each change is also sent as a product.created, product.updated or product.deleted event (see Events). An id you choose with id is unique within your store and mode, so two stores may each have a product called basic; an id Fluveo draws is prod_ and 24 characters. tax_code is checked only for its form (txcd_ and 8 digits) and never expands to an object. attributes is always empty, and tax_details names only the tax_code, with performance_location null. An update that changes nothing sends no product.updated and keeps updated as it was. The list refuses a limit outside 1 to 100 instead of moving it into range. created is not a search field. An image uploaded in the dashboard is kept as a Fluveo file and listed in images as a public file link URL, which loads without a key.

Send every request to `https://api.fluveo.com/v1` with your secret key as a Bearer token. This page lists only what Fluveo serves; [the OpenAPI file](https://docs.fluveo.com/openapi.json) holds the same operations.

An operation or a parameter marked **Preview** works, but it is not locked yet and may change: [what Preview means](https://docs.fluveo.com/api.md#preview).

> **For AI agents:**
>
> Use plain HTTP; install no payments SDK. Send `Authorization: Bearer sk_test_...` and a fresh `Idempotency-Key` on every POST; reuse the same key only when you retry that same POST. Send only the documented parameters. An operation or a parameter marked Preview may change: prefer the ones without the mark.

## Creates a product

`POST /v1/products`

Permission: [`rak_product_write`](https://docs.fluveo.com/permissions.md#product).

**Headers**

- `Idempotency-Key` (string, optional): Send a unique key, 1 to 255 characters, on every POST. A retry with the same key and the same body returns the first answer instead of acting twice (with Idempotent-Replayed: true); the same key with another body is refused. Keys are kept 7 days, except on POST /v1/checkout/sessions, POST /v1/checkout/sessions/{session} and POST /v1/webhook_endpoints, where a key holds 24 hours; webhook endpoint keys are also capped at the newest 32 per store and mode. Do not rely on a replay after that window. GET and DELETE ignore it.
- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

**Body parameters**

- `active` (boolean, optional, preview): false creates the product archived. Default true.
- `default_price_data` (object, optional, preview): A Price to create with the product, in the same write, which becomes its default_price: currency, and unit_amount, unit_amount_decimal or custom_unit_amount, with optional tax_behavior and metadata. currency_options is refused. A refusal names the field as default_price_data[...].
  - `currency` (string, optional)
  - `custom_unit_amount` (object, optional)
    - `enabled` (boolean, optional)
    - `maximum` (integer, optional)
    - `minimum` (integer, optional)
    - `preset` (integer, optional)
  - `metadata` (object, optional)
  - `tax_behavior` (enum, optional): One of: `exclusive`, `inclusive`, `unspecified`.
  - `unit_amount` (integer, optional)
  - `unit_amount_decimal` (string, optional)
- `description` (string, optional, preview): A longer text about the product, up to 40,000 characters. An empty value is refused with parameter_invalid_empty, and nothing is created. The same Idempotency-Key then works again with a text.
- `id` (string, optional, preview): Your own id for the product, 1 to 200 letters, digits, _ or -. It is unique within your store and mode only, so another store may use the same one. Leave it out and Fluveo draws prod_ and 24 characters. An id in use answers resource_already_exists; a deleted product's id may be used again.
- `images` (array of string, optional, preview): Up to 8 image URLs. An image uploaded in the dashboard is kept as a Fluveo file and listed here as a public file link URL (.../links/link_...), which loads without a key.
- `marketing_features` (array of object, optional, preview): Up to 15 selling points, each sent as marketing_features[0][name], up to 80 characters.
  - `name` (string, optional)
- `metadata` (object, optional): Up to 50 key-value pairs of your own, sent as metadata[key]=value: keys of 1 to 40 characters, values up to 500. Keys starting with fluveo_ are reserved. On an update, an empty value removes a key.
- `name` (string, required): The product's name, as buyers see it.
- `package_dimensions` (object, optional, preview): The packed product's height, length, weight and width, as numbers; all four are required.
  - `height` (number, optional)
  - `length` (number, optional)
  - `weight` (number, optional)
  - `width` (number, optional)
- `shippable` (boolean, optional, preview): true when the product is shipped to the buyer.
- `statement_descriptor` (string, optional, preview): Text on the buyer's card statement for this product, up to 22 characters, with at least one Latin letter and none of < > ' ". Only on a product of type service.
- `tax_code` (string, optional, preview): A tax code id, txcd_ and 8 digits. Fluveo checks only its form, not that the code exists, and never expands it.
- `type` (enum, optional, preview): service (the default) or good. statement_descriptor and unit_label are taken only on a service.

  One of: `good`, `service`.
- `unit_label` (string, optional, preview): The name of one unit of the product, such as seat, up to 12 characters. Only on a product of type service.
- `url` (string, optional): A public web page about the product.

Use only the documented body parameters. Send a new `Idempotency-Key` with each new request, and the same key again only to retry that request.

Returns a [`product`](https://docs.fluveo.com/api/products.md#object-product) object.

**Request**

cURL:

```bash
curl https://api.fluveo.com/v1/products \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-urlencode "default_price_data[currency]=usd" \
  --data-urlencode "default_price_data[unit_amount]=2000" \
  --data-urlencode "description=Cotton, size M" \
  --data-urlencode "name=T-shirt"
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products", {
  method: "POST",
  headers: {
    "Authorization": "Bearer sk_test_...",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: new URLSearchParams([["default_price_data[currency]","usd"],["default_price_data[unit_amount]","2000"],["description","Cotton, size M"],["name","T-shirt"]]),
});
console.log(await response.json());
```

Python:

```python
import uuid

import requests

response = requests.post(
    "https://api.fluveo.com/v1/products",
    headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": str(uuid.uuid4())},
    data=[("default_price_data[currency]", "usd"), ("default_price_data[unit_amount]", "2000"), ("description", "Cotton, size M"), ("name", "T-shirt")],
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "POST",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
        "Idempotency-Key: " . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => "default_price_data%5Bcurrency%5D=usd&default_price_data%5Bunit_amount%5D=2000&description=Cotton%2C%20size%20M&name=T-shirt",
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "active": true,
  "created": 1767225600,
  "default_price": "price_123",
  "id": "prod_123",
  "livemode": false,
  "metadata": {},
  "name": "T-shirt",
  "object": "product",
  "type": "service",
  "updated": 1767225600
}
```

## Lists product objects, newest first (Preview)

`GET /v1/products`

Permission: [`rak_product_read`](https://docs.fluveo.com/permissions.md#product).

**Preview:** this operation works, but it is not locked yet; it may change.

**Query parameters**

- `active` (boolean, optional): Only active products (true) or only archived ones (false).
- `created` (object, optional): Filter on the creation time, a Unix timestamp: created=<t> for an exact match, or created[gt], created[gte], created[lt], created[lte].
- `ending_before` (string, optional): Cursor: the id of the first object of the current page, to get the previous page. Do not send it with starting_after.
- `ids` (array of string, optional): Only the products with these ids, sent as ids[]=prod_...
- `limit` (integer, optional): How many objects to return, from 1 to 100. Default 10.
- `shippable` (boolean, optional): Only shippable products (true) or only the others (false).
- `starting_after` (string, optional): Cursor: the id of the last object of the previous page, to get the next page. Do not send it with ending_before.
- `type` (enum, optional): Only products of this type: good or service.

  One of: `good`, `service`.
- `url` (string, optional): Only the products with this url.

**Headers**

- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

Returns a list of [`product`](https://docs.fluveo.com/api/products.md#object-product) objects.

**Request**

cURL:

```bash
curl -G https://api.fluveo.com/v1/products \
  -H "Authorization: Bearer sk_test_..." \
  --data-urlencode "active=true" \
  --data-urlencode "limit=3"
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products" + "?" + new URLSearchParams([["active","true"],["limit","3"]]), {
  method: "GET",
  headers: {
    "Authorization": "Bearer sk_test_...",
  },
});
console.log(await response.json());
```

Python:

```python
import requests

response = requests.get(
    "https://api.fluveo.com/v1/products",
    headers={"Authorization": "Bearer sk_test_..."},
    params=[("active", "true"), ("limit", "3")],
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products?active=true&limit=3");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "GET",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
    ],
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "data": [
    {
      "active": true,
      "created": 1767225600,
      "default_price": "price_123",
      "id": "prod_123",
      "livemode": false,
      "metadata": {},
      "name": "T-shirt",
      "object": "product",
      "type": "service",
      "updated": 1767225600
    }
  ],
  "has_more": false,
  "object": "list",
  "url": "/v1/products"
}
```

## Updates a product (Preview)

`POST /v1/products/{id}`

Permission: [`rak_product_write`](https://docs.fluveo.com/permissions.md#product).

**Preview:** this operation works, but it is not locked yet; it may change.

Send a field empty to clear it: description, url, images, marketing_features, package_dimensions, statement_descriptor, unit_label, tax_code or shippable. metadata merges key by key: an empty value removes that key, and metadata= with no key empties it.

**Path parameters**

- `id` (string, required): The object's id.

**Headers**

- `Idempotency-Key` (string, optional): Send a unique key, 1 to 255 characters, on every POST. A retry with the same key and the same body returns the first answer instead of acting twice (with Idempotent-Replayed: true); the same key with another body is refused. Keys are kept 7 days, except on POST /v1/checkout/sessions, POST /v1/checkout/sessions/{session} and POST /v1/webhook_endpoints, where a key holds 24 hours; webhook endpoint keys are also capped at the newest 32 per store and mode. Do not rely on a replay after that window. GET and DELETE ignore it.
- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

**Body parameters**

- `active` (boolean, optional): false archives the product; true makes it active again.
- `default_price` (string, optional): The id of an active Price of this product, to make it the product's default price.
- `description` (string, optional): A longer text about the product, up to 40,000 characters. Empty clears it.
- `images` (array of string, optional): Up to 8 image URLs. An image uploaded in the dashboard is kept as a Fluveo file and listed here as a public file link URL (.../links/link_...), which loads without a key.
- `marketing_features` (array of object, optional): Up to 15 selling points, each sent as marketing_features[0][name], up to 80 characters.
  - `name` (string, optional)
- `metadata` (object, optional): Up to 50 key-value pairs of your own, sent as metadata[key]=value: keys of 1 to 40 characters, values up to 500. Keys starting with fluveo_ are reserved. On an update, an empty value removes a key.
- `name` (string, optional): The product's name, as buyers see it. It cannot be emptied.
- `package_dimensions` (object, optional): The packed product's height, length, weight and width, as numbers; all four are required.
  - `height` (number, optional)
  - `length` (number, optional)
  - `weight` (number, optional)
  - `width` (number, optional)
- `shippable` (boolean, optional): true when the product is shipped to the buyer.
- `statement_descriptor` (string, optional): Text on the buyer's card statement for this product, up to 22 characters, with at least one Latin letter and none of < > ' ". Only on a product of type service.
- `tax_code` (string, optional): A tax code id, txcd_ and 8 digits. Fluveo checks only its form, not that the code exists, and never expands it.
- `unit_label` (string, optional): The name of one unit of the product, such as seat, up to 12 characters. Only on a product of type service.
- `url` (string, optional): A public web page about the product. Empty clears it.

Use only the documented body parameters. Send a new `Idempotency-Key` with each new request, and the same key again only to retry that request.

Returns a [`product`](https://docs.fluveo.com/api/products.md#object-product) object.

**Request**

cURL:

```bash
curl https://api.fluveo.com/v1/products/prod_123 \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-urlencode "default_price=price_123" \
  --data-urlencode "metadata[sku]=TS-M"
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products/prod_123", {
  method: "POST",
  headers: {
    "Authorization": "Bearer sk_test_...",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: new URLSearchParams([["default_price","price_123"],["metadata[sku]","TS-M"]]),
});
console.log(await response.json());
```

Python:

```python
import uuid

import requests

response = requests.post(
    "https://api.fluveo.com/v1/products/prod_123",
    headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": str(uuid.uuid4())},
    data=[("default_price", "price_123"), ("metadata[sku]", "TS-M")],
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products/prod_123");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "POST",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
        "Idempotency-Key: " . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => "default_price=price_123&metadata%5Bsku%5D=TS-M",
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "active": true,
  "created": 1767225600,
  "default_price": "price_123",
  "id": "prod_123",
  "livemode": false,
  "metadata": {},
  "name": "T-shirt",
  "object": "product",
  "type": "service",
  "updated": 1767225600
}
```

## Retrieves a product (Preview)

`GET /v1/products/{id}`

Permission: [`rak_product_read`](https://docs.fluveo.com/permissions.md#product).

**Preview:** this operation works, but it is not locked yet; it may change.

**Path parameters**

- `id` (string, required): The object's id.

**Headers**

- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

Returns a [`product`](https://docs.fluveo.com/api/products.md#object-product) object.

**Request**

cURL:

```bash
curl https://api.fluveo.com/v1/products/prod_123 \
  -H "Authorization: Bearer sk_test_..."
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products/prod_123", {
  method: "GET",
  headers: {
    "Authorization": "Bearer sk_test_...",
  },
});
console.log(await response.json());
```

Python:

```python
import requests

response = requests.get(
    "https://api.fluveo.com/v1/products/prod_123",
    headers={"Authorization": "Bearer sk_test_..."},
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products/prod_123");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "GET",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
    ],
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "active": true,
  "created": 1767225600,
  "default_price": "price_123",
  "id": "prod_123",
  "livemode": false,
  "metadata": {},
  "name": "T-shirt",
  "object": "product",
  "type": "service",
  "updated": 1767225600
}
```

## Deletes a product that has no prices (Preview)

`DELETE /v1/products/{id}`

Permission: [`rak_product_write`](https://docs.fluveo.com/permissions.md#product).

**Preview:** this operation works, but it is not locked yet; it may change.

A product that has a price, archived or not, cannot be deleted: archive it with active=false instead. After the delete its id answers resource_missing, and you may choose it again for a new product. An Idempotency-Key is ignored here.

**Path parameters**

- `id` (string, required): The object's id.

**Headers**

- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

**Request**

cURL:

```bash
curl -X DELETE https://api.fluveo.com/v1/products/prod_123 \
  -H "Authorization: Bearer sk_test_..."
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products/prod_123", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer sk_test_...",
  },
});
console.log(await response.json());
```

Python:

```python
import requests

response = requests.delete(
    "https://api.fluveo.com/v1/products/prod_123",
    headers={"Authorization": "Bearer sk_test_..."},
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products/prod_123");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "DELETE",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
    ],
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "deleted": true,
  "id": "prod_123",
  "object": "product"
}
```

## Searches product objects with a query, newest first (Preview)

`GET /v1/products/search`

Permission: [`rak_product_read`](https://docs.fluveo.com/permissions.md#product).

**Preview:** this operation works, but it is not locked yet; it may change.

A query is one or more clauses, each a field, an operator and a quoted value, such as name~"shirt" or active:"true". The product fields are active, description, name, shippable, url and metadata["key"]; created is not one. The operator : matches a run of whole words of name, description or url, in order and in any case, and the whole value of active or shippable (true or false). The operator ~ finds a part of name, description or url of at least three characters; a shorter part matches nothing. A - before a clause negates it. field:null matches a field that is absent or empty, and -field:null one that is set. Join clauses with AND or OR (a space means AND), never both in one query and never with parentheses, up to 10 clauses. Quote values with single or double quotes; a backslash keeps the next character as it is. A query Fluveo cannot read answers invalid_request_error with the reason and its column. When has_more is true, send next_page as page with the same query for the next page. Add expand[]=total_count to get total_count, the number of matches. A product is found as soon as its write returns.

**Query parameters**

- `limit` (integer, optional): How many objects to return, from 1 to 100. Default 10.
- `page` (string, optional): The next_page of the previous answer, to get the page after it. Send the same query with it.
- `query` (string, required): The search query, in the language the description above gives.

**Headers**

- `Fluveo-Version` (enum, optional): Optional. The only version Fluveo serves is 2026-05-27.dahlia; any other value is refused with a 400. Every answer names that version in its own Fluveo-Version header, except the refusals Fluveo gives before it reads the request (no key, an unknown key, an unknown URL, a body that is not a form) and a file's bytes, which name no version and no Request-Id.

  One of: `2026-05-27.dahlia`.

Returns a list of [`product`](https://docs.fluveo.com/api/products.md#object-product) objects.

**Request**

cURL:

```bash
curl -G https://api.fluveo.com/v1/products/search \
  -H "Authorization: Bearer sk_test_..." \
  --data-urlencode "query=active:\"true\" AND name~\"shirt\""
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/products/search" + "?" + new URLSearchParams([["query","active:\"true\" AND name~\"shirt\""]]), {
  method: "GET",
  headers: {
    "Authorization": "Bearer sk_test_...",
  },
});
console.log(await response.json());
```

Python:

```python
import requests

response = requests.get(
    "https://api.fluveo.com/v1/products/search",
    headers={"Authorization": "Bearer sk_test_..."},
    params=[("query", "active:\"true\" AND name~\"shirt\"")],
)
print(response.json())
```

PHP:

```php
<?php
$ch = curl_init("https://api.fluveo.com/v1/products/search?query=active%3A%22true%22%20AND%20name~%22shirt%22");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => "GET",
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer sk_test_...",
    ],
]);
echo curl_exec($ch);
```

*Response (abridged example)*

```json
{
  "data": [
    {
      "active": true,
      "created": 1767225600,
      "default_price": "price_123",
      "id": "prod_123",
      "livemode": false,
      "metadata": {},
      "name": "T-shirt",
      "object": "product",
      "type": "service",
      "updated": 1767225600
    }
  ],
  "has_more": false,
  "next_page": null,
  "object": "search_result",
  "url": "/v1/products/search"
}
```

## The product object

Ids start with `prod_`.

**Attributes**

- `active` (boolean)
- `created` (integer)
- `default_price` (string, nullable, expandable): An id by default; send `expand[]=default_price` to get the full price object.
- `description` (string, nullable)
- `id` (string)
- `images` (array)
- `livemode` (boolean)
- `marketing_features` (array)
- `metadata` (object)
- `name` (string)
- `object` (enum): One of: `product`.
- `package_dimensions` (object, nullable)
- `shippable` (boolean, nullable)
- `statement_descriptor` (string, nullable)
- `tax_code` (string, nullable)
- `type` (enum): One of: `good`, `service`.
- `unit_label` (string, nullable)
- `updated` (integer)
- `url` (string, nullable)
