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.

> **Info**
>
> 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](./api-reference). For the product model, see the [concepts and overview](./overview).
## Prerequisites

- [x] A Google or Apple account for sign-in.
- [x] Node.js 22 or later, with [`@mysten/sui`](https://www.npmjs.com/package/@mysten/sui), [`@mysten/seal`](https://www.npmjs.com/package/@mysten/seal), and [`tsx`](https://www.npmjs.com/package/tsx) installed, for the signing and encryption steps.

Every request below carries your API key as a bearer token:

```http
Authorization: Bearer hbr_…
```

## Sign up and create an API key

1. Visit the [Walrus Console app](https://console.walrus.xyz/) and sign in with Google or Apple. zkLogin provisions your account and a Personal Space automatically.
2. 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.
3. On the reveal screen, copy both secrets. Console shows them once and cannot recover them afterward:
   - `hbr_…`, the API key you send as `Authorization: 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 `.env` as `CONSOLE_API_KEY` and `CONSOLE_SERVICE_PRIVATE_KEY`, or into Postman. The reveal screen also shows a `CONSOLE_CREDENTIAL_BUNDLE` value, which the [MCP server](./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

```http
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

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

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

Response (`201`):

```json
{
  "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](/docs/data-security) 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`.

```ts title="config.ts"
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;
}
```

```ts title="seal.ts"
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:

```ts title="sign-reserve.ts"
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:

```sh
$ node --env-file=.env --import tsx sign-reserve.ts <base64-reserve-bytes>
```

### Step 4: Finalize

```http
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`):

```json
{ "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:

```ts title="encrypt-file.ts"
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:

```sh
$ node --env-file=.env --import tsx encrypt-file.ts <plaintextPath> <sealPolicyId>
```

It writes `<plaintextPath>.enc`, the byte stream you upload next.

### Step 6: Upload

```http
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

```http
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

```http
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](./api-reference#fetch-a-seal-key) 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:

```ts title="decrypt-file.ts"
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:

```sh
$ 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](https://discord.gg/walrusprotocol). Include the endpoint and method, the HTTP status, and the `code` field from any error response.