# Fluit Public API > REST API for integrating external systems with Fluit ERP: webshops, WMS, EDI, > BI tools and custom integrations. The API is scoped to a single tenant (your company) — > the credential you call with determines which company's data you read and write. - Base URL: `https://api.fluit.cloud` — the paths listed below already include the `/preview` prefix. - Authentication: two credentials work. Send an API key in the `X-Api-Key` header (created in Fluit under Settings → API keys) for a machine-to-machine integration, or an OAuth 2.1 access token as `Authorization: Bearer` when the calls should be traceable to the person who approved the connection. The OAuth flow is discoverable at `/.well-known/oauth-protected-resource/preview`; scopes are `preview:read` and `preview:write`. - Version: `preview`. - OpenAPI document, with the complete request and response schemas: https://api.fluit.cloud/swagger/public-preview/swagger.json - Reference documentation for humans: https://fluit.se/api-docs/reference - Contact: info@fluit.se ## Authentication Two credentials reach this API. They grant the same operations; what differs is who the calls are attributed to. **API key** — for machine-to-machine integrations. A key belongs to the company, not to a person, so its calls are anonymous within the company. **OAuth 2.1** — for integrations whose calls should be traceable to a person. A user approves the connection, and every read and write the token makes is recorded against that user and your client's name in the company's activity log. Pick per integration. A nightly EDI job wants a key; an app people sign into wants OAuth. The two do **not** reach the same operations. An API key has no user behind it, so only its scopes bound it. An OAuth connection acts on the approving user's mandate and is bounded by their permissions in Fluit as well — a connection approved by someone who cannot post inventory counts cannot post them either, and the call returns `403` with `Permission.Denied`. If your integration needs the full surface, have it approved by a user whose role covers it, or use an API key. ## Getting started with an API key 1. Create an API key in Fluit under **Settings → API keys**. The key is shown once — store it securely. Keys are prefixed `fluit_live_sk_` (production) or `fluit_test_sk_` (test). 2. Call the API with the key in the `X-Api-Key` header: ```bash curl https://api.fluit.cloud/preview/items?pageSize=5 \ -H "X-Api-Key: fluit_live_sk_..." ``` ## Getting started with OAuth 2.1 The authorization server is built in, and everything about it is discoverable — no credentials to exchange with us up front. 1. Fetch `/.well-known/oauth-protected-resource/preview` for the resource identifier and its supported scopes, and `/.well-known/oauth-authorization-server` for the endpoints. 2. Register your client at `POST /oauth/register` (RFC 7591 dynamic client registration). No client secret is issued or accepted — PKCE (S256) is what protects the exchange, and it is mandatory. 3. Send the user to `/oauth/authorize` with `resource=https://api.fluit.cloud/preview` and the scopes you need. They sign in, pick the company, and approve. Passing the `resource` parameter matters: it is what binds the grant to this API rather than to another of our protected surfaces. 4. Exchange the code at `POST /oauth/token`, then call the API with the access token: ```bash curl https://api.fluit.cloud/preview/items?pageSize=5 \ -H "Authorization: Bearer " ``` Refresh tokens rotate on every use: you get a new one with each refresh, and the spent one stops working. Keep only the newest, and treat `invalid_grant` on refresh as "start the flow again" rather than as something to retry. The token is confined to this API. It cannot be used against any other Fluit surface, and a token issued for another surface cannot be used here. A user can see and disconnect their connections under **My settings → Connections**; an administrator sees the company's under **Settings → Connections**. Assume a connection can be revoked at any time and handle `401` by starting the flow again. ### Scopes Every credential carries a space-separated list of scopes, and every operation requires one. The scope is `{resource}:read` for reads and `{resource}:write` for writes, where the resource is the operation's tag in lower kebab-case — `items:read`, `sales-orders:write`, `inventory:write`. Each operation states its scope in the description and in the `x-required-scope` extension. OAuth connections use two surface-wide scopes instead: `preview:read` covers every read, and `preview:write` covers everything. The list above is the right granularity for a key an administrator configures once; it is the wrong thing to put in front of a person approving a connection, so consent asks about reading and acting, and the user can approve a read-only connection by declining the write half. Give a key only what its integration needs. A webshop that reads the catalogue and places orders wants `items:read sales-orders:write`, and nothing more — with that list it cannot write off stock or post an inventory count, even though those endpoints exist on the same API. Use `*` for a key that should reach everything. Calling an operation the credential lacks the scope for returns `403` with the required and granted scopes in the problem details. > Keys created before scopes were enforced have an empty scope list. Those keep working > with unrestricted access, which is the access they already had. Set scopes on the key to > narrow it — there is no way to widen an empty list, because it is already unlimited. ## Your first order Note the required `Idempotency-Key` on POST: ```bash curl -X POST https://api.fluit.cloud/preview/orders \ -H "X-Api-Key: fluit_live_sk_..." \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "customerNumber": "CUST-001", "lines": [ { "itemNumber": "WIDGET-A", "quantity": "10" } ] }' ``` ## Conventions - **Business keys, not GUIDs.** Resources are addressed by their business keys, and `*Code` is used for reference data (warehouses, payment terms, …). List the valid codes via the Reference endpoints. | Resource | Key in the URL | | --- | --- | | Customers | `customerNumber` | | Items | `itemNumber` | | Sales orders | `orderNumber`, lines by `lineNumber` | | Suppliers | `supplierNumber` | | Purchase orders | `orderNumber`, lines by `lineNumber` | | Tickets | `ticketNumber` | Sales orders and purchase orders live under different paths (`/orders` and `/purchase-orders`), so an order number is only ever ambiguous across the two if you reuse the same number series for both. - **Decimals are strings.** All monetary amounts and quantities are serialised as decimal strings (`"123.45"`, invariant format) to avoid IEEE 754 floating-point errors. Requests accept both strings and numbers; responses always return strings. Invariant means `.` as the decimal separator and no thousands separator — `"1234.50"`. A value in any other format (`"1 234,50"`, `"1,234.50"`) is rejected with `400`, never guessed at: `"1,5"` could as easily mean 15 as 1.5, and a quantity is not something to get approximately right. Format for the wire, not for a reader — `toFixed(2)` in JavaScript, `str(Decimal(...))` in Python, `ToString(CultureInfo.InvariantCulture)` in .NET. The same rule applies to decimal query parameters such as `?quantity=`. Check the item's `decimalPlaces` for how many decimals a quantity may have. - **Dates and times.** Dates are ISO 8601 (`2026-06-11`). Timestamps are UTC and carry the `Z` designator (`2026-06-09T22:07:47.7639194Z`), so `new Date(...)` in JavaScript and `datetime.fromisoformat(...)` in Python both resolve them to the right instant without any correction on your side. Request parameters are equally forgiving: `modifiedSince` accepts `Z`, a numeric offset or no designator and resolves all three to the same instant. - **Fields are nullable unless the schema says otherwise.** This bites on fields that look mandatory: `baseUnitCode` is null for items with no unit configured, `salesPrice` is null for items with no list price, and `modifiedDate` is null for records that have never been changed since creation. When syncing on `modifiedDate`, fall back to `createdDate`. - **Enums are strings.** Status fields and similar are serialised as enum names (`Placed`, `Released`, `Closed`, …) and documented per field. Enum values in query-string filters are matched case-insensitively; an unknown value returns `400` rather than an empty result. Treat enum values as open — new ones may be added without a version bump. - **Resources link to themselves.** Every resource that has its own URL carries `links.self`, the canonical URL of that resource. `POST` responses return the same representation as the corresponding `GET`, with the URL in the `Location` header as well. Rows that are only ever read through their parent — order lines, contacts, addresses, packages, reference data — have no self link, because they have no address of their own. - **PATCH is JSON Merge Patch.** Only fields present in the body are updated. Pass `null` to clear a nullable field; omitted fields are left unchanged. A field that is not part of the endpoint's contract is rejected with `400`, naming the field and listing the ones it accepts — so a misspelling fails loudly instead of looking like a change that did not take. The two line endpoints (`PATCH /preview/orders/{orderNumber}/lines/{lineNumber}` and its purchase-order counterpart) ignore unknown fields instead; their descriptions say so. - **Country codes** are ISO 3166-1 alpha-2 (`SE`), **currency codes** ISO 4217 (`SEK`). ## Prices and stock Two fields on the item representation are routinely mistaken for something they are not. Both mistakes are silent — you get a plausible number, not an error. - **`salesPrice` is the item's list price, not the price anyone pays.** It ignores price lists, customer agreements, campaigns and volume breaks. Call `GET /preview/items/{itemNumber}/price` to get the price an order line would actually receive, with `?customerNumber=` for customer-specific pricing and `?quantity=` for volume tiers. The two commonly differ by double-digit percentages. Use `salesPrice` only where you genuinely want an uncontracted reference price. - **The item list carries no stock.** `GET /preview/items` returns no quantity at all. Availability has its own endpoints: `GET /preview/inventory/availability` covers many items in one call — pass up to 200 item numbers in `?itemNumbers=`, or page through the whole stocked catalogue — and `GET /preview/items/{itemNumber}/availability` covers one. Use the bulk endpoint for a catalogue sync, with `?modifiedSince=` so a recurring poll only pays for what moved; one call per item costs 1 + N against the per-minute quota. Remember that `availableQuantity` (on hand minus reservations) is the number you can promise — not `quantityOnHand`. ## Calling from a browser The API sends `Access-Control-Allow-Origin: *` and allows `x-api-key` in the preflight, so a browser page can call `/preview` directly with no proxy. That is deliberate, and it suits internal tools and prototypes. It does not make the key safe in client code. Anything in a page's JavaScript is readable by every visitor, and a leaked key grants full access to the tenant's data. For anything user-facing, keep the key on a server you control and let the browser talk to that server instead — or have each user supply their own key at runtime. ## Flows ### Purchasing: order and receive goods 1. `POST /preview/purchase-orders` with `supplierNumber` and lines. The order is created in `Draft` and nothing is sent to the supplier yet. Omit `unitPrice` to use the supplier price list, and `unit` to use the item's base unit. 2. `POST /preview/purchase-orders/{orderNumber}/send` moves it to `Sent`. By default this only records that the order went out — pass `{"sendEmail": true}` if you want Fluit to email the PDF to the supplier rather than sending it yourself over EDI. 3. `POST /preview/purchase-orders/{orderNumber}/confirm` when the supplier confirms. If they came back with different quantities or dates, `PATCH` the affected lines first. 4. `POST /preview/purchase-orders/{orderNumber}/lines/{lineNumber}/receive` as goods arrive. Each call books stock, creates an inventory transaction and advances the line and order to `PartiallyReceived` and then `Received`. Call it once per delivery for partial deliveries. 5. `POST /preview/purchase-orders/{orderNumber}/close` if the supplier will not deliver the remainder — that settles the order without waiting for the outstanding quantity. Receiving is not reversible through this API, so retries matter: a repeated call with the same `Idempotency-Key` replays the original response instead of booking the goods twice. ### Support: take in a ticket from your own form 1. `POST /preview/tickets` with `title`, `description` and — if you have it — the reporter's `contactEmail` and `customerNumber`. That is the whole contract; the endpoint is meant to sit behind a contact form on your own site. 2. The reporter gets a confirmation mail with the ticket number, and your agents see the ticket in the same queue as tickets phoned in or mailed to the support mailbox. 3. `GET /preview/tickets/{ticketNumber}` reads back the current status, so a "track my ticket" page can show the reporter where their case stands. Answers are written by your agents in Fluit and reach the reporter by email; replies to that mail land back on the same ticket. This API is the way in, not a chat channel. ### Configurator: sell a made-to-measure product Some items are not picked off a shelf — they are built to the customer's measurements and choices. Curtains, blinds and awnings are the archetype: width and height drive the fabric consumption, the fabric and the control type drive the price, and no two orders are alike. These items are ordered through `/preview/configurations` rather than as a plain order line, so the choices survive into production. 1. `GET /preview/items?isConfigurable=true` finds the items that have a configurator. 2. `GET /preview/items/{itemNumber}/configuration` returns the whole form definition in one call: the features, their input types and bounds, and the selectable options. Each feature's `featureType` tells you which field to send back — `Number` → `number`, `Selection` → `optionCode`, `Boolean` → `boolean`, `ItemSelection` → `itemNumber`, `Text` → `text`. `Calculated` features take no input. 3. `POST /preview/configurations/calculate` on every change while the customer configures. It returns the price for the current choices plus `resolvedValues` — the derived numbers such as area and fabric consumption, so you do not have to reimplement the formulas. An incomplete configuration is a normal state, not an error: it comes back as `200` with `isValid: false` and every problem listed in `validationErrors`, ready to show at the right field. This is the one POST that does not require an `Idempotency-Key`. 4. `POST /preview/configurations` once the customer is happy. The response carries a `configurationNumber` — the business key for everything that follows — and the price. `customerNumber` may be left out here and attached later, which is what a storefront that configures before asking who the customer is needs. 5. `POST /preview/configurations/{configurationNumber}/reconfigure` to change it later. Every field is optional: omit `values` to keep the choices and change only the quantity, pass `customerNumber` to claim an anonymous configuration. When `values` *is* present it replaces the whole set. The response is the updated, repriced configuration. 6. `POST /preview/configurations/{configurationNumber}/order` turns it into a sales order and returns the order. Add freight or more lines through the order endpoints, then `POST /preview/orders/{orderNumber}/place`. Behind the line, the configuration becomes a work order carrying the exploded bill of materials and routing, so production knows what to build. A configurator produces abandoned sessions: `DELETE /preview/configurations/{configurationNumber}` discards one that was never ordered. A configuration that has become an order is frozen — reconfiguring or deleting it returns `409`, and it disappears from `GET /preview/configurations` without leaving a tombstone, so store the `orderNumber` from the /order response if you keep local copies. #### Pricing a configuration The price is the item's own price from the price hierarchy (customer price lists, agreements, campaigns, volume breaks) plus the configuration surcharge. Three things are worth knowing before you build against it: - **It is a live price, not a locked quote.** Both `calculate` and the order conversion run the price engine at the moment they are called, so a campaign that starts or expires in between moves the price. The order always uses the customer's currency; a `currencyCode` passed to `calculate` affects that calculation only. - **Automatic customer discounts do not apply.** The composed price is set as a manual unit price on the line, which bypasses the discount engine. Price lists, agreements, campaigns and volume breaks are already reflected in the base price. - **A choice that links an item does not change the price by itself.** When an option has a `linkedItemNumber`, that item is added to the bill of materials as a cost line and the option's `priceImpact` is deliberately ignored. To make a choice cost more, give it a `priceImpact` without a linked item, or drive the price from a `Calculated` feature. ## Pagination and syncing List endpoints are paginated with `?page=` (1-based) and `?pageSize=` (default 50, max 200) and respond with: ```json { "items": [], "totalCount": 0, "page": 1, "pageSize": 50, "totalPages": 0, "hasPreviousPage": false, "hasNextPage": false } ``` ## Incremental sync A sync needs three things: what was created, what changed, and what was removed. Creates and changes come from the list endpoints; removals need a separate feed, because a deleted record leaves nothing behind for a list endpoint to return. **Upserts — `?modifiedSince=` (UTC ISO 8601).** Store the timestamp at which you started the previous sync and pass it on the next run. Both created and modified records are returned. A change anywhere inside the record counts: editing an order line moves the order's `modifiedDate`, so nothing can change below the level of the resource you are polling without the resource itself showing up in the delta. **Removals — `GET /preview/deletions?deletedSince=`.** Deletions cannot be observed from a list endpoint. A deleted customer, supplier, purchase order or configuration is really gone, so it simply stops appearing — which is indistinguishable from "unchanged since your last poll". Every deletion is instead recorded in a log, written in the same transaction as the deletion itself, and read from this endpoint. Each entry carries the `resource`, the `id` and the `businessKey` the record had when it was deleted, so you can match it against your copy. A sync run therefore looks like: ``` since = now = GET /preview/customers?modifiedSince={since} # and the other collections you mirror GET /preview/deletions?deletedSince={since} # what to retire ``` Both directions are safe to re-read from a slightly earlier timestamp: applying the same upsert or the same deletion twice has no further effect. Prefer overlapping a little over cutting it fine. **Conditional reads — `ETag` / `If-None-Match`.** `GET` on a single record returns a weak `ETag`. Pass it back in `If-None-Match` and you get `304 Not Modified` with an empty body while the record is unchanged. The validator follows the whole record, nested parts included, so a `304` is a real promise that nothing in the representation has moved. ## Idempotency All POST requests require an `Idempotency-Key` header — a unique value (UUID recommended, max 255 characters) per logical attempt. There are two exceptions: `POST /preview/configurations/calculate`, which has no side effects and is a POST only because its input does not fit in a URL, and the `multipart/form-data` file uploads (`POST /preview/tickets/{ticketNumber}/attachments` and `POST /preview/channels/{channelCode}/media`), whose bodies cannot be buffered and hashed the way a JSON request can — retrying one of those may create a second copy, so check before you retry. - **Retrying with the same key and body** returns the original response unchanged (marked with the `Idempotency-Replayed: true` header) instead of e.g. creating a duplicate order after a network timeout. - **Reusing a key** for a different endpoint or body returns `422 Unprocessable Entity` with `code: "idempotencyKey.conflict"`. - **Concurrent requests** with the same key are serialised; if the first is still running after 30 s the second receives `503` with a `Retry-After` header. - Stored responses expire after **24 hours**. Keys are scoped to the credential that used them — your API key, or your OAuth connection. Two integrations against the same company never collide, so you are free to generate keys however you like without coordinating with anyone else. Generate a new key for every new logical request; reuse the key only when retrying the same request. ## Errors Errors follow RFC 7807 (`application/problem+json`): ```json { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "customerNumber", "detail": "Customer 'CUST-999' not found.", "status": 400 } ``` | Status | Meaning | | ------ | ------- | | 400 | Validation error — an unknown business key, an invalid request body field, or an unknown enum value in a query filter | | 401 | Missing, invalid or expired credential — no API key, or a bearer token that no longer authenticates | | 403 | The credential is valid but not permitted to perform the operation | | 404 | The resource in the URL does not exist | | 409 | The operation conflicts with the resource's state (e.g. cancelling a shipped order) | | 413 | `POST` body larger than 1 MB, when the request declares a `Content-Length` | | 422 | Idempotency-Key reused for a different request | | 429 | Rate limit exceeded — back off per the `Retry-After` header | | 500 | Unexpected server error. Safe to retry with the same `Idempotency-Key` — server errors are never replayed from cache | Field-level problems are listed in the `errors` extension array, one entry per violation with a `code` (the field or business rule) and a `description`. Request body constraints published in this document — required fields, maximum lengths, patterns and ranges — are enforced; a violation returns `400` with the offending field in `errors`. Nested fields use a dotted path, e.g. `lines[0].discountPercent`. Two cases fall outside this shape and return a plain `400` without a problem+json body: a request body that is not well-formed JSON, and a query parameter other than an enum filter (`page`, `pageSize`, `quantity`, dates and `modifiedSince`) that cannot be parsed into its declared type. Enum filters such as `?status=` are parsed by the API itself and do produce the `errors` array. ## Rate limiting Requests are limited to **1000 per minute per credential** (fixed window) — per API key, or per access token for an OAuth connection. `429` responses carry `Retry-After` in seconds — honour it rather than retrying on a fixed delay. Where the quota headers `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` are present they describe the current window, but they are not emitted on every deployment. Treat them as advisory: read them when they are there, and never make your backoff conditional on finding them. Code that only slows down once `X-RateLimit-Remaining` gets low will otherwise run flat out into a `429`. ## Preview status The API is mounted under `/preview` and is in preview: the schema can change without notice until the stable `/v1` release. Breaking changes are listed in the changelog. Questions or access requests: [info@fluit.se](mailto:info@fluit.se). ## Migrating a company into Fluit Moving a company's data into Fluit — a catalogue, a customer register, price lists, opening stock, the open orders — is a job for two surfaces at once, and knowing which one owns what is most of the work. - **The MCP tools** (`query_*`, `action_*`) are the *setup spine*: everything that needs a decision per row, exists in tens or hundreds rather than thousands, or simply has no endpoint on the public API. This is also where you look around before you write anything. - **The public API at `/preview`** is the *volume*: items, customers, suppliers, stock, open documents. Driven by a script, not by tool calls. The reason for the split is cost, not taste. Every tool call passes through a model's context, so five thousand items as tool calls is hours of latency and a fortune in tokens. The right division of labour is that the agent *orchestrates* the migration — reads the source, maps the columns, makes the judgement calls, spot-checks the result, handles the exceptions — while a script carries the rows. **Rule of thumb.** More than a few hundred rows of the same shape → script it over `/preview`. Fewer than a hundred, or one decision per row → MCP tool calls are fine and usually better. ### 1. Look before you load Never create reference data that already exists. A fresh Fluit tenant is not empty: it has units, an order type, payment and delivery terms, at least one warehouse. - `query_GetTenantReferenceData` — one call, the compact list of units, item categories, warehouses, order types, payment terms and delivery terms with their codes and defaults. Call this first, always. - `GET /preview/reference/…` — the same ground from the script's side: `units`, `warehouses`, `currencies`, `payment-terms`, `delivery-terms`, `order-types`, `shipping-methods`, `tax-classes`, `ticket-queues` and `tenant-info`. - `query_GlobalSearch` and `query_ResolveItemByReference` — to check whether something is already there before creating a duplicate. Then map the source data to what you found, and write the mapping down **once, in code**. A mapping re-decided per row is a mapping that drifts. ### 2. The order the steps depend on Each step needs the one above it to exist. Surface in brackets. 1. **Setup spine** *(mostly MCP)* — units, warehouses → zones → locations, customer groups, tax classes and rates, delivery and payment terms, order types, carriers and shipping methods, product and variant attributes. **Item categories and brands are on the public API** (`POST /preview/categories`, `POST /preview/brands`) — load the category tree parents-first, since `parentCode` must already exist. 2. **Items** *(public API, batch)* — the catalogue itself, including each item's `categoryCode`, `brandCode` and `unitCode`. Those three are looked up, never created, so step 1 has to be done first. Variants go in the same batch: a master row carries `itemType: VariantMaster` and its `variantAxes`, each variant its `parentItemNumber` and `variantValues` — masters first. 3. **Item enrichment** *(public API for the volume, MCP for the rest)* — relations (`POST /preview/item-relations/batch`), configuration choices (`PUT /preview/items/{itemNumber}/features/{code}`), attribute values, images, datasheets and CAD drawings over the public API; unit conversions, supplier items, per-warehouse settings (reorder point, safety stock), bill of materials, operations and cross references over MCP. 4. **Customers and suppliers** *(public API)*, then their **addresses and contacts** *(MCP)*. 5. **Prices** *(MCP)* — price lists and their lines, customer-specific prices, discounts. 6. **Opening stock** *(both — read §8 before you start)*. 7. **Open documents** *(public API)* — sales orders, purchase orders, quotes that are still live. 8. **Verify** *(both)*. History — closed orders, shipped shipments, paid invoices — is normally *not* carried over. See §9. ### 3. Get a credential for `/preview` Two credentials reach the public API: - **API key** — `X-Api-Key: fluit_live_sk_…`. Created under Settings → API keys, or, when an AI agent is driving the migration, with the MCP tool `action_CreateMigrationApiKey`. That tool issues a key that expires within 24 hours and is scoped to the resources you name; it never issues `*`, and it can never exceed what the MCP connection itself was granted. - **OAuth 2.1 token** — `Authorization: Bearer …`, when the calls should be traceable to the person who approved the connection. Discoverable at `/.well-known/oauth-protected-resource/preview`. A key is scoped per resource: `items:write`, `customers:read`, and so on. Ask for exactly what the migration writes to. Issuing a new migration key revokes the previous one, so hold on to the plaintext — it is returned once. ### 4. Know the limits before you write the loop | Limit | Value | | --- | --- | | Requests per minute, per key | 1000 | | Requests per minute, per client IP | 5000 | | Rows per batch call | 200 | | `pageSize` on list endpoints | 200 (default 50) | Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. A `429` carries `Retry-After` in seconds. **Back off, do not fan out.** The window is fixed, so more concurrency past the limit buys nothing and turns a clean run into a retry storm. Four to eight concurrent requests is plenty; when a `429` arrives, sleep for `Retry-After` and continue. ### 5. Make the run resumable — deterministic idempotency keys `Idempotency-Key` is **required on every POST** under `/preview`. It looks like friction and is in fact the single most useful thing in this guide, because you get to choose the key. Derive it from the data instead of generating a random one: ``` Idempotency-Key = sha256(runId + ":" + batchIndex) # for batch calls Idempotency-Key = sha256(runId + ":" + itemNumber) # for single-row calls ``` With a stable `runId` for the whole migration, re-running the same script after a crash, a network drop or a `Ctrl-C` replays the stored response for the batches that already went through and only does real work for the rest. A random key would instead create duplicates on every retry. The key is scoped to the credential that used it, so two integrations cannot replay each other's responses. ### 6. Load the volume Use the batch endpoint where one exists: `POST /preview/items/batch` for items (with their variants) and `POST /preview/item-relations/batch` for the relations between them. Both take up to 200 rows and answer with a result per row: ```http POST /preview/items/batch X-Api-Key: fluit_live_sk_… Idempotency-Key: 6f1c… Content-Type: application/json { "mode": "upsert", "items": [ { "itemNumber": "670-00001", "name": "HDMI cable 2 m", "salesPrice": "249.00" } ] } ``` ```json { "createdCount": 199, "updatedCount": 0, "failedCount": 1, "results": [ { "index": 0, "itemNumber": "670-00001", "outcome": "created" }, { "index": 7, "itemNumber": "670-00008", "outcome": "failed", "error": { "code": "Items.BarcodeNotUnique", "detail": "…" } } ] } ``` The HTTP status describes the request, not the rows: a well-formed batch always answers `200`. Read `results` and re-send only the failed rows. `4xx` is reserved for the batch itself — more than 200 rows, a missing scope, malformed JSON. `mode` is `upsert` (default), `create` or `update`, and unlike `POST /preview/items` the batch never generates an item number: `itemNumber` is required on every row. **Stored responses live for 24 hours.** The replay protection is a stored response per (credential, key), and it expires after a day. A migration that runs longer than that, or is resumed with a *new* credential, no longer replays — the same key executes the batch for real a second time. So keep a run inside the window, resume it with the same key the first attempt used, and let your own checkpoint file — not the server's memory — be what decides which chunks are done. **Re-send failed rows under a new key.** Within that window a batch that got a response is finished: its `Idempotency-Key` returns that same response, failed rows included. Collect the failed rows into their own run with its own `runId`, rather than replaying the original batch and expecting a different answer. Where no batch endpoint exists, use the single-row endpoints: `POST /preview/{resource}` to create, `PATCH /preview/{resource}/{businessKey}` to update. #### Contract details that trip up a first attempt - **Business keys, not GUIDs.** URLs address `items/{itemNumber}`, `customers/{customerNumber}`, `orders/{orderNumber}`. You do not need to look up an id before writing. (MCP is the opposite — see §7.) - **Decimals are JSON strings.** `"salesPrice": "249.00"`, not `249.0`. This avoids IEEE 754 drift in JavaScript and Python. Send them as strings too. - **Updates are PATCH, as JSON Merge Patch.** Only the fields present in the body are touched; a field set to `null` is cleared. The handful of `PUT` routes on the surface replace a whole value (a home page, an attribute value), they are not partial updates. - **Errors are RFC 7807.** `type`, `title`, `status`, `detail`, and for validation errors an `errors` object keyed by field. - **Lists are paginated.** `{ "items": [...], "totalCount", "page", "pageSize", "totalPages", "hasPreviousPage", "hasNextPage" }`. - **`modifiedSince`** on the list endpoints, plus `GET /preview/deletions`, gives you delta sync once the initial load is done. A deletion cannot be observed from a list endpoint — the row is really gone — which is what the deletion log is for. ### 7. What only the MCP surface can create The public API is deliberately narrower than Fluit. These have no `/preview` endpoint and must be created with tool calls (or in the web app) — which is fine, because they are all low-volume: | What | Tool | | --- | --- | | Warehouses, zones, locations | `action_CreateWarehouse`, `action_CreateWarehouseZone`, `action_CreateWarehouseLocation` | | Units and unit conversions | `action_CreateUnit`, `action_AddItemUnitConversion` | | Extra category assignments beyond an item's primary one | `action_AssignItemCategory` | | Per-warehouse item settings | `action_AddItemWarehouse` (reorder point, safety stock, default location). Once the row exists, its settings can be read and updated over the public API — `GET /preview/item-warehouses`, `POST /preview/item-warehouses/batch` | | Bill of materials and routing | `action_AddItemComponent`, `action_AddItemOperation` | | Supplier items and prices | `action_UpsertSupplierItems` — up to 500 items per supplier and call, with price breaks and the primary-supplier flag on each row. Run it with `validateOnly: true` first, then `allowPartialSave: true` so a bad row does not hold back the rest. Lead time, minimum order quantity and order multiple can also be updated with `PATCH /preview/suppliers/{supplierNumber}/items/{itemNumber}` | | Price lists | `action_CreatePriceList`, then `action_UpsertPriceListLines` (up to 500 lines per call, any pricing strategy) or `action_PopulatePriceListLines` (fill from a filter). Link the list to customers with `action_AssignPriceListToCustomers` | | Customer groups, discounts, customer item numbers | `action_CreateCustomerGroup`, `action_CreateCustomerDiscount`, `action_AddCustomerItem` | | Customer/supplier addresses and contacts | `action_AddCustomerAddress`, `action_AddCustomerContact`, `action_AddSupplierContact` | | Tax classes, rates and rules | `action_CreateTaxClass`, `action_CreateTaxRate`, `action_CreateTaxRule` | | Terms, order types, carriers, shipping methods | `action_CreateDeliveryTerm`, `action_CreateOrderType`, `action_CreateCarrier`, `action_CreateShippingMethod` | | Product and variant attributes (the definitions — values and variants go over the public API) | `action_CreateProductAttribute`, `action_CreateVariantAttribute` | | Batches and serial numbers | `action_CreateBatch` | | Inventory revaluation | `action_CreateRevaluationJournal` and friends — see §8 | Two things to remember on this surface: - **IDs are GUIDs, and you must not invent them.** Call the matching `query_*` first and use the id it returns. A code or a name is not an id. This is the opposite of the public API, which addresses everything by business key. - **There is no file upload over MCP.** Images (PNG, JPEG, GIF, WebP), datasheets (PDF) and CAD drawings (DWG) go through `POST /preview/items/{itemNumber}/assets`; other documents through the web app. Item attributes are the exception that works from both sides: `PUT /preview/items/{itemNumber}/attributes/{attributeCode}` sets a value over the public API once the attribute itself exists. ### 8. Opening stock: the quantity is easy, the value is not This is the step that most often ships wrong and is discovered at year-end. `POST /preview/inventory/adjustments` books a signed delta at a location and **does not set a cost**. It books at the location's current average cost — and on a location that has never held that item, the average cost is zero. Load a warehouse this way and you get the right quantities on hand with an inventory value of **0**. The adjustment endpoint also requires `itemNumber`, `warehouseCode` *and* `locationCode`, so the warehouse and its locations must exist first (§2, step 1). `Receipt`, `Issue` and `Transfer` are rejected there on purpose — they belong to the purchase, picking and transfer flows and would leave the ledger inconsistent with the documents behind it. Two ways to get the value right: - **Book the opening balance as a receipt over MCP.** `action_CreateManualInventoryTransaction` with `transactionType: "Receipt"` and an explicit `unitCost` sets the average cost as it books (omit `unitCost` and it falls back to the item's `costPrice`). Right for a few hundred item/location pairs. - **Load quantities over the public API, then revalue.** For real volume: adjust the quantities in, then run the revaluation journal: 1. `action_CreateRevaluationJournal` — generates a line per item/warehouse that has stock on hand, and returns the **journal id only**. 2. `query_GetRevaluationJournalLines` — page through the journal to get each line's `lineId` and its current snapshot. The batch update is keyed by `lineId`, so this step is not optional: without it you have nothing to address. 3. `action_UpdateRevaluationJournalLinesBatch` — set `newAverageCost` per `lineId`. 4. `action_MarkRevaluationJournalReady`, then `action_ApplyRevaluationJournal`. The journal is reviewable before it posts, which is what you want for a number that ends up in the balance sheet. Either way, **verify the value, not just the quantity**, before moving on (§10). ### 9. Open documents, and what not to carry Carry over what is still *live*: unshipped sales orders, unreceived purchase orders, quotes that can still be accepted. Each of the three has its own shape, and guessing a uniform one is how a migration earns a pile of `409`s: - **Sales orders.** `POST /preview/orders` takes the lines inline and **places the order for you** — a following `/place` answers `409`, because it is already placed. Pass `createAsDraft: true` when you want the header first and the lines added afterwards with `POST /preview/orders/{orderNumber}/lines`; then `/place` is yours to call. - **Purchase orders.** A new one is `Draft`. The order is `POST /preview/purchase-orders` → `/send` (Draft → Sent) → `/confirm`, which accepts only `Sent` or `PartiallyConfirmed`. Calling `/confirm` on a draft fails. - **Quotes.** `POST /preview/quotes` takes its lines in the create request; there is no public endpoint for adding a quote line afterwards. Then `/send`, and `/accept` or `/decline`. Leave the closed history in the old system. Fluit's documents carry timestamps from when they were created here, and a shipped order replayed through the flow books inventory movements that never happened. Two years of invoice history is a report, not a migration — keep the old system readable, or export it. The exception is support tickets, which have a purpose-built path that preserves the original timestamps and author: `POST /preview/tickets/migrate`. ### 10. Verify before you call it done A migration that returned `200` everywhere is not a migration that is correct. Check, and show the numbers: - **Counts against the source.** `totalCount` from `GET /preview/items`, `/preview/customers`, `/preview/suppliers` against the row counts you loaded from. - **Spot-check ten rows end to end** — pick them from different parts of the source, and compare every field, not just the name. - **Inventory quantity *and* value** — `query_GetInventoryReport`, which carries the cost. A value of zero on stock you loaded means §8 went wrong. Note that `GET /preview/inventory/availability` answers with quantities only — on hand, allocated, available — so it can confirm the counts but will never reveal a zero valuation. - **The failure report.** Every row your script recorded as failed is either fixed and re-run, or listed to the customer. A migration with a silent failure list is not done. ### 11. A shape that works ``` read source rows map to the API's field names # do this once, in code, not per row dry run: validate the mapping on 10 rows and read every error for each chunk of 200: skip chunks already recorded in the checkpoint file POST /preview/items/batch with Idempotency-Key = sha256(runId + ":" + chunkIndex) on 429: sleep(Retry-After), retry the same chunk with the same key record the chunk in the checkpoint file, append failed rows to a report re-run the failed rows once the underlying data is fixed ``` A reference implementation of exactly this lives in the Fluit repository under `tools/bulk-load/` (`bun tools/bulk-load/load-items.ts --help`). It is meant to be copied and adapted per migration, not treated as a product. ## Endpoints 196 endpoints. Each one is listed with its query parameters, the request body examples from the OpenAPI document and the status codes it can return. Field-level schemas for request and response bodies are in the OpenAPI document linked above. ### Customers Customers with their contact persons and delivery addresses. Addressed by customerNumber. #### `GET /preview/customers` — List customers Returns a paginated list of customers for the authenticated tenant. Sorted by customer number. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `customers:read` scope. Query parameters: `search` (string), `isActive` (boolean), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/customers` — Create a customer Creates a new customer for the authenticated tenant. Customer number is auto-generated if not provided. defaultBackorderBehavior controls what happens to unfulfillable quantities on this customer's orders; omit it to use CreateBackorder. Allowed values: CreateBackorder, CancelRemaining, HoldOrder. The response body is the same representation as GET /preview/customers/{customerNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `customers:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Create customer: ```json { "name": "Acme AB", "customerNumber": "CUST-001", "organizationNumber": "5560001234", "invoiceEmail": "invoice@acme.se", "phone": "+46701234567", "street1": "Storgatan 1", "postalCode": "11122", "city": "Stockholm", "countryCode": "SE" } ``` Responses: `201`, `400`, `401`, `403` #### `GET /preview/customers/{customerNumber}` — Get a customer by customer number Returns the details of a customer belonging to the authenticated tenant. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a nested part invalidates it too. Requires the `customers:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/customers/{customerNumber}` — Update a customer Partially updates a customer. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Requires the `customers:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/customers/{customerNumber}/addresses` — List customer delivery addresses Returns a paginated list of the delivery addresses registered on the customer, default address first. Use the fields to populate deliveryAddress when creating orders. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `customers:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `GET /preview/customers/{customerNumber}/contacts` — List customer contacts Returns a paginated list of the contact persons registered on the customer, default contact first. Use a contact's id as customerContactId when creating orders. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `customers:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` ### Items Items/products including stock availability, price calculation, units, attributes and assets. Addressed by itemNumber. #### `GET /preview/attributes` — List attribute definitions Returns the active product attribute definitions — the specification fields an item can carry — with the rules that govern their values: data type, unit, allowed values for a fixed list, numeric bounds, a pattern text must match, and whether an item may hold several values. Use code when setting a value with PUT /preview/items/{itemNumber}/attributes/{attributeCode}, and when filtering GET /preview/items with ?attribute=. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `items:read` scope. Query parameters: `search` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `GET /preview/inventory/availability` — List stock availability for many items Returns stock availability for every stocked item, paginated, with the same per-warehouse breakdown as GET /preview/items/{itemNumber}/availability — including whether movements in that warehouse require serial or batch numbers. Use this instead of calling the per-item endpoint in a loop: a catalogue of a few thousand SKUs otherwise consumes the entire rate limit budget on every sync cycle. Items with no stock record in any active warehouse are omitted. Pagination is over items, never over warehouse rows, so an item's totals are always complete. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `items:read` scope. Query parameters: `itemNumbers` (string), `warehouseCode` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/item-relations/batch` — Link many items to each other in one call Adds up to 200 item relations per call — accessories, related items, cross-sells, spare parts. Both items are addressed by item number and must already exist. A relation that already exists (same source, target and type) is reported as `unchanged`, not as an error, so a migration can be re-run from the top. Like POST /preview/items/batch, a well-formed batch always answers 200 and every row carries its own `outcome`; re-send only the failed rows. Row errors: `ItemRelations.Bulk.UnknownSourceItem`, `ItemRelations.Bulk.UnknownTargetItem`, `ItemRelation.SelfReference`, `ItemRelation.InvalidType` and `ItemRelations.Bulk.GradedAlternativeNotSupported` — a graded alternative carries a grade and is linked one at a time in the app. Requires the `items:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Two accessories on a chair: ```json { "relations": [ { "sourceItemNumber": "655-001", "targetItemNumber": "100-21OIL", "relationType": "Accessory" }, { "sourceItemNumber": "655-001", "targetItemNumber": "100-FARSKINN", "relationType": "Accessory" } ] } ``` Responses: `200`, `400`, `401`, `403` #### `GET /preview/item-warehouses` — List item warehouse settings Returns one row per item and warehouse the item is set up in, with the reorder point, stock levels, safety stock, lead time, lot sizing, supply policy and replenishment settings. Filter with itemNumber for one item's warehouses, or with warehouseCode for everything in one warehouse — the starting point for a periodic recalculation that writes back through POST /preview/item-warehouses/batch. Sorted by itemNumber, then warehouseCode. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. **How MRP reads these settings.** `minQuantity` is the reorder point. When projected stock falls below max(safety stock, minQuantity), MRP proposes replenishment according to `lotSizing`: `LotForLot` fills to the safety stock and ignores the reorder point, `FixedReorderQuantity` orders `reorderQuantity` (required, > 0) in whole multiples, `FillToMax` orders up to `maxQuantity` (required). The safety stock is `safetyStock`, else `calculatedSafetyStock`, else zero. A purchase quantity is then rounded up to the supplier's `minOrderQuantity` and `orderMultiple`. **Which lead time MRP uses for a purchased item**, first one set wins: (1) the lead time on an active purchase agreement line, (2) `leadTimeDays` on the supplier item of the supplier MRP picks (agreement supplier, else the primary supplier) — see GET/PATCH /preview/suppliers/{supplierNumber}/items, (3) `leadTimeDays` on this item-warehouse row, (4) 14 days, with a warning in the MRP run. A manufactured item uses (3) then (4). A transferred item uses `transferLeadTimeDays`, else the warehouse's transport time. Requires the `items:read` scope. Query parameters: `itemNumber` (string), `warehouseCode` (string), `isActive` (boolean), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/item-warehouses/batch` — Update warehouse settings on many items in one call Updates up to 200 item-warehouse rows per call — for example reorder points recalculated outside Fluit once a month. Each row is a PATCH /preview/items/{itemNumber}/warehouses/{warehouseCode} body plus its address, `itemNumber` and `warehouseCode`, and is validated exactly like that PATCH. The batch only updates rows that exist; it does not set an item up in a new warehouse. The HTTP status describes the request, not the rows: a well-formed batch always answers 200, and every row carries its own `outcome` (`updated` or `failed`) plus an `error` when it failed. A failed row changes nothing on that row and never blocks the others — re-send only the failed rows. Typical row errors: `ItemWarehouse.NotFound` (the item, the warehouse or the pairing does not exist), a validation error naming the field, and `ItemWarehouse.ConcurrentModification` when the row changed while it was written. 4xx is reserved for the batch itself. An `Idempotency-Key` is required like on every POST under /preview; deriving it from the run and the batch index (for example `sha256(runId + ':' + batchIndex)`) makes an interrupted run safe to resume. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. isActive, lotSizing, replenishmentType and importance cannot be null. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Rules are checked against the resulting combination, not field by field, so raising minQuantity and maxQuantity together in one call (10/20 → 30/40) is accepted. Decimals are sent as strings ("12.5"); numbers are accepted too. Enums are sent by name. Quantities are 0 to 99999999999999.9999 and lead times 0 to 365 days. **How MRP reads these settings.** `minQuantity` is the reorder point. When projected stock falls below max(safety stock, minQuantity), MRP proposes replenishment according to `lotSizing`: `LotForLot` fills to the safety stock and ignores the reorder point, `FixedReorderQuantity` orders `reorderQuantity` (required, > 0) in whole multiples, `FillToMax` orders up to `maxQuantity` (required). The safety stock is `safetyStock`, else `calculatedSafetyStock`, else zero. A purchase quantity is then rounded up to the supplier's `minOrderQuantity` and `orderMultiple`. **Which lead time MRP uses for a purchased item**, first one set wins: (1) the lead time on an active purchase agreement line, (2) `leadTimeDays` on the supplier item of the supplier MRP picks (agreement supplier, else the primary supplier) — see GET/PATCH /preview/suppliers/{supplierNumber}/items, (3) `leadTimeDays` on this item-warehouse row, (4) 14 days, with a warning in the MRP run. A manufactured item uses (3) then (4). A transferred item uses `transferLeadTimeDays`, else the warehouse's transport time. Requires the `items:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Monthly reorder point update: ```json { "itemWarehouses": [ { "itemNumber": "670-00001", "warehouseCode": "MAIN", "minQuantity": "40", "maxQuantity": "120" }, { "itemNumber": "670-00002", "warehouseCode": "MAIN", "minQuantity": "15", "safetyStock": "5" } ] } ``` Example request body — Switch to fixed reorder quantity: ```json { "itemWarehouses": [ { "itemNumber": "670-00001", "warehouseCode": "MAIN", "lotSizing": "FixedReorderQuantity", "reorderQuantity": "50", "leadTimeDays": 10 }, { "itemNumber": "670-00003", "warehouseCode": "MAIN", "safetyStock": null } ] } ``` Responses: `200`, `400`, `401`, `403` #### `GET /preview/items` — List items Returns a paginated list of items for the authenticated tenant. Sorted by item number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Filters combine with AND. Enum filters are matched case-insensitively and an unknown value returns 400. Delta sync: ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Newly created items are included even though they have no modifiedDate yet. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Requires the `items:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `PendingApproval`, `Active`, `PhasingOut`, `Discontinued`, `Archived`), `modifiedSince` (date-time), `reference` (string), `referenceType` (string; one of `Manufacturer`, `Oem`, `IndustryStandard`, `Superseded`, `Competitor`, `Barcode`, `Custom`), `itemNumbers` (string), `itemType` (string; one of `StockItem`, `NonStockItem`, `Service`, `Work`, `Consumable`, `Kit`, `Phantom`, `Charge`, `VariantMaster`), `grade` (string; one of `A`, `B`, `C`, `D`), `publishOnWeb` (boolean), `isSellable` (boolean), `isConfigurable` (boolean), `isVariant` (boolean), `categoryCode` (string), `brandCode` (string), `parentItemNumber` (string), `attribute` (string), `attributeValue` (string), `language` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/items` — Create an item Creates a new item for the authenticated tenant. Item number is auto-generated if not provided. The response body is the same representation as GET /preview/items/{itemNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `items:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — name only: ```json { "name": "Widget Pro 3000" } ``` Example request body — Full item with pricing and customs: ```json { "name": "Widget Pro 3000", "itemNumber": "WIDGET-PRO", "description": "High-quality widget for professional use.", "barcode": "7350100500001", "salesPrice": "299.00", "costPrice": "120.00", "hsCode": "84795000", "countryOfOrigin": "SE" } ``` Responses: `201`, `400`, `401`, `403` #### `POST /preview/items/batch` — Create or update many items in one call Loads up to 200 items per call. `itemNumber` is required on every row — unlike POST /preview/items, this endpoint never generates one. The HTTP status describes the request, not the rows: a well-formed batch always answers 200, and every row carries its own `outcome` (`created`, `updated` or `failed`) plus an `error` when it failed. A single bad row therefore never blocks the rest of the batch — re-send only the failed rows. 4xx is reserved for the batch itself: more than 200 rows, a missing scope, malformed JSON. `categoryCode`, `brandCode` and `unitCode` place the item in the catalogue. They are looked up once per batch and must already exist — the batch never creates a category, brand or unit, since a typo would otherwise silently become a new one. A row naming an unknown code fails on its own (`Items.Bulk.UnknownCategoryCode`, `Items.Bulk.UnknownBrandCode`, `Items.Bulk.UnknownUnitCode`) and leaves the rest of the batch alone. Load the catalogue first with POST /preview/categories and POST /preview/brands; GET /preview/reference/units lists the unit codes. **Variants.** A `VariantMaster` row names its axes in `variantAxes`; a variant row names its master in `parentItemNumber` and its place in the matrix in `variantValues`. Put masters before their variants — rows run in order, so a master created earlier in the same batch is found. Each row is one transaction: a variant that cannot be placed (`Variant.IncompleteCombination`, `Variant.DuplicateVariant`, a master that is not a `VariantMaster`) is not created at all. Variant attributes are looked up by name and must exist (`Items.Bulk.UnknownVariantAttribute`); their values are created as needed. `mode` is `upsert` (default), `create` or `update`. An `Idempotency-Key` is required like on every POST under /preview; deriving it from the run and the batch index (for example `sha256(runId + ':' + batchIndex)`) makes an interrupted load safe to resume without duplicates. Requires the `items:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Two rows, upsert: ```json { "items": [ { "itemNumber": "670-00001", "name": "HDMI cable 2 m", "salesPrice": "249.00" }, { "itemNumber": "670-00002", "name": "HDMI cable 5 m", "salesPrice": "319.00" } ] } ``` Example request body — Rows filed under a category and a brand: ```json { "items": [ { "itemNumber": "670-00001", "name": "HDMI cable 2 m", "salesPrice": "249.00", "categoryCode": "HDMI", "brandCode": "KORDZ", "unitCode": "ST", "width": "12.00", "height": "3.50", "depth": "12.00" } ] } ``` Example request body — A master and two variants: ```json { "items": [ { "itemNumber": "655-001", "name": "Verona chair", "itemType": "VariantMaster", "variantAxes": [ "Material", "Finish" ] }, { "itemNumber": "655-001-OAK", "name": "Verona chair, oak oiled", "parentItemNumber": "655-001", "variantValues": { "Material": "Solid oak", "Finish": "Oak oiled" } }, { "itemNumber": "655-001-ASH", "name": "Verona chair, ash blond", "parentItemNumber": "655-001", "status": "PhasingOut", "variantValues": { "Material": "Solid ash", "Finish": "Ash blond" } } ] } ``` Example request body — Update prices on existing items: ```json { "mode": "update", "items": [ { "itemNumber": "670-00001", "salesPrice": "259.00" }, { "itemNumber": "670-00002", "salesPrice": "329.00" } ] } ``` Responses: `200`, `400`, `401`, `403` #### `GET /preview/items/{itemNumber}` — Get an item by item number Returns the full details of an item by its unique item number: all orderable units with barcodes and conversion factors (base unit first), specification attributes, category assignments, cross-references (MPN/OEM/extra barcodes) and, for variants, the parent item and variant attribute values. The list endpoint returns a leaner representation without these collections. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a nested part invalidates it too. Requires the `items:read` scope. Query parameters: `language` (string) Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/items/{itemNumber}` — Update an item Partially updates an item. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. itemNumber cannot be changed here — it is the resource's public key and the address external systems reference. Requires the `items:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/assets` — List item assets Returns a paginated list of the item's public images and documents, primary image first and then by sort order. Only assets marked as public are included. url points either to an external location or to GET /preview/items/{itemNumber}/assets/{assetId}/download (same API key required). Use languageCode to pick language-specific assets where present. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `items:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/items/{itemNumber}/assets` — Upload an item image or document Uploads a product image, a datasheet (PDF) or a CAD drawing (DWG) as multipart/form-data. The field name is 'file'; everything else is a query parameter. Supported types: PNG, JPEG, GIF and WebP up to 50 MB; PDF and DWG up to 100 MB. The file's own bytes decide what it is — a file that only claims to be an image or a PDF is rejected, and a DWG served as application/octet-stream is still recognised. The stored content type is the detected one. Uploaded files are always public: they are what the storefront and this API's list endpoint show. isPrimary=true makes an image the item's primary image, which is what the item's thumbnailUrl points at and what a storefront shows in listings; the item's previous primary image loses the flag. The response is the representation GET /preview/items/{itemNumber}/assets/{assetId} returns, and Location points at that address. This POST takes no Idempotency-Key — a multipart body cannot be hashed the way a JSON one can — so a retry after a network failure may create a second image. List the item's assets to see whether the first attempt landed before retrying, and remove a duplicate with DELETE /preview/items/{itemNumber}/assets/{assetId}. Uploads always append: a new image is sorted after the ones already on the item, so replacing a picture means uploading the new one and deleting the old. Known error codes: ItemAssets.EmptyFile, ItemAssets.FileTooLarge, ItemAssets.UnsupportedType, ItemAssets.NotAnImage, ItemAssets.ContentMismatch, Item.NotFound. **Not idempotent.** A file upload carries no Idempotency-Key: the body cannot be buffered and hashed the way a JSON request can. Retrying after a network failure may therefore create a second copy — list the folder and compare before retrying, or delete the duplicate afterwards. Requires the `items:write` scope. Query parameters: `category` (string), `isPrimary` (boolean), `displayName` (string), `altText` (string), `description` (string), `languageCode` (string) Request body: `multipart/form-data` Responses: `201`, `400`, `401`, `403`, `404`, `413` #### `GET /preview/items/{itemNumber}/assets/{assetId}` — Read one item asset Returns the record for one image or document on the item: its url plus the metadata the listing carries. This endpoint returns JSON, not the file itself — the bytes are fetched from GET /preview/items/{itemNumber}/assets/{assetId}/download, which needs the same API key. This is the address the upload's Location header points at. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `items:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/items/{itemNumber}/assets/{assetId}` — Update an item file's metadata Partially updates an image's or document's metadata without uploading it again, so its url stays the same and links to it keep working. Only provided fields are updated (JSON Merge Patch semantics). Pass null to clear displayName, altText, description or languageCode; category, sortOrder and isPrimary cannot be null. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. category takes an image role for an image and a document category code for a document — the same values as the upload endpoint. isPrimary=true makes the image the item's primary image and the previous one loses the flag. The file itself cannot be replaced here: a new file is a new upload, with a new url. The same files are reachable here as through GET /preview/items/{itemNumber}/assets — public, current images, PDFs and DWGs. Anything else answers 404, the same as an id that does not exist. Known error codes: Item.NotFound, ItemAsset.NotFound, ItemAsset.CategoryDoesNotMatchType, ItemAsset.NotImage, DocumentCategory.CodeNotFound. Requires the `items:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `DELETE /preview/items/{itemNumber}/assets/{assetId}` — Delete an item image or document Removes an image, datasheet (PDF) or CAD drawing (DWG) from the item. Deleting is how you replace a file: the upload endpoint always appends, so a new file lands after the one it was meant to replace. To change a file's category, name or order without replacing it — and without changing its url — use PATCH on the same address instead. Only files this surface manages can be deleted — public, current ones of a type the upload endpoint accepts. An id that belongs to an internal document, a superseded revision, a file type that cannot be uploaded here or another item answers 404, the same as an id that does not exist: what cannot be uploaded here cannot be deleted here. Nothing checks whether anything still points at the url — a storefront or feed that cached it keeps a broken link until it refreshes. Deleting the item's primary image leaves the item without one, and nothing promotes a replacement: thumbnailUrl in GET /preview/items goes null until another image is marked primary. Upload the replacement and mark it primary before deleting if the item should never be without a picture. The deletion leaves a tombstone under GET /preview/deletions with resource 'item-assets' and the business key {itemNumber}/{assetId}, so a client syncing with ?modifiedSince= sees the file disappear. Known error codes: Item.NotFound, ItemAsset.NotFound. Requires the `items:write` scope. Responses: `204`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/assets/{assetId}/download` — Download an item asset Streams the asset file (image or document). Externally hosted assets return a 302 redirect to the external URL. Only assets marked as public can be downloaded. Requires the `items:read` scope. Responses: `200`, `302`, `401`, `403`, `404` #### `PUT /preview/items/{itemNumber}/attributes/{attributeCode}` — Set an item attribute value Sets one attribute value on an item, replacing any previous value. Pass value for a single-value attribute and values for one where isMultiValue is true — the list replaces the whole set, so values left out are removed. The value is checked against the attribute's rules: data type, numeric bounds, pattern, and its list of permitted values where it has one. See GET /preview/attributes for those rules. Send an empty value to clear the attribute. Requires the `items:write` scope. Request body: `application/json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/availability` — Get item stock availability Returns the item's stock availability summed across all active warehouses and broken down per warehouse. availableQuantity is the quantity that can be promised to new orders (on hand minus reservations). Items never stocked in a warehouse return an empty warehouses list with zero totals. The warehouse breakdown is bounded by the tenant's warehouse count and is not paginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `items:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/items/{itemNumber}/categories` — Add a category to an item Files the item under one more category, keeping the categories it already has. The item's categories are listed under categories in GET /preview/items/{itemNumber}. Adding a category the item already has changes nothing and still returns 204, so a sync can re-run without special-casing. To move the item to another main category, use categoryCode on PATCH /preview/items/{itemNumber}: it replaces the main category and leaves every other category untouched. A category counts as main — isPrimary: true on the item — when its category type is the primary one or when it has no category type, so a category created without a type is a main category too. A category type that allows one category per item — the primary one does — rejects a second one here with ItemCategoryAssignments.SingleAssignmentViolation. Known error codes: categoryCode, ItemCategoryAssignments.DirectAssignmentNotAllowed, ItemCategoryAssignments.SingleAssignmentViolation, Item.NotFound. Requires the `items:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Add a category: ```json { "categoryCode": "WEB-SPEAKERS" } ``` Example request body — Add a category at a given position: ```json { "categoryCode": "WEB-SPEAKERS", "sortOrder": 2 } ``` Responses: `204`, `400`, `401`, `403`, `404` #### `DELETE /preview/items/{itemNumber}/categories/{categoryCode}` — Remove a category from an item Takes the item out of one category and leaves its other categories as they are. Removing a category the item is not in is not an error — the end state is the same either way, so a retried call cannot fail on the second attempt. The 404 is about the item or a category code that does not exist at all, not about the link between them. Removing the item's main category leaves it without one, exactly like PATCH /preview/items/{itemNumber} with categoryCode set to null. Known error codes: Item.NotFound, Category.NotFound. Requires the `items:write` scope. Responses: `204`, `401`, `403`, `404` #### `PUT /preview/items/{itemNumber}/features/{code}` — Set a selectable configuration choice on an item Creates or replaces a Selection feature — a choice the buyer makes when ordering the item, such as the seat or back of a chair. The request carries the complete list of options: options are matched on `code`, so an unchanged option keeps its identity, new ones are added and options left out are removed. Sending the same request twice gives the same result, which makes the call safe in a migration that is re-run. An option's `itemNumber` names the item it stands for; that item must exist and is pulled into the bill of materials when the option is chosen. `priceFormula` decides what the choice adds to the price. To charge for the item behind the chosen option — a chair sold as a frame, priced with its seat — send `SEAT_PRIS` for the feature `SEAT`. Every linked item then needs a sales price, or the configuration cannot be priced. Other feature types (measurements, formulas) are set up in the app. Requires the `items:write` scope. Request body: `application/json` (required) Responses: `200`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/price` — Calculate item price Calculates the price for an item via the price engine — the same price an order line would get. quantityBreaks lists the volume price tiers of the applied price list. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `items:read` scope. Query parameters: `customerNumber` (string), `currencyCode` (string), `quantity` (number; default `1`) Headers: `If-None-Match` (string) Responses: `200`, `304`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/relations` — List item relations Returns the item's relations to other items — accessories, spare parts, related products, cross- and up-sells, replacements and graded alternatives. Besides the relations set up on this item (direction Outgoing), the list includes relations set up on another item and marked bidirectional (direction Incoming), since those are shown on this item's product page too. Sorted with outgoing relations first, then by relation type, sort order and item number. Add relations with POST /preview/item-relations/batch. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `items:read` scope. Query parameters: `relationType` (string; one of `Related`, `Accessory`, `CrossSell`, `UpSell`, `SparePart`, `GradedAlternative`, `Replacement`), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/translations` — List an item's translations Returns the item's own texts (base) and, for every active language, the translated name, description, short description and search-engine title and description. A field without a translation is null, and missing lists the fields the item has a text for but the language lacks, so an integration can see which translations remain. Languages without any translation are included. To read an item with its texts already translated, use GET /preview/items/{itemNumber}?language={languageCode}. Requires the `items:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PUT /preview/items/{itemNumber}/translations/{languageCode}` — Set an item's translation Replaces the item's translated texts in one language: name, description, short description and search-engine title and description. A field that is left out, null or empty is removed, and the item's own text is shown in its place — so send every field the language should keep. The language code is case-insensitive and a region is ignored (en-GB is en). Texts set on a channel publication are not language-specific and are shown in every language ahead of these translations. Requires the `items:write` scope. Request body: `application/json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/variants` — List an item's variants Returns the variants belonging to a variant master, e.g. every colour and size of a T-shirt. The master itself declares which axes it varies on — read variantAxes on GET /preview/items/{itemNumber} — and each variant here reports its own values in variantAttributes. Returns 404 only if the item number does not exist; an item that is not a variant master returns an empty page, so a catalogue sync can call this for every item without knowing in advance which ones have variants. The status filter is matched case-insensitively and an unknown value returns 400. Sorted by item number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `items:read` scope. Query parameters: `status` (string; one of `Draft`, `PendingApproval`, `Active`, `PhasingOut`, `Discontinued`, `Archived`), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/warehouses/{warehouseCode}` — Get an item's warehouse settings Returns the reorder point, stock levels, safety stock, lead time, lot sizing, supply policy and replenishment settings for one item in one warehouse. Stock on hand is read from GET /preview/items/{itemNumber}/availability. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. **How MRP reads these settings.** `minQuantity` is the reorder point. When projected stock falls below max(safety stock, minQuantity), MRP proposes replenishment according to `lotSizing`: `LotForLot` fills to the safety stock and ignores the reorder point, `FixedReorderQuantity` orders `reorderQuantity` (required, > 0) in whole multiples, `FillToMax` orders up to `maxQuantity` (required). The safety stock is `safetyStock`, else `calculatedSafetyStock`, else zero. A purchase quantity is then rounded up to the supplier's `minOrderQuantity` and `orderMultiple`. **Which lead time MRP uses for a purchased item**, first one set wins: (1) the lead time on an active purchase agreement line, (2) `leadTimeDays` on the supplier item of the supplier MRP picks (agreement supplier, else the primary supplier) — see GET/PATCH /preview/suppliers/{supplierNumber}/items, (3) `leadTimeDays` on this item-warehouse row, (4) 14 days, with a warning in the MRP run. A manufactured item uses (3) then (4). A transferred item uses `transferLeadTimeDays`, else the warehouse's transport time. Requires the `items:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/items/{itemNumber}/warehouses/{warehouseCode}` — Update an item's warehouse settings Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. isActive, lotSizing, replenishmentType and importance cannot be null. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Rules are checked against the resulting combination, not field by field, so raising minQuantity and maxQuantity together in one call (10/20 → 30/40) is accepted. Decimals are sent as strings ("12.5"); numbers are accepted too. Enums are sent by name. Quantities are 0 to 99999999999999.9999 and lead times 0 to 365 days. **How MRP reads these settings.** `minQuantity` is the reorder point. When projected stock falls below max(safety stock, minQuantity), MRP proposes replenishment according to `lotSizing`: `LotForLot` fills to the safety stock and ignores the reorder point, `FixedReorderQuantity` orders `reorderQuantity` (required, > 0) in whole multiples, `FillToMax` orders up to `maxQuantity` (required). The safety stock is `safetyStock`, else `calculatedSafetyStock`, else zero. A purchase quantity is then rounded up to the supplier's `minOrderQuantity` and `orderMultiple`. **Which lead time MRP uses for a purchased item**, first one set wins: (1) the lead time on an active purchase agreement line, (2) `leadTimeDays` on the supplier item of the supplier MRP picks (agreement supplier, else the primary supplier) — see GET/PATCH /preview/suppliers/{supplierNumber}/items, (3) `leadTimeDays` on this item-warehouse row, (4) 14 days, with a warning in the MRP run. A manufactured item uses (3) then (4). A transferred item uses `transferLeadTimeDays`, else the warehouse's transport time. Requires the `items:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Categories Item categories — the catalogue tree items are filed under. Addressed by code; parentCode nests a category under another, so a tree is loaded parents-first. #### `GET /preview/categories` — List item categories Returns categories ordered by code. `search` matches code and name, case-insensitively and on any part of the value. The response is a paged envelope: `{ items, totalCount, page, pageSize, totalPages, hasPreviousPage, hasNextPage }`. `pageSize` defaults to 50 and is capped at 200. `parentCode` on each row is how the tree is read back — a root category has null. Requires the `categories:read` scope. Query parameters: `search` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/categories` — Create an item category Creates a category that items can be filed under. `code` is the business key: it is what `categoryCode` on an item row references, so it should be stable. `parentCode` nests the category under an existing one. Load a tree parents-first — a parent that does not exist yet is rejected with 400 naming the field, not created implicitly. Known error codes: `ItemCategory.CodeNotUnique`, `categoryCode` (unknown parent). Requires the `categories:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Root category: ```json { "code": "SPEAKERS", "name": "Speakers" } ``` Example request body — Nested category: ```json { "code": "FLOORSTANDING", "name": "Floorstanding speakers", "description": "Full-range speakers that stand on the floor.", "parentCode": "SPEAKERS", "slug": "floorstanding-speakers", "sortOrder": 10 } ``` Responses: `201`, `400`, `401`, `403` #### `GET /preview/categories/{code}` — Get an item category Returns one category by its code, in the same shape as POST /preview/categories. Requires the `categories:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/categories/{code}` — Update an item category Partially updates a category's name, description, slug, sort order and search-result texts. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. code cannot be changed, because it is the resource's address and what item rows reference. name cannot be set to null. slug is normalized the same way as in the admin (lowercase, diacritics folded, spaces to hyphens). Changing it moves the category page to a new URL and automatically creates a 301 from the old address on every active channel, so incoming links and their ranking survive. Passing null makes the category fall back to its code as URL segment, with the same 301. metaTitle and metaDescription are what the category page shows in search results, translated per language like the name. Search engines cut titles around 60 characters and descriptions around 155; longer text is accepted (up to 200 and 500) but may be cut. Pass null to fall back to the name and description. Read the result back with GET /preview/categories/{code}. Requires the `categories:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Brands Brands items carry. Addressed by code. #### `GET /preview/brands` — List brands Returns brands ordered by code. `search` matches code and name, case-insensitively and on any part of the value. The response is a paged envelope: `{ items, totalCount, page, pageSize, totalPages, hasPreviousPage, hasNextPage }`. `pageSize` defaults to 50 and is capped at 200. Requires the `brands:read` scope. Query parameters: `search` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/brands` — Create a brand Creates a brand that items can carry. `code` is the business key: it is what `brandCode` on an item row references, so it should be stable. Known error codes: `Brand.CodeNotUnique`. Requires the `brands:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Code and name: ```json { "code": "KORDZ", "name": "Kordz" } ``` Example request body — With logo and site: ```json { "code": "KORDZ", "name": "Kordz", "description": "Australian maker of HDMI and installation cabling.", "logoUrl": "https://cdn.example.com/brands/kordz.png", "websiteUrl": "https://kordz.com", "sortOrder": 10 } ``` Responses: `201`, `400`, `401`, `403` #### `GET /preview/brands/{code}` — Get a brand Returns one brand by its code, in the same shape as POST /preview/brands. Requires the `brands:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` ### Channels Sales channels — webshops, B2B portals, marketplaces and apps — and which items are published on each, with the channel-specific texts, SEO and ordering. Channels are addressed by channelCode, publications by the pair (itemNumber, channelCode). Publishing is opt-in: an item is only part of a channel's assortment once a publication exists. A channel here carries commercial terms only — currency, warehouse, price list, order type. How the storefront looks (branding, menus, content pages) is not part of this contract. #### `GET /preview/channels` — List sales channels Returns the sales channels the tenant sells through — webshops, B2B portals, marketplaces and apps — with the commercial terms that apply in each: currency, default warehouse, price list and order type. Sorted by channel code. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Filters combine with AND. Enum filters are matched case-insensitively and an unknown value returns 400. A channel carries no presentation — branding, menus and content pages are not part of this contract. What an item looks like in a channel is under GET /preview/channels/{channelCode}/items. Requires the `channels:read` scope. Query parameters: `search` (string), `isActive` (boolean), `type` (string; one of `B2C`, `B2B`, `Marketplace`, `App`, `Store`), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `GET /preview/channels/{channelCode}` — Get a sales channel by code Returns the commercial terms that apply when something is sold through this channel: currency, default warehouse, price list and order type. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `channels:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `GET /preview/channels/{channelCode}/items` — List the items published on a channel The channel's assortment: which items are published, in which order, with the channel-specific texts and SEO that override the item's own. This is the read a storefront builds its catalogue from. Sorted by sort order, then item number and channel code. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. publishDate and unpublishDate are returned as stored and are not applied by this endpoint — a scheduled publication appears here before it goes live, so filter on them if you are rendering a live catalogue. Delta sync: ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Newly created publications are included even though they have no modifiedDate yet. Requires the `channels:read` scope. Query parameters: `isActive` (boolean), `isFeatured` (boolean), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `GET /preview/items/{itemNumber}/channels` — List the channels an item is published on Publishing is opt-in: an item is only visible in a channel once a publication exists, so an empty list means the item is not for sale anywhere. Sorted by sort order, then item number and channel code. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Every content field is null when the channel uses the item's own value, so "not set" stays distinguishable from "set to the same text". Delta sync: ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Newly created publications are included even though they have no modifiedDate yet. Requires the `channels:read` scope. Query parameters: `isActive` (boolean), `isFeatured` (boolean), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/items/{itemNumber}/channels` — Publish an item on a channel Publishing is opt-in: an item is only visible in a channel once this record exists. Every content field is an override — omit it and the channel uses the item's own value. An item can only be published once per channel; publishing it again returns 409. Omitting the slug generates a unique one from the item's name; supplying one that another item in the channel already uses returns 409. The response body is the same representation as GET /preview/items/{itemNumber}/channels/{channelCode}; the canonical URL is returned in the Location header and in links.self. Requires the `channels:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — publish with the item's own content: ```json { "channelCode": "webshop-se" } ``` Example request body — Full — channel-specific content, SEO and scheduling: ```json { "channelCode": "webshop-se", "isActive": true, "name": "Widget Pro 3000 – proffsmodell", "shortDescription": "Vår mest sålda widget.", "metaTitle": "Widget Pro 3000 | Köp online", "metaDescription": "Proffswidget med 5 års garanti. Fri frakt över 500 kr.", "slug": "widget-pro-3000", "showStock": true, "stockDisplayMode": "InStockOutOfStock", "sortOrder": 10, "isFeatured": true, "publishDate": "2026-09-15T00:00:00+02:00" } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/items/{itemNumber}/channels/{channelCode}` — Get an item's publication on a channel Returns the channel-specific content, SEO, stock display and scheduling for one item in one channel. Every content field is null when the channel uses the item's own value. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `channels:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/items/{itemNumber}/channels/{channelCode}` — Update an item's publication on a channel Partially updates one publication. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field — which for the content fields means falling back to the item's own value. isActive, showStock, sortOrder and isFeatured cannot be set to null. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Changing the slug changes the product's public URL and automatically creates a 301 redirect from the old one, so incoming links and their ranking are preserved. itemNumber and channelCode cannot be changed — they are the resource's address; moving a publication to another channel is a delete and a create. Requires the `channels:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `DELETE /preview/items/{itemNumber}/channels/{channelCode}` — Unpublish an item from a channel Removes the publication, so the item is no longer part of the channel's assortment. This discards the channel-specific content, SEO and slug along with it — to take an item out of the shop while keeping that work, patch isActive to false instead. Requires the `channels:write` scope. Responses: `204`, `401`, `403`, `404` ### ContentPages Editorial content in a channel: the home page, plus standing pages, blog posts and news. The last three are the same record told apart by pageType, built from the same sections and carrying the same SEO. Addressed by the pair (channelCode, slug). The home page is built from the same sections but lives on the channel: one per channel, no slug, no publish state, addressed by channel code alone. A page's body is its section list, which is replaced whole with PUT .../config; the metadata is patched separately. Products, categories and suppliers referenced inside a section are named by business key, not by internal id. #### `GET /preview/channels/{channelCode}/content-pages` — List a channel's content pages Returns the channel's editorial pages: standing pages, blog posts and news. All three are the same record told apart by pageType, so one call can fetch everything or be narrowed with ?pageType=. Sorted by sort order, then slug. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Each page carries its full section list under config, with products, categories and suppliers named by business key rather than internal id. publishDate may be in the future: the storefront hides such an article until it passes, but this endpoint returns it either way. Delta sync: ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Requires the `content-pages:read` scope. Query parameters: `pageType` (string; one of `Page`, `BlogPost`, `NewsPost`, `Home`), `isPublished` (boolean), `search` (string), `tag` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403`, `404` #### `POST /preview/channels/{channelCode}/content-pages` — Create a content page Creates a standing page, a blog post or a news item, told apart by pageType. The page is created unpublished and with no sections; add the body with PUT /preview/channels/{channelCode}/content-pages/{slug}/config and make it visible with POST .../publish. publishDate may be in the future to schedule an article. The response body is the same representation as GET on the page; the canonical URL is returned in the Location header and in links.self. Requires the `content-pages:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — a standing page: ```json { "slug": "om-oss", "title": "Om oss" } ``` Example request body — Full — a scheduled blog post: ```json { "slug": "nya-hogtalarserien", "title": "Nya högtalarserien är här", "pageType": "BlogPost", "metaDescription": "Vi släpper en ny serie aktiva högtalare för konferensrum.", "excerpt": "Fyra modeller, samma DSP-plattform.", "featuredImageUrl": "https://cdn.fluit.cloud/media/hero/abc.jpg", "authorName": "Redaktionen", "publishDate": "2026-09-15T08:00:00Z", "tags": [ "produktnyhet", "ljud" ] } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/channels/{channelCode}/content-pages/{slug}` — Get a content page by slug Returns the page with its full section list. Products, categories and suppliers inside the sections are named by business key — item number, category code, supplier number — so the same body can be written back. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the page is unchanged. Requires the `content-pages:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/channels/{channelCode}/content-pages/{slug}` — Update a content page's metadata Partially updates the page's title, SEO, navigation and article fields. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. tags replaces the whole list rather than merging into it. The sections are not touched here — use PUT .../config for the body. Renaming the slug with newSlug changes the page's public URL and automatically creates a 301 redirect from the old one, so incoming links and their ranking are preserved. Changing pageType does the same, because it moves the page between the /blog/... and /pages/... URL families. Requires the `content-pages:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `DELETE /preview/channels/{channelCode}/content-pages/{slug}` — Delete a content page Removes the page and its sections for good. To take a page down while keeping its content, use POST .../unpublish instead. A page with child pages cannot be deleted: move the children to another parent or delete them first. The deletion is reported under GET /preview/deletions as the resource content-pages, with the business key {channelCode}/{slug}, so a client syncing on ?modifiedSince= learns the page is gone. Requires the `content-pages:write` scope. Responses: `204`, `401`, `403`, `404`, `409` #### `PUT /preview/channels/{channelCode}/content-pages/{slug}/config` — Replace a content page's sections Replaces the page body: layout, full-width flag and the whole section list. This is a PUT and not a PATCH because the section list is positional — sending a subset would be ambiguous between "these are the sections now" and "merge these in". Read the page, change what you need, send the whole config back. Keep each section's id when you do: an omitted id creates a new section, so dropping them would replace every section with a copy and lose their identity. This replaces the whole config, so send every field you want kept: an omitted layout or fullWidth falls back to its default rather than to the stored value. sections has no default and must be present — send [] to clear the body deliberately. Products, categories and suppliers are named by business key. An unknown key is rejected with 400 rather than dropped, so a section cannot silently lose half its products. Returns the whole page, the same representation as GET, so the caller sees the result without a second request. No Idempotency-Key is needed — the header is required on POST. Repeating the same body is safe as long as the sections carry their ids; sections sent without one are created afresh on every call. Requires the `content-pages:write` scope. Request body: `application/json` (required) Responses: `200`, `400`, `401`, `403`, `404` #### `POST /preview/channels/{channelCode}/content-pages/{slug}/publish` — Publish a content page Marks the page published and stamps publishedAt. Publishing an already published page is harmless and leaves the original publishedAt alone. An article whose publishDate is still in the future stays hidden in the storefront until that date passes, even once published here. Requires the `content-pages:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `401`, `403`, `404` #### `POST /preview/channels/{channelCode}/content-pages/{slug}/unpublish` — Unpublish a content page Hides the page from the storefront without deleting it. The sections, SEO and slug are kept, so publishing it again restores exactly what was there. Use this rather than DELETE to take something down temporarily. Requires the `content-pages:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `401`, `403`, `404` #### `GET /preview/channels/{channelCode}/home-page` — Read a channel's home page Returns the section list the storefront renders at the channel's root. The home page is not a content page: there is exactly one per channel, it has no slug, and it cannot be unpublished or deleted — which is why it is addressed by channel code alone. The body is the same config a content page carries, with the same section types and the same business-key references, so one renderer covers both. Replace it with PUT on the same URL. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the home page is unchanged. Requires the `content-pages:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PUT /preview/channels/{channelCode}/home-page` — Replace a channel's home page Replaces the home page: layout, full-width flag and the whole section list. This is a PUT and not a PATCH because the section list is positional — sending a subset would be ambiguous between "these are the sections now" and "merge these in". Read the home page, change what you need, send the whole config back. Keep each section's id when you do: an omitted id creates a new section, so dropping them would replace every section with a copy and lose their identity. This replaces the whole config, so send every field you want kept: an omitted layout or fullWidth falls back to its default rather than to the stored value. sections has no default and must be present — send [] to clear the page deliberately, so that a body carrying only layout cannot wipe it by accident. Products, categories and suppliers are named by business key. An unknown key is rejected with 400 rather than dropped, so a section cannot silently lose half its products. Returns the whole home page, the same representation as GET, so the caller sees the result without a second request. No Idempotency-Key is needed — the header is required on POST. Repeating the same body is safe as long as the sections carry their ids; sections sent without one are created afresh on every call. Requires the `content-pages:write` scope. Request body: `application/json` (required) Responses: `200`, `400`, `401`, `403`, `404` ### Redirects URL redirects in a channel's web shop: the old addresses that send visitors on to a new one, applied before the storefront matches a route. Covers the redirects created by hand or over this API and the 301s Fluit records itself when a product, category or page changes slug. Addressed by the pair (channelCode, id): the source path contains slashes and does not fit in a path segment. #### `GET /preview/channels/{channelCode}/redirects` — List a channel's URL redirects Returns the redirects the channel's web shop applies before it matches a route: the ones created by hand or over this API, and the 301s Fluit records itself when a product, category or page changes slug. hitCount and lastHitAt show whether a redirect is still in use. Sorted by fromPath. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. There is no delta sync for redirects: read the full list to reconcile. Requires the `redirects:read` scope. Query parameters: `search` (string), `isActive` (boolean), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/channels/{channelCode}/redirects` — Create a URL redirect Makes the channel's web shop send visitors from fromPath to toPath. Use it when an address stops working: after a move from another platform, or when a product is discontinued. fromPath is normalized (lowercase, leading slash, no trailing slash) and the response carries the stored form. A path can only be redirected once per channel: to change where it leads, update the existing redirect with PATCH instead of creating a second one. Known error codes: UrlRedirect.FromPathAlreadyExists, UrlRedirect.InvalidPath, UrlRedirect.InvalidToPath, UrlRedirect.SelfReference, UrlRedirect.InvalidStatusCode, UrlRedirect.FromPathTooLong, UrlRedirect.ToPathTooLong. Requires the `redirects:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Permanent redirect: ```json { "fromPath": "/old-product", "toPath": "/products/new-product" } ``` Example request body — Temporary redirect to another site: ```json { "fromPath": "/campaign", "toPath": "https://www.example.com/summer-campaign", "statusCode": 302 } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/channels/{channelCode}/redirects/{id}` — Get a URL redirect Returns one redirect, in the same shape as the list and as POST /preview/channels/{channelCode}/redirects. Requires the `redirects:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/channels/{channelCode}/redirects/{id}` — Update a URL redirect Partially updates where a redirect leads, its status code and whether it is active. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. None of the fields can be set to null. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. fromPath cannot be changed: to redirect a different address, create a new redirect and delete this one. Known error codes: UrlRedirect.InvalidPath, UrlRedirect.InvalidToPath, UrlRedirect.SelfReference, UrlRedirect.InvalidStatusCode. Requires the `redirects:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `DELETE /preview/channels/{channelCode}/redirects/{id}` — Delete a URL redirect Removes the redirect, so fromPath answers with whatever the web shop has at that address, often a 404. To stop a redirect while keeping it for later, PATCH it with isActive: false instead. Requires the `redirects:write` scope. Responses: `204`, `401`, `403`, `404` ### Media A channel's media library: the uploaded images and the editorial metadata around them. Files are uploaded as multipart/form-data and addressed by their id — unlike the rest of this API, because a file has no other stable, unique name; two uploads may legitimately be called hero.jpg. The url in the response is what goes into a page section or an article's featuredImageUrl; the file itself is served by the CDN and needs no API key. #### `GET /preview/channels/{channelCode}/media` — List a channel's media library Returns the images uploaded to the channel, newest first. Each entry carries the url the file is served on — that is what goes into a page section or an article's featured image — and the id used to address the record on this API. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Delta sync: ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Requires the `media:read` scope. Query parameters: `folder` (string; one of `Hero`, `Banner`, `Sections`, `Content`, `Documents`), `folderId` (uuid), `includeSubfolders` (boolean), `search` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403`, `404` #### `POST /preview/channels/{channelCode}/media` — Upload a file to the media library Uploads a file as multipart/form-data. The field name is 'file'; everything else is a query parameter. Supported types: PNG, JPEG, GIF, WebP and SVG (max 10 MB), MP4 and WebM (max 50 MB), and PDF (max 20 MB). The size limit is per type: a 10 MB image is nearly always the camera original by mistake, while a 40 MB product video is ordinary. folder decides which part of the storefront the file is filed under and defaults to Content; it only affects filing and filtering, not what the file can be used for. The response is the same representation as GET on the file: its url goes into a page section or an article's featuredImageUrl, its id addresses the record here. The file itself is served by the CDN and needs no API key. This POST takes no Idempotency-Key — a multipart body cannot be hashed the way a JSON one can — so a retry after a network failure may create a second copy; list the folder and compare, or delete the duplicate. Known error codes: ChannelMediaAsset.EmptyFile, ChannelMediaAsset.FileTooLarge, ChannelMediaAsset.UnsupportedType. **Not idempotent.** A file upload carries no Idempotency-Key: the body cannot be buffered and hashed the way a JSON request can. Retrying after a network failure may therefore create a second copy — list the folder and compare before retrying, or delete the duplicate afterwards. Requires the `media:write` scope. Query parameters: `folder` (string), `folderId` (uuid), `title` (string), `altText` (string), `caption` (string) Request body: `multipart/form-data` Responses: `201`, `400`, `401`, `403`, `404` #### `GET /preview/channels/{channelCode}/media/{assetId}` — Read one media file Returns the library record for one file: the url it is served on plus its editorial metadata. This endpoint returns JSON, not the file itself — the bytes are fetched from url, which is served by the CDN and needs no API key. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `media:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/channels/{channelCode}/media/{assetId}` — Update a media file's metadata Partially updates one file's editorial metadata. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear title, altText or caption; folder and folderId cannot be null, and only one of them may be sent. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. The file itself cannot be replaced here — uploading a new file gives it a new url, so replacing one is an upload plus updating whatever referenced the old. Changing folder re-files the asset in the library but does not move the stored file: the url is unchanged, so links already pointing at it keep working. The folder a file was uploaded into therefore stays visible in its url. Requires the `media:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `DELETE /preview/channels/{channelCode}/media/{assetId}` — Delete a media file Removes the library record and the stored file. Nothing checks whether a page still references the url — a section pointing at a deleted file keeps its broken link, so look the file up in the pages that use it first. The deletion leaves a tombstone under GET /preview/deletions with resource 'media', so a client syncing with ?modifiedSince= sees it disappear. Requires the `media:write` scope. Responses: `204`, `401`, `403`, `404` ### SalesOrders Sales orders with lines, fulfilment status and shipments. Addressed by orderNumber. Draft orders are not visible. #### `GET /preview/orders` — List sales orders Returns a paginated list of sales orders for the authenticated tenant. Draft orders are excluded. The status filter is matched case-insensitively and an unknown value returns 400. Sorted by order date descending. channelCode on each order names the sales channel it came in through (null for orders entered in Fluit or created through this API), so ?channelCode= lists the webshop's orders. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `sales-orders:read` scope. Query parameters: `status` (string; one of `Placed`, `Released`, `Closed`, `Cancelled`), `customerNumber` (string), `orderDateFrom` (date), `orderDateTo` (date), `search` (string), `modifiedSince` (date-time), `channelCode` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/orders` — Create a sales order Creates a new sales order for the authenticated tenant. customerNumber and at least one line are required (or set createAsDraft=true to create an empty draft); all other fields default from the customer or tenant settings. backorderBehavior: omit or null to inherit the tenant/customer configured default. Allowed values: CreateBackorder, CancelRemaining, HoldOrder. orderNumber: if omitted a number is auto-generated from the tenant number sequence. Use deliveryAddress to override the delivery address inline; if omitted the customer's default address is used. Optionally include lines to create order lines atomically with the order. Set createAsDraft=true to keep the order in Draft status (e.g. to add more lines via POST /orders/{orderNumber}/lines before placing). The response body is the same representation as GET /preview/orders/{orderNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `sales-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — customer number and one line: ```json { "customerNumber": "CUST-001", "lines": [ { "itemNumber": "WIDGET-A", "quantity": "10" } ] } ``` Example request body — Order with lines and delivery address: ```json { "customerNumber": "CUST-001", "orderDate": "2026-05-25", "currencyCode": "SEK", "customerReference": "PO-2026-042", "lines": [ { "itemNumber": "WIDGET-A", "quantity": "10", "unit": "st" }, { "itemNumber": "WIDGET-B", "quantity": "5", "unitPrice": "299.00" } ], "deliveryAddress": { "name": "Acme AB", "street1": "Storgatan 1", "postalCode": "11122", "city": "Stockholm", "countryCode": "SE" } } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/orders/{orderNumber}` — Get a sales order by order number Returns the details of a sales order by its unique order number. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a nested part invalidates it too. Requires the `sales-orders:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/orders/{orderNumber}/cancel` — Cancel a sales order Cancels a sales order. Only orders that have not been shipped can be cancelled. Requires the `sales-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `401`, `403`, `404`, `409` #### `POST /preview/orders/{orderNumber}/lines` — Add a line to a sales order Adds an order line to an existing sales order. If unitPrice is omitted the price is calculated automatically via the price engine. discountPercent must be between 0 and 100; it overrides the auto-calculated discount, omit it to apply the discount engine rules. The Location header points at the new line's address, orders/{orderNumber}/lines/{lineNumber}. Requires the `sales-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Add order line: ```json { "itemNumber": "WIDGET-B", "quantity": "5", "unit": "st", "unitPrice": "299.00" } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `PATCH /preview/orders/{orderNumber}/lines/{lineNumber}` — Update an order line Partially updates an order line identified by its line number. Only provided fields are updated (JSON Merge Patch semantics); omitted fields are left unchanged. quantity and unitPrice cannot be null; pass null for discountPercent, requestedDeliveryDate, notes or description to clear them. Clearing description makes the line fall back to the item name. Line totals and order totals are recalculated automatically. Unlike the resource-level PATCH endpoints, fields this endpoint does not recognise are ignored rather than rejected, and field names are matched case-sensitively — send them exactly as documented. Requires the `sales-orders:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `DELETE /preview/orders/{orderNumber}/lines/{lineNumber}` — Delete an order line Removes a line from a sales order and releases any stock reservations for it. Order totals are recalculated automatically. Requires the `sales-orders:write` scope. Responses: `204`, `401`, `403`, `404`, `409` #### `POST /preview/orders/{orderNumber}/place` — Place a sales order Transitions a sales order from Draft to Placed, confirming it for processing. Business-rule violations return 400; the Problem Details response includes an `errors` extension array with one entry per violation, each containing a `code` and `description`. Known error codes: `SalesOrder.NoLinesInOrder` (order must have at least one line before it can be placed). Requires the `sales-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/orders/{orderNumber}/release` — Release a sales order Transitions a sales order from Placed to Released, handing it to the warehouse. Runs the same rules as the Release button in Fluit. **Allocation.** Releasing turns the order's soft allocations into hard allocations on specific warehouse locations. Nothing more needs to be called for allocation. What happens to a line that is short depends on the order's backorder behaviour: *create backorder* (default) releases what is in stock and leaves the rest as a backorder that ships later; *cancel remaining* reduces the line to the allocated quantity; *hold order* blocks the release with `SalesOrder.NotFullyAllocated` until everything is covered. **Shipment.** If shipment automation is on for the tenant or the order type, releasing also creates the shipment in the same transaction. Check `GET /preview/orders/{orderNumber}/shipments` after releasing: if a shipment is listed, the warehouse can already pick and you should not call `POST /preview/shipments`. If none is listed, create it with `POST /preview/shipments`; that call answers `Shipment.NoHardAllocations` when every allocated unit is already on a shipment. **Retries.** Send an `Idempotency-Key`. A retry with the same key replays the first response without releasing again. A new request for an order that is already released answers 409 `SalesOrder.InvalidTransition`, so treat 409 on an order you released yourself as done. Business-rule violations return 400; the Problem Details response includes an `errors` extension array with one entry per violation, each containing a `code` and `description`. Known error codes: `SalesOrder.OnHold` (the order is on hold, for example a credit stop), `SalesOrder.LinesAwaitingMeasurement` (a configured line still carries a preliminary measurement; record the measurement, or release it in Fluit with a reason), `SalesOrder.NotFullyAllocated` (hold-order backorder behaviour and not everything is in stock), `SalesOrder.InvalidTransition` (409, the order is not in Placed). Requires the `sales-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/orders/{orderNumber}/shipments` — List shipments for a sales order Returns a paginated list of the shipments fulfilling the order, including carrier tracking numbers once booked. An order can have multiple shipments (partial deliveries) and a shipment can cover multiple orders (consolidated delivery) — only lines belonging to this order are included. Returns an empty items list if fulfilment has not started. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `sales-orders:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` ### Configurations Made-to-order products: read an item's configuration schema, price and validate a set of choices, save it as a configuration and turn it into a sales order. Addressed by configurationNumber. The schema endpoint lives at /preview/items/{itemNumber}/configuration because it belongs to the item, but it is grouped here so the whole configurator flow reads in one place. #### `GET /preview/configurations` — List saved configurations Returns the configurations that have not yet become orders, oldest configuration number first. A configuration disappears from this list once POST /preview/configurations/{configurationNumber}/order has turned it into a sales order — from that point it is an order and is read through GET /preview/orders instead. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. **One removal still has no tombstone: being ordered.** A configuration that has become a sales order is not deleted — it stops matching this list because it is now an order, so it appears in neither the delta nor GET /preview/deletions. Record the `orderNumber` from the response to the /order call, or reconcile against GET /preview/orders, to retire those local copies. The price fields (`unitPrice`, `totalPrice`, …) are **null** in this list — pricing a whole page would mean one price engine run per row. Read a single configuration, or use POST /preview/configurations/calculate, when you need prices. Paginated response: `{ items, totalCount, page, pageSize, totalPages, hasPreviousPage, hasNextPage }`. `?page=` defaults to 1, `?pageSize=` to 50 and is capped at 200. Requires the `configurations:read` scope. Query parameters: `search` (string), `customerNumber` (string), `itemNumber` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/configurations` — Save a configuration Persists a set of choices as a configuration and returns it with a `configurationNumber` — the business key for every follow-up call. Use this once the customer has settled on a configuration that POST /preview/configurations/calculate reports as valid; the calculate endpoint is for the live pricing while they are still choosing. The configuration is validated on save: a missing required value, a number outside its bounds or an option that does not belong to its feature returns `400` with the error code in the `errors` array (`FeatureExplosion.RequiredFeatureMissing`, `FeatureExplosion.ValueOutOfRange`, `FeatureExplosion.InvalidOption`). Validate with calculate first to get all problems at once. `customerNumber` is optional here so an anonymous session can be saved and claimed later, but it must be set before POST /preview/configurations/{configurationNumber}/order will succeed. Add it afterwards by passing `customerNumber` to POST /preview/configurations/{configurationNumber}/reconfigure. The response carries the priced configuration — `unitPrice` and `totalPrice` alongside the choices. Requires an `Idempotency-Key` header like every other POST — reuse the same key on a retry to get the original configuration back instead of creating a duplicate. Requires the `configurations:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — item and quantity only: ```json { "itemNumber": "CURTAIN-PLEAT", "quantity": "1" } ``` Example request body — Full — a measured curtain for a named customer: ```json { "itemNumber": "CURTAIN-PLEAT", "quantity": "2", "customerNumber": "CUST-001", "title": "Living room curtains", "description": "Window 2, measured 2026-07-30", "warehouseCode": "MAIN", "values": [ { "featureCode": "WIDTH", "number": "240" }, { "featureCode": "HEIGHT", "number": "180" }, { "featureCode": "FABRIC", "optionCode": "LINEN-NATURAL" }, { "featureCode": "LINING", "boolean": true }, { "featureCode": "RAIL", "itemNumber": "RAIL-3M" } ] } ``` Responses: `201`, `400`, `401`, `403` #### `POST /preview/configurations/calculate` — Price and validate a configuration Runs the configuration through the same engine and the same price hierarchy a real order uses, without saving anything. Call it on every change while the customer configures. **This is a live price, not a locked quote.** The order re-prices when the configuration is converted, so the same choices give the same `unitPrice` only while the underlying prices hold: a campaign that starts or expires in between, or an agreement that changes, moves the price. The order also always uses the customer's own currency — a `currencyCode` passed here affects the calculation only. Persisting an agreed price is not supported yet; treat a quoted price as valid for the moment it was calculated. **An incomplete configuration is not an error.** Missing required values, numbers outside `minValue`/`maxValue` and options that do not belong to their feature all come back as `200` with `isValid: false` and every problem listed in `validationErrors` (`featureCode`, `code`, `message`), so the app can show them at the right field. The price fields are null in that case. Only structural problems — unknown `itemNumber`, `customerNumber`, `featureCode` or `optionCode` — return `400`. `resolvedValues` carries the derived numbers (area, fabric consumption, …) computed from `Calculated` features, so the app does not have to reimplement the formulas. `unitPrice` excludes VAT, freight and order-level discounts. Note that a configured line is priced as a manual unit price, so automatic customer discounts (the discount engine) do not apply to it; price lists, agreements, campaigns and volume breaks do. A choice whose option carries a `linkedItemNumber` adds that item to the bill of materials but does **not** change `unitPrice` — see the note on `priceImpact` in the configuration schema. This endpoint has no side effects and is the one POST under /preview that does **not** require an `Idempotency-Key` header. Requires the `configurations:read` scope. Query parameters: `includeBreakdown` (boolean; required) Request body: `application/json` (required) Example request body — Minimal — just the item and a quantity: ```json { "itemNumber": "CURTAIN-PLEAT", "quantity": "1" } ``` Example request body — Full — a made-to-measure curtain for a specific customer: ```json { "itemNumber": "CURTAIN-PLEAT", "quantity": "2", "customerNumber": "CUST-001", "currencyCode": "SEK", "values": [ { "featureCode": "WIDTH", "number": "240" }, { "featureCode": "HEIGHT", "number": "180" }, { "featureCode": "FABRIC", "optionCode": "LINEN-NATURAL" }, { "featureCode": "LINING", "boolean": true }, { "featureCode": "RAIL", "itemNumber": "RAIL-3M" }, { "featureCode": "LABEL", "text": "Living room, window 2" } ] } ``` Responses: `200`, `400`, `401`, `403` #### `GET /preview/configurations/{configurationNumber}` — Get a saved configuration Returns one configuration with its chosen values, resolved to feature and option codes, and priced for its customer — `basePricePerUnit`, `configurationSurchargePerUnit`, `unitPrice` and `totalPrice`. Use it to resume a saved configuration without replaying the values through POST /preview/configurations/calculate. Returns `404` once the configuration has become an order — a configuration only exists until it is ordered. Read the resulting order through GET /preview/orders/{orderNumber} instead, using the order number from the response to POST /preview/configurations/{configurationNumber}/order. The price is calculated on read, so it reflects today's price lists and campaigns rather than what was quoted when the configuration was saved. The price fields are omitted if the saved configuration can no longer be priced (e.g. the item's features have changed since). Responses carry a weak ETag; pass it back in `If-None-Match` to get `304 Not Modified` while the response is unchanged. The validator is derived from the response itself, so it covers the calculated price too: if today's price differs from the one in your cached copy you get a fresh `200`, not a `304`. Requires the `configurations:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `DELETE /preview/configurations/{configurationNumber}` — Discard a configuration Deletes a configuration that was never ordered. A configurator produces a lot of abandoned configurations, and this is how the app cleans them up instead of leaving them in the tenant's list. Returns `409` (`Configuration.NotConfigurable`) once the configuration has become an order — what sits behind the order line is production data and is not deletable through this API. Cancel the order with POST /preview/orders/{orderNumber}/cancel instead. Requires the `configurations:write` scope. Responses: `204`, `401`, `403`, `404`, `409` #### `POST /preview/configurations/{configurationNumber}/order` — Turn a configuration into a sales order Creates a sales order with a single line for the configured product, priced at base price plus configuration surcharge through the same price hierarchy POST /preview/configurations/calculate uses. The response is the same representation as GET /preview/orders/{orderNumber}, with the canonical URL in the `Location` header. The line is priced **now**, not from a stored quote: the price engine runs again at conversion, in the customer's own currency. Unchanged choices give the calculated price as long as the underlying prices still hold — a campaign starting or expiring in between will move it. The order is created in Draft. Add freight, extra lines or a delivery address through the order endpoints, then place it with POST /preview/orders/{orderNumber}/place. Behind the line, the configuration becomes a production work order carrying the exploded bill of materials and routing. The configuration itself is consumed: it stops appearing in GET /preview/configurations and its own GET returns `404` afterwards. Store the `orderNumber` from this response — it is the only link back to the configuration you just converted. Known error codes: `409 WorkOrder.NotEstimate` — already ordered. `409 WorkOrder.NoCustomer` — the configuration has no customer; attach one by passing `customerNumber` to reconfigure first. `409 WorkOrder.NoOutputItem` — the configuration has no item. `400 Warehouse.NoDefault` and `400 OrderType.NoDefault` — the tenant has no default warehouse or order type configured; that is a setup problem in Fluit, not something the request can fix. Note that the line is priced as a manual unit price, so automatic customer discounts do not apply to it. Price lists, agreements, campaigns and volume breaks are already reflected in the base price. Requires the `configurations:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No body — order with an open delivery date: ```json {} ``` Example request body — With a requested delivery date: ```json { "requestedDeliveryDate": "2026-09-15" } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/configurations/{configurationNumber}/reconfigure` — Change a saved configuration Changes a saved configuration and re-runs the engine. Every field is optional and omitting one leaves that part alone: - `values` **omitted** keeps the current choices — that is how you change only the quantity. When present it is a **full replacement**, not a patch: anything not in the list is cleared, and an empty array clears every choice. - `quantity` omitted keeps the current quantity. - `customerNumber` omitted keeps the current customer. Set it to claim a configuration that was saved anonymously — a configuration must have a customer before it can be ordered. Returns `200` with the updated configuration, including the recalculated `unitPrice`, rather than `204`. That is a deliberate departure from the usual action-endpoint convention — the whole point of reconfiguring is the new result, and forcing a follow-up GET for it would be wasteful. Returns `409` (`Configuration.NotConfigurable`) once the configuration has become an order: an ordered configuration is frozen, since changing it would silently change what the customer bought. The status is checked before the body, so an ordered configuration returns the conflict regardless of what the payload contains. Change the order line instead, or create a new configuration. Invalid values return `400` with the offending `FeatureExplosion.*` code. Use POST /preview/configurations/calculate first to see all problems at once. Requires the `configurations:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Change the quantity only — the choices are kept: ```json { "quantity": "3" } ``` Example request body — Attach a customer to an anonymously saved configuration: ```json { "customerNumber": "CUST-001" } ``` Example request body — New measurements and a different fabric: ```json { "quantity": "2", "values": [ { "featureCode": "WIDTH", "number": "260" }, { "featureCode": "HEIGHT", "number": "180" }, { "featureCode": "FABRIC", "optionCode": "VELVET-DEEPBLUE" }, { "featureCode": "LINING", "boolean": true } ] } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/items/{itemNumber}/configuration` — Get an item's configuration schema Returns everything needed to render a configurator form for one item: its configurable features, their input types, bounds, defaults and price impact, plus the allowed options for each selection. This is the first call in the configurator flow. Each feature's `featureType` decides which field to send back in `values[]`: `Text` → `text`, `Number` → `number` (bounded by `minValue`/`maxValue`), `Selection` → `optionCode`, `Boolean` → `boolean`, `ItemSelection` → `itemNumber`. `Calculated` features take no input — they are derived from the others and their results come back in `resolvedValues` from POST /preview/configurations/calculate. `features` is not paginated: a configurator cannot render a half-loaded form, so the whole schema always ships in one response. An item with no configurable features returns `isConfigurable: false` and an empty list — filter GET /preview/items with `?isConfigurable=true` to find the configurable ones. Prices here are the tenant base-currency list prices; use POST /preview/configurations/calculate for the real, customer-specific price of a given set of choices. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `configurations:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` ### Suppliers Suppliers with their contact persons and supplier-specific prices, minimum order quantities and lead times. Addressed by supplierNumber. #### `GET /preview/suppliers` — List suppliers Returns a paginated list of suppliers for the authenticated tenant. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Sorted by supplierNumber ascending. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `suppliers:read` scope. Query parameters: `search` (string), `isActive` (boolean), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `POST /preview/suppliers` — Create a supplier Creates a new supplier for the authenticated tenant. Only name is required. supplierNumber: if omitted a number is auto-generated from the tenant number sequence. currencyCode defaults to the tenant base currency and determines the currency of purchase orders and supplier prices. paymentTermCode and deliveryTermCode must match existing reference data (400 if unknown) and become the defaults on purchase orders to this supplier. The response body is the same representation as GET /preview/suppliers/{supplierNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `suppliers:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — name only: ```json { "name": "Nordic Components AB" } ``` Example request body — Full supplier: ```json { "name": "Nordic Components AB", "supplierNumber": "SUP-001", "organizationNumber": "5560001234", "currencyCode": "SEK", "purchaseOrderEmail": "order@nordic-components.se", "phone": "+46812345678", "street1": "Industrigatan 5", "postalCode": "41250", "city": "Göteborg", "countryCode": "SE", "paymentTermCode": "NET30", "deliveryTermCode": "DAP", "leadTimeDays": 14 } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/suppliers/{supplierNumber}` — Get a supplier Returns a single supplier identified by its supplier number (exact match, case-sensitive). Returns 404 Supplier.NotFound if no supplier has that number. paymentTermCode and deliveryTermCode can be passed straight back when creating a purchase order. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a nested part invalidates it too. Requires the `suppliers:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/suppliers/{supplierNumber}` — Update a supplier Partially updates a supplier. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. supplierNumber cannot be changed — it is the resource's address. name and currencyCode cannot be set to null. Requires the `suppliers:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/suppliers/{supplierNumber}/contacts` — List supplier contacts Returns the contact persons registered on a supplier, default contact first, then by name. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `suppliers:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `GET /preview/suppliers/{supplierNumber}/items` — List supplier prices Returns the supplier-specific purchase price, minimum order quantity, order multiple and lead time for each item this supplier can deliver. Prices are expressed in the supplier's currency (see currencyCode on the supplier). validFrom/validTo bound the price period; a row with both null is always valid. Sorted by itemNumber ascending. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `suppliers:read` scope. Query parameters: `itemNumber` (string), `isActive` (boolean), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `PATCH /preview/suppliers/{supplierNumber}/items/{itemNumber}` — Update a supplier's terms for an item Updates the lead time, minimum order quantity and order multiple on the supplier's active price row for the item — the row GET /preview/suppliers/{supplierNumber}/items returns with isActive true. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Decimals are sent as strings ("12.5"); numbers are accepted too. leadTimeDays is 0 to 365 days. MRP uses this lead time for the supplier it picks (an active purchase agreement's supplier, else the primary supplier) before the item-warehouse leadTimeDays; see GET /preview/item-warehouses for the full order. Purchase quantities are rounded up to minOrderQuantity and then to orderMultiple. Requires the `suppliers:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404` ### PurchaseOrders Purchase orders with lines, confirmation status and goods receipts. Addressed by orderNumber. Unlike sales orders, draft purchase orders are visible — an order created through this API starts in Draft. #### `GET /preview/purchase-orders` — List purchase orders Returns a paginated list of purchase orders for the authenticated tenant. The status filter is matched case-insensitively and an unknown value returns 400. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Deletions are not visible here: a deleted record is really gone, so it simply stops appearing, which is indistinguishable from "unchanged". Poll GET /preview/deletions?deletedSince= alongside this endpoint to learn what was removed. Unlike sales orders, Draft purchase orders ARE included — an order created via POST /preview/purchase-orders starts in Draft and the caller has to be able to find it again. Sorted by order date descending, then order number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `purchase-orders:read` scope. Query parameters: `status` (string; one of `Draft`, `Sent`, `PartiallyConfirmed`, `Confirmed`, `PartiallyReceived`, `Received`, `Closed`, `Cancelled`), `supplierNumber` (string), `orderDateFrom` (date), `orderDateTo` (date), `search` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/purchase-orders` — Create a purchase order Creates a new purchase order for the authenticated tenant. Only supplierNumber is required; all other fields default from the supplier or tenant settings. The order is created in Draft status and is NOT sent to the supplier — call POST /preview/purchase-orders/{orderNumber}/send when it is ready to go out. orderNumber: if omitted a number is auto-generated from the tenant number sequence. Lines are created atomically with the order — if any line is rejected, no order is created. On a line, omit unitPrice to use the supplier price list, and omit unit to use the item's base unit (400 if the item has no base unit). The response body is the same representation as GET /preview/purchase-orders/{orderNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Minimal — supplier and one line: ```json { "supplierNumber": "SUP-001", "lines": [ { "itemNumber": "WIDGET-A", "quantity": "100" } ] } ``` Example request body — Full purchase order: ```json { "supplierNumber": "SUP-001", "orderDate": "2026-08-01", "expectedDeliveryDate": "2026-08-15", "warehouseCode": "MAIN", "currencyCode": "SEK", "paymentTermCode": "NET30", "deliveryTermCode": "DAP", "supplierReference": "OUR-REF-4711", "lines": [ { "itemNumber": "WIDGET-A", "quantity": "100", "unit": "st", "unitPrice": "42.50" }, { "itemNumber": "WIDGET-B", "quantity": "25", "expectedDate": "2026-08-22" } ] } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/purchase-orders/{orderNumber}` — Get a purchase order Returns a single purchase order identified by its order number (exact match, case-sensitive), including all lines. Line numbers in the response are the business keys used by the line endpoints, e.g. PATCH /preview/purchase-orders/{orderNumber}/lines/{lineNumber}. confirmedQuantity, confirmedUnitPrice and confirmedDeliveryDate are null until the supplier has confirmed the line. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a nested part invalidates it too. Requires the `purchase-orders:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/purchase-orders/{orderNumber}` — Update a purchase order Partially updates a purchase order header. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. orderNumber and supplierNumber cannot be changed — the order number is the resource's address, and changing supplier would invalidate the prices on every line. orderDate, warehouseCode and currencyCode cannot be set to null. Reference codes that do not exist return 400 with the offending field named. To change lines, use the line endpoints. Requires the `purchase-orders:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/purchase-orders/{orderNumber}/cancel` — Cancel a purchase order Cancels a purchase order. Only orders where nothing has been received can be cancelled — use close instead when goods have already arrived but no more are expected. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `401`, `403`, `404`, `409` #### `POST /preview/purchase-orders/{orderNumber}/close` — Close a purchase order Closes a purchase order so that no further receipts are expected, even if quantities remain outstanding. Use this to settle short deliveries the supplier will not complete. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `401`, `403`, `404`, `409` #### `POST /preview/purchase-orders/{orderNumber}/confirm` — Confirm a purchase order Records that the supplier has confirmed the order, moving it to Confirmed. Pass supplierReference to store the supplier's own order number from their confirmation. The request body is optional. This is a header-level status transition only: it does not populate the per-line confirmation fields (confirmedQuantity, confirmedUnitPrice, confirmedDeliveryDate), which stay null and are reserved for the structured confirmation flow that is not yet part of the public API. If the supplier confirmed different quantities or dates, patch the affected lines (PATCH /preview/purchase-orders/{orderNumber}/lines/{lineNumber}) — note that this changes what is ordered, so the deviation is applied rather than recorded alongside the original values. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Confirm with the supplier's order number: ```json { "supplierReference": "SO-99887" } ``` Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/purchase-orders/{orderNumber}/lines` — List purchase order lines Returns the lines on a purchase order, sorted by line number ascending. The same lines are also embedded in GET /preview/purchase-orders/{orderNumber}; this endpoint exists so that orders with many lines can be paged through. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `purchase-orders:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/purchase-orders/{orderNumber}/lines` — Add a line to a purchase order Adds a line to an existing purchase order. If unitPrice is omitted the price is resolved from the supplier price list for the item. If unit is omitted the item's base unit is used (400 if the item has no base unit). The Location header points at the new line's address, purchase-orders/{orderNumber}/lines/{lineNumber}. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Add purchase order line: ```json { "itemNumber": "WIDGET-B", "quantity": "25", "unit": "st", "unitPrice": "17.90" } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `PATCH /preview/purchase-orders/{orderNumber}/lines/{lineNumber}` — Update a purchase order line Partially updates a purchase order line identified by its line number. Only provided fields are updated (JSON Merge Patch semantics); omitted fields are left unchanged. quantity, unitPrice and unit cannot be null; pass null for expectedDate, promisedDeliveryDate or notes to clear them. Line and order totals are recalculated automatically. The item on a line cannot be changed — delete the line and add a new one instead. Unlike the resource-level PATCH endpoints, fields this endpoint does not recognise are ignored rather than rejected, and field names are matched case-sensitively — send them exactly as documented. Requires the `purchase-orders:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `DELETE /preview/purchase-orders/{orderNumber}/lines/{lineNumber}` — Delete a purchase order line Removes a line from a purchase order and recalculates the order totals. Requires the `purchase-orders:write` scope. Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/purchase-orders/{orderNumber}/lines/{lineNumber}/receive` — Receive goods on a purchase order line Books received goods into stock against a purchase order line. This increases the on-hand quantity, creates an inventory transaction and advances the line and order status (PartiallyReceived, then Received once the full quantity has arrived). locationCode must be a location in the order's receiving warehouse; omit it to use that warehouse's default receiving location. unitCost records what the goods actually cost if it differs from the ordered price — it feeds the inventory valuation and must not be negative. serialNumber and batchNumber are stored on the receipt as supplied; they are not currently validated against the item's tracking type, so send the right one for the item. serialNumbers receives several serial-tracked units in one call: send one number per unit, as many numbers as the quantity, and omit serialNumber. Each number books its own receipt and its own stock record of one unit, exactly as a sequence of single-unit calls would — so at most 100 numbers fit in one call; split a larger delivery across several. Receiving is not reversible through this API; a retry with the same Idempotency-Key replays the original response instead of booking the goods twice. The response contains the created receipt id and the line as it stands after the receipt. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Receive the full outstanding quantity: ```json { "quantity": "100" } ``` Example request body — Receive three serial-tracked units in one call: ```json { "quantity": "3", "serialNumbers": [ "SN-100045", "SN-100046", "SN-100047" ] } ``` Example request body — Partial receipt into a specific location with a batch number: ```json { "quantity": "40", "locationCode": "A-01-02", "unitCost": "42.75", "batchNumber": "B-2026-08-14", "notes": "Short delivery, remainder promised week 34" } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/purchase-orders/{orderNumber}/send` — Send a purchase order Moves a purchase order from Draft to Sent. By default this only records that the order has gone out — nothing is emailed. That is the right behaviour when the order reaches the supplier over EDI or another channel you control. Set sendEmail=true to have Fluit email the purchase order PDF to the supplier; the address defaults to the supplier's purchaseOrderEmail and 400 is returned if neither that nor toEmail is set. Leave subject and message out to have Fluit write them from the tenant's email template, in the same language as the attached PDF. Sending an email is not reversible, so retries with the same Idempotency-Key replay the original response instead of sending again. The request body is optional; POSTing with no body sends without email. Requires the `purchase-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Mark as sent without emailing (default): ```json {} ``` Example request body — Email the purchase order to the supplier: ```json { "sendEmail": true, "subject": "Purchase order PO-2026-00042" } ``` Example request body — Email in English, letting Fluit write the subject and body: ```json { "sendEmail": true, "language": "en" } ``` Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Tickets Support tickets through their whole handling flow: submit one from a contact form, follow its status, update it, exchange messages with the reporter, route it to a team's queue and take it through triage, work, resolution and closure. Addressed by ticketNumber. Tickets created here land in the same queue as tickets created inside the ERP and from the support mailbox. Assignment to a named agent is not exposed — agents are ERP users, and this API routes work by queue instead. Queue codes come from GET /preview/reference/ticket-queues. #### `GET /preview/tickets` — List tickets Returns a paginated list of tickets, sorted by ticketNumber. Use ?customerNumber= to show a customer everything they have reported, ?queueCode= to mirror one team's workload, and ?open=true to skip the finished ones. An unknown customerNumber or queueCode returns an empty page rather than 404 — the filter narrows the collection, it does not address a resource. ?modifiedSince= (ISO 8601 UTC datetime) returns records created or changed at or after that instant, and is the intended way to run an incremental sync. A change anywhere inside the record counts: editing a line moves the parent's modifiedDate too, so no change can hide below the resource level. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Internal notes and the agent-facing history are not part of this representation; messages live under /preview/tickets/{ticketNumber}/comments. Requires the `tickets:read` scope. Query parameters: `search` (string), `status` (string; one of `New`, `Triaged`, `Assigned`, `InProgress`, `WaitingCustomer`, `WaitingInternal`, `OnHold`, `Resolved`, `Closed`, `Cancelled`), `type` (string; one of `ServiceIncident`, `ServiceRequest`, `Maintenance`, `Installation`, `Inspection`, `Rma`, `Warranty`, `Complaint`, `Support`, `Question`, `Internal`, `Firmware`, `FeatureRequest`), `priority` (string; one of `Low`, `Normal`, `High`, `Critical`), `customerNumber` (string), `queueCode` (string), `open` (boolean), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/tickets` — Submit a ticket Creates a support ticket for the authenticated tenant — the endpoint behind a public contact or support form. The ticket lands in the same queue as tickets created inside the ERP and from the support mailbox. type is one of: ServiceRequest, Complaint, Support, Question (default Support). customerNumber is optional; supply it when the reporter is a known customer, and the ticket is linked to that customer. contactEmail is what the confirmation and the answer are sent to — a ticket without it can only be answered by phone. The response body is the same representation as GET /preview/tickets/{ticketNumber}; the canonical URL is returned in the Location header and in links.self. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Submit a ticket: ```json { "title": "Maskinen startar inte", "description": "Efter senaste uppdateringen startar inte maskinen. Displayen är svart.", "type": "Support", "customerNumber": "CUST-001", "contactName": "Anna Andersson", "contactEmail": "anna@acme.example", "contactPhone": "+46701234567" } ``` Responses: `201`, `400`, `401`, `403` #### `POST /preview/tickets/migrate` — Migrate a ticket from another system Creates a ticket that already happened, with its history intact: the date it was created, the status it ended in, how it was resolved and the whole message thread. This is the endpoint for moving ticket history into Fluit when a support system is replaced — use POST /preview/tickets for tickets that are happening now. externalReference is required and must be unique; prefix it with the source system, e.g. 'SuperOffice:112614'. Posting the same reference twice does not create a second ticket — the response is 200 with the ticket that already exists and alreadyExisted = true, so an interrupted migration can simply be run again. The ticket sends no notifications and gets no SLA times: the reporter should not receive a confirmation for a ticket they filed two years ago, and our SLA policies never applied to it. Attach files with POST /preview/tickets/{ticketNumber}/attachments, passing the same original date so the files do not all look like they arrived on migration day. Known error codes: Ticket.ExternalReferenceRequired, Ticket.MigratedCreatedAtRequired, Ticket.MigratedTimestampBeforeCreation, Ticket.CustomerNotFound. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — A closed ticket with one message: ```json { "externalReference": "SuperOffice:112614", "title": "Ingen bild på utgång 3", "createdAt": "2024-12-17T09:43:29", "description": "Matrisen ger ingen bild på utgång 3 efter uppdateringen.", "status": "Closed", "closedAt": "2024-12-20T16:05:00", "contactName": "John Smith", "contactEmail": "john@example.com", "tags": [ "TightAV/Support" ], "messages": [ { "content": "Matrisen ger ingen bild på utgång 3.", "createdAt": "2024-12-17T09:43:29", "authorName": "John Smith" } ] } ``` Responses: `200`, `201`, `400`, `401`, `403` #### `GET /preview/tickets/{ticketNumber}` — Get a ticket by ticket number Returns the current state of a ticket belonging to the authenticated tenant — use it to show a submitter the status of what they reported. Internal notes and the agent-facing history are not part of this representation. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `tickets:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/tickets/{ticketNumber}` — Update a ticket Partially updates a ticket. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. ticketNumber cannot be changed — it is the resource's address. title and priority cannot be set to null. Status is not patchable: it changes through the action endpoints (/triage, /start, /resolve, /close and the rest), which enforce the allowed transitions and write the ticket's history. Changing priority or customer re-evaluates which SLA policy applies and moves the response and resolution deadlines accordingly. Reference keys that do not exist return 400 with the offending field named. Requires the `tickets:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/attachments` — Attach a file to a ticket Uploads a file as multipart/form-data and attaches it to the ticket. The field name is 'file'; everything else is a query parameter. isInternal hides the file from the reporter in the customer portal and defaults to false — a file sent through the API belongs to the conversation unless you say otherwise. createdAt backdates the attachment to when it was attached in the system the ticket was migrated from, and only works on a ticket created through POST /preview/tickets/migrate; without it the file is dated now. File types are checked by extension against the same allowlist the customer portal uses, so executables are refused no matter who uploads them. Maximum 50 MB per file. Known error codes: Ticket.AttachmentTooLarge, Ticket.AttachmentTypeNotAllowed, Ticket.NotMigrated. **Not idempotent.** A file upload carries no Idempotency-Key: the body cannot be buffered and hashed the way a JSON request can. Retrying after a network failure may therefore create a second copy — list the folder and compare before retrying, or delete the duplicate afterwards. Requires the `tickets:write` scope. Query parameters: `isInternal` (boolean), `createdAt` (date-time), `description` (string) Request body: `multipart/form-data` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/cancel` — Cancel a ticket Cancels the ticket — the issue is no longer relevant, the request was withdrawn, or it was a duplicate. Nothing is deleted: the ticket stays readable and can be brought back with /reopen. Use /resolve instead when something was actually done; cancelled tickets are excluded from resolution statistics. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No reason: ```json {} ``` Example request body — With a reason: ```json { "reason": "Dubblett av TKT-2026-00041." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/close` — Close a ticket Closes the ticket and stamps closedAt. A ticket is normally resolved first — /resolve records how it was solved, which is what resolution statistics read — but closing an unresolved ticket is allowed for the cases where nothing was solved and nothing more will happen. A closed ticket can be brought back with /reopen. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Kunden bekräftar att felet är borta." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/tickets/{ticketNumber}/comments` — List ticket messages Returns the conversation on a ticket, oldest first — what the reporter wrote and what the agents answered. This is what a status page or a support widget shows under the ticket. Internal notes are left out unless ?includeInternal=true, which exists for integrations that mirror the whole ticket into another system. They are written by agents for agents and are not safe to show a customer. isFromAgent tells the two apart when rendering; the individual agent is not named. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `tickets:read` scope. Query parameters: `includeInternal` (boolean), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/tickets/{ticketNumber}/comments` — Add a message to a ticket Appends a message to the ticket's conversation — the reporter answering a question, or an integration reporting what happened on its side. The message is visible to the reporter unless isInternal is true. authorName is who the message is from; it is free text because the sender is outside the ERP and has no user account here. The Location header points at the ticket's message collection, which is where the message can be read back — an individual message has no URL of its own. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Reply from the reporter: ```json { "content": "Problemet kvarstår efter omstart." } ``` Example request body — Note from an integration: ```json { "content": "Fjärrdiagnostik körd: felkod E42 kvarstår.", "authorName": "Servicerobot", "isInternal": true } ``` Responses: `201`, `400`, `401`, `403`, `404` #### `POST /preview/tickets/{ticketNumber}/hold` — Pause a ticket Moves the ticket to OnHold: work is deliberately postponed, without waiting for a particular reply or delivery. Use /wait-customer or /wait-internal when there is something specific being waited for — those states are what SLA reporting reads. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled, Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Pausat till efter semesterperioden." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/queue` — Move a ticket to a queue Addresses the ticket to a team's queue so whoever is on duty can pick it up. This is how routing works through this API — assignment to a named agent is not exposed, because the agents are ERP users and this API does not expose users. Omitting queueCode, or sending it as null, takes the ticket out of its queue. The status is untouched: a queued ticket is still unhandled until someone takes it. Valid codes come from GET /preview/reference/ticket-queues. Returns the ticket in its new state. Known error codes: Ticket.CannotAssignToClosedTicket, Ticket.AlreadyCancelled. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Move to a queue: ```json { "queueCode": "SERVICE" } ``` Example request body — Take it out of its queue: ```json { "queueCode": null, "note": "Hanteras direkt av säljaren." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/reopen` — Reopen a ticket Brings a resolved, closed or cancelled ticket back to InProgress — the issue recurred, or it was not actually solved. resolvedAt, closedAt, resolutionType and resolutionNotes are cleared, and the resolution deadline starts over: the old target belonged to the finished round. The recorded root cause is kept — it is what was found out about the product, and it stays true whether or not the fix held. This is the way back into handling; the other transitions refuse to act on a resolved ticket precisely so the reset happens here. Returns the ticket in its new state. Known error code: Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Felet är tillbaka efter två veckor." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/resolve` — Resolve a ticket Marks the ticket resolved, stamps resolvedAt and records how it was solved. The SLA clock stops here and the resolution target is evaluated, so a ticket resolved past its deadline comes back with slaResolutionBreached = true. resolutionType is one of: Fixed, CannotReproduce, Duplicate, WontFix, CustomerResolved, RmaApproved, RmaRejected, Refunded, Replaced, ReturnVisitRequired, ResolvedByInstruction, FirmwareUpdate, ConfigurationChange, NoFaultFound. customerMessage is what the reporter is told, by email and in the customer portal; without it, resolutionNotes is used, as before. rootCause is the internal analysis and is not part of the ticket representation. Set notifyCustomer to false to resolve without emailing the reporter. Resolving does not close the ticket — the reporter may still come back. Close it with /close, or bring it back with /reopen. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled, Ticket.ResolutionRequiredToResolve. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Fixed: ```json { "resolutionType": "Fixed" } ``` Example request body — With notes and root cause: ```json { "resolutionType": "FirmwareUpdate", "resolutionNotes": "Uppdaterad till firmware 2.4.2, felet går inte att återskapa.", "rootCause": "Regression i 2.4.0 vid kall start." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/start` — Start work on a ticket Moves the ticket to InProgress — someone is now working on it. Returns the ticket in its new state, so a mirror does not need a second request. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled, Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Tekniker tilldelad, felsökning påbörjad." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/tags` — Tag a ticket Adds a tag to the ticket for categorisation. Tags are free text and are created by being used — there is no tag register to keep in sync. Adding a tag the ticket already has changes nothing and still returns 200, so a classifier can re-run over the same ticket without special-casing. Returns the ticket with its tags. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Add a tag: ```json { "tag": "garanti" } ``` Responses: `200`, `400`, `401`, `403`, `404` #### `DELETE /preview/tickets/{ticketNumber}/tags/{tag}` — Remove a tag from a ticket Removes a tag from the ticket. Removing a tag the ticket does not have is not an error — the end state is the same either way, so a retried call cannot fail on the second attempt. The 404 is about the ticket, not the tag. Requires the `tickets:write` scope. Responses: `204`, `401`, `403`, `404` #### `POST /preview/tickets/{ticketNumber}/triage` — Triage a ticket Sets the priority on a ticket that is still New and moves it to Triaged — the first pass a support desk makes over its inbox. priority is one of: Low, Normal, High, Critical. The priority decides which SLA policy applies, so the response and resolution deadlines are recalculated here. Returns the ticket in its new state. Known error code: Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Set the priority: ```json { "priority": "High" } ``` Example request body — With a note: ```json { "priority": "Critical", "note": "Produktionsstopp hos kunden." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/wait-customer` — Wait for the reporter Moves the ticket to WaitingCustomer: the handler has asked a question and cannot continue until it is answered. The distinction from WaitingInternal matters for SLA reporting — waiting on the customer is not time the supplier owns. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled, Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Bad kunden fotografera typskylten." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/tickets/{ticketNumber}/wait-internal` — Wait for something internal Moves the ticket to WaitingInternal: work is blocked on spare parts, a supplier delivery or another department rather than on the reporter. Returns the ticket in its new state. Known error codes: Ticket.AlreadyClosed, Ticket.AlreadyCancelled, Ticket.InvalidStatusTransition. Requires the `tickets:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — No note: ```json {} ``` Example request body — With a note: ```json { "note": "Väntar på reservdel, beräknad ankomst vecka 34." } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` ### Shipments Shipments: the physical fulfilment of sales orders, from warehouse release through picking, packing and carrier booking to delivery. Addressed by shipmentNumber. A shipment can cover several orders (consolidation) and an order can have several shipments (partial delivery), so shipments are their own resource rather than a sub-resource of the order. #### `GET /preview/shipments` — List shipments Returns a paginated list of shipments, newest business key last — sorted by shipmentNumber so paging is deterministic. ?modifiedSince= returns both created and modified shipments, so it can be used for delta sync; deletions are reported by GET /preview/deletions. Each shipment carries all of its lines, including lines belonging to other orders on a consolidated shipment — use GET /preview/orders/{orderNumber}/shipments for the order-scoped view. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `shipments:read` scope. Query parameters: `search` (string), `orderNumber` (string), `status` (string; one of `Hold`, `Released`, `Picking`, `Picked`, `ReadyForPickup`, `Packing`, `Packed`, `Booked`, `PickedUp`, `InTransit`, `Delivered`, `Failed`, `Cancelled`), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/shipments` — Create a shipment Creates a shipment covering one or more sales orders and returns it in the same shape as GET /preview/shipments/{shipmentNumber}. The shipment starts in Released status with a line per allocated order line; drive it forward with the pick, pack, book, hand-over and mark-delivered actions. Orders must be released before a shipment can be created for them. Passing several order numbers consolidates them; the orders must agree on delivery address and shipping method. If releasing the order already created a shipment (shipment automation), every allocated unit is on that shipment and this call answers Shipment.NoHardAllocations; check GET /preview/orders/{orderNumber}/shipments first. Known error codes: SalesOrder.NotFound, SalesOrder.InvalidStatus (an order is not Released), SalesOrder.NoDeliveryAddress, Shipment.NoHardAllocations (nothing allocated is left to ship), Shipment.NoDeliverableLines (only service or non-stock lines), Shipment.NoShippingMethod, Shipment.DifferentCustomers, Shipment.DifferentAddresses, Shipment.ShipGroupOrderCannotBeConsolidated. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Single order: ```json { "orderNumbers": [ "SO-2026-00042" ] } ``` Example request body — Consolidated with explicit carrier: ```json { "orderNumbers": [ "SO-2026-00042", "SO-2026-00043" ], "shippingMethodCode": "DHL-PALL", "notes": "Consolidated for weekly pickup" } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/shipments/{shipmentNumber}` — Get a shipment by shipment number Returns a shipment with its delivery address, lines and packages, plus the order numbers it fulfils — more than one on a consolidated shipment. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. The validator follows the whole record, so a change to a line or a package invalidates it too. Requires the `shipments:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `PATCH /preview/shipments/{shipmentNumber}` — Update a shipment Partially updates a shipment. Only provided fields are updated (JSON Merge Patch semantics). Omitted fields are left unchanged. Pass null to clear a nullable field. Unknown fields are rejected with 400, naming the field and listing the ones this endpoint accepts. Use trackingNumber when the freight is booked outside Fluit, in the carrier's own portal or another shipping platform. When Fluit books the shipment (POST /preview/shipments/{shipmentNumber}/book) the number is set for you. Use shippingMethodCode when the carrier is only known once the goods have left — a forwarder choosing between carriers at dispatch, say. The shipping method carries the carrier and its tracking-link template, so setting both fields in the same call gives the customer a working tracking link (trackingUrl on GET). Codes that do not exist return 400 with the field named, and shippingMethodCode cannot be set to null. Changing the shipping method does not rebook the shipment or change the order's freight charge. Both fields can be changed in every status except in transit, including after the shipment is marked delivered, since the waybill often arrives afterwards. A shipment in transit cannot be changed and returns 409. shipmentNumber cannot be changed, because it is the resource's address. The delivery address and the status are not patchable here. Requires the `shipments:write` scope. Request body: `application/json`, `application/merge-patch+json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/shipments/{shipmentNumber}/book` — Book a shipment with the carrier Books the shipment with the carrier behind its shipping method and returns the tracking number and the carrier's booking reference. Returns 200 rather than 204 because the tracking number is the point of the call — it is what gets sent to the customer. Emits the shipment.booked webhook. The call reaches an external carrier system, so it can fail for reasons outside Fluit: a rejected address, a service the receiver's country does not allow, or the carrier being unreachable. Booking is normally triggered automatically when packing is confirmed; call this explicitly to retry a failed booking or when the shipping method does not auto-book. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/shipments/{shipmentNumber}/hand-over` — Hand a shipment over to the carrier Records that the carrier has collected the shipment: status becomes PickedUp and shippedDate is stamped. This is what marks the goods as having left the warehouse, and it moves the underlying sales order lines to delivered. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/shipments/{shipmentNumber}/lines` — List the lines on a shipment Returns every line on the shipment, across all orders it fulfils. Each line carries the orderNumber and orderLineNumber it originates from, so a consolidated shipment can be split back to its orders. Sorted by order number, then order line number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `shipments:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/shipments/{shipmentNumber}/mark-delivered` — Mark a shipment as delivered Records that the shipment reached the recipient: status becomes Delivered and deliveredDate is stamped. Use this when delivery confirmation comes from somewhere other than the carrier integration — a driver app, a signed proof of delivery, or a customer confirming pickup. Emits the shipment.delivered webhook. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/shipments/{shipmentNumber}/pack` — Pack a shipment Packs everything picked on the shipment into a package and, by default, confirms packing. Confirming triggers carrier booking when the shipping method is configured for it, so the response can come back with status Booked and a tracking number already set — read it with GET /preview/shipments/{shipmentNumber}. Returns 200 rather than 204 because the resulting status is the point of the call. Packing is applied line by line and persists as it goes, so the outcome can be partial: lines that could not be packed are reported in skipped[] with a reason. When anything was skipped the shipment is left unconfirmed — confirmed comes back false — so the remaining lines can be handled in the warehouse app and this call repeated. packageNumber may be omitted when the shipment has exactly one package. With several packages it is required — the call fails rather than picking one arbitrarily. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Single package, confirm and book: ```json {} ``` Example request body — Explicit package, leave unconfirmed: ```json { "packageNumber": "PKG-1", "confirmWhenFullyPacked": false } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/shipments/{shipmentNumber}/packages` — List the packages on a shipment Returns the physical packages (parcels) registered on the shipment with their weight, dimensions and per-package tracking numbers. Tracking numbers are null until the shipment is booked with a carrier. Sorted by package number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `shipments:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/shipments/{shipmentNumber}/packages` — Add a package to a shipment Registers a physical package (parcel) on the shipment. Weight and dimensions are what the carrier prices and labels on, so set them when known — nShift rejects bookings for packages without a weight. A shipment needs at least one package before it can be packed. packageNumber is generated within the shipment if omitted. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Weight only: ```json { "weightKg": "12.5" } ``` Example request body — Full parcel: ```json { "packageNumber": "PKG-1", "weightKg": "12.5", "lengthCm": "40", "widthCm": "30", "heightCm": "20", "notes": "Fragile" } ``` Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/shipments/{shipmentNumber}/pick` — Pick the lines on a shipment Picks every outstanding quantity on the shipment from its allocated stock locations. Returns 200 rather than 204 because the outcome is partial by nature: lines that need a serial or batch number, or that lack stock at the allocated location, are reported in skipped[] with a reason and must be picked from the warehouse app. By default the shipment advances to Picked when nothing was skipped; pass completeWhenFullyPicked=false to keep it open and call again. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Pick and complete: ```json {} ``` Example request body — Pick without completing: ```json { "completeWhenFullyPicked": false } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/shipments/{shipmentNumber}/release` — Release a shipment to the warehouse Moves a shipment from Hold to Released so the warehouse can start picking. Shipments created through this API are already Released — this endpoint exists for shipments put on hold. Requires the `shipments:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Invoices Sales invoices with their lines and totals. Addressed by invoiceNumber. #### `GET /preview/invoices` — List invoices Returns a paginated list of sales invoices, newest invoice date first. Lines are omitted from the list — fetch a single invoice via its links.self for the full document. ?modifiedSince= returns both created and modified invoices, so it can be used for delta sync; deletions are reported by GET /preview/deletions. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `invoices:read` scope. Query parameters: `search` (string), `status` (string; one of `Open`, `Booked`, `Cancelled`), `paymentStatus` (string; one of `Unpaid`, `PartiallyPaid`, `Paid`, `AwaitingTaxReduction`), `customerNumber` (string), `invoiceDateFrom` (date), `invoiceDateTo` (date), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `GET /preview/invoices/{invoiceNumber}` — Get an invoice by invoice number Returns the invoice including its lines. Invoices are read-only over the public API — they are created from sales orders inside Fluit. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `invoices:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` ### PriceLists Price lists and their prices per item. Addressed by the price list's code. A line is identified by itemNumber, minQuantity and validFrom, so today's price and a price change from a later date live side by side. The API writes fixed prices only; lines calculated from cost, the item's sales price or another price list are read-only here. The price one customer pays is under GET /preview/items/{itemNumber}/price. #### `GET /preview/price-lists` — List price lists Returns the price lists with code, name, currency, validity and whether they are active. Inactive lists are included, so a list can be checked before it is switched on. The prices are under GET /preview/price-lists/{code}/lines. Sorted by code. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `price-lists:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403` #### `GET /preview/price-lists/{code}/lines` — List the prices in a price list Returns the price list's lines: one price per item, quantity break and start date. A line is identified by itemNumber, minQuantity and validFrom, so an item can have today's price and a price change from a later date as two lines. ?validOn= keeps only the lines that apply on that day, which is how to read the prices in force now. unitPrice is the price that applies: the price as entered on a Fixed line, or the most recently calculated price on a line priced from cost, the item's sales price or another price list (null until it has been calculated). Sorted by item number, minQuantity and validFrom. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `price-lists:read` scope. Query parameters: `itemNumber` (string), `validOn` (date), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/price-lists/{code}/lines/batch` — Set many prices in a price list in one call Sets up to 500 fixed prices per call, with the same rules as PUT /preview/price-lists/{code}/lines/{itemNumber}. Each line is identified by itemNumber, minQuantity and validFrom; a line that exists gets the new price, maxQuantity and validTo, and a line that does not is created. The HTTP status describes the request, not the rows: a well-formed batch always answers 200, and every row carries its own `outcome` (`created`, `updated` or `failed`) plus an `error` when it failed. A bad row never blocks the rest — re-send only the failed rows. Row error codes: PriceListLine.UnknownItem, PriceListLine.DuplicateInBatch, PriceListLine.QuantityRange.Invalid, PriceListLine.ValidityRange.Invalid, PriceListLine.NotFixedPrice, PriceListLine.Ambiguous, and with `mode` set PriceListLine.AlreadyExists or PriceListLine.NoSuchLine. `mode` is `upsert` (default), `create` or `update`. An `Idempotency-Key` is required like on every POST under /preview. Requires the `price-lists:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Two prices: ```json { "lines": [ { "itemNumber": "670-00001", "unitPrice": "249.00" }, { "itemNumber": "670-00002", "unitPrice": "319.00" } ] } ``` Example request body — New prices from 1 January, next to today's: ```json { "lines": [ { "itemNumber": "670-00001", "unitPrice": "259.00", "validFrom": "2027-01-01" }, { "itemNumber": "670-00001", "unitPrice": "239.00", "minQuantity": "10", "validFrom": "2027-01-01" } ] } ``` Example request body — Change prices on lines that already exist: ```json { "mode": "update", "lines": [ { "itemNumber": "670-00001", "unitPrice": "255.00" } ] } ``` Responses: `200`, `400`, `401`, `403`, `404` #### `PUT /preview/price-lists/{code}/lines/{itemNumber}` — Set an item's price in a price list Sets the fixed price for the item in the price list and creates the line if it does not exist. The line is identified by the item, minQuantity and validFrom: omit both for the item's ordinary price, give minQuantity for a quantity break, and give validFrom for a price change from a later date — that becomes a line of its own and leaves the current price alone. On an existing line, maxQuantity and validTo are replaced by what the request says, so leaving them out removes a limit the line had. Only fixed prices are set here. A line priced from cost, the item's sales price or another price list answers 409 PriceListLine.NotFixedPrice rather than silently becoming a fixed price; change such a line in Fluit. To set many prices in one call, use POST /preview/price-lists/{code}/lines/batch. Known error codes: PriceList.NotFound, Item.NotFound, PriceListLine.QuantityRange.Invalid, PriceListLine.ValidityRange.Invalid, PriceListLine.NotFixedPrice, PriceListLine.Ambiguous. Requires the `price-lists:write` scope. Request body: `application/json` (required) Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Quotes Sales quotes with lines and validity. Addressed by quoteNumber. A quote that is accepted or explicitly converted becomes a sales order. #### `GET /preview/quotes` — List sales quotes Returns a paginated list of quotes, sorted by quoteNumber. ?modifiedSince= returns both created and modified quotes, so it can be used for delta sync; deletions are reported by GET /preview/deletions. Unlike sales orders, draft quotes are visible — a quote created through this API starts in Draft. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `quotes:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `Sent`, `Accepted`, `Declined`, `Expired`, `Converted`), `customerNumber` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/quotes` — Create a sales quote Creates a quote in Draft status and returns it in the same shape as GET /preview/quotes/{quoteNumber}. Every line must reference an existing item by itemNumber. Free-text lines are rejected: a sales order line requires an item, so such a line could never be carried into the order the quote is meant to become, and would silently disappear on conversion. description overrides the item's name on the printed quote. Lines carry the price you pass; they are not repriced from the customer's price list, but VAT is calculated per line from the customer's tax setup and the item's tax class. customerContactId must belong to customerNumber; a contact from another customer returns 400. The quote is not sent to the customer by creating it — use POST /preview/quotes/{quoteNumber}/send for that. Known error codes: Customer.NotFound, Item.NotFound, Quote.InvalidValidUntil. Requires the `quotes:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — One line: ```json { "customerNumber": "10001", "lines": [ { "itemNumber": "ART-1001", "quantity": "10", "unitPrice": "249.00" } ] } ``` Example request body — Titled quote with a volume discount: ```json { "customerNumber": "10001", "title": "Autumn delivery", "description": "Prices valid on the stated volume.", "currencyCode": "SEK", "validUntil": "2026-09-30", "lines": [ { "itemNumber": "ART-1001", "quantity": "100", "unitPrice": "249.00", "description": "Widget, blue", "unit": "st", "discountPercent": "12.5" } ] } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/quotes/{quoteNumber}` — Get a quote by quote number Returns a quote with its lines and totals. Once the quote has been converted, convertedToOrderNumber points at the resulting sales order. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `quotes:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/quotes/{quoteNumber}/accept` — Accept a quote Records the customer's acceptance and stamps acceptedAt. Returns the quote so a storefront can show the confirmed state without a second request. Accepting does not create a sales order — call POST /preview/quotes/{quoteNumber}/convert-to-order for that. Keeping the two apart means an acceptance can be recorded the moment the customer clicks, and the order created when your flow is ready for it. Requires the `quotes:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/quotes/{quoteNumber}/convert-to-order` — Convert a quote to a sales order Creates a sales order from an accepted quote and returns the order in the same shape as GET /preview/orders/{orderNumber} — the Location header points at the new order, not back at the quote. The quote moves to Converted and its convertedToOrderNumber is set, so the link between the two is readable from either side. Only lines that reference an item are carried over. A sales order line requires an item, so free-text lines — services, one-off charges — are left behind: compare the returned order's lines against the quote's before relying on the totals. A quote consisting only of free-text lines cannot be converted. The quote must be Accepted, and can only be converted once. Known error codes: Quote.NotFound, Quote.NotAccepted, Quote.AlreadyConverted, Quote.NoConvertibleLines. Requires the `quotes:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `201`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/quotes/{quoteNumber}/decline` — Decline a quote Records the customer's rejection and stamps declinedAt. Returns the quote so a storefront can show the final state without a second request. Send a reason when you have one — it is what makes win/loss reporting usable. Requires the `quotes:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Decline without a reason: ```json {} ``` Example request body — Decline with a reason: ```json { "reason": "Chose a competitor on lead time" } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/quotes/{quoteNumber}/lines` — List quote lines Returns the lines on a quote, sorted by line number ascending. The same lines are also embedded in GET /preview/quotes/{quoteNumber}; this endpoint exists so that quotes with many lines can be paged through rather than arriving as one unbounded array. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `quotes:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/quotes/{quoteNumber}/send` — Send a quote Moves the quote from Draft to Sent. mode=MarkAsSent (the default) only records the transition — use it when your own system delivers the quote to the customer. mode=Email makes Fluit render the quote PDF and email it. With mode=Email the mail leaves immediately and cannot be recalled, so treat a retry as a second email to the customer rather than a no-op. Requires the `quotes:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Mark as sent: ```json {} ``` Example request body — Let Fluit email the quote: ```json { "mode": "Email", "toEmail": "inkop@kund.se", "subject": "Your quote from Fluit", "message": "Hi, please find our quote attached.", "language": "sv" } ``` Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Returns Customer returns (RMA): register a return against a sales order, approve it and receive the goods back into stock. Addressed by returnNumber. #### `GET /preview/returns` — List customer returns Returns a paginated list of customer returns, sorted by returnNumber. ?modifiedSince= returns both created and modified returns, so it can be used for delta sync; deletions are reported by GET /preview/deletions. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `returns:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `Approved`, `Received`, `CreditNoted`, `Closed`, `Cancelled`), `orderNumber` (string), `customerNumber` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/returns` — Create a customer return Registers a return against a sales order in Draft status and returns it in the same shape as GET /preview/returns/{returnNumber}. Omit lines to return the whole order — every delivered line is added at its delivered quantity, which is the common case for a webshop return. Pass lines to return specific quantities. The return and its lines are created in one transaction — if any line fails, nothing is created. Registering a return does not move stock: approve it, then receive it with POST /preview/returns/{returnNumber}/receive. Known error codes: SalesOrder.NotFound, SalesOrderLine.NotFound, SalesReturn.QuantityExceedsDelivered, SalesReturn.NoDeliveredLines. Requires the `returns:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Return the whole order: ```json { "orderNumber": "SO-2026-00042", "reason": "CustomerRemorse" } ``` Example request body — Return two units of one line as damaged: ```json { "orderNumber": "SO-2026-00042", "reason": "DamagedInTransit", "warehouseCode": "HUVUD", "rmaNumber": "RMA-9912", "externalNotes": "Outer carton crushed on arrival.", "lines": [ { "orderLineNumber": 1, "quantity": "2", "condition": "Damaged", "notes": "Both units dented" } ] } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/returns/{returnNumber}` — Get a customer return by return number Returns a customer return with its lines. Each line points back to the sales order line it came from and carries the condition the goods arrived in. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `returns:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/returns/{returnNumber}/approve` — Approve a customer return Moves the return from Draft to Approved, which is the point at which the customer can be told to send the goods back. Returns the return so a storefront can show the approved state without a second request. No stock moves here — that happens on receipt. Requires the `returns:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `GET /preview/returns/{returnNumber}/lines` — List return lines Returns the lines on a return, sorted by line number ascending. The same lines are also embedded in GET /preview/returns/{returnNumber}; this endpoint exists so that a return with many lines can be paged through rather than arriving as one unbounded array. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `returns:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/returns/{returnNumber}/receive` — Receive a customer return Books the returned goods back into the return's warehouse and moves the return to Received. Only lines in Resellable condition go back into available stock; damaged defective and for-disposal lines are booked but not made available. Returns the return so the caller can see the received state without a second request. The resulting movements are readable from GET /preview/inventory/transactions with ?transactionType=Return. Receiving does not credit the customer — the credit note is raised separately. Requires the `returns:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Example request body — Receive today: ```json {} ``` Example request body — Backdate the receipt: ```json { "receivedDate": "2026-08-05" } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` ### Inventory Stock on hand, the stock ledger and the operations that move it: adjustments, relocations and physical counts. Availability per item lives under Items; this tag covers what has happened and how to change it. #### `POST /preview/inventory/adjustments` — Adjust stock at a location Books a manual stock movement and returns the resulting ledger entry in the same shape as GET /preview/inventory/transactions/{transactionId}, so the balance after the movement is readable without a second request. quantity is a signed delta, not a target balance: -3 writes off three units, +3 adds three. Zero is rejected. transactionType defaults to Adjustment; Scrap is the other allowed value and always reduces stock, so it requires a negative quantity. Receipt, Issue and Transfer belong to the purchase, picking and transfer flows and are rejected here — booking them without their counterpart would leave the ledger inconsistent with the documents behind it. Returning goods to stock is done through the return flow, POST /preview/returns/{returnNumber}/receive. The movement is booked at the location's current average cost; there is no way to set a cost here, because neither an adjustment nor a scrap revalues stock. Use a revaluation for that. Reducing below the quantity on hand at that location returns 409. Known error codes: Item.NotFound, Warehouse.NotFound, WarehouseLocation.NotFound, Inventory.InsufficientStock, Inventory.NoStockAtLocation. Requires the `inventory:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Write off three units: ```json { "itemNumber": "ART-1001", "warehouseCode": "HUVUD", "locationCode": "A-01-01", "quantity": "-3", "notes": "Damaged in handling" } ``` Example request body — Scrap one unit: ```json { "itemNumber": "ART-1001", "warehouseCode": "HUVUD", "locationCode": "A-01-01", "quantity": "-1", "transactionType": "Scrap", "notes": "Quality inspection reject, NCR-2026-0042" } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/inventory/batches` — List batches Lists batches (lots) with expiry, status and current stock, sorted by expiry date so the batch that expires first comes first. Filter with itemNumber for a single article, batchNumber to search by lot number (also matches the supplier's own lot number), expiringWithinDays to find batches close to expiry, and inStockOnly to skip batches without remaining stock. Check IsPickable before selling from a batch — a blocked, quarantined, recalled or expired batch reports false. Requires the `inventory:read` scope. Query parameters: `itemNumber` (string), `batchNumber` (string), `expiringWithinDays` (integer), `inStockOnly` (boolean), `page` (integer), `pageSize` (integer) Responses: `200`, `401`, `403`, `404` #### `GET /preview/inventory/counts` — List inventory counts Returns a paginated list of physical inventory counts, sorted by countNumber. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `inventory:read` scope. Query parameters: `status` (string; one of `Draft`, `InProgress`, `Completed`, `Applied`, `Cancelled`), `warehouseCode` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/inventory/counts` — Create an inventory count Creates a physical inventory count and generates its lines from the chosen type and filters. Returns it in the same shape as GET /preview/inventory/counts/{countNumber}. type must be Full, Location, Category, Random or Impulse. Each type has a required companion field: Location takes zoneFilter or locationCodeFilter, Category takes categoryCode, Random takes randomSampleSize and Impulse takes itemNumber. ABC counts are not available through this API yet — the selection is not implemented server-side, and exposing it would produce a count covering the whole warehouse rather than the chosen class. The count starts in Draft; set autoStart=true to go straight to InProgress. Counting itself happens in the warehouse app — this API creates the count and, once it is Completed, posts the differences with POST /preview/inventory/counts/{countNumber}/apply. blindCount=true (the default) hides the recorded quantity from the counter, which is what makes the count independent evidence rather than a confirmation of what the system already believes. Known error codes: Warehouse.NotFound, Item.NotFound, InventoryCount.NoLinesGenerated. Requires the `inventory:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Full count of a warehouse: ```json { "warehouseCode": "HUVUD", "type": "Full" } ``` Example request body — Zone count, started immediately: ```json { "warehouseCode": "HUVUD", "type": "Location", "zoneFilter": "A", "description": "Quarterly count, zone A", "blindCount": true, "autoStart": true } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/inventory/counts/{countNumber}` — Get an inventory count by count number Returns a physical inventory count with its progress: how many lines exist, how many have been counted, how many differ from the recorded quantity and what those differences are worth. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `inventory:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/inventory/counts/{countNumber}/apply` — Apply an inventory count Posts the counted differences to stock: every line whose counted quantity differs from the recorded one produces an Adjustment movement in the ledger, and the count moves to Applied. Returns the count so the caller can see the posted variance without a second request. This is irreversible — the resulting movements can only be undone by counting again or adjusting manually. The count must be Completed; applying a Draft or InProgress count returns 409, as does applying one that is already Applied. Read the resulting movements from GET /preview/inventory/transactions with ?transactionType=Adjustment. Requires the `inventory:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/inventory/items/{itemNumber}/batches/{batchNumber}/block` — Block a batch Blocks a batch so it can no longer be allocated or picked. Use this when an external quality system rejects a lot. The block applies to every location holding the batch. Blocking an already rejected batch returns 400. Requires the `inventory:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` Responses: `204`, `400`, `401`, `403`, `404` #### `GET /preview/inventory/items/{itemNumber}/batches/{batchNumber}/trace` — Trace a batch Returns where a batch came from and which customers received it — one step back and one step forward. Use this to answer a recall question from an external system: given a lot number, which sales orders and customers are affected. DeliveredTo lists one entry per sales order with the quantity that order received. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `inventory:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `POST /preview/inventory/movements` — Move stock between locations Relocates stock within one warehouse and returns the resulting balances at both locations. Returns 200 rather than 201 because a move produces two ledger entries rather than one addressable resource — read them back from GET /preview/inventory/transactions with ?transactionType=Transfer. Both locations must belong to warehouseCode; moving between warehouses is a transfer order, not a movement. Set moveAllocations=true when relocating stock that is already allocated to orders — otherwise the allocations keep pointing at the source location and picking will fail there. Known error codes: Item.NotFound, Warehouse.NotFound, WarehouseLocation.NotFound, Inventory.InsufficientStock. Requires the `inventory:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Move ten units: ```json { "itemNumber": "ART-1001", "warehouseCode": "HUVUD", "fromLocationCode": "INKOMMANDE", "toLocationCode": "A-01-01", "quantity": "10" } ``` Example request body — Move allocated batch-tracked stock: ```json { "itemNumber": "ART-1001", "warehouseCode": "HUVUD", "fromLocationCode": "A-01-01", "toLocationCode": "PLOCK-03", "quantity": "4", "moveAllocations": true, "batchNumber": "L-2026-14", "notes": "Replenish pick face" } ``` Responses: `200`, `400`, `401`, `403`, `409` #### `GET /preview/inventory/transactions` — Read the stock ledger Returns stock movements oldest first, which is the order they should be applied in when mirroring the ledger into another system. Quantity is signed: positive increases stock, negative decreases it. For incremental reads, page with ?since= and deduplicate on transactionId — movements are never modified or deleted after the fact, so the ledger only ever grows. Sorting is by transactionDate then id, so paging stays deterministic when several movements share a timestamp. Costs are in the tenant's base currency. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `inventory:read` scope. Query parameters: `itemNumber` (string), `warehouseCode` (string), `transactionType` (string; one of `Receipt`, `Issue`, `Adjustment`, `Transfer`, `Return`, `Scrap`, `ConsignmentConsumption`, `Revaluation`), `since` (date-time), `until` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `GET /preview/inventory/transactions/{transactionId}` — Read a stock movement Returns one entry from the stock ledger. This is the only resource on the public API addressed by an internal id rather than a business key: a ledger entry has no number of its own — it is identified by the movement it records. The id is the transactionId returned by POST /preview/inventory/adjustments and carried by every row in GET /preview/inventory/transactions. Movements are never modified or deleted after the fact, so a 200 here is stable and safe to cache indefinitely. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `inventory:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` ### TransferOrders Stock transfers between warehouses, from release through shipping to receipt. Addressed by orderNumber. #### `GET /preview/transfer-orders` — List transfer orders Returns a paginated list of stock transfers, sorted by orderNumber. ?modifiedSince= returns both created and modified transfers, so it can be used for delta sync; deletions are reported by GET /preview/deletions. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `transfer-orders:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `Placed`, `Released`, `Picking`, `ReadyToShip`, `InTransit`, `PartiallyReceived`, `Received`, `Cancelled`), `fromWarehouseCode` (string), `toWarehouseCode` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `POST /preview/transfer-orders` — Create a transfer order Creates a stock transfer between two warehouses in Draft status and returns it in the same shape as GET /preview/transfer-orders/{orderNumber}. The transfer and its lines are created in one transaction — if any line fails, nothing is created. Release it with POST /preview/transfer-orders/{orderNumber}/release to allocate stock and let the source warehouse pick. To move stock between locations inside one warehouse, use POST /preview/inventory/movements instead. Known error codes: Warehouse.NotFound, Item.NotFound, TransferOrder.SameWarehouse. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — One line: ```json { "fromWarehouseCode": "HUVUD", "toWarehouseCode": "BUTIK-1", "lines": [ { "itemNumber": "ART-1001", "quantity": "20" } ] } ``` Example request body — Dated with locations and carrier: ```json { "fromWarehouseCode": "HUVUD", "toWarehouseCode": "BUTIK-1", "requestedDate": "2026-09-01", "shippingMethodCode": "INTERN-BIL", "notes": "Weekly store replenishment", "lines": [ { "itemNumber": "ART-1001", "quantity": "20", "fromLocationCode": "A-01-01", "toLocationCode": "INKOMMANDE" } ] } ``` Responses: `201`, `400`, `401`, `403`, `409` #### `GET /preview/transfer-orders/{orderNumber}` — Get a transfer order by order number Returns a stock transfer with its lines, including the requested, picked, shipped and received quantity per line — the four numbers that together say where the goods currently are. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `transfer-orders:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `GET /preview/transfer-orders/{orderNumber}/lines` — List transfer order lines Returns the lines on a transfer order, sorted by line number ascending. The same lines are also embedded in GET /preview/transfer-orders/{orderNumber}; this endpoint exists so that a transfer order with many lines can be paged through rather than arriving as one unbounded array. Returns 404 TransferOrder.NotFound if no transfer order has that number. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `transfer-orders:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` #### `POST /preview/transfer-orders/{orderNumber}/lines/{lineNumber}/pick` — Pick a transfer order line Picks a quantity from a location in the source warehouse and returns the whole transfer order. The stock leaves the location immediately — two picks cannot take the same units — and the line's reservation shrinks by the picked quantity. Release the transfer first. A line can be picked in several calls, from several locations. When everything is picked the transfer moves to ReadyToShip; ship it to send what is picked. Undo picks with the unpick endpoint until the transfer ships. Known error codes: TransferOrder.NotFound, TransferOrderLine.NotFound, TransferOrder.CannotPickInStatus, TransferOrder.CannotPickMoreThanAvailable, TransferOrder.StockReservedForOtherOrders, TransferOrderPick.AmbiguousSource, Inventory.InsufficientStock. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Pick from a location: ```json { "quantity": "5", "fromLocationCode": "A-01-02" } ``` Example request body — Pick a specific batch: ```json { "quantity": "12", "fromLocationCode": "BUF-02", "batchNumber": "L-2026-14" } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/transfer-orders/{orderNumber}/lines/{lineNumber}/receive` — Receive a transfer order line Books a received quantity into the destination warehouse and returns the whole transfer order so the caller can see the remaining quantities without a second request. Receive lines one at a time; partial receipts are allowed and move the transfer to PartiallyReceived until every line is fully received. You cannot receive more than was shipped — ship the quantity first. toLocationCode must be a location in the destination warehouse. serialNumbers receives several serial-tracked units in one call: send one number per unit, as many numbers as the quantity, and omit serialNumber. Each number books its own stock record of one unit, so at most 100 numbers fit in one call; split a larger transfer across several. Known error codes: TransferOrder.NotFound, TransferOrderLine.NotFound, TransferOrder.CannotReceiveInStatus, TransferOrder.CannotReceiveMoreThanShipped. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Request body: `application/json` (required) Example request body — Receive the full quantity: ```json { "quantity": "20" } ``` Example request body — Receive three serial-tracked units in one call: ```json { "quantity": "3", "serialNumbers": [ "SN-100045", "SN-100046", "SN-100047" ] } ``` Example request body — Partial receipt into a named location: ```json { "quantity": "12", "toLocationCode": "INKOMMANDE", "batchNumber": "L-2026-14" } ``` Responses: `200`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/transfer-orders/{orderNumber}/lines/{lineNumber}/unpick` — Undo the picks on a transfer order line Puts every picked unit on the line back on the location it was picked from, at the cost it had when picked, and reserves it for the line again. Returns the whole transfer order. Allowed until the transfer ships; a line with nothing picked is left as it is. Known error codes: TransferOrder.NotFound, TransferOrderLine.NotFound, TransferOrder.CannotUnpickInStatus. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `200`, `401`, `403`, `404`, `409` #### `POST /preview/transfer-orders/{orderNumber}/release` — Release a transfer order Moves the transfer from Draft to Released, which allocates stock at the source warehouse and makes the lines available for picking. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` #### `POST /preview/transfer-orders/{orderNumber}/ship` — Ship a transfer order Ships the picked quantities from the source warehouse: every line's shipped quantity is set to its picked quantity, the stock leaves the source warehouse and the transfer moves to InTransit. Lines that were not picked ship as zero, so pick before shipping if you expect the full quantity to move. Requires the `transfer-orders:write` scope. Headers: `Idempotency-Key` (string; required) Responses: `204`, `400`, `401`, `403`, `404`, `409` ### Receipts Goods receipts — what physically arrived, from purchase orders and inbound transfers. Addressed by receiptNumber. Receiving against a purchase order line is done under PurchaseOrders. #### `GET /preview/receipts` — List goods receipts Returns a paginated list of goods receipts, sorted by receiptNumber. A receipt records what physically arrived; each line points back to the purchase order line or transfer order line it received against. Receipts are created by receiving against a purchase order (POST /preview/purchase-orders/{orderNumber}/lines/{lineNumber}/receive) or a transfer order — there is no endpoint to create one directly, because a receipt without a document behind it is a stock adjustment. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `receipts:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `InProgress`, `Completed`, `Cancelled`), `warehouseCode` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `GET /preview/receipts/{receiptNumber}` — Get a goods receipt by receipt number Returns a goods receipt with its lines: what arrived, in what quantity, at what unit cost and into which location. Each line carries the purchase order number or transfer order number it received against, so the receipt can be matched back to the document that ordered it. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `receipts:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` #### `GET /preview/receipts/{receiptNumber}/lines` — List goods receipt lines Returns the lines on a goods receipt, sorted by line number ascending. The same lines are also embedded in GET /preview/receipts/{receiptNumber}; this endpoint exists so that a goods receipt with many lines can be paged through rather than arriving as one unbounded array. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `receipts:read` scope. Query parameters: `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `401`, `403`, `404` ### WorkOrders Production and service work orders. Read-only. Addressed by workOrderNumber. #### `GET /preview/work-orders` — List work orders Returns a paginated list of work orders, newest first. Read-only — production is planned and reported inside the ERP. ?modifiedSince= returns both created and modified work orders, so it can be used for delta sync. Fetch a single work order via its links.self. Response shape: { items: T[], totalCount: int, page: int, pageSize: int, totalPages: int, hasPreviousPage: bool, hasNextPage: bool }. Page is 1-based. Default pageSize is 50, maximum is 200. Requires the `work-orders:read` scope. Query parameters: `search` (string), `status` (string; one of `Draft`, `Planned`, `Waiting`, `Active`, `Paused`, `Completed`, `Closed`, `Cancelled`, `Estimate`), `type` (string; one of `Manufacturing`, `Project`, `Service`, `Maintenance`), `outputItemNumber` (string), `modifiedSince` (date-time), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` #### `GET /preview/work-orders/{workOrderNumber}` — Get a work order by number Read-only view of a work order: what is being produced, how much is done and when. Reporting output happens inside Fluit or in the mobile app. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `work-orders:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`, `404` ### Sync Endpoints that exist for incremental synchronisation. The list endpoints' ?modifiedSince= covers records that were created or changed; GET /preview/deletions covers the ones that were removed, which no list endpoint can report because the row is gone. #### `GET /preview/deletions` — List deleted records Returns records that have been deleted, so an incremental sync can retire its local copies. This endpoint exists because a deletion cannot be observed from the list endpoints. When a customer, supplier, purchase order or configuration is deleted the row is really gone, so no filter on GET /preview/customers (or any other collection) can surface it — it simply stops appearing, which is indistinguishable from "unchanged since your last poll". Every deletion is recorded here instead, in the same transaction as the deletion itself. Covers: `customers`, `items`, `suppliers`, `orders`, `purchase-orders`, `configurations`, `quotes`, `returns`, `shipments`, `invoices`, `item-channels`, `content-pages`, `media`, `item-assets`. Filter to one collection with `?resource=` (an unknown value returns 400 rather than an empty page). **Running a sync.** Poll `?modifiedSince=` on the list endpoints for creates and updates, and `?deletedSince=` here for removals, using the same timestamp for both. Results are sorted by `deletedDate` ascending, so take the highest `deletedDate` you have seen and pass it as the next `?deletedSince=`. Re-reading from a slightly earlier timestamp is safe: applying a deletion twice has no further effect. `businessKey` is the identifier the record had when it was deleted — customer number, item number, order number. For `item-channels`, `content-pages` and `media` the key is composite and written as the URL tail — `{itemNumber}/{channelCode}`, `{channelCode}/{slug}` and `{channelCode}/{id}` — so the removed record can be addressed directly. It is normally the field you stored, so match on it; `id` is there for clients that keyed on the GUID instead. Omitting `?deletedSince=` returns the full log from the beginning, which is rarely what you want after the first sync. Paginated response: `{ items, totalCount, page, pageSize, totalPages, hasPreviousPage, hasNextPage }`. `?page=` defaults to 1, `?pageSize=` to 50 and is capped at 200. Requires the `sync:read` scope. Query parameters: `deletedSince` (date-time), `resource` (string), `page` (integer; default `1`), `pageSize` (integer; default `50`) Responses: `200`, `400`, `401`, `403` ### Reference Read-only reference data: the valid codes for warehouses, currencies, payment/delivery terms, order types and shipping methods used in other requests. #### `GET /preview/reference/currencies` — List currencies Returns all configured currencies. Use the code field as currencyCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/delivery-terms` — List delivery terms Returns all delivery terms available for use as deliveryTermCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/order-types` — List order types Returns all order types available for use as orderTypeCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/payment-terms` — List payment terms Returns all payment terms available for use as paymentTermCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/shipping-methods` — List shipping methods Returns shipping methods that have a code and can be referenced via shippingMethodCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/tax-classes` — List VAT classes Returns the tenant's VAT classes together with the rate in effect today. An item carries only taxClassCode — join it against this list to get the rate. The rate depends on tax class, country and date, so it is not a property of the item. rate is null for a class with no rate configured in that country. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Query parameters: `countryCode` (string) Headers: `If-None-Match` (string) Responses: `200`, `304`, `400`, `401`, `403` #### `GET /preview/reference/tenant-info` — Get tenant info Returns company details and base configuration (currency, language, timezone) for the authenticated tenant. Address fields use the same names as elsewhere in the API (street1, countryCode) and codes are ISO: countryCode is ISO 3166-1 alpha-2, baseCurrencyCode is ISO 4217 and defaultLanguageCode is ISO 639-1. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the record is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/ticket-queues` — List ticket queues Returns the active ticket queues (team workspaces) and their codes, for use as queueCode when routing a ticket with POST /preview/tickets/{ticketNumber}/queue or PATCH /preview/tickets/{ticketNumber}. Inactive queues are left out because they do not accept new tickets. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/units` — List units of measure Returns all active units of measure. These are the valid values for unit on order lines. An item's own orderable units — the base unit plus any case/pallet units with their conversion factors — are returned by GET /preview/items/{itemNumber} in units[]. Unit codes are stored uppercase and matched case-insensitively. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403` #### `GET /preview/reference/warehouses` — List warehouses Returns all active warehouses available for use as warehouseCode in order creation. Reference lists are bounded configuration data and are returned unpaginated. Responses carry a weak ETag; pass it back in If-None-Match to get 304 Not Modified while the list is unchanged. Requires the `reference:read` scope. Headers: `If-None-Match` (string) Responses: `200`, `304`, `401`, `403`