Walrus Console API Quickstart

Alpha. Testnet only. Endpoint shapes may change before mainnet GA.

A "hello world" tour of the Walrus Console API: sign up, create a Seal-encrypted bucket, then upload + download a file. In alpha, all bucket creation goes through the private (Seal-encrypted) flow — public bucket creation is disabled at the API boundary.

Hosted versions. This guide is also served live from the API:


1. Hello world — sign up via zkLogin

  1. Visit testnet.console.walrus.xyz/ and sign in with Google (via zkLogin) — or a Sui wallet, if you prefer. Your account and a Personal Space are provisioned automatically.

  2. Open Settings → API Keys → Create API Key, name the key, pick a Permissions option, submit.

    Pick read_write if you intend to follow §2 end-to-end; pick read_only only for read-side integrations.

  3. On the reveal screen, copy the hbr_… Walrus Console API key. It is shown once — Walrus Console cannot recover it afterwards. Store it like an AWS secret access key.

  4. (Optional) Import the curated Postman pair into Postman Desktop. Download them from the public quickstart repo:

    Paste your hbr_… key into the bearerToken env variable. baseUrl defaults to https://api.testnet.console.walrus.xyz.

Every request below carries Authorization: Bearer hbr_….


2. Create an encrypted bucket and upload a file

service key setup → get space → reserve → sign → finalize →
encrypt → upload → poll → download → decrypt

Buckets are Seal-encrypted client-side — Walrus Console stores ciphertext only and never sees plaintext or decryption material. Creation goes through a reserve → sign → finalize handshake, with a one-time service-key setup beforehand and a local decrypt step on download.

You will end §2.1 holding two secrets: an hbr_… Walrus Console API key (sent as Authorization: Bearer … on every request) and a suiprivkey1… service private key (kept locally; used to sign the finalize transaction and to authenticate decrypt sessions with Seal). Both are shown once on the reveal screen — store them like AWS access keys.

1. One-time setup — API key with Read & Write

In Settings → API Keys → Create API Key, pick Read & Write (read_write). Private-bucket creation, finalize, and uploads are all writes — Read-only keys can't perform them. The reveal screen now exposes two secrets:

Both are shown once. Paste them into Postman as bearerToken and consoleServicePrivateKey, or stash them in your .env.

2. Get your space id

GET /api/v1/spaces

The response's data[] array contains your spaces; copy the id of the Personal Space created during sign-up. (Postman: alpha (bearer) / spaces (read) / List spaces.)

3. Reserve the bucket

POST /api/v1/spaces/{spaceId}/buckets
Content-Type: application/json

{ "name": "secrets", "scope": "private" }

Response (201):

{
  "bucket_id": "…",
  "bytes": "<base64 Enoki-sponsored Sui tx>",
  "digest": "…",
  "provisioning_state": "pending_policy"
}

bytes is the Enoki-sponsored Sui transaction that creates the bucket's Seal access policy, with your service key's address set as the sender. Walrus Console cannot sign it for you — that is the whole point of client-side encryption — but it has already attached the gas sponsor's signature, so your service key just needs to add its own signature. digest is the Enoki sponsor digest; Walrus Console uses it server-side at finalize to look up the sponsored tx. The bucket row stays in pending_policy until the Finalize call below succeeds — until then no files can be uploaded to it.

Sponsor signatures expire fast. Treat reserve → sign → finalize as a single tight sequence. If you stall between reserve and finalize, finalize returns {"code":"digest_expired"}. Re-run reserve to get fresh bytes and try again. The Postman collection handles this by auto-signing in the Reserve request's post-response script.

Bucket names are unique per space. A 409 on reserve means the name is taken — either by a live bucket or by one stuck in pending_policy from a previous aborted reserve. Pick a new name (e.g. append a Unix timestamp), or clean up the stale bucket: list with GET /api/v1/spaces/{id}/buckets, filter state == "pending_policy", then DELETE /api/v1/buckets/{id}?confirm=true on each. confirm=true is required. A bucket that still holds files is refused with bucket_not_empty and a file_count; add &deleteContents=true to delete them along with it.

4. Sign bytes with the service key

Use @mysten/sui — it handles the Bech32 decode and the Sui signature envelope (0x00 || sig || pubKey) for you:

import { decodeSuiPrivateKey } from '@mysten/sui/cryptography';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { fromBase64 } from '@mysten/sui/utils';

const { secretKey } = decodeSuiPrivateKey(process.env.HARBOR_SERVICE_PRIVKEY);
const keypair = Ed25519Keypair.fromSecretKey(secretKey);
const { signature } = await keypair.signTransaction(fromBase64(bytes));

5. Finalize

POST /api/v1/buckets/{bucketId}/finalize
Content-Type: application/json

{ "signature": "<base64 signature from step 4>" }

Walrus Console combines your signature with Enoki's gas-sponsor signature and broadcasts the transaction. Response (200):

{ "bucket_id": "…", "seal_policy_id": "…", "provisioning_state": "active" }

seal_policy_id is the on-chain bucket-group object id used by Seal for access checks. The bucket is now usable.

6. Encrypt the file with Seal

Private buckets store Seal ciphertext only — Walrus Console never sees plaintext. Encrypt locally with @mysten/seal against the seal_policy_id returned by Finalize:

import { SealClient } from '@mysten/seal';
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { bcs } from '@mysten/sui/bcs';

// Walrus Console's bucket-policy package + Seal's three testnet key servers (threshold 2).
// `ORIGINAL` here means the *original-id* of the upgradeable package
// (its original/canonical published id). Seal pins identity
// derivation to the original/canonical package id, so encrypt MUST use this
// value even after the package has been upgraded — otherwise an upgrade would
// invalidate every previously-encrypted blob's DEK.
const HARBOR_ORIGINAL_PACKAGE_ID =
  '0xf9b261d4c0dbcf845d79f864e85581f9686fd6de9f4770ba1d77489d67f7833c';
const SEAL_KEY_SERVER_OBJECT_IDS = [
  '0x6068c0acb197dddbacd4746a9de7f025b2ed5a5b6c1b1ab44dade4426d141da2',
  '0x164ac3d2b3b8694b8181c13f671950004765c23f270321a45fdd04d40cccf0f2',
  '0x9c949e53c36ab7a9c484ed9e8b43267a77d4b8d70e79aa6b39042e3d4c434105',
];

const sui = new SuiGrpcClient({
  network: 'testnet',
  baseUrl: 'https://fullnode.testnet.sui.io:443',
});
const seal = new SealClient({
  suiClient: sui,
  serverConfigs: SEAL_KEY_SERVER_OBJECT_IDS.map((objectId) => ({ objectId, weight: 1 })),
  // Pass this explicitly. The SDK's own default changed between 1.1 (true) and
  // 1.4 (false), so omitting it means different behaviour on different installs.
  verifyKeyServers: false,
});

// Each file's Seal id = (bucket policy id, 32 random bytes). The bcs serializer
// matches Walrus Console's on-chain `seal_approve` check.
const SealIdentity = bcs.struct('SealIdentity', {
  policyObjectId: bcs.Address,
  nonce: bcs.fixedArray(32, bcs.u8()),
});
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)));
const id = SealIdentity.serialize({ policyObjectId: sealPolicyId, nonce }).toHex();

const { encryptedObject } = await seal.encrypt({
  threshold: 2,
  packageId: HARBOR_ORIGINAL_PACKAGE_ID,
  id,
  data: plaintextBytes, // Uint8Array
});

encryptedObject is the byte stream you upload next.

About verifyKeyServers

It is worth knowing what you are turning off, because the name suggests more than it does.

verifyKeyServers: true runs one proof-of-possession check per key server, the first time the server list loads: the SDK GETs <server url>/v1/service, checks the service_id it returns, and verifies a BLS signature against the public key recorded in that server's on-chain object. It proves the host answering the URL holds the key the chain says it should. The shares you later receive are checked against that same on-chain key either way, on both settings (verifyUserSecretKey, inside fetchKeys), so this flag adds only the host attestation. It never runs for a Seal committee server, which the SDK skips because a committee has no single host to attest.

false skips that check. The trade-off is availability, not convenience: the check runs across all servers at once and any single failure rejects the whole client, so an unreachable server, an expired or self-signed certificate, or a rotated key stops you encrypting at all rather than degrading. That is why this quickstart leaves it off against the shared testnet servers above, which are operated by third parties.

Walrus Console's own app passes true in production, but that buys it nothing today: it uses a single committee server, which is the case the check skips. The value is that the flag is already armed if a non-committee server is ever added.

Turn it on once your key servers are ones you operate, or ones you are willing to have your uploads depend on.

7. Upload — retry on mirror_missing_grant

POST /api/v1/buckets/{bucketId}/files
Content-Type: multipart/form-data

file=@<encryptedObject>

Standard multipart upload, but the on-chain grant your key gets from Finalize — BucketEditor for a readwrite key, BucketViewer for a read key — needs a few seconds to land in Walrus Console's ACL indexer. Until then this endpoint returns 403 with code: "mirror_missing_grant". Retry every ~3 seconds; ≤20 attempts is plenty in practice. Once the grant mirrors, the response is 202 with data.id.

8. Poll status

GET /api/v1/buckets/{bucketId}/files/{fileId}/status

Returns { "data": { "state": "queued" | "active" | "completed" | "failed" } }. Poll every second or two until state === "completed". Typical completion is under 30 seconds on testnet.

Writing a CI job or an agent? Programmatic clients covers the upload preflight, delete states and when storage is released.

9. Download → decrypt locally

GET /api/v1/buckets/{bucketId}/files/{fileId}/download

Follow redirects. On deployments that serve user content from its own hostname, this answers 307 to that host rather than returning bytes, so use curl -L or an HTTP client with redirects enabled. The redirect target is single-use and short-lived; mint your own with POST .../download-url if you need a link that lasts.

Returns the raw Seal ciphertext. Decrypt with @mysten/seal by building the bucket's seal_approve access-check PTB (signed by your service key via a SessionKey) and feeding both the ciphertext and the PTB to SealClient.decrypt:

import { EncryptedObject, SealClient, SessionKey } from '@mysten/seal';
import { Transaction } from '@mysten/sui/transactions';
import { decodeSuiPrivateKey } from '@mysten/sui/cryptography';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { fromHex } from '@mysten/sui/utils';

// Latest Walrus Console bucket-policy package — host of the `seal_approve` move call.
const HARBOR_LATEST_PACKAGE_ID =
  '0xf9b261d4c0dbcf845d79f864e85581f9686fd6de9f4770ba1d77489d67f7833c';
// Shared BucketRegistry — `seal_approve` reads its pause + version state.
const HARBOR_BUCKET_REGISTRY_ID =
  '0x902841af0cd25c5f8dee4980fe2942687c9ca80db56d77ff67a4ba6d9d97b9cf';

const { secretKey } = decodeSuiPrivateKey(process.env.HARBOR_SERVICE_PRIVKEY);
const keypair = Ed25519Keypair.fromSecretKey(secretKey);

// ciphertext = bytes from GET /download
const parsed = EncryptedObject.parse(ciphertext);
const idBytes = fromHex(parsed.id.startsWith('0x') ? parsed.id : '0x' + parsed.id);

// 1. Build the access-check PTB (TransactionKind only — never broadcast).
const tx = new Transaction();
tx.moveCall({
  target: `${HARBOR_LATEST_PACKAGE_ID}::bucket_policy::seal_approve`,
  arguments: [
    tx.pure.vector('u8', idBytes),
    tx.object(HARBOR_BUCKET_REGISTRY_ID),
    tx.object(sealPolicyId),
  ],
});
const txBytes = await tx.build({ client: sui, onlyTransactionKind: true });

// 2. SessionKey lets Seal key servers verify the caller without re-signing per request.
const sessionKey = await SessionKey.create({
  address: keypair.toSuiAddress(),
  packageId: HARBOR_ORIGINAL_PACKAGE_ID,
  ttlMin: 10,
  suiClient: sui,
  signer: keypair,
});

// 3. Decrypt — SealClient fetches threshold key shares and reconstructs the DEK locally.
const plaintext = await seal.decrypt({ data: ciphertext, sessionKey, txBytes });

Walrus Console's API surface stops at the ciphertext byte stream — decryption is fully client-side and never touches Walrus Console's backend.


3. When a key stops working

A key that stops authenticating gets a 401 whose code says why:

code What happened What to do
api_key_revoked The key was revoked. Create a new key in Console.
api_key_rotation_incomplete The key was revoked by a rotation that has not finished. The key's creator resumes the rotation under Settings → Integrations.
api_key_replaced The key was rotated and a new key replaces it. Install the new hbr_… key and service private key.

Rotating revokes the old key before the new one is shown, so there is no window where both work.


4. Filing issues

Open an issue at github.com/MystenLabs/walrus-console-quickstart/issues with the label developer-docs.

Please include:

For the full machine-readable surface, see /openapi.yaml (curated, Bearer-only, 16 endpoints), or browse it interactively at /docs/openapi.