> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.walletstech.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.walletstech.com/_mcp/server.

# Authentication

## The two secrets

Almost every integration bug in this area comes from conflating these. They are unrelated.

|             | **API credentials**             | **Wallet password**                             |
| ----------- | ------------------------------- | ----------------------------------------------- |
| What        | `client id` + `api key`         | The `password` you chose at `/create`           |
| Identifies  | **your application**            | **one user's keystore**                         |
| Used by     | `/auth`, to mint a bearer token | `/send` and `/extend`, to decrypt the keys      |
| Scope       | the whole service               | a single `userId`                               |
| Recoverable | No — re-issued by the operator  | **No. Never. By anyone.**                       |
| Rotatable   | Ask your operator               | **No** — it is fixed for the life of the wallet |

The wallet password is **not** a setup-time secret you can forget about. It is required on **every single transfer**, so your system has to store it and retrieve it per user, for as long as the wallet exists.

There is no change-password endpoint and no recovery path. If a user's password is lost, their funds cannot be moved — not by you, and not by us. The seed phrase from `/create` is the only other way to reach those keys, and it is shown exactly once.

## Getting a token

Your client id and api key go in **headers**. There is no request body.

```bash
curl -X POST "$BASE_URL/auth" \
  -H "x-client-id: $CLIENT_ID" \
  -H "x-api-key: $API_KEY"
```

```json
{
  "result": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expiresAt": "2026-07-13T10:15:00.000Z"
  },
  "requestId": "b7a1f0c2-3d4e-4a5b-9c6d-7e8f90a1b2c3"
}
```

Present it on every other endpoint as `Authorization: Bearer <token>`. `/auth` is the only endpoint that does not require one.

## There is no refresh token

Tokens last **15 minutes** and that is the whole lifecycle. There is no refresh token, no `/refresh` endpoint, and no OAuth flow — when a token expires you call `/auth` again with the same credentials.

The practical pattern:

* **Cache the token** until `expiresAt` and reuse it. Do not call `/auth` before every request; it is a signing operation, not a free lookup.
* **Re-authenticate once on a `401`**, then replay the original request. If the replay also `401`s, stop — you have a credential problem, not an expiry problem.
* Refresh slightly early (say, 60 seconds before `expiresAt`) so a request in flight doesn't expire mid-call.

## `401` means two different things

This is the one status code you must disambiguate by reading `message`, because the correct response to each is opposite.

| `message`                  | Where                          | What it means                           | What to do                                           |
| -------------------------- | ------------------------------ | --------------------------------------- | ---------------------------------------------------- |
| `Missing or expired token` | any endpoint                   | Your bearer token is absent or expired. | Call `/auth`, retry the request. Safe and automatic. |
| `Invalid credentials`      | `/auth`                        | Bad client id or api key.               | Stop. Your app's credentials are wrong.              |
| `Invalid credentials`      | `/send`, `/extend` (EVM, Tron) | The **wallet password** is wrong.       | Stop. **Retrying is an infinite loop.**              |
| `Invalid password`         | `/send` (Solana)               | The wallet password is wrong.           | Stop.                                                |

Bitcoin is the odd one out: a wrong wallet password there comes back as **`400 Invalid password`**, not a `401`.

**A `401` on `/send` is the one failure you can trust completely.** The password is checked before anything is signed, so nothing was broadcast. Once you fix the password, retrying is entirely safe — no risk of a double-send. Contrast that with a `500` or a timeout, which is genuinely ambiguous — see [Reliability](/get-started/reliability).

## Behind a gateway: `X-USER-ID`

In production this service usually sits behind a trusted gateway that authenticates the end user and forwards their id in an **`X-USER-ID`** header.

**When that header is present, it overrides any `userId` in your request body.** The per-user scope comes from the authenticated session, not from free-form input — which is the point, but it will surprise you if you don't know:

* Calling **through the gateway**, the `userId` in your body is ignored. The header wins.
* Calling the service **directly** (local testing, a server-to-server integration with no gateway), there is no header, so the body `userId` is used — which is why every example in these docs puts it there.

If you are behind a gateway and your requests seem to operate on the wrong user, this is why.

## Rate limits

There are none. The service does not rate-limit `/auth`, `/create` or `/send`, and it will never return a `429` — abuse control belongs to the gateway in front of it.

That is not permission to hammer it. Because there is no push mechanism (see [Reliability](/get-started/reliability)), a naive integration polls every user against every asset on a tight loop and generates enormous load for no benefit. Throttle yourself: poll only the assets you actually support, and back off when nothing is changing.

## Credential issuance

Your operator issues the client id and api key, and shows them **once** — only a SHA-256 hash of the key is stored, so it cannot be recovered or re-displayed. Lost credentials are re-issued, not recovered. There is no self-service rotation endpoint; ask your operator.