Skip to content

How to manage accounts and wallets

Create accounts, derive or import wallets, tag them, and unlock the ones with their own password.

Updated View as Markdown

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>/v1

Everything here uses a workspace token. Creating and modifying needs the write scope; listing needs read.

Create an account

curl -X POST "$API_URL/accounts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"displayName":"Treasury","kind":"HD"}'
Field Required Notes
displayName yes Human label; does not have to be unique. Must normalize to 3–64 characters and contain a Latin letter — the slug is derived from it
kind no HD (default) or PK
secret on PK Mnemonic for HD, private key for PK. Omit on HD to generate one; required on PK
defaultNetwork no Network slug this account derives against — one the workspace lacks returns 400 unsupported_network
hasOwnPassword no Give the account its own lock, separate from the session
accountPassword when hasOwnPassword The account’s own password. Held to the same policy as a workspace password — at least 12 characters. Sending it without hasOwnPassword: true is rejected too, so the pair travels together

Returns 201. The response carries the account and whether a secret was generated:

{
  "account": {
    "slug": "treasury",
    "displayName": "Treasury",
    "kind": "HD",
    "hasOwnPassword": false,
    "locked": false,
    "defaultNetwork": "ethereum",
    "wallets": 1
  },
  "generatedSecret": true
}

Note the wallets: 1. A new account is not empty — it arrives with one wallet already derived, and that wallet is charged against the tenant’s maxWallets allowance. Creating an account when the allowance is already full is refused with 409 and quota_wallets_exceeded before any key material is generated.

Derive wallets

An HD account starts with the one wallet its creation derived, so this is how you add more. Derive in bulk:

curl -X POST "$API_URL/accounts/$ACCOUNT/wallets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"count":5}'

Returns 201 with the wallet count on both sides of the call:

{ "before": 1, "after": 6 }

count is a positive integer up to 500 per call. The tenant-wide maxWallets limit still applies — crossing it returns 409 with quota_wallets_exceeded. Because the response reports both sides, a retry after an ambiguous network failure is easy to reconcile.

The allowance is counted across every workspace the tenant holds, and it is reserved before the derivation runs, so two workspaces racing for the last slot produce one 201 and one 409 rather than both succeeding.

Import a private key

Import needs a PK account. The $ACCOUNT created above is HD, and an HD account refuses an import with 422 and unsupported_for_kind — the two kinds are exclusive in both directions, so a PK account equally refuses derive. Create one for the key:

curl -X POST "$API_URL/accounts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"displayName":"Imported Key","kind":"PK","secret":"0x…"}'

secret is required on a PK account — there is nothing for the service to generate. Then import further keys into it:

curl -X POST "$API_URL/accounts/$PK_ACCOUNT/wallets/import" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"privateKey":"0x…"}'

Returns 201 with { "wallet": { … } } — exactly one wallet added. A key the service cannot parse returns 400 with invalid_private_key. An optional vm field (evm or svm) pins the signing scheme for a key that could belong to either.

List wallets and addresses

If you did not keep the slug from the create response, GET /v1/accounts lists every account in the workspace with its slug, kind, and lock state:

curl "$API_URL/accounts" -H "Authorization: Bearer $TOKEN"

Then, per account:

curl "$API_URL/accounts/$ACCOUNT/wallets" -H "Authorization: Bearer $TOKEN"

Each wallet carries its addresses inline:

{
  "wallets": [
    {
      "id": 1,
      "tags": ["hot"],
      "addresses": [
        { "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
      ]
    }
  ]
}

For one wallet’s addresses only:

curl "$API_URL/accounts/$ACCOUNT/wallets/1/addresses" \
  -H "Authorization: Bearer $TOKEN"

Tag wallets

Tags are yours to use; the service attaches no meaning to them. The call replaces the whole list rather than appending:

curl -X PUT "$API_URL/accounts/$ACCOUNT/wallets/1/tags" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"tags":["hot","treasury"]}'

Up to 32 tags. To remove them all, send {"tags":[]}.

Accounts with their own password

An account created with hasOwnPassword: true stays locked even while the workspace is open, and answers 423 with account_locked until unlocked:

curl -X POST "$API_URL/accounts/$ACCOUNT/unlock" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"accountPassword":"…"}'

Returns 204. A wrong password is 401 with bad_password. Unlocks last for their own (usually short) window — the operator sets it, and the shipped default is five minutes.

Unlocking also spends the tenant’s token-minting budget, because both pay the same key-derivation cost. Exhausting it returns 429 with mint_rate_limited from the unlock route — a different code from the per-account backoff above, and one that clears on its own once the window rolls over.

Lock it again as soon as you are done:

curl -X POST "$API_URL/accounts/$ACCOUNT/lock" -H "Authorization: Bearer $TOKEN"

Both routes accept any workspace token regardless of scope; the scope check still applies to whatever you do next.

Accounts that share the workspace password open transparently on first use, so they need none of this to get started. They still lock when their custody window closes, and after that they need exactly this call — with the workspace password as accountPassword.

The same is true after an explicit lock: POST /v1/accounts/{slug}/lock locks any account, shared-password or not, and only unlock reopens it.

Delete an account

curl -X DELETE "$API_URL/accounts/$ACCOUNT" -H "Authorization: Bearer $TOKEN"

Returns 204 and destroys the account’s key material. Export first if anything needs to survive.

Failures worth handling

Code Status What happened
invalid_body 400 The body did not match the route’s shape. The message names the offending fields — body must be { count } — "count" is required
invalid_slug 400 The account slug in the path is not a lowercase slug
invalid_parameter 400 The wallet id in the path is not a plain non-negative integer, or displayName did not normalize
invalid_private_key 400 The key could not be parsed
invalid_mnemonic 400 An HD secret you supplied could not be parsed as a mnemonic
unsupported_network 400 defaultNetwork named a network the workspace does not have
bad_password 401 Wrong accountPassword on unlock
scope_denied 403 Token lacks write (create, derive, import, tags, delete) or read (listing)
account_not_found 404 No such account, or no such wallet id on it
unsupported_for_kind 422 Derive on a PK account, or import into an HD account
weak_password 422 accountPassword is below the twelve-character policy
account_locked 423 The account has its own password, was locked explicitly, or its custody window lapsed
quota_wallets_exceeded 409 The tenant’s maxWallets allowance is full — account creation counts too
account_unlock_rate_limited 429 Unlock backoff after a failed attempt
mint_rate_limited 429 The tenant’s shared key-derivation budget is spent

Every 429 here carries a retry-after header alongside the JSON body, so a client can honour the wait without parsing the error.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close