Auth: workspace token on every route. Mutations need write; reads need
read.
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 4–64 characters and contain at least one Latin letter — 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 |
defaultNetwork |
string | no | Network slug. Must be one the workspace defines — a well-formed slug for a network it does not have is rejected |
hasOwnPassword |
boolean | no | Give the account a lock separate from the session |
accountPassword |
string | no | Required when hasOwnPassword is set, and rejected without it. 12–256 characters, held to the same policy as a workspace password |
Response
{
"account": {
"slug": "treasury-a1b2",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": false,
"defaultNetwork": "ethereum",
"wallets": 0
},
"generatedSecret": true
}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.
{ "count": 5 }count is a positive integer, maximum 500 per call.
{ "before": 0, "after": 5 }Reporting both sides makes the call easy to reconcile after an ambiguous network failure.
POST /v1/accounts/{slug}/wallets/import
Import a private key as one wallet. Scope: write. Returns 201.
{ "privateKey": "0x…" }{
"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 }
]
}
]
}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.
{
"wallet": {
"id": 1,
"tags": ["hot", "treasury"],
"addresses": [ ]
}
}Errors
| Status | Code |
|---|---|
| 400 | invalid_body, invalid_slug, invalid_parameter, invalid_private_key, invalid_mnemonic |
| 401 | session_expired |
| 403 | scope_denied |
| 404 | account_not_found |
| 409 | quota_wallets_exceeded |
| 422 | unsupported_for_kind, weak_password |
| 423 | account_locked |