Skip to content

Conventions

The rules that hold across the whole API. Most of the surprises people meet are on this page.

  • Send the token on every call: Authorization: Bearer <access token>. There’s no other way to authenticate. A browser’s Foldr cookie is not accepted and gets the same 401 as no token.
  • JSON bodies are sent as Content-Type: application/json and are limited to 1 MB. File contents are the exception: an upload or overwrite body is the file’s raw bytes (application/octet-stream), whatever its size.
  • JSON answers come as application/json. Every error is application/problem+json (see Errors).
  • Shared caches never store a response. An answer carrying an ETag is Cache-Control: private, no-cache: the client’s own cache may keep it but must ask again with If-None-Match before using it. Every other answer is no-store. The OpenAPI document and the problem-type pages are the exception: public, and cached for a short while.
  • Ignore what you don’t know. New fields and new header values can appear within v1. Don’t fail on them.
  • Shares, custom fields, share requests and operations have UUIDs (for example 01a11dce-14f0-7f23-b857-c621356097a0). They’re always UUIDs, on Foldr Cloud and on a Foldr server alike. Treat them as opaque strings anyway; compare them as text, in lower case.
  • Files and folders have no id. An item is its share’s id plus its path inside the share. If a file is renamed or moved, its path changes, and the response tells you the new one. (If Foldr ever adds stable item ids, an id field will be added to items; that’s an additive change.)
  • People are identified by id in a few responses (the person who has a file checked out, the person who last set a field value). That’s their directory id, an opaque string.
  • Version ids and upload session ids are opaque strings. Never build one.

Files and folders are addressed by a share id and a path from the top of the share.

Where the path goes. The path never goes in the URL path. It’s a query parameter, ?path=, on reads, on the raw-bytes upload (uploadFile), on overwriteFile and on deleteItem, and a JSON "path" field on everything else. The reason is that proxies and frameworks quietly rewrite %2F, // and .. in URL paths, and file names can legally contain #, ? and %.

Encoding. Percent-encode the value of path as you would any query value, with your HTTP library’s own encoder: a name such as /Invoices/Q3 & Q4 #1 100%.txt has to travel as %2FInvoices%2FQ3%20%26%20Q4%20%231%20100%25.txt (a / may also be left as it is). With curl, let it do the work:

Terminal window
curl -s -G -H "Authorization: Bearer $TOKEN" \
"$FOLDR/api/v1/shares/$SHARE/item" \
--data-urlencode "path=/Invoices/Q3 & Q4 #1 100%.txt"

Give a parameter only once. ?path=/a&path=/b is refused with 400 invalid-request, as is a list.

The forms you may send.

  • The leading / is optional in a request: Reports/Q3.xlsx and /Reports/Q3.xlsx mean the same.
  • /, an empty path or no path at all is the top of the share, where the operation takes a folder. A path made only of spaces is refused (400 invalid-path); it’s never quietly read as the top.
  • Nothing is trimmed. A name that begins or ends with a space is addressed, and returned, with it.
  • At most 4,096 bytes.
  • Refused with 400 invalid-path before anything is looked up: a .. segment, a control character, a backslash, an absolute-URL or drive form (smb://x, C:\x, //server/x), a path of only spaces. A new name (a folder, a rename, a copy’s or upload’s name) is a single name: no /, no control characters, and no space at either end.

The form you get back. Every response gives the item’s canonical path: exactly as the storage holds it (its own upper and lower case), in Unicode NFC, always beginning with /. / alone is the top of the share. Keep that form and use it in later calls, in If-Match bookkeeping and in your own records. Two storage details:

  • Case. Storage that ignores case (file servers, for one) finds an item whatever case you send and answers with the stored case. Storage that is case sensitive (S3, for one) matches exactly.
  • Unicode. macOS tends to produce names in decomposed form (NFD). You can send either; Foldr converts the request to NFC. If a folder holds two names that differ only in how an accented letter is encoded, a path that could mean either is 409 ambiguous-path.

Names may be tidied. A name you supply for something new is made safe for the storage: characters the storage forbids are replaced, runs of spaces are collapsed, trailing dots and dashes are removed and a very long name is shortened. Reserved device names and format characters are refused. So always use the path in the answer, never the name you sent, for every create, copy, move and rename. A name that already exists is addressed as stored, even if a new name like it would have been changed.

After a change. A rename or move answers with the item at its new canonical path. The old path names nothing afterwards, except where the storage ignores case and the old spelling is just another way to write the new one (a rename that only changes case). The case in a canonical path comes from cached folder listings, so after a case-only change made outside Foldr it can be a minute out of date.

Every item has an etag, and the same value comes in an ETag header when you read an item on its own, download it, or change it.

  • An ETag is an opaque string. Send it back exactly as you received it, including any W/ and the quotation marks. It may be a weak tag (a hash of size and modified time) or the storage’s own tag; which depends on the storage. A weak tag is a best-effort change detector: two writes of the same size within the same second can share one, and a folder’s tag follows the folder’s own timestamp, not its contents. Where you need to be sure, read the item on its own with getItem first and send the tag it gives. A listing may carry one form and a read of the item on its own another: either is accepted when you send it back as If-Match.
  • Reading with If-None-Match. Send an item’s ETag as If-None-Match to getItem, getContent or getFieldValues. If nothing has changed, you get 304 with no body, and the call costs 1 on your rate budget whatever the read would have cost. (If-None-Match compares weakly, as HTTP says it should. A list of tags, or *, is accepted.)
  • Changing with If-Match. A call that changes something that already exists says which version it means with If-Match: <etag>. The operations that need it are renameItem, overwriteFile, setFieldValue, moveItem, deleteItem and restoreVersion.
    • Without it: 428 precondition-required.
    • With a tag that no longer matches: 412 precondition-failed. Someone changed the item since you read it. Read it again, decide again, and don’t just retry.
    • For overwrite, move, delete and restore, If-Match: * is refused (400 invalid-request): name the version you mean. (setFieldValue accepts *, meaning “whatever the values are now”.)
    • A list of tags matches if any member does.
  • Creating with If-None-Match: *. uploadFile creates a file and never replaces one, and says so in HTTP’s own words: If-None-Match: * is required (428 without it). If the name is taken: 412 precondition-failed.
  • A write’s answer carries the item’s new ETag. Use it for the next change instead of reading again.
  • A search hit comes from the search index, so its etag and checked_out are null. getItem gives the live ones.
  • The ETag of a file’s field values is a different tag, over the whole set of values; it’s used with getFieldValues and setFieldValue only.

Networks drop answers. If you create something and never see the reply, you can’t tell whether it happened. An Idempotency-Key lets you send the same request again safely.

Which calls take one. All of these require it (without one: 400 invalid-request, and nothing runs): createFolder, uploadFile, copyItem, moveItem, deleteItem, restoreVersion and createShareRequest. The others don’t take one, because If-Match already protects them (renameItem, overwriteFile, setFieldValue) or they’re safe to repeat.

The key. A value of your own, 1 to 255 visible ASCII characters (a UUID is ideal), new for every new request. Keys belong to your app and the person it acts for, so nobody else’s keys can collide with yours.

What the server does.

You sendYou get
A key it hasn’t seenThe request runs, once. A successful (2xx) answer is kept for 24 hours.
The same key and the same request again, within 24 hoursThe first answer again, with the header Idempotent-Replayed: true. Nothing is done a second time, and a repeat doesn’t use up your daily limit.
The same key with a different request (another path, another query, other bytes, another body)422 idempotency-key-reused.
The same key while the first request is still being handled409 conflict. Wait a moment and send it again.
The same key after the first request was cut off part way (a server restarting, say)The key, and any name it was writing, are free about two minutes after the request last made progress; send the same request again and it runs.
A key whose first request was refused or failed (any answer that isn’t 2xx)It runs again: a refused request changed nothing, so the key is free.

The one refusal that is kept like a success is 409 source-remains for a move to another share, because that move did change things (the copy was made); sending it again tells you the same and doesn’t copy a second time.

An upload is matched on its bytes too: Foldr reads the repeated body to be sure it’s the same file. Other bytes of the same length under the same key are 422.

After 24 hours. The kept answer is forgotten.

  • For a request that can become an operation (copyItem, moveItem, deleteItem and restoreVersion) the key still finds the operation: one still under way answers 202 again, and one that has finished answers what the request would have answered if it had made the change at once (201 with the copy, 200 with the item or the deletion) or the problem it ended with, carrying the operation.
  • For createFolder, uploadFile and createShareRequest the key is simply forgotten and the request runs as a new one. Nothing is ever replaced, so the usual result is 409 conflict or 412 precondition-failed because it already exists; but with on_conflict=rename you would get a second copy under a new name. So retry soon, within the day, and use the default on_conflict (fail) when you rely on a key.

Collections come as a page:

{ "items": [ ... ], "next_cursor": "opaque-string-or-null", "truncated": false }
  • Ask for the next page by sending next_cursor back as ?cursor=. When next_cursor is null there are no more.
  • A cursor is opaque. Never build one or change one, and don’t keep one for later; its form can change within v1. Send it back with the same other parameters (the same path, the same search).
  • limit sets the page size, from 1. A limit over the largest allowed is read as the largest; 0, or something that isn’t a whole number, is refused (400 invalid-request).
CollectionDefault limitLargest
listShares100200
listChildren100200
search2050
listOperations50200
listShareRequests50200
  • listVersions puts all of a file’s versions on one page. listFields and getFieldValues return a plain array with no paging.
  • A cursor that isn’t one Foldr issued for that collection is 400 invalid-cursor. A listing cursor whose snapshot has gone is 410 cursor-expired: start again from the first page.
  • Folder listings are a snapshot. The first page takes a snapshot of the folder and keeps it for a minute while you page through it. A folder that changes in the meantime shows the snapshot, so don’t use a listing to decide whether something exists now; use getItem.
  • Very big folders. One listing reads at most 25,000 entries. A folder with more is paged as its first 25,000, and every page of it says "truncated": true. A null next_cursor on a truncated listing is therefore not “the whole folder”. truncated is always present and false otherwise.
  • Search pages walk the person’s search indexes one after another, so a page can hold fewer hits than limit and still not be the last. total_estimate is only an estimate.

RFC 3339, in UTC, to the second: 2026-10-08T23:17:11Z. A timestamp can be null where the storage doesn’t report one (modified on some storage). Daily limits reset at 00:00 UTC.

Every call takes some tokens out of a bucket that refills steadily. There are two buckets, and a call is charged to both or to neither:

  • your app’s budget for each person, by default 120 tokens refilling at 2 a second;
  • your app’s budget across the organisation, by default 1,200 tokens refilling at 20 a second.

So you can burst, and then sustain a rate. These numbers are defaults, and a Foldr server’s administrator can change them.

What a call costs. Each operation’s x-foldr-rate-cost in the OpenAPI document says:

CostOperations
1getMe, listShares, getShare, listChildren, getItem, listFields, getFieldValues, listOperations, getOperation, listShareRequests, getShareRequest
2getDownloadUrl, listVersions, createFolder, renameItem, copyItem, setFieldValue, createShareRequest, cancelOperation, createUploadSession, completeUploadSession, cancelUploadSession
3search
5getContent, uploadFile, overwriteFile, moveItem, deleteItem, restoreVersion

A HEAD costs 1 whatever its GET costs, and so does a 304.

Headers. Every answer, a success or a refusal, carries X-RateLimit-Limit (the size of the bucket that would run out first for you) and X-RateLimit-Remaining (what’s left in it). Use them to slow down background work before you’re refused.

When you’re over budget: 429 with a problem document of type rate-limited and a Retry-After header, in seconds.

HTTP/2 429
Retry-After: 3
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Content-Type: application/problem+json

Wait at least that long. If several requests are waiting, don’t send them all at once afterwards; space them out, and back off further on repeated 429s. Treat 429 as flow control, not as a failure to show a person.

Other limits that answer 429:

  • By default at most 4 uploads in flight at once for each person through your app (uploadFile streams the file through a server process for as long as it takes): rate-limited, with Retry-After: 10.
  • At most 20 copies waiting for each person through your app, with two running at a time: rate-limited. Two copies also run at a time across the whole site, so a copy can wait behind other people’s.
  • The daily limits an administrator sets for your app: daily-write-limit for writes, destructive-budget for removing and replacing. Retry-After says how many seconds until the limit resets at midnight UTC.
  • A very heavy flood from one network address is stopped before it reaches the API, with an HTML 429 and no Retry-After. A client that parses every 429 as JSON should check the content type first.

Every read (GET) also answers HEAD: the same status and headers as the GET, with no body, for a cost of 1. HEAD on getContent answers from the file’s details and doesn’t read the file. Writes don’t answer HEAD.

  • An address that exists but doesn’t answer that method is 405 method-not-allowed with an Allow header naming the methods it does answer. For example, PATCH on a file’s content answers Allow: GET, HEAD, PUT. OPTIONS is answered 405 everywhere: the API sends no CORS headers, because it’s meant to be called from servers.
  • An address that isn’t the API’s is 404 not-found.
  • Without a valid token, both of these are the 401 first. You find out nothing about the API’s shape before you’ve signed in.
  • 404 not-found is deliberately blunt. It answers a share or path that doesn’t exist, one that exists but isn’t the person’s, one that a share’s path rules hide, and a share outside the shares your app’s policy names. They look the same on purpose, so that an app can’t use the API to find out what it isn’t allowed to see. Don’t build logic that tries to tell them apart.
  • The exceptions are two true answers about the person’s own shares: 503 share-unreachable (a file server that’s down) and 409 storage-account-not-connected (a cloud storage account of theirs that they haven’t yet connected in Foldr’s web app).