Authentication.
Every call carries a project key. The key says which project it is for and what it may do there — area by area, verb by verb.
Keys
- A key is made in the portal: project → API keys → New key. It belongs to that one project, so no endpoint takes a project id — the key already says which project, and nothing a call sends can reach another one.
- It begins
af_, so a leaked one is easy to find in a log or a repository scan. - It exists once, in the portal's answer when it is made. ArdaForm keeps only its SHA-256 and its first eight characters (the
prefixyou see in lists). Lose it and make another; nothing can show it again, and no answer ever repeats it. - How many keys an account may have is its package's
api_keys. The rate limit is per account, so a second key does not buy a second allowance. - Keep it on a server. This API answers no cross-origin request, so a key in a web page does not work — and would be a key anybody can read.
Sending it
As a bearer token, on every call except GET /v1 and GET /v1/openapi.json:
Authorization: Bearer af_…
- No header, or one not of the form
Bearer af_…, is401 NO_KEY. - A key-shaped token that matches no key is
401 BAD_KEY. - A key that was revoked is
401 KEY_REVOKED; one past its expiry day is401 KEY_EXPIRED— two different conversations, so two words. - An account that is suspended or closed is
403 ACCOUNT_INACTIVE; a package without API access is403 NO_API_ACCESS.
No key at all
curl "https://api.ardaform.net/v1/auth"const res = await fetch('https://api.ardaform.net/v1/auth')
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/auth",
)
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": "Send your API key as `Authorization: Bearer af_…`."
}
}
A key that does not exist
curl "https://api.ardaform.net/v1/auth" \
-H "Authorization: Bearer af_this-key-does-not-exist"const res = await fetch('https://api.ardaform.net/v1/auth', {
headers: {
Authorization: 'Bearer af_this-key-does-not-exist'
}
})
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/auth",
headers={"Authorization": "Bearer af_this-key-does-not-exist"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "BAD_KEY",
"message": "No key matches the one sent."
}
}
Scopes
A key carries a scopes document, set in the portal's key editor: for each area of the API, which verbs it may use. The API has no endpoint for changing a key, on purpose.
| Area | Covers | Verbs |
|---|---|---|
datasets | the dataset objects — /v1/datasets | r w a d |
records | the data — …/records and …/changes. The only area that can be narrowed to named datasets. | r w d (a is not used) |
files | files and their bytes — /v1/files, /v1/content | r w d (a is not used: registering and uploading are w) |
webhooks | /v1/webhooks: r lists, reads the log, answers /verify; a creates; w changes one or runs a Test; d deletes | r w a d |
project | /v1/project, read-only | r only |
settings | /v1/settings: r reads, w sets a project value | r w (settings are defined by the app's manifest) |
code | branches. Nothing serves code over this API; the entry is where a key's branch limit lives, reported as key.branch | recorded, not enforced |
- Each verb is
"*"(all), or — forrecords— a list of dataset keys. An empty list is nothing. wis "change what is there",ais "make new ones": a key that feeds one dataset has no business creating others. On records,walso allowsPUT, which replaces the whole dataset.- Each endpoint on these pages names the scope it needs (and so does the OpenAPI document, as
x-scope). A refusal is403 OUT_OF_SCOPEnaming thearea, theactionand, for records, the dataset asref. - A useful shape: a till key with
records: {r: ["products"], w: ["transactions"]}and nothing else — it reads the catalogue, writes its sales, and cannot empty either. - A key with no document at all (
scopes: null) was made before scopes existed. It falls back to itsscope:readmay read everything,writemay do everything. Editing it in the portal gives it a document.
Keys made before an area existed
A document is a list of what a person chose. An area added to the API after the document was written has no entry in it — and is refused, not granted: webhooks (added 2026-10-06), project and settings (both 2026-10-19).
Such a key answers 403 OUT_OF_SCOPE on that area until someone edits it in the portal and ticks the area. Nothing else changes for it. The key used for the examples on these pages was made in August: here it is, asking for the project.
A key written before the project area existed
curl "https://api.ardaform.net/v1/project" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/project', {
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/project",
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": "OUT_OF_SCOPE",
"message": "This key may not read project.",
"area": "project",
"action": "r"
}
}
Expiry and revocation
key.expires_onis a date, ornullfor never. A key works all through its expiry day (UTC) and answers401 KEY_EXPIRED, carryingexpired_on, after it.- A revoked key answers
401 KEY_REVOKED. Revoking is in the portal, and it is immediate.
{
"error": {
"code": "KEY_EXPIRED",
"message": "That key expired on 2026-10-05",
"expired_on": "2026-10-05"
}
}
Who this key is
GET/v1/authConfirm a key and see what it is for
Confirms a key works and says what it may do: the first call a program makes. limits is the account's allowance — package plus any overrides — and the API's own per-call ceilings; read it once at start-up.
key.scopeis the coarse summary (writeif the key may write anything at all);key.scopesis the document that decides. Read the second.key.branchis the one branch the key is limited to, ornullfor none or several.- The token itself is never in the answer — only its
prefix, masked here.
Key scope any valid key
Answer 200 The key, its project and the account limits
| Field | Type | |
|---|---|---|
key | object | |
key.name | string | What the key was called in the portal. |
key.prefix | string | The first eight characters, af_ included. The token itself is stored only as a sha256 and cannot be shown again by anything. |
key.scope | string — read or write | The coarse summary: write if the key may write anything at all. |
key.branch | string, nullable | The one branch the key is limited to, derived from its code scopes; null when limited to none or several. |
key.scopes | object, nullable | What one API key may do, per area of the API. Null on a key made before scopes existed — which means it falls back to scope, NOT that it may do nothing. |
key.expires_on | string (date), nullable | The last DAY this key works, or null for never. It ends the day rather than the midnight before it. After that the answer is 401 KEY_EXPIRED. |
key.created_at | string (date-time) | When the key was made. UTC. |
project | object | |
project.slug | string | The project's slug — frozen at creation. |
project.name | string | For people. |
project.status | string | The project's status, e.g. active. |
space | object, nullable | The space this call acted in (Arda-Space), or null. |
space.id | integer | The space's id. |
space.slug | string | Its slug. |
space.name | string | What the portal calls it. |
space.private | boolean | Its data is its own — project is then its store. |
account | object | |
account.name | string | The account's name. |
limits | object | The package's resolved allowance (more fields than listed — the package's own), plus the API's own per-call ceilings. |
Errors
| Status | Word | When |
|---|---|---|
| 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/auth" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/auth', {
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/auth",
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){
"key": {
"name": "api npm test",
"prefix": "af_••••••••",
"scope": "write",
"branch": null,
"scopes": {
"code": {
"r": "*",
"w": "*",
"a": "*",
"d": "*"
},
"datasets": {
"r": "*",
"w": "*",
"a": "*",
"d": "*"
},
"records": {
"r": "*",
"w": "*",
"a": "*",
"d": "*"
},
"files": {
"r": "*",
"w": "*",
"a": "*",
"d": "*"
}
},
"expires_on": null,
"created_at": "2026-08-21T08:49:42.652Z"
},
"project": {
"slug": "api-test",
"name": "API test",
"status": "active"
},
"space": null,
"account": {
"name": "ArdaForm Test"
},
"limits": {
"max_projects": 3,
"max_devices": 10,
"max_users": 1,
"max_invited": 0,
"max_datasets": 20,
"max_spaces": 3,
"storage_mb": 50,
"console": false,
"marketplace": true,
"marketplace_fee": 20,
"gui": true,
"detect_priority": "normal",
"support": "email",
"webhooks": true,
"webhook_endpoints": 2,
"webhook_per_min": 60,
"api_access": true,
"api_keys": 2,
"api_per_min": 600,
"api_burst": 1200,
"max_records_per_call": 10000,
"max_bytes_per_call": 10485760
}
}
The key scopes shape
Key scopes
What one API key may do, per area of the API. Null on a key made before scopes existed — which means it falls back to scope, NOT that it may do nothing.
| Field | Type | |
|---|---|---|
code | object | Branches. Nothing serves code over this API yet, so these are recorded rather than enforced — but they are where a key's branch scope lives, and branch is derived from them. |
datasets | object | Managing the dataset objects themselves — /v1/datasets. |
records | object | The data — /v1/datasets/{key}/records and /changes. THE ONE AREA THAT CAN BE NARROWED TO PARTICULAR DATASETS, which is the useful shape: a till key that appends to transactions and reads products and touches nothing else. |
files | object | Assets — /v1/files and /v1/content. |
webhooks | object | Webhooks — /v1/webhooks. r lists them, reads the delivery log and answers /verify; a creates; w changes one or runs a Test; d deletes. A document written before this area existed has no entry for it and is refused here until the key is edited in the portal. |
project | object | The project itself — /v1/project. READ ONLY: r is the one verb this area has, because a project's settings are changed in the portal and nowhere else. A document written before this area existed has no entry for it and is refused here until the key is edited in the portal. |
settings | object | The app's settings — /v1/settings. r reads the definitions and the project values; w sets a project value the manifest lets the API write. No a or d: settings are defined by the app's manifest.json. A document written before this area existed has no entry for it and is refused here until the key is edited in the portal. |
devices | object | The project's devices — /v1/devices. r lists them and reads one; w sets a device's policy (its switches) and activates a device by its code (POST /v1/devices/activate). No a or d: a device is removed in the portal. A document written before this area existed (2026-10-24) has no entry for it and is refused here until the key is edited in the portal. |
spaces | object | Spaces — /v1/spaces: the project's groups of devices, each with the branch it runs and, when private, data of its own. r lists and reads; a makes one; w changes one (its branch moves its devices); d removes one, its devices to Unassigned. Acting INSIDE a space (Arda-Space) needs the scope of the area acted on, not this one. A document written before this area existed (2026-10-27) has no entry for it and is refused here until the key is edited in the portal. |
r | string | Inside each area: "*" for any, or a list of dataset keys. Read. |
w | string | Write — change what is already there. |
a | string | Add — create new ones. Separate from w because "may change the datasets it was given" and "may make more" are different powers. |
d | string | Delete. |