PPayNow Docs
Menu — Error Handling

Integration Guide

Error Handling

PaymentApiException, retry policy, and how the engine surfaces failures so the UI clears its loading state.

Every backend call goes through PaymentEngine, which wraps PaymentApi calls in a configurable RetryPolicy and surfaces failures as a typed exception.

PaymentApiException

class PaymentApiException implements Exception {
              final String message;        // human-readable
              final bool isRetryable;      // safe to retry?
              final int? statusCode;       // HTTP status when applicable
              final String? code;          // typed code, e.g. MERCHANT_CREDENTIALS_MISSING
            }
            

Common code values:

Code Meaning
MERCHANT_CREDENTIALS_MISSING Api-Key or Secret-Key not configured. Check your env.
GATEWAY_AUTH_REJECTED OnePay returned 4xx with no body — usually invalid credentials or unprovisioned merchant.
SESSION_NOT_FOUND Tried to act on a paymentId the SDK has no context for. Initiate first.
INVALID_RESPONSE Backend returned a non-JSON body where JSON was expected.
CONTEXT_MISSING Internal — the per-paymentId scratch state was lost (usually a stale tab).

Retry policy

RetryPolicy ships with sensible defaults:

const RetryPolicy(
              maxAttempts: 3,
              baseDelay: Duration(milliseconds: 250),
            );
            

Delay grows exponentially (baseDelay × 2^(attempt-1)). Override by passing your own to PaymentEngine — useful for tests that want zero delay.

A retry is only attempted when both:

  1. error.isRetryable is true (the API marks 408/425/429/5xx as retryable; 4xx as non-retryable).
  2. RetryPolicy.shouldRetry(error, attempt) returns true (or is null — defaults to "yes").

How the gateway surfaces errors

In gateway_host_app.dart, _runAction wraps every user-initiated call:

Future<void> _runAction(Future<void> Function() action) async {
              try {
                await action();
              } catch (error) {
                final friendly = _getUserFriendlyErrorMessage(error);
                setState(() {
                  _statusLine = friendly;
                  _errorMessage = friendly;
                });
                // Re-hydrate the session so CheckoutPage receives a PaymentEvent and
                // clears its loading modal — the engine doesn't emit on direct API
                // failures from pre-initiated paths.
                if (_session != null) component.sdk.restoreSession(_session!);
              }
            }
            

The pattern: catch the exception, show the message, and re-emit the current session so any "loading" UI clears.

Offline action queue (optional)

PaymentEngine accepts an OfflineActionStore. If a retryable error exhausts its retries while the customer's device is offline (no wifi, no data), the engine queues the action with its idempotency key. When the device comes back, call engine.replayQueuedActions() to drain the queue.

The default in-memory engine doesn't ship with a store; mobile hosts (when the Flutter SDK lands) plug in an SQLite-backed implementation.

Idempotency

Every state-changing request carries an idempotencyKey you control. Reusing the same key replays the response from the OnePay side instead of double-charging. Mint keys per logical action, not per HTTP retry — the engine's retry layer reuses the same key, which is correct.

sdk.generateQr(GenerateQrRequest(
              paymentId: 'wpr_42',
              idempotencyKey: 'idem_qr_$orderId',
              qrProvider: 'OnePay',
            ));