Authentication
Smart Engines authenticates callers with a Web3 challenge-response flow: prove you own a wallet by signing a one-time challenge, exchange the signature for a session token, then carry that token on every request. No password, no key ever leaves your machine.
The SDK does all of this for you (see SDK Authentication); this page is the raw contract.
Chain-agnostic, XRPL default
Section titled “Chain-agnostic, XRPL default”Every auth request names its chain in the chain field — the API never assumes one. The cluster verifies signatures server-side per chain:
| Chain | chain value |
Status |
|---|---|---|
| Hedera | hedera |
Live |
| XRPL | xrpl |
Live |
| Stellar, Polkadot, Solana, Bitcoin | stellar, polkadot, solana, bitcoin |
Coming next |
| EVM (Ethereum, Polygon) | — | Postponed |
The examples on this page default to XRPL, where the HSUITE token and the subscription NFTs live.
The flow
Section titled “The flow”sequenceDiagram
participant C as Client
participant V as Cluster
C->>V: POST /auth/validator/challenge { chain, address }
V-->>C: { success, challenge, message, expiresIn }
C->>C: sign(challenge) with wallet key
C->>V: POST /auth/validator/authenticate { chain, address, publicKey, signature, challenge }
V-->>C: { token, sessionId, validatorId, expiresAt, sessionSecret, message }
C->>V: GET /auth/validator/session (Authorization: Bearer <token>)
V-->>C: { success, session { ... } }
Step 1 — Request a challenge
Section titled “Step 1 — Request a challenge”POST /auth/validator/challenge Public
Request body:
{ "chain": "xrpl", "address": "rYourAddress..."}Response:
{ "success": true, "challenge": "a1b2c3d4e5f6789...", "message": "Sign this challenge with your wallet private key", "expiresIn": "5 minutes"}Step 2 — Sign the challenge
Section titled “Step 2 — Sign the challenge”Sign the raw challenge string with your wallet key. The exact encoding is chain-specific.
XRPL (the SDK’s createXrplWeb3Signer does this for you — the verifier expects upper-hex UTF-8 bytes signed with ripple-keypairs):
import { Wallet } from 'xrpl';const { sign } = require('ripple-keypairs');
const wallet = Wallet.fromSeed('sYourSeed...');const messageHex = Buffer.from(challenge, 'utf-8').toString('hex').toUpperCase();const signature = sign(messageHex, wallet.privateKey);Hedera (ED25519):
import { PrivateKey } from '@hashgraph/sdk';
const privateKey = PrivateKey.fromString('your-private-key');const signature = Buffer.from(privateKey.sign(Buffer.from(challenge, 'utf-8'))).toString('hex');Step 3 — Authenticate
Section titled “Step 3 — Authenticate”POST /auth/validator/authenticate Public
Request body:
{ "chain": "xrpl", "address": "rYourAddress...", "publicKey": "ED01AB...", "signature": "3045022100...", "challenge": "a1b2c3d4e5f6789...", "metadata": { "nodeId": "optional", "endpoint": "optional", "capabilities": ["optional"] }}Response:
{ "token": "q3Vx0kD8Zr7tN2bY5mWcJ1hP4sLaE6uFgK9oTiXyR0w", "sessionId": "550e8400-e29b-41d4-a716-446655440000", "validatorId": "xrpl:rYourAddress...", "expiresAt": "2026-01-02T00:00:00.000Z", "sessionSecret": "9f2c...", "message": "Authentication successful. Use this token in X-Validator-Token header or Authorization: Bearer header"}The token is an opaque random string, not a JWT. A session lasts 24 hours from authentication (expiresAt).
Using the session token
Section titled “Using the session token”Carry the token on subsequent requests:
# Authorization Bearer (recommended)curl https://gateway.testnet.hsuite.network/auth/validator/session \ -H "Authorization: Bearer q3Vx0kD8Zr7tN2bY5mWcJ1hP4sLaE6uFgK9oTiXyR0w"
# Or the dedicated validator-token headercurl https://gateway.testnet.hsuite.network/auth/validator/session \ -H "X-Validator-Token: q3Vx0kD8Zr7tN2bY5mWcJ1hP4sLaE6uFgK9oTiXyR0w"Never put the token in a URL — a ?token= bearer is revoked
Section titled “Never put the token in a URL — a ?token= bearer is revoked”This holds for every bearer in a repeated
(?token=a&token=b) or bracketed (?token[k]=a) query too, and it still fires
when the request is answered on a different credential — e.g. a short-lived
stream ticket — carried alongside the leaked ?token=.
Inspecting a session
Section titled “Inspecting a session”GET /auth/validator/session Bearer token required
{ "success": true, "session": { "sessionId": "550e8400-e29b-41d4-a716-446655440000", "validatorId": "xrpl:rYourAddress...", "chain": "xrpl", "address": "rYourAddress...", "metadata": { }, "createdAt": "2026-01-01T00:00:00.000Z", "expiresAt": "2026-01-02T00:00:00.000Z", "lastActivityAt": "2026-01-01T12:00:00.000Z" }}API-key authentication
Section titled “API-key authentication”There are no developer API keys. Every developer call authenticates with a session bearer from the wallet flow above.
To call the API directly with a session, send the bearer:
curl https://gateway.testnet.hsuite.network/api/v3/subscription/list \ -H "Authorization: Bearer <session-token>"The SDK sends Authorization: Bearer for you when you construct an HttpClient with authToken:
import { createHttpClient, SubscriptionClient } from '@hsuite/smart-engines-sdk';
const http = createHttpClient({ baseUrl: 'https://gateway.testnet.hsuite.network/api/v3', authToken: '<session-token>',});const subscription = new SubscriptionClient(http);const mine = await subscription.list();Deployment-context auth (APP_TOKEN)
Section titled “Deployment-context auth (APP_TOKEN)”There is a third way to authenticate — one you never invoke by hand. When a
smart-app is deployed onto the cluster, the platform mints a per-app
APP_TOKEN and injects it into the running container, so that the
identical smart-app code could authenticate one way locally (developer wallet +
challenge) and another way in-cluster (injected APP_TOKEN), with no code change.
| Property | Value |
|---|---|
| Header | X-App-Token: <APP_TOKEN> (plus X-App-Id: <appId>) |
| What it is | A BLS-signed, per-app token, signed by the validators’ threshold-signing quorum |
| Injected by | The platform, at deploy time, into the app container’s environment — alongside the host’s in-cluster SMART_HOST_URL and APP_ID |
| Verified by | The host, checking the BLS signature and that the token’s appId matches the X-App-Id header — built, not yet enabled |
| Scope | The deployed app → the host. This is not the customer-session bearer (X-Customer-Session-Token), which authenticates end-user → smart-app calls |
The SDK reads the deployment context for you:
import { BaasClient } from '@hsuite/smart-engines-sdk';
// In-cluster: reads APP_TOKEN + internal SMART_HOST_URL from the injected env// and returns a client carrying that token. Returns null when that context// is absent (running locally), so you fall back to the challenge path.const baas = BaasClient.fromEnv(process.env) ?? (await localChallengeConnect());Forwarding a customer session (X-Customer-Session-Claim)
Section titled “Forwarding a customer session (X-Customer-Session-Claim)”A smart-app often calls the BaaS/host on behalf of one of its own end-users —
so the platform can attribute the call to that customer (metering, per-customer
quota) and scope reads to that customer’s rows. The app carries its own
session in Authorization and the customer’s session in a forwarding header:
| Header | Carries | Notes |
|---|---|---|
X-Customer-Session-Claim |
The customer’s opaque session claim — the bearer the customer received when it logged in (43+ base64url characters, no dots) | The call is attributed/scoped to that customer |
X-Customer-Session-Token |
The same customer session bearer | The older forwarding header. Some routes read only the claim and refuse a bare X-Customer-Session-Token; prefer X-Customer-Session-Claim |
Which routes require which principal
Section titled “Which routes require which principal”Holding a valid session is not the same as being allowed to call every route. Several routes are gated on who the caller is, not merely that they authenticated. Two distinctions matter:
- Session principal class. A BaaS session carries a class stamped by the
mint that issued it.
/baas/customer-session/verifyproves only “some wallet signed a string naming this app” → principalcustomer. An app-owner mint behind thedeveloperWalletownership proof → principalapp-owner. Onlyapp-ownercarries tenant-administrator authority. - In-cluster only. A handful of routes serve only HSuite’s own services and are never reachable with a public wallet bearer. See the three-posture model in API Overview.
A call with the wrong credential class is rejected — 401 for anonymous
where a session is required, 403 where an authenticated caller holds the wrong
principal class.
Membership routes (validator)
Section titled “Membership routes (validator)”The validator’s membership routes (operator applications, network lists, node status) are not reachable through the public gateway. The public view of membership is read-only, on the gateway, and needs no credential:
| Method | Route | Returns |
|---|---|---|
GET |
/api/v3/gov/membership/network-stats |
Network membership statistics |
GET |
/api/v3/gov/membership/config |
Membership configuration |
GET |
/api/v3/gov/membership/deposit-requirements |
Deposit requirements |
Applying, activating, renewing and leaving stay wallet-signed on the validator; the gateway forwards none of them.
BaaS admin data plane — app-owner only
Section titled “BaaS admin data plane — app-owner only”The BaaS admin data plane — the database, storage, and functions routes —
is gated on the app-owner session principal. A customer session (or any
unclassified session) that names the app is refused with 403 — “requires an
app-owner session” — even though its appId matches the route, because a
self-serve customer mint proves no ownership relationship to the tenant it names.
| Namespace | Who may call |
|---|---|
/api/v3/baas/db/* |
app-owner session only |
/api/v3/baas/storage/* |
app-owner session only |
/api/v3/baas/functions/* |
app-owner session only |
The check fails closed on every axis independently: no session, no :appId
param, an unbound session appId, a session appId that differs from the route,
or a principal that is not provably app-owner — each denies on its own. The
self-serve customer mint is untouched: customers still authenticate, still ride
/baas/customer-session/*, and still act on-behalf-of on the agents routes. What
they lose is administering someone else’s tenant.
Messaging tenant-admin routes — app-owner only
Section titled “Messaging tenant-admin routes — app-owner only”The realtime messaging plane stays open to any customer session for the pub/sub
routes that participate in a channel, but the five routes that administer the
tenant carry the same app-owner gate:
| Method | Route | Purpose |
|---|---|---|
POST |
/api/v3/baas/messaging/:appId/channels |
Create a channel |
PATCH |
/api/v3/baas/messaging/:appId/channels/:channel/shared |
Share or unshare a channel with every customer of the app |
DELETE |
/api/v3/baas/messaging/:appId/channels/:channel |
Delete a channel (and all its subscriptions) |
DELETE |
/api/v3/baas/messaging/:appId/channels/:channel/presence/:clientId |
Evict a client’s presence |
GET |
/api/v3/baas/messaging/:appId/stats |
Enumerate the tenant’s messaging usage |
Without this gate any customer session could plant/destroy channels or evict another customer’s live presence in the app it named.
Domain routes (gateway) — bound to the caller wallet
Section titled “Domain routes (gateway) — bound to the caller wallet”The gateway’s tenant domain routes (/api/v3/domains/*) resolve the session
wallet and fail closed on all seven mutating routes: no session is 401, and
a proven non-owner is 403 — “Domain not owned by caller”.
| Method | Route | Bound to caller by |
|---|---|---|
POST |
/api/v3/domains |
Registers under the caller as owner; a body-supplied owner is ignored |
POST |
/api/v3/domains/:domain/verification |
Owner check |
POST |
/api/v3/domains/:domain/verify |
Owner check |
POST |
/api/v3/domains/:domain/renew |
Owner check |
POST |
/api/v3/domains/:domain/transfer |
Owner check |
POST |
/api/v3/domains/:domain/transfer/approve |
Owner check |
POST |
/api/v3/domains/:domain/transfer/reject |
Owner check |
An unregistered domain is not rejected by the owner check; the request then fails the way every unknown-domain request does.
Public endpoints
Section titled “Public endpoints”These require no authentication:
| Endpoint | Description |
|---|---|
POST /auth/validator/challenge |
Request a challenge |
POST /auth/validator/authenticate |
Exchange a signature for a token |
GET /auth/validator/health |
Auth-subsystem health |
POST /api/v3/baas/auth/challenge |
Request a BaaS (app-bound) challenge |
POST /api/v3/baas/auth/verify |
Exchange a signed BaaS challenge for a session |
GET /api/v3/health |
Gateway health |
Token gating
Section titled “Token gating”When a cluster has token gating enabled, authenticate additionally checks that your address holds the required token/NFT on the chain you authenticated with. If the requirement isn’t met you get a 403 Forbidden with the reason. Cross-chain requirements can’t be satisfied by a single-chain address.