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.
Keys
- A record's key is its own id, as you know it: the
idfield, elsekey, elserecord_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 inexceptionsand counts them inrefused. A call in which no record has a key is400 NO_KEYand stores nothing.
"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
partitionsis 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
| Name | Type | |
|---|---|---|
key | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The dataset's key — what you chose, not an id. |
Query parameters
| Name | Type | |
|---|---|---|
limit | integer 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. |
offset | integer ≥ 0 · default 0 | How many to skip. Negative or not a number is 0. |
partition | string | FILTER 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
| Field | Type | |
|---|---|---|
dataset | string | The dataset key. |
total | integer | Matching records, counted once each — a record in four partitions is one. |
limit | integer | The page size used, after clamping. |
offset | integer | The offset used. |
has_more | boolean | There 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. |
records | array of Record |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_SPACE | No 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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records?branch=oldbury&limit=2', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records?branch=oldbury&limit=2",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records?shop=oldbury', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records?shop=oldbury",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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
| Name | Type | |
|---|---|---|
key | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The dataset's key — what you chose, not an id. |
recordKey | string 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
| Field | Type | |
|---|---|---|
record | 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. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_RECORD | No record with that key in the dataset. |
| 404 | NO_SPACE | No 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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records/A1', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records/A1",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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:won 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
| Name | Type | |
|---|---|---|
key | string 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
| Field | Type | |
|---|---|---|
dataset | string | The dataset key. |
replaced | integer | Records now in the dataset from this call: those stored, less any retention pruned. |
refused | integer | How many records this call refused one by one, while the rest stored. 0 when none. |
exceptions | array of object | One entry per refused record, in the order sent. Empty when none. |
cursor | integer | The sync number this write ended on. Pass it as since to /changes. |
pruned | integer | Records 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
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 400 | BAD_REQUEST | The 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. |
| 400 | TOO_MANY | More than 10,000 records in one call. |
| 400 | BAD_RECORD | An element of the records array is not a JSON object. |
| 400 | NO_KEY | A 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. |
| 400 | DUPLICATE_KEY | Two records in one call have the same key. Send it once, or give them different keys. |
| 400 | BAD_PARTITION | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | API_READ_ONLY | The app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work. |
| 403 | DATASET_FULL | The write would take the dataset past its own storage_limit_bytes and its retention is never, so nothing is dropped to make room. |
| 403 | PROJECT_FULL | The write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance. |
| 403 | STORAGE_FULL | The write would take the account past its package's storage_mb. Code, datasets and files share it. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_SPACE | No 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"}]'const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify([
{
id: 'A1',
name: 'Sourdough',
price: 3.2,
branch: ['oldbury', 'moseley']
},
{ id: 'A2', name: 'Rye', price: 2.9, branch: 'oldbury' }
])
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.put(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json=[
{
"id": "A1",
"name": "Sourdough",
"price": 3.2,
"branch": ["oldbury", "moseley"],
},
{"id": "A2", "name": "Rye", "price": 2.9, "branch": "oldbury"},
],
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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
| Name | Type | |
|---|---|---|
key | string 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
| Field | Type | |
|---|---|---|
dataset | string | The dataset key. |
written | integer | Records stored — the number sent, less refused. |
appended | integer | Always 0 since 2026-10-19, when keyless records stopped being appended (they are refused instead). Kept so a client reading it does not break. |
refused | integer | How many records this call refused one by one, while the rest stored. 0 when none. |
exceptions | array of object | One entry per refused record, in the order sent. Empty when none. |
cursor | integer | The sync number this write ended on. Pass it as since to /changes. |
pruned | integer | Records 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
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 400 | BAD_REQUEST | The 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. |
| 400 | TOO_MANY | More than 10,000 records in one call. |
| 400 | BAD_RECORD | An element of the records array is not a JSON object. |
| 400 | NO_KEY | A 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. |
| 400 | DUPLICATE_KEY | Two records in one call have the same key. Send it once, or give them different keys. |
| 400 | BAD_PARTITION | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | API_READ_ONLY | The app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work. |
| 403 | DATASET_FULL | The write would take the dataset past its own storage_limit_bytes and its retention is never, so nothing is dropped to make room. |
| 403 | PROJECT_FULL | The write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance. |
| 403 | STORAGE_FULL | The write would take the account past its package's storage_mb. Code, datasets and files share it. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_SPACE | No 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"}]'const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify([
{ id: 'A2', name: 'Rye', price: 3.05, branch: 'oldbury' },
{ id: 'A3', name: 'Bagel', price: 1.2, branch: 'moseley' }
])
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json=[
{"id": "A2", "name": "Rye", "price": 3.05, "branch": "oldbury"},
{"id": "A3", "name": "Bagel", "price": 1.2, "branch": "moseley"},
],
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"}]}'const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
records: [
{ id: 'A4', name: 'Scone', price: 1.5, branch: 'moseley' },
{ event: 'price-board-shown', branch: 'oldbury' }
]
})
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"records": [
{"id": "A4", "name": "Scone", "price": 1.5, "branch": "moseley"},
{"event": "price-board-shown", "branch": "oldbury"},
],
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"}]'const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify([{ event: 'price-board-shown', branch: 'oldbury' }])
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json=[{"event": "price-board-shown", "branch": "oldbury"}],
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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}]'const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify([{ id: 'A1', price: 3.4 }, { id: 'A1', price: 3.5 }])
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.post(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json=[{"id": "A1", "price": 3.4}, {"id": "A1", "price": 3.5}],
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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
| Name | Type | |
|---|---|---|
key | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The dataset's key — what you chose, not an id. |
recordKey | string at most 190 characters | The record's key — the id, key or record_key it was written with (URL-encode it). |
Answer 200 Deleted
| Field | Type | |
|---|---|---|
deleted | object | |
deleted.dataset | string | The dataset key. |
deleted.record | string | The record key. |
cursor | integer | The sync number the deletion was written at. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | API_READ_ONLY | The app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_RECORD | No record with that key in the dataset. |
| 404 | NO_SPACE | No 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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records/A3', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.delete(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records/A3",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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
| Name | Type | |
|---|---|---|
key | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The dataset's key — what you chose, not an id. |
Query parameters
| Name | Type | |
|---|---|---|
confirm required | string — 1 | Exactly 1. |
Answer 200 Emptied
| Field | Type | |
|---|---|---|
dataset | string | The dataset key. |
deleted | integer | How many records went. |
cursor | integer | The sync number the purge was written at. |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 400 | CONFIRM_REQUIRED | Emptying 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | API_READ_ONLY | The app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_SPACE | No 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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records?confirm=1', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.delete(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records?confirm=1",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/records', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.delete(
"https://api.ardaform.net/v1/datasets/zz-docs-products/records",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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:
- The first time, call
?since=0. Keep calling with each answer'scursorwhilehas_moreis true. - If
purgedis true, drop everything you hold first: the dataset was emptied or replaced inside this window. - Upsert every record in
changed, delete every key indeleted— a key is never in both. - Store
cursor. Next time, send it assince.
- 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: readhas_more, not the length of the lists. - With a partition filter (
?branch=oldbury), a record that left your partition comes back indeleted. 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
changedwith"key": nulland is never indeleted. - 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
| Name | Type | |
|---|---|---|
key | string pattern ^[a-z0-9][a-z0-9_-]{0,79}$ | The dataset's key — what you chose, not an id. |
Query parameters
| Name | Type | |
|---|---|---|
since | integer ≥ 0 · default 0 | The cursor from your last call. 0 (or absent, negative, not a number) for a full sync. |
limit | integer 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. |
partition | string | FILTER 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
| Field | Type | |
|---|---|---|
dataset | string | The dataset key. |
since | integer | The since used. |
cursor | integer | Store it; pass it as since next time. |
has_more | boolean | More windows are waiting — call again now, with the new cursor. |
purged | boolean | The dataset was emptied inside this window. Drop everything you hold, then apply changed and deleted. |
changed | array of Record | Records written or updated in the window, oldest first. |
deleted | array of string | Record keys that are gone (or left your partition). |
Errors
| Status | Word | When |
|---|---|---|
| 400 | BAD_PARAM | A 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. |
| 403 | API_CLOSED | The app's manifest closes this dataset to the API: "api": "none". The fix is in the manifest, not on the key. |
| 403 | OUT_OF_SCOPE | The 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. |
| 404 | NO_DATASET | No dataset with that key on the key's project. |
| 404 | NO_SPACE | No 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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=0', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=0",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6&branch=moseley', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=6&branch=moseley",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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"const res = await fetch('https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=8', {
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`
}
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/datasets/zz-docs-products/changes?since=8",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"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.
| Field | Type | |
|---|---|---|
key | string, nullable | The 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. |
partitions | array of string | The 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. |
data | object | Your record, parsed. Returned as an object, never as a JSON string — left as text it would arrive double-encoded, which no importer reads. |
created_at | string (date-time) | When the record first arrived. UTC. |
updated_at | string (date-time) | When it was last written — an upsert moves it. UTC. |