Errors.
Every failure has the same shape: a word your code matches on, a sentence for whoever is reading a terminal, and — where it helps — the numbers behind the refusal.
Working — v1 at api.ardaform.net
The envelope
Every error
{
"error": {
"code": "DUPLICATE_KEY", // match on this
"message": "Records 0 and 1 both have the key 'A1'", // for people; reworded without notice
"index": 1, "first": 0, "key": "A1" // extras, per word (the table below)
}
}
- Match on
code, never onmessage. A word never changes meaning inside v1; new words may be added (an endpoint may learn a new refusal), so treat an unknown word by its status. - The extras are the useful part:
STORAGE_FULLsays how full and by how much,BAD_PARAMnames the parameter and what is accepted,OUT_OF_SCOPEnames the area and the verb. - A
500 INTERNALsays nothing about our inside on purpose. Quote the time and the path to us. - The one answer that is not this shape: a
503with an HTML body is the web server's own limit, before the API — see Limits.
Statuses
| Status | Means |
|---|---|
200 201 | Done; 201 when something new was created. A delete reports what it removed rather than answering 204; only OPTIONS answers 204. |
400 | Your request: a body, a field or a parameter. |
401 | Your key: missing, unknown, revoked or expired. |
403 | Allowed to ask, not to do: the key's scope, the package, a storage ceiling, the app's manifest. |
404 | Not here: no such dataset, record, file, webhook, delivery, setting — or no such endpoint. |
405 | The path exists; the method does not. |
409 | The state is in the way: it exists already, it is switched off, its bytes were never uploaded. |
410 | A file's bytes are missing on our side. |
413 | The body is over 10 MB. |
429 | The account's rate is spent: wait Retry-After seconds. |
500 502 | Ours. 502 is the webhook delivery service not answering a Test. |
Handling them
- Retry
429afterRetry-After, and500/502/503with a backoff. Do not retry other4xx— the same request gets the same answer. - Writes are safe to repeat: every record has a key, so an upsert of the same record twice is one record. A record without a key is refused (
NO_KEY) — check a write'sexceptions, which lists records refused while the rest stored. 404 NO_DATASETright after a commit usually means the manifest has not been packed yet — the dataset does not exist until it is.
# -w prints the status after the body; -s keeps it quiet
curl -s -w '\n%{http_code}\n' "https://api.ardaform.net/v1/datasets/products" \
-H "Authorization: Bearer $ARDA_KEY"async function arda(path, init = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch('https://api.ardaform.net' + path, {
...init,
headers: { Authorization: `Bearer ${process.env.ARDA_KEY}`, ...init.headers }
})
const answer = await res.json()
if (res.ok) return answer
const wait = res.status === 429 ? Number(res.headers.get('retry-after')) || 1
: res.status >= 500 && attempt < 4 ? 2 ** attempt : 0
if (!wait) {
const e = new Error(answer.error.message)
Object.assign(e, { status: res.status, ...answer.error }) // e.code is the word
throw e
}
await new Promise((r) => setTimeout(r, wait * 1000))
}
}import os, time
import requests
class ArdaError(Exception):
def __init__(self, status, error):
super().__init__(error["message"])
self.status, self.code, self.error = status, error["code"], error
def arda(method, path, **kw):
for attempt in range(5):
r = requests.request(method, "https://api.ardaform.net" + path,
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"}, **kw)
if r.ok:
return r.json()
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
elif r.status_code >= 500 and attempt < 4:
time.sleep(2 ** attempt)
else:
raise ArdaError(r.status_code, r.json()["error"])Using it the JavaScript and Python helpers ran live 2026-10-27: 404 NO_DATASET caught
// match on the word
try { await arda('/v1/datasets/products') }
catch (e) { if (e.code === 'NO_DATASET') { /* the manifest is not packed yet */ } else throw e }
Every word
Every error word the public API can answer. The list is generated from the API's own catalogue, and a test fails if the code answers a word that is not in it, or with a different status — so this table is the whole list. Each endpoint's own section lists the words it can answer.
| Status | Word | When | Also carries |
|---|---|---|---|
| 400 | BAD_BASE64 | base64 is not valid base64 — it decodes to different bytes than it describes, or to nothing. | |
| 400 | BAD_FILE_NAME | The file name is missing, holds a slash, or has no extension. | name the namemime the mimeaccepted the extensions accepted |
| 400 | BAD_FILE_TYPE | The extension is not on the allowlist: any image, plus documents, data, web, media and archive types. | name the namemime the mimeaccepted the extensions accepted |
| 400 | BAD_JSON | The body was sent as application/json and does not parse. | |
| 400 | BAD_KEY_FORMAT | A dataset key or webhook slug is not lower-case letters, digits, - and _, starting with a letter or digit, at most 80 — or is the reserved verify (webhooks). | |
| 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. | parameter the one refusedaccepted what the endpoint takes (values for an unknown value) |
| 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. | index records: the record's positionfield records: the partition field |
| 400 | BAD_PATH | A folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters. | |
| 400 | BAD_RECORD | An element of the records array is not a JSON object. | index its position, from 0 |
| 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 | BAD_RETENTION | retention_mode is not never, days or space, or its retention_value is missing or out of range (days ≥ 1; space 1–100). | |
| 400 | BAD_URL | The url is not http or https, is over 1024 characters, does not parse, or names localhost, a loopback, private or link-local address. | |
| 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. | |
| 400 | DUPLICATE_KEY | Two records in one call have the same key. Send it once, or give them different keys. | index the secondfirst the firstkey the key |
| 400 | HASH_NOT_YOURS | POST /v1/files was sent a hash. The hash is computed from the bytes, by PUT /v1/content/{path} alone. | upload the content path to PUT the bytes to |
| 400 | INVALID_VALUE | The value does not fit the setting's type. The message gives the reason. | key the setting key |
| 400 | KEY_IMMUTABLE | A PATCH sent a different dataset key. A key never changes — your devices address the dataset by it. | |
| 400 | NO_BODY | A PUT to /v1/content/… with an empty body. | |
| 400 | NO_BRANCH | The project has no branch of that name — a space runs one of the project's branches. | branch what was asked for |
| 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. | dataset the dataset keyrefused how many records were sent, all of them keyless |
| 400 | NOT_VERIFIABLE | The delivery's webhook has verify off (or no url), so the delivery carried no token. | webhook the slug |
| 400 | NOTHING_TO_DO | A PATCH sent no field it can change (a dataset with none of its fields; a device policy with no key). | |
| 400 | RESERVED_KEY | outbox is the device runtime's own queue and cannot be a dataset key. | |
| 400 | SLUG_IMMUTABLE | A PATCH sent a different slug. A slug never changes — your app addresses the webhook by it. | |
| 400 | TOO_MANY | More than 10,000 records in one call. | limit 10000sent how many were sent |
| 400 | TYPE_MISMATCH | The mime says image and the extension does not. | name the namemime the mimeaccepted the extensions accepted |
| 401 | BAD_KEY | No key matches the one sent. | |
| 401 | KEY_EXPIRED | The key passed the end of its expiry day. Ask for a new one. | expired_on the date it expired, YYYY-MM-DD |
| 401 | KEY_REVOKED | The key existed and was revoked in the portal. Ask why. | |
| 401 | NO_KEY | No Authorization header, or it is not Bearer af_…. | |
| 403 | ACCOUNT_INACTIVE | The account is suspended or closed. | |
| 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. | dataset the dataset keyapi the manifest's word |
| 403 | API_READ_ONLY | The app's manifest makes this dataset read-only for the API: "api": "ro". Reads still work. | dataset the dataset keyapi the manifest's word |
| 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. | limit_bytes the limitused_bytes in use nowadding_bytes what this call addsdataset the dataset key |
| 403 | LIMIT_REACHED | The package's ceiling on datasets (max_datasets), webhooks (webhook_endpoints), spaces beyond each project's Default (max_spaces) or devices (max_devices), each across the whole account, is reached. | limit the ceilingused how many exist |
| 403 | NO_API_ACCESS | The account's package does not include API access. | |
| 403 | NO_SPACES | The account's plan has no spaces beyond each project's Default (max_spaces 0). | |
| 403 | NO_WEBHOOKS | The account's package does not include webhooks. Reads still work. | |
| 403 | NOT_WRITABLE | The app's manifest says "writable": {"api": false} for this setting — or the setting is a select whose choices are each device's own ("options": {"device": …}), chosen on each device — or, on PATCH /v1/devices/{uid}/policy, the manifest's screen.writable.api is false and orientation was sent. Read it; change it in the portal. | key the setting key, or orientation |
| 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. | area the area: datasets, records, files, webhooks, project, settings or devicesaction the verb: r, w, a or dref the dataset key, for records |
| 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. | limit_bytes the limitused_bytes in use nowadding_bytes what this call addsproject the project slug |
| 403 | STORAGE_FULL | The write would take the account past its package's storage_mb. Code, datasets and files share it. | limit_bytes the limitused_bytes in use nowadding_bytes what this call adds |
| 404 | NO_DATASET | No dataset with that key on the key's project. | |
| 404 | NO_DELIVERY | No such delivery: not this webhook's, never made, older than the log keeps (500 rows or seven days), or — for /verify — older than 24 hours. | |
| 404 | NO_DEVICE | No device with that uid on the key's project. A uid is 24 hex characters; a device on another project answers the same, so nothing can be probed for. | |
| 404 | NO_FILE | No file at that path on the key's project. | |
| 404 | NO_PENDING_DEVICE | No device is showing that activation code right now: check the code on its screen, and that the device is switched on and online. | |
| 404 | NO_RECORD | No record with that key in the dataset. | |
| 404 | NO_ROUTE | No endpoint at that path — a typo, or too many path segments. | |
| 404 | NO_SETTING | No setting with that key on the key's project (keys are a-z, digits and _, starting with a letter). | |
| 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. | space what was asked for |
| 404 | NO_VERSION | The first path segment is not a version this API has. There is only v1. | |
| 404 | NO_WEBHOOK | No webhook with that slug on the key's project. | |
| 405 | BAD_METHOD | The path exists and does not take that method. The message names the ones it does. | |
| 409 | DATASET_EXISTS | A dataset with that key already exists on the project. | |
| 409 | NO_CONTENT_YET | The file is registered and its bytes were never uploaded. | at the path |
| 409 | NOT_A_PARENT | The key's project is a private space's store, which has no spaces of its own. | |
| 409 | NOT_ACTIVATED | The device service refused the activation — a code tried too often, a device already registered. reason says which. | reason the service's words |
| 409 | SLUG_TAKEN | Another space of this project has that slug — or it is one of the two reserved: default (the Default space) and unassigned (a device in none). | slug the slug |
| 409 | SPACE_DEFAULT | The Default space keeps its name and slug, uses the project's own data, and is never removed — every project has it. Its branch and max_devices change like any space's. | |
| 409 | SPACE_DEFINED | Inside a private space (Arda-Space), a dataset cannot be made, changed or dropped: its datasets are the project's, mirrored. Do it without the header and every private space follows; its records are its own. | |
| 409 | SPACE_FULL | The space already holds its max_devices. | limit its max_devices |
| 409 | WEBHOOK_DISABLED | A Test on a webhook that is switched off. | |
| 409 | WEBHOOK_EXISTS | A webhook with that slug already exists on the project. | |
| 410 | CONTENT_GONE | The file is registered and its bytes are not in storage. Ours, not yours — upload it again. | at the pathhash the hash the row names |
| 413 | TOO_LARGE | The body is over 10 MB (10,485,760 bytes) — every call, files included: a file can be 10 MB sent as it is, about 7.5 MB as base64. Nothing is stored. | limit_bytes 10485760 |
| 429 | RATE_LIMITED | The account's calls a minute are spent (api_per_min, bursts to api_burst). Wait Retry-After seconds. From POST /v1/webhooks/{slug}/test it means the account's webhook deliveries a minute instead. | limit api_per_minburst api_burstretry_after seconds — also the Retry-After header |
| 500 | INTERNAL | Something failed on our side. The details are in our log, not in the answer; quote the time and the path. | |
| 502 | CONNECT_UNAVAILABLE | The device service did not answer — a webhook Test, an activation, or the wipes a space removal needs. Nothing was done; try again in a moment. |
Examples
No such endpoint
curl "https://api.ardaform.net/v1/nothing-here" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/nothing-here', {
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/nothing-here",
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)Answer · 404 Not Foundran live 2026-10-27 · test project
{
"error": {
"code": "NO_ROUTE",
"message": "No endpoint at /v1/nothing-here."
}
}
A method the path does not take
curl -X PUT "https://api.ardaform.net/v1/datasets" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/datasets', {
method: 'PUT',
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.put(
"https://api.ardaform.net/v1/datasets",
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)Answer · 405 Method Not Allowedran live 2026-10-27 · test project
{
"error": {
"code": "BAD_METHOD",
"message": "Use GET or POST."
}
}
No such version
curl "https://api.ardaform.net/v2/datasets" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v2/datasets', {
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/v2/datasets",
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)Answer · 404 Not Foundran live 2026-10-27 · test project
{
"error": {
"code": "NO_VERSION",
"message": "There is no API version 'v2'; this API has v1."
}
}
A body that does not parse
curl -X POST "https://api.ardaform.net/v1/datasets" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
--data-binary '{"name": "Products",'const res = await fetch('https://api.ardaform.net/v1/datasets', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: '{"name": "Products",'
})
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",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}", "Content-Type": "application/json"},
data="{\"name\": \"Products\",".encode(),
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer)Answer · 400 Bad Requestran live 2026-10-27 · test project
{
"error": {
"code": "BAD_JSON",
"message": "The body is not valid JSON: Expected double-quoted property name in JSON at position 20 (line 1 column 21)"
}
}