Admin API

The Admin API lets app operators manage accounts, API keys, and S3 access keys. All admin endpoints require admin-key authentication (long-lived per-app Bearer tokens issued through oysterd app issue-admin-key; see Authentication).

An app can only manage accounts it created. Attempting to access another app's accounts returns 403 Forbidden.

Accounts

Create Account

POST /api/v1/accounts

Creates a new account owned by the authenticated app. An initial API key is generated automatically.

Request body (optional):

{
  "name": "my-app-user",
  "max_unencoded_bytes": 5000000000,
  "avg_blob_size": 10000000
}
FieldTypeRequiredDescription
namestringnoHuman-readable account name; defaults to the account ID if omitted
max_unencoded_bytesintegernoPer-account storage cap, in unencoded bytes. Defaults to 5_000_000_000 (5 × 10⁹) when omitted. Must be strictly positive; 0 and negative values are rejected with 400
avg_blob_sizeintegernoAssumed average blob size, in unencoded bytes. Turns max_unencoded_bytes into a lower bound on storable capacity for blobs of this size (see Lower-bound semantics below). Defaults to the server's OYSTER_DEFAULT_AVG_BLOB_SIZE (10 MB) when omitted. 0 disables inflation (the historical upper-bound behavior); negative values are rejected with 400; an oversized value is accepted as a silent no-op

Example:

curl -s -X POST \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-app-user"}' \
  "$OYSTER_URL/api/v1/accounts" | jq

Response (201 Created):

{
  "account_id": "550e8400-e29b-41d4-a716-446655440000",
  "api_key": {
    "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
    "prefix": "a1b2c3d4",
    "bearer_token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
    "created_at": "2025-01-15T10:30:00Z"
  }
}
FieldTypeDescription
account_idstringUUID of the new account
api_key.idstringUnique key identifier
api_key.prefixstringFirst 8 characters of the raw key (for identification)
api_key.bearer_tokenstringThe full API key, shown only once
api_key.created_atstringISO 8601 timestamp

The bearer_token is returned only at creation time. A lost key cannot be recovered; create a new one instead.

Errors:

StatusCondition
400max_unencoded_bytes must be a positive integer, or avg_blob_size is negative
401Missing or invalid admin key

Update Storage Cap

PUT /api/v1/accounts/{account_id}/max-storage

Raises or lowers the per-account max_unencoded_bytes cap. Lowering the cap below the account's current onchain encoded usage is rejected; lowering between current usage and current pool capacity submits an onchain shrink transaction to release the freed reserve back to the Pearl-derived wallet.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account

Request body:

{ "max_unencoded_bytes": 10000000000, "avg_blob_size": 10000000 }
FieldTypeRequiredDescription
max_unencoded_bytesintegeryesNew per-account cap, in unencoded bytes. Must be strictly positive
avg_blob_sizeintegernoNew assumed average blob size, in unencoded bytes (see Lower-bound semantics). When omitted, the account's existing avg_blob_size is retained and the orphan/shrink threshold is recomputed against it. 0 disables inflation; negative values are rejected with 400; an oversized value is a silent no-op

Example:

curl -s -X PUT \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_unencoded_bytes": 10000000000}' \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/max-storage" | jq

Response (200 OK):

{
  "account_id": "550e8400-e29b-41d4-a716-446655440000",
  "max_unencoded_bytes": 10000000000,
  "avg_blob_size": 10000000,
  "pool": {
    "reserved_encoded_bytes": 8000000000,
    "used_encoded_bytes": 4123456789
  },
  "shrink_tx_digest": "5jK...digest"
}
FieldTypeDescription
account_idstringThe account whose cap was updated
max_unencoded_bytesintegerThe new cap, in unencoded bytes
avg_blob_sizeintegerThe effective assumed average blob size after the update. Echoes the request value when supplied, otherwise the account's retained value
poolobject or nullOnchain StoragePool snapshot after the (optional) shrink. null when the account has never lazy-created a pool (DB-only fast path; no onchain read was performed)
pool.reserved_encoded_bytesintegerstorage.storage_size: encoded bytes reserved by the pool
pool.used_encoded_bytesintegerEncoded bytes currently consumed by registered blobs
shrink_tx_digeststring or nullDigest of the submitted decrease_storage_pool_capacity_by_size PTB, or null when no shrink was needed

When the account has no pool yet (no upload has lazy-created one), the cap is updated in the DB only (pool and shrink_tx_digest are both null). When the new cap covers the existing pool's reserved bytes, the same DB-only path runs (shrink_tx_digest is null, pool is populated from the onchain read).

Lower-bound semantics (avg_blob_size)

max_unencoded_bytes is stated in unencoded bytes, but Walrus tracks usage in encoded bytes, and the encoding f carries a large fixed per-blob metadata overhead. By default (avg_blob_size = 0) the cap therefore acts as an upper bound: an account storing many small blobs pays that overhead per blob and hits the cap well below its stated unencoded budget.

Setting avg_blob_size = s flips this into a lower bound for blobs averaging s: Oyster inflates the encoded admission ceiling by the per-blob expansion factor f(s)/s, guaranteeing that at least max_unencoded_bytes unencoded bytes are storable when the account's blobs average ≥ s. The expansion factor shrinks as blobs grow: roughly 66034× at 1 KB, 70× at 1 MB, 11× at 10 MB, 5.1× at 100 MB, asymptoting to ~4.5×. A 10 MB avg_blob_size (the default) sets the ceiling at about 11 × the cap's bare encoded value. Blobs smaller than s carry more overhead and so reach the ceiling before the unencoded total reaches max_unencoded_bytes.

This only raises the admission ceiling; it does not pre-reserve or pre-pay capacity. Onchain pool capacity still grows incrementally per upload (pay-as-you-go for actual encoded usage), so a non-zero avg_blob_size costs nothing for normal workloads. Setting avg_blob_size = 0 reproduces the historical upper-bound behavior byte-for-byte; accounts created before this feature default to 0.

Onchain shrink semantics. When the new cap is lower than the pool's current reserved capacity and at least one encoded byte can be freed without orphaning data, Oyster submits a Pearl-signed system::decrease_storage_pool_capacity_by_size PTB. The contract extracts a Storage object covering the freed bytes and transfers it back to the pool's owner (the Pearl-managed sender). Over time these extracted Storage objects accumulate in the wallet1.

1

Tooling for absorbing accumulated Storage objects back into a pool is planned but not yet present. See the project's PLAN.md for the "Recycle orphaned Storage objects" item.

Errors:

StatusCondition
400Body invalid (max_unencoded_bytes ≤ 0 or avg_blob_size < 0), would_orphan (the new cap is below the account's current onchain encoded usage), or shrink_aborted (a concurrent upload re-consumed the freed capacity between the onchain read and the PTB submission)
401Missing or invalid admin key
403Account does not belong to the authenticated app
404Account not found
503Pearl or Sui RPC unavailable while performing the onchain read or PTB submission

would_orphan body: emitted when lowering would drop the cap below current onchain usage:

{
  "error": "max-storage update would orphan stored data: ...",
  "would_orphan": {
    "max_unencoded_bytes": 1000000000,
    "used_encoded_bytes": 4123456789,
    "threshold_encoded": 1500000000
  }
}

shrink_aborted body: emitted when the shrink PTB aborted because another replica's upload raced and re-consumed the capacity Oyster was about to extract:

{
  "error": "max-storage shrink aborted: ...",
  "shrink_aborted": {
    "move_abort_description": "EInsufficientCapacity in storage_pool::extract_storage",
    "max_unencoded_bytes": 2000000000,
    "extract_size": 1234567890
  }
}

Both would_orphan and shrink_aborted are safe to retry after the underlying state has settled (for example, delete some blobs to lower used_encoded_bytes, or wait for the concurrent upload to finish).

A successful cap change writes an account.max_storage_updated audit event.

API Keys

Create API Key

POST /api/v1/accounts/{account_id}/api-keys

Creates a new API key for an existing account.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account

Example:

curl -s -X POST \
  -H "Authorization: Bearer $ADMIN_KEY" \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/api-keys" | jq

Response (201 Created):

{
  "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
  "prefix": "a1b2c3d4",
  "bearer_token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
  "created_at": "2025-01-15T10:30:00Z"
}
FieldTypeDescription
idstringUnique key identifier
prefixstringFirst 8 characters of the raw key
bearer_tokenstringThe full API key, shown only once
created_atstringISO 8601 timestamp

Errors:

StatusCondition
401Missing or invalid admin key
403Account does not belong to the authenticated app
404Account not found

Revoke API Key

DELETE /api/v1/accounts/{account_id}/api-keys/{key_id}

Revokes an API key. The key immediately stops working for authentication.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account
key_idstringID of the API key to revoke

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $ADMIN_KEY" \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/api-keys/b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"

Response: 204 No Content

Errors:

StatusCondition
401Missing or invalid admin key
403Account does not belong to the authenticated app
404API key not found or already revoked

S3 Access Keys

These endpoints manage S3-compatible access keys for accounts. See S3 Access Keys for key format details and limits.

Create Access Key

POST /api/v1/accounts/{account_id}/access-keys

Creates a new S3 access key pair. The secret is returned only once, so save it immediately. Each account can have up to 3 active access keys.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account

Example:

curl -s -X POST \
  -H "Authorization: Bearer $ADMIN_KEY" \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/access-keys" | jq

Response (201 Created):

{
  "access_key_id": "OYAK1234567890ABCDEF",
  "secret_access_key": "abcdef1234567890abcdef1234567890abcdef12",
  "created_at": "2025-01-15T10:30:00Z"
}
FieldTypeDescription
access_key_idstring20-character key ID (starts with OYAK)
secret_access_keystring40-character hex secret, shown only once
created_atstringISO 8601 timestamp

Errors:

StatusCondition
401Missing or invalid admin key
403Account does not belong to the authenticated app
404Account not found
409Access key limit reached (max 3 active keys)

List Access Keys

GET /api/v1/accounts/{account_id}/access-keys

Returns all S3 access keys for the account, including revoked ones. Secrets are never included in list responses.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account

Example:

curl -s \
  -H "Authorization: Bearer $ADMIN_KEY" \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/access-keys" | jq

Response (200 OK):

[
  {
    "access_key_id": "OYAK1234567890ABCDEF",
    "created_at": "2025-01-15T10:30:00Z",
    "revoked_at": null
  },
  {
    "access_key_id": "OYAKFEDCBA0987654321",
    "created_at": "2025-01-10T08:00:00Z",
    "revoked_at": "2025-01-14T12:00:00Z"
  }
]
FieldTypeDescription
access_key_idstring20-character key ID
created_atstringISO 8601 timestamp
revoked_atstring or nullISO 8601 timestamp if revoked, null if active

Errors:

StatusCondition
401Missing or invalid admin key
403Account does not belong to the authenticated app
404Account not found

Revoke Access Key

DELETE /api/v1/accounts/{account_id}/access-keys/{access_key_id}

Revokes an S3 access key. Any S3 requests using this key stop working immediately. Revoked keys no longer count toward the 3-key active limit.

Path parameters:

ParameterTypeDescription
account_idstringUUID of the account
access_key_idstringThe 20-character access key ID to revoke

Example:

curl -s -X DELETE \
  -H "Authorization: Bearer $ADMIN_KEY" \
  "$OYSTER_URL/api/v1/accounts/550e8400-e29b-41d4-a716-446655440000/access-keys/OYAK1234567890ABCDEF"

Response: 204 No Content

Errors:

StatusCondition
401Missing or invalid admin key
403Account does not belong to the authenticated app
404Access key not found or already revoked

App

Get App

GET /api/v1/admin/app

Returns the authenticated app, including the current webhook URL and the base64-encoded Ed25519 public key paired with it. Use this endpoint when you need to retrieve the response from PUT /admin/app/webhook.

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "my-app",
  "contact_email": "admin@example.com",
  "webhook_url": "https://example.com/oyster/webhook",
  "webhook_public_key": "base64-encoded-32-byte-key",
  "created_at": "2025-01-15T10:30:00Z"
}
FieldTypeDescription
webhook_urlstring or nullCurrently configured webhook URL, or null when none
webhook_public_keystring or nullBase64-encoded 32-byte Ed25519 public key, or null when no webhook is configured

Errors:

StatusCondition
401Missing or invalid admin key

Set Webhook URL

PUT /api/v1/admin/app/webhook

Registers or rotates the webhook URL for the authenticated app. Each call generates a fresh Ed25519 keypair; the response is the only opportunity to capture the public key for verification. Subsequent deliveries are signed with the corresponding private key. See Webhooks for the signature format.

Request body:

{ "webhook_url": "https://example.com/oyster/webhook" }
FieldTypeRequiredDescription
webhook_urlstringyesReceiver URL. Must be https://, ≤ 2048 chars, must not embed credentials, must have a host

Response (200 OK): same shape as GET /admin/app above.

Errors:

StatusCondition
400Webhook URL invalid (bad scheme, embedded credentials, oversize, host-less, malformed)
401Missing or invalid admin key

Clear Webhook URL

DELETE /api/v1/admin/app/webhook

Clears the webhook URL and discards the keypair. Subsequent extension failures do not deliver a webhook.

Response (200 OK): the updated app row with all three webhook fields nulled.

Errors:

StatusCondition
401Missing or invalid admin key

Server Commands

oysterd is the server binary. Besides the oysterd app subcommands below (for managing apps and admin keys), it runs the service itself.

Running the Server

oysterd serve     # run the HTTP + S3 server (default when no subcommand given)
oysterd extend     # run the background blob-extension service only

oysterd serve is the default; running oysterd with no subcommand is equivalent. oysterd extend runs only the background task that renews expiring storage pools (see Blob Lifecycle), without serving the API.

The global --pearl-service-secret-file <PATH> flag reads the Pearl service secret from a file instead of the PEARL_SERVICE_SECRET environment variable, which is useful for mounting the secret as a file (for example, a Kubernetes secret). It applies to any oysterd invocation.

Both services are otherwise configured through environment variables; see the README for the full env-var reference.

Create App

oysterd app new --name <NAME> --contact_email <EMAIL> [--no-key]

Creates a new app, prints its UUID, and (by default) auto-issues a first admin key alongside. Webhook URLs are configured by the app builder using the self-service PUT /admin/app/webhook endpoint above (or oyster app webhook set <URL>).

FlagRequiredDescription
--nameyesHuman-readable app name
--contact_emailyesContact email for the app owner
--no-keynoSkip the auto-issued first admin key

Example:

oysterd app new --name "my-app" --contact_email "admin@example.com"
# 550e8400-e29b-41d4-a716-446655440000
# 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

Issue Admin Key

oysterd app issue-admin-key <app_id>

Generates a fresh admin key for the given app. Multiple admin keys per app are supported with no cap; use this for AWS-style two-key rotation.

stdout carries the raw admin key as a single line; this is the only machine-readable output, suitable for capturing in a variable or piping. The key id and 8-char prefix are not printed to stdout; they appear in a tracing::info! structured log line (fields app_id, key_id, prefix, message issued admin key) written to stderr. That line is emitted at the info level, so it shows with the default log filter but is suppressed if RUST_LOG raises the threshold above info. It is a human-readable log line, not a stable machine-readable string. To recover a key id reliably, use List Admin Keys.

Example:

oysterd app issue-admin-key 550e8400-e29b-41d4-a716-446655440000
# stdout: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# stderr (info log): ... issued admin key app_id=550e8400-... key_id=<key_id> prefix=01234567

The printed key can be used directly in the Authorization header:

curl -H "Authorization: Bearer $(oysterd app issue-admin-key $APP_ID)" ...

List Admin Keys

oysterd app list-admin-keys <app_id>

Lists all admin keys for the given app in tab-separated format, including revoked ones (so an operator can confirm what is currently live). There is no header row; columns are id, prefix, created_at, revoked_at. The revoked_at column is the empty string for active keys (so a line for an active key ends with a trailing tab).

Example output (→ marks a tab):

b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e→01234567→2026-04-15T10:30:00Z→
a1b2c3d4-e5f6-7890-abcd-ef0123456789→89abcdef→2026-03-01T08:00:00Z→2026-04-15T10:31:00Z

Revoke Admin Key

oysterd app revoke-admin-key <key_id>

Marks an admin key as revoked. Subsequent requests using that key are rejected with 401. Revocation is by key_id (globally unique), not by the raw key value.

Example:

oysterd app revoke-admin-key b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e

List Apps

oysterd app list

Lists all registered apps in tab-separated format.

Example output:

ID	NAME	CONTACT_EMAIL
550e8400-e29b-41d4-a716-446655440000	my-app	admin@example.com