Versioning.
The version is in the path, and there is no default. v1 changes only in ways that cannot break a client written against it.
In the path
- Every endpoint starts
/v1/. There is no unversioned alias and no "latest": a client that did not say which API it was written against would break silently the day the next one ships. Anything else is404 NO_VERSION. - When a change would break a client, it becomes v2, beside v1. v1 keeps answering, from the same service. There is no separate test API either: the version in the path is the whole story.
What changes inside v1
| May happen in v1 | Would be v2 |
|---|---|
| a new endpoint, a new area of scopes | an endpoint removed or moved |
| a new field in an answer (ignore fields you do not know) | a field removed, renamed or retyped |
| a new optional body field or query parameter | a new required one |
| a new error word for a new refusal | an existing word changing meaning or status |
| a message reworded | — |
| a bug fixed so the API does what these pages say | — |
Recent examples of the last row, from 2026-10-27: /v1/auth/anything answered as if it were /v1/auth, POST /v1 answered the index, and /changes did not honour a dataset the app's manifest closes to the API. All three now answer as documented.
A new area of scopes is refused for keys whose scopes were written before it existed — see Authentication. That is the one addition a key has to be edited for.
GET/v1Every endpoint of v1, in one short answer
Every endpoint of v1, one line each — the quickest way to see what exists. No key needed, not counted against the rate. Derived from the OpenAPI document, so the two cannot disagree.
Key scope no key needed
Answer 200 The index
| Field | Type | |
|---|---|---|
version | string | Always v1 here. |
openapi | string | Where this document is. |
endpoints | array of string | METHOD /path, one per operation. |
Errors
Only what every call can answer: INTERNAL.
curl "https://api.ardaform.net/v1"const res = await fetch('https://api.ardaform.net/v1')
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import requests
r = requests.get(
"https://api.ardaform.net/v1",
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"version": "v1",
"openapi": "https://api.ardaform.net/v1/openapi.json",
"endpoints": [
"GET /v1",
"GET /v1/openapi.json",
"GET /v1/auth",
"GET /v1/project",
"GET /v1/settings",
"GET /v1/settings/{key}",
"PUT /v1/settings/{key}",
"GET /v1/devices",
"GET /v1/devices/{uid}",
"PATCH /v1/devices/{uid}",
"PATCH /v1/devices/{uid}/policy",
"GET /v1/spaces",
"POST /v1/spaces",
"GET /v1/spaces/{space}",
"PATCH /v1/spaces/{space}",
"DELETE /v1/spaces/{space}",
"POST /v1/devices/activate",
"GET /v1/files",
"POST /v1/files",
"GET /v1/files/{path}",
"DELETE /v1/files/{path}",
"GET /v1/content/{path}",
"PUT /v1/content/{path}",
"GET /v1/datasets",
"POST /v1/datasets",
"GET /v1/datasets/{key}",
"PATCH /v1/datasets/{key}",
"DELETE /v1/datasets/{key}",
"GET /v1/datasets/{key}/changes",
"GET /v1/datasets/{key}/records",
"POST /v1/datasets/{key}/records",
"PUT /v1/datasets/{key}/records",
"DELETE /v1/datasets/{key}/records",
"GET /v1/datasets/{key}/records/{recordKey}",
"DELETE /v1/datasets/{key}/records/{recordKey}",
"GET /v1/webhooks",
"POST /v1/webhooks",
"POST /v1/webhooks/verify",
"GET /v1/webhooks/{slug}",
"PATCH /v1/webhooks/{slug}",
"DELETE /v1/webhooks/{slug}",
"GET /v1/webhooks/{slug}/deliveries",
"GET /v1/webhooks/{slug}/deliveries/{id}",
"POST /v1/webhooks/{slug}/test"
]
}
GET/v1/openapi.jsonThis document
The OpenAPI 3.0.3 description of v1, with no key needed. Import it into Postman (Import → Link), Insomnia, Bruno, Hoppscotch or Swagger UI from the link; it is always current. Every operation names its key scope in x-scope ({"area", "verb"}, with "by": "dataset" where a grant can list datasets) and its error words in x-error-codes.
A test walks every route the API answers and fails if one is missing from the document, if an operation's x-scope differs from the scope the code refuses with, or if the code answers an error word the document does not describe. The document passes Redocly's OpenAPI linter.
Shortened here to its version and the list of paths
curl "https://api.ardaform.net/v1/openapi.json"const res = await fetch('https://api.ardaform.net/v1/openapi.json')
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import requests
r = requests.get(
"https://api.ardaform.net/v1/openapi.json",
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"openapi": "3.0.3",
"info": {
"title": "ArdaForm HTTP API",
"version": "1.1.3"
},
"servers": [
{
"url": "https://api.ardaform.net",
"description": "Live"
}
],
"paths": [
"/v1",
"/v1/openapi.json",
"/v1/auth",
"/v1/project",
"/v1/settings",
"/v1/settings/{key}",
"/v1/devices",
"/v1/devices/{uid}",
"/v1/devices/{uid}/policy",
"/v1/spaces",
"/v1/spaces/{space}",
"/v1/devices/activate",
"/v1/files",
"/v1/files/{path}",
"/v1/content/{path}",
"/v1/datasets",
"/v1/datasets/{key}",
"/v1/datasets/{key}/changes",
"/v1/datasets/{key}/records",
"/v1/datasets/{key}/records/{recordKey}",
"/v1/webhooks",
"/v1/webhooks/verify",
"/v1/webhooks/{slug}",
"/v1/webhooks/{slug}/deliveries",
"/v1/webhooks/{slug}/deliveries/{id}",
"/v1/webhooks/{slug}/test"
]
}
On the wire
- HTTPS only, at
api.ardaform.net. JSON in and out, UTF-8. - Timestamps are UTC, ISO 8601 with milliseconds and a
Z:2026-10-19T11:29:15.241Z. Dates (a key'sexpires_on) areYYYY-MM-DD. - Nothing is cacheable: every answer carries
Cache-Control: no-store(a file's bytes:private, no-store), because an answer is scoped to one key. - No CORS.
OPTIONSon any path answers204with noAccess-Control-*headers, so a browser refuses every cross-origin call. This API is for programs that hold a secret; an app on a device reaches its data throughwindow.Arda. - Addressed by name. Datasets by key, records by their own key, files by path, webhooks by slug, settings by key — never by an internal id.