API och integrationer Referens
EnglishAPI-referens
Hela det publika REST-API:et: 162 anrop i 20 grupper och 254 scheman. Sidorna genereras ur API:ets OpenAPI-dokument och uppdateras automatiskt efter varje driftsättning.
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.
Endpoints
Customers with their contact persons and delivery addresses. Addressed by customerNumber.
Items/products including stock availability, price calculation, units, attributes and assets. Addressed by itemNumber.
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.
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.
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.
Sales orders with lines, fulfilment status and shipments. Addressed by orderNumber. Draft orders are not visible.
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.
Suppliers with their contact persons and supplier-specific prices, minimum order quantities and lead times. Addressed by supplierNumber.
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.
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.
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.
Sales invoices with their lines and totals. Addressed by invoiceNumber.
Sales quotes with lines and validity. Addressed by quoteNumber. A quote that is accepted or explicitly converted becomes a sales order.
Customer returns (RMA): register a return against a sales order, approve it and receive the goods back into stock. Addressed by returnNumber.
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.
Stock transfers between warehouses, from release through shipping to receipt. Addressed by orderNumber.
Goods receipts — what physically arrived, from purchase orders and inbound transfers. Addressed by receiptNumber. Receiving against a purchase order line is done under PurchaseOrders.
Production and service work orders. Read-only. Addressed by workOrderNumber.
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.
Read-only reference data: the valid codes for warehouses, currencies, payment/delivery terms, order types and shipping methods used in other requests.
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
- 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) orfluit_test_sk_(test). - Call the API with the key in the
X-Api-Keyheader:
curl https://api.erp.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.
- Fetch
/.well-known/oauth-protected-resource/previewfor the resource identifier and its supported scopes, and/.well-known/oauth-authorization-serverfor the endpoints. - 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. - Send the user to
/oauth/authorizewithresource=https://api.erp.fluit.cloud/previewand the scopes you need. They sign in, pick the company, and approve. Passing theresourceparameter matters: it is what binds the grant to this API rather than to another of our protected surfaces. - Exchange the code at
POST /oauth/token, then call the API with the access token:
curl https://api.erp.fluit.cloud/preview/items?pageSize=5 \
-H "Authorization: Bearer <access token>"
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:
curl -X POST https://api.erp.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
*Codeis used for reference data (warehouses, payment terms, …). List the valid codes via the Reference endpoints.Resource Key in the URL Customers customerNumberItems itemNumberSales orders orderNumber, lines bylineNumberSuppliers supplierNumberPurchase orders orderNumber, lines bylineNumberTickets ticketNumberSales orders and purchase orders live under different paths (
/ordersand/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 with400, 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'sdecimalPlacesfor how many decimals a quantity may have. -
Dates and times. Dates are ISO 8601 (
2026-06-11). Timestamps are UTC and carry theZdesignator (2026-06-09T22:07:47.7639194Z), sonew Date(...)in JavaScript anddatetime.fromisoformat(...)in Python both resolve them to the right instant without any correction on your side. Request parameters are equally forgiving:modifiedSinceacceptsZ, 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:
baseUnitCodeis null for items with no unit configured,salesPriceis null for items with no list price, andmodifiedDateis null for records that have never been changed since creation. When syncing onmodifiedDate, fall back tocreatedDate. -
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 returns400rather 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.POSTresponses return the same representation as the correspondingGET, with the URL in theLocationheader 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
nullto clear a nullable field; omitted fields are left unchanged. A field that is not part of the endpoint's contract is rejected with400, 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.
salesPriceis the item's list price, not the price anyone pays. It ignores price lists, customer agreements, campaigns and volume breaks. CallGET /preview/items/{itemNumber}/priceto 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. UsesalesPriceonly where you genuinely want an uncontracted reference price.- The item list carries no stock.
GET /preview/itemsreturns no quantity at all. Availability has its own endpoints:GET /preview/inventory/availabilitycovers many items in one call — pass up to 200 item numbers in?itemNumbers=, or page through the whole stocked catalogue — andGET /preview/items/{itemNumber}/availabilitycovers 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 thatavailableQuantity(on hand minus reservations) is the number you can promise — notquantityOnHand.
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
POST /preview/purchase-orderswithsupplierNumberand lines. The order is created inDraftand nothing is sent to the supplier yet. OmitunitPriceto use the supplier price list, andunitto use the item's base unit.POST /preview/purchase-orders/{orderNumber}/sendmoves it toSent. 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.POST /preview/purchase-orders/{orderNumber}/confirmwhen the supplier confirms. If they came back with different quantities or dates,PATCHthe affected lines first.POST /preview/purchase-orders/{orderNumber}/lines/{lineNumber}/receiveas goods arrive. Each call books stock, creates an inventory transaction and advances the line and order toPartiallyReceivedand thenReceived. Call it once per delivery for partial deliveries.POST /preview/purchase-orders/{orderNumber}/closeif 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
POST /preview/ticketswithtitle,descriptionand — if you have it — the reporter'scontactEmailandcustomerNumber. That is the whole contract; the endpoint is meant to sit behind a contact form on your own site.- 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.
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.
GET /preview/items?isConfigurable=truefinds the items that have a configurator.GET /preview/items/{itemNumber}/configurationreturns the whole form definition in one call: the features, their input types and bounds, and the selectable options. Each feature'sfeatureTypetells you which field to send back —Number→number,Selection→optionCode,Boolean→boolean,ItemSelection→itemNumber,Text→text.Calculatedfeatures take no input.POST /preview/configurations/calculateon every change while the customer configures. It returns the price for the current choices plusresolvedValues— 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 as200withisValid: falseand every problem listed invalidationErrors, ready to show at the right field. This is the one POST that does not require anIdempotency-Key.POST /preview/configurationsonce the customer is happy. The response carries aconfigurationNumber— the business key for everything that follows — and the price.customerNumbermay be left out here and attached later, which is what a storefront that configures before asking who the customer is needs.POST /preview/configurations/{configurationNumber}/reconfigureto change it later. Every field is optional: omitvaluesto keep the choices and change only the quantity, passcustomerNumberto claim an anonymous configuration. Whenvaluesis present it replaces the whole set. The response is the updated, repriced configuration.POST /preview/configurations/{configurationNumber}/orderturns it into a sales order and returns the order. Add freight or more lines through the order endpoints, thenPOST /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
calculateand 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; acurrencyCodepassed tocalculateaffects 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'spriceImpactis deliberately ignored. To make a choice cost more, give it apriceImpactwithout a linked item, or drive the price from aCalculatedfeature.
Pagination and syncing
List endpoints are paginated with ?page= (1-based) and ?pageSize= (default 50, max 200)
and respond with:
{ "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 = <timestamp stored at the start of the previous run>
now = <timestamp now, stored for the next run>
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: trueheader) instead of e.g. creating a duplicate order after a network timeout. - Reusing a key for a different endpoint or body returns
422 Unprocessable Entitywithcode: "idempotencyKey.conflict". - Concurrent requests with the same key are serialised; if the first is still running
after 30 s the second receives
503with aRetry-Afterheader. - 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):
{
"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.