Skip to content

Quickstart: from credentials to your first signature

Six calls — workspace, token, account, addresses, signature, revoke — plus the custody timer that can close an account mid-walkthrough.

Updated View as Markdown

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-password

Create 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-value

Create 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=treasury

Two things to read off that response rather than assume:

  • wallets is a count, not a list, and it is already 1. Creating an account derives its first wallet for you, and that wallet is charged against the tenant’s maxWallets quota — 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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close