Skip to main content

Blobs

Blobs are binary objects stored inside buckets. A user-chosen key (like a file path) identifies each blob, and each blob has a content-addressed blob ID computed from its contents.

Key properties:

  • Reads are public: no authentication needed to download a blob
  • Writes require authentication: uploading, updating, and deleting need a Bearer token
  • Overwrite semantics: uploading to an existing key replaces the blob
  • Content-addressed: Oyster stores identical content only once
  • Reference-counted deletion: Oyster removes underlying data 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, the upload replaces it.

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-MatchNoneOnly overwrite if the existing blob's ETag matches (412 otherwise)
If-None-MatchNoneSet to * to create only if the key doesn't exist (412 otherwise)
x-oyster-tagNoneAttach a tag as key=value (percent-decoded). Repeatable; send the header once per tag. See Blob Tags

Request body: Raw binary data (max 1GB)

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). Every PUT replaces the tags, 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 or 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 1GB, or exceeds the Walrus encoder's per-blob ceiling for the network's n_shards (body carries a payload_too_large block)

When an upload exceeds the cap, 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

Oyster enforces the cap 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, Oyster returns blobs as downloads so a text/html or 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 holds it. No authentication required.

Path parameters:

ParameterTypeDescription
blob_idstringContent-addressed blob hash

Example:

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

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
cursorstringNoneOpaque 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 you can change only content_type.

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. Oyster removes the underlying data from storage only 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
402Pearl-derived wallet lacks the SUI needed for gas to delete the PooledBlob onchain; Oyster leaves the DB row intact so you can fund that wallet and retry the same DELETE
404Blob not found
412If-Match or If-None-Match condition failed

A DELETE 402 carries only an error message and no funding_required block, because the delete needs only SUI gas. Check your Pearl-derived wallet's SUI balance through Get Wallet Address. When delete_blob returns 402, Oyster leaves the DB row intact on purpose so the client can fund the Pearl-derived wallet and retry the same DELETE. Oyster still swallows other onchain delete errors 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 authentication, and return 404 if the blob does not exist or the authenticated account does not own it.

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. Oyster rejects duplicate keys. 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. The endpoint removes tags not present in the request.

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 does not exist.

Merge tags​

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

Merges the supplied map into the blob's existing tags (upsert per key). The endpoint leaves keys not mentioned in the request 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 does not exist.

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 or key violates tag rules; 401 if unauthenticated; 404 if the blob does not exist.

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

Why does a read by blob ID return application/octet-stream?​

Reads by blob ID always return Content-Type: application/octet-stream, whatever MIME type you set at upload. The endpoint reads only the raw content from the blob store and does not look up any key metadata, so it can't return the original MIME type. The same data produces the same blob ID in any bucket or under any key, so a blob ID is not tied to one key's stored MIME type. To get the MIME type you set at upload, download the blob by bucket and key with GET /api/v1/buckets/{bucket_name}/blobs/{key}.