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
StoragePoollifetime; 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:
| Parameter | Type | Description |
|---|---|---|
bucket_name | string | Target bucket |
key | string | Object key (for example, images/photo.png) |
Request headers:
| Header | Default | Description |
|---|---|---|
Content-Type | application/octet-stream | MIME type stored with the blob |
If-Match | None | Only overwrite if the existing blob's ETag matches (412 otherwise) |
If-None-Match | None | Set to * to create only if the key doesn't exist (412 otherwise) |
x-oyster-tag | None | Attach 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"
}
| Field | Type | Description |
|---|---|---|
key | string | The object key |
blob_id | string | Content-addressed hash of the blob data |
size | integer | Size in bytes |
md5 | string | Hex-encoded MD5 digest (used as S3 ETag) |
sui_object_id | string or null | Onchain Sui object ID (if stored on Walrus) |
created_at | string | ISO 8601 timestamp |
The response includes an ETag header containing the quoted MD5 digest
(for example, "9a0364b9e99bb480dd25e1f0284c8555").
Errors:
| Status | Condition |
|---|---|
400 | Upload would push the account past its per-account max_unencoded_bytes cap (body carries a cap_exceeded block) |
401 | Missing or invalid API key |
402 | Pearl-derived wallet lacks WAL or SUI to fund the upload (body carries a funding_required block; see Cross-Cutting Error Contracts) |
404 | Bucket not found |
412 | If-Match or If-None-Match condition failed |
413 | Payload 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"
}
}
| Field | Type | Description |
|---|---|---|
max_unencoded_bytes | integer | Configured per-account cap, in unencoded bytes |
used_encoded_bytes | integer | Onchain encoded usage observed at check time |
new_unencoded_bytes | integer | Unencoded size of the rejected upload |
admin_endpoint | string | Admin 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:
| Parameter | Type | Description |
|---|---|---|
bucket_name | string | Bucket containing the blob |
key | string | Object key |
Example:
curl -s "$OYSTER_URL/api/v1/buckets/my-bucket/blobs/hello.txt"
Conditional headers:
| Header | Effect |
|---|---|
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 atext/htmlor SVG payload can't execute as a page on the Oyster origin. The response also carriesX-Content-Type-Options: nosniffandContent-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:
| Status | Condition |
|---|---|
304 | If-None-Match matched: blob has not changed |
404 | Blob not found |
412 | If-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:
| Parameter | Type | Description |
|---|---|---|
blob_id | string | Content-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:
| Status | Condition |
|---|---|
404 | Blob ID not found |
List blobs
GET /api/v1/buckets/{bucket_name}/blobs
Returns a paginated list of blobs in a bucket.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
bucket_name | string | Bucket to list |
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | None | Opaque cursor from a previous next_cursor |
limit | integer | 20 | Items 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
}
| Field | Type | Description |
|---|---|---|
key | string | Object key |
blob_id | string | Content-addressed hash |
bucket_name | string | Containing bucket |
account_id | string | Owning account UUID |
content_type | string | MIME type |
size | integer | Size in bytes |
md5 | string | Hex-encoded MD5 digest |
sui_object_id | string or null | Onchain Sui object ID |
created_at | string | ISO 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:
| Parameter | Type | Description |
|---|---|---|
bucket_name | string | Bucket containing the blob |
key | string | Object key |
Request body:
{
"content_type": "image/png"
}
| Field | Type | Required | Description |
|---|---|---|---|
content_type | string | yes | New 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:
| Status | Condition |
|---|---|
400 | content_type not provided |
401 | Missing or invalid API key |
404 | Blob 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:
| Parameter | Type | Description |
|---|---|---|
bucket_name | string | Bucket containing the blob |
key | string | Object key to delete |
Conditional headers:
| Header | Effect |
|---|---|
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:
| Status | Condition |
|---|---|
401 | Missing or invalid API key |
402 | Pearl-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 |
404 | Blob not found |
412 | If-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
| Limit | Value |
|---|---|
| Max tags per blob | 10 |
| Max tag key length | 128 bytes |
| Max tag value length | 256 bytes |
| Max total set size | 2048 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"
}
}
| Field | Type | Description |
|---|---|---|
tags | object | Map 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}.