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.
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. Both areON DELETE SET NULLserver-side, so deleting a brand or category does not cascade-delete the products that referenced it.
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 row per currency on a product (or per currency on a specific variant). Sale pricing is opt-in.
totalAmount— 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 one-time products and whenever no sale is active. See Subscription Rebills.
If
saleAmountis set andnow ∈ [saleStartsAt, saleEndsAt](bounds inclusive), thentotalAmountprice resolves 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.
Flat-price product
{
"currency": "USD",
"totalAmount": 49.99
}Black Friday sale (timed window)
{
"currency": "USD",
"totalAmount": 29.99,
"saleAmount": 29.99,
"originalAmount": 49.99,
"saleStartsAt": "2026-11-27T00:00:00Z",
"saleEndsAt": "2026-11-30T23:59:59Z"
}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.
Media
productMedia is a polymorphic table of catalog assets attached to either the product (variantId: null) or a specific variant.
- 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 does not decrement them at checkout, reserve units, or maintain a movement ledger — 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.
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.
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.
- Provider sync. Catalog fields are not pushed to Stripe or PayPal. Stripe
tax_codemapping, Stripeimages[]mirroring, and PayPal catalogue sync are tracked separately and will land in a follow-up release. - Product lifecycle webhooks.
product.created,product.updated, andproduct.deletedevents are not emitted. Today only purchase-side events exist. - 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": "one_time",
"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.