# 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:
> - Rendered HTML: [`/docs/quickstart`](https://api.testnet.console.walrus.xyz/docs/quickstart)
> - Raw markdown (LLM/`curl`-friendly): [`/docs/quickstart.md`](https://api.testnet.console.walrus.xyz/docs/quickstart.md)
> - OpenAPI viewer (Scalar): [`/docs/openapi`](https://api.testnet.console.walrus.xyz/docs/openapi)
> - OpenAPI spec (raw): [`/openapi.yaml`](https://api.testnet.console.walrus.xyz/openapi.yaml) · [`/openapi.json`](https://api.testnet.console.walrus.xyz/openapi.json)
> - Docs index: [`/docs`](https://api.testnet.console.walrus.xyz/docs)

---

## 1. Hello world — sign up via zkLogin

1. Visit [testnet.console.walrus.xyz/](https://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.
   - **`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.
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:
   - [`console.postman_collection.json`](https://github.com/MystenLabs/walrus-console-quickstart/blob/main/postman/console.postman_collection.json)
   - [`console.postman_environment.json`](https://github.com/MystenLabs/walrus-console-quickstart/blob/main/postman/console.postman_environment.json)

   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:

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


### 2. Get your space id

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

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

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

Response (`201`):

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

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

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

```json
{ "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`](https://www.npmjs.com/package/@mysten/seal)
against the `seal_policy_id` returned by Finalize:

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

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

```http
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](/docs/programmatic-clients)
covers the upload preflight, delete states and when storage is released.

### 9. Download → decrypt locally

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

```ts
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](https://github.com/MystenLabs/walrus-console-quickstart/issues)**
with the label **`developer-docs`**.

Please include:

- The endpoint and HTTP method
- The HTTP status and the `code` field from the error response (if any)
- Postman collection version (visible in the collection's **Info** tab)

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