PPayNow Docs
Menu — Status Polling

Integration Guide

Status Polling

How the gateway detects settlement without user action — the /check-status request shape and the schedule that drives it.

Settlement happens outside the gateway tab — either the customer is paying from another app (QR flow) or the OnePay switch is debiting their bank asynchronously (OnePay account flow). The gateway runs a poller that keeps asking "is it done yet?" until it gets a terminal answer.

Endpoint

POST <baseUri>/wallet-service/wallet/payment-integration/web-payment/check-status
            Authorization: Bearer <session JWT>
            Content-Type: application/json
            
            {
              "byAccountNumber": false,
              "orderId": "<your orderId>"
            }
            

byAccountNumber is derived from the session state:

  • true once the customer has gone down the OnePay bank-account + OTP path (the SDK has captured a debitorAccNumber).
  • false for QR flows and PayNow-wallet flows.

The flag adapts mid-session — switching tabs from QR to Account flips it on the next poll automatically.

Response envelope

The endpoint follows the same envelope shape as the rest of OnePay's wallet-service API:

{
              "success": true,
              "message": "...",
              "data": { ... }
            }
            

success: true means the lookup itself worked — orthogonal to whether the payment succeeded. The paymentStatus field inside data is the source of truth.

Success response

{
              "success": true,
              "data": {
                "paymentStatus": "SUCCESS",
                "transactionId": "txn_018f7a3c1b9d",
                "referenceId": "ref_42",
                "hostReference": "host_ref_42",
                "dphReference": "dph_ref_42",
                "receiverName": "Bella Cart",
                "receiverAccountNumber": "9700001234",
                "amount": "25.900",
                "currency": "LYD",
                "orderId": "order_42",
                "completedAt": "2026-05-05T11:30:00Z",
                "message": "Payment confirmed and settled."
              }
            }
            

Failure response

A failed payment is HTTP 200 — the API call succeeded, the payment didn't. The gateway uses the body to render the failure reason on the receipt screen.

{
              "success": true,
              "data": {
                "paymentStatus": "FAILED",
                "failureCode": "INSUFFICIENT_FUNDS",
                "message": "The payer's account has insufficient balance.",
                "orderId": "order_42",
                "completedAt": "2026-05-05T11:30:00Z"
              }
            }
            

paymentStatus: EXPIRED uses the same shape with codes like QR_EXPIRED, OTP_TIMEOUT, or SESSION_TIMEOUT.

Pending response

{
              "success": true,
              "data": {
                "paymentStatus": "PENDING"
              }
            }
            

The poller treats PENDING / PROCESSING / AUTHORIZED as non-terminal and ticks again at the next interval.

Status string mapping

paymentStatus (or any of status / state / transactionStatus) is matched case-insensitively as a substring:

Contains Mapped state
SUCCESS / SETTLED / OK PaymentState.success (terminal)
FAIL / REJECT / DECLINE / ERROR PaymentState.failed (terminal)
EXPIRE / TIMEOUT PaymentState.expired (terminal)
PENDING / PROCESSING / AUTHORIZ PaymentState.authorized (still polling)

A response without any of those fields is treated as "still in progress" — the gateway returns the last-known session unchanged rather than guessing success and prematurely flipping the receipt.

What gets extracted

Field on PaymentSession Read from (in priority order) Populated when
transactionId transactionId / txnId / paymentId always when present
referenceId referenceId / reference / hostReference always when present
dphReference dphReference always when present
receiverName receiverName / creditorName / merchantName always when present
receiverAccountNumber receiverAccountNumber / creditorAccNumber / merchantAccountNumber always when present
completedAt completedAt / settledAt / paymentDate / transactionDate always when present (ISO-8601 UTC)
statusMessage message / statusMessage / description always (falls back to a generic string)
failureCode failureCode / failure_code / errorCode / reasonCode / code only on failed / expired

Stable codes let the gateway show targeted "try again" copy and let merchants slice failure rates by reason:

Code When Suggested customer copy
INSUFFICIENT_FUNDS Payer's account balance too low. Try a different account or top up.
ACCOUNT_BLOCKED Account blocked or restricted. Contact bank.
INVALID_OTP Wrong OTP entered. Retry (if attempts left).
OTP_RETRIES_EXHAUSTED Too many wrong OTPs. Restart checkout.
BANK_DECLINED Payer's bank declined the transaction. Try a different account / payment method.
FRAUD_REJECTED Flagged for review. Contact support.
LIMIT_EXCEEDED Daily / per-transaction limit hit. Try smaller amount or different account.
QR_EXPIRED QR not scanned in window. Restart checkout.
OTP_TIMEOUT OTP not entered in time. Restart checkout.
SESSION_TIMEOUT Session expired. Restart checkout.
UNKNOWN Catch-all when the backend can't categorise. Restart checkout.

Lookup-call failures (HTTP 4xx / 5xx)

Different scenario — the /check-status call itself errors out. Use the standard error envelope:

{
              "success": false,
              "message": "Session not found.",
              "code": "SESSION_NOT_FOUND"
            }
            

The SDK turns this into a typed PaymentApiException. isRetryable follows the status code:

Status Behaviour
408 / 425 / 429 / 5xx Retryable. The poller catches and re-ticks.
4xx (other) Non-retryable. The poller stops, the gateway shows an error.

Schedule

PollSchedule.standard()
              fastInterval = 3s
              slowInterval = 10s
              fastWindow   = 30s
              maxDuration  = 5 minutes
            

Worst-case (customer never pays) ~37 calls per checkout. Happy path (5–20 s settlement) is 2–7 calls. Override by passing a custom PollSchedule to PayNowWebSdk.startStatusPolling:

sdk.startStatusPolling(paymentId, schedule: const PollSchedule(
              fastInterval: Duration(seconds: 5),
              slowInterval: Duration(seconds: 15),
              fastWindow: Duration(minutes: 1),
              maxDuration: Duration(minutes: 10),
            ));
            

When the poller is active

The gateway page (gateway_host_app.dart) starts polling only on these screens:

  • QR view (PaymentState.qrGenerated)
  • OnePay account waiting view (PaymentState.waitingPayment)

Everywhere else — pre-init, OTP entry, terminal screens — polling is paused. This protects OnePay from unnecessary load and protects the customer's mobile data / battery.

Disabling polling

To turn polling off entirely (e.g. for tests or when running against a backend without a status endpoint), pass an empty statusPath on the config:

PayNowOnePayConfig(
              baseUri: ...,
              apiKey: ...,
              secretKey: ...,
              statusPath: '',
            );
            

PayNowOnePayApi.supportsStatusPolling reflects this and the SDK's startStatusPolling becomes a silent no-op. The manual "Refresh status" button still hits fetchStatus, which now returns the last-known session unchanged (no spurious state transitions).