# Seamless API > A read-only GraphQL API for reading a business's own product library and sales data from Seamless POS. - Version: 1.0.0, sent on every response as the `X-API-Version` header. - Endpoint: `POST https://api.seamlesspos.com/graphql` with a JSON body of `{ "query": "...", "variables": { } }`. - Authentication: `Authorization: Bearer `. Keys start with `k_live_` and are created in the Seamless backoffice. - Read-only: there are no mutations. - Money is an integer in the minor unit (pence) and includes tax. - Lists that page (`products`, `orders`, `refunds`) return `nodes` and `nextCursor`; pass `nextCursor` as `after` until it is null. This file is the complete documentation: every guide, then the full GraphQL schema. --- ## Seamless API The Seamless API gives you read access to the product library and sales data you hold in Seamless POS, for use in your accounting, stock, reporting systems, and BI tools. There is one [GraphQL](https://graphql.org/learn/) endpoint: ``` https://api.seamlesspos.com/graphql ``` ### What you can read | Data | What you get | |---|---| | Product library | Categories, products, prices by order mode and variant, allergens, modifier groups | | Orders | Every closed order with its lines, modifiers, discounts and payments | | Refunds | Each refund as its own record, linked to the order and lines it refunds | | Daily summary | Sales totals per business date, matching the reports in the Seamless backoffice | The API is read-only: no request can change your data. ### How it works 1. Create an API key in the Seamless backoffice, choosing which sites and which kinds of data it can read. 2. Send GraphQL queries to the endpoint with the key in the `Authorization` header. 3. The response is JSON containing exactly the fields you asked for. ### Using an AI assistant These docs are also published as plain text for AI coding assistants and other language models. [llms-full.txt](https://dev.seamlesspos.com/llms-full.txt) has every guide and the full schema in one file, which you can paste into your assistant or pass to it as a link. [llms.txt](https://dev.seamlesspos.com/llms.txt) is an index of the pages, each of them available as Markdown, and [schema.graphql](https://dev.seamlesspos.com/schema.graphql) is the GraphQL schema on its own. ### Where to start The [quickstart](https://dev.seamlesspos.com/quickstart.md) gets a first request working in a couple of minutes, and the [API explorer](https://dev.seamlesspos.com/explorer) runs queries in the browser with your own key. Before you build anything, read [API keys and permissions](https://dev.seamlesspos.com/authentication.md) for what a key can and cannot read, and [conventions](https://dev.seamlesspos.com/conventions.md) for how money, dates and business dates are represented. Every type and field is listed in the [reference](https://dev.seamlesspos.com/schema.graphql). --- ## Quickstart ### 1. Create an API key 1. Sign in to the Seamless backoffice. 2. Go to **Settings**, then **System Settings**, and find **API keys**. 3. Choose **Create API key**. Give it a name, pick the sites it covers and tick what it can read. 4. Copy the key. It is shown once and cannot be retrieved later. Keys start with `k_live_`. A key is as sensitive as a password, so keep it on your server and out of web pages and mobile apps. ### 2. Make a request Send a `POST` with a JSON body containing your query. Replace `YOUR_API_KEY` with your key. ```bash curl https://api.seamlesspos.com/graphql \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "{ sites { id name currency timezone } }"}' ``` You get back the sites the key covers: ```json { "data": { "sites": [ { "id": "66159dbc4cd289f22a12a7ba", "name": "The Harbour Cafe", "currency": "GBP", "timezone": "Europe/London" } ] } } ``` You can also paste your key into the [API explorer](https://dev.seamlesspos.com/explorer) and run queries from the browser. ### 3. Read some sales All data is read through a site. If your key covers one site you can leave the site id out: ```graphql { site { name orders(from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z") { nodes { number businessDate closedAt total items { name price } payments { type amount } } nextCursor } } } ``` If the key covers several sites, name the one you want: `site(id: "66159dbc4cd289f22a12a7ba")`. ### From code The API needs no client library, so any HTTP client can call it. ```js title="Node.js 18 or later" const res = await fetch("https://api.seamlesspos.com/graphql", { method: "POST", headers: { Authorization: `Bearer ${process.env.SEAMLESS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ query: `query Orders($from: DateTime!, $to: DateTime!) { site { orders(from: $from, to: $to) { nodes { number total } nextCursor } } }`, variables: { from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z" }, }), }); const { data, errors } = await res.json(); if (errors) throw new Error(errors[0].message); console.log(data.site.orders.nodes); ``` ```python title="Python" import os, requests res = requests.post( "https://api.seamlesspos.com/graphql", headers={"Authorization": f"Bearer {os.environ['SEAMLESS_API_KEY']}"}, json={ "query": """query Orders($from: DateTime!, $to: DateTime!) { site { orders(from: $from, to: $to) { nodes { number total } nextCursor } } }""", "variables": {"from": "2026-11-01T00:00:00Z", "to": "2026-11-02T00:00:00Z"}, }, ) body = res.json() if body.get("errors"): raise RuntimeError(body["errors"][0]["message"]) print(body["data"]["site"]["orders"]["nodes"]) ``` ### Next [Conventions](https://dev.seamlesspos.com/conventions.md) explains why amounts are in pence and what a business date is. [Pagination](https://dev.seamlesspos.com/pagination.md) covers reading more than one page, and [orders and refunds](https://dev.seamlesspos.com/guides/sales.md) describes the sales data in detail. --- ## API keys and permissions Every request needs an API key, sent as a bearer token: ``` Authorization: Bearer k_live_... ``` ### Creating and revoking keys Keys are managed in the Seamless backoffice under **Settings**, **System Settings**, **API keys**. A key is shown once, when you create it. If you lose it, revoke it and create another. Revoking a key stops it working straight away. It is worth creating a separate key for each system you connect, so that you can revoke one without disturbing the others. ### What a key can read Two things limit a key, and you choose both when you create it: the sites it covers and its permissions. #### Sites A key reads only the sites you pick for it, and only sites that you have access to yourself. If your own access to a site is removed, your keys stop reading that site too. You can change which sites a key covers later. #### Permissions | Permission | In the backoffice | Lets the key read | |---|---|---| | `read:products` | Product library | `categories`, `products`, `modifierGroups` | | `read:reports` | Sales data | `orders`, `refunds`, `dailySummary` | | `read:costs` | Cost prices and profit | `costPrice` on product prices | | `read:staff` | Staff | Staff names on orders, order lines and refunds | Permissions are fixed when the key is created. To change them, create a new key. Asking for a field the key has no permission for returns a `FORBIDDEN` error that names the missing permission. Fields you do not ask for are never checked, so a key without `read:staff` can still read orders as long as the query leaves the staff fields out. ### Keeping keys safe Call the API from your server. A key in a browser or a mobile app can be read by anyone who uses it. Keep keys out of source control as well, in an environment variable or a secrets manager, and give each key only the sites and permissions it needs. If a key may have leaked, revoke it: there is no cost to creating a new one. --- ## Conventions ### Money Every amount is an integer in the minor unit of the site's currency, so for a site trading in pounds `1250` means £12.50. Prices and totals include tax. Where the tax is known it is given beside the amount, as `tax`, in the same unit. The currency is on the site, as an ISO 4217 code: ```graphql { site { currency } } ``` ### Dates and times | Type | Format | Example | |---|---|---| | `DateTime` | An exact moment, ISO 8601 | `2026-11-01T18:30:00.000Z` | | `Date` | A calendar date | `2026-11-01` | `DateTime` values are returned in UTC. When you send one, include a time zone: either `Z` or an offset such as `+01:00`. A date-time without one is rejected. A range runs from `from` up to but not including `to`, so consecutive ranges neither overlap nor leave a gap: ```graphql orders(from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z") orders(from: "2026-11-02T00:00:00Z", to: "2026-11-03T00:00:00Z") ``` ### Business dates A night's trading often runs past midnight, so each site has a rollover hour: the local time at which a new trading day starts. A site with a rollover hour of 4 treats a sale at 01:30 on Sunday morning as part of Saturday. Every order and refund has a `businessDate` as well as its exact time. It is worked out in the site's own time zone, which saves you doing the calculation: ```json { "closedAt": "2026-11-01T01:30:00.000Z", "businessDate": "2026-10-31" } ``` The site tells you its time zone and rollover hour: ```graphql { site { timezone rolloverHour } } ``` Group by `businessDate` if your figures should match a day's trading. The exact times are there for when you need the moment itself. ### Missing values A field without `!` in the [reference](https://dev.seamlesspos.com/schema.graphql) can be `null`, which means the value was not recorded. Lists are never null: an order with no payments has `payments: []`. ### Identifiers An `id` is an opaque string: store it as text and do not read meaning into it. Ids do not change, which lets you match records between requests, for example a refund's `orderId` to an order's `id`. --- ## Pagination `products`, `orders` and `refunds` return a page at a time. Each page has two fields: | Field | Meaning | |---|---| | `nodes` | The records on this page | | `nextCursor` | Pass this as `after` to get the next page. `null` on the last page | ### Reading every page Ask for a page, then keep passing `nextCursor` back as `after` until it comes back `null`. ```graphql query Orders($from: DateTime!, $to: DateTime!, $after: String) { site { orders(from: $from, to: $to, first: 200, after: $after) { nodes { id number total } nextCursor } } } ``` ```js let after = null; const orders = []; do { const { data } = await query(ORDERS, { from, to, after }); orders.push(...data.site.orders.nodes); after = data.site.orders.nextCursor; } while (after); ``` ### Page size `first` sets how many records come back. The default is 100 and the most you can ask for is 200. ### Rules for cursors A cursor is an opaque string, so do not build or change one, and use it only with the same query and the same `from` and `to` it came from. Orders and refunds come back oldest first, which means you can stop and resume at any point. --- ## Product library Needs the `read:products` permission. The product library is three lists on a site: `categories`, `products` and `modifierGroups`. ### Products `products` is [paginated](https://dev.seamlesspos.com/pagination.md). **Request** ```graphql { site { products(first: 200) { nodes { id name displayName category { id name department } allergens prices { amount orderMode variant taxRatePercent } modifierGroupId } nextCursor } } } ``` **Response** ```json { "data": { "site": { "products": { "nodes": [ { "id": "66168c952dacadbfc5e41576", "name": "Lemonade", "displayName": "Lemonade", "category": { "id": "66168c862dacadbfc5e3f9f2", "name": "Soft Drinks", "department": "Wet" }, "allergens": [], "prices": [ { "amount": 350, "orderMode": "Sit In", "variant": null, "taxRatePercent": 20 }, { "amount": 320, "orderMode": "Take Out", "variant": null, "taxRatePercent": 20 } ], "modifierGroupId": null }, { "id": "66336eae98f4389e8af4dcc2", "name": "Steak", "displayName": "Sirloin Steak", "category": { "id": "6622784e10530e5203265c55", "name": "Hot Food", "department": "Dry" }, "allergens": ["Milk"], "prices": [ { "amount": 2500, "orderMode": "Sit In", "variant": null, "taxRatePercent": 20 } ], "modifierGroupId": "66251ef86f004bc90262d436" } ], "nextCursor": "Mg" } } } } ``` #### Prices A product can have several prices. Each one says when it applies: | Field | Meaning | |---|---| | `amount` | The selling price, including tax, in pence | | `orderMode` | The order mode this price is for, such as `Sit In` or `Take Out`, as named at the site | | `variant` | The variant this price is for, such as a size, where the product has variants | | `taxRatePercent` | The tax rate applied, for example `20` | | `costPrice` | What the product costs you, in pence. Needs `read:costs` | #### Cost prices `costPrice` needs the `read:costs` permission as well as `read:products`. Leave it out of the query if your key does not have it. It is `null` where no cost has been entered. ```graphql { site { products(first: 200) { nodes { id name prices { amount costPrice orderMode } } nextCursor } } } ``` ### Categories **Request** ```graphql { site { categories { id name department } } } ``` **Response** ```json { "data": { "site": { "categories": [ { "id": "66168c862dacadbfc5e3f9f2", "name": "Soft Drinks", "department": "Wet" }, { "id": "6622784e10530e5203265c55", "name": "Hot Food", "department": "Dry" } ] } } } ``` A product's `category` carries the same `id`, so you can join the two. ### Modifier groups A modifier group is the set of choices offered when a product is sold, such as how a steak is cooked. A product points at its group with `modifierGroupId`. **Request** ```graphql { site { modifierGroups { id name steps { position optional maxChoices options { id name price } } } } } ``` **Response** ```json { "data": { "site": { "modifierGroups": [ { "id": "66251ef86f004bc90262d436", "name": "Steak", "steps": [ { "position": 1, "optional": false, "maxChoices": 1, "options": [ { "id": "662520249f51cce4a3e11f8f", "name": "Rare", "price": null }, { "id": "6625202a9f51cce4a3e11f90", "name": "Medium", "price": null }, { "id": "662520309f51cce4a3e11f91", "name": "Well Done", "price": null } ] }, { "position": 2, "optional": true, "maxChoices": 1, "options": [ { "id": "6967984193f50ff80073d03f", "name": "Peppercorn Sauce", "price": 250 }, { "id": "69679845a7a33e0f9787c002", "name": "Bearnaise Sauce", "price": 250 } ] } ] } ] } } } ``` A group is made of steps, asked in `position` order. Each step offers `options`; `optional` says whether a choice can be skipped and `maxChoices` how many can be picked. An option's `price` is the extra charged for choosing it, in pence, and is `null` when there is no charge. ### Keeping a copy in step The product library changes when someone edits it in the backoffice. To keep your own copy current, read all three lists on a schedule that suits you and replace what you hold, matching on `id`. --- ## Orders and refunds Needs the `read:reports` permission. Sales come as two lists, `orders` and `refunds`, holding the raw records exactly as the tills recorded them. No totals are worked out here. For those, see the [daily summary](https://dev.seamlesspos.com/guides/daily-summary.md). ### Orders `orders` goes by the time an order was closed: it returns those closed from `from` up to but not including `to`, oldest first. It is [paginated](https://dev.seamlesspos.com/pagination.md), and a single request can cover at most 92 days. **Request** ```graphql { site { orders(from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z", first: 200) { nodes { id number reference businessDate openedAt closedAt orderMode covers total tax voided voidedAt items { id type name productId categoryId category price tax modifiers { name price tax } discounts notes voided refunded } payments { id type scheme amount changeDue paidAt } refundIds } nextCursor } } } ``` **Response** ```json { "data": { "site": { "orders": { "nodes": [ { "id": "6724c1f0a3b1c2d4e5f60718", "number": 1042, "reference": "ORD-1793536867000", "businessDate": "2026-11-01", "openedAt": "2026-11-01T12:21:07.000Z", "closedAt": "2026-11-01T12:41:07.512Z", "orderMode": "Sit In", "covers": 2, "total": 4114, "tax": 686, "voided": false, "voidedAt": null, "items": [ { "id": "6724c1f4a3b1c2d4e5f60719", "type": "product", "name": "Steak", "productId": "66336eae98f4389e8af4dcc2", "categoryId": "6622784e10530e5203265c55", "category": "Hot Food", "price": 2500, "tax": 417, "modifiers": [ { "name": "Rare", "price": 0, "tax": 0 }, { "name": "Bearnaise Sauce", "price": 250, "tax": 42 } ], "discounts": [], "notes": [], "voided": false, "refunded": false }, { "id": "6724c1f9a3b1c2d4e5f6071a", "type": "product", "name": "Fishcakes", "productId": "66336f226c8580a9aeae02d9", "categoryId": "6622784e10530e5203265c55", "category": "Hot Food", "price": 990, "tax": 165, "modifiers": [], "discounts": ["10%"], "notes": [], "voided": false, "refunded": false }, { "id": "6724c1fda3b1c2d4e5f6071b", "type": "serviceCharge", "name": "Service Charge", "productId": null, "categoryId": null, "category": null, "price": 374, "tax": 62, "modifiers": [], "discounts": [], "notes": [], "voided": false, "refunded": false } ], "payments": [ { "id": "6724c213a3b1c2d4e5f6071c", "type": "CASH", "scheme": null, "amount": 5000, "changeDue": 886, "paidAt": "2026-11-01T12:41:07.334Z" } ], "refundIds": [] } ], "nextCursor": null } } } } ``` #### Order lines Each entry in `items` is one line on the order. `type` says what kind: | `type` | Meaning | |---|---| | `product` | Something sold from the product library | | `serviceCharge` | A service charge added to the order | | `gratuity` | A tip | | `paidOut` | Cash paid out of the till | | `noSale` | The till drawer opened without a sale | Treat a `type` you do not recognise as a line that is not a product. More may be added. A line's `price` is what was charged for it, including tax and after any discount. The discount amount is not recorded separately, so `discounts` gives the names of the discounts applied. In the example above the Fishcakes sell for £11.00 and were charged at £9.90 with the `10%` discount. `modifiers` are the choices made on the line. A modifier's `price` is the extra charged for it, and is zero when the choice was free. It is charged on top of the line's `price`: the Steak above is £25.00 plus £2.50 for the sauce. #### Totals An order's `total` is the sum of its lines' prices and their modifiers' prices, leaving out voided lines. It includes tax and any service charge. In the example: 2500 + 250 + 990 + 374 = 4114. A gratuity is not part of `total`. It appears as its own line and in the payment. `tax` adds up the same way, from the lines' and modifiers' `tax`. #### Payments | Field | Meaning | |---|---| | `type` | How it was paid, as named on the till: `CASH`, `CARD`, or the name of the card provider | | `scheme` | The card scheme, such as `VISA`, where the till recorded one | | `amount` | What was handed over or charged | | `changeDue` | Change given back out of `amount` | For cash, `amount` is what the customer handed over, which can be more than was owed. What the till kept is `amount` minus `changeDue`: in the example, £50.00 was handed over, £8.86 change was given, and the order came to £41.14. When a card payment is refunded, the money going back is also recorded on the order as a payment with a negative `amount`. The refund itself is in `refunds`, described below. #### Voids Voided orders and voided lines stay in the results with a flag on them, so you can see what was rung in and then removed. An order with `voided: true` was cancelled as a whole, and `voidedAt` says when. A line with `voided: true` was removed from an order that otherwise stands. Leave both out when you add up sales. ### Refunds A refund is a record of its own, dated when it happened. A sale on Monday that is refunded on Wednesday appears as an order on Monday and a refund on Wednesday, which is how it affects each day's takings. `refunds` is [paginated](https://dev.seamlesspos.com/pagination.md) and limited to 92 days per request, like `orders`. **Request** ```graphql { site { refunds(from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z") { nodes { id orderId businessDate refundedAt amount tax paymentId itemIds } nextCursor } } } ``` **Response** ```json { "data": { "site": { "refunds": { "nodes": [ { "id": "6724d8faa3b1c2d4e5f6437d", "orderId": "6724c1f0a3b1c2d4e5f60718", "businessDate": "2026-11-01", "refundedAt": "2026-11-01T13:58:16.074Z", "amount": 990, "tax": 165, "paymentId": "6724d8faa3b1c2d4e5f6437c", "itemIds": ["6724c1f9a3b1c2d4e5f6071a"] } ], "nextCursor": null } } } } ``` | Field | Meaning | |---|---| | `orderId` | The order that was refunded. Matches an order's `id` | | `itemIds` | The order lines the refund covers. Each matches a line's `id` | | `paymentId` | The payment that returned the money | | `amount` | The amount refunded, in pence, as a positive number | The link works both ways: an order's `refundIds` lists the refunds made against it, and a refunded line has `refunded: true`. ### Staff With the `read:staff` permission you can also ask who handled a sale. **Request** ```graphql { site { orders(from: "2026-11-01T00:00:00Z", to: "2026-11-02T00:00:00Z") { nodes { number openedBy { id name } closedBy { id name } } } } } ``` **Response** ```json { "data": { "site": { "orders": { "nodes": [ { "number": 1042, "openedBy": { "id": "66159dbc4cd289f22a12a7cf", "name": "Sam" }, "closedBy": { "id": "66159dbc4cd289f22a12a7cf", "name": "Sam" } } ] } } } } ``` Order lines have `addedBy` and refunds have `refundedBy` in the same way. Any of these can be `null` where the till did not record who it was. Leave these fields out if your key does not have the permission. ### Working out a day's sales To total a business date from the raw records: 1. Add up `total` for the orders with that `businessDate`, leaving out voided orders. 2. Take off `amount` for the refunds with that `businessDate`. The result matches `gross` in the [daily summary](https://dev.seamlesspos.com/guides/daily-summary.md) for that date. --- ## Daily summary Needs the `read:reports` permission. `dailySummary` gives sales totals for each business date. It uses the same calculation as the reports in the Seamless backoffice, so the figures match what you see there. ```graphql { site { dailySummary(from: "2026-11-01", to: "2026-11-07") { date gross tax net transactions } } } ``` ```json { "data": { "site": { "dailySummary": [ { "date": "2026-11-01", "gross": 184250, "tax": 30708, "net": 153542, "transactions": 143 }, { "date": "2026-11-02", "gross": 121400, "tax": 20233, "net": 101167, "transactions": 97 } ] } } } ``` ### Arguments `from` and `to` are business dates and, unlike the ranges on `orders` and `refunds`, both ends are included: the example above returns seven days. A single request can cover at most 31 days. ### Fields | Field | Meaning | |---|---| | `date` | The business date | | `gross` | Sales including tax, after taking off refunds made on this date | | `tax` | The tax within `gross` | | `net` | Sales excluding tax: `gross` minus `tax` | | `transactions` | Orders closed on this date plus refunds made on it. Each refund counts as one | All amounts are in pence. ### Summary or raw records? The daily summary is the one to use when a day's totals have to agree with Seamless reports, for example when you post takings to your accounts. For the detail behind a total (what was sold, how it was paid for, and when) read [orders and refunds](https://dev.seamlesspos.com/guides/sales.md). --- ## Complete examples Each of these scripts is complete and runs as it stands. Both read every page, retry the errors that are worth retrying and stop with a message on any other. ### Total a day's sales (Node.js) Adds up one business date from the raw orders and refunds, then compares the result with the [daily summary](https://dev.seamlesspos.com/guides/daily-summary.md). Needs the `read:reports` permission. ```js title="day.mjs" // Totals one business date from the raw orders and refunds, and checks the // result against the daily summary. Node.js 18 or later, no dependencies. // // SEAMLESS_API_KEY=k_live_... node day.mjs 2026-11-01 const ENDPOINT = "https://api.seamlesspos.com/graphql"; const RETRY = new Set(["RATE_LIMITED", "UNAVAILABLE", "INTERNAL_SERVER_ERROR"]); // Sends one query. Retries the errors worth retrying, waiting longer each time. async function seamless(query, variables = {}) { for (let attempt = 0; ; attempt++) { const res = await fetch(ENDPOINT, { method: "POST", headers: { Authorization: `Bearer ${process.env.SEAMLESS_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), }); const body = await res.json(); if (!body.errors) return body.data; const { message, extensions } = body.errors[0]; if (!RETRY.has(extensions?.code) || attempt === 4) { throw new Error(`${extensions?.code}: ${message}`); } await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt)); } } // Reads every page of `orders` or `refunds`. async function allPages(field, selection, from, to) { const query = `query Page($from: DateTime!, $to: DateTime!, $after: String) { site { ${field}(from: $from, to: $to, first: 200, after: $after) { nodes { ${selection} } nextCursor } } }`; const records = []; let after = null; do { const data = await seamless(query, { from, to, after }); records.push(...data.site[field].nodes); after = data.site[field].nextCursor; } while (after); return records; } const date = process.argv[2]; if (!/^\d{4}-\d{2}-\d{2}$/.test(date ?? "")) throw new Error("Pass a date, like 2026-11-01"); // A business date does not line up with midnight UTC, so read a day either // side and keep the records whose businessDate is the one wanted. const day = 24 * 60 * 60 * 1000; const from = new Date(Date.parse(`${date}T00:00:00Z`) - day).toISOString(); const to = new Date(Date.parse(`${date}T00:00:00Z`) + 2 * day).toISOString(); const orders = (await allPages("orders", "businessDate total voided", from, to)) .filter(o => o.businessDate === date && !o.voided); const refunds = (await allPages("refunds", "businessDate amount", from, to)) .filter(r => r.businessDate === date); const sales = orders.reduce((sum, o) => sum + (o.total ?? 0), 0); const refunded = refunds.reduce((sum, r) => sum + (r.amount ?? 0), 0); const { site } = await seamless( `query Summary($date: Date!) { site { currency dailySummary(from: $date, to: $date) { gross transactions } } }`, { date }, ); const summary = site.dailySummary[0]; const money = pence => `${(pence / 100).toFixed(2)} ${site.currency}`; console.log(`${date}: ${orders.length} orders, ${refunds.length} refunds`); console.log(`Sales ${money(sales)} less refunds ${money(refunded)} = ${money(sales - refunded)}`); console.log(`Daily summary gross: ${money(summary?.gross ?? 0)}`); ``` **Output** ``` 2026-11-01: 143 orders, 2 refunds Sales 1867.40 GBP less refunds 24.90 GBP = 1842.50 GBP Daily summary gross: 1842.50 GBP ``` ### Export the product library (Python) Writes every product and price to a CSV file. Needs the `read:products` permission. ```python title="products.py" """Writes the whole product library to products.csv. Python 3.8 or later. pip install requests SEAMLESS_API_KEY=k_live_... python products.py """ import csv import os import time import requests ENDPOINT = "https://api.seamlesspos.com/graphql" RETRY = {"RATE_LIMITED", "UNAVAILABLE", "INTERNAL_SERVER_ERROR"} def seamless(query, variables=None): """Sends one query. Retries the errors worth retrying, waiting longer each time.""" for attempt in range(5): res = requests.post( ENDPOINT, headers={"Authorization": f"Bearer {os.environ['SEAMLESS_API_KEY']}"}, json={"query": query, "variables": variables or {}}, timeout=30, ) body = res.json() if not body.get("errors"): return body["data"] error = body["errors"][0] code = error.get("extensions", {}).get("code") if code not in RETRY or attempt == 4: raise RuntimeError(f"{code}: {error['message']}") time.sleep(2 ** attempt) QUERY = """ query Products($after: String) { site { products(first: 200, after: $after) { nodes { id name category { name } prices { amount orderMode variant taxRatePercent } } nextCursor } } } """ products, after = [], None while True: page = seamless(QUERY, {"after": after})["site"]["products"] products.extend(page["nodes"]) after = page["nextCursor"] if not after: break with open("products.csv", "w", newline="") as f: out = csv.writer(f) out.writerow(["id", "name", "category", "order mode", "variant", "price", "tax rate %"]) for product in products: for price in product["prices"]: out.writerow([ product["id"], product["name"], (product["category"] or {}).get("name"), price["orderMode"], price["variant"], None if price["amount"] is None else f"{price['amount'] / 100:.2f}", price["taxRatePercent"], ]) print(f"Wrote {len(products)} products to products.csv") ``` **Output** ``` Wrote 408 products to products.csv ``` ```csv title="products.csv" id,name,category,order mode,variant,price,tax rate % 66168c952dacadbfc5e41576,Lemonade,Soft Drinks,Sit In,,3.50,20 66168c952dacadbfc5e41576,Lemonade,Soft Drinks,Take Out,,3.20,20 66336eae98f4389e8af4dcc2,Steak,Hot Food,Sit In,,25.00,20 ``` --- ## Errors When a request fails, the response has an `errors` list. Each error has a `message` written to be read by a person and a `code` in `extensions` for your program to act on. ```json { "errors": [ { "message": "This API key does not have the read:staff permission", "path": ["site", "orders"], "extensions": { "code": "FORBIDDEN" } } ], "data": null } ``` Check the body for `errors` whatever the HTTP status, because a response can be `200` and still carry one. Have your code branch on `extensions.code`. The wording of `message` may change. ### Codes | Code | HTTP status | Meaning | What to do | |---|---|---|---| | `UNAUTHENTICATED` | 401 | The API key is missing, wrong or revoked | Check the `Authorization` header and the key | | `FORBIDDEN` | 403 | The key cannot read that site, or lacks a permission | Check the key's sites and permissions | | `BAD_USER_INPUT` | 400 | An argument is wrong, such as a bad date or a range that is too long | Fix the request. The message says what is wrong | | `GRAPHQL_VALIDATION_FAILED` | 400 | The query asks for a field that does not exist | Check the query against the [reference](https://dev.seamlesspos.com/schema.graphql) | | `GRAPHQL_PARSE_FAILED` | 400 | The query is not valid GraphQL | Check the query's syntax | | `QUERY_TOO_LARGE` | 400 | One request asks for too much at once | Split it into smaller requests | | `RATE_LIMITED` | 429 | Too many requests in a short time | Wait, then try again. See [limits](https://dev.seamlesspos.com/limits.md) | | `UNAVAILABLE` | 503 | The service is temporarily unavailable | Try again shortly | | `INTERNAL_SERVER_ERROR` | 200 | Something went wrong on our side | Try again. If it keeps happening, contact support | ### Examples A missing or wrong key: ```json { "errors": [ { "message": "Invalid or missing API key", "path": ["sites"], "extensions": { "code": "UNAUTHENTICATED" } } ], "data": null } ``` A site the key does not cover: ```json { "errors": [ { "message": "This API key cannot read that site", "path": ["site"], "extensions": { "code": "FORBIDDEN" } } ], "data": null } ``` A date range that is too long: ```json { "errors": [ { "message": "Range too long - request 92 days or less at a time", "path": ["site", "orders"], "extensions": { "code": "BAD_USER_INPUT" } } ], "data": null } ``` A query that names a field that does not exist. GraphQL suggests the nearest match: ```json { "errors": [ { "message": "Cannot query field \"nope\" on type \"Site\". Did you mean \"name\"?", "extensions": { "code": "GRAPHQL_VALIDATION_FAILED" } } ] } ``` Too many requests: ```json { "errors": [ { "message": "Too many requests - slow down and try again shortly", "extensions": { "code": "RATE_LIMITED" } } ] } ``` ### Retrying `RATE_LIMITED`, `UNAVAILABLE` and `INTERNAL_SERVER_ERROR` are worth retrying. Wait a little longer after each attempt, for example 1, 2, 4 then 8 seconds, and give up after a few tries. The other codes mean the request itself needs changing, and sending it again will fail the same way. Since every request is a read, a retry cannot do any harm. --- ## Limits The limits are there to keep the API responsive for every customer. You are unlikely to meet them in normal use: a nightly import of a busy site's sales needs a handful of requests. ### Request rate Each API key can make 120 requests a minute. Past that, requests fail with `RATE_LIMITED` until the minute is up. If you hit the limit, slow down, and ask for bigger pages: one request for 200 records does the work of ten requests for 20. ### Size of a request | Limit | Value | |---|---| | Records per page | 200 | | Date range for `orders` and `refunds` | 92 days | | Date range for `dailySummary` | 31 days | To read a longer period, split it into several requests, one range after another. A single request also cannot read too many things at once, for example orders for a long list of sites in one query. Such a request fails with `QUERY_TOO_LARGE`. Send one request per site instead. ### Keeping within the limits Ask only for the fields you use, since smaller queries are faster, and page through results with `first: 200` instead of many small pages. Large imports are best run outside your busiest hours. --- ## Versioning The Seamless API is at version 1.0.0. Every response carries the version in a header: ``` X-API-Version: 1.0.0 ``` ### What the numbers mean The version follows [semantic versioning](https://semver.org/), `MAJOR.MINOR.PATCH`: | Part | Changes when | Do you need to do anything? | |---|---|---| | `MAJOR` | Something existing is removed or changes meaning | Yes. You will be told well in advance | | `MINOR` | Something is added: a new field, type, argument or query | No | | `PATCH` | A fix that does not change the schema | No | There is one endpoint, and it always serves the latest version. The version is not part of the URL. ### What stays the same Within version 1 you can rely on the following: - A field or query that exists will keep existing, with the same type and meaning. - An argument that is optional will stay optional. - Error codes will keep their meaning. ### What can change without notice The following can be added or reworded at any time. A client that allows for them will not be affected: - New fields on existing types. GraphQL only returns the fields you ask for, so you will not see them until you want them. - New types, queries and optional arguments. - New values for fields that describe a kind of thing, such as an order line's `type` or a payment's `type`. Handle a value you do not recognise sensibly instead of failing. - The wording of error messages. Branch on the error `code`. ### Retiring a field Before a field is removed it is marked as deprecated in the schema, with a note saying what to use instead. It goes on working in the meantime. Deprecated fields are listed in the [reference](https://dev.seamlesspos.com/schema.graphql) and flagged by GraphQL tools. ### Changelog #### 1.0.0 The first release. - Product library: `categories`, `products`, `modifierGroups`. - Sales: `orders`, `refunds`, `dailySummary`. - API keys limited by site and by permission. --- ## GraphQL schema ```graphql "A calendar date, YYYY-MM-DD. For sales this is the site's business date." scalar Date "An exact moment, ISO 8601 in UTC, e.g. 2026-11-01T18:30:00.000Z." scalar DateTime type Query { "The sites this API key can read." sites: [Site!]! "One site. The id can be left out when the key covers a single site." site(id: ID): Site! } "A site: one trading location. Everything else is read through a site." type Site { id: ID! name: String "ISO 4217 code for every amount at this site." currency: String "IANA time zone the site trades in." timezone: String "Hour of the local day at which a new business date starts." rolloverHour: Int "Needs the read:products permission." categories: [Category!]! "Needs the read:products permission." products(first: Int = 100, after: String): ProductPage! "Needs the read:products permission." modifierGroups: [ModifierGroup!]! """ Orders closed from `from` up to but not including `to`, oldest first. Voided orders are included and flagged. At most 92 days per request. Needs the read:reports permission. """ orders(from: DateTime!, to: DateTime!, first: Int = 100, after: String): OrderPage! """ Refunds made from `from` up to but not including `to`, oldest first. A refund is its own record, dated when it happened, and can fall on a later day than the order it refunds. At most 92 days per request. Needs the read:reports permission. """ refunds(from: DateTime!, to: DateTime!, first: Int = 100, after: String): RefundPage! """ Sales totals per business date, calculated the same way as the Seamless reports. At most 31 days per request. Needs the read:reports permission. """ dailySummary(from: Date!, to: Date!): [DailySummary!]! } "A category that products are grouped under." type Category { id: ID! name: String department: String } "One page of products." type ProductPage { nodes: [Product!]! "Pass as `after` to read the next page. Null on the last page." nextCursor: String } "Something a site sells." type Product { id: ID! name: String "Name shown to customers, where it differs." displayName: String category: Category allergens: [String!]! prices: [ProductPrice!]! modifierGroupId: ID } "One price of a product, with the order mode and variant it applies to." type ProductPrice { amount: Int "Needs the read:costs permission." costPrice: Int orderMode: String variant: String taxRatePercent: Float } "The choices offered when a product is sold, as one or more steps." type ModifierGroup { id: ID! name: String steps: [ModifierStep!]! } "One step of a modifier group: a set of options to choose from." type ModifierStep { position: Int optional: Boolean! maxChoices: Int options: [ModifierOption!]! } "One choice within a modifier step." type ModifierOption { id: ID! name: String price: Int } "One page of orders." type OrderPage { nodes: [Order!]! "Pass as `after` to read the next page. Null on the last page." nextCursor: String } "A closed order, as the till recorded it." type Order { id: ID! number: Int reference: String businessDate: Date! openedAt: DateTime closedAt: DateTime! "For example Eat in or Takeaway." orderMode: String covers: Int "What the order came to, including tax and any service charge. A gratuity is not part of it." total: Int tax: Int voided: Boolean! voidedAt: DateTime items: [OrderItem!]! payments: [Payment!]! "Refunds made against this order. Read them with `refunds`." refundIds: [ID!]! "Needs the read:staff permission." openedBy: Staff "Needs the read:staff permission." closedBy: Staff } "One line on an order." type OrderItem { id: ID! "What kind of line this is: product, serviceCharge, gratuity, paidOut or noSale. Others may be added." type: String name: String productId: ID categoryId: ID category: String "What was charged for this line, including tax and after any discount. Priced modifiers are charged on top of it." price: Int tax: Int modifiers: [ItemModifier!]! "Discounts applied to this line. The price is already reduced by them." discounts: [String!]! notes: [String!]! voided: Boolean! refunded: Boolean! "Needs the read:staff permission." addedBy: Staff } "A choice made on an order line." type ItemModifier { name: String "Extra charged for this choice, including tax. Zero when it was free." price: Int tax: Int } "A payment taken against an order." type Payment { id: ID! "How it was paid, as named on the till: CASH, CARD, or the name of the card provider." type: String "The card scheme, such as VISA, where the till recorded one." scheme: String "What was handed over or charged. For cash this can be more than was owed: see changeDue. Negative when money went back to the customer." amount: Int "Change given back out of amount." changeDue: Int paidAt: DateTime } "One page of refunds." type RefundPage { nodes: [Refund!]! "Pass as `after` to read the next page. Null on the last page." nextCursor: String } "A refund, dated when it was made and linked to the order and lines it refunds." type Refund { id: ID! orderId: ID businessDate: Date! refundedAt: DateTime! amount: Int tax: Int paymentId: ID "The order lines this refund covers." itemIds: [ID!]! "Needs the read:staff permission." refundedBy: Staff } "A member of staff." type Staff { id: ID! name: String } "One business date's sales totals." type DailySummary { date: Date! "Sales including tax, after taking off refunds made on this date." gross: Int! tax: Int! net: Int! "Orders closed on this date plus refunds made on it: each refund counts as one." transactions: Int! } ```