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

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 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"}
  1. error: A sentence you may show to a person.
  2. code: Stable: branch on it, with the HTTP status.
  3. details.errors: Each failing field, all at once.
  4. requestId: Log it, and quote it to support.
Status and codeWhat it meansWhat to do
400 validation_failedA field is missing or wrong (details.errors)Fix the request
401 unauthorizedThe key is missing, mistyped, revoked or expiredCheck QBITFLOW_API_KEY
404 not_foundUnknown, not yours, or the other mode's: QBitFlow never says whichCheck the id and the key's mode
409 unique_violationA reference already used (details.field)Use a new reference
409 merchant_not_readyYour wallets accept nothing a checkout could takeAdd a wallet, or currencies
429 rate_limit_exceededToo many requestsWait, then retry (below)
500, 503, 504QBitFlow's sideRetry (the SDKs do)

Retry without charging twice

Retries and idempotency keys

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": "…" })
// 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}`);
# 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)
// 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)
// 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.

  1. Your server → QBitFlow API: Creates, with Idempotency-Key: K
  2. QBitFlow API → Your server: 201: created
  3. Your server → QBitFlow API: The same request again, with K
  4. QBitFlow API → Your server: The first answer, Idempotent-Replayed: true
  5. Your server → QBitFlow API: Another body, with K
  6. 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

Iterate over 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

  1. 429 rate_limit_exceededA request past a limitNothing was done. Retry-After says how long to wait
  2. Wait Retry-After secondsThenSooner is refused again
  3. 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).