Examples on this page use $API_URL. Set it once, with the /v1 prefix
included — every route lives under it:
export API_URL=https://<your-api-host>/v1Export is the highest-consequence operation in the service. A leaked signing token can sign what the key allows — a transaction its holder then broadcasts — and leaves a trace; leaked key material is permanent, offline, irrevocable control. The design reflects that.
Three gates, all required
An Unlimited-tier scoped API key
Export is not a workspace-token operation. The tenant mints an Unlimited scoped API key for the end user, bound to the account or wallet, and carrying the end user’s inquiry key:
curl -X POST "$API_URL/auth/api-key" \
-H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d '{"workspace":"demo","password":"…","account":"treasury","tier":"unlimited","inquiryKey":"x25519:<base64>"}'A Basic key cannot export, and neither can a plain workspace token — both are
refused with 403 scope_denied.
A live inquiry key
The end user generates an X25519 key pair in the browser and hands the tenant
only the public inquiry key. It is time-boxed by validationDuration
(default 4 hours): once it lapses, export locks again even though the key keeps
working for everything else.
Encryption to the end user’s own key
The response never carries plaintext. It carries a blob sealed to the inquiry key, so it opens only for the holder of the matching private half — the end user, never the operator.
Export a mnemonic
HD accounts only. Returns 200:
curl -X POST "$API_URL/accounts/$ACCOUNT/export" \
-H "Authorization: Bearer $SCOPED_KEY"{
"kind": "mnemonic",
"account": "treasury",
"sealed": {
"alg": "x25519-hkdf-sha256-aes256gcm",
"recipientFingerprint": "9f86d081884c7d65",
"ephemeralPublicKey": "base64…",
"nonce": "base64…",
"ciphertext": "base64…",
"tag": "base64…"
}
}A PK account has no mnemonic — asking returns 422 with
unsupported_for_kind.
Export a private key
Per wallet, and the VM must be named:
curl -X POST "$API_URL/accounts/$ACCOUNT/wallets/1/export?vm=evm" \
-H "Authorization: Bearer $SCOPED_KEY"vm accepts exactly evm or svm; anything else — including omitting it —
returns 400 with invalid_parameter. There is no default. The wallet id in
the path must be a plain non-negative integer, and a non-canonical spelling
(01, +1, 0x1) fails the same way. Asking for a VM the wallet has no
address for returns 422 with unsupported_for_kind. The response echoes the
selection back:
{
"kind": "privateKey",
"account": "treasury",
"walletId": 1,
"vm": "evm",
"sealed": { }
}Reading the sealed blob
| Field | Meaning |
|---|---|
alg |
The sealing algorithm identifier. Currently always x25519-hkdf-sha256-aes256gcm |
recipientFingerprint |
First 16 hex characters of SHA-256 over the recipient’s raw public key |
ephemeralPublicKey |
Base64 raw X25519 public key of a single-use sender key |
nonce |
Base64 AES-GCM nonce, 12 bytes |
ciphertext |
Base64 sealed payload |
tag |
Base64 GCM authentication tag, 16 bytes |
Every export uses a fresh single-use sender key, so exporting the same secret
twice returns two completely different blobs — different ciphertext, different
ephemeralPublicKey — that decrypt to the same plaintext. Comparing blobs is
not a way to tell whether two exports covered the same key.
Check recipientFingerprint against the public key you expect before
attempting decryption — it tells you which key the blob was sealed to, and a
mismatch means it was sealed for someone else.
Opening the blob requires the corresponding X25519 private key, which the service never sees and never holds. Decryption happens client-side — in the end user’s browser, where the inquiry key pair was generated. If you do not hold that private key, you cannot open what you exported — by design.
The full procedure — the key-derivation parameters and a working client — is in Decrypting a sealed blob. Read it before you export: the scheme binds the sender’s key as additional authenticated data, and a client that omits that fails in a way that looks like a corrupted blob.
Every attempt is audited
Every attempt that reaches the export handler is recorded with the tenant, workspace, account, target kind, and — for private keys — the wallet id and VM. “When did this key leave” has to be answerable after the fact, so a failed export is logged just as loudly as a successful one.
A token without the export scope (403 scope_denied) is recorded as well,
with the outcome DENIED — so “who reached for key material and was refused”
is answerable, not just “whose export ran”. That record names the tenant,
workspace, and target kind and the scope required, but deliberately omits the
account slug, wallet id, and vm: a scope check refuses before any of those
have been validated, and recording unvalidated input is how an audit log
becomes a place to inject text.
Two rejections still leave no record: an expired or missing token
(401 session_expired) and a malformed slug, wallet id, or vm selector
(400). If you are building detection on these records, that is the edge to
know about.
Failures worth handling
| Code | Status | What happened |
|---|---|---|
scope_denied |
403 | Not an Unlimited key (no export scope), or no live inquiry key |
unsupported_for_kind |
422 | Mnemonic from a PK account, or a VM the wallet lacks |
invalid_parameter |
400 | vm was missing or not evm/svm, or the wallet id was not a plain integer |
invalid_slug |
400 | The account slug in the path is not a lowercase slug |
account_not_found |
404 | No such account or wallet |
account_locked |
423 | The account is locked — its own password, an explicit lock, or a lapsed custody window. Unlock it |
Related
- Scopes and permissions — why export is gated apart
- Export endpoints — exact shapes
- Safe usage — handling what comes back