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.

Working — v1 at api.ardaform.net

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. The hash is 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_NAME without one.
  • ⚠️ An image is recognised by its mime. Send "mime": "image/png" (or the right image/…) with an image: tile.png with no mime is 400 BAD_FILE_TYPE. An image/… mime with a non-image extension is 400 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

NameType
pathstring
at most 255 characters
That folder exactly. Leading and trailing slashes are dropped; the root is an empty string.
understring
at most 255 characters
That folder and everything below it. Empty is everything.
partitionstringFiles in that partition. One asset four branches share is one file. Empty is no filter.
limitinteger
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.
offsetinteger
≥ 0 · default 0
How many to skip. Negative or not a number is 0.

Answer 200 A page of files

FieldType
totalintegerMatching files.
limitintegerThe page size used.
offsetintegerThe offset used.
has_morebooleanThere are more beyond this page.
filesarray of File

Errors

StatusWordWhen
400BAD_PARAMA 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.
400BAD_PATHA folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters.
403OUT_OF_SCOPEThe 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.
404NO_SPACENo 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"
Answer · 200 OKran live 2026-10-27 · test project
{
  "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

FieldType
name requiredstring
at least 1 character
The file name with its extension. No slashes — the folder goes in path.
pathstring
at most 255 characters
The folder; empty or absent is the root. No . or .., none of \ ? * : | " < >.
mimestringWhat the file is. An image/… mime needs an image extension. Absent is none.
bytesinteger
≥ 0
The size you expect to upload. Counts against the project and account allowance now. Not a number is 0.
widthinteger
≥ 1
What you happen to know. Not a positive number is null.
heightinteger
≥ 1
As width.
partitionsstring or number or arrayA string or a number, or a list of them (each cut to 190 characters). REPLACES the list.

Answer 200 Updated

FieldType
fileFileOne 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

FieldType
fileFileOne 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

StatusWordWhen
400BAD_PARAMA 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.
400BAD_FILE_NAMEThe file name is missing, holds a slash, or has no extension.
400BAD_PATHA folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters.
400BAD_FILE_TYPEThe extension is not on the allowlist: any image, plus documents, data, web, media and archive types.
400TYPE_MISMATCHThe mime says image and the extension does not.
400HASH_NOT_YOURSPOST /v1/files was sent a hash. The hash is computed from the bytes, by PUT /v1/content/{path} alone.
400BAD_PARTITIONA 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.
403PROJECT_FULLThe write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance.
403STORAGE_FULLThe write would take the account past its package's storage_mb. Code, datasets and files share it.
403OUT_OF_SCOPEThe 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.
404NO_SPACENo 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"]}'
Answer · 201 Createdran live 2026-10-27 · test project
{
  "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"}'
Answer · 400 Bad Requestran live 2026-10-27 · test project
{
  "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"}'
Answer · 400 Bad Requestran live 2026-10-27 · test project · a list shortened
{
  "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

NameType
pathstringThe 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

FieldType
fileFileOne 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

StatusWordWhen
403OUT_OF_SCOPEThe 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.
404NO_FILENo file at that path on the key's project.
404NO_SPACENo 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"
Answer · 200 OKran live 2026-10-27 · test project
{
  "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: false means those exact bytes were already stored — nothing was written. created says whether the file row is new.
  • Here the row existed (it was registered first), so the answer is 200 with created: false; a path with no row answers 201.

Key scope files:w — write files

Path parameters

NameType
pathstringThe 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

FieldType
base64 requiredstringThe file, base64 encoded (padding optional, whitespace ignored).
mimestringThe 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

FieldType
atstringThe path.
hashstringThe sha256 of what was stored.
bytesintegerWhat was stored.
mimestring, nullableThe mime stored.
storedbooleanFalse when nothing was written — identical bytes were already stored.
createdbooleanWhether the file row is new.

Answer 201 Created

FieldType
atstringThe path.
hashstringThe sha256 of what was stored.
bytesintegerWhat was stored.
mimestring, nullableThe mime stored.
storedbooleanFalse when nothing was written — identical bytes were already stored.
createdbooleanWhether the file row is new.

Errors

StatusWordWhen
400BAD_REQUESTThe 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.
400BAD_BASE64base64 is not valid base64 — it decodes to different bytes than it describes, or to nothing.
400NO_BODYA PUT to /v1/content/… with an empty body.
400BAD_FILE_NAMEThe file name is missing, holds a slash, or has no extension.
400BAD_PATHA folder path holds . or .., a character a path cannot carry (\ ? * : | " < >), or is over 255 characters.
400BAD_FILE_TYPEThe extension is not on the allowlist: any image, plus documents, data, web, media and archive types.
400TYPE_MISMATCHThe mime says image and the extension does not.
403PROJECT_FULLThe write would take the project past the storage limit set on it in the portal — a fence on the project, not the account allowance.
403STORAGE_FULLThe write would take the account past its package's storage_mb. Code, datasets and files share it.
403OUT_OF_SCOPEThe 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.
404NO_SPACENo 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"}'
Answer · 200 OKran live 2026-10-27 · test project
{
  "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"}'
Answer · 200 OKran live 2026-10-27 · test project
{
  "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.png
Answer · 201 Createdran live 2026-10-27 · test project
{
  "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'
Answer · 201 Createdran live 2026-10-27 · test project
{
  "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

NameType
pathstringThe 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

StatusWordWhen
403OUT_OF_SCOPEThe 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.
404NO_FILENo file at that path on the key's project.
404NO_SPACENo 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.
409NO_CONTENT_YETThe file is registered and its bytes were never uploaded.
410CONTENT_GONEThe 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.png
Answer · 200 OKran live 2026-10-27 · test project
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"
Answer · 409 Conflictran live 2026-10-27 · test project
{
  "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

NameType
pathstringThe 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

FieldType
deletedobject
deleted.atstringThe path that went.
deleted.bytesintegerIts size — what the allowance got back.

Errors

StatusWordWhen
400BAD_PARAMA 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.
403OUT_OF_SCOPEThe 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.
404NO_FILENo file at that path on the key's project.
404NO_SPACENo 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"
Answer · 200 OKran live 2026-10-27 · test project
{
  "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.

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 })
  }
}

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}.

FieldType
pathstringThe 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.
namestringThe file name, with its extension. No slashes — the folder is path.
atstringPath and name together, which is what you put in a URL and most likely what your own code already says.
hashstring, nullablesha256 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.
mimestring, nullableWhat the writer said it was. Checked against the extension on write, and not otherwise validated.
bytesintegerCounts 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.
widthinteger, nullableWhat the writer happened to know. Nothing validates it, and a file whose writer knew nothing about it is a perfectly good row.
heightinteger, nullableAs width.
partitionsarray of stringThe 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_atstring (date-time)When it was first registered. UTC.
has_contentbooleanWhether 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_atstring, nullableWhere 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.