Make it robust
Errors you act on by code, retries that can't charge twice, lists walked to the end.
Handle errors by their code
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 ordererrors-and-retries.ts
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; }}errors_and_retries.py
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")errors_and_retries/main.go
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, ¬Found): 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)}errors-and-retries.php
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 SDK examples look up order-1042, your paid test order by now. The cURL tab reads the same order
through the list: an order nobody paid is an empty page (200), never 404. Every error answers the
same 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"}error: A sentence you may show to a person.code: Stable: branch on it, with the HTTP status.details.errors: Each failing field, all at once.requestId: Log it, and quote it to support.
| Status and code | What it means | What to do |
|---|---|---|
400 validation_failed | A field is missing or wrong (details.errors) | Fix the request |
401 unauthorized | The key is missing, mistyped, revoked or expired | Check QBITFLOW_API_KEY |
404 not_found | Unknown, not yours, or the other mode's: QBitFlow never says which | Check the id and the key's mode |
409 unique_violation | A reference already used (details.field) | Use a new reference |
409 merchant_not_ready | Your wallets accept nothing a checkout could take | Add a wallet, or currencies |
429 rate_limit_exceeded | Too many requests | Wait, then retry (below) |
500, 503, 504 | QBitFlow's side | Retry (the SDKs do) |
Retry without charging twice
retries-idempotency.sh
# A key derived from the order: a retry (a timeout, a 5xx, a job re-run after a crash) sends the# same one and gets the first answer again, with Idempotent-Replayed: true, instead of a second# checkout. Retry 429s after Retry-After, with the same key.curl https://api.qbitflow.app/v2/checkout-sessions/payment \ -H "X-API-Key: $QBITFLOW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: checkout-order-1042" \ -d '{ "productName": "T-shirt", "description": "Blue, size M", "priceUsd": "4.99", "reference": "order-1042" }'# 201: a CreatedCheckoutSession ({ "link": "https://…/checkout?uuid=pay@…", "uuid": "pay@…", "expiresAt": "…" })errors-and-retries.ts
// Reads and the 6 creates are retried on network errors, 5xx and 429, waiting 1 s, 2 s, 4 s…// (default: 3 retries; 0 turns them off). timeout bounds each attempt, in milliseconds.const client = QBitFlow.fromEnv({ maxRetries: 5, timeout: 15_000 }); // Each create sends an Idempotency-Key, the same on its retries. A key derived from the order// also covers a job re-run after a crash: the same key returns the first session instead of// opening a second one.const orderId = 'order-1042';const session = await client. checkoutSessions. createPayment( { productName: 'T-shirt', description: 'Blue, size M', priceUsd: '4.99', reference: orderId }, { idempotencyKey: `checkout-${orderId}` });console.log(`Redirect the customer to ${session.link}`);errors_and_retries.py
# Network errors, timeouts, 5xx and 429 are retried with back-off (3 retries by default).client = qbitflow. QBitFlow. from_env( max_retries=5) order_reference = "order-1042"session = client. checkout_sessions. create_payment( product_name="T-shirt", description="Blue, size M", price_usd="4.99", # a decimal string, never a float reference=order_reference, # A key derived from the order: any retry, even from another process after a crash, # returns this same checkout instead of creating a second one. options=qbitflow. RequestOptions( idempotency_key=f"checkout-{order_reference}"),)print("Send the customer to", session.link)errors_and_retries/main.go
// Reads and creates are retried on network errors, 5xx and 429 (1 s, 2 s, 4 s…);// WithMaxRetries(0) turns that off. The client reads QBITFLOW_API_KEY.client, err := qbitflow. NewClientFromEnv( qbitflow. WithMaxRetries( 5))if err != nil { log.Fatal(err)} // Each create sends an Idempotency-Key, the same on its retries. Derive your own from the// order to retry across processes: the same key returns the same session, never a second one.orderID := "order-1042"session, err := client. CheckoutSessions. CreatePayment( ctx, &qbitflow. CreatePaymentSessionParams{ ProductName: "T-shirt", Description: "Blue, size M", PriceUSD: "4.99", Reference: orderID, SuccessURL: "https://shop.example.com/orders/success?uuid=" + qbitflow.PlaceholderUUID, CancelURL: "https://shop.example.com/orders/cancel",}, qbitflow. WithIdempotencyKey( "checkout-"+orderID))var reused *qbitflow.IdempotencyErrorif errors.As(err, &reused) { log.Fatal("this key was already used with other params") // 422 idempotency_key_reused}if err != nil { log.Fatal(err)}fmt.Println("Redirect the customer to", session.Link)errors-and-retries.php
// Reads and creates are retried on network errors, 5xx and 429 (3 retries by default).$client = \QBitFlow\QBitFlow::fromEnv( maxRetries: 5); $orderId = 'order-1042';$session = $client->checkoutSessions->createPayment( new \QBitFlow\Params\CreatePaymentSessionParams( productName: 'T-shirt', description: 'Blue, size M', priceUsd: '4.99', reference: $orderId, successUrl: 'https://shop.example.com/orders/success?uuid=' . \QBitFlow\Placeholders::UUID, cancelUrl: 'https://shop.example.com/orders/cancel', ), // The same key returns the same session, even from another process after a crash. new \QBitFlow\RequestOptions( idempotencyKey: 'checkout-' . $orderId),);echo "Pay at {$session->link}\n";The SDKs retry network errors, 5xx and 429, waiting longer each time, and send one
Idempotency-Key across a create's retries. Derive your own key from the order, and a job re-run
after a crash is covered too.
- Your server → QBitFlow API: Creates, with Idempotency-Key: K
- QBitFlow API → Your server: 201: created
- Your server → QBitFlow API: The same request again, with K
- QBitFlow API → Your server: The first answer, Idempotent-Replayed: true
- Your server → QBitFlow API: Another body, with K
- QBitFlow API → Your server: 422 idempotency_key_reused
- Same key, same request, within 24 hours: the first answer again
(
Idempotent-Replayed: true). Another request:422 idempotency_key_reused; the first still running:409 idempotency_key_in_use. - Only a success is kept: after an error, fix the request and retry with the same key.
- The creates that take a key: checkouts, products, customers, webhook endpoints, invitations (idempotency).
Check it
Run the example twice with a fresh order id (order-1042 is taken: 409 unique_violation). Both
runs print the same checkout link: one checkout for that order.
Walk every page
pagination-iterate.sh
# The next page: pass the previous page's nextCursor back, verbatim, as cursor, until hasMore is# false (nextCursor is present only while hasMore is true).curl "https://api.qbitflow.app/v2/payments?createdAfter=2026-10-01T00:00:00Z&limit=50&cursor=$NEXT_CURSOR" \ -H "X-API-Key: $QBITFLOW_API_KEY"# 200: a page of Payment ({ items, hasMore, nextCursor? })// Every payment of the last 30 days: the iterator fetches the pages one at a time, as the loop// consumes them, and stops when you break (or after the page whose hasMore is false).const since = new Date(Date.now() - 30 * 24 * 3600 * 1000);let count = 0;for await (const payment of client.payments.iterate({ createdAfter: since, limit: 50 })) { count++; console.log(payment.uuid, payment.amountUsd, 'USD');}console.log(`${count} payments over the last 30 days`);# Fetches one page at a time, only as the loop consumes it; break stops the fetching. It# stops after the page whose has_more is False.for payment in client. payments. iterate( limit=50): print(payment.uuid, payment.amount_usd, "USD")// Fetches one page at a time, only as the loop consumes it; break stops the fetching.// It stops after the page whose HasMore is false.count := 0for payment, err := range client.Payments.Iterate(ctx, &qbitflow. PaymentListParams{Limit: 50}) { if err != nil { log.Fatal(err) } count++ fmt.Println(payment.UUID, payment.AmountUSD, "USD")}fmt.Println(count, "payments")// The next page is fetched when the loop needs it; it stops after the page whose hasMore is false.$count = 0;foreach ($client->payments->iterate(new \QBitFlow\Params\PaymentListParams( limit: 50)) as $payment) { $count++; echo "{$payment->uuid} {$payment->amountUsd} USD\n";}echo "{$count} payments\n";Pass each page's nextCursor back as cursor until hasMore is false. A limit above the list's maximum
is cut, never refused (page sizes). To keep up with new payments, prefer
webhooks; if you must poll, overlap your windows and deduplicate on uuid.
When you're rate limited
- 429 rate_limit_exceededA request past a limitNothing was done. Retry-After says how long to wait
- Wait Retry-After secondsThenSooner is refused again
- Send it again, once ✓ThenA create keeps its Idempotency-Key
A 429 did nothing. Wait Retry-After (also in details.retryAfterSeconds), then retry with the
same Idempotency-Key (rate limits).