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.

FieldTypeMeaning
idstringThe record's identifier, stable and unique within the collection.
restaurantIdstringThe restaurant it belongs to. Echoes the path.
collectionstringWhich collection it came from. Echoes the path.
dataobject | absentThe record itself. A tombstone is the only case where it can be missing, so treat it as present everywhere else.
deletedbooleantrue means a tombstone: the record was deleted in the app.
capturedAtstringWhen the device recorded the change, epoch milliseconds as a string.
receivedAtstringWhen this API received it. Later than capturedAt, sometimes by hours.
sequencestringA monotonically increasing position in the stream of changes. Compare two of them; do not do arithmetic on one.
mutationIdstringThe identifier of the change that produced this state. Idempotency key on our side; a deduplication key on yours.
sourceVersionstring | nullThe version stamp of the originating record, when it has one.
payloadVersionnumberCurrently always 1. It bumps if the meaning of data ever changes.
stateKindstringAlways 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 daysKept 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-picturesrestaurants, 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.

PageCollections
Temperaturestemperature-records, temperature-readings, cooking-temperature-records
Deliveriesdeliveries, delivery-pictures
Traceability labelstraceability-labels
Cleaningcleaning-tasks, cleaning-task-records, cleaning-task-pictures
Fryersfryer-equipment, fryer-checks
Cooling & food processescooling, freezing, reheating, transport
Surface analysessurface-analyses
Documentsdrive-files
Cataloguesuppliers, products, preparations
Facilities & peoplerestaurants, 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
ParameterRequiredRules
limitno1 to 100. Defaults to 50.
cursornoThe 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 id within a collection, and overwrite when the sequence you receive is higher than the one you stored.
  • Honour tombstones. deleted: true is the delete instruction; applying it is the only way your copy stops diverging.
  • Walk at a sane hour. A full collection at limit=100 is 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.

Last updated 2026-09-29.