Errors
Errors are part of the API contract. Every error response — 4xx and 5xx — has the same shape, and the SDKs surface them as typed exception classes.
Response shape
HTTP/1.1 400 Bad Request{ "error": { "type": "invalid_parameter", "code": "missing_field", "message": "Field `holder.email` is required.", "param": "holder.email", "request_id": "req_01HZX", "docs_url": "https://developers.qairopay.com/concepts/errors#missing_field" }}type— broad classification (see below).code— specific machine-readable identifier within the type.message— human-readable explanation, safe to surface to end users (it never includes raw input).param— forinvalid_parametererrors, the JSON pointer to the offending field.request_id— include this when you contact support.docs_url— links to the documentation for that specific code.
Error types
| Type | HTTP | Meaning | Recovery |
|---|---|---|---|
invalid_parameter | 400 | The request body failed validation. | Fix the body. Look at param. |
unauthenticated | 401 | No key, malformed key, or revoked key. | Check the Authorization header. |
insufficient_scope | 403 | Key is valid but lacks the required scope. | Use a key with the correct role. |
not_found | 404 | The resource doesn’t exist, or doesn’t exist in this tenant. | Confirm the id; cross-tenant access returns 404, not 403. |
idempotency_conflict | 409 | Same Idempotency-Key reused with a different body. | Generate a new key. |
idempotency_inflight | 409 | A retry arrived while the original is still running. | Wait retry_after_ms and retry. |
conflict | 409 | Resource state precludes the operation (e.g. revoking an already-revoked pass). | Fetch the current state and decide whether to proceed. |
rate_limited | 429 | You exceeded the rate limit for this endpoint. | Honor Retry-After. See Rate limits. |
validation_failed | 422 | Business-rule validation failed (e.g. KYB rejected, sanctions hit). | The code will tell you what; some are user-correctable, some require manual review. |
card_declined | 402 | Card-network decline. The processor decline reason is in code. | Surface to the user; some declines are retryable, some are not. |
provider_error | 502 | Upstream provider (Stripe, Persona, Bridge, card network) failed. | Retry with exponential backoff. |
internal_error | 500 | We dropped the ball. | Retry with exponential backoff. The platform’s SLA covers these. |
service_unavailable | 503 | Maintenance or partial outage. | Honor Retry-After. Check status. |
What’s retryable
- Always retry:
provider_error(502),internal_error(500),service_unavailable(503). - Retry with backoff:
rate_limited(429),idempotency_inflight(409). - Never retry without code changes:
invalid_parameter,unauthenticated,insufficient_scope,idempotency_conflict,validation_failed. - Depends on the decline code:
card_declined. The reference for each decline code says whether it’s terminal or retryable.
The TypeScript SDK retries idempotent retryable errors automatically (default: 3 attempts with exponential backoff). Disable per-call with { retries: 0 } if you need fine-grained control.
Catching errors in the SDK
The TypeScript SDK ships a typed exception hierarchy — one subclass per API error.type, plus three local-only subclasses for SDK-side conditions:
import { QairoPayError, // base — every subclass extends it InvalidParameterError, // 400 invalid_parameter UnauthenticatedError, // 401 unauthenticated InsufficientScopeError, // 403 insufficient_scope NotFoundError, // 404 not_found IdempotencyConflictError, // 409 idempotency_conflict IdempotencyInflightError, // 409 idempotency_inflight ConflictError, // 409 conflict ValidationFailedError, // 422 validation_failed RateLimitError, // 429 rate_limited CardDeclinedError, // 402 card_declined (.declineReason accessor) ProviderError, // 502 provider_error InternalError, // 500 internal_error ServiceUnavailableError, // 503 service_unavailable // Local-only — not produced by the API: TimeoutError, // outer timeout fired (.timeoutMs) MalformedResponseError, // 2xx with unparseable body (.rawBody) WebhookSignatureError, // verification failed (.reason)} from "@qairopay/sdk";
try { await qp.passes.create({ template_id: "tpl_x", holder: { email: "rider@example.com" } });} catch (err) { if (err instanceof InvalidParameterError) { // err.param identifies which field failed; err.code is machine-readable. return showFieldError(err.param!, err.message); } if (err instanceof CardDeclinedError) { // declineReason equals .code (e.g., "insufficient_funds"). return showDeclineUI(err.declineReason); } if (err instanceof RateLimitError) { // err.retryAfterMs from the platform; SDK already waited and retried up to budget. return scheduleRetry(err.retryAfterMs); } if (err instanceof QairoPayError) { // Catch-all for any platform error, including forward-compat for // new error.type values added in future API releases. console.error(err.requestId, err.type, err.code, err.message); } throw err;}Forward-compat (FR-018): if a future API release adds a new error.type the SDK doesn’t know about yet, it falls back to the base QairoPayError with every envelope field populated. Your existing instanceof QairoPayError blocks keep working — no upgrade required to handle unknown types safely.
Surfacing errors to end users
message is always safe to surface verbatim — it never includes secrets or unparsed user input. That said, you’ll usually want to translate it to your product’s tone. The TS SDK exposes a userMessage helper that returns a localized, customer-friendly version of the standard library of codes.
When in doubt
Include the request_id in any support ticket. Without it we can find your request, but it takes longer.