Authentication

Two different secrets. Know which is which before you write any code.
View as MarkdownOpen in Claude

The two secrets

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

API credentialsWallet password
Whatclient id + api keyThe password you chose at /create
Identifiesyour applicationone user’s keystore
Used by/auth, to mint a bearer token/send and /extend, to decrypt the keys
Scopethe whole servicea single userId
RecoverableNo — re-issued by the operatorNo. Never. By anyone.
RotatableAsk your operatorNo — 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.

Store the wallet password durably, per user, from the moment you call /create — and never lose it. A lost password means the user’s funds cannot be moved. Not by you, and not by us. The password is fixed for the life of the wallet, and the seed phrase from /create is the only other way to reach those keys — shown exactly once, at creation.

Getting a token

Your client id and api key go in headers. Send the request with an empty body.

curl -X POST "$BASE_URL/auth" \
-H "x-client-id: $CLIENT_ID" \
-H "x-api-key: $API_KEY"
{
"result": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresAt": "2026-07-13T10:15:00.000Z",
"refreshToken": "5d41402abc4b2a76b9719d911017c592a1b2c3d4e5f60718293a4b5c6d7e8f90",
"refreshExpiresAt": "2026-07-20T10:00:00.000Z"
},
"requestId": "b7a1f0c2-3d4e-4a5b-9c6d-7e8f90a1b2c3"
}

You get two tokens. Present the token on every other endpoint as Authorization: Bearer <token>; keep the refreshToken to mint the next one. /auth and /refresh are the only endpoints that do not require a bearer token.

Token lifecycle

The access token lasts 15 minutes. The refreshToken lasts 7 days. When the access token expires, do not call /auth again — trade the refresh token at /refresh instead, so your api key stays off the wire after startup.

curl -X POST "$BASE_URL/refresh" \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "'"$REFRESH_TOKEN"'" }'

The response is the same shape as /auth: a new access token and a new refresh token.

Refresh tokens are single-use, and they rotate. Every /refresh consumes the token you presented and returns its replacement. Store the new one immediately — the one you just sent is dead.

Presenting an already-consumed token is what a stolen token looks like, so the service revokes the entire rotation chain: you are logged out too, and must go back to /auth with your api key. The practical consequence is that exactly one process may hold and roll a given refresh token. Two workers sharing one will race, one will replay, and both will be kicked out.

The practical pattern:

  • Cache the access token until expiresAt and reuse it. Do not mint one per request; it is a signing operation, not a free lookup.
  • Refresh slightly early (say, 60 seconds before expiresAt) so a request in flight doesn’t expire mid-call.
  • On a 401, refresh once and replay the original request. If the refresh itself 401s, fall back to /auth. If that 401s, stop — you have a credential problem, not an expiry problem.
  • Call /auth only at startup or after a refresh has been rejected.

401 means several different things

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

messageWhereWhat it meansWhat to do
Unauthorizedany authenticated endpointNo bearer token, or a malformed Authorization header.Send the header.
Invalid or expired tokenany authenticated endpointYour access token expired or does not verify.Call /refresh, retry the request. Safe and automatic.
Missing refresh token/refreshNo refreshToken in the body.Fix the request.
Invalid refresh token/refreshExpired, already consumed, or revoked by a replay.Go back to /auth. Retrying the refresh is an infinite loop.
Invalid credentials/auth, /refreshBad client id or api key — or your api client was deactivated.Stop. Your app’s credentials are wrong.
Invalid credentials/send, /extend (every chain)The wallet password is wrong.Stop. Retrying is an infinite loop.

A wrong wallet password is 401 Invalid credentials on every chain. Omitting password entirely on a Bitcoin /send is the one variation: that is a 400 Missing/Invalid password.

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.

X-USER-ID overrides the body

X-USER-ID is an optional request header. When it is present, it overrides the userId in your request body — the body value is ignored entirely.

  • When it is absent, the body userId is used. That is what these docs assume, and why every example puts userId in the body.
  • When it is present, that id wins, whatever the body says.

This will surprise you if you don’t know about it: your body is not silently ignored because it is malformed, but because a header outranked it. If your calls seem to operate on the wrong user, check whether an X-USER-ID is being attached to them somewhere along the way.

Throttling

Throttle yourself. Since you learn a wallet’s state by polling for it (see Reliability), a naive integration checks every user against every asset on a tight loop and generates enormous load for no benefit. Poll only the assets you actually support, stagger users across the interval, and back off when nothing is changing.

Your operator sets the request limits that apply to you — ask them what yours are before you design a polling schedule.

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. To rotate credentials, ask your operator.

You need a separate set per environment. Sandbox (https://api-demo.walletstech.com) and production (https://api.walletstech.com) do not share credentials — a sandbox client id and api key will not authenticate against production, and presenting them there is a 401 Invalid credentials, not a hint that something is misconfigured. Request sandbox credentials first, build against sandbox, then ask for production credentials when you are ready to go live.

If /auth or /refresh returns 403, your credentials are valid but the api client is not fully provisioned, so no token can be issued. This is not something you can fix from the client side — retrying, or falling back from /refresh to /auth, returns the same 403. Ask your operator to finish provisioning it.