Collections & records
The twenty-six record collections, the snapshot envelope they share, and how to walk one.
A collection is one record type: temperature-records, cooling,
deliveries, and twenty-three others. Every one is read the same way,
through the same four endpoints, and every record comes back in the same
envelope: the fields around data that say when it was captured and whether
it still exists.
Each collection is a separate grant. A key reads temperature-records because
someone granted temperature-records:read, and nothing else comes with it.
What you can read
GET /v1/restaurants/{restaurantId}/collections
GET /v1/restaurants/{restaurantId}/collections/{collection}/records?limit=…&cursor=…
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}/assets
Start with the first one. It lists only the collections your key was granted, each with the scope that opened it, so you never have to guess:
{
"data": [
{
"collection": "temperature-records",
"readScope": "temperature-records:read",
"description": "Received shadow snapshots for temperature-records; not complete primary-store history.",
"stateKind": "received-shadow-snapshot",
"payloadVersion": 1
}
]
}
An empty data is not an error and not an outage: it is a key with no
collection grants. Most keys issued before this surface existed have exactly
that, and widening one is an email.
The envelope
Every record in every collection carries the same outer fields. Only data
changes shape.
| Field | Type | Meaning |
|---|---|---|
id | string | The record's identifier, stable and unique within the collection. |
restaurantId | string | The restaurant it belongs to. Echoes the path. |
collection | string | Which collection it came from. Echoes the path. |
data | object | absent | The record itself. A tombstone is the only case where it can be missing, so treat it as present everywhere else. |
deleted | boolean | true means a tombstone: the record was deleted in the app. |
capturedAt | string | When the device recorded the change, epoch milliseconds as a string. |
receivedAt | string | When this API received it. Later than capturedAt, sometimes by hours. |
sequence | string | A monotonically increasing position in the stream of changes. Compare two of them; do not do arithmetic on one. |
mutationId | string | The identifier of the change that produced this state. Idempotency key on our side; a deduplication key on yours. |
sourceVersion | string | null | The version stamp of the originating record, when it has one. |
payloadVersion | number | Currently always 1. It bumps if the meaning of data ever changes. |
stateKind | string | Always received-shadow-snapshot. Read the next section. |
capturedAt and receivedAt both matter. A tablet in a cold room with no
signal records at 09:00 and uploads at 14:00; ordering your own pipeline on
receivedAt keeps it correct, and reporting on capturedAt keeps it true.
What a snapshot is, and is not
stateKind says received-shadow-snapshot, and the wording is careful.
- It is the current state of a record, not a log of every edit. Read a record twice and you get what it looks like now, both times.
- It is what we received, not what the restaurant holds. A device that never uploaded a change means a record this API has never seen.
- It is not a backfill. A collection begins for a restaurant the day capture is switched on for it. Records created before that are in the app and not here. Deliveries are the one exception: those two collections carry the history the app still held when they opened.
So an absent record is genuinely ambiguous: never recorded, or recorded and not yet arrived. Do not build a figure that reads as an audit on top of it, and see the completeness note before you report a count to anyone.
Tombstones are the one case where absence is unambiguous. deleted: true with
no data means the record existed and was deleted, and it is the only way you
learn that something went away.
How long records stay
Operational records stay here for 90 days, counted from when they happened
in the kitchen (data.timestamp), not from when we received them. After that, a
nightly job moves them to a locked cold archive, kept for five years, and they
stop appearing in every read: lists, single records and their assets.
| Kept 90 days | Kept for the life of the account |
|---|---|
temperature-readings, temperature-records, cooking-temperature-records, cleaning-task-records, cleaning-task-pictures, cooling, freezing, reheating, transport, fryer-checks, surface-analyses, traceability-labels, deliveries, delivery-pictures | restaurants, users, areas, equipment, sensors, cooking-equipment, suppliers, products, preparations, cleaning-tasks, fryer-equipment, drive-files |
An archived record is not a tombstone, and it is the one exception to the tombstone rule above. It leaves without a deleted: true,
because nobody deleted it: it moved. If you keep a copy, keep what you already
hold, and never treat a record missing from a walk as a delete. For anything
older than 90 days, your copy is the source; an inspection that needs our
archive goes through support.
The twenty-six collections
Every one takes <collection>:read as its scope, so cooling needs
cooling:read, and so on. They are documented by domain, field by field, on
the pages below.
| Page | Collections |
|---|---|
| Temperatures | temperature-records, temperature-readings, cooking-temperature-records |
| Deliveries | deliveries, delivery-pictures |
| Traceability labels | traceability-labels |
| Cleaning | cleaning-tasks, cleaning-task-records, cleaning-task-pictures |
| Fryers | fryer-equipment, fryer-checks |
| Cooling & food processes | cooling, freezing, reheating, transport |
| Surface analyses | surface-analyses |
| Documents | drive-files |
| Catalogue | suppliers, products, preparations |
| Facilities & people | restaurants, users, areas, equipment, sensors, cooking-equipment |
Temperatures inside a delivery are always Celsius: a reading taken in
Fahrenheit is converted when it arrives, and every temperatureRecords[].unit
says "C".
Start with Facilities & people if you are reading any of the others: equipment thresholds and areas are what turn a bare measurement into something you can judge.
The OpenAPI document carries the same shapes, generated from the running service. When a page here and that document disagree, the document is right and we have a page to fix.
Walking a collection
GET /v1/restaurants/{restaurantId}/collections/{collection}/records?limit=100
| Parameter | Required | Rules |
|---|---|---|
limit | no | 1 to 100. Defaults to 50. |
cursor | no | The nextCursor of the previous page, verbatim. |
There is no time range: you walk a collection, not a window of it. To read a
week of deliveries, walk deliveries and keep the records whose
data.timestamp falls in that week.
A walk is a consistent snapshot. The first page pins the position of the
stream, and every later page is answered as of that same position. Records
written while you are paging do not shift the pages under you and do not appear
mid-walk. You see them on your next walk. Ordering is by sequence, ascending.
The cursor is bound to the restaurant, the collection and the limit. Change the page size mid-walk and it is rejected: pick a limit, keep it for the whole walk.
async function* records({ apiKey, restaurantId, collection }) {
const base = `https://api.backresto.com/v1/restaurants/${restaurantId}/collections/${collection}/records`;
let cursor = null;
do {
const url = new URL(base);
url.searchParams.set('limit', '100');
if (cursor !== null) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!response.ok) {
throw new Error(`BackResto ${response.status}`);
}
const page = await response.json();
yield* page.data;
cursor = page.nextCursor;
} while (cursor !== null);
}
Keeping a copy up to date
There is no since parameter today. A refresh is another walk, and you
reconcile with what you already hold:
- Key on
idwithin a collection, and overwrite when thesequenceyou receive is higher than the one you stored. - Honour tombstones.
deleted: trueis the delete instruction; applying it is the only way your copy stops diverging. - Walk at a sane hour. A full collection at
limit=100is a handful of requests for a single restaurant, and the budget is 300 a minute, but several hundred restaurants on the same cron minute is a spike you own.
If an incremental cursor would change what you can build, say so. It is a small change to a stream that is already ordered. The reason it does not exist is that nobody has needed it yet.
Assets
Four collections carry a file: cleaning-task-pictures,
delivery-pictures, traceability-labels and drive-files. The record names it in data.asset;
the bytes come from the assets endpoint.
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}/assets
{
"data": [
{
"id": "a17c93be4f02",
"contentType": "application/pdf",
"byteLength": 184320,
"sha256": "9f2a…",
"uploadedAt": "1789000000000",
"url": "https://…?X-Amz-Signature=…",
"urlExpiresAt": "1789000900000"
}
]
}
It answers with a list of signed URLs, each valid for fifteen minutes, and
every URL in one response shares the same expiry. Each one carries its own
authorisation, so the download needs no Authorization header, which also
means the link is a bearer credential in itself: for fifteen minutes anybody
holding it can read that file. Two things are worth knowing.
These are files, not only pictures. A cleaning proof or a delivery
photograph is a picture, but a
traceability label or a drive file can be a PDF, a CSV, a spreadsheet or a Word
document. Read contentType rather than assuming an image, and do not rename
everything .jpg on the way in.
sha256 is there so you can skip work. It is the digest of the bytes: if it
matches something you already stored, you do not need to download it again.
Only assets whose upload completed and was verified appear. One still in flight is absent rather than broken, and the same is true once the parent record is deleted.
The rest of the advice is worth repeating because the failure is quiet: fetch the bytes now, store the bytes rather than the link, never put the URL somewhere it outlives its purpose (an email, a ticket, a long-lived cache), and call the endpoint again when you need a fresh URL. It is cheap, and it re-checks the grant.
The failures you will meet
403: your key reaches the restaurant but not with that collection's
scope. The collections endpoint is the cheap way to find out which ones it does
have.
404: no grant for that restaurant at all, or no such record. The two are
indistinguishable on purpose.
400: a collection outside the twenty-six above, or a cursor that does
not belong to this restaurant, collection and limit.
All three are problem documents with a requestId.