proven on the hosted testnet instance · 2026-09-13 · QA-14
Headers, status codes, 401 and 429
Two things a client has to get right: the headers that carry Permafrost’s own state, and the handful of statuses that mean something specific here rather than what they mean in S3 generally.
The x-amz-meta-permafrost-* headers
Section titled “The x-amz-meta-permafrost-* headers”These are the only custom headers on the instance. Everything else in the x-amz-meta-* namespace is
yours.
On the way in
Section titled “On the way in”| Header | Values | Effect |
|---|---|---|
x-amz-meta-permafrost-encrypt |
default, or absent, or empty |
Follow the instance’s own posture, which is to encrypt. |
none |
Store this one object as plaintext. |
Matching is case-insensitive over the whole value, so NONE and None are the same word.
Anything else — including a value with trailing whitespace — fails the request with
400 InvalidArgument. There is deliberately no always: a policy the instance could not enforce
would be a promise, not a setting.
The parse runs before the content hash, before any bytes are written and before anything is queued,
so a rejected PUT leaves nothing behind to clean up. What the header asked for and what
actually happened to the bytes are recorded separately — see
Encryption at rest.
On the way out
Section titled “On the way out”| Header | When | Values |
|---|---|---|
x-amz-meta-permafrost-sync-state |
every single-object GET and HEAD |
PENDING, UPLOADING, SYNCED, FAILED, DELETED |
x-amz-meta-permafrost-blob-id |
only when the object is SYNCED and a blob id was recorded |
the Walrus blob id |
Both conditions are required on the blob id, so a caller is never handed a locator it cannot resolve
— and the header’s absence is the “not certified yet” signal. Poll HEAD until it appears, or
read the same state as JSON from GET /v1/api/meta/objects/{id}.
Neither header appears on a multipart object. Its bytes resolve through the upload’s manifest and its part rows rather than through one blob, so there is no single blob id to report.
Both are written before the object’s stored user metadata is replayed onto the response, so something
a client stashed under the same name at PUT time cannot shadow them.
The owner header is not yours to set
Section titled “The owner header is not yours to set”x-amz-meta-permafrost-owner is injected by the edge, after your credential is resolved, overwriting
whatever arrived under that name. It carries the calling tenant and it is what the storage layer
enforces ownership from. Sending it yourself changes nothing.
Stored-byte responses carry five more
Section titled “Stored-byte responses carry five more”Every response returning stored bytes also carries an attachment disposition, a
default-src 'none'; sandbox policy, nosniff, X-Frame-Options: DENY and
Referrer-Policy: no-referrer. They are listed, with what they change for a caller, on
The S3 surface.
One 401, whatever went wrong
Section titled “One 401, whatever went wrong”Every credential failure answers 401 with the same body. All of these:
- no
Authorizationheader; - a header that is not a bearer at all;
- a bearer that is neither a key nor a token in shape;
- an unknown key;
- a revoked key;
- a token from an issuer this instance has not been configured with;
- a token whose signature does not verify;
- a token whose audience is wrong, whose
exphas passed, or whosenbfhas not; - a token whose subject is not a canonical Sui address;
- a token-shaped bearer sent to an instance where the token lane is off entirely.
That last one is the reason the rule is absolute rather than approximate. If a rejection told a caller which of these it was, the rejection would also tell them whether the token lane is configured — and a remote caller learning the shape of an instance’s configuration by probing it is exactly what one uniform answer prevents.
The corollary for anyone integrating: a 401 is never evidence about your issuer. Check the
audience, the algorithm and the subject form against
Bring your own issuer rather than reading anything into the
response.
/me and /me/keys/rotate authenticate the key themselves rather than trusting context resolved
elsewhere, so they answer their own 401 in the same spirit: absent, malformed, unknown, revoked and
inactive-tenant are one answer, and a token-lane caller lands there too because it has no key to
present.
The rest of the statuses
Section titled “The rest of the statuses”| Status | Body | Means |
|---|---|---|
400 |
{"error":"invalid_hash"} |
GET /v1/api/verify was called without a 64-hex hash. The input is not echoed back. |
400 |
{"error":"invalid or expired invite"} |
Redemption failed. Not found, expired, already used, revoked and malformed input are one answer, so valid tokens cannot be probed for. |
400 |
InvalidArgument (XML) |
An unrecognised x-amz-meta-permafrost-encrypt value. |
403 |
{"error":"Tenant inactive or not found"} |
The credential is real; the tenant it names has been revoked. Revoking a tenant closes the key lane and the token lane together. |
403 |
AccessDenied (XML) |
A cross-tenant operation on the S3 surface (Tenancy), or a read whose key cannot be selected (Encryption at rest). |
403 |
plain text | An operator-only path, answered at the edge for a credentialed caller and never proxied; without a credential the same path answers the usual 401. Not part of this API. |
404 |
{"error":"not_found","detail":"Object not found"} |
A /v1/api/meta read. Not yours and not there are one answer — no 403, so object existence never leaks. |
404 |
{"error":"not found"} |
A custody read, delete or status call. Another tenant’s id, an id with no owner recorded, and an id that was never stored are all this. |
410 |
{"error":"plaintext_unrecoverable"} |
The key for that custody object was destroyed. There is no second path to the bytes. |
413 |
{"error":"quota_exceeded","limit_mib":…,"used_mib":…} |
A custody store whose declared size would take the tenant past its ceiling. See Limits. |
429 |
{"error":"rate_limited","retry_after_ms":N} |
Over the tenant’s allowance. Below. |
429 |
{"error":"too many attempts, retry later"} |
The redeem route’s own limit. A redeemer has no tenant yet, so the per-tenant bucket cannot apply — and this body carries no Retry-After. |
500 |
{"error":"internal_error"} |
Logged on the instance, not returned. |
502 |
{"error":"store failed"}, {"error":"decrypt/serve failed"} |
A custody operation could not reach or decode the stored bytes. |
429 is per tenant, not global
Section titled “429 is per tenant, not global”Each tenant carries its own requests-per-second ceiling and its own token bucket, keyed by the tenant address. One tenant spending its allowance does not consume another’s — that is the property, and it is the one that was exercised: one tenant was driven to its ceiling while a second, bursting at the same moment, was answered normally.
429 {"error":"rate_limited","retry_after_ms":N}Retry-After: <whole seconds, rounded up>Retry-After is rounded up and never zero, because a zero would invite an immediate retry that
cannot succeed. retry_after_ms is the finer figure, and the one to sleep on.
Both credentials share one bucket per tenant. Arriving with a key and with a token does not buy two allowances — the bucket is keyed by the address, not by the credential.
The custody surface keeps a second, local limiter of its own; its refusal carries the code without
retry_after_ms. And an overall limit exists at the edge, across every caller at once, which can
answer 429 on any route — including the keyless ones. Sizes for all three are on
Limits.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.