DevelopersDocs › 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.

HTTPCodeMeaning
401variesMissing, malformed, revoked or expired token
403no_active_subscriptionThe token is valid but the FLUF plan has lapsed. Don't create a new token
403api_read_onlyThe plan allows reads only. The body's upgrade names the plan that adds writes
403api_not_in_planThe 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"
  }'
FieldTypeNotes
photosarrayRequired. 1–20 public http(s) image URLs, in display order. Or upload files as multipart photos[]
titlestringMax 255 characters
descriptionstringPlain text
pricenumberMajor units. If sent, AI never changes it
brand, category, condition, size, departmentstringFree text. FLUF maps them to each marketplace's own values when you publish
coloursarraye.g. ["Blue"]
skustringYour stock code
stockintegerDefault 1
costnumberWhat you paid. Used by pricing rules, never shown to buyers
weightnumberPackage weight, in your store's unit
aibooleanDefault 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.

HTTPCodeMeaning
400missing_photosNo photos sent
400too_many_photosMore than 20
400bad_fieldA field has the wrong type, e.g. a negative price
400missing_fields"ai": false without title and price
422photo_unreachable, photo_not_an_image, bad_photo_urlOne photo couldn't be used. photo names it
502ai_failedAI 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).

FieldNotes
fileMultipart upload: .csv, .txt, .xlsx or .xls. Max 5,000 rows
csvAlternative to file: the CSV as a string
mappingOptional. {fluf_field: "Column header"}. Omit it and FLUF matches columns itself; the response shows what it chose
aiDefault 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.

  • photos replaces the product's photos, in the order you send them. To keep a current photo, send its URL back as GET /items/{vid} returned it. To add one, send the full list with the new URL included.
  • price here is the product's own price. It changes on every listing. Use POST /items with a target price to change one account only.
  • update_listings: false changes 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

FieldTypeRequiredNotes
vidstringyesProduct id, e.g. shopify_8123456789012_0 or fluf_326428506_0
targetsarrayyes1-20 target objects. Only named targets are touched
targets[].accountstringusuallyThe id from GET /accounts, e.g. ebay:718
targets[].channelstringalternativeChannel slug, used with connection_id when not sending account
targets[].connection_idintegeralternativeMarketplace connection id, used with channel
targets[].pricenumbernoPrice for this account, in major units. Omit to leave the current override alone
targets[].relistbooleannoForce 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.

StatusMeaning
201At least one listing was created
200Applied, but nothing new was created: updates, no-ops, skipped targets, or per-target failures
202At least one target was queued

Per target, branch on outcome, not on message text.

FieldValues
operationcreate, reprice, relist, noop
outcomeok, 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:

HTTPCodeMeaning
400missing_targetstargets is missing or empty
400too_many_targetsMore than 20 targets were sent
400missing_product_refvid is missing
400bad_vidvid is malformed
401 / 403variesToken or plan refused. See Authentication
404 / 422product_not_foundThe product cannot be resolved on this account
409publish_in_progressAnother 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"
}
ParameterTypeNotes
limitinteger1-100, default 50
cursorstringOpaque token from next_cursor; omit on the first page
updated_sinceRFC 3339 timestampChanged 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

FieldNotes
vidProduct id to send back to POST /items
fluf_idFLUF internal product id; informational
titleProduct title when the product record is still available
skuSeller SKU when available
priceProduct's own price in FLUF, not a per-account override
photosThe product's photo URLs, in order. GET /items/{vid} only, not the GET /items sweep
listingsOne listing object per marketplace account where this product has state

Listing fields

FieldNotes
channelMarketplace slug
account{channel}:{connection_id} target id
connection_idNumeric account connection id
listing_idMarketplace's own listing id
statePublic listing state
listing_urlBuyer-facing URL when the channel exposes one
pricePrice on this account; may differ from the product price
errorPresent when state is error
viewsLifetime views, or null when unknown
likesFavourites, or null when unknown
soldSale info when FLUF can attribute a sale
live_atWhen the listing went live on the channel, when known
created_atWhen FLUF recorded the listing
updated_atLast recorded listing update

Listing state values:

StateMeaning
liveVisible to buyers
delistedTaken down
soldSold on this channel
not_visibleDraft, hidden, or paused
out_of_stockListed but unavailable
pendingBeing published
errorChannel refused or could not complete the listing
unknownFLUF 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.

Scroll to Top