Skip to main content

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" }
}
FieldMeaning
statusCodeHTTP status (400, 401, 403, 404, 409, 422, 429, 5xx).
messageHuman-readable description. Safe to log; do not parse.
codeStable, machine-readable error code — branch on this.
requestIdCorrelation id. Include it in support requests.
detailsOptional structured context (validation issues, the required scope, etc.).

Branch on code, never on message (which may change). Common codes you'll encounter:

codeTypical statusMeaning
UNAUTHENTICATED401Missing or invalid API key.
INSUFFICIENT_SCOPE403Key lacks the required scope.
TENANT_REQUIRED403Privy user with no tenant yet — bootstrap via POST /v1/tenants.
WALLET_NOT_FOUND404No wallet with that id in your tenant.
POLICY_DENIED403 / 422A spend policy rejected the payment.
TWOFA_REQUIRED403The transfer needs two-factor approval. details carries challengeId, requiredFactors, factors[], and the bound transaction. See Transfer protection.
NEW_PAYEE_NOT_SAVED403The destination is not a saved contact. Save it, then wait out the cooling-off window.
PAYEE_COOLING_OFF403The contact is younger than the cooling-off window. details.availableAt / details.secondsRemaining.
TWOFA_INVALID_CODE400Wrong code. details.attemptsRemaining.
TWOFA_TOO_MANY_ATTEMPTS403The challenge was cancelled after too many wrong codes. Start the transfer again.
TWOFA_CHALLENGE_EXPIRED403The challenge expired. Start the transfer again.
TWOFA_BINDING_MISMATCH403The replayed request differs from the one that was approved — the amount or destination changed.
TWOFA_CHALLENGE_NOT_REDEEMABLE403Already used, expired, or not yet satisfied.
TWOFA_NOT_ENROLLED403Protection is on but you have no usable second factor.
TWOFA_AGENT_NOT_PERMITTED403An 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):

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

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.

tip

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
},
})
Reuse the idempotency key

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.