API reference · 466 operations in 10 areas
Tenvelo API
Every operation below is served by one contract, one version and one token. The areas run in the order you meet them: who you are, what you sell, what you sell it on, and what happens when you do.
Areas
Area 4
Orders
The Tenvelo API manages a merchant's catalogue, locations, tills, inventory and sales.
The contract this API is built around
Tenvelo's tills sell from market stalls, food vans and shops with poor wifi. A till that stops working when the network does is not a till. So every design decision here starts from the assumption that a device will be offline, will accumulate sales, and will reconcile later — sometimes hours later.
Five mechanisms follow from that, and they apply across the whole API rather than to any one endpoint:
Clients mint ids. Resources a device can originate use UUIDv7 chosen by the device before anything is synced. A retry after a lost response targets the same record instead of creating a second sale.
Writes are batches. A device that has been offline has a queue, and a queue is
sent as a batch: POST /v1/{collection}/batch-upsert and its siblings, a batch of one
for a single thing. Every item carries its own idempotency key and its own result, and
stands or falls on its own — one bad item never holds back the rest.
Every mutation takes an Idempotency-Key. On a bad connection a client cannot
distinguish a lost request from a lost response. Without a key its only choices are to
risk a duplicate or drop a real sale. With one it retries freely — the first request
wins and every replay returns that same stored response.
State is pulled from a change feed, not re-downloaded. listChanges returns
everything that has changed since a cursor, including deletions as tombstones. A till
holds a cursor and applies deltas rather than fetching the catalogue each morning.
Writes are guarded by versions. Every resource carries a version, surfaced as an
ETag. Updates require If-Match and are rejected with 412 if anything has changed
since the read. Last-write-wins is the wrong default when two managers might edit the
same product from different devices.
A client names the version it was built for. This document's version is a date, and a
client generated from it sends it as API-Version on every request. Every response
carries the version the server speaks. A request naming any other version is refused
whole, before anything in it is judged, so a till built for a different contract keeps
its queue rather than having it misread: an earlier version, no longer served, with 410
and api-version-retired, and a later one with 400 and api-version-unknown. Either
names the server's version in api_version.
Which way data flows
Each resource is authoritative in exactly one direction, and this is a contract, not a convention:
- Catalogue — products, categories, tax rates — is server-authoritative. Devices pull it and never push it. Two tills editing prices offline would diverge with no principled way to reconcile them.
- Sales — orders — are device-authoritative and append-only. Tills create them; the server never modifies one. Corrections happen by voiding and re-issuing, because an order is a financial record.
- Inventory moves in both directions, so it is written only as signed adjustments, never absolute levels. Two offline tills each selling three of the same item must subtract six in total, which absolute writes cannot express.
Money
Every amount is an integer count of a currency's minor unit, paired with its currency.
A rate is a percentage as a decimal string, "8.875". Floating point appears nowhere in
this API: binary floats cannot hold most decimal fractions exactly, and the error compounds
across the lines of a basket into a total that disagrees with the printed receipt.
Standards
This API prefers a published standard to a house convention wherever one exists. That is not pedantry: every bespoke mechanism is one a client library cannot already speak, so it becomes integration code that someone has to write and get subtly wrong.
| Concern | Standard |
|---|---|
| Errors | RFC 9457 Problem Details, as application/problem+json |
| Validation detail | RFC 6901 JSON Pointer in invalid_params |
| Identifiers | RFC 9562 UUIDv7 — time-ordered, client-mintable |
| Conditional writes | RFC 9110 ETag / If-Match / 412 |
| Async work | RFC 9110 202 + Location + Retry-After |
| Custom headers | RFC 9651 Structured Field Values |
| Webhook signing | RFC 9421 HTTP Message Signatures |
| Webhook body integrity | RFC 9530 Content-Digest |
| Deprecation | RFC 9745 Deprecation + RFC 8594 Sunset + RFC 8288 Link |
| Integration access tokens | RFC 9068 JWT profile, validated per RFC 8725. A till's token is opaque. |
| Client address behind a proxy | RFC 7239 Forwarded, believed only from configured proxies |
| Timestamps | RFC 3339, always UTC |
| Currency / country / language | ISO 4217, ISO 3166-1 alpha-2, BCP 47 |
Two deliberate exceptions, both flagged rather than hidden:
Idempotency-Keyfollows draft-ietf-httpapi-idempotency-key-header, which never became an RFC: the last revision,-07, was published in October 2025 and expired in April 2026. Treat it as a de facto standard rather than as work in progress — Stripe, Square and Adyen all use this header name and these semantics, so following it is what makes us predictable, standard or not. If the working group revives it, what it standardises is likely to be what these four already do.RateLimitis deliberately absent. The limiter is per process — a guard against one client swamping a pod, not a billing meter — so aRemainingvalue sent from one of roughly thirty processes would be wrong by thirty times, and an integrator would build a scheduler against a number nothing promised.
Rate limits, honestly stated: a request that records trade at a site — order
facts, payments, card holds and checkouts, refunds, and what a guest does at a venue
— spends that site's quota, and a till's own smaller quota inside it: both must
admit the request, so a chain's admission grows with its sites, and one till in a
retry loop caps itself without touching its site's budget. A person or an integration
trading where no site is named — a manager's refund from the office, a batch over
several sites — spends the caller's own quota, keyed by the client id joined with the
merchant for an integration and the merchant alone for a person. Everything that
does not trade — catalogue, staff, settings, reports, exports — spends that same
caller's quota, so a heavy export never slows a counter. An integration that trades
acts at a location; one that does not is never asked for one. The quota is per
process: two processes do not share one bucket, and the figure is not published as a
promise. When a quota is spent the API answers 429 with Retry-After — honour it
rather than applying your own backoff, and expect to be throttled on the way to it,
because that is the honest part of per-process limiting. A trading 429 only delays
a till's sync: ingress answers 202 and the till holds its own outbox, so no sale
waits on it.
Identity, and the keys that sign your token
An integration's access token is an RFC 9068 JWT, typed at+jwt and signed RS256. It
comes from the issuer, https://api.tenvelo.com, this API's own origin. The issuer
describes itself at https://api.tenvelo.com/.well-known/oauth-authorization-server (RFC
8414 metadata) and at https://api.tenvelo.com/.well-known/openid-configuration, where
OpenID Connect clients look. It carries the token endpoint and jwks_uri, and it declares
only what the issuer supports for an integration: client_credentials.
Verify against the published JWKS. Do not pin a key. The key set rotates, and more than one key is published at a time: a successor appears in the JWKS before it signs anything, and a replaced key keeps verifying until every token it signed has expired. So:
- read
kidfrom the token header and select that key from the set; - cache the JWKS, but treat a
kidyou do not recognise as a reason to fetch it again, not as a reason to reject the token; - expect more than one key, and do not assume the first is the right one.
A client that fetched the key once and hard-coded it will work until the first rotation and then fail, roughly a month later, for reasons that will not look like its own.
Errors
All errors are RFC 9457 problem documents. Branch on type, which is a stable URI —
title and detail are human-readable and may be reworded without notice.
Every type URI serves its own documentation, and /problems lists them all. The
list is read from the same table the API maps its errors through, so it cannot name a
type the API does not send.
Versioning
The path carries the major version. Breaking changes are gated in CI against the
published spec, because tills in the field run builds we do not control and cannot be
made to upgrade on demand. Deprecations are announced in-band via the Deprecation,
Sunset and Link headers, so a device can detect its own obsolescence rather than
relying on someone having read a changelog.