---
title: "Quickstart: from credentials to your first signature"
description: "Six calls — workspace, token, account, addresses, signature, revoke — plus the custody timer that can close an account mid-walkthrough."
---

> Documentation Index
> Fetch the complete documentation index at: https://tee.hypetrade.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart: from credentials to your first signature

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](/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.

```bash
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
```

> **The workspace password has a minimum length**
>
> A password shorter than **12 characters** is refused with `422` and code
> `weak_password`, before anything is created. There is no character-class
> requirement — length is the bar. The same policy applies to a per-account
> password later.

> **Treat these like passwords**
>
> Tenant credentials administer every workspace you own. Keep them out of client
> code, browsers, and source control. See [Safe usage](/safe-usage/).

1. **Step 1**

### 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.

```bash
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`.

   > **Deleting and recreating the same slug has a cooldown**
   >
   > If you delete `demo` and immediately create it again, the second create answers
   > `429` with `workspace_recreation_cooldown` — **60 seconds by default**. The
   > response carries a `retry-after` header. Use a fresh slug rather than waiting if
   > you are iterating.

2. **Step 2**

### Mint a workspace token

   This is the unlock. The token that comes back holds the workspace open, and its
   lifetime *is* the session.

```bash
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\"}"
```

```json
{
  "token": "eyJhbGciOi...",
  "expiresAt": "2026-08-18T12:34:56.000Z",
  "workspace": "demo",
  "scopes": ["read", "write", "sign"]
}
```

   Capture the token — every remaining step uses it:

```bash
export TOKEN=the-token-value
```

3. **Step 3**

### 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.

```bash
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`:

```json
{
  "account": {
"slug": "treasury",
"displayName": "Treasury",
"kind": "HD",
"hasOwnPassword": false,
"locked": false,
"defaultNetwork": "ethereum",
"wallets": 1
  },
  "generatedSecret": true
}
```

```bash
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.

   > **Display names are validated, not just stored**
   >
   > `displayName` must normalize to 3–64 characters *and* still contain a Latin
   > letter afterwards, because the slug is projected from it. A name made only of
   > non-Latin characters is refused with `400` and code `invalid_parameter` — the
   > same status family as a malformed path, not `invalid_body`.

   > **The account closes on a timer**
   >
   > Creating an account opens it, and that opening lasts `ttl.accountAbsoluteSec` —
   > **five minutes by default**. If you take longer than that over the remaining
   > steps, the next call that touches this account — including `POST /sign/message`
   > — answers `423` with `account_locked`. That is not an error in what you typed;
   > it is the custody window closing.
   >
   > The workspace-wide listing `GET /accounts` keeps working and shows the account
   > with `"locked": true`, which is the quickest way to confirm that is what
   > happened.
   >
   > Once the window has closed the account does *not* reopen by itself, even though
   > it shares the workspace password. Reopen it explicitly and carry on — the
   > password to send is the workspace password:
   >
   > ```bash
curl -X POST "$API_URL/accounts/$ACCOUNT/unlock" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d "{\"accountPassword\":\"$PASSWORD\"}"
```
   >
   > A successful unlock returns `204` with no body.

4. **Step 4**

### 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`.

```bash
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:

```json
{ "before": 1, "after": 3 }
```

   Crossing the tenant's wallet allowance returns `409` with
   `quota_wallets_exceeded`, and nothing is derived.

5. **Step 5**

### Read the addresses

```bash
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](/concepts/accounts-wallets-addresses/):

```json
{
  "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.

6. **Step 6**

### Sign a message

   Signing addresses a public key directly — you never walk account → wallet →
   address to sign. Resolution is scoped to your own workspace.

```bash
curl -X POST "$API_URL/sign/message" \
  -H "Authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"address":"0xabc…","message":"hello from tee-docker"}'
```

```json
{ "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](/guides/sign-messages/) for the encodings.

7. **Step 7**

### 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.

```bash
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](/concepts/workspaces-and-tokens/) — session lifetime, refresh, and what expiry looks like
- [Scopes and tiers](/concepts/scopes/) — including why `export` is different
- [Sign messages](/guides/sign-messages/) — messages, typed data, digests, and bytes
- [API keys](/api/api-keys/) — hand an end user their own durable key
- [Errors](/errors/) — the full code catalog and what to do about each

Source: https://tee.hypetrade.xyz/guides/quickstart/index.mdx
