These routes address the workspace named by the token’s own claim — no
workspace-token route takes the workspace in its path, so a path and a token
can never disagree about which one is in play. (The tenant-tier route
DELETE /v1/workspaces/{slug} does name a workspace in its path; it carries no
token to disagree with.)
Auth: workspace token on every route — except GET /accounts/{slug}, which
also accepts an account- or wallet-scoped API key bound to that account.
GET /v1/workspace
Current workspace state. Scope: read. Returns 200.
{
"workspace": "demo",
"locked": false,
"accounts": 3,
"damagedAccounts": 0,
"expiresAt": "2026-08-18T12:34:56.000Z"
}| Field | Meaning |
|---|---|
accounts |
Number of readable accounts. Damaged ones are not counted here |
damagedAccounts |
Count of accounts whose records could not be read |
expiresAt |
When the idle clock currently runs out |
The workspace holds accounts + damagedAccounts accounts in total. A damaged
record is absent from accounts and from the account listing, so neither is a
census — add the two fields when you need one.
GET /v1/accounts
List every account in the workspace. Scope: read. Returns 200.
{
"accounts": [
{
"slug": "treasury",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": true,
"defaultNetwork": "ethereum",
"wallets": 4
}
]
}Listing never unlocks anything. Every account reads locked: true after a
fresh workspace unlock and stays that way until something opens it — resolving
it by slug, or an explicit unlock. Do not read a false here as a precondition
for the next call; read it as the custody state at the moment of the listing.
GET /v1/accounts/{slug}
Resolve one account, unlocking it lazily if it shares the workspace password.
Scope: read. Returns 200.
{
"account": {
"slug": "treasury",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": false,
"defaultNetwork": "ethereum",
"wallets": 4
}
}An account with its own password returns 423 with account_locked until
explicitly unlocked.
GET /v1/workspace/assets
Assets on one network. Scope: read. Returns 200.
Query parameters
| Parameter | Required | Notes |
|---|---|---|
network |
yes | Network slug |
The network must be named — listing every network’s assets would be an unbounded response shaped by how many networks the tenant has registered.
{
"network": "ethereum",
"assets": [
{
"id": 1,
"symbol": "ETH",
"name": "Ether",
"decimals": 18,
"native": true,
"contractAddress": null
}
]
}POST /v1/accounts/{slug}/unlock
Reopen an account that is locked. Any scope. Returns 204.
{ "accountPassword": "…" }An account created with hasOwnPassword: true needs this before its first
use, with its own password.
Every other account needs it too, eventually. An account sharing the workspace
password opens transparently the first time you touch it, but that opening is a
custody window bounded by ttl.accountAbsoluteSec — five minutes by
default. When it lapses the account locks, and every route touching it answers
423 and account_locked until you call this one. Pass the workspace password
in accountPassword.
The same applies after POST /v1/accounts/{slug}/lock, which locks any account
regardless of how it was created.
A wrong accountPassword returns 401 with bad_password.
Failed attempts are rate-limited two ways. A repeated failure on the same
account backs off with 429 and account_unlock_rate_limited. Separately,
every unlock attempt spends from the same tenant-wide budget token minting
draws on, so a burst of unlocks can answer 429 with mint_rate_limited —
the same code a mint would return.
Two side effects are easy to miss. An admitted attempt slides the workspace idle
clock before the password is checked, so even a wrong password keeps the
session from going idle. And re-verifying an account that is already open does
not extend its custody window — only a fresh unlock of a locked account
starts a new one. After five consecutive failures (or the mint-budget limit,
whichever is lower) the whole session is retired, and later calls return 401
with session_expired.
POST /v1/accounts/{slug}/lock
Lock an account again. Any scope. Returns 204. No body.
Errors
| Status | Code |
|---|---|
| 400 | invalid_body, invalid_slug, invalid_parameter, unsupported_network |
| 401 | session_expired, bad_password |
| 403 | scope_denied |
| 404 | account_not_found |
| 423 | account_locked |
| 429 | account_unlock_rate_limited, mint_rate_limited |