Errors
Every non-2xx response uses one canonical envelope, emitted by the API's global exception filter:
{
"statusCode": 403,
"message": "Key is not permitted for payments:create",
"code": "INSUFFICIENT_SCOPE",
"requestId": "req_9f8e7d6c",
"details": { "required": "payments:create" }
}
| Field | Meaning |
|---|---|
statusCode | HTTP status (400, 401, 403, 404, 409, 422, 429, 5xx). |
message | Human-readable description. Safe to log; do not parse. |
code | Stable, machine-readable error code — branch on this. |
requestId | Correlation id. Include it in support requests. |
details | Optional structured context (validation issues, the required scope, etc.). |
Branch on code, never on message (which may change). Common codes you'll
encounter:
code | Typical status | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | Missing or invalid API key. |
INSUFFICIENT_SCOPE | 403 | Key lacks the required scope. |
TENANT_REQUIRED | 403 | Privy user with no tenant yet — bootstrap via POST /v1/tenants. |
WALLET_NOT_FOUND | 404 | No wallet with that id in your tenant. |
POLICY_DENIED | 403 / 422 | A spend policy rejected the payment. |
TWOFA_REQUIRED | 403 | The transfer needs two-factor approval. details carries challengeId, requiredFactors, factors[], and the bound transaction. See Transfer protection. |
NEW_PAYEE_NOT_SAVED | 403 | The destination is not a saved contact. Save it, then wait out the cooling-off window. |
PAYEE_COOLING_OFF | 403 | The contact is younger than the cooling-off window. details.availableAt / details.secondsRemaining. |
TWOFA_INVALID_CODE | 400 | Wrong code. details.attemptsRemaining. |
TWOFA_TOO_MANY_ATTEMPTS | 403 | The challenge was cancelled after too many wrong codes. Start the transfer again. |
TWOFA_CHALLENGE_EXPIRED | 403 | The challenge expired. Start the transfer again. |
TWOFA_BINDING_MISMATCH | 403 | The replayed request differs from the one that was approved — the amount or destination changed. |
TWOFA_CHALLENGE_NOT_REDEEMABLE | 403 | Already used, expired, or not yet satisfied. |
TWOFA_NOT_ENROLLED | 403 | Protection is on but you have no usable second factor. |
TWOFA_AGENT_NOT_PERMITTED | 403 | An API key cannot complete an interactive challenge. |
Surfacing errors in the SDK
The SDK maps the envelope onto a typed SafeBankApiError (for any non-2xx) and
SafeBankNetworkError (for DNS/connection/timeout failures):
- TypeScript
- curl
import { SafeBank, SafeBankApiError, SafeBankNetworkError } from '@safebank/sdk'
try {
await sb.payments.create({ /* … */ })
} catch (err) {
if (err instanceof SafeBankApiError) {
// Structured, stable fields from the envelope:
console.error(err.code, err.statusCode, err.requestId)
if (err.code === 'INSUFFICIENT_SCOPE') {
// e.g. re-issue the key with payments:create
}
console.error(err.details)
} else if (err instanceof SafeBankNetworkError) {
// Transport failure — err.cause has the underlying error.
console.error('network', err.cause)
} else {
throw err
}
}
# The raw envelope is the JSON body of any non-2xx response.
curl -i -X POST https://api.sandbox.safebank.ai/v1/payments \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "sourceWalletId": "nope" }'
SafeBankApiError exposes statusCode, code, requestId, and details
directly on the instance, plus the human message on .message.
Retries
The SDK automatically retries transient failures — 429, 502, 503,
504, and network errors — with jittered exponential backoff, honoring the
Retry-After header. It does not retry 4xx client errors (other than
429), because those won't succeed on retry. When retries do fire, the
idempotency key is reused so nothing double-executes.
Log requestId on every failure. It's the fastest way for support to trace a
request end-to-end.
Handling TWOFA_REQUIRED
The SDK throws a TwoFactorRequiredError (a subclass of SafeBankApiError, so
existing catch blocks keep working) carrying the challenge:
import { TwoFactorRequiredError, PayeeBlockedError } from '@safebank/sdk'
try {
await sb.payments.create({ /* … */ })
} catch (err) {
if (err instanceof TwoFactorRequiredError) {
console.log(err.challenge.transaction) // { amountUsd, destinationAddress, destinationLabel }
} else if (err instanceof PayeeBlockedError) {
console.log(err.reason, err.block.secondsRemaining)
}
}
Most callers should instead pass onTwoFactorRequired, which drives the
handshake and replays the original request with the same idempotency key:
const sb = new SafeBank({
apiKey: process.env.SAFEBANK_API_KEY!,
onTwoFactorRequired: async challenge => {
// Show challenge.transaction to the user VERBATIM, collect a code, then:
await sb.twoFactor.sendChallengeCode(challenge.challengeId, challenge.factors[0].id)
await sb.twoFactor.verifyChallenge(challenge.challengeId, challenge.factors[0].id, { code })
return challenge.challengeId
},
})
If you drive the handshake yourself and retry POST /v1/payments as a fresh
call, the SDK mints a new Idempotency-Key and the API treats the replay as
a separate payment. Pass the original key explicitly, or let
onTwoFactorRequired do it for you.