Skip to content

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.

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.

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 { ... } }

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"
}

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);

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

Carry the token on subsequent requests:

Terminal window
# Authorization Bearer (recommended)
curl https://gateway.testnet.hsuite.network/auth/validator/session \
-H "Authorization: Bearer q3Vx0kD8Zr7tN2bY5mWcJ1hP4sLaE6uFgK9oTiXyR0w"
# Or the dedicated validator-token header
curl 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=.

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"
}
}

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:

Terminal window
curl https://gateway.testnet.hsuite.network/api/v3/subscription/list \
-H "Authorization: Bearer <session-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

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/verify proves only “some wallet signed a string naming this app” → principal customer. An app-owner mint behind the developerWallet ownership proof → principal app-owner. Only app-owner carries 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.

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.

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.

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

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.