This walks the shortest honest path to one working result: a signature produced by a key the service manages for you. It assumes your operator has already issued you a tenant API key and secret. If you are the operator, stand the instance up first — see Deploy — and the credentials this needs are the ones you issue there.
What you need
Set these once for the session. The API key and secret come from your operator —
they are not self-service. PASSWORD is yours to choose now; every step below
reuses it, so pick it before you start.
export API_URL=https://<your-api-host>/v1
export API_KEY=your-api-key
export API_SECRET=your-api-secret
export PASSWORD=a-strong-workspace-passwordCreate a workspace
A workspace is a password-protected container. Pick a slug and a strong password; you will need the password again to open it.
curl -X POST "$API_URL/workspaces" \
-H "X-Api-Key: $API_KEY" \
-H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d "{\"slug\":\"demo\",\"password\":\"$PASSWORD\"}"Slugs are lowercase letters, digits, and hyphens, must start alphanumeric, and
run to 63 characters. Four names are reserved and refused even though they fit
that rule — con, prn, aux and nul. A 201 returns the workspace record —
slug, createdAt, and walletCount.
Mint a workspace token
This is the unlock. The token that comes back holds the workspace open, and its lifetime is the session.
curl -X POST "$API_URL/auth/token" \
-H "X-Api-Key: $API_KEY" \
-H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d "{\"workspace\":\"demo\",\"password\":\"$PASSWORD\"}"{
"token": "eyJhbGciOi...",
"expiresAt": "2026-08-18T12:34:56.000Z",
"workspace": "demo",
"scopes": ["read", "write", "sign"]
}Capture the token — every remaining step uses it:
export TOKEN=the-token-valueCreate an account
An HD account generates its own mnemonic. The mnemonic is never returned in
a response body; generatedSecret: true only tells you one was created.
curl -X POST "$API_URL/accounts" \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"displayName":"Treasury","kind":"HD"}'The account slug is generated by the service, not chosen by you. It comes back
at account.slug:
{
"account": {
"slug": "treasury",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": false,
"defaultNetwork": "ethereum",
"wallets": 1
},
"generatedSecret": true
}export ACCOUNT=treasuryTwo things to read off that response rather than assume:
walletsis a count, not a list, and it is already1. Creating an account derives its first wallet for you, and that wallet is charged against the tenant’smaxWalletsquota — so a tenant with a wallet limit of 1 can create exactly one account and derive nothing.- The slug is derived from the display name, lowercased and stripped of accents, and truncated if the name is long. A second account with the same display name gets the same base slug plus a numeric suffix, so never reconstruct it yourself — save what came back.
Derive more wallets (optional)
You already have one wallet from the previous step, so you can skip straight to
signing. Derive more when you want additional addresses — count is a positive
integer up to 500, and each new wallet is charged against maxWallets.
curl -X POST "$API_URL/accounts/$ACCOUNT/wallets" \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"count":2}'A 201 reports the count on both sides of the operation, which is what makes
the call safe to retry:
{ "before": 1, "after": 3 }Crossing the tenant’s wallet allowance returns 409 with
quota_wallets_exceeded, and nothing is derived.
Read the addresses
curl "$API_URL/accounts/$ACCOUNT/wallets" \
-H "Authorization: Bearer $TOKEN"Each wallet carries one address per VM, and each address reports the network
and chainId it belongs to — see
Accounts, wallets, and addresses:
{
"wallets": [
{
"id": 1,
"tags": [],
"addresses": [
{ "publicKey": "0xabc…", "vm": "evm", "network": "ethereum", "chainId": 1 }
]
}
]
}A second entry with "vm": "svm" sits alongside the EVM one, carrying its own
network and chainId. Read vm, network and chainId off the address you
intend to use rather than inferring them — the signing routes take a public
key and get the network from it, so the address you pick is the chain
selection. For the rest of this walkthrough, take the "vm": "evm" entry’s
publicKey. Take id from the response too; do not assume where the numbering
starts.
Sign a message
Signing addresses a public key directly — you never walk account → wallet → address to sign. Resolution is scoped to your own workspace.
curl -X POST "$API_URL/sign/message" \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"address":"0xabc…","message":"hello from tee-docker"}'{ "address": "0xabc…", "signature": "0x…" }message is always read as UTF-8 text, and omitting encoding uses that
address’s own default scheme — which is what makes this snippet work unchanged
for an EVM or a Solana address. An address that is not in your workspace
returns 404 with account_not_found. See
Sign messages for the encodings.
Lock up when you are done
Revoking ends this token’s lease. It is the only token you minted here, so the workspace locks with it.
curl -X DELETE "$API_URL/auth/token" \
-H "Authorization: Bearer $TOKEN"A 204 with no body. The route needs no particular scope — a token can always
revoke itself.
What just happened
You used both credential tiers. Steps 1 and 2 used tenant credentials — the
only two calls in this walkthrough that do, though workspace listing, quota and
deletion use them too. Everything after used the workspace token, which
carried the default read, write, and sign scopes, because the mint request
named no scopes of its own.
Next
- Workspaces and tokens — session lifetime, refresh, and what expiry looks like
- Scopes and tiers — including why
exportis different - Sign messages — messages, typed data, digests, and bytes
- API keys — hand an end user their own durable key
- Errors — the full code catalog and what to do about each