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
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | no | Human-readable account name; defaults to the account ID if omitted |
max_unencoded_bytes | integer | no | Per-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_size | integer | no | Assumed 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"
}
}
| Field | Type | Description |
|---|---|---|
account_id | string | UUID of the new account |
api_key.id | string | Unique key identifier |
api_key.prefix | string | First 8 characters of the raw key (for identification) |
api_key.bearer_token | string | The full API key, shown only once |
api_key.created_at | string | ISO 8601 timestamp |
The
bearer_tokenis returned only at creation time. A lost key cannot be recovered; create a new one instead.
Errors:
| Status | Condition |
|---|---|
400 | max_unencoded_bytes must be a positive integer, or avg_blob_size is negative |
401 | Missing 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID of the account |
Request body:
{ "max_unencoded_bytes": 10000000000, "avg_blob_size": 10000000 }
| Field | Type | Required | Description |
|---|---|---|---|
max_unencoded_bytes | integer | yes | New per-account cap, in unencoded bytes. Must be strictly positive |
avg_blob_size | integer | no | New 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"
}
| Field | Type | Description |
|---|---|---|
account_id | string | The account whose cap was updated |
max_unencoded_bytes | integer | The new cap, in unencoded bytes |
avg_blob_size | integer | The effective assumed average blob size after the update. Echoes the request value when supplied, otherwise the account's retained value |
pool | object or null | Onchain 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_bytes | integer | storage.storage_size: encoded bytes reserved by the pool |
pool.used_encoded_bytes | integer | Encoded bytes currently consumed by registered blobs |
shrink_tx_digest | string or null | Digest 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.
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:
| Status | Condition |
|---|---|
400 | Body 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) |
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | Account not found |
503 | Pearl 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID 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"
}
| Field | Type | Description |
|---|---|---|
id | string | Unique key identifier |
prefix | string | First 8 characters of the raw key |
bearer_token | string | The full API key, shown only once |
created_at | string | ISO 8601 timestamp |
Errors:
| Status | Condition |
|---|---|
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | Account 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID of the account |
key_id | string | ID 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:
| Status | Condition |
|---|---|
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | API 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID 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"
}
| Field | Type | Description |
|---|---|---|
access_key_id | string | 20-character key ID (starts with OYAK) |
secret_access_key | string | 40-character hex secret, shown only once |
created_at | string | ISO 8601 timestamp |
Errors:
| Status | Condition |
|---|---|
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | Account not found |
409 | Access 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID 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"
}
]
| Field | Type | Description |
|---|---|---|
access_key_id | string | 20-character key ID |
created_at | string | ISO 8601 timestamp |
revoked_at | string or null | ISO 8601 timestamp if revoked, null if active |
Errors:
| Status | Condition |
|---|---|
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | Account 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:
| Parameter | Type | Description |
|---|---|---|
account_id | string | UUID of the account |
access_key_id | string | The 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:
| Status | Condition |
|---|---|
401 | Missing or invalid admin key |
403 | Account does not belong to the authenticated app |
404 | Access 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"
}
| Field | Type | Description |
|---|---|---|
webhook_url | string or null | Currently configured webhook URL, or null when none |
webhook_public_key | string or null | Base64-encoded 32-byte Ed25519 public key, or null when no webhook is configured |
Errors:
| Status | Condition |
|---|---|
401 | Missing 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" }
| Field | Type | Required | Description |
|---|---|---|---|
webhook_url | string | yes | Receiver 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:
| Status | Condition |
|---|---|
400 | Webhook URL invalid (bad scheme, embedded credentials, oversize, host-less, malformed) |
401 | Missing 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:
| Status | Condition |
|---|---|
401 | Missing 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>).
| Flag | Required | Description |
|---|---|---|
--name | yes | Human-readable app name |
--contact_email | yes | Contact email for the app owner |
--no-key | no | Skip 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