How-to

Handle errors

One body for every failure. Branch on its code, never on its sentence.

Handle errors

errors-handling.sh

# A failure answers its HTTP status and { error, code, details, requestId }: branch on the status# and the code, quote requestId to support. An order nobody paid yet is not an error here: the# page has no item.curl "https://api.qbitflow.app/v2/payments?reference=order-1042" \  -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a page of Payment ({ items, hasMore }), items empty when nobody paid that order
try {	const payment = await client.payments.getByReference('order-1042');	console.log(`order-1042 paid: ${payment.amountUsd} USD`);} catch (err) {	if (err instanceof NotFoundError) {		console.log('no payment for order-1042 yet');	} else if (err instanceof ValidationError) {		// Refused before sending (status undefined) or by the API (400).		for (const f of err.fieldErrors) console.log(`${f.field}: ${f.message}`);	} else if (err instanceof ApiError) {		// Branch on status and code, never on the message. Quote requestId to support.		console.log(`QBitFlow error ${err.status} ${err.code} (request ${err.requestId})`);		if (isRetryable(err)) console.log('transient: try again later');	} else {		throw err;	}}
try:    payment = client.payments.get_by_reference("order-1042")    print("paid:", payment.uuid)except qbitflow.ValidationError as exc:  # status None: refused before sending    for field_error in exc.field_errors:        print(f"{field_error.field}: {field_error.message}")except qbitflow.NotFoundError:    print("no payment for order-1042 yet")except qbitflow.ApiError as exc:  # the base of every error the SDK raises    print(f"QBitFlow error {exc.status} {exc.code} (request {exc.request_id})")    if qbitflow.is_retryable(exc):  # network, 5xx, 429: worth trying again later        print("transient: try again later")
payment, err := client.Payments.GetByReference(ctx, "order-1042")var (	validation *qbitflow.ValidationError	notFound   *qbitflow.NotFoundError	apiErr     *qbitflow.APIError)switch {case err == nil:	fmt.Println("order-1042 paid by", payment.UUID)case errors.As(err, &validation):	// StatusCode 0: refused before sending; 400: refused by the API.	for _, f := range validation.FieldErrors {		fmt.Printf("%s: %s\n", f.Field, f.Message)	}case errors.As(err, &notFound):	fmt.Println("no payment for order-1042")case errors.As(err, &apiErr):	// Every SDK error embeds an *APIError: branch on Code, quote RequestID to support.	fmt.Printf("QBitFlow error %d %s (request %s), retryable: %v\n",		apiErr.StatusCode, apiErr.Code, apiErr.RequestID, qbitflow.IsRetryable(err))default:	log.Fatal(err)}
try {	$payment = $client->payments->getByReference('order-1042');	echo "order-1042 was paid by {$payment->uuid}\n";} catch (\QBitFlow\Exceptions\ValidationException $e) {	foreach ($e->fieldErrors as $fieldError) { // refused before sending (status 0), or a 400		echo "{$fieldError->field}: {$fieldError->message}\n";	}} catch (\QBitFlow\Exceptions\NotFoundException) {	echo "No payment for order-1042 yet\n";} catch (\QBitFlow\Exceptions\ApiException $e) {	// Branch on apiCode, never on the message; quote the request id to support.	printf("QBitFlow error %d %s (request %s), retryable: %s\n",		$e->status, $e->apiCode, $e->requestId, $e->isRetryable() ? 'yes' : 'no');}

The SDKs raise one typed error per kind. Underneath, every failure answers this body:

An error body

{  "error": "Invalid request",  "code": "validation_failed",  "details": {    "errors": [{"field":"name","message":"name must be at least 2 characters"}]  },  "requestId": "0b9f6f3e-7c1a-4d8e-9a51-2f4c8e7d1a90"}
  1. error: A sentence, safe to show a person. Never branch on it.
  2. code: What to branch on, with the HTTP status.
  3. details.errors: Structured data for some codes: here, every failing field.
  4. requestId: Also the X-Request-Id header. Log it; quote it to support.

The codes you'll handle

Most integrations only ever meet these:

  • validation_failedHTTP 400

    path/query/body failed binding or validation, or a business rule about one input (a field named in errors)

    What to do: Fix the inputs details.errors lists: each field is the name you sent (dotted when nested), and every failing input is reported at once.

    details: errors: [{ field, message, reason? }]; with some, max / min (decimal strings for money)

  • unauthorizedHTTP 401

    missing / invalid JWT or API key, an ended login

    What to do: Check the key: a missing, unknown, deleted or expired key answers this.

  • forbiddenHTTP 403

    authenticated but not allowed (role, peer resource)

    What to do: The key can't do this: the route needs an admin key, the On-Behalf-Of value isn't a member this key may act for, or a key wrote a members.* policy (policies change in the dashboard).

  • not_foundHTTP 404

    unknown or out-of-scope resource; also any route hidden by a guard

    What to do: Check the id and the key's mode (test or live): a resource of another space answers the same, so existence is never revealed.

  • unique_violationHTTP 409

    a unique value already exists (e.g. a duplicate reference or email)

    What to do: details.field names the value already used in this space (the value itself is never echoed): pick another one.

    details: field (never the value)

  • merchant_not_readyHTTP 409

    opening a checkout nobody could pay: the space's wallets (the organization's, while it holds the member's funds) accept no currency, or no token for a subscription; a subscription for a member whose funds the organization holds (fundsHeld: one-time payments only until trusted) (Checkout sessions); at a payer's intent (POST /hosted/payments/intent, POST /hosted/subscriptions/intent), a member's checkout whose organization takes a fee on it and has no wallet on the paid currency's chain anymore (organizationFeeNoWallet: a guard, the fee never dropped from a new payment; removing the organization's wallet of a chain already removes that chain's currencies from its members, so an open checkout meets currency_not_accepted first; a subscription signed before keeps billing without the fee, Members and held funds)

    details: reason: noWallet, organizationNoWallet, noTokenWallet, fundsHeld, organizationFeeNoWallet

  • rate_limit_exceededHTTP 429

    rate limit hit (see below)

    What to do: Wait details.retryAfterSeconds (also sent as the Retry-After header), then send the same request again, with the same Idempotency-Key: the refused one did nothing.

    details: limit, periodSeconds, retryAfterSeconds; limitedBy (and what it counts) on a route with several limits

  • internalHTTP 500

    server error

    What to do: Retry a read or an idempotent create; quote requestId when asking support.

  • timeoutHTTP 504

    the request ran past the server's deadline: retry (idempotent reads) or check the resource before retrying a write

    What to do: Retry a read; check the resource before retrying a write.

Three rules that save debugging time

  • 404 hides existence. Another space's resource looks like none, and a test key never sees live data: check the key's mode before the id.
  • Every failing field comes at once in details.errors (frequency.unit when nested): map them onto your form in one pass.
  • Retry only what's transient: a timeout, a 5xx, a 429.

Avoid

Retry any write blindly

A 504 timeout may have done the work; a 4xx comes back until you change the request.

Do

Retry creates with their key

Same Idempotency-Key. Any other write: read the resource first.