Developers › Docs › REST API
REST API
Publish, reprice and reconcile. Every endpoint, with request and response shapes.
Base URL: https://fluf.io/wp-json/fc/api/v1 · Auth: Authorization: Bearer fluf_pat_…
Create and edit products, publish and delist them, report sales, and read back state. The machine-readable contract is openapi.yaml; this page explains how to use it without guessing at fields or edge cases.
Authentication
Create a Personal Access Token in FLUF Connect → Developers, then send it on every API request:
Authorization: Bearer fluf_pat_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Tokens act as the seller account that created them. The plan decides what they may do: Seller is read-only and Pro and above can read and write. Plans and fees has the details. Keep tokens server-side and create a separate one for each integration.
| HTTP | Code | Meaning |
|---|---|---|
401 | varies | Missing, malformed, revoked or expired token |
403 | no_active_subscription | The token is valid but the FLUF plan has lapsed. Don't create a new token |
403 | api_read_only | The plan allows reads only. The body's upgrade names the plan that adds writes |
403 | api_not_in_plan | The plan has no API access |
Product identity: vid
Every item is addressed by a vid:
{channel}_{channel_product_id}_{variant}
Examples:
shopify_8123456789012_0
fluf_326428506_0
The trailing variant defaults to 0, so shopify_8123456789012 and shopify_8123456789012_0 identify the same product. Pass the vid returned by reads straight back to writes; do not split it on underscores, because some marketplace ids contain underscores.
Accounts and targets
Use GET /accounts before publishing. It returns the exact target ids this token can use.
curl "https://fluf.io/wp-json/fc/api/v1/accounts" \
-H "Authorization: Bearer fluf_pat_your_token"
{
"accounts": [
{
"id": "ebay:718",
"channel": "ebay",
"label": "your-ebay-handle",
"name": "eBay",
"currency": "GBP",
"store_url": "https://www.ebay.co.uk/usr/your-ebay-handle",
"auth_status": "valid",
"runs_in_browser": false
}
]
}
Send id back verbatim as a target's account. It is {channel}:{connection_id}; a seller with two eBay accounts has two different ids, and they are not interchangeable.
You may also send channel plus connection_id, but account is the safer default because it is exactly what the API returned.
runs_in_browser: true means the marketplace only accepts changes from the seller's own signed-in browser session. Writes to that account come back queued and complete once the FLUF extension (or, for some marketplaces, the FLUF mobile app) picks them up. Marketplaces that run in the seller's browser covers this in full.
POST /products
Creates one product from photos, plus whatever details you already have. AI fills in anything you leave out (title, description, brand, category, condition, size, colours and, if you don't send one, price) from the photos, exactly as the FLUF app does. The product isn't listed anywhere yet. Publish it with POST /items.
curl -X POST "https://fluf.io/wp-json/fc/api/v1/products" \
-H "Authorization: Bearer fluf_pat_your_token" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-product-8812" \
-d '{
"photos": ["https://example.com/img/8812-front.jpg", "https://example.com/img/8812-back.jpg"],
"title": "Vintage Levi'"'"'s 501 jeans",
"price": 42.00,
"sku": "LV-501-32",
"size": "W32 L32"
}'
| Field | Type | Notes |
|---|---|---|
photos | array | Required. 1–20 public http(s) image URLs, in display order. Or upload files as multipart photos[] |
title | string | Max 255 characters |
description | string | Plain text |
price | number | Major units. If sent, AI never changes it |
brand, category, condition, size, department | string | Free text. FLUF maps them to each marketplace's own values when you publish |
colours | array | e.g. ["Blue"] |
sku | string | Your stock code |
stock | integer | Default 1 |
cost | number | What you paid. Used by pricing rules, never shown to buyers |
weight | number | Package weight, in your store's unit |
ai | boolean | Default true. With false, nothing is generated and title and price are required |
Returns 201:
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"product": {
"title": "Vintage Levi's 501 jeans",
"description": "Classic straight-leg 501s in a mid-blue wash…",
"price": 42,
"currency": "GBP",
"sku": "LV-501-32",
"stock": 1,
"brand": "Levi's",
"category": "Jeans",
"condition": "Used - Good",
"size": "W32 L32",
"department": "Men",
"colours": ["Blue"],
"photos": ["https://fluf.io/wp-content/uploads/…/fc_external_….jpg"]
},
"ai_filled": ["description", "brand", "category", "condition", "department", "colours"]
}
ai_filled names the fields the AI supplied, so you can review them before publishing.
Send an Idempotency-Key header with a value unique to the product on your side. If a request times out and you retry with the same key within 24 hours, you get the first response back (200) instead of a second product.
A photo FLUF can't download fails the whole request with 422 (photo_unreachable or photo_not_an_image) and names the photo. Nothing is created from a partial set of photos.
| HTTP | Code | Meaning |
|---|---|---|
400 | missing_photos | No photos sent |
400 | too_many_photos | More than 20 |
400 | bad_field | A field has the wrong type, e.g. a negative price |
400 | missing_fields | "ai": false without title and price |
422 | photo_unreachable, photo_not_an_image, bad_photo_url | One photo couldn't be used. photo names it |
502 | ai_failed | AI couldn't read the photos. Send the fields yourself, or retry |
POST /products/import
Creates products in bulk from a CSV or Excel file. This is the same importer as Create → Upload CSV in FLUF, including AI for missing fields and photo URLs in cells. Runs in the background and returns an import id straight away.
curl -X POST "https://fluf.io/wp-json/fc/api/v1/products/import" \
-H "Authorization: Bearer fluf_pat_your_token" \
-F "[email protected]"
Or send the CSV text as JSON: {"csv": "title,price,photos\n…"} (up to 20 MB).
| Field | Notes |
|---|---|
file | Multipart upload: .csv, .txt, .xlsx or .xls. Max 5,000 rows |
csv | Alternative to file: the CSV as a string |
mapping | Optional. {fluf_field: "Column header"}. Omit it and FLUF matches columns itself; the response shows what it chose |
ai | Default true. With false, rows are imported exactly as given |
FLUF fields you can map: images, title, sku, description, category, department, brand, size, colours, condition, price, cost, stock, weight. Every row needs at least one photo URL. A row without one fails with the reason shown in its import status. An images cell can hold several URLs separated by | or ;. Columns you don't map are kept on each product as custom fields rather than thrown away.
Returns 202:
{
"import_id": "batch_66f0c1a2b3c4d5.12345678",
"status": "pending",
"total_rows": 120,
"mapping": { "title": "Item name", "price": "Price", "images": "Photo URLs" }
}
A row that matches a product you already have (same SKU or, with no SKU, the exact same title) updates that product rather than creating a duplicate. That makes it safe to import the same file again.
GET /products/import/{id}
Progress and per-row results. Poll until status is completed.
{
"import_id": "batch_66f0c1a2b3c4d5.12345678",
"status": "processing",
"total_rows": 120,
"processed_rows": 64,
"failed_rows": 1,
"rows": [
{ "row": 2, "status": "created", "vid": "fluf_326428506_0" },
{ "row": 3, "status": "failed", "error": "…the reason this row was skipped…" }
]
}
row is the line number in your file, counting the header as line 1. Row status is created, failed or processing. Rows not started yet aren't listed.
PATCH /items/{vid}
Edits the product and applies the change to every listing that's currently up. Send only the fields you're changing. Anything you leave out stays as it is.
curl -X PATCH "https://fluf.io/wp-json/fc/api/v1/items/fluf_326428506_0" \
-H "Authorization: Bearer fluf_pat_your_token" \
-H "Content-Type: application/json" \
-d '{
"title": "Vintage Levi'"'"'s 501 jeans — W32 L32",
"description": "Classic straight-leg 501s.\n\nShipped within 2 days from our London studio.",
"price": 39.00
}'
Accepts title, description, price, brand, category, condition, size, department, colours, sku, stock, weight and photos, with the same types as POST /products.
photosreplaces the product's photos, in the order you send them. To keep a current photo, send its URL back asGET /items/{vid}returned it. To add one, send the full list with the new URL included.pricehere is the product's own price. It changes on every listing. UsePOST /itemswith a targetpriceto change one account only.update_listings: falsechanges the product in FLUF without touching the live listings.
Returns 200:
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"updated": ["title", "description", "price"],
"product": { "title": "Vintage Levi's 501 jeans — W32 L32", "price": 39, "…": "…" },
"listings_queued": ["ebay", "depop"],
"listings": [ { "channel": "ebay", "state": "live", "…": "…" } ]
}
listings_queued names the marketplaces the change is being sent to. If the product is live in more than one of your accounts on the same marketplace, each of those listings is updated. On marketplaces that run in the seller's browser (runs_in_browser: true), only the product's main listing is updated for now. That happens in the background: each listing reports back as a listing.updated or listing.error webhook, and GET /items/{vid} shows the result. Some marketplaces limit what can change after listing. Vestiaire Collective, for example, only accepts price reductions.
POST /items
Per target, FLUF creates a listing if the item isn't listed there, reprices it if it is, or does nothing if nothing needs changing.
The product must already exist, either in FLUF or in a connected source store. A source-store vid that FLUF hasn't seen yet is imported on the first publish. This call can't create a product from raw fields or change its title, description or photos. See Where products come from.
curl -X POST "https://fluf.io/wp-json/fc/api/v1/items" \
-H "Authorization: Bearer fluf_pat_your_token" \
-H "Content-Type: application/json" \
-d '{
"vid": "shopify_8123456789012_0",
"targets": [
{ "account": "ebay:718", "price": 45.00 },
{ "account": "vinted:22", "price": 40.00 }
]
}'
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
vid | string | yes | Product id, e.g. shopify_8123456789012_0 or fluf_326428506_0 |
targets | array | yes | 1-20 target objects. Only named targets are touched |
targets[].account | string | usually | The id from GET /accounts, e.g. ebay:718 |
targets[].channel | string | alternative | Channel slug, used with connection_id when not sending account |
targets[].connection_id | integer | alternative | Marketplace connection id, used with channel |
targets[].price | number | no | Price for this account, in major units. Omit to leave the current override alone |
targets[].relist | boolean | no | Force a fresh listing in place of one already live. Default false |
Omitted targets are a no-op. If an item is live on eBay, Vinted and Etsy, and your request names only eBay, the Vinted and Etsy listings are left alone. Delisting is not implied by omission — use DELETE /items below.
Price overrides are scoped to {product, channel, account}. They do not change the product's own price, and they persist so future relists on that account use the same override.
relist: true only matters when the target is already live: a dead, sold, or removed listing is already treated as not-listed, so a plain POST /items without the flag republishes it. Use relist to reset a listing's position in a marketplace's search results without waiting for it to sell or expire. It is always asynchronous — the response reports outcome: "queued", never "ok" — and not every channel supports it; an unsupported channel returns outcome: "failed", code: "relist_not_supported".
Response body
{
"vid": "shopify_8123456789012_0",
"fluf_id": 326428506,
"results": [
{
"channel": "ebay",
"account": "ebay:718",
"operation": "create",
"outcome": "ok",
"listing_id": "123456789",
"message": "Listed at 45."
},
{
"channel": "vinted",
"account": "vinted:22",
"operation": "create",
"outcome": "queued",
"message": "Accepted. This channel publishes asynchronously — poll GET /items/{vid}."
}
]
}
The HTTP status is the overall result; results is per target.
| Status | Meaning |
|---|---|
201 | At least one listing was created |
200 | Applied, but nothing new was created: updates, no-ops, skipped targets, or per-target failures |
202 | At least one target was queued |
Per target, branch on outcome, not on message text.
| Field | Values |
|---|---|
operation | create, reprice, relist, noop |
outcome | ok, queued, failed, skipped |
queued is accepted work, not a failed publish. Some channels complete asynchronously; poll GET /items/{vid} until the listing appears or reports an error.
Warnings
A successful target may include warnings when the marketplace accepted the listing but not exactly as submitted:
{
"code": "brand_not_recognised",
"message": "Brand was listed as Unbranded.",
"context": { "requested": "Bape", "applied": "Unbranded" }
}
Known warning codes: brand_not_recognised, size_not_mapped, category_fallback, attribute_dropped, photos_reduced, description_truncated.
Surface warnings to the seller. They usually explain discoverability issues such as an unmapped size or a brand fallback.
POST errors
Request-level errors use a stable code and human message:
| HTTP | Code | Meaning |
|---|---|---|
400 | missing_targets | targets is missing or empty |
400 | too_many_targets | More than 20 targets were sent |
400 | missing_product_ref | vid is missing |
400 | bad_vid | vid is malformed |
401 / 403 | varies | Token or plan refused. See Authentication |
404 / 422 | product_not_found | The product cannot be resolved on this account |
409 | publish_in_progress | Another request is already publishing this product |
409 is a wait-and-retry condition. It exists to stop two concurrent requests creating duplicate marketplace listings.
Target-level failures stay inside results[] with outcome: "failed" and a target code. Common codes include bad_target, unknown_channel, channel_unavailable, update_failed, listing_failed, crosslist_failed, already_listed, and no_result.
DELETE /items
The reverse of POST /items: same {vid, targets} body, same additive rule applied the other way — only the accounts you name are taken down, and targets is required, so there is no "delist everywhere" shortcut.
curl -X DELETE "https://fluf.io/wp-json/fc/api/v1/items" \
-H "Authorization: Bearer fluf_pat_your_token" \
-H "Content-Type: application/json" \
-d '{
"vid": "fluf_326428506_0",
"targets": [
{ "account": "ebay:718" }
]
}'
Response shape matches POST /items, with operation: "delist" per target:
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"results": [
{
"channel": "ebay",
"account": "ebay:718",
"operation": "delist",
"outcome": "ok",
"listing_id": "123456789",
"message": "Delisted."
}
]
}
A target the product was never listed on returns outcome: "skipped" — nothing to take down is success, not an error. Status codes follow the same pattern as POST /items: 200 applied, 202 at least one delist was queued (extension-first channels), 400/401/404 as documented above.
DELETE /items/{vid}
Delete the product. Different action from DELETE /items (plural), which only takes listings down off channels and keeps the product.
FLUF takes the product down everywhere first and only deletes once every channel is confirmed down. If anything is still up — including a delist that is only queued on an extension-first channel like Vinted or Facebook — you get 409 with code: "still_listed" and a still_live array naming what is up, and nothing is deleted. Poll GET /items/{vid} until those clear, then repeat.
That ordering matters. Deleting the product while a listing is live orphans that listing, and the next inventory read can re-import it as a brand-new product and relist it.
curl -X DELETE "https://fluf.io/wp-json/fc/api/v1/items/fluf_326428506_0" \
-H "Authorization: Bearer fluf_pat_your_token"
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"deleted": true,
"delisted": [
{ "channel": "ebay", "account": "ebay:800", "listing_id": "1976…", "outcome": "ok" }
],
"still_live": []
}
Add ?force=true to delete regardless; the response then reports what was left live. Deletion is permanent.
POST /items/{vid}/mark-sold
Report a sale FLUF doesn't see — your own storefront, a phone sale. Zeroes stock and delists every channel the product is currently live on. There is no targets list here, unlike DELETE /items: a sale is singular, one unit is gone, so it cannot legitimately stay live anywhere else.
curl -X POST "https://fluf.io/wp-json/fc/api/v1/items/fluf_326428506_0/mark-sold" \
-H "Authorization: Bearer fluf_pat_your_token"
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"success": true,
"results": {
"ebay": { "success": true, "message": "Successfully marked as sold" },
"vinted": { "success": true, "message": "Successfully marked as sold" }
}
}
results is keyed by channel — one entry per account the product was live on. 409 not_listed means the product wasn't live anywhere, so there was nothing to mark sold.
GET /items
Returns a reconciliation page: products on your account, each with its current marketplace listing state.
curl "https://fluf.io/wp-json/fc/api/v1/items?limit=50" \
-H "Authorization: Bearer fluf_pat_your_token"
{
"items": [
{
"vid": "fluf_326428506_0",
"fluf_id": 326428506,
"title": "Vintage Levi's 501 jeans",
"sku": "LV-501-32",
"price": 42,
"listings": []
}
],
"has_more": true,
"next_cursor": "326428506"
}
| Parameter | Type | Notes |
|---|---|---|
limit | integer | 1-100, default 50 |
cursor | string | Opaque token from next_cursor; omit on the first page |
updated_since | RFC 3339 timestamp | Changed products only, max 30 days back |
Paginate until next_cursor is absent. Treat the cursor as opaque: it is not an offset or timestamp, and it carries the updated_since filter from page one.
Catching up
Use updated_since when your webhook endpoint was down, a consumer bug dropped events, or you never received a write response:
curl "https://fluf.io/wp-json/fc/api/v1/items?updated_since=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer fluf_pat_your_token"
It returns products that genuinely changed: sold, repriced, relisted, delisted, hidden, restocked, newly listed, or edited by the seller. Reindexing does not count. If you need more than 30 days, omit updated_since and sweep the full catalogue.
GET /items/{vid}
Returns one product and all marketplace listings FLUF can currently see for it. This is what you poll after POST /items returns 202.
curl "https://fluf.io/wp-json/fc/api/v1/items/shopify_8123456789012_0" \
-H "Authorization: Bearer fluf_pat_your_token"
{
"vid": "shopify_8123456789012_0",
"fluf_id": 326428506,
"title": "Vintage Levi's 501 jeans",
"sku": "LV-501-32",
"price": 42,
"listings": [
{
"channel": "ebay",
"account": "ebay:718",
"connection_id": 718,
"listing_id": "123456789",
"state": "live",
"listing_url": "https://www.ebay.co.uk/itm/123456789",
"price": 45,
"error": null,
"views": null,
"likes": null,
"sold": null,
"live_at": "2026-08-11T07:15:00Z",
"created_at": "2026-08-11T07:14:59Z",
"updated_at": "2026-08-11T07:15:00Z"
}
]
}
Item fields
| Field | Notes |
|---|---|
vid | Product id to send back to POST /items |
fluf_id | FLUF internal product id; informational |
title | Product title when the product record is still available |
sku | Seller SKU when available |
price | Product's own price in FLUF, not a per-account override |
photos | The product's photo URLs, in order. GET /items/{vid} only, not the GET /items sweep |
listings | One listing object per marketplace account where this product has state |
Listing fields
| Field | Notes |
|---|---|
channel | Marketplace slug |
account | {channel}:{connection_id} target id |
connection_id | Numeric account connection id |
listing_id | Marketplace's own listing id |
state | Public listing state |
listing_url | Buyer-facing URL when the channel exposes one |
price | Price on this account; may differ from the product price |
error | Present when state is error |
views | Lifetime views, or null when unknown |
likes | Favourites, or null when unknown |
sold | Sale info when FLUF can attribute a sale |
live_at | When the listing went live on the channel, when known |
created_at | When FLUF recorded the listing |
updated_at | Last recorded listing update |
Listing state values:
| State | Meaning |
|---|---|
live | Visible to buyers |
delisted | Taken down |
sold | Sold on this channel |
not_visible | Draft, hidden, or paused |
out_of_stock | Listed but unavailable |
pending | Being published |
error | Channel refused or could not complete the listing |
unknown | FLUF has no reliable state |
Accounts flagged runs_in_browser publish through the seller's own browser rather than directly, so a fresh listing can sit pending until that browser picks it up. This usually takes a few minutes, and longer if no signed-in session is open. A pending listing has no listing_id yet ("") and connection_id may read 0 even on a multi-account seller — neither is known until the channel confirms. Poll again once it resolves to live or error.
When present, sold contains order_id, sold_at, price, and currency. A sold block can appear while state is live if the item sold once and was relisted.
Integration behaviour
Publishing is per product, not per target: one request fans out to the target accounts you name.
Prices are decimal major units (45.00, not 4500). Use the account currency shown by GET /accounts.
Do not parse message; it is for humans and can change. Branch on HTTP status, top-level error code, target outcome, and target code.
Use webhooks for timely changes and GET /items?updated_since=… as the recovery path. The REST API is the source of current state; webhooks are delivery notifications.
Something missing? Email [email protected] — we prioritise by what people actually ask for.
