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:
trueonce the customer has gone down the OnePay bank-account + OTP path (the SDK has captured adebitorAccNumber).falsefor 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 |
Recommended failureCode values
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).