Product Catalog
Model your products in Flo with brand, category, variants, sale pricing, media, inventory hints, marketing attributes, and shipping specs.
Product Catalog
The product catalog is how you describe what you sell to Flo. A product captures the identifiers, pricing, media, and marketing fields needed to render listings, drive checkout, and feed downstream shopping channels. This guide walks through the catalog model end to end so you can build your integration directly against /v1/products.
Manage the catalog
Open the Dashboard's top-level Catalog section at Dashboard Catalog. Catalog is a primary navigation destination, not a page under Settings.
| Dashboard role | Access |
|---|---|
| Client owners and admins | Full CRUD for the active client. |
| Client member | Read-only access, including inactive records. Mutation controls are hidden, and the API still enforces the restriction. |
| FloPay superuser | Full CRUD only after selecting a client. Changing the selection changes the tenant scope; a previous client's catalog is never reused. |
The management experience covers the complete catalog surface. Follow these links for the existing field-level detail rather than treating the management UI as a separate data model:
| Resource | Field and behavior details |
|---|---|
| Products | Data model and full create example |
| Stable prices and scheduled sales | Pricing and sale windows |
| Variants | Variants |
| Media URLs | Media |
| Brands | Brand and category |
| Hierarchical categories | Brand and category |
| Collections | Collection assignment |
| Coupons | REST API reference |
The client and superuser APIs expose equivalent resource trees. Client paths accept Client Basic credentials or an OAuth bearer; bearer writes are role-checked. Every admin request requires the explicitly selected clientId:
| Resource | Client path | Superuser admin path |
|---|---|---|
| Products | /v1/products | /v1/admin/products?clientId={clientUuid} |
| Prices | /v1/products/{productUuid}/prices | /v1/admin/products/{productId}/prices?clientId={clientUuid} |
| Variants | /v1/products/{productUuid}/variants | /v1/admin/products/{productId}/variants?clientId={clientUuid} |
| Media URLs | /v1/products/{productUuid}/media | /v1/admin/products/{productId}/media?clientId={clientUuid} |
| Brands | /v1/products/brands | /v1/admin/products/brands?clientId={clientUuid} |
| Categories | /v1/products/categories | /v1/admin/products/categories?clientId={clientUuid} |
| Collections | /v1/collections | /v1/admin/collections?clientId={clientUuid} |
| Coupons | /v1/coupons | /v1/admin/coupons?clientId={clientUuid} |
See the generated REST API reference for authentication, request schemas, and every operation. Admin paths apply the same tenant ownership and validation rules as client paths.
Overview
The catalog organizes a product's surface area into seven concerns:
- Basic identifiers —
brand,category,taxCode,gtin. - Pricing — per-currency rows on the product (or variant), with optional sale windows.
- Variants — per-SKU rows under a product, each with their own attributes, prices, stock hint, and shipping dimensions.
- Media — images, video, 3D models, and documents, with a single primary image and explicit ordering.
- Inventory hints —
stockQuantityandavailabilityStatus. Hints only. Flo is not an inventory ledger (see Inventory — a hint, not a ledger). - Marketing attributes —
gender,ageGroup,tags,tangibility, plus content fields (longDescription,knowledgeBaseUrl). - Generic metadata — a
metadataJSONB column for anything that does not fit a typed field.
Every field outside of code, name, and type is optional. Start with the minimum and adopt the rest as your channels need them.
Data model at a glance
productPricerows belong to the product whenvariantIdisnull, or to a specific variant when set.productMediarows belong to the product whenvariantIdisnull, or to a specific variant when set.collectionProductis a many-to-many join betweenproductandcollection, written from the product side viacollectionIds.
Brand and category
Both productBrand and productCategory are first-class entities, scoped per client. The code field is unique per client on both.
- Endpoints:
/v1/products/brands,/v1/products/categories. - Categories are recursive. Each category has an optional
parentIdto model arbitrary-depth taxonomies (e.g. Apparel → Clothing → Dresses). The service rejects writes that would create a cycle. - Attach by id. Products link via
brandIdandcategoryIdon create and update. The API blocks deletion while a brand or category is assigned to any product; unassign it first. Deletion never cascades to a product.
Brand create
POST /v1/products/brands
{
"code": "acme",
"name": "Acme",
"description": "A leading manufacturer of widgets."
}Category create (nested)
POST /v1/products/categories
{
"code": "dresses",
"name": "Dresses",
"parentId": "0fbe7c6b-3d4f-4b53-9d8d-1c6c2e2d7a01"
}Variants
A variant represents a specific SKU under a product — for example, red / medium. Use variants when material, size, or colour differ between SKUs that share the same product page. For single-SKU products, the product row alone is enough; pricing, stock hint, and dimensions live directly on it.
- Endpoint:
/v1/products/{productUuid}/variants. - Each variant has its own
sku(unique per product),attributes(free-form JSON),stockQuantity,availabilityStatus, and shipping dimensions.
Variant create
POST /v1/products/{productUuid}/variants
{
"sku": "sku-001-red-m",
"name": "Red / Medium",
"attributes": {
"color": "red",
"size": "M"
},
"stockQuantity": 12,
"availabilityStatus": "in_stock",
"weightGrams": 250,
"lengthMm": 200,
"widthMm": 150,
"heightMm": 50
}Pricing and sale windows
productPrice is one stable row per currency on a product or a specific variant. Use the dedicated operations after product creation so changing one amount does not replace unrelated prices or change its ID:
| Operation | Client path |
|---|---|
| Create stable Product Price | POST /v1/products/{productUuid}/prices |
| Update stable Product Price | PATCH /v1/products/{productUuid}/prices/{priceUuid} |
| Delete Product Price | DELETE /v1/products/{productUuid}/prices/{priceUuid} |
| Reactivate Product Price | POST /v1/products/{productUuid}/prices/{priceUuid}/reactivate |
Price scope is part of its uniqueness rule:
-
For a base-product price, omit
variantIdon writes; reads returnvariantId: null. -
For a variant price, send that product's active
variantId. -
There can be only one active price for each product, optional variant, and case-insensitive currency tuple. For example, a product may have one base USD price and one USD price for each variant, but not two active base USD prices.
-
amount— base price in major units (e.g.49.99). -
saleAmount,saleStartsAt,saleEndsAt— optional. All three must be supplied together, andsaleEndsAtmust not precedesaleStartsAt. -
rebillAtSaleAmount— optional boolean, defaultfalse. Only meaningful for subscription products with an active sale:falsediscounts the first charge and renews at the fullamount;truekeeps the sale price for the subscription's lifetime. A no-op for item products and whenever no sale is active. See Subscription Rebills.
If
saleAmountis set andnow ∈ [saleStartsAt, saleEndsAt](bounds inclusive), theneffectiveAmountresolves tosaleAmount. Otherwise it resolves toamount.
This is the single sale-window rule Flo applies everywhere — the catalog read above, the checkout session response, and the amount the gateway charges all resolve a price the same way.
Create a base-product price
POST /v1/products/{productUuid}/prices
{
"currency": "USD",
"amount": 49.99
}The 201 Created response includes the server-generated stable ID. This example is abridged to the fields relevant to a later edit:
{
"id": "6f1d9f0c-3a91-4bd2-9b48-9f9d2a3b81a1",
"currency": "USD",
"amount": 49.99,
"status": "active",
"effectiveAmount": 49.99,
"variantId": null
}Update one amount without changing its ID
Send only the fields that change:
PATCH /v1/products/{productUuid}/prices/{priceUuid}
{
"amount": 44.99
}The response carries the same id; the ID remains stable and other price rows are untouched:
{
"id": "6f1d9f0c-3a91-4bd2-9b48-9f9d2a3b81a1",
"currency": "USD",
"amount": 44.99,
"status": "active",
"effectiveAmount": 44.99,
"variantId": null
}Create a scheduled sale for a variant
POST /v1/products/{productUuid}/prices
{
"currency": "USD",
"amount": 49.99,
"variantId": "91b8384d-9f18-46aa-9640-893a66a92457",
"saleAmount": 29.99,
"saleStartsAt": "2026-11-27T00:00:00Z",
"saleEndsAt": "2026-11-30T23:59:59Z",
"rebillAtSaleAmount": false
}The backward-compatible nested prices array on POST /v1/products and PATCH /v1/products/{productUuid} uses full-set replacement semantics. On update, omitted rows are removed and supplied rows may receive new IDs. Use it for the initial set or an intentional wholesale replacement; use the dedicated operations above for routine price management.
How an active sale surfaces in a checkout session
When a session is read back, the sale window is resolved for every line at the moment of the read. The resolution shows up in two places.
Per-line fields on each entry in products[]:
totalAmount— the resolved per-unit charge for the line. EqualssaleAmountwhile the window is live, otherwise the baseamount. Always present.saleAmount— the sale price in major units.originalAmount— the regularamount, supplied so you can render strikethrough pricing.saleStartsAt/saleEndsAt— the sale window boundaries.
The last four fields are omitted entirely when no sale is active for that line. Detect a sale by the presence of saleAmount, not by comparing totalAmount against a price you fetched separately.
Two more fields ride along on subscription lines so your UI can disclose the renewal price:
rebillAmount— the go-forward per-cycle renewal price in major units. Equals the fullamountwhen the sale is intro-only (rebillAtSaleAmount: false) and thesaleAmountwhen the sale is retained for life.rebillAtSaleAmount— echoes which rule applies (false= intro-only,true= retained for life).
Unlike the sale fields above, these two follow the product type rather than the sale window: they are present on every subscription line (even outside a sale, where rebillAmount equals the plain amount) and absent on one-time product lines. See Subscription Rebills.
Session-level totals are recomputed against the original prices:
| Field | Meaning |
|---|---|
subtotalAmount | Sum of the original (non-sale) prices, before any reduction. |
discountAmount | subtotalAmount − totalAmount. A single field that rolls up both sale savings and coupon reductions. |
totalAmount | Sum of the sale prices, minus any coupons — what the customer pays. |
When no line in the cart has an active sale, the totals are unchanged from prior behaviour: subtotalAmount is the plain sum of prices and discountAmount reflects coupon reductions only.
Worked example
Take the Black Friday price above (amount: 49.99, saleAmount: 29.99) on a single-unit cart with no coupon, read inside the sale window. The relevant slice of the checkout session response:
{
"subtotalAmount": 49.99,
"discountAmount": 20.00,
"totalAmount": 29.99,
"products": [
{
"code": "summer-dress-2026",
"quantity": 1,
"currency": "USD",
"totalAmount": 29.99,
"saleAmount": 29.99,
"originalAmount": 49.99,
"saleStartsAt": "2026-11-27T00:00:00Z",
"saleEndsAt": "2026-11-30T23:59:59Z"
}
]
}Sale pricing is charged, not just displayed. Every Stripe and PayPal path resolves the price through the same sale-window rule, so inside the window the customer is charged saleAmount and outside it amount — there is no separate "apply the sale" step on your side. For subscriptions the sale always governs the first charge; whether renewals stay on sale or revert to the full amount is decided by rebillAtSaleAmount (see Subscription Rebills).
Subscription Rebills
A sale window raises a question for subscription products that one-time products never face: once a customer subscribes at the sale price, does the next renewal keep that price or revert to the full one? The rebillAtSaleAmount boolean on the price row decides.
rebillAtSaleAmount | First charge | Every rebill (renewal) |
|---|---|---|
false (default) | saleAmount | full amount |
true | saleAmount | saleAmount |
The flag only matters while a sale is active. With no active sale — or on a one-time product — it is a no-op, and the first charge and every rebill are the plain amount.
false is the default because most promotions are intro offers: the sale discounts the first cycle to win the signup, and renewals bill at the standard price. Set true only when the sale price is meant to be permanent for everyone who subscribes during the window — this reproduces the pre-rebillAtSaleAmount behaviour exactly.
This resolves consistently across both gateways. Stripe holds the rebill amount on the recurring price and nets the first invoice back to the sale price with a one-time line item; PayPal models an intro-only sale as a sale-priced intro cycle followed by a full-price regular cycle.
Example — intro sale on a subscription
POST /v1/products
{
"code": "pro-plan",
"name": "Pro Plan",
"type": "subscription",
"prices": [
{
"currency": "USD",
"amount": 49.99,
"saleAmount": 29.99,
"saleStartsAt": "2026-11-27T00:00:00Z",
"saleEndsAt": "2026-11-30T23:59:59Z",
"rebillAtSaleAmount": false
}
]
}A customer who subscribes inside the window pays $29.99 on the first cycle, then $49.99 on every renewal. Flip rebillAtSaleAmount to true and they keep $29.99 for the life of the subscription.
Disclosing the renewal price at checkout
The checkout session response carries the go-forward price on every subscription line so your UI can disclose it before purchase (see How an active sale surfaces in a checkout session):
rebillAmount— the per-cycle renewal price in major units (49.99for the intro sale above;29.99whenrebillAtSaleAmountistrue).rebillAtSaleAmount— the flag itself, so the UI can label the line (for example, "$29.99 now, then $49.99 per renewal").
Rebill resolution applies to new subscriptions only. Subscription changes (upgrade or downgrade of an existing subscription) are out of scope and are not affected by rebillAtSaleAmount.
Trials
A subscription can open with a trial window before it starts billing on its normal interval. Two fields on the product (not the price row) control it:
| Field | Type | Default | Meaning |
|---|---|---|---|
recurringTrialPeriodDays | int | null | Length of the trial window, in days. The whole trial mechanism is a no-op unless this is greater than 0. |
recurringTrialPeriodDaysFree | bool | true | Whether the trial window is free. true (default) preserves the historical free-trial behaviour; false charges for the trial. Only meaningful when recurringTrialPeriodDays > 0. |
In both models the first recurring charge is deferred to the end of the trial window, and renewals then bill on the normal interval per Subscription Rebills.
Free trial (default)
With recurringTrialPeriodDaysFree left true, the trial window is free — nothing is charged at signup:
$0.00 for 7 days, then $39.99 every 28 days
POST /v1/products
{
"code": "pro-plan",
"name": "Pro Plan",
"type": "subscription",
"recurringTrialPeriodDays": 7,
"recurringTrialPeriodDaysFree": true,
"prices": [
{ "currency": "USD", "amount": 39.99 }
]
}If a sale is active, it discounts the first paid invoice — not the free window itself: the free trial runs at no charge, the first paid invoice at trial end charges saleAmount, and later renewals charge the full amount (when rebillAtSaleAmount is false).
Paid trial
With recurringTrialPeriodDaysFree: false, the buyer is charged for the trial window up front. The trial charge resolves through the same single sale-window rule used everywhere else — saleAmount when a sale is active (now ∈ [saleStartsAt, saleEndsAt]), otherwise amount:
$7 for 7 days, then $39.99 every 28 days
To charge a lower trial price than the rebill you must set an active sale window that covers signup — the sale price becomes the trial charge, while the full amount stays the rebill. To bill "$7 for 7 days, then $39.99 every 28 days", set amount: 39.99 and a saleAmount: 7.00 window:
POST /v1/products
{
"code": "pro-plan-paid-trial",
"name": "Pro Plan",
"type": "subscription",
"recurringTrialPeriodDays": 7,
"recurringTrialPeriodDaysFree": false,
"prices": [
{
"currency": "USD",
"amount": 39.99,
"saleAmount": 7.00,
"saleStartsAt": "2026-01-01T00:00:00Z",
"saleEndsAt": "2026-12-31T23:59:59Z",
"rebillAtSaleAmount": false
}
]
}A paid trial with no active sale charges the full amount. Without a sale window covering signup, recurringTrialPeriodDaysFree: false bills amount for the trial — i.e. "$39.99 for 7 days, then $39.99 every 28 days". The sale window is what makes the trial cheaper than the rebill. The trial charge and the go-forward rebill resolve independently: rebillAtSaleAmount still governs renewals (default false → renewals revert to the full amount).
Both gateways implement this identically: Stripe keeps the trial period so the first recurring charge is deferred, and adds the paid-trial charge as a line item on the first invoice; PayPal models it as a sale-priced trial billing cycle followed by the regular cycle.
How trials surface in a checkout session
A trial subscription line reports its trial pricing on the checkout session response (see How an active sale surfaces in a checkout session):
- Free trial → the line carries
overrideAmount: 0and is excluded from the session totals (subtotalAmount/totalAmount); nothing is charged today. - Paid trial → the resolved trial charge is baked into the line's
totalAmountand included in the session totals, exactly like any other charged line (overrideAmountcarries the same resolved trial charge).
overrideAmount here is computed by the backend — it is server-authoritative and is not a value you send when creating the session.
Managing the fields
Both fields are set on POST /v1/products and PATCH /v1/products/{productUuid}, and echoed back on product reads. recurringTrialPeriodDaysFree always reads back as a concrete boolean (defaulting to true); recurringTrialPeriodDays reads back as null when no trial is configured.
Lifecycle and deletion
A valid new product is active immediately; there is no draft state. Lifecycle status is separate from stockQuantity and availabilityStatus: an active product can be out of stock, while an inactive product is unavailable for new commercial use regardless of its inventory hint.
Products, variants, prices, and coupons support active and inactive. Any persisted checkout line or session, subscription, invoice, transaction, or transaction-linked commercial record counts as historical use. The service—not the caller—checks that history and selects the delete outcome:
- An unused record is hard-deleted. Its code, SKU, ID tuple, or other unique identity is released where applicable.
- A historically used record is inactivated so existing snapshots and references remain valid.
Use the lifecycle-aware operations:
| Resource | Conditional delete | Reactivate |
|---|---|---|
| Product | DELETE /v1/products/{productUuid}?lifecycle=true | POST /v1/products/{productUuid}/reactivate |
| Variant | POST /v1/products/{productUuid}/variants/{variantUuid}/lifecycle/delete | POST /v1/products/{productUuid}/variants/{variantUuid}/reactivate |
| Price | DELETE /v1/products/{productUuid}/prices/{priceUuid} | POST /v1/products/{productUuid}/prices/{priceUuid}/reactivate |
| Coupon | DELETE /v1/coupons/{couponUuid}?lifecycle=true | POST /v1/coupons/{couponUuid}/reactivate |
Include lifecycle=true when deleting products or coupons through the client API. Omitting it retains the legacy 204 soft-delete behavior. Dedicated price deletion is conditional by default; variants use the explicit lifecycle action shown above.
Conditional delete responses
Deleting an unused product returns an explicit hard-delete result:
DELETE /v1/products/{productUuid}?lifecycle=true
{
"id": "3c0c1f14-cc71-4dd3-85df-f4cfec5a5a40",
"resourceType": "product",
"outcome": "hard_deleted",
"status": null
}Deleting a historically used product through the same operation returns the inactivation outcome instead:
{
"id": "3c0c1f14-cc71-4dd3-85df-f4cfec5a5a40",
"resourceType": "product",
"outcome": "inactivated",
"status": "inactive"
}Inactive records remain visible and readable to Dashboard bearer and admin callers. They are included in lists by default; use status=active or status=inactive to filter products, variants, prices, and coupons. Legacy Client Basic list behavior remains active-only by default.
Inactive records continue to reserve their identity and preserve history. They cannot enter a new checkout or other new commercial record. Reactivation re-runs current ownership, product-type, active-parent, SKU/code, and price-tuple validation, so it can return a validation or conflict error.
Reactivation
POST /v1/products/{productUuid}/reactivateAn abridged successful response shows the same identity active again:
{
"id": "3c0c1f14-cc71-4dd3-85df-f4cfec5a5a40",
"code": "summer-dress-2026",
"status": "active"
}A product must have a valid active price before reactivation. A variant or price also requires its parent product to be active. Reactivate parent resources before their children.
Brands, categories, and collections do not use historical inactivation. They must be unassigned from every product—including inactive products—before deletion, then they are hard-deleted. Media URLs can be hard-deleted normally because the stored historical commercial record does not depend on removing a hosted asset.
Media
productMedia is a polymorphic table of catalog assets attached to either the product (variantId: null) or a specific variant.
Phase 1 manages media URLs only. Flo does not upload or host images, videos, models, or documents. Deleting a media row removes the catalog URL; it does not delete the asset from your host.
- Endpoint:
/v1/products/{productUuid}/media. typeis one ofimage,video,model_3d,document.isPrimarymarks the lead asset. At most one image per product (or per variant) can beisPrimary: true.positioncontrols display order, ascending. Lower values render first.altTextis recommended for accessibility onimageandvideotypes.
Knowledge-base content for LLM consumption is not a media row. It lives on the product itself as longDescription (long-form text) and knowledgeBaseUrl (link to an external knowledge base).
Media create
POST /v1/products/{productUuid}/media
{
"type": "image",
"url": "https://cdn.example.com/products/pro-plan/hero.jpg",
"altText": "Front view of the Pro Plan kit",
"position": 0,
"isPrimary": true
}Inventory — a hint, not a ledger
stockQuantity and availabilityStatus are client-supplied hints. Flo surfaces them on reads so you can render availability in listings and checkout UIs. Flo is not an inventory ledger, reservation system, or oversell control: it does not decrement stock at checkout or reserve units. The client remains the system of record for inventory.
Concretely, Flo deliberately does not:
- Atomically decrement
stockQuantitywhen a checkout session is processed. - Block authorization based on stock availability.
- Expose a reservation or hold API.
- Broadcast
stock.*events.
The rationale: payment platforms that try to be inventory systems end up incorrect. Stock decisions depend on your own warehouse, drop-shipper, marketplace listings, refund flow, and human reconciliation — not on whether a card auth cleared. If a sale should be blocked because stock has run out, make that decision on your side before opening the checkout session.
Attributes and shipping specs
Two distinct buckets carry the long tail of product spec data.
Typed shipping dimensions live on dedicated columns so they can drive freight integrations later:
weightGrams(integer)lengthMm,widthMm,heightMm(integers)
Everything else lives in the free-form attributes JSON map on the product (or on a variant). Use this for ingredients, energy rating, fabric, technical specs, etc.
Recommended key conventions
- Use snake_case keys (
energy_rating,fabric_blend,ingredients). - Use ISO units where applicable (
volume_ml,power_watts). - Prefer flat keys over deeply nested objects — they index and filter better downstream.
{
"attributes": {
"energy_rating": "A+",
"fabric_blend": "60% cotton / 40% polyester",
"ingredients": ["water", "sodium chloride", "potassium sorbate"],
"voltage_v": 110
}
}Marketing fields
| Field | Purpose | Notes |
|---|---|---|
gtin | GTIN / UPC / EAN / ISBN | Must be 8, 12, 13, or 14 digits. Required by most ad platforms for shopping feeds. |
gender | Target audience demographic | Free-form string (e.g. male, female, unisex). |
ageGroup | Target audience age group | Free-form string (e.g. adults, kids, infant). |
tags | Free-form tags for filtering | Array of strings, GIN indexed. Drives any-of filters on the list endpoint. |
tangibility | digital or physical | Drives downstream tax and shipping behaviour where applicable. |
taxCode | Provider-neutral tax code | Stored as-is. Provider mapping is out of scope for v1 (see What's not in scope). |
Shopping ad platforms typically require some subset of gtin, brand, gender, ageGroup, and tags. Populate the ones your channels need; everything is optional.
taxCode is descriptive catalog data. Flo stores and returns the value but does not calculate tax in this catalog-management scope; calculate and apply tax in your own tax or commerce system.
Generic metadata
The metadata JSONB column is the home for anything that does not fit a typed field. When you need to stash something that the typed schema does not model, put it in metadata.
{
"metadata": {
"merchant_internal_id": "WHS-998877",
"campaign": "spring-26"
}
}The catalog never inspects metadata. It is round-tripped verbatim on reads.
Collection assignment from product create / update
POST /v1/products and PATCH /v1/products/{productUuid} accept an optional collectionIds: string[] to manage collection membership inline. The semantics are full-set replacement:
| Value | Effect |
|---|---|
undefined (field omitted) | Leave collection membership untouched. |
[] (empty array) | Remove the product from every collection. |
[a, b] | After the write the product belongs to exactly a and b — any prior membership not in the list is removed. |
The replacement runs in the same transaction as the product write. If any supplied collection id does not exist for the client, the request fails with 400 Bad Request and the entire write is rolled back.
Product update — replace collection membership
PATCH /v1/products/{productUuid}
{
"collectionIds": [
"fa9a8b6f-1f6c-44fa-9c1b-19c84a0a9e21",
"0a3a7e4c-cc8e-4d20-9a4c-2c2f4f5d1c8d"
]
}To remove the product from every collection: { "collectionIds": [] }. To leave membership untouched: omit the field.
API errors and troubleshooting
The generated REST API reference is authoritative for schemas and operation responses. These are the catalog failures most likely to require a caller action; quoted messages match the backend contract, with {...Uuid} standing in for the actual UUID.
| Situation | API result | Resolution |
|---|---|---|
| Member attempts a write | 403 Forbidden — "Client members have read-only catalog access." | Switch to an owner/admin membership or keep the flow read-only. Hiding a Dashboard control does not replace backend authorization. |
| Cross-client resource access | 404 Not Found — for example, "Product {productUuid} not found" | Confirm the bearer or Basic credential's client, or the superuser's selected clientId. Flo does not expose another client's resource. |
| Duplicate active currency price | 409 Conflict — "An active base price already exists for currency USD." | Update the existing row, choose another currency/scope, or inactivate it before creating a replacement. A variant conflict names variant {variantUuid} instead of base. |
| Price uses another product's variant | 400 Bad Request — "Variant does not belong to product {productUuid}." | Fetch the variants under this product and send one of those active IDs. |
| Item has recurring fields | 400 Bad Request — "Item products cannot include recurring configuration." | Remove recurring interval and trial fields, or change the product type. |
| Subscription is incomplete | 400 Bad Request — "Subscription products require recurringInterval and recurringIntervalCount." | Supply both recurring fields with a coherent subscription configuration. |
| Assigned organizational resource deletion | 409 Conflict — "Brand {brandUuid} is assigned to a product.", "Category {categoryUuid} is assigned to a product.", or "Collection {collectionUuid} is assigned to a product." | Find every assigned product, including inactive records, unassign the resource, and retry. |
| Reactivation conflict | 409 Conflict — for a price, "An active base price already exists for currency USD." | Resolve the code, SKU, or active price-tuple conflict, then retry reactivation. Parent and recurring validation may instead return 400 Bad Request. |
Inactive resource at checkout
New checkout selection rejects inactive products, variants, prices, and coupons with 422 Unprocessable Entity. The top-level message is "Checkout session contains invalid catalog entries."; inspect issues[] for the affected field and identity.
For a session created as a shell, this validation runs when the cart is attached — on PATCH /v1/checkouts/sessions/{id}/claim rather than on the create. The status, message and issues[] shape are identical; only the timing differs. See Session Creation.
An inactive product uses the canonical deleted_product issue code for backward compatibility:
{
"statusCode": 422,
"error": "Unprocessable Entity",
"message": "Checkout session contains invalid catalog entries.",
"issues": [
{
"code": "deleted_product",
"field": "products",
"value": "summer-dress-2026",
"message": "Product \"summer-dress-2026\" is inactive and cannot be selected."
}
]
}Inactive coupons use deleted_coupon with field: "couponCodes". An inactive variant also reports deleted_product; when only the price is inactive or missing, the issue message says the product has no active price for the selected currency and catalog entry.
What's not in scope
These items are intentionally not part of the v1 catalog. They are listed here so integrators can plan around them rather than file tickets asking for them.
- Hosted media uploads. Catalog management stores media URLs but does not upload, host, or delete the underlying assets.
- CSV import/export. Bulk file workflows are not part of catalog management in this phase.
- Lifecycle webhooks.
product.created,product.updated, andproduct.deletedevents are not emitted. Today only purchase-side events exist. - Commerce connector setup. Shopify, WooCommerce, and other connector configuration is handled separately.
- SDK wrappers. Catalog management uses the Dashboard or REST API; dedicated SDK helpers are not included here.
- Provider sync. Catalog fields are not pushed to Stripe or PayPal. Stripe
tax_codemapping, Stripeimages[]mirroring, and PayPal catalogue sync are tracked separately. - Inventory reservations or decrements. Covered above under Inventory — a hint, not a ledger. Flo does not hold stock, decrement at checkout, or expose a reservation API.
Recommended build order
A pragmatic order for getting a catalog into Flo:
- Create the brands and categories your products belong to.
- Create products with the typed fields you have (
brandId,categoryId,gtin,tags). - Attach
productMediarows for images. Promote one image per product withisPrimary: true. - Add variants only when SKUs differ on size / colour / material. Single-SKU products do not need them.
- Layer in pricing windows (
saleAmount+saleStartsAt+saleEndsAt) as your promotion calendar requires.
Full product create — worked example
POST /v1/products
{
"code": "summer-dress-2026",
"name": "Summer Dress 2026",
"description": "Lightweight summer dress in three colours.",
"type": "item",
"brandId": "9a1f2b3c-4d5e-6f70-8081-9a2b3c4d5e6f",
"categoryId": "0fbe7c6b-3d4f-4b53-9d8d-1c6c2e2d7a01",
"taxCode": "txcd_99999999",
"gtin": "00012345678905",
"gender": "female",
"ageGroup": "adults",
"tags": ["new", "summer"],
"tangibility": "physical",
"longDescription": "A lightweight, breathable summer dress made from a cotton-polyester blend. Available in three colours and four sizes...",
"knowledgeBaseUrl": "https://kb.example.com/products/summer-dress-2026",
"stockQuantity": 200,
"availabilityStatus": "in_stock",
"weightGrams": 250,
"lengthMm": 400,
"widthMm": 300,
"heightMm": 20,
"attributes": {
"fabric_blend": "60% cotton / 40% polyester",
"care": "machine wash cold"
},
"metadata": {
"campaign": "spring-26"
},
"prices": [
{
"currency": "USD",
"amount": 49.99
}
],
"collectionIds": [
"fa9a8b6f-1f6c-44fa-9c1b-19c84a0a9e21"
]
}Variants, media, brand, and category are managed via their own endpoints — see the sections above. Once the product exists, attach variants with POST /v1/products/{productUuid}/variants and media with POST /v1/products/{productUuid}/media.
Related guides
- Checkout Session Token — how the checkout session references products via
products: [{ code, totalAmount }]. - Error Handling — the
FloPayErrorshape returned when catalog writes fail validation.