# Dalo API reference (complete, for AI agents) Dalo turns a merchant's public ABA Payment Link into a payment API for Cambodia. You create an ABA KHQR payment from your server, send the customer to a hosted checkout page (or show the QR image yourself), and learn the outcome through a signed webhook or by polling the payment. This document is generated from the real API and is the authoritative reference; prefer it over assumptions carried over from other payment gateways. Machine-readable spec: https://dalodesk.com/openapi.json (OpenAPI 3.1) Human docs: https://dalodesk.com/docs Dashboard (API keys, webhooks, redirect URLs): https://dalodesk.com/app ## Essentials - Base URL: https://dalodesk.com - Every request and response is JSON (`Content-Type: application/json`). - Authentication: `Authorization: Bearer dk_live_...` using a store API key created in the dashboard under "API keys". The key is shown once. Keys belong to one store, so payments made with a key are always for that store's ABA account. Keys come in two modes: `dk_live_...` creates real payments; `dk_test_...` creates simulated test payments (see "Test mode"). - The API key must live only on the server, in an environment variable (for example `DALO_API_KEY`). Never put it in browser, mobile or client-bundled code, and never commit it. The browser talks to your server; your server talks to Dalo. - Amounts are strings with two decimals (`"1.50"`). Currency is always `USD`. - Test mode (Stripe-style): create a test key in the dashboard (API keys, tick "Test key"). It works on every endpoint below but every payment it creates has `livemode: false`, carries a placeholder QR that no bank will pay, never counts toward plan limits or statistics, and is moved to `scanned`, `paid`, `expired` or `failed` with `POST /v1/payments/{id}/simulate` or the "Simulate" buttons on its checkout page. Webhooks for test payments are real, signed deliveries with `livemode: false` in the body and `X-KHQR-Livemode: false` in the headers. Test keys work before the store is verified. - Creating a payment takes about a second and returns a real ABA KHQR for the exact amount. Use `checkout_url` exactly as returned: its `t` token lets the buyer cancel from the page; without it the page is view-only. The QR stays open for 5 minutes by default (the store can set 1-30 minutes); a single payment can ask for 60 seconds to 3 days with `expires_in`, and `expiresAt` is exact. Create the payment when the customer is ready to pay, not when the order is placed. - Every live payment carries `qr_string` (the KHQR payload, render it yourself if you like) and `deeplink` (`ios` and `android` links that open ABA Mobile with the QR pre-filled, for customers paying on the same phone). - Money has arrived only when a payment's `status` is `paid` and `livemode` is `true`, which is the `payment.completed` webhook event with `livemode: true`. Never fulfil an order on `scanned`, on a redirect query string alone, on a `webhook.test` event, or on any event with `livemode: false`. ## Endpoints | Method | Path | Purpose | |---|---|---| | POST | /v1/payments | Create a payment (generates the ABA QR) | | GET | /v1/payments/{id} | Retrieve one payment | | GET | /v1/payments?status=&limit= | List the store's payments, newest first | | POST | /v1/payments/{id}/cancel | Cancel a `pending` or `scanned` payment | | POST | /v1/payments/{id}/simulate | Test mode only: move a test payment to `scanned`, `paid`, `expired` or `failed` | All five require the bearer API key. A key only ever sees payments of its own mode (live keys see live payments, test keys see test payments); the other mode's ids return `404 payment_not_found`. Any other path returns `404 {"error":"not_found"}`. ### POST /v1/payments Headers: `Authorization: Bearer dk_live_...` (or `dk_test_...` for a test payment), `Content-Type: application/json`, and `Idempotency-Key: ` (recommended; may instead be sent as `idempotency_key` in the body). Request body fields: | Field | Required | Rules | |---|---|---| | amount | yes, unless `open_amount` | 0.01 to 10000, at most two decimals. String or number (`"1.50"` or `1.5`). Returned as a two-decimal string. | | open_amount | no | Beta. `true` creates a QR with no amount; the customer types it in their banking app. Send it instead of `amount` (both: `400 amount_not_allowed`). See "Open-amount QR". | | currency | no | Must be `USD` (the store's settlement currency). Defaults to USD. | | reference_id | no | Your order id, up to 255 characters. Visible to anyone who has the checkout link, so never put personal or secret data in it. Shown on the checkout page, returned in the payment and in webhooks, and appended to redirects. Defaults to a generated `order_...` id. | | success_url | no | Absolute HTTPS URL or a path relative to the Dalo origin; must not contain credentials. The hosted checkout redirects here about 2 seconds after the payment is `paid`. Falls back to the store's success URL from the dashboard. Returned in the payment as `return_url`. | | metadata | no | Any JSON object of at most 4 KB serialised. Returned in the payment and in webhooks. | | expires_in | no | Seconds the QR stays open, 60 to 259200 (3 days); values outside the range are clamped, anything that is not a positive number is `400 invalid_expires_in`. Defaults to the store's QR lifetime (5 minutes unless changed in the dashboard). Values above 1800 need the per-store long-QR beta (private; otherwise `403 long_qr_not_enabled`). Use it for orders the buyer pays later, for example over Messenger. | | branch | no | A branch id (`br_...`) of this store, see "Branches". Defaults to the API key's branch when the key is bound to one; otherwise the payment is store-wide (`branch: null`). | | idempotency_key | no | Alternative to the `Idempotency-Key` header. | Responses: - `201` with the payment object (see "Payment object") when a new payment was created. - `200` with the original payment object when a payment with the same idempotency key already exists for this store. No second QR is created, so the same key is safe to retry. - Errors (see "Errors"): `400 invalid_amount | unsupported_currency | invalid_expires_in | invalid_success_url | invalid_metadata`, `401 unauthorized`, `402 plan_limit_reached`, `403 store_not_active | store_disabled | account_suspended`, `404 branch_not_found`, `409 payment_creation_in_progress` (with `Retry-After: 5`), `409 store_not_linked`, `409 branch_not_active`, `502 provider_error`, `503 provider_unavailable`, `503 provider_busy` (with `Retry-After: 3`). Only one QR can be generated per store at a time. A concurrent create for the same store gets `409 payment_creation_in_progress` with a `Retry-After` header (seconds); wait and retry. Idempotency-key guidance: a key is scoped to the store and to the mode (live or test) and maps to exactly one payment forever. Use the order id plus an attempt counter (for example `order_1024_1`). Reuse the key while that payment is still `pending`; after it expired or was cancelled, create the next payment with a new key (`order_1024_2`), because the old key keeps returning the dead payment. Example: ``` POST /v1/payments Authorization: Bearer dk_live_... Idempotency-Key: order_1024_1 Content-Type: application/json {"amount":"1.50","currency":"USD","reference_id":"order_1024","success_url":"https://shop.example/orders/1024/return","metadata":{"customer_id":"c_42"}} ``` ``` 201 Created { "id": "pay_8Qm2kV3xLp9c", "status": "pending", "statusSource": "created", "livemode": true, "amount": "1.50", "currency": "USD", "reference_id": "order_1024", "metadata": {"customer_id": "c_42"}, "provider": "aba", "store_id": "store_4Hj7s", "branch": null, "verification": false, "approvedAt": null, "createdAt": "2026-10-09T05:09:00.000Z", "updatedAt": "2026-10-09T05:09:00.000Z", "expiresAt": "2026-10-09T05:19:00.000Z", "return_url": "https://shop.example/orders/1024/return", "checkout_url": "https://dalodesk.com/pay/pay_8Qm2kV3xLp9c?t=0f3c9a1e7b2d4c6a8e1f2b3d", "qr_image_url": "https://dalodesk.com/api/public/payments/pay_8Qm2kV3xLp9c/qr.svg", "qr_string": "00020101021230510016abaakhppxxx@abaa...6304A1B2", "deeplink": {"ios": "https://dpayment-epreprod.ababank.com/deep-link?type=payway&qrcode=...", "android": "intent://ababank.com?type=payway&qrcode=...#Intent;package=com.paygo24.ibank;S.browser_fallback_url=https://play.google.com/store/apps/details?id=com.paygo24.ibank;scheme=abamobilebank;end;"} } ``` ### GET /v1/payments/{id} Returns the payment object, or `404 {"error":"payment_not_found"}` when the id is wrong or the payment belongs to another store. Polling every 2-3 seconds is fine while a checkout is open; for anything else use webhooks. ### GET /v1/payments?status=paid&branch=br_...&limit=50 Returns `{"data":[ ...payment objects... ]}`, newest first. `status` filters on one status; `branch` (a `br_...` id of this store) limits the list to one branch (`404 branch_not_found` otherwise). `limit` is 1-100 and defaults to 20. There is no cursor or offset. ### POST /v1/payments/{id}/cancel No body. Allowed only while the payment is `pending` or `scanned`; the response is the payment object with `status: "cancelled"` and `statusSource: "merchant_cancelled"`. Otherwise `409 {"error":"payment_not_cancellable"}`. `404 payment_not_found` for unknown or foreign ids. Cancelling never reverses money that has already been paid. ### POST /v1/payments/{id}/simulate (test mode only) Body: `{"status": "scanned" | "paid" | "expired" | "failed"}`. Requires a `dk_test_` key and a test payment of the same store. Moves the payment to that status exactly as ABA would (so `paid` sets `approvedAt`, fires `payment.completed`, and the checkout page redirects to the success URL) and returns the payment object with `statusSource: "simulated_"`. Errors: `403 test_mode_only` when the key is a live key; `404 payment_not_found` when the payment is not a test payment of this store; `400 invalid_status`; `409 payment_not_simulatable` when the payment already ended. A test payment that is never simulated expires by itself after 3 minutes (`payment.expired`). Example: ``` POST /v1/payments/pay_T3stKV3xLp9c/simulate Authorization: Bearer dk_test_... Content-Type: application/json {"status":"paid"} ``` ``` 200 OK {"id":"pay_T3stKV3xLp9c","status":"paid","statusSource":"simulated_paid","livemode":false,"provider":"test", ...} ``` ### GET /v1/usage Where the account stands this calendar month, pooled across its stores: `{"plan":"free","limit":50,"used":12,"remaining":38,"period_start":"2026-10-01T00:00:00.000Z","period_end":"2026-11-01T00:00:00.000Z","stores":{"used":1,"limit":3}}`. Only paid live payments count. At `remaining` 0 a live create returns `402 plan_limit_reached`. ## Payment object The same object is returned by every endpoint and delivered as `data.payment` in webhooks. | Field | Type | Meaning | |---|---|---| | id | string | `pay_...` | | status | string | `pending`, `scanned`, `paid`, `expired`, `failed` or `cancelled` (see "Statuses") | | livemode | boolean | `true` for a real payment created with a `dk_live_` key; `false` for a simulated test payment (`dk_test_` key or the dashboard's "Test payment" checkbox). Fulfil only when `true` | | statusSource | string or null | Diagnostic: what set the status, for example `created`, `aba_confirmed`, `expiry_worker`, `merchant_cancelled`, `checkout_cancelled`, `simulated_paid`. Do not branch on it. | | amount | string | Two decimals, for example `"1.50"` | | currency | string | `USD` | | reference_id | string | Your order id (or a generated `order_...`) | | metadata | object or null | What you sent | | provider | string | `aba` for real ABA payments; `test` for test-mode payments; `demo` only on self-hosted development gateways | | store_id | string | `store_...` | | branch | object or null | `{"id": "br_...", "name": "Riverside"}` when the payment belongs to a branch of the store, else `null`. Also in webhooks. See "Branches" | | verification | boolean | `true` only for the 0.01 USD store-verification payment made from the dashboard | | providerVerification | object (optional) | Present once the provider confirmed the payment; diagnostic details of what ABA displayed | | approvedAt | ISO 8601 or null | When the payment became `paid` | | createdAt, updatedAt | ISO 8601 | Timestamps | | expiresAt | ISO 8601 | When the QR stops being accepted (5 minutes after creation by default; the store can set 1-30 minutes, and `expires_in` on the request can set 1 minute to 3 days) | | return_url | string (optional) | Effective success URL, from `success_url` or the store setting; absent when none | | checkout_url | string | Hosted checkout page `https://dalodesk.com/pay/` | | qr_image_url | string | Public image of the QR, usable as an `` without authentication. The response `Content-Type` tells the format: usually SVG, sometimes PNG despite the `.svg` suffix. | | qr_string | string or null | KHQR payload text: the real ABA KHQR for live payments, a placeholder for test payments; `null` in rare cases (then use `qr_image_url`) | | deeplink | object or null | `{"ios": "...", "android": "..."}`: links that open ABA Mobile with this QR pre-filled, for a customer paying on the same phone (iOS/desktop universal link; Android intent URL). `null` for test payments and when `qr_string` is null | ## Statuses | status | Meaning | Action | |---|---|---| | pending | QR created, waiting for the customer | Show the QR or checkout page | | scanned | ABA shows the QR was scanned; the customer still has to confirm in their app | Not proof of payment. Keep waiting | | paid | Money arrived: ABA displayed the payment as paid with the expected merchant, amount and currency | Fulfil the order (only on this status) | | expired | The window passed without payment | Create a new payment (new idempotency key) to try again | | failed | ABA showed a paid page that did not match the expected merchant, amount or currency | Do not fulfil; the merchant checks the ABA app | | cancelled | Cancelled by the merchant (API or dashboard) or by the customer on the checkout page | Treat as not paid | `paid`, `expired`, `failed` and `cancelled` are final. A payment may jump straight from `pending` to `paid` without passing through `scanned`. ## How the QR behaves (verified) - Every payment has its own QR. The callback is tied to that QR, never to the amount, so two open QRs for the same amount never get mixed up: paying one marks only that payment `paid`. - Verified 2026-10-16: a QR created without an amount, paid for an amount the customer typed, was reported as paid within seconds. Dalo offers this as the beta "Open-amount QR" below. - `expiresAt` is how long Dalo watches a payment (5 minutes by default, 1 to 30 per store; up to 3 days per payment in the private long-QR beta). The bank keeps accepting the QR much longer and Dalo cannot cancel it there. If a customer pays an old screenshot after `expired` or `cancelled`, the merchant receives the money but Dalo does not report `paid`; check the ABA statement and use "Mark paid" in the dashboard. ## Open-amount QR (beta) For orders where the customer chooses the amount, create the payment with `open_amount: true` and no `amount`: `POST /v1/payments {"open_amount": true, "reference_id": "topup_77"}`. The payment starts with `amount: null`, `open_amount: true`, `amount_status: "pending"`. The customer scans the QR and types the amount in their banking app. When paid, Dalo reads the amount from the bank's receipt and sets `amount` and `amount_status`: | amount_status | Meaning | |---|---| | pending | Not paid yet; `amount` is null | | receipt | Paid; `amount` is the total printed on the bank's receipt | | unverified | Paid, but the receipt could not be read; `amount` stays null. Check the amount in the merchant's ABA app before fulfilling; the owner is alerted | | simulated | Test mode only: you supplied the amount to `simulate` | - `payment.completed` carries the final `amount`; for `unverified` payments it is null, so never fulfil those automatically. - The customer can pay any amount the bank allows (up to 100,000 USD per transaction): always compare the paid `amount` with what you expect before fulfilling. - Test mode: create with a `dk_test_` key, then `POST /v1/payments/{id}/simulate {"status":"paid","amount":"12.50"}` (the checkout page's Simulate paid button uses 1.00). - Errors: `400 amount_not_allowed` (both `amount` and `open_amount`), `409 open_amount_unavailable`, `503 provider_unavailable` (no fallback for open-amount QR codes). - Beta: reading the receipt depends on the bank's receipt layout, and a banking app may occasionally refuse a no-amount QR. Use fixed amounts whenever you know the price. ## Errors Errors are JSON: `{"error":"","message":""}`. | HTTP | error | What to do | |---|---|---| | 400 | invalid_amount, amount_not_allowed, invalid_expires_in, unsupported_currency, invalid_success_url, invalid_metadata, invalid_status | Fix the request | | 401 | unauthorized | Missing, revoked or wrong API key | | 402 | plan_limit_reached | The store's monthly paid-transaction limit is used up; upgrade in the dashboard | | 403 | store_not_active, store_disabled, account_suspended | Finish the 0.01 USD store verification in the dashboard (test keys are exempt); the store was removed; or contact support | | 403 | test_mode_only | Simulation needs a `dk_test_` key and a test payment | | 404 | payment_not_found | Wrong id, or the payment belongs to another store or to the other mode (live vs test) | | 404 | branch_not_found | The `branch` id is not a branch of this store | | 409 | branch_not_active | The branch has its own ABA Payment Link that is not verified yet (the merchant verifies it with $0.01 in the dashboard). Test keys are exempt | | 409 | payment_creation_in_progress | Another QR is being generated for this store. Wait `Retry-After` seconds (5) and retry | | 409 | payment_not_cancellable | The payment already ended | | 409 | payment_not_simulatable | The test payment already ended | | 409 | store_not_linked | The store has no ABA Payment Link yet | | 502 | provider_error | The QR could not be created. Retry once, then report it | | 503 | provider_unavailable, provider_busy | ABA's page or the payment service is temporarily unavailable. Retry after a few seconds (`Retry-After`) and show the customer a "try again" message | ## Branches A store is one shop. A shop with several branches stays one store; the merchant adds branches in the dashboard (Settings → Branches, at most 20 per store, not counted toward the plan's store limit). Each branch either shares the store's ABA Payment Link or has its own link (a separate ABA account, verified once with a $0.01 payment exactly like the store), and either shares the store's Telegram chat or has its own. - Routing: `POST /v1/payments` with `"branch": "br_..."` creates the payment for that branch. Without it, the payment goes to the API key's branch when the key was bound to one in the dashboard, else it is store-wide. An explicit `branch` always wins over the key's branch. - Which QR: a branch with its own verified link gets its QR on that link (the money lands in that branch's ABA account); a branch sharing the store's link gets the store's QR. - Every payment object carries `branch` (`{"id": "br_...", "name": "..."}` or `null`), including `data.payment` in webhooks, so one webhook endpoint can tell branches apart. `GET /v1/payments?branch=br_...` lists one branch's payments. The dashboard's Payments page filters by branch and the CSV export has a `branch` column. - Errors: `404 branch_not_found` (the id is not a branch of this store, or the branch was removed), `409 branch_not_active` (the branch's own link is still unverified; test-mode payments are allowed). ## Webhooks Add an HTTPS endpoint per store in the dashboard. You get a signing secret once (`whsec_...`); keep it in a server environment variable (for example `DALO_WEBHOOK_SECRET`). Dalo sends a `POST` to every enabled endpoint of the store whenever a payment changes status. Test payments send exactly the same events, signed with the same secret, with `livemode: false` at the top level of the body and in `data.payment`, and the header `X-KHQR-Livemode: false`. Live events carry `livemode: true` and `X-KHQR-Livemode: true`. Events (`type` in the body and the `X-KHQR-Event` header): | Event | When | |---|---| | payment.scanned | QR scanned, not yet paid (may be skipped) | | payment.completed | Paid and verified. The only event that should trigger fulfilment | | payment.expired | Timed out | | payment.failed | Paid page did not match | | payment.cancelled | Cancelled | | webhook.test | Sample sent from the dashboard's "Send test" button. `data.payment.test` is `true`, the delivery id is `test` and the event id starts with `evt_test_`. It looks like a paid payment; never fulfil on it | Request: ``` POST Content-Type: application/json X-KHQR-Event: payment.completed X-KHQR-Delivery: del_7KcZ2qa1 X-KHQR-Livemode: true X-KHQR-Signature: t=1760000000,v1=<64 hex chars> {"id":"evt_3m9Qx1Zb","type":"payment.completed","created":"2026-10-09T05:10:12.000Z","livemode":true,"data":{"payment":{ ...payment object with status "paid", livemode true and "branch": {"id": "br_7Qx2", "name": "Riverside"} or null... }}} ``` Signature verification (do this before trusting the body): 1. Read the raw request body bytes exactly as received. Do not parse and re-serialise JSON first; any whitespace or key-order change breaks the signature. In frameworks that parse JSON automatically, configure the webhook route to receive the raw body. 2. Parse `X-KHQR-Signature` with the pattern `^t=(\d+),v1=([a-f0-9]{64})$`. Reject if it does not match. 3. Reject if `t` (unix seconds) differs from the current time by more than 300 seconds. 4. Compute `HMAC-SHA256(secret, t + "." + rawBody)` as lowercase hex, where `secret` is the `whsec_...` string. 5. Compare it to `v1` with a constant-time comparison. Reject on mismatch with HTTP 401. 6. Only then parse the JSON. Handling rules: - Respond with any 2xx within 8 seconds. Do the slow work (emails, fulfilment) after responding or in a queue. - On failure or timeout Dalo retries after 5 s, 30 s, 2 min, 10 min and 1 hour (6 attempts in total), then dead-letters the delivery (it can be retried from the dashboard). Retries reuse the same `X-KHQR-Delivery` id, so store processed delivery ids and ignore repeats. - Fulfil only when `type === "payment.completed"` (equivalently `data.payment.status === "paid"`), `livemode === true`, and `data.payment.test` is not `true`. Events with `livemode: false` come from test payments: use them to exercise your handler, log them, and never ship goods for them. - Find your order by `data.payment.reference_id` (or by the `id` you stored when creating the payment), and check that `amount` and `currency` equal what the order expects before fulfilling. - Treat every event as possibly out of order or duplicated; your handler must be idempotent. Reference implementation (Node.js): ```js import crypto from "node:crypto"; function verifyDaloSignature(rawBody, header, secret, now = Date.now() / 1000) { const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || ""); if (!m) return false; if (Math.abs(now - Number(m[1])) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(m[2], "hex")); } ``` ## Redirects from the hosted checkout - Per store, the dashboard (Settings) holds a success URL and a failure URL. Per payment, `success_url` overrides the store's success URL. There is no per-payment failure URL. - When the payment becomes `paid`, the checkout page shows "Payment received" and, about 2 seconds later, redirects to the success URL. When it becomes `expired`, `failed` or `cancelled`, it redirects to the store's failure URL if one is set. - The checkout appends `?status=paid|expired|failed|cancelled&payment_id=pay_...&reference_id=...` to whichever URL it redirects to (`reference_id` may be empty). - The query string is a hint for the user interface only. It can be forged or replayed. On the return page, read `payment_id`, call `GET /v1/payments/{id}` from your server (or check the order state your webhook handler already updated) and show the real status. - Customers can cancel from the checkout page while the payment is `pending` and a success URL is present. - If you build your own checkout page instead, embed `qr_image_url` in an `` and poll `GET /v1/payments/{id}` from your server every 2-3 seconds until the status is final. Never call Dalo from the browser. ## Hosted checkout page `checkout_url` opens a mobile-friendly page with the QR, the store name, the amount and `reference_id`. It refreshes itself every 2 seconds, shows "Payment received" when paid and redirects as described above. The page colour can be branded per store in the dashboard. It may be embedded in an iframe. For a test payment the page shows a "TEST MODE" banner, a greyed-out placeholder QR and three buttons, "Simulate scanned", "Simulate paid" and "Simulate expired", which call a public simulate endpoint that only accepts test payments. Redirects and polling then behave exactly as for a live payment. ## Test mode (summary) 1. Dashboard → store → API keys → tick "Test key" → Create key. The key starts with `dk_test_`. Test keys work before the store is verified. 2. Use it exactly like a live key. Every payment it creates has `livemode: false` and `provider: "test"`, expires after the store's QR lifetime (5 minutes by default), never counts toward plan limits, statistics or revenue, never sends Telegram messages, and is invisible to live keys (and live payments are invisible to test keys). 3. Choose the outcome with `POST /v1/payments/{id}/simulate` `{"status":"paid"}` (or `scanned`, `expired`, `failed`), or with the buttons on the checkout page. Webhooks fire as for live payments with `livemode: false` and `X-KHQR-Livemode: false`. 4. The dashboard also has a "Test payment" checkbox in "Create payment"; test payments show a "Test" badge and a "Test" filter in Payments. 5. Switch the environment variable to a `dk_live_` key to go live. Nothing else changes. ## Going live checklist 0. Build against a `dk_test_` key first: create test payments, simulate `paid`, `expired` and `failed`, and confirm your webhook handler verifies the signature and ignores `livemode: false`. 1. Verify the store with the 0.01 USD test in the dashboard (until then the live API returns `403 store_not_active`). 2. Create an API key for the store and keep it on the server only. 3. Add a webhook endpoint, store the secret, verify signatures, send a test from the dashboard and confirm it is rejected for fulfilment (`test: true`) but acknowledged with 2xx. 4. Fulfil orders only on `payment.completed` / `status: "paid"` with `livemode: true`. 5. Set the store's success and failure URLs, or pass `success_url` per payment. Confirm the real status on the return page. 6. Handle `503` and `409` with a short retry and a friendly message; handle `expired` by offering a fresh QR. 7. Reconcile against the ABA app or statement for disputes; Dalo reports what ABA displays and is not the bank's ledger.