Quick Start
Go from sign-up to a working encrypted upload: create an account, mint an API key, then create a Seal-encrypted folder and upload, download, and decrypt a file.
The Walrus Console developer API is at api.console.walrus.xyz. All folder creation goes through the private, Seal-encrypted flow. For the full endpoint surface, see the API reference. For the product model, see the concepts and overview.
Prerequisites
- Prerequisites
- A Google or Apple account for sign-in.
- Node.js 22 or later, with
@mysten/sui,@mysten/seal, andtsxinstalled, for the signing and encryption steps.
Every request below carries your API key as a bearer token:
Authorization: Bearer hbr_…
Sign up and create an API key
-
Visit the Walrus Console app and sign in with Google or Apple. zkLogin provisions your account and a Personal Space automatically.
-
Open Integrations, then click Create API Key in the top-right corner. Give the key a name and choose API key. The other type, Management API key, only mints further API keys programmatically through the CLI; it cannot upload, download, or manage assets, so the steps below need a plain API key. The choice is not permanent, so you can create a Management API key later if you need one.
-
On the reveal screen, copy both secrets. Console shows them once and cannot recover them afterward:
hbr_…, the API key you send asAuthorization: Bearer …on every request.suiprivkey1…, the service private key: an Ed25519 secret in Sui keytool format, bound to this API key. Console stores only the derived public address, and it does not need a token balance. You use it to sign the finalize transaction and to authenticate decrypt sessions with Seal.
Store both like cloud secret access keys. Paste them into your
.envasCONSOLE_API_KEYandCONSOLE_SERVICE_PRIVATE_KEY, or into Postman. The reveal screen also shows aCONSOLE_CREDENTIAL_BUNDLEvalue, which the MCP server installer accepts as a single paste.
A read_only key (listing, status, and download only) is also available for downstream consumers that should not change your data; every write endpoint returns 403 with code read_only_api_key for those keys. The encrypted flow below needs a write-capable key and both secrets above.
Create an encrypted folder and upload a file
Private folders are encrypted client-side, so Console stores ciphertext only and never sees your plaintext or decryption material. With your key in hand, creation goes through a reserve, sign, and finalize handshake, then a local encrypt on upload and decrypt on download:
get space → reserve → sign → finalize → encrypt → upload → poll → download → decrypt
Step 1: Get your space ID
GET /api/v1/spaces
The response's data[] array holds your spaces. Copy the id of the Personal Space created at sign-up.
Step 2: Reserve the folder
POST /api/v1/spaces/{spaceId}/buckets
Content-Type: application/json
{ "name": "secrets", "scope": "private" }
Response (201):
{
"bucket_id": "…",
"bytes": "<base64 sponsored Sui transaction>",
"digest": "…",
"provisioning_state": "pending_policy",
"owner_address": "0x…",
"admin_signer_address": null
}
bytes is the sponsored Sui transaction that creates the folder's Seal access policy, with your service key's address as the sender. Console has already attached the gas sponsor's signature, so your service key only needs to add its own. digest is the sponsor digest, which Console uses at finalize to look up the sponsored transaction. The folder stays in pending_policy until finalize succeeds. Until then, the file endpoints return 404 for it.
The helper code
The signing, encryption, and decryption steps below share two files. config.ts holds the Mainnet constants: the Console API host, the Sui full node, the bucket-policy package IDs, the bucket registry, and the Seal committee object. seal.ts wraps the signing and Seal encrypt and decrypt helpers. Put both in a working directory next to a .env file that sets CONSOLE_API_KEY and CONSOLE_SERVICE_PRIVATE_KEY.
export const API_BASE = 'https://api.console.walrus.xyz';
export const SUI_NETWORK = 'mainnet' as const;
export const FULLNODE_URL = 'https://fullnode.mainnet.sui.io:443';
// Original published ID of Console's bucket-policy package. Seal derives
// identities from it, so it must not change on package upgrade.
export const ORIGINAL_PACKAGE_ID =
'0xb8d5b1cade7917190c47b8abfc789f527389fc021a8963c22755bcc1b539786c';
// Latest published ID of the same package. The `seal_approve` move call
// targets this ID during decrypt.
export const LATEST_PACKAGE_ID =
'0xb8d5b1cade7917190c47b8abfc789f527389fc021a8963c22755bcc1b539786c';
// Shared BucketRegistry object, the second argument of `seal_approve`.
export const BUCKET_REGISTRY_ID =
'0x871f3d0341f36101ff0b30cd01dbe363f8d89d7f004df80e8084752d2f496958';
// Seal committee KeyServer object. One object stands for the whole committee.
export const SEAL_COMMITTEE_OBJECT_ID =
'0x686098f1439237fff9f36b99c7329683c22979d2005c2465cb891acb012a7595';
export const SEAL_THRESHOLD = 2;
// Seal key fetches go through Console's fetch_key proxy, which authenticates
// the API key and relays the request to the aggregator.
export const SEAL_AGGREGATOR_URL = `${API_BASE}/api/v1/seal/aggregator`;
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
import { bcs } from '@mysten/sui/bcs';
import { decodeSuiPrivateKey } from '@mysten/sui/cryptography';
import { SuiGrpcClient } from '@mysten/sui/grpc';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { Transaction } from '@mysten/sui/transactions';
import { fromBase64, fromHex } from '@mysten/sui/utils';
import { EncryptedObject, SealClient, SessionKey } from '@mysten/seal';
import {
BUCKET_REGISTRY_ID,
FULLNODE_URL,
LATEST_PACKAGE_ID,
ORIGINAL_PACKAGE_ID,
SEAL_AGGREGATOR_URL,
SEAL_COMMITTEE_OBJECT_ID,
SEAL_THRESHOLD,
SUI_NETWORK,
} from './config.js';
// Onchain `seal_approve` expects the identity as this BCS struct. Keep the
// field order in sync with Console's Move definition.
const SealIdentity = bcs.struct('SealIdentity', {
policyObjectId: bcs.Address,
nonce: bcs.fixedArray(32, bcs.u8()),
});
export function loadKeypair(suiPrivkey: string): Ed25519Keypair {
const { secretKey } = decodeSuiPrivateKey(suiPrivkey);
return Ed25519Keypair.fromSecretKey(secretKey);
}
export function makeSuiClient(): SuiGrpcClient {
return new SuiGrpcClient({ network: SUI_NETWORK, baseUrl: FULLNODE_URL });
}
// The SDK sends the `apiKeyName: apiKey` pair as a request header, so the
// pair below becomes `Authorization: Bearer hbr_…` on every key fetch.
export function makeSealClient(suiClient: SuiGrpcClient, apiKey: string): SealClient {
return new SealClient({
suiClient,
serverConfigs: [
{
objectId: SEAL_COMMITTEE_OBJECT_ID,
weight: 1,
aggregatorUrl: SEAL_AGGREGATOR_URL,
apiKeyName: 'Authorization',
apiKey: `Bearer ${apiKey}`,
},
],
verifyKeyServers: false,
});
}
export async function signReserveBytes(
keypair: Ed25519Keypair,
base64Bytes: string,
): Promise<string> {
const { signature } = await keypair.signTransaction(fromBase64(base64Bytes));
return signature;
}
export function buildIdentity(sealPolicyId: string): { id: string; nonce: number[] } {
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)));
const id = SealIdentity.serialize({ policyObjectId: sealPolicyId, nonce }).toHex();
return { id, nonce };
}
export async function encryptBytes(
seal: SealClient,
sealPolicyId: string,
plaintext: Uint8Array,
): Promise<Uint8Array<ArrayBuffer>> {
const { id } = buildIdentity(sealPolicyId);
const { encryptedObject } = await seal.encrypt({
threshold: SEAL_THRESHOLD,
packageId: ORIGINAL_PACKAGE_ID,
id,
data: plaintext,
});
return encryptedObject;
}
export function buildSealApproveTxBytes(
suiClient: SuiGrpcClient,
sealPolicyId: string,
idBytes: Uint8Array,
): Promise<Uint8Array> {
const tx = new Transaction();
tx.moveCall({
target: `${LATEST_PACKAGE_ID}::bucket_policy::seal_approve`,
arguments: [
tx.pure.vector('u8', Array.from(idBytes)),
tx.object(BUCKET_REGISTRY_ID),
tx.object(sealPolicyId),
],
});
return tx.build({ client: suiClient, onlyTransactionKind: true });
}
export async function createSessionKey(
suiClient: SuiGrpcClient,
keypair: Ed25519Keypair,
ttlMin = 10,
): Promise<SessionKey> {
return SessionKey.create({
address: keypair.toSuiAddress(),
packageId: ORIGINAL_PACKAGE_ID,
ttlMin,
suiClient,
signer: keypair,
});
}
export async function decryptBytes(
seal: SealClient,
suiClient: SuiGrpcClient,
keypair: Ed25519Keypair,
sealPolicyId: string,
ciphertext: Uint8Array,
): Promise<Uint8Array<ArrayBuffer>> {
const parsed = EncryptedObject.parse(ciphertext);
const idBytes = fromHex(parsed.id.startsWith('0x') ? parsed.id : '0x' + parsed.id);
const txBytes = await buildSealApproveTxBytes(suiClient, sealPolicyId, idBytes);
const sessionKey = await createSessionKey(suiClient, keypair);
const plaintext = await seal.decrypt({ data: ciphertext, sessionKey, txBytes });
return new Uint8Array(plaintext);
}
Step 3: Sign the bytes with the service key
Sign the reserved bytes with your service key. The script loads the key with loadKeypair and calls signReserveBytes, then prints the base64 signature you pass to finalize:
import { fromBase64 } from '@mysten/sui/utils';
import { requireEnv } from './config.js';
import { loadKeypair, signReserveBytes } from './seal.js';
async function main() {
const [, , base64Bytes] = process.argv;
if (!base64Bytes) {
throw new Error('Usage: sign-reserve.ts <base64-reserve-bytes>');
}
fromBase64(base64Bytes); // throws when the argument is not valid base64
const keypair = loadKeypair(requireEnv('CONSOLE_SERVICE_PRIVATE_KEY'));
console.log(await signReserveBytes(keypair, base64Bytes));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with the reserve bytes as the only argument:
$ node --env-file=.env --import tsx sign-reserve.ts <base64-reserve-bytes>
Step 4: Finalize
POST /api/v1/buckets/{bucketId}/finalize
Content-Type: application/json
{ "signature": "<base64 signature from step 3>" }
Console combines your signature with the gas-sponsor signature and broadcasts the transaction. Response (200):
{ "bucket_id": "…", "seal_policy_id": "…", "provisioning_state": "active" }
seal_policy_id is the onchain bucket-policy object ID that Seal uses for access checks. You can now use the folder.
Step 5: Encrypt the file with Seal
Encrypt locally against the seal_policy_id from finalize. encryptBytes (in seal.ts) derives a per-file Seal identity and encrypts against the folder policy using ORIGINAL_PACKAGE_ID from config.ts. Seal pins identity derivation to the original package ID, so this value must stay fixed across package upgrades, otherwise an upgrade would invalidate every previously encrypted blob's key. This script wires it up:
import { readFile, writeFile } from 'node:fs/promises';
import { requireEnv } from './config.js';
import { encryptBytes, makeSealClient, makeSuiClient } from './seal.js';
async function main() {
const [, , plaintextPath, sealPolicyId] = process.argv;
if (!plaintextPath || !sealPolicyId) {
throw new Error('Usage: encrypt-file.ts <plaintextPath> <sealPolicyId>');
}
const suiClient = makeSuiClient();
const seal = makeSealClient(suiClient, requireEnv('CONSOLE_API_KEY'));
const plaintext = await readFile(plaintextPath);
const ciphertext = await encryptBytes(seal, sealPolicyId, plaintext);
const outPath = `${plaintextPath}.enc`;
await writeFile(outPath, ciphertext);
console.log(`Encrypted ${plaintext.byteLength} -> ${ciphertext.byteLength} bytes -> ${outPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with the plaintext path and the policy ID:
$ node --env-file=.env --import tsx encrypt-file.ts <plaintextPath> <sealPolicyId>
It writes <plaintextPath>.enc, the byte stream you upload next.
Step 6: Upload
POST /api/v1/buckets/{bucketId}/files
Content-Type: multipart/form-data
file=@<encryptedObject>
Just after finalize, the onchain access grant needs a few seconds to propagate into Console's access index. Until it does, this endpoint returns 403 with code mirror_missing_grant. Retry every few seconds; usually 20 attempts is plenty in practice. Once the grant mirrors, the response is 202 with data.id.
Step 7: Poll status
GET /api/v1/buckets/{bucketId}/files/{fileId}/status
Returns data.state, one of queued, active, completed, or failed. Poll every second or two until the state is completed. Console evicts finished jobs after a while, so a 404 from this endpoint after a 202 is not a failure. Read the file metadata endpoint and treat a file status of active as success.
Step 8: Download and decrypt
GET /api/v1/buckets/{bucketId}/files/{fileId}/download
The response is a 307 redirect to a short-lived signed URL on the user-content host. Most HTTP clients follow it automatically, and curl needs -L. The bytes you receive are the raw Seal ciphertext. decryptBytes (in seal.ts) builds the seal_approve access-check transaction, signs it with a short-lived session key, and decrypts client-side. The Seal SDK fetches the decryption key through Console's key-fetch proxy with your API key, so you need no aggregator credential. This script runs the ciphertext-to-plaintext round trip and compares the result against the original when you pass its path:
import { readFile, writeFile } from 'node:fs/promises';
import { timingSafeEqual } from 'node:crypto';
import { requireEnv } from './config.js';
import { decryptBytes, loadKeypair, makeSealClient, makeSuiClient } from './seal.js';
async function main() {
const [, , ciphertextPath, sealPolicyId, originalPath] = process.argv;
if (!ciphertextPath || !sealPolicyId) {
throw new Error('Usage: decrypt-file.ts <ciphertextPath> <sealPolicyId> [originalPath]');
}
const suiClient = makeSuiClient();
const seal = makeSealClient(suiClient, requireEnv('CONSOLE_API_KEY'));
const keypair = loadKeypair(requireEnv('CONSOLE_SERVICE_PRIVATE_KEY'));
const ciphertext = await readFile(ciphertextPath);
const plaintext = await decryptBytes(seal, suiClient, keypair, sealPolicyId, ciphertext);
const outPath = `${ciphertextPath}.dec`;
await writeFile(outPath, plaintext);
console.log(`Decrypted ${ciphertext.byteLength} -> ${plaintext.byteLength} bytes -> ${outPath}`);
if (originalPath) {
const original = await readFile(originalPath);
const matches =
original.byteLength === plaintext.byteLength && timingSafeEqual(original, plaintext);
console.log(matches ? `MATCH (vs ${originalPath})` : `MISMATCH (vs ${originalPath})`);
if (!matches) {
process.exitCode = 1;
}
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run it with the ciphertext path, the policy ID, and optionally the original file to compare against:
$ node --env-file=.env --import tsx decrypt-file.ts <ciphertextPath> <sealPolicyId> [originalPath]
Decryption is fully client-side and never touches Console's backend.
Get help
Ask the team and other developers in the Walrus Discord. Include the endpoint and method, the HTTP status, and the code field from any error response.