Skip to content

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.

These are the only custom headers on the instance. Everything else in the x-amz-meta-* namespace is yours.

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.

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.

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.

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.

Every credential failure answers 401 with the same body. All of these:

  • no Authorization header;
  • 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 exp has passed, or whose nbf has 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.

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.

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.