Blobs

Blobs are binary objects stored inside buckets. Each blob is identified by a user-chosen key (like a file path) and has a content-addressed blob ID computed from its contents.

Key properties:

  • Reads are public: no authentication needed to download a blob
  • Writes require auth: uploading, updating, and deleting need a Bearer token
  • Overwrite semantics: uploading to an existing key replaces the blob
  • Content-addressed: identical content is stored only once
  • Reference-counted deletion: underlying data is removed only when no keys reference it
  • Pool-scoped expiration: blobs share their account's StoragePool lifetime; a background extension service renews each pool before it expires (see Blob Lifecycle)

Store Blob

PUT /api/v1/buckets/{bucket_name}/blobs/{key}

Uploads binary data to the specified bucket and key. If a blob already exists at that key, it is replaced.

Path parameters:

ParameterTypeDescription
bucket_namestringTarget bucket
keystringObject key (for example, images/photo.png)

Request headers:

HeaderDefaultDescription
Content-Typeapplication/octet-streamMIME type stored with the blob
If-Match—Only overwrite if the existing blob's ETag matches (412 otherwise)
If-None-Match—Set to * to create only if the key doesn't exist (412 otherwise)
x-oyster-tag—Attach a tag as key=value (percent-decoded). Repeatable; send the header once per tag. See Blob Tags

Request body: Raw binary data (max 1 GB)

Example: upload a string:

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: text/plain" \
  --data-binary "Hello, Oyster!" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt" | jq

Example: upload a file:

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: image/png" \
  --data-binary @photo.png \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/images/photo.png" | jq

Example: create only (fail if key exists):

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: text/plain" \
  -H "If-None-Match: *" \
  --data-binary "Hello, Oyster!" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt" | jq

Example: safe overwrite (only if ETag matches):

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: text/plain" \
  -H 'If-Match: "9a0364b9e99bb480dd25e1f0284c8555"' \
  --data-binary "Updated content" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt" | jq

Example: upload with tags:

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: text/plain" \
  -H "x-oyster-tag: env=prod" \
  -H "x-oyster-tag: team=platform" \
  --data-binary "Hello, Oyster!" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt" | jq

Each x-oyster-tag header carries exactly one key=value pair (percent-decoded; no &-joined pairs). Tags are replaced on every PUT, so re-uploading a key without any x-oyster-tag headers clears its tags. The same caps as the Blob Tags endpoints apply. See Tag rules.

Response (201 Created):

{
  "key": "hello.txt",
  "blob_id": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
  "size": 14,
  "md5": "9a0364b9e99bb480dd25e1f0284c8555",
  "sui_object_id": null,
  "created_at": "2025-01-15T10:31:00Z"
}
FieldTypeDescription
keystringThe object key
blob_idstringContent-addressed hash of the blob data
sizeintegerSize in bytes
md5stringHex-encoded MD5 digest (used as S3 ETag)
sui_object_idstring or nullOnchain Sui object ID (if stored on Walrus)
created_atstringISO 8601 timestamp

The response includes an ETag header containing the quoted MD5 digest (for example, "9a0364b9e99bb480dd25e1f0284c8555").

Errors:

StatusCondition
400Upload would push the account past its per-account max_unencoded_bytes cap (body carries a cap_exceeded block)
401Missing or invalid API key
402Pearl-derived wallet lacks WAL/SUI to fund the upload (body carries a funding_required block; see Cross-Cutting Error Contracts)
404Bucket not found
412If-Match or If-None-Match condition failed
413Payload exceeds 1 GB, or exceeds the Walrus encoder's per-blob ceiling for the network's n_shards (body carries a payload_too_large block)

When the cap is exceeded the response body looks like:

{
  "error": "storage cap exceeded: ...",
  "cap_exceeded": {
    "max_unencoded_bytes": 5000000000,
    "used_encoded_bytes": 4998123456,
    "new_unencoded_bytes": 16384,
    "admin_endpoint": "PUT /api/v1/accounts/{account_id}/max-storage"
  }
}
FieldTypeDescription
max_unencoded_bytesintegerConfigured per-account cap, in unencoded bytes
used_encoded_bytesintegerOnchain encoded usage observed at check time
new_unencoded_bytesintegerUnencoded size of the rejected upload
admin_endpointstringAdmin route that can raise the cap

The cap is enforced in unencoded bytes against onchain encoded usage through the same f = encoded_blob_length_for_n_shards that the upload path uses to project the post-upload encoded total, so the short-circuit fires before any onchain work. Raise (or lower) the cap through the admin Update Storage Cap endpoint.

Read Blob by Key

GET /api/v1/buckets/{bucket_name}/blobs/{key}

Downloads a blob's contents. No authentication required.

Path parameters:

ParameterTypeDescription
bucket_namestringBucket containing the blob
keystringObject key

Example:

curl -s "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt"

Conditional headers:

HeaderEffect
If-Match: "<etag>"Return the blob only if its ETag matches; otherwise 412
If-None-Match: "<etag>"Return the blob only if its ETag differs; otherwise 304

Example: cache validation:

curl -s -o /dev/null -w "%{http_code}" \
  -H 'If-None-Match: "9a0364b9e99bb480dd25e1f0284c8555"' \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt"
# Returns 304 if unchanged, 200 with body if changed

Response (200 OK):

  • Body: Raw binary blob data
  • Content-Type: The MIME type set during upload
  • ETag: Quoted MD5 digest (for example, "9a0364b9e99bb480dd25e1f0284c8555")
  • Content-Disposition: attachment. Because reads are public and serve a caller-supplied Content-Type, blobs are returned as downloads so a text/html/SVG payload can't execute as a page on the Oyster origin. The response also carries X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox. Embedding a blob as a subresource (<img>, <video>, <script src>, fetch) is unaffected; only direct top-level navigation downloads instead of rendering.

Errors:

StatusCondition
304If-None-Match matched: blob has not changed
404Blob not found
412If-Match condition failed

Read Blob by Blob ID

GET /api/v1/blobs/by-blob-id/{blob_id}

Downloads a blob by its content-addressed hash. Useful when you know the blob ID but not which bucket or key it's stored under. No authentication required.

Path parameters:

ParameterTypeDescription
blob_idstringContent-addressed blob hash

Example:

curl -s "$OYSTER_URL/api/v1/blobs/by-blob-id/2cf24dba5fb0a30e..."

Response (200 OK):

  • Body: Raw binary blob data
  • Content-Type: application/octet-stream

Errors:

StatusCondition
404Blob ID not found

List Blobs

GET /api/v1/buckets/{bucket_name}/blobs

Returns a paginated list of blobs in a bucket.

Path parameters:

ParameterTypeDescription
bucket_namestringBucket to list

Query parameters:

ParameterTypeDefaultDescription
cursorstring—Opaque cursor from a previous next_cursor
limitinteger20Items per page (max: 100)

Example:

curl -s -H "Authorization: Bearer $API_KEY" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs?limit=50" | jq

Response (200 OK):

{
  "data": [
    {
      "key": "hello.txt",
      "blob_id": "2cf24dba5fb0a30e...",
      "bucket_name": "my-bucket",
      "account_id": "550e8400-e29b-41d4-a716-446655440000",
      "content_type": "text/plain",
      "size": 14,
      "md5": "9a0364b9e99bb480...",
      "sui_object_id": null,
      "created_at": "2025-01-15T10:31:00Z"
    }
  ],
  "next_cursor": null
}
FieldTypeDescription
keystringObject key
blob_idstringContent-addressed hash
bucket_namestringContaining bucket
account_idstringOwning account UUID
content_typestringMIME type
sizeintegerSize in bytes
md5stringHex-encoded MD5 digest
sui_object_idstring or nullOnchain Sui object ID
created_atstringISO 8601 timestamp

Update Blob Metadata

PATCH /api/v1/buckets/{bucket_name}/blobs/{key}/metadata

Updates metadata for an existing blob. Currently only content_type can be changed.

Path parameters:

ParameterTypeDescription
bucket_namestringBucket containing the blob
keystringObject key

Request body:

{
  "content_type": "image/png"
}
FieldTypeRequiredDescription
content_typestringyesNew MIME type for the blob

Example:

curl -s -X PATCH \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content_type": "image/png"}' \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/photo.png/metadata" | jq

Response (200 OK): Full blob metadata (same shape as items in List Blobs).

Errors:

StatusCondition
400content_type not provided
401Missing or invalid API key
404Blob not found

Delete Blob

DELETE /api/v1/buckets/{bucket_name}/blobs/{key}

Deletes a blob by key. The underlying data is only removed from storage when no other keys reference the same content (reference-counted deletion).

Path parameters:

ParameterTypeDescription
bucket_namestringBucket containing the blob
keystringObject key to delete

Conditional headers:

HeaderEffect
If-Match: "<etag>"Delete only if ETag matches; otherwise 412
If-None-Match: "<etag>"Delete only if ETag differs; otherwise 412

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $API_KEY" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt"

Example: delete only if ETag matches:

curl -s -X DELETE \
  -H "Authorization: Bearer $API_KEY" \
  -H 'If-Match: "9a0364b9e99bb480dd25e1f0284c8555"' \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt"

Response: 204 No Content

Errors:

StatusCondition
401Missing or invalid API key
402Insufficient onchain balance to clear the PooledBlob; the DB row is left intact for retry
404Blob not found
412If-Match or If-None-Match condition failed

A 402 carries the same funding_required block as the upload path:

{
  "error": "insufficient balance: ...",
  "funding_required": {
    "wal_frost": "1234567890",
    "sui_mist": "98765432"
  }
}

wal_frost and sui_mist are decimal strings (Pearl-derived wallet's owed top-up); inspect your wallet through Get Wallet Address. When delete_blob returns 402, the DB row is left intact on purpose so the client can fund the Pearl-derived wallet and retry the same DELETE. Other onchain delete errors are still swallowed to preserve idempotent-delete semantics.

Blob Tags

Each blob can carry a small set of arbitrary key=value tags, stored in Oyster's database (independent of the underlying blob content). Tags set through this JSON API and tags set through the S3 Object Tagging operations share the same backing store. A tag written through one API is visible through the other.

All tag endpoints live under a blob's /tags path, require Bearer auth, and return 404 if the blob does not exist or is not owned by the authenticated account.

Tag rules

LimitValue
Max tags per blob10
Max tag key length128 bytes
Max tag value length256 bytes
Max total set size2048 bytes (sum of all keys + values)

Allowed characters in keys and values: ASCII alphanumerics plus space and + - = . _ : / @. Keys must be non-empty; values might be empty. Duplicate keys are rejected. Any request whose resulting tag set violates these rules returns 400.

Get Tags

GET /api/v1/buckets/{bucket_name}/blobs/{key}/tags

Returns all tags on the blob.

Example:

curl -s -H "Authorization: Bearer $API_KEY" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags" | jq

Response (200 OK):

{
  "tags": {
    "env": "prod",
    "team": "platform"
  }
}
FieldTypeDescription
tagsobjectMap of tag keys to values (empty object if the blob has no tags)

Replace Tags

PUT /api/v1/buckets/{bucket_name}/blobs/{key}/tags

Replaces the blob's entire tag set with the supplied map. Tags not present in the request are removed.

Request body:

{ "tags": { "env": "prod", "team": "platform" } }

Example:

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": {"env": "prod", "team": "platform"}}' \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags"

Response: 204 No Content

Errors: 400 if the tag set violates tag rules; 401 if unauthenticated; 404 if the blob is not found.

Merge Tags

PATCH /api/v1/buckets/{bucket_name}/blobs/{key}/tags

Merges the supplied map into the blob's existing tags (upsert per key). Keys not mentioned in the request are left untouched.

Request body:

{ "tags": { "team": "storage" } }

Example:

curl -s -X PATCH \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": {"team": "storage"}}' \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags"

Response: 204 No Content

Errors: 400 if the merged set would violate tag rules (for example, exceed the 10-tag cap); 401 if unauthenticated; 404 if the blob is not found.

Delete All Tags

DELETE /api/v1/buckets/{bucket_name}/blobs/{key}/tags

Clears every tag on the blob.

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $API_KEY" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags"

Response: 204 No Content

Set a Single Tag

PUT /api/v1/buckets/{bucket_name}/blobs/{key}/tags/{tag_key}

Upserts a single tag. The request body is the raw tag value as text/plain (not JSON).

Example:

curl -s -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: text/plain" \
  --data-binary "prod" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags/env"

Response: 204 No Content

Errors: 400 if adding the tag would exceed the 10-tag cap or the value/key violates tag rules; 401 if unauthenticated; 404 if the blob is not found.

Delete a Single Tag

DELETE /api/v1/buckets/{bucket_name}/blobs/{key}/tags/{tag_key}

Deletes a single tag by key. Idempotent: deleting a tag that doesn't exist still returns 204.

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $API_KEY" \
  "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt/tags/env"

Response: 204 No Content