Alpha. Testnet only. Endpoint shapes may change before mainnet GA.
A "hello world" tour of the Harbor 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.harbor.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_… Harbor API key. It is shown
once — Harbor 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.harbor.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 — Harbor 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_…Harbor 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 Harbor API key (same as section 1).suiprivkey1… — the service private key, an Ed25519 Base64 secret
in Sui keytool format. Bound to this API key; Harbor stores only the
derived public address. Does not need any SUI balance — gas is
sponsored by Harbor via Enoki.Both are shown once. Paste them into Postman as bearerToken and
harborServicePrivateKey, 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": "…",
"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. Harbor 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; Harbor 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 (theconfirm=truequery param is required; delete also 400s if the bucket still has files).
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>" }
Harbor combines your signature with Enoki's gas-sponsor signature and
broadcasts the transaction. Response (200):
{ "bucket_id": "…", "seal_policy_id": "…", "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 — Harbor 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';
// Harbor'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 =
'0x8b2429358e9b0f005b69fe8ad3cbd1268ad87f35047a21612e082c64824faf8d';
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 })),
verifyKeyServers: false,
});
// Each file's Seal id = (bucket policy id, 32 random bytes). The bcs serializer
// matches Harbor'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.
mirror_missing_grantPOST /api/v1/buckets/{bucketId}/files
Content-Type: multipart/form-data
file=@<encryptedObject>
Standard multipart upload, but the on-chain BucketAdmin grant from
Finalize needs a few seconds to land in Harbor'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.
GET /api/v1/buckets/{bucketId}/files/{fileId}/download
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 Harbor bucket-policy package — host of the `seal_approve` move call.
const HARBOR_LATEST_PACKAGE_ID =
'0xc11d875481544e9b6c616f7d6704266e1633b4034eab7ed76626dc25ebfcd506';
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(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 });
Harbor's API surface stops at the ciphertext byte stream — decryption is fully client-side and never touches Harbor's backend.
Open an issue at
github.com/MystenLabs/walrus-harbor-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, 11 endpoints), or
browse it interactively at /docs/openapi.