Records and sync.

A record is any JSON object; what is in it is yours. Three ways to write — replace all, upsert some, delete one — and one way to keep a copy in step: ask what changed since your cursor.

Working — v1 at api.ardaform.net

Keys

  • A record's key is its own id, as you know it: the id field, else key, else record_key, as text (cut to 190 characters). With a key, writing the record again updates it — a write is safe to repeat.
  • The same key twice in one call is refused (400 DUPLICATE_KEY), never quietly collapsed.
  • A record without a key is refused (NO_KEY) — an empty or blank key counts as none. The rest of the call is stored; the answer lists each refused record in exceptions and counts them in refused. A call in which no record has a key is 400 NO_KEY and stores nothing.
!
Why every record needs a key A record without one cannot sync back to any device: it would reach the device with "key": null, and ArdaPlayer up to v1.1.16 refuses the whole sync window that carries such a record, so the dataset would stop updating on every device. A device's own push without a key is refused by the platform in the same words, with the same NO_KEY. For a log, give each entry an id of its own — the time and a counter will do. (401 NO_KEY is a different refusal: a call with no API key.)

Partitions

  • If the dataset has a partition_field, that field is read from each record: a string or a number, or a list of them. A list puts one record in several partitions — one price four branches share is one record, not four copies.
  • Each record's partitions is always an array (empty when the dataset is not partitioned), sorted.
  • A write replaces a record's partitions with what it holds now: a record that stops being shared stops being served there.
  • An object or a boolean in the field is 400 BAD_PARTITION.

Query parameters

Each endpoint takes exactly the parameters in its table, plus — on reads — the dataset's own partition_field. Anything else is 400 BAD_PARAM, not ignored: a filter whose job is to keep a branch's data apart must not return everything because of a typo. A write takes none at all — PUT …/records?branch=oldbury would read as "republish Oldbury" and mean "delete every other branch", so it is refused.

GET/v1/datasets/{key}/recordsRead records

A page of records, in the order they were first written. Filter by the partition field's own name; total counts a record in several partitions once, and has_more says there is another page.

For a copy you keep up to date, use /changes instead: reading pages and then the dataset's cursor can miss writes that land in between.

Key scope records:r — read records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.

Query parameters

NameType
limitinteger
1–1000 · default 100
Page size, 1–1000, default 100. Out of range is clamped to the range, not refused; a value that is not a number is the default.
offsetinteger
≥ 0 · default 0
How many to skip. Negative or not a number is 0.
partitionstringFILTER BY YOUR OWN FIELD NAME, not by the word "partition". The parameter is whatever the dataset's partition_field says — ?shop_id=oldbury for a dataset partitioned by shop_id. This entry exists so generated clients have somewhere to put it; the name is per dataset, so it cannot be spelt here. An empty value is no filter. Any OTHER query parameter is 400 BAD_PARAM rather than ignored — a filter whose whole job is isolation must not fail open on a typo.

Answer 200 A page of records

FieldType
datasetstringThe dataset key.
totalintegerMatching records, counted once each — a record in four partitions is one.
limitintegerThe page size used, after clamping.
offsetintegerThe offset used.
has_morebooleanThere are more beyond this page. A caller who reads 1000 of 48,000 and is given no hint will ship believing it read the lot.
recordsarray of Record

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/records?branch=oldbury&limit=2" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "total": 2,
  "limit": 2,
  "offset": 0,
  "has_more": false,
  "records": [
    {
      "key": "A1",
      "partitions": [
        "moseley",
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:01.364Z",
      "data": {
        "id": "A1",
        "name": "Sourdough",
        "price": 3.2,
        "branch": [
          "oldbury",
          "moseley"
        ]
      }
    },
    {
      "key": "A2",
      "partitions": [
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:01.462Z",
      "data": {
        "id": "A2",
        "name": "Rye",
        "price": 3.05,
        "branch": "oldbury"
      }
    }
  ]
}

Filtering by a name the dataset does not use

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/records?shop=oldbury" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 400 Bad Requestran live 2026-10-27 · test project
{
  "error": {
    "code": "BAD_PARAM",
    "message": "'shop' is not a parameter this endpoint takes; it takes branch, limit, offset. This dataset is partitioned by 'branch' — that is the name to filter on.",
    "parameter": "shop",
    "accepted": [
      "branch",
      "limit",
      "offset"
    ]
  }
}

GET/v1/datasets/{key}/records/{recordKey}One record

One record by its key. URL-encode a key that holds anything but letters, digits, - and _.

Key scope records:r — read records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.
recordKeystring
at most 190 characters
The record's key — the id, key or record_key it was written with (URL-encode it).

Answer 200 The record

FieldType
recordRecordOne row of your data. What is inside data is entirely yours; the API only reads the two fields it needs to address and partition it.

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_RECORDNo record with that key in the dataset.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/records/A1" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "record": {
    "key": "A1",
    "partitions": [
      "moseley",
      "oldbury"
    ],
    "created_at": "2026-10-27T20:10:01.364Z",
    "updated_at": "2026-10-27T20:10:01.364Z",
    "data": {
      "id": "A1",
      "name": "Sourdough",
      "price": 3.2,
      "branch": [
        "oldbury",
        "moseley"
      ]
    }
  }
}

PUT/v1/datasets/{key}/recordsReplace every record in the dataset

Replaces everything in the dataset with what you send, in one transaction — for a catalogue published from somewhere else. The body is an array of records, or {"records": [...]}.

  • Storage is checked before anything is written; what is replaced counts as freed.
  • A syncing client sees a purge (purged: true) followed by the new records, not one deletion per old record.
  • A key with records:w on the dataset may do this. Keep a separate key for feeds that should only add.

Key scope records:w — write records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.

Body

A JSON array of record objects, or {"records": [...]} — both are accepted. At most 10,000 records and 10 MB. A record's key is its id, else key, else record_key (as text, cut to 190 characters). ⚠️ A record without one is refused (NO_KEY): a record with no key cannot sync back to any device. The rest of the call stores, and each refused record is listed in exceptions; a call in which no record has a key is 400 NO_KEY and stores nothing. The dataset's partition_field, when it has one, is read from each record: a string, a number or a list of them (each cut to 190).

Answer 200 Replaced

FieldType
datasetstringThe dataset key.
replacedintegerRecords now in the dataset from this call: those stored, less any retention pruned.
refusedintegerHow many records this call refused one by one, while the rest stored. 0 when none.
exceptionsarray of objectOne entry per refused record, in the order sent. Empty when none.
cursorintegerThe sync number this write ended on. Pass it as since to /changes.
prunedintegerRecords retention dropped to make room, as part of THIS call. An upload that silently shrank your dataset by 4,000 rows is something the call that did it has to say out loud.

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
400BAD_REQUESTThe body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which.
400TOO_MANYMore than 10,000 records in one call.
400BAD_RECORDAn element of the records array is not a JSON object.
400NO_KEYA record write in which NO record has a key (id, key or record_key; empty or blank counts as none). Nothing is stored. When only some lack one, the rest store and each keyless one is listed in the answer's exceptions with this word instead. A record without a key cannot sync back to any device, so it is never stored. The same word and message a device push gets from connect.
400DUPLICATE_KEYTwo records in one call have the same key. Send it once, or give them different keys.
400BAD_PARTITIONA partition value is an object or a boolean (records: in the dataset's partition_field; files: in partitions). A partition is a string or a number, or a list of them.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403API_READ_ONLYThe app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work.
403DATASET_FULLThe write would take the dataset past its own storage_limit_bytes and its retention is never, so nothing is dropped to make room.
403PROJECT_FULLThe write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance.
403STORAGE_FULLThe write would take the account past its package's storage_mb. Code, datasets and files share it.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL BAD_JSON TOO_LARGE.

curl -X PUT "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"id":"A1","name":"Sourdough","price":3.2,"branch":["oldbury","moseley"]},{"id":"A2","name":"Rye","price":2.9,"branch":"oldbury"}]'
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "replaced": 2,
  "pruned": 0,
  "cursor": 3,
  "refused": 0,
  "exceptions": []
}

POST/v1/datasets/{key}/recordsUpsert the records sent, leaving the rest alone

Writes the records sent and leaves the rest alone: a record whose key exists is updated, a new key is added. written is every record stored; a record without a key is refused alone and listed in exceptions. (appended is always 0 — keyless records were appended until 2026-10-19 — and stays so a client reading it does not break.)

Every write answers the cursor it ended on, and pruned — records the dataset's retention dropped to make room during this call.

Key scope records:w — write records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.

Body

A JSON array of record objects, or {"records": [...]} — both are accepted. At most 10,000 records and 10 MB. A record's key is its id, else key, else record_key (as text, cut to 190 characters). ⚠️ A record without one is refused (NO_KEY): a record with no key cannot sync back to any device. The rest of the call stores, and each refused record is listed in exceptions; a call in which no record has a key is 400 NO_KEY and stores nothing. The dataset's partition_field, when it has one, is read from each record: a string, a number or a list of them (each cut to 190).

Answer 200 Written

FieldType
datasetstringThe dataset key.
writtenintegerRecords stored — the number sent, less refused.
appendedintegerAlways 0 since 2026-10-19, when keyless records stopped being appended (they are refused instead). Kept so a client reading it does not break.
refusedintegerHow many records this call refused one by one, while the rest stored. 0 when none.
exceptionsarray of objectOne entry per refused record, in the order sent. Empty when none.
cursorintegerThe sync number this write ended on. Pass it as since to /changes.
prunedintegerRecords retention dropped to make room, as part of THIS call. An upload that silently shrank your dataset by 4,000 rows is something the call that did it has to say out loud.

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
400BAD_REQUESTThe body is missing a required field, has one of the wrong type or length, or (on PUT /v1/content/…) arrived shorter or longer than its Content-Length. The message says which.
400TOO_MANYMore than 10,000 records in one call.
400BAD_RECORDAn element of the records array is not a JSON object.
400NO_KEYA record write in which NO record has a key (id, key or record_key; empty or blank counts as none). Nothing is stored. When only some lack one, the rest store and each keyless one is listed in the answer's exceptions with this word instead. A record without a key cannot sync back to any device, so it is never stored. The same word and message a device push gets from connect.
400DUPLICATE_KEYTwo records in one call have the same key. Send it once, or give them different keys.
400BAD_PARTITIONA partition value is an object or a boolean (records: in the dataset's partition_field; files: in partitions). A partition is a string or a number, or a list of them.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403API_READ_ONLYThe app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work.
403DATASET_FULLThe write would take the dataset past its own storage_limit_bytes and its retention is never, so nothing is dropped to make room.
403PROJECT_FULLThe write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance.
403STORAGE_FULLThe write would take the account past its package's storage_mb. Code, datasets and files share it.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL BAD_JSON TOO_LARGE.

curl -X POST "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"id":"A2","name":"Rye","price":3.05,"branch":"oldbury"},{"id":"A3","name":"Bagel","price":1.2,"branch":"moseley"}]'
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "written": 2,
  "appended": 0,
  "refused": 0,
  "exceptions": [],
  "cursor": 5,
  "pruned": 0
}

One record without a key, the rest stored

curl -X POST "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"records":[{"id":"A4","name":"Scone","price":1.5,"branch":"moseley"},{"event":"price-board-shown","branch":"oldbury"}]}'
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "written": 1,
  "appended": 0,
  "refused": 1,
  "exceptions": [
    {
      "index": 1,
      "code": "NO_KEY",
      "dataset": "zz-docs-products",
      "message": "a record pushed to dataset \"zz-docs-products\" needs a key — without one it cannot sync back to any device"
    }
  ],
  "cursor": 6,
  "pruned": 0
}

A call with no keyed record

curl -X POST "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"event":"price-board-shown","branch":"oldbury"}]'
Answer · 400 Bad Requestran live 2026-10-27 · test project
{
  "error": {
    "code": "NO_KEY",
    "message": "a record pushed to dataset \"zz-docs-products\" needs a key — without one it cannot sync back to any device",
    "dataset": "zz-docs-products",
    "refused": 1
  }
}

The same key twice in one call

curl -X POST "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY" \
  -H "Content-Type: application/json" \
  -d '[{"id":"A1","price":3.4},{"id":"A1","price":3.5}]'
Answer · 400 Bad Requestran live 2026-10-27 · test project
{
  "error": {
    "code": "DUPLICATE_KEY",
    "message": "Records 0 and 1 both have the key 'A1'; send it once, or give them different keys.",
    "index": 1,
    "first": 0,
    "key": "A1"
  }
}

DELETE/v1/datasets/{key}/records/{recordKey}Remove one record

Removes one record by its key. A syncing client sees its key in deleted.

Key scope records:d — delete records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.
recordKeystring
at most 190 characters
The record's key — the id, key or record_key it was written with (URL-encode it).

Answer 200 Deleted

FieldType
deletedobject
deleted.datasetstringThe dataset key.
deleted.recordstringThe record key.
cursorintegerThe sync number the deletion was written at.

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403API_READ_ONLYThe app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_RECORDNo record with that key in the dataset.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.

curl -X DELETE "https://api.ardaform.net/v1/datasets/zz-docs-products/records/A3" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "deleted": {
    "dataset": "zz-docs-products",
    "record": "A3"
  },
  "cursor": 7
}

DELETE/v1/datasets/{key}/recordsEmpty the dataset, keeping the dataset

Empties the dataset and keeps the dataset. ?confirm=1 is required, because without it a missing record key in a path would turn "delete one" into "delete everything". A syncing client sees a purge.

Key scope records:d — delete records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.

Query parameters

NameType
confirm requiredstring — 1Exactly 1.

Answer 200 Emptied

FieldType
datasetstringThe dataset key.
deletedintegerHow many records went.
cursorintegerThe sync number the purge was written at.

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
400CONFIRM_REQUIREDEmptying a whole dataset, removing a space (its devices to Unassigned, a private one's own data deleted), or turning a space's private off (its own data deleted) needs ?confirm=1.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403API_READ_ONLYThe app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.

curl -X DELETE "https://api.ardaform.net/v1/datasets/zz-docs-products/records?confirm=1" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "deleted": 3,
  "cursor": 9
}

Without ?confirm=1

curl -X DELETE "https://api.ardaform.net/v1/datasets/zz-docs-products/records" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 400 Bad Requestran live 2026-10-27 · test project
{
  "error": {
    "code": "CONFIRM_REQUIRED",
    "message": "Emptying dataset 'zz-docs-products' deletes every record in it; add ?confirm=1 to do it."
  }
}

Sync: /changes

Ask what happened since your cursor, apply it, store the new cursor, repeat. That is the whole protocol:

  1. The first time, call ?since=0. Keep calling with each answer's cursor while has_more is true.
  2. If purged is true, drop everything you hold first: the dataset was emptied or replaced inside this window.
  3. Upsert every record in changed, delete every key in deleted — a key is never in both.
  4. Store cursor. Next time, send it as since.
  • The cursor is a sequence number, not a time. Treat it as opaque: store it, send it back, never do arithmetic on it. Times tie, clocks step, and a row is stamped before it is visible; the cursor has none of those problems.
  • Changes and deletions come from one window, so a stored cursor has seen every change and every deletion up to it. A window ends early when either half fills limit: read has_more, not the length of the lists.
  • With a partition filter (?branch=oldbury), a record that left your partition comes back in deleted. A record that was never yours and was touched may appear there too; deleting a key you do not hold does nothing.
  • A record written without a key before 2026-10-19 (when the API still stored them) is in changed with "key": null and is never in deleted.
  • A record written in the portal moves the cursor exactly like one written here.

GET/v1/datasets/{key}/changesWhat changed since a cursor

What changed since since. Here since=0 came back with purged: true because the dataset began with a replace; every record follows in changed, including the one written without a key.

Key scope records:r — read records, grantable per dataset

Path parameters

NameType
keystring
pattern ^[a-z0-9][a-z0-9_-]{0,79}$
The dataset's key — what you chose, not an id.

Query parameters

NameType
sinceinteger
≥ 0 · default 0
The cursor from your last call. 0 (or absent, negative, not a number) for a full sync.
limitinteger
1–1000 · default 100
Page size, 1–1000, default 100. Out of range is clamped to the range, not refused; a value that is not a number is the default. The window closes at whichever of the changed rows or the deletions overflows it first — read has_more, not the page length.
partitionstringFILTER BY YOUR OWN FIELD NAME, not by the word "partition". The parameter is whatever the dataset's partition_field says — ?shop_id=oldbury for a dataset partitioned by shop_id. This entry exists so generated clients have somewhere to put it; the name is per dataset, so it cannot be spelt here. An empty value is no filter. Any OTHER query parameter is 400 BAD_PARAM rather than ignored — a filter whose whole job is isolation must not fail open on a typo.

Answer 200 The window

FieldType
datasetstringThe dataset key.
sinceintegerThe since used.
cursorintegerStore it; pass it as since next time.
has_morebooleanMore windows are waiting — call again now, with the new cursor.
purgedbooleanThe dataset was emptied inside this window. Drop everything you hold, then apply changed and deleted.
changedarray of RecordRecords written or updated in the window, oldest first.
deletedarray of stringRecord keys that are gone (or left your partition).

Errors

StatusWordWhen
400BAD_PARAMA query parameter the endpoint does not take, or a value it does not know. Refused rather than ignored, so a typo in a filter never returns everything.
403API_CLOSEDThe app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key.
403OUT_OF_SCOPEThe key's scopes do not allow this area and verb (or this dataset, for records). A key edited before an area existed has no entry for it and is refused there until it is edited again in the portal.
404NO_DATASETNo dataset with that key on the key's project.
404NO_SPACENo space with that id or slug in the key's project — in /v1/spaces/{space}, a space field or parameter, or the Arda-Space header. unassigned is no space (the devices in none). Another project's space answers the same, so nothing can be probed for.

And, like every keyed call: NO_KEY BAD_KEY KEY_REVOKED KEY_EXPIRED ACCOUNT_INACTIVE NO_API_ACCESS RATE_LIMITED INTERNAL.

since=0: everything

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=0" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "since": 0,
  "cursor": 6,
  "has_more": false,
  "purged": true,
  "changed": [
    {
      "key": "A1",
      "partitions": [
        "moseley",
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:01.364Z",
      "data": {
        "id": "A1",
        "name": "Sourdough",
        "price": 3.2,
        "branch": [
          "oldbury",
          "moseley"
        ]
      }
    },
    {
      "key": "A2",
      "partitions": [
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:01.462Z",
      "data": {
        "id": "A2",
        "name": "Rye",
        "price": 3.05,
        "branch": "oldbury"
      }
    },
    {
      "key": "A3",
      "partitions": [
        "moseley"
      ],
      "created_at": "2026-10-27T20:10:01.462Z",
      "updated_at": "2026-10-27T20:10:01.462Z",
      "data": {
        "id": "A3",
        "name": "Bagel",
        "price": 1.2,
        "branch": "moseley"
      }
    },
    {
      "key": "A4",
      "partitions": [
        "moseley"
      ],
      "created_at": "2026-10-27T20:10:01.541Z",
      "updated_at": "2026-10-27T20:10:01.541Z",
      "data": {
        "id": "A4",
        "name": "Scone",
        "price": 1.5,
        "branch": "moseley"
      }
    }
  ],
  "deleted": []
}

Then, with the cursor it gave

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "since": 6,
  "cursor": 8,
  "has_more": false,
  "purged": false,
  "changed": [
    {
      "key": "A1",
      "partitions": [
        "moseley",
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:03.501Z",
      "data": {
        "id": "A1",
        "name": "Sourdough",
        "price": 3.4,
        "branch": [
          "oldbury",
          "moseley"
        ]
      }
    }
  ],
  "deleted": [
    "A3"
  ]
}

The same window, for one branch

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6&branch=moseley" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "since": 6,
  "cursor": 8,
  "has_more": false,
  "purged": false,
  "changed": [
    {
      "key": "A1",
      "partitions": [
        "moseley",
        "oldbury"
      ],
      "created_at": "2026-10-27T20:10:01.364Z",
      "updated_at": "2026-10-27T20:10:03.501Z",
      "data": {
        "id": "A1",
        "name": "Sourdough",
        "price": 3.4,
        "branch": [
          "oldbury",
          "moseley"
        ]
      }
    }
  ],
  "deleted": [
    "A3"
  ]
}

After the dataset was emptied

curl "https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=8" \
  -H "Authorization: Bearer $ARDA_KEY"
Answer · 200 OKran live 2026-10-27 · test project
{
  "dataset": "zz-docs-products",
  "since": 8,
  "cursor": 9,
  "has_more": false,
  "purged": true,
  "changed": [],
  "deleted": []
}

The Record shape

Record

One row of your data. What is inside data is entirely yours; the API only reads the two fields it needs to address and partition it.

FieldType
keystring, nullableThe record's own id, as you know it — taken from id, key or record_key in the object you sent. A write is an upsert on it, and a record sent without one is refused (NO_KEY). Null only on a record appended before 2026-10-19, when the API still stored keyless records.
partitionsarray of stringThe partitions this record belongs to, lifted out of the payload using the dataset's partition_field. ALWAYS an array — empty when the dataset is not partitioned, one entry for a single value, several when the field held a list. One price four branches share is one record saying "branch": ["oldbury", "kings-heath", …], not four copies that have to be kept in step.
dataobjectYour record, parsed. Returned as an object, never as a JSON string — left as text it would arrive double-encoded, which no importer reads.
created_atstring (date-time)When the record first arrived. UTC.
updated_atstring (date-time)When it was last written — an upsert moves it. UTC.