For developers
Your sales, in your own code.
A read-only HTTP API over everything a venue has sold: orders, the items on them, what each one cost in tax, how the money arrived, and what went back. Point your own reporting at it, or somebody else's.
Every amount is an integer number of pence. Every
timestamp is ISO 8601 with a zone. Nothing here can change a venue's trade — the
whole API is GET, apart from the one call that mints a token.
Getting a key
You generate an Ed25519 keypair and send us the public half. We never hold anything that can impersonate you — the private key stays on your machine, and losing our copy of the public one costs you nothing.
ssh-keygen -t ed25519 -f helchpos-api -N "" # or the two lines below in Node
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32)
console.log(raw.toString('base64')) # this is what you send us
Email that base64 string to hello@helch.uk with the name of the food hall or the venue it should read. You get back a connection ID — a small number. A key is scoped to one food hall, or to a single kitchen inside one; it can never see another.
Signing in
Sign the current time with your private key and exchange the signature for a token. The token lasts just under a day, so a nightly job can mint a fresh one every run and never store one.
const timestamp = new Date().toISOString()
const signature = crypto.sign(null, Buffer.from(timestamp), privateKey).toString('base64')
const r = await fetch('https://helchpos.com/api/v1/connections/7/access-token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ timestamp, signature }),
})
const { just: token } = await r.json()
Then put it on every other request:
curl -H "Authorization: Bearer $TOKEN" \
"https://helchpos.com/api/v1/limits"
The timestamp has to be within five minutes of now, so a signature copied off the wire yesterday is not a key. Revoking a connection kills every token it has already issued, immediately — the connection is checked on every request, not baked into the token.
Conventions
| Rule | What it means |
|---|---|
| Money | Always an integer number of pence. 1250 is £12.50.
There are no floats anywhere in this API. |
| Time | ISO 8601 with a zone: 2026-08-12T19:00:00.000Z. |
| Ids | Strings. An order's id is its permanent uid, not a row number — row numbers are guessable and count upwards, which tells anybody holding one how many sales a venue has made. |
| Single values | Wrapped: { "just": "…" }. Always an object, so
adding a field later never breaks a parser. |
| Relationships | null means not asked for. An empty
array means asked for, and there are none. They are different answers and this
API never confuses them. |
| Paging | offset and num, maximum
num of 100. Fewer rows than you asked for means the end of the
collection. |
| Windows | from and to are required
on anything that could return a year of trade. A missing window is a 400, not "since
the beginning". |
| Unknowns | A tax nobody worked out comes back as null, never
0. Zero is a decision somebody made about a cold sandwich; null means the
record does not know. |
Endpoints
Base URL https://helchpos.com/api/v1. A business is a food hall.
| Method | Path | |
|---|---|---|
POST |
/connections/{id}/access-token |
Signature in, token out. The only call that is not a GET. |
GET |
/limits |
What this key may read: its business, its venues, the page maximum. Needs no window — call it first. |
GET |
/businesses/{id} |
The food hall. |
GET |
/businesses/{id}/sites |
Its kitchens. |
GET |
/businesses/{id}/products |
Stock items. offset, num. |
GET |
/businesses/{id}/orders |
Every order in the window, whatever state it is in. |
GET |
/businesses/{id}/orders/completed |
Only the ones that can no longer change. |
GET |
/businesses/{id}/payments |
Every payment in the window, each naming its order. |
GET |
/businesses/{id}/refunds |
Every refund in the window, with its line breakdown. |
GET |
/sites/{id} |
One kitchen. |
GET |
/sites/{id}/orders/completed |
Completed orders for one kitchen. |
GET |
/orders/{id} |
One order by its uid, with items, payments and refunds expanded. |
Everything with a window takes from, to,
offset and num.
The order
{
"id": "df1297aba4d78558b03d957dcc8ca4fe",
"reference": "H-193",
"status": "refunded",
"channel": "pos",
"site": { "id": "2" },
"check": null,
"currency": "GBP",
"created_at": "2026-08-12T20:41:38.000Z",
"amount": 3600,
"tax_amount": 600,
"service_charge_amount": 0,
"surcharge_amount": 0,
"tip_amount": 0,
"staff_discount_amount": 500,
"staff_discount_name": "Manager discount",
"bundle_discount_amount": 0,
"loyalty_discount_amount": 0,
"items": [
{
"id": "281",
"sku": "BACON-CHEESE",
"name": "BACON CHEESE",
"category": "Burgers",
"quantity": 2,
"unit_amount": 1400,
"amount": 2800,
"tax_amount": 410,
"tax_rate": 20,
"deal_amount": 0,
"staff_discount_amount": 341,
"bundle_discount_amount": 0,
"loyalty_discount_amount": 0,
"refunded": true,
"modifiers": [{ "name": "Extra bacon", "quantity": 1, "amount": 150 }]
}
],
"payments": [
{
"id": "255cd86e182584aaaa414cba93e673f6",
"method": "card",
"amount": 3600,
"tip_amount": 0,
"acquirer_fee_amount": 0,
"platform_fee_amount": 0,
"reference": "",
"terminal": "",
"taken_by": "Jamal",
"created_at": "2026-08-12T20:41:38.000Z"
}
],
"refunds": [
{
"id": "6cc4109b1c5b12032ec5d00393ca3951",
"amount": 2459,
"tax_amount": 410,
"reason": "Wrong order",
"note": "",
"method": "card",
"restocked": false,
"refunded_by": "Jamal",
"created_at": "2026-08-12T20:41:38.000Z",
"items": [{ "item_id": "281", "quantity": 2, "amount": 2459, "tax_amount": 410 }]
}
]
}
Things worth knowing about that shape
| Field | Type | |
|---|---|---|
amount | pence | On an order, what the guest paid. On an item, what that line came to after the deal on it. |
tax_amount | pence | null | The VAT inside that amount, recorded at the moment of sale. null on a sale taken before the venue had worked its rate out — and on the order too, because a total that silently omits one line is worse than an honest gap. |
category | string | null | The category as it read on the day. Renaming a category today does not move last quarter’s sales. |
staff_discount_amount | pence | On an item: this line’s share of the order-level discount, apportioned by what the line was worth. The shares add back to the order exactly. |
payments[] | array | One row per payment. A bill split across cash and card is two rows, each with its own time and whoever took it. |
refunds[] | array | One row per refund. Two partial refunds on different days are two records with their own reasons, amounts and restock decisions. |
check | object | null | Set when the order was part of one basket paid across several kitchens. Each kitchen still keeps its own order; the check is what the guest experienced. |
Errors
| Code | When |
|---|---|
400 | Something in the request was wrong. The body names each parameter and what was wrong with it, so you can fix them all at once. |
401 | No token, a forged token, an expired one, or a connection that has been switched off. |
404 | The thing does not exist, or it is not yours. Deliberately the same answer: telling you an id exists is itself an answer. |
{ "from": "required", "to": "required" }
{ "num": "too-large" }
{ "to": "before-from" }
{ "timestamp": "out-of-range" }
Want a key?
Send the public half and say which hall or kitchen it should read. There is no charge for the API and no rate card — if you manage to make it a problem, we will talk about it then.