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 SDKs ship a typed exception hierarchy — one subclass per API error.type.
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 {} 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;}import time
from qairopay import ( QairoPay, AuthenticationError, # 401 NotFoundError, # 404 RateLimitError, # 429 — has .retry_after_ms UnprocessableEntityError, # 422 — has .field_errors CardDeclinedError, # 402 — has .decline_reason InternalServerError, # 500 ServiceUnavailableError,# 503 QairoPayError, # base catch-all)
qp = QairoPay(api_key="qp_sk_sandbox_example")
try: pass_ = qp.passes.create( template_id="tpl_01HZX...", holder_id="holder_01HZX...", )except AuthenticationError: raise SystemExit("Check your API key")except NotFoundError as err: print(f"Not found: {err.message}")except CardDeclinedError as err: print(f"Declined: {err.decline_reason}")except RateLimitError as err: wait_ms = err.retry_after_ms or 1000 time.sleep(wait_ms / 1000)except UnprocessableEntityError as err: for field, msgs in err.field_errors.items(): print(f"{field}: {', '.join(msgs)}")except QairoPayError as err: # Catch-all — forward-compat for new error types in future releases. print(f"[{err.status_code}] {err.message} (request_id={err.request_id})")Forward-compat: 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 catch 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.