# Asstio Public API Read-only access to your Asstio data, for your own applications and AI assistants. Two ways to talk to it: - **REST API** — `GET / POST` requests against versioned endpoints under `/api/v1/*`. Use this from servers, scripts, or web apps. - **MCP server** — the same data exposed as a set of read-only tools an AI assistant (Claude Desktop, Cursor, Cline, custom agents) can call. Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over streamable-HTTP. Both share one authentication model, one set of underlying data shapes, and one stability contract. --- ## Status & versioning The current version is **v1** and is stable. We will only make **additive** changes inside v1 — new endpoints, new optional fields, new query parameters. Existing field names, types, and default behaviours will not change inside v1. Breaking changes will land in v2 at a different URL prefix (`/api/v2/*`). v1 will keep running until our last integrator has moved. Every response under `/api/v1/*` carries an `X-Asstio-Api-Version: v1` header. Pin against it if you want defence-in-depth against accidental upgrades. --- ## Authentication Every request needs a **Zitadel JWT Bearer token** in the `Authorization` header. The tenant the token belongs to is derived from its `InstanceId` claim — you never pass a tenant id on the wire. ```http Authorization: Bearer eyJhbGciOi... ``` ### How to get a token | Use case | Mechanism | | --- | --- | | An Asstio user calling from the browser | They already have a token — your in-browser code can read it from the existing session. | | A back-end service / cron job | Provision an **app-password** through the Asstio identity service (`Inställningar > Användare > App-lösenord`). Exchange the app-password for a Zitadel token at the documented Zitadel token endpoint. Cache the token until just before it expires. | | An AI assistant via MCP | Either let the MCP client do the OAuth dance itself using our discovery endpoint (see [MCP authentication](#mcp-authentication)), or paste a pre-fetched Bearer into the client's config. | > Don't embed user passwords. App-passwords are scoped, revocable, and intended for machine-to-machine use. ### Rate limits - **REST API**: no per-user limit today. Heavy clients should still page through results rather than asking for everything at once. - **MCP server**: **60 tool calls per minute** per JWT `sub` claim, sliding window. Bursts above the limit get HTTP `429` with `Retry-After: 60`. --- ## Conventions - **Encoding**: UTF-8 JSON in, UTF-8 JSON out. No XML, no form-encoding. - **Dates**: ISO 8601 with timezone (`2026-05-20T00:00:00Z`). When you omit the time component on a query parameter (`2026-05-20`), we treat it as midnight UTC. - **Money**: decimals serialised as JSON numbers. Two decimal places of precision. No currency symbol — the row carries `currencyIsoCode` separately. - **Booleans**: `true` / `false`. - **Nulls**: optional fields may come back as `null` rather than be omitted. Don't rely on field absence. ### Pagination Every list endpoint returns a `PageResponse` envelope: ```json { "items": [ /* T[] */ ], "nextCursor": "AQABBBgAAAA=", "pageSizeApplied": 50, "truncated": true } ``` - `nextCursor` — pass this back as `?cursor=...` to get the next page. `null` when there is no next page. - `pageSizeApplied` — the actual page size used (we clamp `pageSize` to the per-endpoint max — typically 100, 200 for warehouse stock). - `truncated` — `true` when more rows are available beyond this page. Cursors are opaque base64 — don't parse them. They are stable across deploys as long as the underlying query is unchanged; if a query shape changes, a server upgrade may invalidate cursors and you'll restart from page 1. ### Document model in Asstio Sales orders, sales invoices, sales credits, and purchase orders are all **rows in one `Orders` table**, discriminated by `OrderStatusType.SystemName`: | `SystemName` | Document | | --- | --- | | `SALESORDER` | Sales order (booked, not yet shipped) | | `SALESINVOICE` | Sales invoice (shipped / billed) | | `SALESCREDIT` | Credit note (return / refund) | | `PURCHASEORDER` | Purchase order to a supplier | In addition, every `OrderStatus` row carries three boolean flags that define what counts as what for reporting: | Flag | Meaning | | --- | --- | | `IsOrderIntake = 1` | This document represents real order intake — included whether the goods are still pending (`SALESORDER`) or already shipped (`SALESINVOICE`). Defines the order book. | | `IsSales = 1` | This document counts towards revenue. Excludes preview / draft / void statuses. | | `IsPurchase = 1` | An active purchase order (open or in-process), not closed / cancelled. | The header endpoints (`/sales-orders`, `/sales-invoices`, `/purchase-orders`) default their filter to the natural flag for their document type, so out-of-the-box numbers match Asstio's BI. You can override via a `statusKind` query parameter or by pinning to an explicit `statusId`. --- ## REST API reference Base URL: `https://purchase.svc.order.asstio.com/api/v1/`. The same host serves every tenant — your tenant is selected by the `iid` (InstanceId) claim inside your token, not by hostname. ### `GET /api/v1/sales-orders` Sales order headers. Defaults to the order book (statuses with `IsOrderIntake=1`). | Query parameter | Type | Notes | | --- | --- | --- | | `customerId` | int | Match a specific customer. | | `customerName` | string | Substring match on the customer name. | | `currency` | string | Currency ISO code (substring match). | | `statusId` | int | Exact `OrderStatus.Id`. | | `statusKind` | enum | `intake` (default) \| `sales` \| `any`. | | `orderDateFrom` / `orderDateTo` | ISO date | Range on `dateOrder`. | | `deliveryDateFrom` / `deliveryDateTo` | ISO date | Range on `dateDelivery`. | | `invoiceDateFrom` / `invoiceDateTo` | ISO date | Range on `dateInvoice`. | | `totalMin` / `totalMax` | decimal | Range on order total (ex VAT). | | `q` | string | Free-text query — id, invoice number, customer name, or order name. | | `cursor` | string | Pagination cursor. | | `pageSize` | int | Default 50, max 100. | **Response item** — `SalesOrderDto`: ```jsonc { "id": 18743, "name": "Webshop order 2026-05", "customerId": 412, "customerName": "Acme Ltd", "customerOrgno": "556677-8899", "statusId": 31, "statusName": "Bekräftad", "dateOrder": "2026-05-12T00:00:00Z", "dateDelivery": "2026-05-25T00:00:00Z", "dateInvoice": null, "dateDue": null, "totalExVat": 18250.00, "totalVat": 4562.50, "totalIncVat": 22812.50, "openAmount": 22812.50, "isLate": false, "isFullyPaid": false, "hasShipmentDiscrepancy": false, "currencyIsoCode": "SEK" } ``` ### `GET /api/v1/purchase-orders` Purchase orders (orders TO suppliers). Defaults to active POs (`IsPurchase=1`). | Query parameter | Type | Notes | | --- | --- | --- | | `vendorId` | int | Match a specific vendor. | | `vendorName` | string | Substring match on the vendor name. | | `currency` | string | Currency ISO code. | | `statusId` | int | Exact status id. | | `statusKind` | enum | `purchase` (default) \| `any`. | | `orderDateFrom` / `orderDateTo` | ISO date | Range on `dateOrder`. | | `deliveryDateFrom` / `deliveryDateTo` | ISO date | Range on `dateDelivery`. | | `totalExVatMin` / `totalExVatMax` | decimal | Range on order total (ex VAT). | | `openToInvoiceMin` / `openToInvoiceMax` | decimal | Range on value received but not yet invoiced. | | `q` | string | Free-text query. | | `cursor` / `pageSize` | | Default 50, max 100. | **Response item** — `PurchaseOrderDto`: ```jsonc { "id": 92044, "purchaseNumber": 2026131, "vendorId": 78, "vendorName": "Northwind Components AB", "vendorOrgno": "112233-4455", "statusId": 64, "statusName": "Beställd", "dateOrder": "2026-05-02T00:00:00Z", "dateDelivery": "2026-05-30T00:00:00Z", "dateInvoice": null, "dateDue": null, "orderTotalExVat": 42500.00, "orderTotalIncVat": 53125.00, "receivedQtyPercent": null, "receivedValueExVat": 12700.00, "invoicedValueExVat": 0.00, "openToInvoiceExVat": 12700.00, "currencyIsoCode": "SEK" } ``` ### `GET /api/v1/sales-invoices` Sales invoice headers. Defaults to revenue rows (`IsSales=1`). Set `documentType=credit` to query credit notes, `both` for invoices + credits combined. | Query parameter | Type | Notes | | --- | --- | --- | | `customerId` | int | | | `customerName` | string | | | `currency` | string | | | `statusId` | int | | | `statusKind` | enum | `sales` (default) \| `any`. | | `documentType` | enum | `invoice` (default) \| `credit` \| `both`. | | `invoiceDateFrom` / `invoiceDateTo` | ISO date | | | `dueDateFrom` / `dueDateTo` | ISO date | | | `totalMin` / `totalMax` | decimal | | | `q` | string | | | `cursor` / `pageSize` | | Default 50, max 100. | **Response item** — `SalesInvoiceDto`: ```jsonc { "id": 88312, "invoiceNumber": 2026301, "customerId": 412, "customerName": "Acme Ltd", "customerOrgno": "556677-8899", "statusId": 47, "statusName": "Skickad", "dateInvoice": "2026-05-01T00:00:00Z", "dateDue": "2026-05-31T00:00:00Z", "dateOrder": "2026-04-28T00:00:00Z", "totalExVat": 12000.00, "totalVat": 3000.00, "totalIncVat": 15000.00, "openAmount": 15000.00, "paidAmount": 0.00, "isLate": false, "isFullyPaid": false, "currencyIsoCode": "SEK", "documentType": "invoice" } ``` ### `POST /api/v1/sales-invoice-lines` Line items across sales invoices — the answer to "what is sold." Defaults to lines from invoices with `IsSales=1`. Use a `POST` body rather than query parameters because the filter set is wide. Request body: ```jsonc { "from": "2026-01-01T00:00:00Z", "to": "2026-05-20T00:00:00Z", "customerId": 412, "productId": null, "statusId": null, "includeNonSales": false, "cursor": null, "pageSize": 100 } ``` **Response item** — `SalesInvoiceLineDto`: ```jsonc { "invoiceId": 88312, "invoiceNumber": 2026301, "lineId": 410551, "productId": 9921, "productNumber": "WID-12", "description": "Widget, large", "quantity": 4.0, "qtyDelivered": 4.0, "price": 3000.00, "discount": 0.0, "sumExVat": 12000.00, "sumIncVat": 15000.00, "dateInvoice": "2026-05-01T00:00:00Z", "customerId": 412, "customerName": "Acme Ltd", "currencyIsoCode": "SEK" } ``` ### `GET /api/v1/open-order-lines` Sales order lines with something still left to deliver. Returns `qtyOrdered`, `qtyDelivered`, and server-computed `qtyLeftToDeliver`. Filtered to `SALESORDER + IsOrderIntake=1 + qtyDelivered < qtyOrdered` by default. | Query parameter | Type | Notes | | --- | --- | --- | | `customerId` | int | | | `productId` | int | | | `warehouseId` | int | Only lines on products stocked in this warehouse. | | `includeFullyDelivered` | bool | If `true`, also return fully-delivered lines. Default `false`. | | `cursor` / `pageSize` | | Default 50, max 100. | **Response item** — `OpenOrderLineDto`: ```jsonc { "orderId": 18743, "orderName": "Webshop order 2026-05", "lineId": 411099, "productId": 9921, "productNumber": "WID-12", "description": "Widget, large", "qtyOrdered": 10.0, "qtyDelivered": 4.0, "qtyLeftToDeliver": 6.0, "dateOrder": "2026-05-12T00:00:00Z", "dateDelivery": "2026-05-25T00:00:00Z", "customerId": 412, "customerName": "Acme Ltd", "statusId": 31, "statusName": "Bekräftad" } ``` ### `GET /api/v1/warehouse-stock` Stock per product per warehouse location. | Query parameter | Type | Notes | | --- | --- | --- | | `productId` | int | | | `warehouseId` | int | | | `locationId` | int | | | `cursor` / `pageSize` | | Default 50, max 200. | **Response item** — `WarehouseStockRowDto`: ```jsonc { "productId": 9921, "productNumber": "WID-12", "productDescription": "Widget, large", "warehouseId": 1, "warehouseName": "Main", "locationId": 22, "locationName": "A-12-3", "qtyInStock": 47.0, "qtyIncoming": 20.0, "qtyOutgoing": 6.0, "qtyDistributable": 61.0 } ``` `qtyDistributable = qtyInStock - qtyOutgoing + qtyIncoming` — pre-computed so you don't have to. ### `GET /api/v1/vendor-prices` Suppliers and their prices for a specific product. | Query parameter | Type | Notes | | --- | --- | --- | | `productId` | int | **Required.** | | `supplierId` | int | | | `currencyCode` | string | Exact ISO code. | | `cursor` / `pageSize` | | Default 50, max 100. | **Response item** — `VendorPriceDto`: ```jsonc { "productId": 9921, "productNumber": "WID-12", "supplierId": 78, "supplierName": "Northwind Components AB", "supplierProductNumber": "NW-W-LRG", "price": 850.00, "currencyIsoCode": "SEK", "leadTimeDays": null, "validFrom": null, "validTo": null } ``` ### `GET /api/v1/products/find` Resolve a product by name, product number, or substring. Returns up to `limit` candidates so the caller can disambiguate. | Query parameter | Type | Notes | | --- | --- | --- | | `q` | string | **Required.** Number, partial product code, or part of the description. | | `limit` | int | 1..20, default 10. | **Response** — array of `ProductFindDto`: ```jsonc [ { "productId": 9921, "productNumber": "WID-12", "description": "Widget, large", "barcode": null, "isActive": true } ] ``` ### `GET /api/v1/customers` List or search customer (Company) records. | Query parameter | Type | Notes | | --- | --- | --- | | `q` | string | Match against id, name, or organisation number. | | `inactive` | bool | Include inactive customers. Default `false`. | | `cursor` / `pageSize` | | Default 50, max 100. | **Response item** — `CustomerDto`: ```jsonc { "id": 412, "contactNumber": 10042, "name": "Acme Ltd", "orgno": "556677-8899", "currencyIsoCode": null, "inactive": false, "openOrderCount": 3 } ``` ### `GET /api/v1/customers/{customerId}` Full customer detail. **Response** — `CustomerDetailDto`: ```jsonc { "id": 412, "contactNumber": 10042, "name": "Acme Ltd", "orgno": "556677-8899", "creditLimit": 250000.00, "overdueOrderSum": 4200.00, "creditStop": false, "paymentTermId": 3, "paymentTermName": "30 dagar netto", "deliveryTermId": 1, "deliveryTermName": "Fritt vårt lager", "priceListId": 7, "priceListName": "Standard", "currencyId": null, "currencyIsoCode": null, "vatTypeId": null, "vatTypeName": null, "inactive": false, "orderCount": 84 } ``` Returns `404` with an `ErrorEnvelope` if the customer does not exist or is in a different tenant. --- ### `GET /api/v1/purchase-invoice-matches/{invoiceId}` 3-way matching for a purchase-invoice draft. For every invoice line, returns the top-5 ranked candidate goods-receipt lines along with the signals that contributed to the score, the qty/price/date variances, and an `isAutoEligible` flag (score ≥ tolerance threshold AND every variance inside its tolerance). The response is read-only: nothing is booked or persisted by this endpoint. It is memory-cached per draft for 30 seconds, so repeated polling while the user navigates the picker is cheap. **Response** — `PurchaseInvoiceMatchResponseDto`: ```jsonc { "invoiceId": 5012, "companyId": 412, "tolerances": { "qtyPercent": 0.02, "qtyAbsolute": 1, "pricePercent": 0.01, "priceAbsoluteSek": 5, "dateWindowDays": 14, "autoEligibleScore": 70 }, "suggestions": [ { "invoiceLineIndex": 0, "vendorProductNumber": "VND-100", "productNumber": "PROD-A", "description": "DIN 933 M8x20 ZN", "quantity": 100, "unitPrice": 4.20, "totalExVat": 420.00, "isExtraCharge": false, "candidates": [ { "orderId": 9001, "orderItemId": 88012, "invTransactionId": 130045, "orderName": "PO-9001", "productNumber": "PROD-A", "vendorProductNumber": "VND-100", "description": "DIN 933 M8x20 ZN", "receiptQuantity": 100, "receiptUnitPriceSek": 4.20, "receiptDate": "2026-05-04T00:00:00Z", "score": 100, "reasons": ["VendorSku","ProductNumber","QtyExact","PriceExact","DateProximity","SoloOpenLineBonus"], "qtyVariance": 0, "priceVarianceSek": 0, "dateVarianceDays": -1, "isAutoEligible": true } ] } ], "unmatchedReceipts": [] } ``` ### `POST /api/v1/purchase-invoice-matches/{invoiceId}/confirm` Persist user-confirmed (invoice line → receipt) pairs onto the draft. Multiple receipts can be attached to the same `invoiceLineIndex` by repeating it. Does **not** book the voucher — booking remains a separate human-driven step via `POST /purchase-invoices/{id}/book`. **Request** — `PurchaseInvoiceConfirmMatchesRequestDto`: ```jsonc { "matches": [ { "invoiceLineIndex": 0, "orderItemId": 88012, "invTransactionId": 130045 } ] } ``` **Response** — `PurchaseInvoiceConfirmMatchesResponseDto` — `{ "invoiceId": 5012 }` (the saved draft id). ### `POST /api/v1/purchase-invoice-matches/{invoiceId}/match-whole-order` One-click "match this whole PO" shortcut. Attaches every unbooked receipt on the chosen order to the draft, distributed across invoice lines by the scorer (best-fit per receipt; leftover receipts pile onto the first non-extra-charge line). Use this when AI line-extraction missed rows and the invoice maps cleanly to one PO. The candidate PO ids are returned in the `wholeOrderOptions` field of the suggest response above. **Request** — `PurchaseInvoiceMatchWholeOrderRequestDto`: ```jsonc { "orderId": 9001 } ``` **Response** — `PurchaseInvoiceConfirmMatchesResponseDto` — `{ "invoiceId": 5012 }`. --- ## Errors Non-2xx responses carry an `ErrorEnvelope`: ```jsonc { "code": "not_found", "message": "Customer 99999 not found", "traceId": null } ``` | HTTP status | `code` examples | When | | --- | --- | --- | | `400` | `bad_request` | Invalid query parameter, malformed body. | | `401` | `unauthorized` | Missing or expired Bearer token. | | `403` | `forbidden` | Token valid but no access to the tenant's resource. | | `404` | `not_found` | Resource id not in the tenant. | | `429` | `rate_limited` | (MCP only) per-user rate limit exceeded — see `Retry-After` header. | | `500` | `internal_error` | Unhandled server-side fault. Includes a `traceId` you can quote to support. |