FlowStock API v2

Base URL: https://dev2025.flowstock.pro/api/v2 · 300 req/min · default page size 20 (max 100)

Getting Started

FlowStock's REST API (v2) lets you read and write products, purchase orders, warehouse stock, tasks and users for a single shop.

This page is documentation only. It is served from a different host than the API itself. Every request you actually make goes to the base URL below — not to the domain this page is on.

Base URL: https://dev2025.flowstock.pro/api/v2

All endpoints are versioned under /api/v2. There is no self-service signup: a FlowStock admin creates an API user for your shop from the admin panel (Shop → API Users), which is where you get your X-Api-Key / X-Api-Email pair and choose which scopes it can use.

Boolean query-string filters take 0 or 1 — e.g. ?active=0, ?discontinued=1. Query strings are always plain text, and true/false as text is rejected (422 VALIDATION_ERROR), not treated as a boolean. This only applies to filters in the URL; a boolean field inside a JSON request body (e.g. {"enabled": true}) uses a real JSON boolean as normal.

Every response carries an X-Api-Version: 2 header and echoes back an X-Request-Id header (send your own via X-Request-Id to correlate a request with your own logs, or let the API generate one for you).

Responses are always JSON. A successful response wraps its payload in a top-level data key; list endpoints add a meta key with pagination info. Errors always look like:

{ "error": { "code": "NOT_FOUND", "message": "Product not found." } }

See Errors for the full list of error codes.

Authentication

Every request must include two headers:

X-Api-Key: fs_<random>
X-Api-Email: you@example.com

The key always starts with fs_. Both headers are required on every request — there is no session, cookie, or bearer-token alternative for this API.

A locked-out or invalid attempt never reveals which part was wrong (whether the email exists, or the key format vs. the key value) — you'll get one of the codes below either way.

Code Status Meaning
MISSING_CREDENTIALS 401 One or both headers were not sent.
INVALID_CREDENTIALS 401 The key or email did not match an active API user.
API_KEY_EXPIRED 401 The key's expiry date has passed.
TOO_MANY_FAILED_ATTEMPTS 429 5 failed attempts for this email+IP within the lockout window — wait before retrying, even with correct credentials.

Scopes

Each API user is granted one or more scopes by the admin who creates it. A request missing the scope its endpoint requires gets 403 INSUFFICIENT_SCOPE. One endpoint, GET /products/{productCode}/warehouses, requires two scopes at once (products.read + warehouses.read) — every other endpoint needs only the single scope listed next to it below.

Scope Grants
products.read Read products, variations, barcodes, stock events.
products.write Update backorder status, manage barcodes, trigger a catalog resync.
purchase_orders.read List and view purchase orders.
purchase_orders.write Create/update purchase orders and their product lines, import, change status.
warehouses.read List warehouses and read warehouse stock.
tasks.read List and view tasks.
tasks.write Create and update tasks.
users.read Look up users of this shop, for task assignment.

Rate Limiting

Requests are rate-limited per API user (not per IP), so multiple integrations sharing an office network never interfere with each other. Going over the limit returns 429 RATE_LIMIT_EXCEEDED.

Idempotency is required on every POST — see the Idempotency section.

Limit Applies to
300 requests / minute Every authenticated request.

Idempotency

Every POST request must include an X-Idempotency-Key header (1–255 characters, [a-zA-Z0-9\-_:.]). GET, PATCH and DELETE don't need one — they're treated as inherently safe to retry.

  • Missing key → 400 MISSING_IDEMPOTENCY_KEY.
  • Malformed key → 400 INVALID_IDEMPOTENCY_KEY.
  • Reusing a key that already produced a response → that exact response (including its status code) is replayed, with an X-Idempotent-Replayed: true header added. This applies to error responses too, not just successes.
  • A different key, but the same path + body submitted again within 10 seconds → 409 DUPLICATE_REQUEST.
Reusing the same key with a genuinely different body currently replays the first response rather than rejecting the request. If your integration retries with a modified payload, use a fresh key.

Errors

Errors always take this shape, with an optional details key for field-level validation messages:

{ "error": { "code": "VALIDATION_ERROR", "message": "The given data was invalid.", "details": { "quantity": ["The quantity field is required."] } } }

A 404 NOT_FOUND is also returned for a record that exists but belongs to a different shop — the API never returns 403 for that case, so a caller can't use the response to probe whether an ID exists elsewhere.

Code Status Where it happens
HTTPS_REQUIRED 403 Request made over plain HTTP in production.
MISSING_CREDENTIALS 401 X-Api-Key or X-Api-Email header missing.
INVALID_CREDENTIALS 401 Key/email do not match an active API user.
API_KEY_EXPIRED 401 The API user's key has expired.
TOO_MANY_FAILED_ATTEMPTS 429 Brute-force lockout window active.
RATE_LIMIT_EXCEEDED 429 Over 300 requests/minute for this API user.
INSUFFICIENT_SCOPE 403 The API user's scopes don't include this endpoint's required scope.
MISSING_IDEMPOTENCY_KEY 400 POST request sent without X-Idempotency-Key.
INVALID_IDEMPOTENCY_KEY 400 X-Idempotency-Key does not match the allowed format.
DUPLICATE_REQUEST 409 Same path + body resubmitted under a different key within 10s.
VALIDATION_ERROR 422 Request body/query failed validation (see details).
NOT_FOUND 404 Resource doesn't exist, or belongs to a different shop.
ORDER_IMMUTABLE 422 Write attempted on a purchase order already imported.
SUPPLIER_NOT_FOUND 422 supplier_id doesn't resolve to a supplier of this shop.
WAREHOUSE_NOT_FOUND 422 warehouse_id doesn't resolve to a warehouse of this shop.
SUPPLIER_PRODUCT_NOT_FOUND 422 No SupplierProduct links this product to the resolved supplier.
PRODUCTS_UNRESOLVED 422 A product_code in the request couldn't be resolved (only_existing=false).
INVALID_ASSIGNEE 422 assigned_to isn't a user of this shop.
INVALID_STATUS_TRANSITION 422 Requested status change is not an allowed transition.
INVALID_STATUS_FOR_IMPORT 422 Purchase order is not in an importable status.
WAREHOUSE_INACTIVE 422 Purchase order targets an inactive or missing warehouse.
INVALID_CUSTOMER_GROUP 422 customer_group_ids entry doesn't resolve to a group of this shop.
MISSING_MCF_CUSTOMER_ID 422 update_in_mcf=true but no mcf_customer_id on the request or the customer record.
MCF_SYNC_FAILED 502 The upstream MCF push failed; no local change was made.
METHOD_NOT_ALLOWED 405 Wrong HTTP verb for this path.
UNAUTHENTICATED 401 Generic auth failure outside the normal credential-check path.
INTERNAL_ERROR 500 Uncaught server error.

Products

{productCode} resolves to either a product or a variation — both share the same product-code namespace per shop. Every endpoint below returns 404 NOT_FOUND for an unknown code, or one belonging to a different shop.

GET /products/{productCode}

Resolve a product or variation by code

Scope: products.read

price / purchase_price fall back to the parent product's value whenever a variation's own value is empty.

Param Type Rules Description
productCode string path The product or variation code.
Request
curl "https://dev2025.flowstock.pro/api/v2/products/SKU-001" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "product_code": "SKU-001",
    "product_id": 42,
    "variation_id": null,
    "name": "Blue Widget",
    "product_name": "Blue Widget",
    "variation_name": null,
    "price": "19.90",
    "purchase_price": "8.50",
    "supplier_id": 3,
    "stock": {
      "quantity": 100,
      "reserved": 5,
      "balance": 95,
      "location": "A-12",
      "backorder_enabled": false,
      "backorder_estimate": null
    }
  }
}
PATCH /products/{productCode}/backorder

Update backorder status

Scope: products.write

Pushes the change to MCF first — only persists locally on success. An omitted field keeps its current value; it is not reset. Sending estimate without enabled forces enabled to true.

Param Type Rules Description
enabled boolean sometimes Whether backorders are allowed.
estimate string|null sometimes, max:255 Free-text delivery estimate shown to customers.

MCF failure → 502 MCF_SYNC_FAILED, and no local write happens.

Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/products/SKU-001/backorder" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true, "estimate": "Ships in 2 weeks"}'
Response
{
  "data": {
    "product_code": "SKU-001",
    "backorder_enabled": true,
    "backorder_estimate": "Ships in 2 weeks"
  }
}
GET /products/{productCode}/barcodes

List barcodes

Scope: products.read

Request
curl "https://dev2025.flowstock.pro/api/v2/products/SKU-001/barcodes" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "id": 1, "value": "0123456789012", "product_id": 42, "variation_id": null }
  ]
}
POST /products/{productCode}/barcodes

Add a barcode

Scope: products.write

Param Type Rules Description
value string required, max:255 The barcode value.
Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/products/SKU-001/barcodes" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: add-barcode-001" \
  -H "Content-Type: application/json" \
  -d '{"value": "0123456789012"}'
Response
{
  "data": {
    "id": 1,
    "value": "0123456789012",
    "product_id": 42,
    "variation_id": null
  }
}
DELETE /products/{productCode}/barcodes/{barcodeId}

Remove a barcode

Scope: products.write

Param Type Rules Description
barcodeId integer path Must belong to this product's/variation's own barcode list.
Request
curl -X DELETE "https://dev2025.flowstock.pro/api/v2/products/SKU-001/barcodes/1" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
204 No Content
POST /products/sync

Trigger a full catalog resync from MCF

Scope: products.write

No body. Always shop-wide — there is no per-product resync. Dispatches the same jobs as the admin panel's "reload shop products" action, asynchronously.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/products/sync" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: sync-2026-08-12-01"
Response
{
  "data": {
    "status": "queued"
  }
}
POST /products/{productCode}/purchase-orders

Add this product to a purchase order

Scopes: products.read purchase_orders.write

Pass exactly one of purchase_order_id (adds a line to an existing, open order) or supplier_id (creates a new draft order for that supplier first, then adds the line).

Param Type Rules Description
purchase_order_id integer|null prohibits:supplier_id Existing order to add a line to.
supplier_id integer|null required_without:purchase_order_id Creates a new draft order for this supplier.
quantity integer required, min:1 Line quantity.

422 SUPPLIER_PRODUCT_NOT_FOUND if no supplier-product link exists; 422 ORDER_IMMUTABLE if the target order is already imported.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/products/SKU-001/purchase-orders" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: add-to-po-001" \
  -H "Content-Type: application/json" \
  -d '{"supplier_id": 3, "quantity": 50}'
Response
{
  "data": {
    "id": 501,
    "purchase_order_id": "2026072815",
    "purchase_product_id": 9001
  }
}
GET /products/{productCode}/stock-events

Paginated stock event log

Scope: products.read

Param Type Rules Description
type string|null max:100 Filter by source_type.
page integer default 1
per_page integer default 20, max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/products/SKU-001/stock-events?page=1&per_page=20" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "id": 1, "source_type": "order", "source_id": 55, "additional_info": null, "quantity": 100, "quantity_change": -2, "changed_at": "2026-07-30T10:00:00+00:00" }
  ],
  "meta": { "total": 40, "page": 1, "per_page": 20 }
}
GET /products/{productCode}/warehouses

Stock for this product across every warehouse

Scopes: products.read warehouses.read

One row per warehouse this product has a stock row in. To look up the full stock list of one already-identified warehouse instead, see GET /warehouses/stock.

Param Type Rules Description
page integer default 1
per_page integer default 20, max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/products/SKU-001/warehouses" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "warehouse_id": 1, "warehouse_name": "Main Warehouse", "quantity": 100, "min_stock": 10, "max_stock": 200, "location": "A-12", "status": "in-stock" },
    { "warehouse_id": 2, "warehouse_name": "Berlin Store", "quantity": 15, "min_stock": 5, "max_stock": 50, "location": "B-03", "status": "low-in-stock" }
  ],
  "meta": { "total": 2, "page": 1, "per_page": 20 }
}
PATCH /products/prices

Update selling price and/or purchase price, one or many at once

Scope: products.write

Always takes an items array — a single update is just an array of one. Each item may carry price, purchase_price, or both; at least one is required. Applied best-effort: one item's error never blocks the others in the same request.

Exactly one item is applied inline and the response carries its result directly (200). Two or more items are always queued instead: the response is 202 with a batch_id, and results are read back from GET /products/prices/batches/{batchId} once status is completed. A queued batch needs a queue worker running to ever complete.

Param Type Rules Description
items array required, min:1 Array of price updates.
items.*.product_code string required The product or variation code.
items.*.price string required_without items.*.purchase_price New selling price.
items.*.purchase_price string required_without items.*.price New purchase price.
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/products/prices" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"product_code":"SKU-001","price":"19.99","purchase_price":"9.99"}]}'
Response
{
  "data": {
    "updated": 1,
    "failed": 0,
    "results": [
      { "product_code": "SKU-001", "status": "ok" }
    ]
  }
}
GET /products/prices/batches/{batchId}

Poll a queued price-update batch

Scope: products.write

Returns the batch's current status, and once completed, the same results shape the inline path returns.

Param Type Rules Description
batchId integer path The batch_id returned by PATCH /products/prices.
Request
curl "https://dev2025.flowstock.pro/api/v2/products/prices/batches/42" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "batch_id": 42,
    "status": "completed",
    "item_count": 2,
    "results": {
      "updated": 2,
      "failed": 0,
      "results": [
        { "product_code": "SKU-001", "status": "ok" },
        { "product_code": "SKU-002", "status": "ok" }
      ]
    }
  }
}

Purchase Orders

{id} is always the numeric primary key. Any write on an order already imported returns 422 ORDER_IMMUTABLE, checked before any other validation. Status is always sent/received as a lowercase string: draft, sent, completed, imported, partially_imported.

Status transitions: draft/sent/ completed/partially_imported can move freely between each other. imported is reachable only from completed or partially_imported, and once imported no further transition is allowed.

GET /purchase-orders

List, filtered/paginated

Scope: purchase_orders.read

Param Type Rules Description
status string One of the status values above.
supplier_id integer
date_from / date_to date
search string max:255
page / per_page integer per_page max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/purchase-orders?status=sent&per_page=20" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [ { "...": "purchase order" } ],
  "meta": { "total": 12, "page": 1, "per_page": 20 }
}
GET /purchase-orders/{id}

Fetch one, with its product lines

Scope: purchase_orders.read

Request
curl "https://dev2025.flowstock.pro/api/v2/purchase-orders/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "id": 501,
    "purchase_order_id": "2026072815",
    "status": "sent",
    "supplier_id": 3,
    "warehouse_id": 1,
    "arrival_date": "2026-08-05",
    "reference_number": "PO-2026-001",
    "comment": null,
    "checked": false,
    "overdue": false,
    "currency": "EUR",
    "imported_at": null,
    "products": [
      {
        "id": 9001,
        "product_id": 42,
        "product_code": "SKU-001",
        "name": "Blue Widget",
        "quantity": 10,
        "import_quantity": 0,
        "unit": "pcs",
        "purchase_price": "8.50",
        "checked": false
      }
    ]
  }
}
POST /purchase-orders

Create, optionally with inline product lines

Scope: purchase_orders.write

Product resolution happens before the order is created. With only_existing=false (default), any unresolvable line aborts the whole request — nothing is persisted. With only_existing=true, unresolvable lines are skipped and reported in meta.skipped_product_codes; the order is still created.

Param Type Rules Description
supplier_id integer required
warehouse_id integer|null
arrival_date date|null
reference_number string|null max:255
only_existing boolean default false
products[].product_code / products[].quantity string / integer required with products, quantity min:1 Inline lines to create with the order.

422 PRODUCTS_UNRESOLVED, 422 SUPPLIER_NOT_FOUND, 422 WAREHOUSE_NOT_FOUND as described above.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/purchase-orders" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: create-po-001" \
  -H "Content-Type: application/json" \
  -d '{"supplier_id": 3, "warehouse_id": 1, "products": [{"product_code": "SKU-001", "quantity": 10}]}'
Response
{
  "data": {
    "id": 501,
    "purchase_order_id": "2026072815",
    "status": "draft"
  }
}
PATCH /purchase-orders/{id}

Update order-level fields

Scope: purchase_orders.write

All fields are optional; an omitted field keeps its current value. Because of that, warehouse_id can't be cleared back to null this way — only reassigned to a different warehouse.

Param Type Rules Description
supplier_id / warehouse_id integer sometimes
arrival_date date sometimes
reference_number string sometimes, max:255
comment string sometimes
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/purchase-orders/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"reference_number": "PO-2026-001-REV2"}'
Response
{
  "data": {
    "id": 501,
    "reference_number": "PO-2026-001-REV2"
  }
}
POST /purchase-orders/{id}/products

Add a product line

Scope: purchase_orders.write

Param Type Rules Description
product_code string required, max:255
quantity integer required, min:1

422 SUPPLIER_PRODUCT_NOT_FOUND if no SupplierProduct links the code to this order's supplier.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/products" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: add-line-001" \
  -H "Content-Type: application/json" \
  -d '{"product_code": "SKU-002", "quantity": 5}'
Response
{
  "data": {
    "id": 9002,
    "product_code": "SKU-002",
    "quantity": 5,
    "checked": false
  }
}
PATCH /purchase-orders/{id}/products/{productId}

Update a product line

Scope: purchase_orders.write

All fields optional, omitted = unchanged. A quantity change adds a "Change amount" log entry on the order.

Param Type Rules Description
quantity integer sometimes, min:1
purchase_price string sometimes, max:255
unit string sometimes, max:255
additional_info string|null sometimes
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/products/9002" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 8}'
Response
{
  "data": {
    "id": 9002,
    "quantity": 8
  }
}
DELETE /purchase-orders/{id}/products/{productId}

Soft-delete a product line

Scope: purchase_orders.write

Request
curl -X DELETE "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/products/9002" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
204 No Content
PATCH /purchase-orders/{id}/products/{productId}/confirm

Flip a line's checked flag

Scope: purchase_orders.write

When every line on the order is checked, the order's own checked flag flips too.

Param Type Rules Description
checked boolean required
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/products/9002/confirm" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"checked": true}'
Response
{
  "data": {
    "id": 9002,
    "checked": true
  }
}
POST /purchase-orders/{id}/import

Import checked lines into stock

Scope: purchase_orders.write

No body. Imports every line that is checked and not yet imported. If nothing qualifies, this is a no-op: 200 with an empty list and the status left untouched.

422 INVALID_STATUS_FOR_IMPORT if the order is not in an importable status; 422 WAREHOUSE_INACTIVE if the order targets an inactive or missing warehouse.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/import" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: import-po-501"
Response
{
  "data": {
    "imported_product_codes": ["SKU-001", "SKU-002"],
    "status": "partially_imported"
  }
}
PATCH /purchase-orders/{id}/status

Change order status

Scope: purchase_orders.write

Param Type Rules Description
status string required One of the status values above.

422 INVALID_STATUS_TRANSITION for a disallowed change; 422 ORDER_IMMUTABLE if already imported.

Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/purchase-orders/501/status" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'
Response
{
  "data": {
    "id": 501,
    "status": "completed"
  }
}

Supplier Products

A supplier product is one catalogue row a supplier offers — its own code, pack size, and purchase price. Every route addresses one by supplier_id + supplier_code, never FlowStock's internal row id. There is no DELETE route: retiring one from the assortment is a PATCH with discontinued: true.

GET /suppliers/{supplierId}/supplier-products

List a supplier's catalogue

Scope: supplier_products.read

Param Type Rules Description
discontinued boolean (0/1) sometimes Filter by discontinued status. Pass 0 or 1 - not true/false.
search string sometimes Matches supplier_code, ean, or name.
page integer default 1
per_page integer default 20, max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/suppliers/3/supplier-products?discontinued=0" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "id": 10, "supplier_id": 3, "supplier_code": "ABC-1", "ean": "4006381333931", "name": "Widget", "purchase_price": "4.99", "purchase_quantity": 1, "unit": "pcs", "discontinued": false }
  ],
  "meta": { "total": 1, "page": 1, "per_page": 20 }
}
GET /suppliers/{supplierId}/supplier-products/{supplierCode}

Show one supplier product

Scope: supplier_products.read

Param Type Rules Description
supplierId integer path
supplierCode string path
Request
curl "https://dev2025.flowstock.pro/api/v2/suppliers/3/supplier-products/ABC-1" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": { "id": 10, "supplier_id": 3, "supplier_code": "ABC-1", "ean": "4006381333931", "name": "Widget", "purchase_price": "4.99", "purchase_quantity": 1, "unit": "pcs", "discontinued": false }
}
POST /suppliers/{supplierId}/supplier-products

Create a supplier product

Scope: supplier_products.write

purchase_price is required here only — it can never be left unset on creation. Changing it afterwards always goes through PATCH /supplier-products/purchase-prices instead.

Param Type Rules Description
supplier_code string required Unique per supplier.
purchase_price string required
name string required
purchase_quantity integer required Pack size.
ean string sometimes
unit string sometimes
additional_info string sometimes
discontinued boolean sometimes
auto_import boolean sometimes
standard_assortment boolean sometimes
Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/suppliers/3/supplier-products" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: create-abc-1" \
  -d '{"supplier_code":"ABC-1","purchase_price":"4.99","name":"Widget","purchase_quantity":1}'
Response
{ "data": { "id": 10, "supplier_id": 3, "supplier_code": "ABC-1", "purchase_price": "4.99" } }
PATCH /suppliers/{supplierId}/supplier-products/{supplierCode}

Update catalogue fields

Scope: supplier_products.write

Returns 422 FIELD_NOT_ALLOWED if the body carries purchase_price — use the dedicated price route instead. Setting discontinued: true triggers the same supplier-reassignment cascade the admin panel's discontinue toggle does.

Param Type Rules Description
name string sometimes
ean string|null sometimes
purchase_quantity integer sometimes
unit string|null sometimes
additional_info string|null sometimes
discontinued boolean sometimes
auto_import boolean sometimes
standard_assortment boolean|null sometimes
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/suppliers/3/supplier-products/ABC-1" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"discontinued":true}'
Response
{ "data": { "id": 10, "supplier_id": 3, "supplier_code": "ABC-1", "discontinued": true } }
PATCH /supplier-products/purchase-prices

Update purchase price, one or many at once

Scope: supplier_products.write

Always takes an items array — a single update is just an array of one, each identified by supplier_id + supplier_code. A changed price cascades to the linked product/variation's own purchase price, pushes to MyCashflow and active chains, and logs price history — the same effects a purchase-order price edit has in the admin panel. Applied best-effort: one item's error never blocks the others.

Exactly one item is applied inline (200). Two or more are always queued (202 + batch_id); poll GET /supplier-products/purchase-prices/batches/{batchId} for the result. A queued batch needs a queue worker running to ever complete.

Param Type Rules Description
items array required, min:1
items.*.supplier_id integer required
items.*.supplier_code string required
items.*.purchase_price string required
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/supplier-products/purchase-prices" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"supplier_id":3,"supplier_code":"ABC-1","purchase_price":"5.50"}]}'
Response
{
  "data": {
    "updated": 1,
    "failed": 0,
    "results": [
      { "supplier_id": 3, "supplier_code": "ABC-1", "status": "ok" }
    ]
  }
}
GET /supplier-products/purchase-prices/batches/{batchId}

Poll a queued purchase-price batch

Scope: supplier_products.write

Returns the batch's current status, and once completed, the same results shape the inline path returns.

Param Type Rules Description
batchId integer path The batch_id returned by PATCH /supplier-products/purchase-prices.
Request
curl "https://dev2025.flowstock.pro/api/v2/supplier-products/purchase-prices/batches/17" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "batch_id": 17,
    "status": "completed",
    "item_count": 2,
    "results": {
      "updated": 2,
      "failed": 0,
      "results": [
        { "supplier_id": 3, "supplier_code": "ABC-1", "status": "ok" },
        { "supplier_id": 3, "supplier_code": "ABC-2", "status": "ok" }
      ]
    }
  }
}

Warehouses

Read-only stock lookups — warehouses themselves are managed in the admin panel, so there's no warehouse CRUD here.

GET /warehouses

List warehouses, filtered/paginated

Scope: warehouses.read

Param Type Rules Description
search string max:255 Substring match on name/city, exact match on warehouse_code.
active boolean (0/1) default true Pass active=0 (not false) explicitly to see inactive warehouses.
page / per_page integer
Request
curl "https://dev2025.flowstock.pro/api/v2/warehouses?active=1" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "id": 1, "name": "Main Warehouse", "warehouse_code": "WH-MAIN", "city": "Berlin", "active": true, "main": true, "pos_allow_sales": true, "stock_status": "in-stock" }
  ],
  "meta": { "total": 6, "page": 1, "per_page": 20 }
}
GET /warehouses/stock

Full stock list for one warehouse

Scope: warehouses.read

Exactly one of warehouse_id/warehouse_code must be given. To ask "where is product Y stocked across every warehouse" instead, use GET /products/{productCode}/warehouses.

Param Type Rules Description
warehouse_id integer required_without:warehouse_code Must belong to the authenticated shop.
warehouse_code string max:255, required_without:warehouse_id Exact match; must belong to the authenticated shop.
sort string one of product_code, name, quantity, updated_at Default product_code.
sort_dir string asc|desc Default asc.
page / per_page integer

status is one of not-tracked, low-in-stock, in-stock, out-of-stock, delivery.

Request
curl "https://dev2025.flowstock.pro/api/v2/warehouses/stock?warehouse_id=1&sort=quantity&sort_dir=desc" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "warehouse_id": 1,
      "warehouse_name": "Main Warehouse",
      "product_id": 42,
      "product_code": "SKU-001",
      "name": "Blue Widget",
      "quantity": 100,
      "min_stock": 10,
      "max_stock": 200,
      "location": "A-12",
      "status": "in-stock"
    }
  ],
  "meta": { "total": 340, "page": 1, "per_page": 20 }
}

Outgoing scans

Read-only access to the warehouse scan-out pairs (parcel → pallet, pallet → pickup) recorded for the authenticated shop. Scans are made in the warehouse app, so there's nothing to write here.

GET /outgoing-scans

List outgoing scans, filtered/paginated

Scope: outgoing_scans.read

Results are ordered oldest first by id. To keep another system in sync, remember the highest id you have stored and ask for since_id=<that id> next time, paging until data comes back empty.

Param Type Rules Description
date_from date (Y-m-d) optional Scanned on or after this day.
date_to date (Y-m-d) after_or_equal:date_from Scanned on or before this day (the whole day is included).
since_id integer min:0 Only scans with an id greater than this.
flow string internal|outbound <code>internal</code> = parcel put on a pallet, <code>outbound</code> = pallet handed to the carrier.
code string max:255 Exact match on either the parcel or the pallet code.
page / per_page integer

flow is null for scans recorded before flows were introduced. processed_by is the email of the warehouse user who scanned the pair, or null for older scans.

Request
curl "https://dev2025.flowstock.pro/api/v2/outgoing-scans?since_id=1520&per_page=100" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "id": 1521,
      "parcel_tracking_code": "00340434161094015902",
      "pallet_tracking_code": "NK000123",
      "flow": "internal",
      "processed_by": "warehouse@example.com",
      "scanned_at": "2026-10-20T08:14:03+00:00"
    }
  ],
  "meta": { "total": 1, "page": 1, "per_page": 100 }
}

Tasks

Full CRUD except delete, for FlowStock's internal to-do/CRM-event task. {id} is the numeric primary key.

Status: new, in_progress, completed — not settable on create, a task always starts new.
Priority: low, normal, high, urgent, immediate.
Type: order, outbound_call, inbound_call, registration, note, offer, meeting.

GET /tasks

List, filtered/paginated

Scope: tasks.read

Param Type Rules Description
status / priority / type string one of the slugs above
assigned_to integer FlowStock user ID.
due_date_from / due_date_to date
search string max:255 Substring match on name.
page / per_page integer
Request
curl "https://dev2025.flowstock.pro/api/v2/tasks?status=new&priority=urgent" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [ { "...": "task" } ],
  "meta": { "total": 8, "page": 1, "per_page": 20 }
}
GET /tasks/{id}

Fetch one

Scope: tasks.read

Request
curl "https://dev2025.flowstock.pro/api/v2/tasks/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "id": 501,
    "name": "Call the supplier",
    "description": "Ask about the delayed shipment",
    "status": "new",
    "priority": "urgent",
    "type": "outbound_call",
    "due_date": "2026-09-01",
    "assigned_to": 7
  }
}
POST /tasks

Create

Scope: tasks.write

Call GET /users first to find a valid assigned_to value.

Param Type Rules Description
name string required, max:255
description string|null
type / priority string required, one of the slugs above
due_date date|null
assigned_to integer required Must be a user of this shop.

422 INVALID_ASSIGNEE if assigned_to isn't a user on this shop.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/tasks" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: create-task-001" \
  -H "Content-Type: application/json" \
  -d '{"name": "Call the supplier", "type": "outbound_call", "priority": "urgent", "assigned_to": 7}'
Response
{
  "data": {
    "id": 501,
    "name": "Call the supplier",
    "status": "new"
  }
}
PATCH /tasks/{id}

Partial update

Scope: tasks.write

All fields optional, omitted = unchanged. Same rules/slugs as create, plus status becomes settable here. description and due_date are nullable: sending either explicitly as null clears it, distinct from omitting the field.

Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/tasks/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'
Response
{
  "data": {
    "id": 501,
    "status": "completed"
  }
}

Users

Read-only. Discovers which FlowStock users a task can be assigned to — the API never creates or edits users.

GET /users

List users assignable within this shop

Scope: users.read

No query parameters. Returns every user assignable within the authenticated shop — the shop's owner, plus anyone attached via the shop_user pivot.

Request
curl "https://dev2025.flowstock.pro/api/v2/users" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    { "id": 1, "name": "Jane Doe", "email": "jane@example.com" },
    { "id": 7, "name": "John Smith", "email": "john@example.com" }
  ]
}

Customers

Create/update only — there is no DELETE endpoint at all, customers.write covers create and update. Every action first resolves the customer shop-scoped by numeric {id} → 404 NOT_FOUND if missing or belonging to another shop.

GET /customers

List customers

Scope: customers.read

Every given filter is ANDed. Pass expand=customer_groups to eager-load and include each customer's groups — the only endpoint in API v2 with an opt-in expandable relation.

Param Type Rules Description
id integer sometimes Exact match.
mcf_customer_id integer sometimes Exact match.
email string sometimes, max:255 Case-insensitive substring match.
expand string sometimes, in:customer_groups Include customer_groups on each result.
page integer default 1
per_page integer default 20, max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/customers?email=jane&expand=customer_groups" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "id": 501,
      "mcf_customer_id": 9001,
      "email": "jane@example.com",
      "first_name": "Jane",
      "last_name": "Doe",
      "company": null,
      "vat_code": null,
      "street_address": "Main St 1",
      "postal_code": "10115",
      "city": "Berlin",
      "country": "DE",
      "phone": null,
      "email_subscription": true,
      "sms_subscription": false,
      "created_at": "2026-08-18T10:00:00+00:00",
      "updated_at": "2026-08-18T10:00:00+00:00",
      "customer_groups": [
        { "id": 3, "mcf_customer_group_id": 4001, "name": "VIP" }
      ]
    }
  ],
  "meta": { "total": 1, "page": 1, "per_page": 20 }
}
GET /customers/{id}

Fetch one customer

Scope: customers.read

customer_groups is omitted entirely (not even null) unless ?expand=customer_groups is passed. Each entry has both id (the local customer_groups.id) and mcf_customer_group_id (MCF's own group id, populated on import — null for a group never synced from MCF).

Param Type Rules Description
id integer path The customer's numeric PK.
expand string sometimes, in:customer_groups Include customer_groups.
Request
curl "https://dev2025.flowstock.pro/api/v2/customers/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "id": 501,
    "mcf_customer_id": 9001,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "company": null,
    "vat_code": null,
    "street_address": "Main St 1",
    "postal_code": "10115",
    "city": "Berlin",
    "country": "DE",
    "phone": null,
    "email_subscription": true,
    "sms_subscription": false,
    "created_at": "2026-08-18T10:00:00+00:00",
    "updated_at": "2026-08-18T10:00:00+00:00"
  }
}
POST /customers

Create a customer

Scope: customers.write

There is no mcf_customer_id field on create — MCF assigns that id, not the caller. create_in_mcf pushes the new customer to MCF first (POST /api/v1/customers) and uses the id MCF returns. MCF's own schema requires first_name, last_name, street_address, postal_code, city and country, so those become required here too when create_in_mcf is true (MCF has no concept of email_subscription/sms_subscription — those stay local-only either way). Omitting the flag creates a local-only customer with no mcf_customer_id, which can still be linked later via PATCH .../{id}.

Param Type Rules Description
email string required, email, max:255
create_in_mcf boolean sometimes Creates a new customer in MCF too. See description above.
first_name string|null max:255, required_if:create_in_mcf,true
last_name string|null max:255, required_if:create_in_mcf,true
company string|null max:255
vat_code string|null max:255
street_address string|null max:255, required_if:create_in_mcf,true
postal_code string|null max:255, required_if:create_in_mcf,true
city string|null max:255, required_if:create_in_mcf,true
country string|null max:255, required_if:create_in_mcf,true
phone string|null max:255
email_subscription boolean sometimes
sms_subscription boolean sometimes
customer_group_ids integer[]|null sometimes Local customer_groups.id values (not mcf_customer_group_id). Each must belong to this shop.

422 INVALID_CUSTOMER_GROUP if any customer_group_ids entry doesn't resolve to a group of this shop — nothing is persisted. 502 MCF_SYNC_FAILED if create_in_mcf was true and the MCF push failed — nothing is persisted either way. Success → 201.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/customers" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: create-customer-001" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "street_address": "Main St 1", "postal_code": "10115", "city": "Berlin", "country": "DE", "create_in_mcf": true}'
Response
{
  "data": {
    "id": 501,
    "mcf_customer_id": 9001,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "company": null,
    "vat_code": null,
    "street_address": "Main St 1",
    "postal_code": "10115",
    "city": "Berlin",
    "country": "DE",
    "phone": null,
    "email_subscription": false,
    "sms_subscription": false,
    "created_at": "2026-08-18T10:00:00+00:00",
    "updated_at": "2026-08-18T10:00:00+00:00"
  }
}
PATCH /customers/{id}

Partially update a customer

Scope: customers.write

Every field sometimes except update_in_mcf, which is required on every request — every update must explicitly say whether it should also sync to MCF, there is no default. create_in_mcf is replaced by update_in_mcf here (see below), and mcf_customer_id is accepted (unlike create) to link the customer to an MCF customer that already exists. An omitted optional field keeps the customer's current value, same convention as PATCH /purchase-orders/{id}. When given, customer_group_ids replaces the customer's group membership entirely (not merged); omit it to leave group membership untouched.

update_in_mcf: true pushes the merged field values to MCF (PATCH /api/v1/customers/{mcfCustomerId}). It needs an MCF id to target: either this same request's mcf_customer_id, or one already on the customer record. Neither present → 422 MISSING_MCF_CUSTOMER_ID, nothing persisted. update_in_mcf: false never touches MCF, even if the customer already has an mcf_customer_id — but must still be passed explicitly.

Param Type Rules Description
id integer path The customer's numeric PK.
mcf_customer_id integer|null sometimes Links to an MCF customer that already exists.
update_in_mcf boolean required Whether this update should also sync to MCF. See description above.

Same 422 INVALID_CUSTOMER_GROUP failure mode as create. 422 MISSING_MCF_CUSTOMER_ID if update_in_mcf was true with no MCF id to target. 502 MCF_SYNC_FAILED if the MCF push itself failed — nothing is persisted locally in either case. Omitting a nullable field (company, vat_code, first_name, last_name, street_address, postal_code, city, country, phone, mcf_customer_id) keeps its current value; sending it explicitly as null clears it.

Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/customers/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: update-customer-501" \
  -H "Content-Type: application/json" \
  -d '{"phone": "+49123456789", "update_in_mcf": true}'
Response
{
  "data": {
    "id": 501,
    "mcf_customer_id": 9001,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "company": null,
    "vat_code": null,
    "street_address": "Main St 1",
    "postal_code": "10115",
    "city": "Berlin",
    "country": "DE",
    "phone": "+49123456789",
    "email_subscription": true,
    "sms_subscription": false,
    "created_at": "2026-08-18T10:00:00+00:00",
    "updated_at": "2026-08-18T11:00:00+00:00"
  }
}

Offers

{id} is always the numeric primary key. Status is always sent/received as a lowercase string: draft, sent, accepted, rejected, completed. Every transition between these is allowed — there is no immutability rule once an offer reaches any particular status.

Important: PATCH /offers/{id}/status with status: sent only records that the offer was marked sent and sets sent_at — it does not generate a PDF or email/FTP anything to the customer. Actually sending an offer stays a manual admin-panel action. Treat this endpoint as a status label, not a trigger.

price is never caller-supplied — it is always the product's/variation's own current price. offer_price is caller-supplied when given; when omitted, it resolves from the offer's linked campaigns (lowest matching price wins), falling back to price if no campaign matches or none are linked. See the Campaigns section and PUT /offers/{id}/campaigns below.

Every offer records where it came from: source is admin, api, or cart_link. Offers created through this API always get source: "api" and creator_email set to the calling API user's email — created_by is not accepted on create; there is no concept of attributing an API-created offer to a specific FlowStock staff user.

GET /offers

List, filtered/paginated

Scope: offers.read

Param Type Rules Description
status string One of the status values above.
customer_id integer The real Customer id, not the offer-local customer snapshot id.
date_from / date_to date
search string max:255 Matched against offer_id.
page / per_page integer per_page max 100
Request
curl "https://dev2025.flowstock.pro/api/v2/offers?status=sent&per_page=20" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [ { "...": "offer" } ],
  "meta": { "total": 4, "page": 1, "per_page": 20 }
}
GET /offers/{id}

Fetch one, with its customer and product lines

Scope: offers.read

Request
curl "https://dev2025.flowstock.pro/api/v2/offers/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "id": 501,
    "offer_id": "2026081901",
    "status": "draft",
    "reference": "OFR-2026-001",
    "comment": null,
    "currency": "EUR",
    "include_vat": 1,
    "total_value": 82.5,
    "shipping_method_id": 1,
    "payment_method_id": 1,
    "created_by": null,
    "source": "api",
    "creator_email": "you@example.com",
    "sent_at": null,
    "accepted_at": null,
    "rejected_at": null,
    "completed_at": null,
    "customer": {
      "id": 900,
      "customer_id": null,
      "email": "jane@example.com",
      "first_name": "Jane",
      "last_name": "Doe"
    },
    "products": [
      {
        "id": 9001,
        "product_id": 42,
        "product_code": "SKU-001",
        "name": "Blue Widget",
        "quantity": 10,
        "price": 9.99,
        "offer_price": 8.25,
        "discount": 1.74
      }
    ],
    "campaigns": [
      { "id": 12, "name": "Summer sale" }
    ]
  }
}
POST /offers

Create, optionally with an inline customer and product lines

Scope: offers.write

The offer is stamped with source: "api" and creator_email set to the calling API user's email automatically — there is no created_by field to send. customer is optional: give customer_id to link an existing Customer, or leave it out/null and give only the name/address fields for a standalone quote-recipient snapshot not tied to any Customer record.

Param Type Rules Description
reference string|null max:255
comment string|null
currency string|null size:3 One of the supported currency codes (EUR, USD, CAD, CNY, GBP, JPY).
include_vat integer|null 1 = enabled, 2 = disabled. Defaults to 1 (enabled) when omitted.
shipping_method_id / payment_method_id integer|null Must belong to this shop.
customer object|null customer_id (get this from GET /customers's id field, not mcf_customer_id), email, first_name, last_name, street_address, postal_code, city, country, phone, company.
products[] array sometimes Each: product_code, quantity required; offer_price optional (resolved from linked campaigns, then the product's own price, when omitted); vat_rate optional. price is never accepted.

422 INVALID_CUSTOMER if customer.customer_id doesn't resolve to a Customer of this shop, 422 SHIPPING_METHOD_NOT_FOUND / 422 PAYMENT_METHOD_NOT_FOUND as described above.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/offers" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: create-offer-001" \
  -H "Content-Type: application/json" \
  -d '{"customer": {"email": "jane@example.com", "first_name": "Jane", "last_name": "Doe"}, "products": [{"product_code": "SKU-001", "quantity": 10, "offer_price": 8.25}]}'
Response
{
  "data": {
    "id": 501,
    "offer_id": "2026081901",
    "status": "draft",
    "source": "api",
    "creator_email": "you@example.com"
  }
}
PATCH /offers/{id}

Update offer-level fields and/or the customer

Scope: offers.write

All fields are optional; an omitted field keeps its current value. source/creator_email are set once at creation and cannot be changed here. Sending customer updates the existing offer-linked customer snapshot in place, or creates one if the offer had none yet.

Param Type Rules Description
reference / comment / currency / include_vat mixed sometimes Same shape as create.
shipping_method_id / payment_method_id integer sometimes
customer object sometimes Same shape as create.
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/offers/501" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"reference": "OFR-2026-001-REV2"}'
Response
{
  "data": {
    "id": 501,
    "reference": "OFR-2026-001-REV2"
  }
}
POST /offers/{id}/products

Add a product line

Scope: offers.write

price is never accepted — it's always the product's/variation's own current price. When offer_price is omitted, it resolves from the offer's linked campaigns (lowest matching price wins), falling back to price if none match or none are linked — see Campaigns. When vat_rate is given, price_without_vat/offer_price_without_vat are derived from it; otherwise they equal price/offer_price.

Param Type Rules Description
product_code string required, max:255
quantity integer required, min:1
offer_price numeric sometimes, min:0 The quoted price. Resolved from linked campaigns, then the product's own price, when omitted.
vat_rate numeric|null

404 NOT_FOUND if the product code does not resolve for this shop.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/offers/501/products" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: add-line-001" \
  -H "Content-Type: application/json" \
  -d '{"product_code": "SKU-002", "quantity": 5, "offer_price": 10, "vat_rate": 21}'
Response
{
  "data": {
    "id": 9002,
    "product_code": "SKU-002",
    "quantity": 5,
    "price": 12.5,
    "offer_price": 10,
    "discount": 2.5
  }
}
PATCH /offers/{id}/products/{productId}

Update a product line

Scope: offers.write

All fields optional, omitted = unchanged. Changing offer_price recalculates discount and offer_price_without_vat. discount is a different lever, only valid on the offer's discount row (see POST /offers/{id}/discount-row below) — sending it against a normal product line returns 422 NOT_A_DISCOUNT_ROW.

Param Type Rules Description
quantity integer sometimes, min:1
offer_price numeric sometimes
description string|null sometimes
discount numeric sometimes, gt:0 Discount row only. The new discount amount; must not exceed the offer's other lines' subtotal.

404 NOT_FOUND if the product line does not belong to this offer.

Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/offers/501/products/9002" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 8}'
Response
{
  "data": {
    "id": 9002,
    "quantity": 8
  }
}
DELETE /offers/{id}/products/{productId}

Remove a product line

Scope: offers.write

404 NOT_FOUND if the product line does not belong to this offer.

Request
curl -X DELETE "https://dev2025.flowstock.pro/api/v2/offers/501/products/9002" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
204 No Content
POST /offers/{id}/discount-row

Add the offer's discount row

Scope: offers.write

An offer can have at most one discount row. It behaves like a product line (it comes back in products[] and can be removed with DELETE /offers/{id}/products/{productId} using its returned id), but has no real product behind it — amount is subtracted from the offer total as a flat reduction. Rejected with 422 DISCOUNT_EXCEEDS_OFFER_TOTAL if amount is more than the sum of the offer's other lines. To change the amount afterwards, use PATCH /offers/{id}/products/{productId} with discount.

Param Type Rules Description
amount numeric required, gt:0 Flat discount amount, in the offer's currency.
description string|null

422 DISCOUNT_ROW_EXISTS if the offer already has one, 422 DISCOUNT_EXCEEDS_OFFER_TOTAL as described above.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/offers/501/discount-row" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: add-discount-001" \
  -H "Content-Type: application/json" \
  -d '{"amount": 15, "description": "Loyalty discount"}'
Response
{
  "data": {
    "id": 9003,
    "product_id": 0,
    "quantity": 1,
    "price": 0,
    "offer_price": -15,
    "discount": 15,
    "description": "Loyalty discount"
  }
}
POST /offers/{id}/comments

Add a comment to the offer's log

Scope: offers.write

Writes a user-authored entry into the offer's existing audit-trail log (the same one visible in the admin panel) — separate from the auto-generated entries other endpoints write (e.g. "Added product"). This is unrelated to the comment field returned on the offer itself.

Param Type Rules Description
comment string required
Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/offers/501/comments" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"comment": "Customer asked to rush this."}'
Response
{
  "data": {
    "id": 4501,
    "operation": "Comment",
    "comment": "Customer asked to rush this.",
    "user": "you@example.com",
    "created_at": "2026-08-19T10:00:00+00:00"
  }
}
PATCH /offers/{id}/status

Change offer status

Scope: offers.write

Sets the matching timestamp column (sent_at/accepted_at/rejected_at/completed_at) alongside status. See the callout at the top of this section — status: sent does not send anything.

Param Type Rules Description
status string required One of the status values above.
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/offers/501/status" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"status": "sent"}'
Response
{
  "data": {
    "id": 501,
    "status": "sent",
    "sent_at": "2026-08-19T10:00:00+00:00"
  }
}
PUT /offers/{id}/campaigns

Replace the offer's linked campaigns in one call

Scope: offers.write

Bulk-replaces the offer's entire set of linked campaigns (sync()), unlike the admin panel's campaign checkboxes which attach/detach one at a time. Send an empty array to unlink every campaign. Every future POST /offers/{id}/products call resolves its price from the newly-linked campaigns, and every product line already on the offer is repriced immediately — the same reprice the admin panel's "Apply campaigns" button runs. A line's offer_price only ever moves down to the cheapest matching campaign price; it is left alone if no linked campaign matches, or if the matching price is not actually lower than what the line already has.

Param Type Rules Description
campaign_ids integer[] required, array Every id must belong to a Campaign of this shop, or nothing is persisted.

422 INVALID_CAMPAIGN (error.details.invalid_campaign_ids) if any campaign_ids entry doesn't belong to this shop.

Request
curl -X PUT "https://dev2025.flowstock.pro/api/v2/offers/501/campaigns" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{"campaign_ids": [12, 13]}'
Response
{
  "data": {
    "id": 501,
    "campaigns": [
      { "id": 12, "name": "Summer sale" },
      { "id": 13, "name": "Loyalty club" }
    ]
  }
}

Campaigns

Read-only — campaigns and their prices are managed exclusively in the admin panel, so there's no campaign CRUD here. This API only lists them and links them to offers (see PUT /offers/{id}/campaigns in the Offers section), for campaign-based offer pricing.

campaigns.read is a brand-new scope — an existing ShopApiUser gets no access to these endpoints until an admin explicitly checks the box for it.

GET /campaigns

List campaigns, filtered/paginated

Scope: campaigns.read

Param Type Rules Description
active boolean (0/1) default true 1 = published and within its time window. Pass active=0 (not false) explicitly to see inactive campaigns.
pricing_enabled boolean (0/1) default none Filters separately on whether the campaign is enabled for offer pricing (see PUT /offers/{id}/campaigns). Combine with active to narrow further, e.g. active=1&pricing_enabled=1 for campaigns ready to price offers against.
campaign_id integer Exact match on campaign_id, the MCF campaign ID &mdash; not the internal id used in the campaign's URL. Use this to look a campaign up by the ID your MCF-side records already have.
page / per_page integer
Request
curl "https://dev2025.flowstock.pro/api/v2/campaigns?active=1&pricing_enabled=1" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "id": 12,
      "campaign_id": 48213,
      "name": "Summer sale",
      "code": "SUMMER26",
      "published": true,
      "enable_pricing": true,
      "registered_customers": false,
      "customers_group_ids": null,
      "time_from": "2026-06-01",
      "time_to": "2026-08-31",
      "created_at": "2026-05-20T10:00:00+00:00",
      "updated_at": "2026-05-20T10:00:00+00:00"
    }
  ],
  "meta": { "total": 3, "page": 1, "per_page": 20 }
}
GET /campaigns/{id}

Fetch one campaign

Scope: campaigns.read

{id} is the internal id, the same one returned as id in the list response — not campaign_id. To look a campaign up by its MCF campaign_id instead, use GET /campaigns?campaign_id=.

404 NOT_FOUND if the campaign doesn't belong to this shop.

Request
curl "https://dev2025.flowstock.pro/api/v2/campaigns/12" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "id": 12,
    "campaign_id": 48213,
    "name": "Summer sale",
    "code": "SUMMER26",
    "published": true,
    "enable_pricing": true,
    "registered_customers": false,
    "customers_group_ids": null,
    "time_from": "2026-06-01",
    "time_to": "2026-08-31",
    "created_at": "2026-05-20T10:00:00+00:00",
    "updated_at": "2026-05-20T10:00:00+00:00"
  }
}
GET /campaigns/{id}/prices

List a campaign's prices, paginated

Scope: campaigns.read

These are the same rows consulted when resolving an offer line's offer_price — see POST /offers/{id}/products in the Offers section. variation_id is null for a product-level price row, which applies to every variation of that product that has no row of its own.

Param Type Rules Description
page / per_page integer

404 NOT_FOUND if the campaign doesn't belong to this shop.

Request
curl "https://dev2025.flowstock.pro/api/v2/campaigns/12/prices" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "id": 901,
      "product_id": 42,
      "variation_id": null,
      "quantity": null,
      "price": 8.25,
      "order_limit": null
    }
  ],
  "meta": { "total": 15, "page": 1, "per_page": 20 }
}

Product bundles

A bundle is an ordinary MyCashflow product FlowStock creates and keeps in step with its components: the stock balance is the number of complete bundles the components allow, the sell state is "continue selling" only while every component allows it, and the price follows a pricing rule. Requires the bundles module; the bundles.read / bundles.write scopes gate the endpoints.

{productCode} is the bundle's own product code. Components are product codes of products or variations that have a stock row in the shop; a bundle cannot contain itself or another bundle.

Pricing rules: fixed (fixed_price), component_sum (sum of component sales prices minus price_param %), cost_plus (sum of component purchase prices marked up to a price_param % margin on the net price, VAT added). price_rounding is one of none, 0.10, 1.00, .90, .95, .99. A price below min_margin_percent is not pushed and the bundle carries a warning.

GET /bundles

List bundles

Scope: bundles.read

Param Type Rules Description
active boolean optional Only active (true) or inactive (false) bundles.
status string optional One of ok, warning, error, inactive, unlinked.
search string optional Matches the name or product code.
page integer optional Page number, from 1.
per_page integer optional, max 100 Page size (default 20).
Request
curl "https://dev2025.flowstock.pro/api/v2/bundles?status=warning" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": [
    {
      "product_code": "BNDL-0001",
      "mcf_product_id": 5120,
      "name": "Gin & tonic set",
      "is_active": true,
      "status": "warning",
      "pricing": { "rule": "cost_plus", "param": 30, "rounding": ".90", "min_margin_percent": 20, "fixed_price": null },
      "price": "41.90",
      "purchase_price": "24.00",
      "stock": { "quantity": 3, "reserved": 0, "balance": 3, "backorder_enabled": false, "backorder_estimate": "" },
      "components": [ { "product_code": "GIN-500", "quantity": 1 }, { "product_code": "TONIC-6", "quantity": 2 } ],
      "last_synced_at": "2026-09-16T10:00:00+03:00",
      "last_sync_error": null,
      "last_warning": "Jokin osa on \"lopeta myynti\" -tilassa: paketti siirtyy samaan tilaan."
    }
  ],
  "meta": { "total": 1, "page": 1, "per_page": 20 }
}
GET /bundles/{productCode}

Get a bundle

Scope: bundles.read

Param Type Rules Description
productCode string path The bundle product code.
Request
curl "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": { "product_code": "BNDL-0001", "name": "Gin & tonic set", "status": "ok", "components": [ { "product_code": "GIN-500", "quantity": 1 } ] } }
POST /bundles

Create a bundle and its MyCashflow product

Scope: bundles.write

Creates the product in MyCashflow (hidden unless version_ids is given), links the category, mirrors it locally and pushes the derived stock row. Returns 201 when the product exists, or 202 with meta.mcf_errors when the bundle was saved but the MyCashflow step did not finish — retry it with POST /bundles/{productCode}/create-product. Leave product_code empty to have one generated (e.g. BNDL-0001). Set link_existing to attach an existing MyCashflow product instead of creating one. Supports the X-Idempotency-Key header.

Param Type Rules Description
name string required, max:255 Product name.
product_code string optional, max:64 Product code; generated when omitted. Must be free locally and in MyCashflow.
description string optional HTML description. When empty, a list of the contents is generated.
vat_rate number required, 0–100 VAT rate in percent.
category_id integer optional FlowStock category id to link the product to.
version_ids integer[] optional FlowStock version ids the product is visible in. Empty = hidden.
is_active boolean optional, default true Whether the sync derives and pushes stock and price.
link_existing boolean optional Link an existing MyCashflow product with this code instead of creating one.
create_in_chains boolean optional, default true Create the product in the active chain shops as well.
price_rule string required fixed | component_sum | cost_plus.
price_param number required unless fixed, 0–99.99 Discount % (component_sum) or margin % (cost_plus).
fixed_price number required when fixed Sales price incl. VAT.
price_rounding string optional none | 0.10 | 1.00 | .90 | .95 | .99.
min_margin_percent number optional A computed price below this margin is not pushed.
components object[] required, min:1 Rows of {product_code, quantity}.
Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/bundles" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: 4b0c2e7a" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Gin & tonic set",
    "vat_rate": 25.5,
    "price_rule": "cost_plus",
    "price_param": 30,
    "price_rounding": ".90",
    "min_margin_percent": 20,
    "components": [
      { "product_code": "GIN-500", "quantity": 1 },
      { "product_code": "TONIC-6", "quantity": 2 }
    ]
  }'
Response
{ "data": { "product_code": "BNDL-0001", "mcf_product_id": 5120, "status": "ok", "price": "41.90" } }
PATCH /bundles/{productCode}

Update a bundle

Scope: bundles.write

Any definition field may be sent alone. components replaces the whole list. The product code cannot change once the MyCashflow product exists. A change queues a sync.

Param Type Rules Description
productCode string path The bundle product code.
is_active boolean optional Activate or deactivate the sync.
components object[] optional, min:1 Rows of {product_code, quantity}; replaces the current list.
Request
curl -X PATCH "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "Content-Type: application/json" \
  -d '{ "price_rule": "component_sum", "price_param": 10 }'
Response
{ "data": { "product_code": "BNDL-0001", "pricing": { "rule": "component_sum", "param": 10 } } }
DELETE /bundles/{productCode}

Delete a bundle

Scope: bundles.write

Removes the bundle definition; the MyCashflow product stays as a plain product unless delete_mcf_product=1 is given, in which case it is deleted too — refused with 409 BUNDLE_HAS_SALES when the product has order lines.

Param Type Rules Description
delete_mcf_product boolean optional Also delete the product in MyCashflow.
Request
curl -X DELETE "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
(204 No Content)
POST /bundles/{productCode}/sync

Sync now

Scope: bundles.write

Re-derives the stock row and price and pushes what changed to MyCashflow. meta.stock / meta.price carry the decisions. 502 MCF_SYNC_FAILED when MyCashflow refused a write.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001/sync" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": { "product_code": "BNDL-0001", "status": "ok" }, "meta": { "stock": { "changes": true, "target": { "balance": 3, "backorder_enabled": true } }, "price": { "changes": false } } }
GET /bundles/{productCode}/preview

Preview what a sync would do

Scope: bundles.read

Evaluates without writing anything. Same shape as meta of the sync endpoint, plus per-component detail.

Request
curl "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001/preview" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": { "product_code": "BNDL-0001", "stock": { "current": { "balance": 5 }, "target": { "balance": 3 }, "components": [ { "product_code": "GIN-500", "quantity": 1, "balance": 3, "bundles_possible": 3 } ] }, "price": { "target": { "price": 41.9, "purchase_price": 24 }, "margin_percent": 28.1 } } }
POST /bundles/{productCode}/create-product

Retry creating the MyCashflow product

Scope: bundles.write

For a bundle whose creation returned 202. Idempotent: an already created product is found by code and the remaining steps are completed.

Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001/create-product" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": { "product_code": "BNDL-0001", "mcf_product_id": 5120, "status": "ok" } }
POST /bundles/{productCode}/campaign

Schedule a bundle offer (cheaper package price)

Scope: bundles.write

One open offer per bundle; scheduling replaces the current one. mode campaign (default) puts a fixed price row into the shop's MyCashflow campaign (shop setting bundle_campaign_id; the storefront shows the regular price struck through) — 422 CAMPAIGN_FAILED when no campaign is configured or the price falls under the bundle's minimum margin. direct prices the bundle at the offer for the period instead. An offer whose start has come is pushed at once; otherwise the 30-minute sweep activates and later ends it.

Param Type Rules Description
campaign_price number required, > 0 Offer price incl. VAT.
mode string optional, default campaign campaign | direct.
starts_at datetime optional, default now When the offer starts.
ends_at datetime optional When it ends; omit for until further notice.
Request
curl -X POST "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001/campaign" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com" \
  -H "X-Idempotency-Key: 7d1e9c" \
  -H "Content-Type: application/json" \
  -d '{ "campaign_price": 34.90, "starts_at": "2026-09-20T00:00:00+03:00", "ends_at": "2026-09-30T23:59:59+03:00" }'
Response
{ "data": { "product_code": "BNDL-0001", "campaign": { "status": "scheduled", "mode": "campaign", "campaign_price": 34.9, "starts_at": "2026-09-20T00:00:00+03:00", "ends_at": "2026-09-30T23:59:59+03:00" } } }
DELETE /bundles/{productCode}/campaign

End the open bundle offer now

Scope: bundles.write

Request
curl -X DELETE "https://dev2025.flowstock.pro/api/v2/bundles/BNDL-0001/campaign" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": { "product_code": "BNDL-0001", "campaign": null } }
GET /products/{productCode}/bundles

Bundles a product is part of

Scopes: products.read bundles.read

Request
curl "https://dev2025.flowstock.pro/api/v2/products/GIN-500/bundles" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{ "data": [ { "product_code": "BNDL-0001", "name": "Gin & tonic set", "is_active": true } ] }

Meta

Answers "what are my pagination limits, sort options, filters and scopes" without hardcoding them client-side. Requires valid credentials but no specific scope — any active API user can call it.

scopes and endpoints are filtered to what the calling key actually holds: a key with only customers.read sees just that one scope and the customers endpoint entry, not every scope/endpoint that exists in the system. An endpoint that requires more than one scope (e.g. products.warehouses, which needs both products.read and warehouses.read) is only listed once the key holds all of them. A key with the unrestricted * scope sees everything.

GET /meta

Server capability info for this API version

No scope required.

Request
curl "https://dev2025.flowstock.pro/api/v2/meta" \
  -H "X-Api-Key: fs_xxx" \
  -H "X-Api-Email: you@example.com"
Response
{
  "data": {
    "pagination": { "default_per_page": 20, "max_per_page": 100 },
    "endpoints": {
      "purchase_orders": { "max_per_page": 100, "sortable_fields": [], "filters": ["status", "supplier_id", "date_from", "date_to", "search"] },
      "tasks": { "max_per_page": 100, "sortable_fields": [], "filters": ["status", "priority", "type", "assigned_to", "due_date_from", "due_date_to", "search"] },
      "customers": { "max_per_page": 100, "sortable_fields": [], "filters": ["id", "mcf_customer_id", "email", "expand"] },
      "offers": { "max_per_page": 100, "sortable_fields": [], "filters": ["status", "customer_id", "date_from", "date_to", "search"] },
      "campaigns": { "max_per_page": 100, "sortable_fields": [], "filters": ["active", "pricing_enabled", "campaign_id"] },
      "campaigns.prices": { "max_per_page": 100, "sortable_fields": [], "filters": [] }
    },
    "scopes": ["products.read", "products.write", "purchase_orders.read", "purchase_orders.write", "warehouses.read", "tasks.read", "tasks.write", "users.read", "customers.read", "customers.write", "offers.read", "offers.write", "campaigns.read"],
    "rate_limit": { "requests_per_minute": 300 }
  }
}