The entire client-facing surface is plain, synchronous HTTPS. There’s no agent, no SDK, and
no framing on top of a zfs send stream — just stock zfs send | curl -T - or
curl | zfs receive talking to us directly. If you haven’t already, the
quickstart walks through your first send end to end; this page is the full
reference.
One URL convention holds across every endpoint: the dataset or snapshot name is a path
segment, never a query parameter — ZFS names already contain /, so query-encoding one
would just add friction. A request to a real path with the wrong HTTP method gets an
automatic 405 with an Allow header, before anything else runs.
Authentication
Every endpoint except /healthz requires a token, as either:
Authorization: Bearer YOUR-TOKEN-HERE, or?token=YOUR-TOKEN-HERE
(the header wins if you somehow send both). The token is the one shown once when you create a pool from your account page — copy it immediately, it isn’t displayed again.
401— missing, malformed, unknown token, a pool whose traffic landed on the wrong region’s endpoint, or a pool on a plan this endpoint doesn’t serve.429(withRetry-After) — too many failed authentication attempts from your address (10 within a minute). Everything from that address is refused until the window passes, so a broken, retrying client can’t hammer the login path.503— your token couldn’t be checked right now (a transient problem on our side). This is deliberately a different code from401so your tooling doesn’t conclude the token itself is bad and give up — back off and retry.
Token checks are cached briefly, so revoking a pool from the account page can take up to a minute to take effect everywhere.
Request IDs
Every response carries an X-Request-Id header — echoing one you sent, if it looks like
[A-Za-z0-9._-]{1,64}, otherwise a random one we generate. Send your own if you want to
correlate it with your own logs. If you ever need to report a problem, this id is the fastest
way for us to find the matching entry server-side — internal failures are only ever reported
to you as a generic message plus this id, never a raw stack trace.
Endpoints
| Endpoint | Purpose | zfs equivalent |
|---|---|---|
PUT /v1/pool_create |
create your account’s pool | zpool create [-o compatibility=] |
DELETE /v1/pool_destroy |
destroy your pool and everything in it | zpool destroy |
PUT /v1/pool_upgrade |
enable more pool features | zpool upgrade |
GET /v1/pool_get |
read the pool’s own properties | zpool get -p [all|prop,...] |
GET /v1/list[/{fs...}] |
list filesystems, volumes and snapshots (and, with type=, bookmarks) |
zfs list [-t ...] |
GET /v1/detailed[/{fs...}] |
list with GUIDs, origins, space, resume tokens | zfs list -o space,guid,origin |
PUT /v1/recv/{target...} |
upload a send stream | zfs receive |
GET /v1/send/{snapshot...} |
download a send stream | zfs send |
DELETE /v1/destroy/{target...} |
destroy a dataset, bookmark, or snapshot(s) | zfs destroy [-r] |
GET /v1/token/{fs...} |
fetch a partial receive’s resume token | zfs get receive_resume_token |
DELETE /v1/abort/{fs...} |
discard partial receive state | zfs receive -A |
PUT /v1/hold/{tag}/{snapshot...} |
place a user hold | zfs hold [-r] |
DELETE /v1/release/{tag}/{snapshot...} |
remove a user hold | zfs release [-r] |
GET /v1/holds/{snapshot...} |
list user holds | zfs holds [-r] |
PUT /v1/rename/{src...} |
rename a filesystem or snapshot | zfs rename [-r] |
PUT /v1/rollback/{snapshot...} |
roll a filesystem back to a snapshot | zfs rollback [-r] |
PUT /v1/bookmark/{source...} |
create a bookmark | zfs bookmark |
GET /v1/props/{target...} |
read every property | zfs get -p all |
PUT /v1/props/{target...} |
set properties | zfs set |
PUT /v1/inherit/{target...} |
clear a property’s local value | zfs inherit [-rS] |
GET /healthz |
health check, unauthenticated | — |
Your account has no pool until PUT /v1/pool_create makes one. Until then, every other
endpoint answers 404:
{"ok":false,"errno":2,"error":"the pool has not been created yet (pool_create)","no_pool":true}
Unless noted otherwise, success is 200 with {"ok":true, ...}, and a rejected request is
502 with {"ok":false,"errno":E,"error":"..."} — errno is a POSIX errno from the
underlying ZFS call wherever one applies (EEXIST, EBUSY, ENOENT, …).
Managing your pool
PUT /v1/pool_create?token=YOUR-TOKEN-HERE&compat=SET
compat is optional. Creates your account’s pool, sized to your plan — this is the one call
that has to happen before anything else works. Fails (502, EEXIST) if a pool already
exists for your account.
compat pins the pool to an older OpenZFS release’s feature set, from the same compatibility
database zpool create -o compatibility= draws on — e.g. openzfs-2.1-linux, openzfs-2.2,
freebsd-11.0, several comma-separated (only features common to all of them), legacy (no
features at all), or the default, off (every feature we support). An upload whose stream
needs a feature outside that set is refused, so pin this to whatever release you’ll
actually be restoring to, if that matters to you — it also limits what pool_upgrade can
later turn on.
{"ok":true,"pool":{"compatibility":"openzfs-2.1-linux","features":{
"async_destroy":"enabled","lz4_compress":"active","block_cloning":"disabled"}}}
Every feature we support appears, reported the same way zpool get feature@... does
(disabled, enabled or active). 502 otherwise — EEXIST if a pool already exists, or
an unrecognized compat set name.
DELETE /v1/pool_destroy?token=YOUR-TOKEN-HERE&confirm=GUID
Destroys your pool — every dataset, snapshot and bookmark in it, holds included — and frees
its space on our side. confirm is required and must be the pool’s own GUID, which you read
first with GET /v1/pool_get?property=guid: a deliberate extra lookup, so a stale or
replayed request can never destroy a pool created after it. A missing or non-numeric
confirm is 400; any other number is 502 (“confirm does not match the pool’s guid”), and
nothing is destroyed.
This is irreversible.
{"ok":true}
Afterwards your account is exactly as it was before pool_create — every other endpoint
answers 404 again — until PUT /v1/pool_create makes a new pool, optionally with a
different compat=. 404 if there was no pool to destroy.
PUT /v1/pool_upgrade?token=YOUR-TOKEN-HERE&features=a,b&compat=SET
Both optional. Turns on pool features, same idea as zpool upgrade:
featuresenables exactly those (and whatever they depend on) — each must already be allowed by the pool’s compatibility set, or it’s refused.- without
features, every feature your current compatibility set allows is turned on. compatchanges the compatibility set itself first — e.g.compat=openzfs-2.2alone means “allow whatever 2.2 supports,” then enables it.
Features can never be disabled again. Narrowing compat below what’s already enabled
only warns — it doesn’t undo anything — and a restore that uses an enabled feature is no
longer receivable by an older release.
{"ok":true,"pool":{"compatibility":"...","features":{...},"enabled":["block_cloning"]}}
Same shape as pool_create’s response, plus enabled: the features this specific call
turned on.
GET /v1/pool_get?token=YOUR-TOKEN-HERE&property=a,b
property is optional (default: every property). Reads the pool’s own properties —
zpool get -p, shaped like zpool get -j:
{"ok":true,"properties":{
"size":{"value":2199023255552,"source":{"type":"NONE","data":"-"}},
"health":{"value":"ONLINE","source":{"type":"NONE","data":"-"}},
"compatibility":{"value":"openzfs-2.1-linux","source":{"type":"LOCAL","data":"-"}},
"feature@block_cloning":{"value":"disabled","source":{"type":"LOCAL","data":"-"}}
}}
size/free/allocated/capacity and friends reflect your plan’s storage, not a physical
disk. A feature@NAME entry appears for every feature we support. Server-internal details
(our internal pool name, altroot, cachefile) are left out. An unknown or excluded
property is 502; a malformed property list is 400.
Listing datasets and snapshots
GET /v1/list?token=YOUR-TOKEN-HERE (whole pool)
GET /v1/list/{fs...}?token=YOUR-TOKEN-HERE (filtered to one dataset tree)
Optionally add &type=filesystem,volume,snapshot,bookmark (short forms fs/vol/snap
work too, or all), comma-separated. The default lists filesystems, volumes and snapshots —
bookmarks only appear when you ask for them. &depth=N (zfs list -d) limits how far down
the tree to go: at most N levels below fs (or below the pool’s top, for the whole-pool
form), where a snapshot or bookmark counts as one level below its own dataset — so depth=1
of a filesystem returns just it, its snapshots and bookmarks, and its immediate children.
A negative or non-numeric depth is 400. Everything comes back in the same order plain
zfs list does: hierarchically by pathname, each dataset immediately followed by its own
snapshots, then its bookmarks (both in creation order).
{"ok":true,"datasets":["backups/fs1","backups/fs1@s1","backups/fs1@s2",...]}
GET /v1/detailed[/{fs...}] (same type and depth filters) is the same listing with per-entry detail
instead of bare names — each entry’s type, GUID, creation txg, space accounting, a
volume’s size, a clone’s origin, and (for a filesystem or volume with a receive in progress
or interrupted) its resume token:
{"ok":true,"datasets":[
{"name":"backups/fs1","type":"filesystem","guid":123,"createtxg":9001,"used":N,
"available":N,"referenced":N,"usedbysnapshots":N,"usedbydataset":N,
"usedbyrefreservation":N,"usedbychildren":N},
{"name":"backups/fs1@s1","type":"snapshot","guid":456,"createtxg":9002,"used":N,
"available":N,"referenced":N},
{"name":"backups/vol1","type":"volume","guid":789,"createtxg":9005,"volsize":N,
"volblocksize":N,"used":N,"available":N,"referenced":N,"usedbysnapshots":N,
"usedbydataset":N,"usedbyrefreservation":N,"usedbychildren":N},
{"name":"backups/fs1#mark","type":"bookmark","guid":456,"createtxg":9002}
]}
type/guid/createtxg/used/available/referenced are present on every dataset or
snapshot entry; a bookmark only carries type/guid/createtxg. A volume also carries
volsize/volblocksize in bytes. used/available/referenced mirror zfs list -o space’s USED/AVAIL/REFER columns. The four usedby* fields only apply to filesystems
and volumes, not snapshots, so they’re simply omitted on a snapshot entry rather than
reported as zero. origin (the snapshot a clone was created from) appears only on a clone’s
entry.
Uploading a stream
PUT /v1/recv/{target...}?token=YOUR-TOKEN-HERE
Optional query parameters:
| Parameter | Default | zfs receive equivalent |
|---|---|---|
force=true|false |
false |
-F |
resumable=true|false |
true |
-s |
The request body is the zfs send stream, byte for byte — no JSON, no framing — so a
stock pipeline talks to the endpoint directly:
# zfs send -Rw backups/fs1@s1 | \ curl -T - "https://us-sw-01.zfsrecv.com/v1/recv/backups/fs1@s1?token=YOUR-TOKEN-HERE"
Every stream zfs send can produce is accepted: a plain stream, an incremental, a full
replication package (-R, -I, -p, -b, -h), raw or not, from a sender of either byte
order.
{"ok":true,"bytes":123456789,"received":["backups/fs1@s1"]}
received lists, in order, every snapshot that actually landed — on a failure partway
through a package, whatever got in before the error (a package upload isn’t atomic, same as
in plain ZFS). A failed receive returns 502 with errno/error, the same received list,
a resume_token only when the failure left resumable state, and warnings only when
something non-fatal couldn’t be applied (e.g. a rename that didn’t carry over):
{"ok":false,"errno":5,"error":"...","resume_token":"1-...","received":["backups/fs1@s1"]}
The target may omit the @snapshot suffix for a plain substream (the name is then taken from
the stream itself); a recursive (-R-style) package target must never include @.
force behaves differently depending on what you send. For a plain stream, an
incremental rolls the target back to its source snapshot first (discarding anything newer);
a full stream may only replace an existing filesystem that has no snapshots — refused
otherwise, as zfs receive -F does. For a replication stream (zfs send -R -i/-I),
force additionally lets ZFSRecv destroy snapshots and filesystems that no longer exist at
your source — without it, everything else in the package (renames, promotions, property
updates) still applies, just not destruction.
resumable=false (zfs receive without -s) means a failed upload leaves nothing
behind and returns no resume_token. The default, true, is what makes resuming an
interrupted upload possible.
Encrypted datasets must be sent raw (-w) — we never hold your key, so we can’t encrypt a
plaintext stream on your behalf. A clone is received as a clone if its origin snapshot is
already stored somewhere in the pool (searched by GUID, not by name); otherwise the receive
fails. Uploads may be capped in size by server policy; an oversized body fails exactly like
any other interrupted one.
A few zfs receive options have no equivalent here, by design:
| Option | Why not |
|---|---|
-o / -x (override or exclude properties) |
A backup should hold what you sent; a restore hands back exactly what was received. |
-d / -e (derive the target name from the stream) |
The target is always the URL path. |
-u (don’t mount) |
Nothing on our side is ever mounted. |
-c (corrective/healing receive) |
Repairing a pool from a stream isn’t something this API does. |
-n (dry run) |
A dry run would still mean uploading the whole stream — there’s nothing to gain over HTTP. |
Downloading a stream
GET /v1/send/{snapshot...}?token=YOUR-TOKEN-HERE
Optional query parameters:
| Parameter | Default | zfs send equivalent |
|---|---|---|
from=FS@SNAP (or FS#BOOKMARK) |
full send | -i |
raw=true|false |
true |
-w |
compressed=true|false |
true |
-c |
largeblock=true|false |
true |
-L |
embed=true|false |
true |
-e (implied by raw) |
holds=true|false |
false |
-h |
props=true|false |
false |
-p |
backup=true|false |
false |
-b (received property values only) |
recursive=true|false |
false |
-R |
intermediates=true|false |
false |
-I (needs from, unless recursive) |
estimate=true|false |
false |
-nP — report the size instead of sending |
The response body is the raw restore stream, byte for byte, so it pipes straight into
zfs receive:
curl "https://us-sw-01.zfsrecv.com/v1/send/backups/fs1@s1?token=YOUR-TOKEN-HERE" | zfs receive otherpool/fs1
Response is 200, Content-Type: application/octet-stream, chunked (the length isn’t known
upfront), with an X-Zbackupd-Status: ok trailer once the body completes cleanly. If the send
fails before any bytes went out (e.g. the snapshot doesn’t exist), you get an ordinary 502
JSON error instead. If it fails after streaming has already started, a response that already
committed to 200 can’t be downgraded — the only reliable signal is the connection closing
before a clean end (best-effort, there’s also an X-Zbackupd-Status: error: ... trailer).
raw and compressed default on — a restore comes back exactly as stored. If the
dataset was received raw (encrypted), the restore arrives still encrypted and needs
zfs load-key on your side; requesting raw=false for an encrypted dataset is refused
(502, EINVAL), since we never hold the key to decrypt it for you. For unencrypted data,
raw just means “keep it compressed.” Pass raw=false&compressed=false for a plain stream.
recursive=true restores a whole replication package — the snapshot’s filesystem and every
descendant, each with its own snapshots — exactly like zfs send -R, unpacked on the other
end by zfs receive. holds=true includes each snapshot’s user holds (re-created by
zfs receive); props=true includes every property value currently set; backup=true
includes only the values originally received, even if something local has since overridden
them. Any of the three turns even a single-snapshot send into a small package. With
recursive, intermediates, holds, props or backup, from must name a snapshot of
the same filesystem as snapshot, and applies to every descendant by its last component —
same as zfs send -R -i.
Resuming an interrupted restore (zfs send -t):
GET /v1/send?token=YOUR-TOKEN-HERE&resume=RESUME_TOKEN (no snapshot in the path) resumes
the stream described by a resume token — either one you got back from your receiving side
(zfs get receive_resume_token), or one we handed back from a previous PUT /v1/recv. The
token fully determines the snapshot, source, offset and flags, so no other parameter may be
given except estimate.
Estimating a restore’s size without downloading it: add estimate=true to any of the
above to get 200 with JSON instead of the stream itself:
{"ok":true,"estimate":{"substreams":[{"snapshot":"backups/fs1@s1","from":null,"bytes":N}],"bytes":12345}}
One entry per substream the real transfer would contain, sized the same way zfs send -nP
estimates — from block accounting, not a dry run of the actual send.
Destroying a dataset, bookmark or snapshot(s)
DELETE /v1/destroy/{target...}?token=YOUR-TOKEN-HERE&recursive=true
recursive is optional (default false). target can be:
- a dataset (filesystem or volume);
- a bookmark — percent-encode its
#as%23:/v1/destroy/backups/fs1%23mark; - one snapshot (
fs@snap), several (fs@a,b,c), or a creation-order range:fs@a%cdestroysathroughcinclusive; leave either end off for “from the oldest” (fs@%c), “to the newest” (fs@a%), or both for every snapshot offs(fs@%).%must itself be percent-encoded as%25—/v1/destroy/backups/fs1@a%25c— and a list and a range combine:fs@a%c,e.
With a list or range, every matching snapshot is destroyed all together or not at all:
if any one of them refuses (held: EBUSY; a clone origin: EEXIST), nothing is destroyed,
and the error names the one that failed. A name or range end a filesystem doesn’t have is
simply skipped, same as zfs destroy; if nothing matched anywhere, it’s ENOENT. The one
exception is a single, non-recursive fs@snap that’s already gone — that still succeeds.
For a dataset, recursive works as zfs destroy’s own safety rules do: refuses if it has
children or its own snapshots, unless set, in which case descendants are destroyed
leaf-to-root before the target itself. recursive with a snapshot, list or range instead
also reaches the same-named snapshots of every descendant; recursive with a bookmark is
refused, same as zfs destroy. A snapshot that’s a clone origin is never chased and
destroyed along with its clones; it just fails EEXIST, same as plain zfs destroy -r in
that situation.
This is irreversible.
{"ok":false,"errno":17,"error":"dataset has children"}
Most common errnos: EEXIST (children, or a clone origin, without recursive) and EBUSY
(the dataset still owns snapshots, or something holds it).
Resuming an interrupted upload
GET /v1/token/{fs...}?token=YOUR-TOKEN-HERE
Returns the resume token for a partial receive on fs, if one exists, to pass straight to
zfs send -t:
{"ok":true,"resume_token":"1-..."}
502 with {"ok":false,"error":"..."} if there’s no resume state to report.
To discard partial receive state instead of resuming it (equivalent to zfs receive -A):
DELETE /v1/abort/{fs...}?token=YOUR-TOKEN-HERE
(PUT /v1/abort/{fs...} and DELETE /v1/recv/{fs...} are accepted as the same operation,
whichever reads more naturally against your tooling.) This is irreversible — it clears
whatever was resumable. 502 if there was nothing to abort.
Holds
PUT /v1/hold/{tag}/{snapshot...}?token=YOUR-TOKEN-HERE&recursive=true
Places a user hold named tag on snapshot — tag comes before snapshot in the URL, same
order as zfs hold <tag> <snap>. While held, the snapshot refuses destroy (EBUSY) until
released. recursive (optional, default false) also places the same tag on every descendant
that has a same-named snapshot. As with zfs hold, a tag starting with . is reserved, and
holding the same tag twice on the same snapshot is EEXIST. Every hold placed this way is
permanent — there’s no equivalent of zfs hold -t.
DELETE /v1/release/{tag}/{snapshot...}?token=YOUR-TOKEN-HERE&recursive=true
Removes a hold previously placed by hold. (PUT /v1/release/... and
DELETE /v1/hold/... are accepted as the same operation.) recursive releases the same tag
from every descendant that has it; a descendant missing the snapshot or the tag is simply
skipped — unless nothing at all had the tag, in which case the whole request fails
(ENOENT if snapshot itself doesn’t exist, otherwise “no such tag”).
GET /v1/holds/{snapshot...}?token=YOUR-TOKEN-HERE&recursive=true
Lists holds on snapshot (and, with recursive, on every descendant that has a matching
one):
{"ok":true,"holds":[{"name":"backups/fs1@s1","tag":"mytag","timestamp":1700000000}]}
One entry per (snapshot, tag) pair — a snapshot with several holds appears several times.
Unlike hold/release, no matching snapshot or no holds at all is never an error here; the
array is just empty, the same way list/detailed report an empty set rather than failing.
Renaming
PUT /v1/rename/{src...}?token=YOUR-TOKEN-HERE&to=DST&recursive=true
recursive is optional (default false). Renames a filesystem — and everything below it —
or a snapshot, to the full name to. src and to must both be snapshots, or both not —
mixing the two is 400. recursive (zfs rename -r) renames the same-named snapshot of
every descendant too, so it only applies to snapshot renames. Nothing is ever mounted here,
so there’s no -u/-f equivalent, and there’s no -p: the destination’s parent must
already exist.
{"ok":true}
502 with errno/error otherwise — EEXIST when to already exists, ENOENT when src
or the destination’s parent doesn’t.
Rolling back
PUT /v1/rollback/{snapshot...}?token=YOUR-TOKEN-HERE&destroy_newer=true
Rolls snapshot’s filesystem back to it, discarding every change made since — same as
zfs rollback. Refuses while anything newer (a snapshot or bookmark) exists on that
filesystem, unless destroy_newer=true (zfs rollback -r) destroys them first. A newer
snapshot that’s a clone origin or currently held still blocks it either way.
This is irreversible.
{"ok":true,"target":"zb_alice/backups/fs1@s1"}
target names the snapshot rolled back to — as a full pool name including our internal
prefix, unlike every name you pass in, which is relative to your account. 502 with
EEXIST (“more recent snapshots or bookmarks exist”) if you didn’t set destroy_newer.
Bookmarks
PUT /v1/bookmark/{source...}?token=YOUR-TOKEN-HERE&bookmark=FS%23NAME
Creates a bookmark of snapshot source (or a copy of bookmark source, if that’s what it
names). # must be percent-encoded as %23, in the path and in bookmark alike — a
literal # starts a URL’s fragment, which never reaches the server. bookmark may be just
%23NAME, meaning a bookmark on source’s own filesystem, same as plain zfs bookmark.
{"ok":true}
502 with errno/error otherwise — EEXIST if that bookmark already exists.
Properties
GET /v1/props/{target...}?token=YOUR-TOKEN-HERE
Every property of a filesystem, volume or snapshot, shaped like zfs get -j:
{"ok":true,"properties":{
"compression":{"value":"lz4","source":{"type":"LOCAL","data":"-"}},
"quota":{"value":10737418240,"source":{"type":"NONE","data":"-"}},
"user:team":{"value":"ops","source":{"type":"INHERITED","data":"backups"}}
}}
Index properties come back by name ("lz4", "on"); everything else is a JSON number
(bytes, seconds since the epoch, 100 meaning a 1.00x ratio) or string. Every dataset name
— including source.data for an inherited property — is relative to your account, same as
everywhere else in this API. source.type is LOCAL (set directly), RECEIVED (arrived on
a receive), INHERITED (data names where it’s inherited from), DEFAULT (ZFS’s own
built-in) or NONE (a statistic, not a settable property). User properties (module:name)
are included; properties ZFS only derives from actually being mounted (mounted, an unset
mountpoint, …) are left out, since nothing here is ever mounted.
PUT /v1/props/{target...}?token=YOUR-TOKEN-HERE
with a JSON body naming the properties to set, leaving everything else alone:
{"compression":"lz4","quota":"10G","user:team":"ops"}
Values are written exactly as zfs set takes them — strings like "lz4", "10G", "1.5T",
"none" (clears a quota or reservation), or non-negative integers. The whole request is
validated before anything changes: one bad value (an unknown, read-only or creation-only
property; an invalid value; "quota":0 instead of "none") fails the entire call — nothing
partially applies. A volume’s volsize can’t be changed this way (volumes aren’t resized
here). Most properties have no effect on a never-mounted pool like this one, but are stored,
returned by GET /v1/props, and included in a restore with props=true/recursive=true.
To undo a value you’ve set, see inheriting a property below.
{"ok":false,"errno":22,"error":"property 'quota' must not be 0 (use none to disable it)","failed":{"quota":22}}
error describes the first problem found; failed names every property that was rejected.
A body that isn’t a flat JSON object of valid property names to strings or integers is
400; one over 1 MiB is 413.
Inheriting a property
PUT /v1/inherit/{target...}?token=YOUR-TOKEN-HERE&property=NAME&recursive=true&received=true
Clears property’s locally-set value on a filesystem or snapshot, same as zfs inherit — it
reverts to whatever its parent has, or ZFS’s own default if nothing does. received=true
(zfs inherit -S) instead reverts to whatever the last receive set, and is the only way
to clear quota/reservation/refquota/refreservation — those fail EINVAL without it,
since they have no ordinary inherited value to fall back to. recursive=true
(zfs inherit -r) does the same on every descendant and snapshot too, silently skipping ones
the property doesn’t apply to. A read-only property fails EINVAL.
{"ok":true}
502 with errno/error otherwise.
Naming rules
Every dataset, snapshot, bookmark or hold tag you reference is validated before it reaches
storage, following ZFS’s own naming rules: path components of letters, digits,
_, ., :, or -, never empty or ./.., at most 200 bytes total, with exactly one
@ (snapshot) or # (bookmark) where one is expected. Hold tags accept any printable
character but can’t start with .. Anything that fails this is 400, before any handler
runs — and a literal .. or // in the URL path itself never even reaches that check, it’s
redirected to the cleaned path first.
Boolean query parameters (force, recursive, estimate, and the rest) are just as strict:
only 1/t/T/true/True/TRUE or 0/f/F/false/False/FALSE are accepted. A
parameter you don’t send takes its documented default, but a typo like recursive=yes is
400 — never silently read as false.
Errors
| Status | Meaning |
|---|---|
200 |
success — {"ok":true, ...}, or for /v1/send, the raw stream itself |
400 |
a name failed validation (see naming rules above) |
401 |
missing/invalid token, or a pool on a plan this endpoint doesn’t serve |
404 |
your account has no pool yet — call PUT /v1/pool_create first, see Endpoints |
405 |
wrong HTTP method for an otherwise-real path (Allow header names the right one) |
413 |
request body too large (PUT /v1/props’s JSON body over 1 MiB) |
429 |
too many failed logins from your address — see Authentication |
502 |
the operation itself failed — {"ok":false,"errno":E,"error":"..."} |
503 |
couldn’t check your token right now, or your account is momentarily busy — retry, honoring Retry-After if present |
507 |
we’re low on storage — uploads and new pools’ first use are refused until that clears |
Limits
- Concurrency: each pool is served by one worker at a time. A second request that
arrives while one’s already in flight waits up to 15 seconds, then fails
503withRetry-Afterrather than queue indefinitely — retry it. - Idle timeout:
/v1/recvand/v1/sendbodies abort after 60 seconds with zero bytes moving in either direction. This is a sliding deadline reset on every chunk that makes progress, so a large, slow-but-progressing transfer is never cut off — only a connection that’s gone genuinely silent is dropped. - Health check:
GET /healthzneeds no token and returns200 {"ok":true,...}when healthy, or503 {"ok":false,"error":"..."}if we’re out of capacity — safe to poll from your own monitoring.