Rule verdict
A 403 rule deny is a rule verdict: the operation is disallowed as-is and
will not succeed on retry.
Every failed request comes back as JSON with an HTTP status code. The SDK wraps these in typed error classes so you don’t parse raw bodies by hand.
flowchart TD
Err["Request failed"] --> Code{"statusCode?"}
Code -->|"0 / 408"| Net["Network or timeout — retry"]
Code -->|"403"| Rule["Forbidden or blocked by a rule — do not retry"]
Code -->|"other 4xx"| Fix["Fix the request — do not retry"]
Code -->|"429"| Wait["Wait retryAfterSeconds / Retry-After — then retry"]
Code -->|"5xx"| Backoff["Retry with backoff"]
Every SDK call rejects with a typed error carrying the status code:
| Error class | Raised by | Fields |
|---|---|---|
SdkHttpError |
Core HTTP layer (most clients, including every BaasClient sub-client) |
message, statusCode, details (the parsed response body), isRetryable |
BaasError |
BaasClient setup (e.g. connectToCluster) |
message, statusCode, details |
ValidatorAuthError |
ValidatorAuthClient |
message, statusCode, details |
SmartEngineError |
Shared validation/config error base | message, code, statusCode, context, isRetryable |
import { BaasClient, SdkHttpError } from '@hsuite/smart-engines-sdk';
try { await baas.entities.transfer('entity-1', { chain: 'xrpl', to: 'rB...', amount: '1000000' });} catch (error) { if (error instanceof SdkHttpError) { console.error(`Error ${error.statusCode}: ${error.message}`); // error.details holds the server's parsed JSON body (when present) }}The validator, host and gateway return the same envelope for an ordinary HTTP error:
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "Human-readable error description", "isRetryable": false, "timestamp": "2026-11-14T12:00:00.000Z", "path": "/api/v3/baas/entities/entity-1/transfer", "method": "POST"}| Field | Type | Description |
|---|---|---|
statusCode |
number | HTTP status code |
error |
string | Machine code. For an ordinary HTTP error it follows the status: VALIDATION_ERROR (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), PAYLOAD_TOO_LARGE (413), RATE_LIMIT_EXCEEDED (429), EXTERNAL_SERVICE_ERROR (503), else UNKNOWN_ERROR. A typed error carries its own code instead (see below) |
message |
string | Human-readable message |
isRetryable |
boolean | true for 5xx |
timestamp, path, method |
string | Where and when it happened |
A typed platform error adds code, domain and retryable (with error and isRetryable mirroring them), plus retryAfterSeconds on a policy hold:
{ "statusCode": 429, "message": "Signing was denied by policy for entity 0.0.1234. Retry after 96 seconds.", "code": "POLICY_DENIED", "domain": "policy", "retryable": true, "retryAfterSeconds": 96, "timestamp": "2026-11-14T12:00:00.000Z", "path": "/api/v3/baas/entities/entity-1/transfer", "method": "POST", "error": "POLICY_DENIED", "isRetryable": true}The SDK lifts message into error.message and stores the entire parsed body in error.details.
| Code | Meaning | Retryable |
|---|---|---|
| 400 | Bad request — invalid input or failed validation | No |
| 401 | Unauthorized — missing, invalid or expired token | No |
| 403 | Forbidden — not allowed for this caller, a token gate, or an operation blocked by a rule | No |
| 404 | Not found (also returned for another customer’s object) | No |
| 408 | Request timeout (SDK client-side) | Yes |
| 422 | Unprocessable — the request is well formed but a precondition fails, e.g. an entity create whose XRPL payer does not exist on the validated ledger (payer-account-not-found) |
No (fix the precondition, then retry) |
| 429 | Too many requests — a rate or quota limit (including the gateway’s per-app, per-IP smart-app limit), or a policy cooldown/velocity hold (POLICY_DENIED) |
Yes (after Retry-After / retryAfterSeconds) |
| Code | Meaning | Retryable |
|---|---|---|
| 500 | Internal server error, or OPERATOR_CREDENTIAL_REJECTED: a dependency refused a credential the platform holds (for example the image registry while your app is deployed). It is not caused by your request or your subscription, and only the network’s operators can fix it |
Yes, except OPERATOR_CREDENTIAL_REJECTED (retryable: false) |
| 502 | Bad gateway | Yes |
| 503 | Service unavailable — no cluster reachable during discovery, or an endpoint whose backing dependency is unavailable (returned as 503, never a misleading 400) |
Yes |
| 504 | Gateway timeout | Yes |
A request that fails schema validation returns a 400 with error: "VALIDATION_ERROR". When the schema check names fields, they are listed in context.errors:
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "Validation failed", "context": { "errors": [{ "path": "chain", "message": "Invalid enum value", "code": "invalid_enum_value" }] }, "isRetryable": false}A structurally malformed destination — a bad XRPL classic address, or a Hedera
id that isn’t shard.realm.num — is caught as soon as the transfer is prepared
and returned as a 400, before any signing work happens.
XRPL returns:
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "Invalid XRPL destination address: 'rKnownScam0000000000000000000000000'"}Hedera names the offending field (from, to, or tokenId):
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "Invalid Hedera account id for 'to': 'rKnownScam0000000000000000000000000'"}A prepared transfer is valid only for a bounded window — on Hedera, your
co-signature must arrive inside the transaction’s validity window. If you
call executeWithdraw after that window, the cluster returns a 400 whose
message contains EXPIRED, the original transactionId, the expiry timestamp
and the recovery step:
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "… the prepared transfer for entity '0.0.1234' (transactionId 0.0.100@169...) EXPIRED at 2026-08-28T12:00:00.000Z — Hedera partial signing must complete within the transaction validity window. …"}On Hedera, executeWithdraw co-signs only the transfer its prepareWithdraw
produced. The validators keep the prepared bytes on the operation’s record, find it
by the idempotencyKey the prepare returned, and require the transaction they are
about to sign to be exactly that transfer: the recipient, amount, token, memo, payer,
valid start and node must all match. Only the signatures may differ, since the
prepared bytes are unsigned and yours are signed. Anything else is refused as a 400
before any signing round:
{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "… the transaction to co-sign ([email protected]) is not the transfer this operation prepared ([email protected]). … Fetch the current prepared transaction and sign that one instead"}The usual causes are signing the bytes of an earlier prepare after the operation was prepared again (the first one expired and was rebuilt), and sending a transaction you assembled yourself instead of the one the prepare returned.
An executeWithdraw carries only the signed bytes, not a description of the
transfer, so on Hedera it must carry the idempotencyKey the prepare returned.
Without a key that resolves to the prepare’s record it is refused with
400 ... names no transfer: send the idempotencyKey of the prepare that built these bytes ....
When an operation is blocked by an on-chain rule (e.g. a destination that isn’t on
the entity’s allowlist), the validators refuse to sign and the call returns a 403.
The validators describe the deny with a structured rule_rejected envelope:
{ "statusCode": 403, "error": "rule_rejected", "reason": "soulbound_transfer_rejected", "ruleAtoms": ["SoulboundNftAtom"], "message": "soulbound_transfer_rejected", "isRetryable": false}| Field | Type | Meaning |
|---|---|---|
error |
'rule_rejected' |
The literal discriminator — this is how you know it’s a rule deny, not a generic 403 |
reason |
string | Human-readable prose for logs/UI (e.g. soulbound_transfer_rejected) |
ruleAtoms |
string[] | Machine-stable identifiers of the rule atom(s) that fired — e.g. ["WhitelistAtom"], ["SoulboundNftAtom"]. Branch on these, not on reason text |
message |
string | Mirror of reason, so error.message carries it too |
ruleAtoms are stable identifiers, not proseWhere the envelope is present, ruleAtoms carries the rule atom’s class-name identity (WhitelistAtom, SoulboundNftAtom, AmountLimitAtom, OperationEnabledAtom, …) — a value that does not drift when the human-readable reason is reworded. Branch on ruleAtoms; keep reason for humans and logs.
When a deny does not come from a rule atom — no rule check wired, or the check itself failed (both fail closed) — there is no atom identity to report, so ruleAtoms falls back to a single-element [reason] placeholder.
The SDK ships the isRuleRejected type guard for the full envelope. Because a deny through BaasClient arrives as a plain 403, check the status as well:
import { isRuleRejected, isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
try { await baas.entities.transfer('entity-1', { chain: 'xrpl', to: 'rDest...', amount: '1000000' });} catch (err) { if (isRuleRejected(err)) { console.log(`Blocked by ${err.details.ruleAtoms.join(', ')}: ${err.details.reason}`); return; } if (isSmartEngineSdkError(err) && err.statusCode === 403) { console.log(`Blocked: ${err.message}`); // the rule's reason, or an authorization refusal return; } throw err;}Creating an entity with an XRPL payer (payerAccountId opts a customer in as
the payer — see Entity creation is host-mediated)
runs an expensive per-entity DKG. The validators check that the payer exists on the
validated ledger before admitting the operation, publishing the rule, or running
the DKG, and refuse with a typed error — so a bad payer leaves nothing behind.
These codes pass through the host unchanged: both the exact status and the
error code reach your client, with a client-safe message.
{ "statusCode": 422, "error": "payer-account-not-found", "message": "… payer account 'rUXfdYQp6JWp7DHZbTNv9Zr8NokpQ8X1Hy' does not exist on the XRPL ledger — fund it with at least its own 1 XRP account reserve plus the funding amount and the fee before preparing; nothing was created", "isRetryable": false}error |
Status | When | Retry? |
|---|---|---|---|
payer-account-not-found |
422 |
The XRPL payer does not exist on the validated ledger (caught before the DKG; also the backstop if the payer is deleted between the check and the funding payment) | No — fund the payer, then retry |
payer-account-unverifiable |
503 |
The ledger could not be read to verify the payer, so the DKG was refused rather than run on an unknown payer (fail-closed) | Yes — retry after a backoff |
xrpl-ledger-unavailable |
503 |
A transient XRPL server error (noNetwork, tooBusy, slowDown, …) while preparing the funding payment |
Yes — retry after a backoff |
xrpl-funding-prepare-failed |
500 |
Any other XRPL server error while preparing the funding payment, named in the message | No — the request needs attention |
The 503 refusals never leak the ledger transport detail: the client body carries
only the payer address and the retry advice.
A 403 rule deny is a permanent block — the operation is disallowed by
policy and will never succeed as-is. A cooldown or velocity deny is different:
the same request will be allowed once enough time has passed. When the validators
refuse to sign because of a cooldown (e.g. cooldown active: 96s remain), the call
returns a 429 with code: "POLICY_DENIED" and a whole-second
retryAfterSeconds in the body (the second example under
Server error response shape). Read the countdown from
the body.
This is a deliberately minimal signal: the body carries the retry countdown
and nothing about the rule itself — no rule id, no configured limit, no
evaluator reason text. Treat it as “you asked too soon”, wait out
retryAfterSeconds, then retry the same request.
Distinguishing it in code: a POLICY_DENIED is retryable after a wait, whereas a
QUORUM_NOT_REACHED (409) means the validators genuinely could not form a
signing quorum. Do not conflate the two — a policy deny is the system saying
“not yet”, not “the network is down”.
import { isSmartEngineSdkError } from '@hsuite/smart-engines-sdk';
try { await baas.entities.transfer('entity-1', { chain: 'hedera', to: '0.0.5678', amount: '100000000' });} catch (error) { if (isSmartEngineSdkError(error) && error.statusCode === 429 && error.details?.code === 'POLICY_DENIED') { const wait = error.details.retryAfterSeconds ?? 60; console.log(`Policy cooldown — retry in ${wait}s`); return; // wait `wait` seconds, then retry the SAME request } throw error;}Entity value movement — transfer, prepareWithdraw / executeWithdraw, mint,
burn, compliance, trustline — goes through the host, which relays to the
validators (see API Overview).
The host keeps a validator’s 429 POLICY_DENIED as a 429 and carries
retryAfterSeconds through to your client.
A 429 POLICY_DENIED is never reported as a 403 rule deny, and vice versa —
they are two distinct verdicts.
Rule verdict
A 403 rule deny is a rule verdict: the operation is disallowed as-is and
will not succeed on retry.
Transient hold
A 429 POLICY_DENIED is a transient hold: the
same request succeeds once retryAfterSeconds have elapsed.
Keep the two branches separate in your handler.
The POLICY_DENIED 429 above is a signing cooldown. A second, unrelated 429
comes from the gateway: traffic it proxies to a deployed smart-app is
rate-limited per app and per client IP. When a caller spends its budget, the
gateway refuses the request before any proxy, body, or database work:
{ "error": "Too Many Requests", "message": "Rate limit exceeded; retry after 42s.", "retryAfter": 42}Every response on this path carries the standard rate-limit headers —
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset — and a refusal
adds Retry-After. The window defaults to 600 requests / 60 s per
(appId, clientIP). Note the body field here is retryAfter (seconds), not the
retryAfterSeconds of the signing-cooldown envelope.
202 admission, not an error)A wait: false entity create does not fail and does not block — it is
admitted and answered 202 Accepted with an operationId to poll (see
Async entity creation).
A 202 is a success, not an error; the operation’s outcome arrives later on
GET /api/v3/baas/operations/:operationId. Two of its outcomes need handling,
and the SDK raises a typed error for each (exported on the baas namespace) — but
neither is a request failure:
| SDK error | Meaning | What to do |
|---|---|---|
OperationPendingError |
Your client-side wait budget (timeoutMs, default 120000) expired while the operation was still pending. The ceremony keeps running server-side and stays answerable |
Not a failure. Re-invoke operations.wait(operationId) or poll operations.get(operationId); the entity is still being created |
OperationAbortedError |
The operation reached the terminal aborted state and will not finalize |
Start a fresh create; the status route holds the recorded reason |
Both carry the operationId and the last status snapshot. Retrying the
create itself is always safe: it is keyed by the idempotencyKey, so a retry
is answered with the first admission’s operation and never starts a second
ceremony or a second entity.
The async create refuses, before admitting anything, in exactly these cases
(each a plain 400): wait: false on a single-shot create (createToken /
createAccount / createTopic) for a chain other than Hedera, wait: false
without an idempotencyKey, and wait: false on an account-entity /transfer
(the operation-status contract cannot represent a transfer). The status route
itself answers 404 for an absent or not-owned operation (no existence oracle),
403 for a session not bound to an app, and 503 when the operation record is
unreachable.
The SDK exports typed capability errors — UnsupportedCapabilityError, CapabilityNotEnabledError and CapabilityValidationError — for an operation the requested chain does not support or has not enabled. Live chains are Hedera and XRPL; an operation on any other chain fails until that chain ships.
Retry only 5xx, 408, and (after the stated wait) 429. Never retry other 4xx errors — the request is wrong and won’t get better.
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3, baseDelayMs = 1000): Promise<T> { let lastError: unknown; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; const status = (error as { statusCode?: number }).statusCode ?? 0; // Don't retry client errors (except timeout 408 and 429). if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw error; const delay = baseDelayMs * 2 ** attempt + Math.random() * 1000; // exponential + jitter await new Promise((r) => setTimeout(r, delay)); } } throw lastError;}
const result = await withRetry(() => baas.entities.transfer('entity-1', { chain: 'xrpl', to: 'rDest...', amount: '1000000' }),);Retrying a transfer is safe: the validators merge a repeat of the same transfer (same entity, chain, destination, amount, token and memo) sent within 15 minutes into one operation.
| Symptom | Likely cause | Fix |
|---|---|---|
401 on every call |
Missing or expired token | Re-authenticate; check the Authorization header |
403 right after auth |
Token gate requirement not met, or the session is not allowed on that route | Verify the wallet holds the required NFT/token on that chain, and that the route serves your principal |
403 on a value-movement call |
A rule blocked the operation | Read message (the rule’s reason); the action is disallowed by policy — do not retry |
429 with POLICY_DENIED |
A policy cooldown/velocity limit refused the signing (temporary) | Wait retryAfterSeconds, then retry the same request |
429 (no POLICY_DENIED) |
A rate or subscription quota limit | Back off; consider an upgrade (see Subscriptions client) |
400 Invalid ... destination / Invalid Hedera account id |
Malformed destination address | Fix the address; the check fires before any signing or charge |
503 during connect |
No cluster reachable via the bootstrap seeds | Check network reachability / bootstrap URLs |
statusCode: 0 |
Network-level failure (no HTTP response) | Check connectivity, DNS, CORS |
408 |
Client-side timeout | Raise the client timeout, or retry |