Files.
/v1/files is what a file is: its path, type, size, hash and partitions. /v1/content is what it holds: the bytes. A file is addressed by its path.
About files
- Addressed by path — folder and name,
menu/tiles/bread.webp— with no leading or trailing slash; the root folder is"". A folder may not contain.or.., nor any of\ ? * : | " < >, and is at most 255 characters. - Two routes, because everything after
/v1/files/is the path:/v1/files/{path}for what a file is,/v1/content/{path}for its bytes. - A file can be registered before its bytes arrive (
has_content: false);PUT /v1/content/…creates the row if there is none. Thehashis set only by an upload, from the bytes themselves. - Identical bytes are stored once, whatever their paths; deleting one path keeps the bytes for the others.
- Partitions work as on records: a file in
["oldbury", "moseley"]is served to both branches' devices. A write replaces the list. - Files count against the same storage as records and code: see Storage. A body is at most 10 MB: a file sent as it is can be 10 MB, one sent as base64 about 7.5 MB.
Types
An allowlist, because a denylist of executables is a list you lose. Any image, plus: pdf txt md rtf doc docx odt xls xlsx ods ppt pptx odp · json csv tsv xml yaml yml · html htm css js mjs map svg woff woff2 ttf otf · mp3 wav ogg mp4 webm m4a · zip gz tar. Your app is made of html, css and js, so those are allowed.
- A name needs an extension:
400 BAD_FILE_NAMEwithout one. - ⚠️ An image is recognised by its mime. Send
"mime": "image/png"(or the rightimage/…) with an image:tile.pngwith no mime is400 BAD_FILE_TYPE. Animage/…mime with a non-image extension is400 TYPE_MISMATCH. - The bytes are not inspected; the type is what the name and the mime say.
GET/v1/filesThe project's files
The project's files, sorted by folder then name, each with its hash. Filter with path (that folder only), under (that folder and everything below), partition.
Key scope files:r — read files
Query parameters
| Name | Type | |
|---|---|---|
path | string at most 255 characters | That folder exactly. Leading and trailing slashes are dropped; the root is an empty string. |
under | string at most 255 characters | That folder and everything below it. Empty is everything. |
partition | string | Files in that partition. One asset four branches share is one file. Empty is no filter. |
limit | integer 1–1000 · default 200 | Page size, 1–1000, default 200. 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. |
Answer 200 A page of files
| Field | Type | |
|---|---|---|
total | integer | Matching files. |
limit | integer | The page size used. |
offset | integer | The offset used. |
has_more | boolean | There are more beyond this page. |
files | array of File |
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_PATH | A folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters. |
| 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_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/files?under=zz-docs" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/files?under=zz-docs', {
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/files?under=zz-docs",
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){
"total": 1,
"limit": 200,
"offset": 0,
"has_more": false,
"files": [
{
"path": "zz-docs/menu",
"name": "tile.png",
"at": "zz-docs/menu/tile.png",
"hash": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77",
"mime": "image/png",
"bytes": 70,
"width": 1,
"height": 1,
"partitions": [
"moseley",
"oldbury"
],
"created_at": "2026-10-27T20:10:05.520Z",
"has_content": true,
"content_at": "/v1/content/zz-docs/menu/tile.png"
}
]
}
POST/v1/filesRegister a file, or update the one at that path
Registers a file — or, when one is at that path already, replaces its mime, bytes, width, height and partitions with what is sent (a field left out becomes empty). It never touches the bytes or the hash. 201 when new, 200 when it was there. Takes no query parameters.
bytes here is what you expect to upload, and counts against storage at once; the upload then records the real size — 68 became 70 in these examples.
Key scope files:w — write files
Body
| Field | Type | |
|---|---|---|
name required | string at least 1 character | The file name with its extension. No slashes — the folder goes in path. |
path | string at most 255 characters | The folder; empty or absent is the root. No . or .., none of \ ? * : | " < >. |
mime | string | What the file is. An image/… mime needs an image extension. Absent is none. |
bytes | integer ≥ 0 | The size you expect to upload. Counts against the project and account allowance now. Not a number is 0. |
width | integer ≥ 1 | What you happen to know. Not a positive number is null. |
height | integer ≥ 1 | As width. |
partitions | string or number or array | A string or a number, or a list of them (each cut to 190 characters). REPLACES the list. |
Answer 200 Updated
| Field | Type | |
|---|---|---|
file | File | One asset belonging to a project — an image, a stylesheet, a PDF: what the file IS. What it holds is at content_at, /v1/content/{path}. |
Answer 201 Registered
| Field | Type | |
|---|---|---|
file | File | One asset belonging to a project — an image, a stylesheet, a PDF: what the file IS. What it holds is at content_at, /v1/content/{path}. |
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_FILE_NAME | The file name is missing, holds a slash, or has no extension. |
| 400 | BAD_PATH | A folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters. |
| 400 | BAD_FILE_TYPE | The extension is not on the allowlist: any image, plus documents, data, web, media and archive types. |
| 400 | TYPE_MISMATCH | The mime says image and the extension does not. |
| 400 | HASH_NOT_YOURS | POST /v1/files was sent a hash. The hash is computed from the bytes, by PUT /v1/content/{path} alone. |
| 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 | 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_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/files" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"zz-docs/menu","name":"tile.png","mime":"image/png","bytes":68,"width":1,"height":1,"partitions":["oldbury","moseley"]}'const res = await fetch('https://api.ardaform.net/v1/files', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: 'zz-docs/menu',
name: 'tile.png',
mime: 'image/png',
bytes: 68,
width: 1,
height: 1,
partitions: ['oldbury', '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/files",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"path": "zz-docs/menu",
"name": "tile.png",
"mime": "image/png",
"bytes": 68,
"width": 1,
"height": 1,
"partitions": ["oldbury", "moseley"],
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"file": {
"path": "zz-docs/menu",
"name": "tile.png",
"at": "zz-docs/menu/tile.png",
"hash": null,
"mime": "image/png",
"bytes": 68,
"width": 1,
"height": 1,
"partitions": [
"moseley",
"oldbury"
],
"created_at": "2026-10-27T20:10:05.520Z",
"has_content": false,
"content_at": null
}
}
Stating a hash
curl -X POST "https://api.ardaform.net/v1/files" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"zz-docs/menu","name":"tile.png","mime":"image/png","hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}'const res = await fetch('https://api.ardaform.net/v1/files', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: 'zz-docs/menu',
name: 'tile.png',
mime: 'image/png',
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
})
})
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/files",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={
"path": "zz-docs/menu",
"name": "tile.png",
"mime": "image/png",
"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "HASH_NOT_YOURS",
"message": "Do not send `hash`: it is computed from the bytes when they are uploaded with PUT /v1/content/{path}.",
"upload": "/v1/content/zz-docs/menu/tile.png"
}
}
A type that is not allowed
curl -X POST "https://api.ardaform.net/v1/files" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"zz-docs","name":"setup.exe"}'const res = await fetch('https://api.ardaform.net/v1/files', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: 'zz-docs', name: 'setup.exe' })
})
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/files",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"path": "zz-docs", "name": "setup.exe"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"error": {
"code": "BAD_FILE_TYPE",
"message": "'.exe' is not a type this platform stores; it takes any image, plus pdf, txt, md, rtf, doc, docx, odt, xls, xlsx, ods, ppt, pptx, odp, json, csv, tsv, xml, yaml, yml, html, htm, css, js, mjs, map, svg, woff, woff2, ttf, otf, mp3, wav, ogg, mp4, webm, m4a, zip, gz, tar.",
"name": "setup.exe",
"mime": null,
"accepted": [
"pdf",
"txt",
"md",
"rtf",
"doc",
"docx",
"… 33 more"
]
}
}
GET/v1/files/{path}One file's metadata
One file's metadata: has_content says whether the bytes are here, content_at where to get them.
Key scope files:r — read files
Path parameters
| Name | Type | |
|---|---|---|
path | string | The file's folder and name together — menu/tiles/bread.webp. Slashes are part of it; addressed by path rather than by an id, because the path is what your own code already says. |
Answer 200 The file
| Field | Type | |
|---|---|---|
file | File | One asset belonging to a project — an image, a stylesheet, a PDF: what the file IS. What it holds is at content_at, /v1/content/{path}. |
Errors
| Status | Word | When |
|---|---|---|
| 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_FILE | No file at that path 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/files/zz-docs/menu/tile.png" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/files/zz-docs/menu/tile.png', {
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/files/zz-docs/menu/tile.png",
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){
"file": {
"path": "zz-docs/menu",
"name": "tile.png",
"at": "zz-docs/menu/tile.png",
"hash": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77",
"mime": "image/png",
"bytes": 70,
"width": 1,
"height": 1,
"partitions": [
"moseley",
"oldbury"
],
"created_at": "2026-10-27T20:10:05.520Z",
"has_content": true,
"content_at": "/v1/content/zz-docs/menu/tile.png"
}
}
PUT/v1/content/{path}Upload the bytes
Uploads the bytes, two ways: the file itself as the body, with its own Content-Type as the mime (curl --data-binary @tile.png); or JSON, {"base64": "…", "mime": "image/png"}. Any type travels raw. A body that arrives a different length from its Content-Length is refused with 400 BAD_REQUEST rather than stored damaged. Over 10 MB is 413 TOO_LARGE.
stored: falsemeans those exact bytes were already stored — nothing was written.createdsays whether the file row is new.- Here the row existed (it was registered first), so the answer is
200withcreated: false; a path with no row answers201.
Key scope files:w — write files
Path parameters
| Name | Type | |
|---|---|---|
path | string | The file's folder and name together — menu/tiles/bread.webp. Slashes are part of it; addressed by path rather than by an id, because the path is what your own code already says. |
Body
application/json
| Field | Type | |
|---|---|---|
base64 required | string | The file, base64 encoded (padding optional, whitespace ignored). |
mime | string | The FILE's type, not the envelope's. Without it the file is stored with no mime and served as application/octet-stream. |
*/*
The file as it is; the Content-Type is its mime (image/png, text/css, …).
Answer 200 Replaced
| Field | Type | |
|---|---|---|
at | string | The path. |
hash | string | The sha256 of what was stored. |
bytes | integer | What was stored. |
mime | string, nullable | The mime stored. |
stored | boolean | False when nothing was written — identical bytes were already stored. |
created | boolean | Whether the file row is new. |
Answer 201 Created
| Field | Type | |
|---|---|---|
at | string | The path. |
hash | string | The sha256 of what was stored. |
bytes | integer | What was stored. |
mime | string, nullable | The mime stored. |
stored | boolean | False when nothing was written — identical bytes were already stored. |
created | boolean | Whether the file row is new. |
Errors
| Status | Word | When |
|---|---|---|
| 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_BASE64 | base64 is not valid base64 — it decodes to different bytes than it describes, or to nothing. |
| 400 | NO_BODY | A PUT to /v1/content/… with an empty body. |
| 400 | BAD_FILE_NAME | The file name is missing, holds a slash, or has no extension. |
| 400 | BAD_PATH | A folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters. |
| 400 | BAD_FILE_TYPE | The extension is not on the allowlist: any image, plus documents, data, web, media and archive types. |
| 400 | TYPE_MISMATCH | The mime says image and the extension does not. |
| 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_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/content/zz-docs/menu/tile.png" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"base64": "'"$(base64 -w0 tile.png)"'", "mime": "image/png"}'import { readFileSync } from 'node:fs'
const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/menu/tile.png', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ base64: readFileSync('tile.png').toString('base64'), mime: 'image/png' })
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import base64
import os
import requests
with open("tile.png", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
r = requests.put(
"https://api.ardaform.net/v1/content/zz-docs/menu/tile.png",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"base64": b64, "mime": "image/png"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"at": "zz-docs/menu/tile.png",
"hash": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77",
"bytes": 70,
"mime": "image/png",
"stored": true,
"created": false
}
The same bytes again
curl -X PUT "https://api.ardaform.net/v1/content/zz-docs/menu/tile.png" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: application/json" \
-d '{"base64": "'"$(base64 -w0 tile.png)"'", "mime": "image/png"}'import { readFileSync } from 'node:fs'
const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/menu/tile.png', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ base64: readFileSync('tile.png').toString('base64'), mime: 'image/png' })
})
const answer = await res.json()
if (!res.ok) throw new Error(`${res.status} ${answer.error.code}: ${answer.error.message}`)
console.log(answer)import base64
import os
import requests
with open("tile.png", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
r = requests.put(
"https://api.ardaform.net/v1/content/zz-docs/menu/tile.png",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
json={"base64": b64, "mime": "image/png"},
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"at": "zz-docs/menu/tile.png",
"hash": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77",
"bytes": 70,
"mime": "image/png",
"stored": false,
"created": false
}
The file itself as the body
curl -X PUT "https://api.ardaform.net/v1/content/zz-docs/menu/tile-raw.png" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: image/png" \
--data-binary @tile.pngimport { readFileSync } from 'node:fs'
const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/menu/tile-raw.png', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'image/png'
},
body: readFileSync('tile.png')
})
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
with open("tile.png", "rb") as f:
r = requests.put(
"https://api.ardaform.net/v1/content/zz-docs/menu/tile-raw.png",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}", "Content-Type": "image/png"},
data=f,
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"at": "zz-docs/menu/tile-raw.png",
"hash": "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77",
"bytes": 70,
"mime": "image/png",
"stored": false,
"created": true
}
A text file, sent as it is
curl -X PUT "https://api.ardaform.net/v1/content/zz-docs/hello.txt" \
-H "Authorization: Bearer $ARDA_KEY" \
-H "Content-Type: text/plain" \
--data-binary 'Hello from the reference'const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/hello.txt', {
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.ARDA_KEY}`,
'Content-Type': 'text/plain'
},
body: 'Hello from the reference'
})
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/content/zz-docs/hello.txt",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}", "Content-Type": "text/plain"},
data="Hello from the reference".encode(),
)
answer = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {answer['error']['code']}: {answer['error']['message']}")
print(answer){
"at": "zz-docs/hello.txt",
"hash": "d54caa77c3989c30b21a9c152ba7177e9e693d4abfed86e25f6db833bf4a6a89",
"bytes": 24,
"mime": "text/plain",
"stored": true,
"created": true
}
GET/v1/content/{path}Download the bytes
The bytes, with the stored mime as Content-Type and the hash as the ETag. A file registered but never uploaded is 409 NO_CONTENT_YET; bytes missing on our side are 410 CONTENT_GONE — upload them again.
Key scope files:r — read files
Path parameters
| Name | Type | |
|---|---|---|
path | string | The file's folder and name together — menu/tiles/bread.webp. Slashes are part of it; addressed by path rather than by an id, because the path is what your own code already says. |
Answer 200 The file
Errors
| Status | Word | When |
|---|---|---|
| 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_FILE | No file at that path 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. |
| 409 | NO_CONTENT_YET | The file is registered and its bytes were never uploaded. |
| 410 | CONTENT_GONE | The file is registered and its bytes are not in storage. Ours, not yours — upload it again. |
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/content/zz-docs/menu/tile.png" \
-H "Authorization: Bearer $ARDA_KEY" \
-o tile-copy.pngimport { writeFileSync } from 'node:fs'
const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/menu/tile.png', {
headers: { Authorization: `Bearer ${process.env.ARDA_KEY}` }
})
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error.code}`)
writeFileSync('tile-copy.png', Buffer.from(await res.arrayBuffer()))
console.log(res.headers.get('etag'))import os
import requests
r = requests.get(
"https://api.ardaform.net/v1/content/zz-docs/menu/tile.png",
headers={"Authorization": f"Bearer {os.environ['ARDA_KEY']}"},
)
r.raise_for_status()
with open("tile-copy.png", "wb") as f:
f.write(r.content)
print(r.headers["ETag"])content-type: image/png
content-length: 70
cache-control: private, no-store
etag: "c414cd0e204de974f73753c7e28d7638e7b3691bb8b1a2bab6b25bb7fed7ce77"
(70 bytes, identical to the file uploaded)
A file registered but never uploaded
curl "https://api.ardaform.net/v1/content/zz-docs/later.pdf" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/content/zz-docs/later.pdf', {
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/content/zz-docs/later.pdf",
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": "NO_CONTENT_YET",
"message": "'zz-docs/later.pdf' is registered but its bytes were never uploaded; PUT them to /v1/content/zz-docs/later.pdf.",
"at": "zz-docs/later.pdf"
}
}
DELETE/v1/files/{path}Remove a file
Removes the file, and says how much storage it gave back. The bytes go too, unless another path holds the same ones. Takes no query parameters.
Key scope files:d — delete files
Path parameters
| Name | Type | |
|---|---|---|
path | string | The file's folder and name together — menu/tiles/bread.webp. Slashes are part of it; addressed by path rather than by an id, because the path is what your own code already says. |
Answer 200 Deleted
| Field | Type | |
|---|---|---|
deleted | object | |
deleted.at | string | The path that went. |
deleted.bytes | integer | Its size — what the allowance got back. |
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 | 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_FILE | No file at that path 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/files/zz-docs/menu/tile.png" \
-H "Authorization: Bearer $ARDA_KEY"const res = await fetch('https://api.ardaform.net/v1/files/zz-docs/menu/tile.png', {
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/files/zz-docs/menu/tile.png",
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": {
"at": "zz-docs/menu/tile.png",
"bytes": 70
}
}
Keeping a library in step
There is no cursor for files and none is needed: one GET /v1/files returns every file with its hash. Compare those with your own, upload what differs, delete what is gone.
# the hashes ArdaForm has, one "hash path" per line
curl -s "https://api.ardaform.net/v1/files?under=menu&limit=1000" \
-H "Authorization: Bearer $ARDA_KEY" | jq -r '.files[] | "\(.hash) \(.at)"'
# the hashes you have
(cd site && find menu -type f -exec sha256sum {} +)import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
const API = 'https://api.ardaform.net/v1'
const auth = { Authorization: `Bearer ${process.env.ARDA_KEY}` }
// mine: { 'menu/tiles/bread.webp': { file: 'site/menu/tiles/bread.webp', mime: 'image/webp' }, … }
export async function sync(mine) {
const res = await fetch(`${API}/files?under=menu&limit=1000`, { headers: auth })
const theirs = new Map((await res.json()).files.map((f) => [f.at, f.hash]))
for (const [at, { file, mime }] of Object.entries(mine)) {
const bytes = readFileSync(file)
if (theirs.get(at) === createHash('sha256').update(bytes).digest('hex')) continue
await fetch(`${API}/content/${at}`, {
method: 'PUT',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ base64: bytes.toString('base64'), mime })
})
}
for (const at of theirs.keys()) {
if (!(at in mine)) await fetch(`${API}/files/${at}`, { method: 'DELETE', headers: auth })
}
}import base64, hashlib, os
import requests
API = "https://api.ardaform.net/v1"
AUTH = {"Authorization": f"Bearer {os.environ['ARDA_KEY']}"}
def sync(mine):
"""mine: {"menu/tiles/bread.webp": ("site/menu/tiles/bread.webp", "image/webp"), ...}"""
theirs = {f["at"]: f["hash"] for f in
requests.get(f"{API}/files", params={"under": "menu", "limit": 1000}, headers=AUTH).json()["files"]}
for at, (path, mime) in mine.items():
data = open(path, "rb").read()
if theirs.get(at) == hashlib.sha256(data).hexdigest():
continue
requests.put(f"{API}/content/{at}", headers=AUTH,
json={"base64": base64.b64encode(data).decode(), "mime": mime}).raise_for_status()
for at in theirs.keys() - mine.keys():
requests.delete(f"{API}/files/{at}", headers=AUTH).raise_for_status()A pattern rather than a captured call: not run as a whole. More than 1000 files needs offset paging.
The File shape
File
One asset belonging to a project — an image, a stylesheet, a PDF: what the file IS. What it holds is at content_at, /v1/content/{path}.
| Field | Type | |
|---|---|---|
path | string | The folder it lives in, with no leading or trailing slash. An empty string is the root. One canonical spelling is what makes ?path= and a folder tree agree with each other. |
name | string | The file name, with its extension. No slashes — the folder is path. |
at | string | Path and name together, which is what you put in a URL and most likely what your own code already says. |
hash | string, nullable | sha256 of the contents, and how you answer "have I already got this one?" without downloading it — the whole of an efficient device sync. SET BY THE UPLOAD AND ONLY BY THE UPLOAD: POST /v1/files refuses a stated one, because a hash you send is a claim about bytes this service does not have. NOT the identity either: two folders may hold the same bytes, and a file whose contents change keeps its name. |
mime | string, nullable | What the writer said it was. Checked against the extension on write, and not otherwise validated. |
bytes | integer | Counts against the ACCOUNT allowance, shared with your code and your datasets. There is no separate file allowance; an upload's body is at most 10 MB. |
width | integer, nullable | What the writer happened to know. Nothing validates it, and a file whose writer knew nothing about it is a perfectly good row. |
height | integer, nullable | As width. |
partitions | array of string | The partitions this file belongs to. ALWAYS an array — one asset four branches share is one file, not four copies. A write REPLACES the list rather than adding to it. |
created_at | string (date-time) | When it was first registered. UTC. |
has_content | boolean | Whether the bytes are here. POST /v1/files registers what a file IS; PUT /v1/content/{path} puts what it holds and is the only thing that sets hash — so this is never a claim. Reconciling a library needs to tell "not uploaded yet" from "done". |
content_at | string, nullable | Where to GET the bytes, or null when there are none. The storage layout behind it is ours — a blob is named after its own sha256, in a directory per tenant and project, and none of that is constructible from anything returned here. |