Skip to content

Create accounts and derive wallets

Every field create validates — including the display-name rule that rejects a name with no Latin letter — and the canonical form a wallet id must take.

Updated View as Markdown

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 below BUFFER_SIZE / 2. Pre-derived wallets count against the tenant wallet ceiling, so a buffered account holds up to BUFFER_SIZE wallets against its limit ahead of use.
  • Rough mode (BUFFER_SIZE unset or 0) — derives one wallet on demand and returns it, exactly like POST .../wallets with count: 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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close