API and integrations Reference
SvenskaAPI reference
The whole public REST API: 134 calls across 17 groups and 218 schemas. The pages are generated from the API's OpenAPI document and refreshed automatically after every deployment.
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 API key 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 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.
Getting started
- 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_..."
Scopes
A key 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.
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 key 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.
- Create your first order (note the required
Idempotency-Keyon 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. The one exception is
POST /preview/configurations/calculate, which has no side effects and is a POST only
because its input does not fit in a URL:
- 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.
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 or invalid API key |
| 403 | The API key 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 API key (fixed window). 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.