Auth: workspace token. Mutations need write; reads need read. The
bound-account read routes (GET .../wallets, GET .../wallets/{id}/addresses)
also accept an account- or wallet-scoped API key bound to that account; the
mutation routes are workspace-token-only.
POST /v1/accounts
Create an account. Scope: write. Returns 201.
Request
{
"displayName": "Treasury",
"kind": "HD",
"defaultNetwork": "ethereum",
"hasOwnPassword": false
}| Field | Type | Required | Notes |
|---|---|---|---|
displayName |
string | yes | Human label. Must normalize to 3–64 characters and contain at least one Latin letter (a name that fails to normalize returns 400 invalid_parameter) — the account slug is derived from it |
kind |
"HD" | "PK" |
no | Defaults to HD |
secret |
string | no | Mnemonic for HD, private key for PK. Omit on HD to generate; required on PK |
wordCount |
12 | 15 | 18 | 21 | 24 |
no | Length of the mnemonic the service generates for an HD account; defaults to 12 (128-bit), 24 is 256-bit. Only valid when secret is omitted — sending both is 400 invalid_body |
defaultNetwork |
string | no | Network slug. Must be one the workspace defines — a well-formed slug for a network it lacks returns 400 unsupported_network |
hasOwnPassword |
boolean | no | Give the account a lock separate from the session |
accountPassword |
string | no | Required when hasOwnPassword is set, and rejected without it. At most 256 characters — over-length is 400 invalid_body; a value under the 12-character policy minimum is 422 weak_password, the same policy as a workspace password |
Response
{
"account": {
"slug": "treasury",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": false,
"defaultNetwork": "ethereum",
"wallets": 1
},
"generatedSecret": true
}A new account is not empty: creation also produces its first wallet. That
wallet is charged to the tenant’s wallet allowance, so creating an account
with no headroom left returns 409 with quota_wallets_exceeded before any
key material is generated.
A hasOwnPassword: true (Cold Vault) account comes back locked: true:
creating it does not unlock it, so it must be opened with an explicit unlock
(POST /v1/accounts/{slug}/unlock) before its first use.
The slug is generated by the service. Read it from the response — it is the identifier every later call uses.
DELETE /v1/accounts/{slug}
Delete an account and its key material. Scope: write. Returns 204.
POST /v1/accounts/{slug}/wallets
Derive wallets. Scope: write. Returns 201. HD accounts only — deriving on
a PK account returns 422 unsupported_for_kind.
{ "count": 5 }count is a positive integer, maximum 500 per call.
{ "before": 1, "after": 6 }before and after are the account’s total wallet count on either side of the
call, not the number added. An account starts at one wallet, so the first
count: 5 on a fresh account lands on six. Reporting both sides makes the call
easy to reconcile after an ambiguous network failure.
POST /v1/accounts/{slug}/wallets/allocate
Hand this HD account’s next wallet to a new user. Scope: write. Returns 201.
No body.
{
"wallet": {
"id": 12,
"tags": [],
"addresses": [
{ "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
]
}
}One creation request, no key derivation on its critical path. The behavior
depends on the deployment’s BUFFER_SIZE:
- Buffer mode (
BUFFER_SIZE > 0) — the service pre-derives wallets under the account and returns the next unallocated one immediately, then refills the buffer asynchronously when the free pool drops belowBUFFER_SIZE / 2. Pre-derived wallets count against the tenant wallet ceiling, so a buffered account holds up toBUFFER_SIZEwallets against its limit ahead of use. - Rough mode (
BUFFER_SIZEunset or0) — derives one wallet on demand and returns it, exactly likePOST .../walletswithcount: 1.
If the buffer is momentarily empty it falls back to deriving one on demand, so the call always returns a wallet (subject to the wallet quota). Allocation is tracked with a service-internal reserved tag that never appears in a response and cannot be set or cleared through the tags endpoint.
POST /v1/accounts/{slug}/wallets/seed-allocated
Mark the first count wallets of this HD account as already allocated. Scope:
write. Returns 200.
{ "count": 128 }{ "allocated": 128, "total": 128 }Migration prep for buffer mode. allocate only skips wallets it has itself
marked, so pointing it at an account whose wallets were bound out of band — for
example provisioned by index through POST .../wallets — would hand an
already-bound wallet to a new user. Seeding marks the lowest count wallet ids
as allocated first, so allocate on that account starts from the next free
index. count is a non-negative integer, maximum 100,000. Idempotent; a count
above the account’s wallet total is rejected with 400. Existing per-wallet
tags and later derivations are untouched.
POST /v1/accounts/{slug}/wallets/import
Import a private key as one wallet. Scope: write. Returns 201. PK accounts
only — importing into an HD account returns 422 unsupported_for_kind; an
unparseable key is 400 invalid_private_key.
{ "privateKey": "0x…", "vm": "evm" }vm is optional — evm or svm — to pin the signing scheme for a key that
could belong to either. Omit it to let the key’s format decide.
{
"wallet": {
"id": 2,
"tags": [],
"addresses": [
{ "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
]
}
}GET /v1/accounts/{slug}/wallets
List wallets with their addresses. Scope: read. Returns 200.
{
"wallets": [
{
"id": 1,
"tags": ["hot"],
"addresses": [
{ "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
]
}
]
}The example is trimmed to one address. A wallet normally carries an address per
supported VM — an evm and an svm one — so addresses usually holds more
than one entry. Always select by vm rather than taking addresses[0], and
handle the VM being absent: exporting a VM a wallet has no address for returns
422 unsupported_for_kind, so the set is not guaranteed.
GET /v1/accounts/{slug}/wallets/{id}/addresses
Addresses for one wallet. Scope: read. Returns 200.
{
"addresses": [
{ "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
]
}{id} must be a canonical non-negative integer — 0, 1, 2. Aliases of a
number are rejected with 400 and invalid_parameter before the account is
looked up, so 01, 1.0, 1e0, and 0x1 all fail. An unknown but
well-formed id returns 404 with account_not_found.
PUT /v1/accounts/{slug}/wallets/{id}/tags
Replace a wallet’s tags. Scope: write. Returns 200.
{ "tags": ["hot", "treasury"] }Up to 32 tags, each 1–64 characters after normalization. The list is
replaced, not merged — send {"tags":[]} to clear. The request rejects
unknown fields.
Tags are trimmed, Unicode-normalized, and de-duplicated before they are stored, so the list you get back is not always the list you sent. Compare against the response, not against your request.
Tags beginning sys: are reserved for service-internal state (buffer
allocation). They are stripped from your request and any existing ones are
preserved across a replace, so you can neither set nor clear them here, and they
never appear in a wallet’s tag list.
{
"wallet": {
"id": 1,
"tags": ["hot", "treasury"],
"addresses": [ ]
}
}Errors
| Status | Code |
|---|---|
| 400 | invalid_body, invalid_slug, invalid_parameter, invalid_private_key, invalid_mnemonic, unsupported_network |
| 401 | session_expired |
| 403 | scope_denied |
| 404 | account_not_found |
| 409 | quota_wallets_exceeded |
| 422 | unsupported_for_kind, weak_password |
| 423 | account_locked |