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>/v1Everything 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.
Related
- Accounts, wallets, and addresses — the model
- Accounts endpoints — exact shapes