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:
- Rendered HTML:
/docs/quickstart- Raw markdown (LLM/
curl-friendly):/docs/quickstart.md- OpenAPI viewer (Scalar):
/docs/openapi- OpenAPI spec (raw):
/openapi.yaml·/openapi.json- Docs index:
/docs
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.
Open Settings → API Keys → Create API Key, name the key, pick a Permissions option, submit.
read_write — required for any state change: create/delete
buckets, upload/rename/delete files, finalize private buckets.read_only — listing, status, download only. Every write
endpoint returns 403 read_only_api_key for these keys; pick this
scope when handing a key to a downstream consumer that should not
mutate your data.Pick read_write if you intend to follow §2 end-to-end; pick
read_only only for read-side integrations.
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.
(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_….
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 asAuthorization: Bearer …on every request) and asuiprivkey1…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.
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:
hbr_… — the Walrus Console API key (same as section 1).suiprivkey1… — the service private key, an Ed25519 Base64 secret
in Sui keytool format. Bound to this API key; Walrus Console stores only the
derived public address. Does not need any SUI balance — gas is
sponsored by Walrus Console via Enoki.Both are shown once. Paste them into Postman as bearerToken and
consoleServicePrivateKey, or stash them in your .env.
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.)
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 freshbytesand 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_policyfrom a previous aborted reserve. Pick a new name (e.g. append a Unix timestamp), or clean up the stale bucket: list withGET /api/v1/spaces/{id}/buckets, filterstate == "pending_policy", thenDELETE /api/v1/buckets/{id}?confirm=trueon each.confirm=trueis required. A bucket that still holds files is refused withbucket_not_emptyand afile_count; add&deleteContents=trueto delete them along with it.
bytes with the service keyUse @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));
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.
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.
verifyKeyServersIt 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.
mirror_missing_grantPOST /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.
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.
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.
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.
Open an issue at
github.com/MystenLabs/walrus-console-quickstart/issues
with the label developer-docs.
Please include:
code field from the error response (if any)For the full machine-readable surface, see
/openapi.yaml (curated, Bearer-only, 16 endpoints), or
browse it interactively at /docs/openapi.