API documentation
Quick startAuthenticationCreate a paymentRetrieve & listCancelStatusesWebhooksHosted checkoutErrorsGoing liveMore: test mode, usage, QR behaviour, open amount, branches, AI
Quick start
Create a payment from your server, send the customer to checkout_url (or show qr_image_url yourself), then wait for the payment.completed webhook or poll the payment until status is paid.
curl https://dalodesk.com/v1/payments \
-H "Authorization: Bearer dk_live_..." \
-H "Idempotency-Key: order_1024" \
-H "Content-Type: application/json" \
-d '{ "amount": "1.50", "currency": "USD", "reference_id": "order_1024",
"success_url": "https://shop.example/orders/1024" }'
201 Created
{
"id": "pay_8Qm2…",
"status": "pending",
"amount": "1.50",
"currency": "USD",
"reference_id": "order_1024",
"store_id": "store_…",
"branch": null,
"checkout_url": "https://dalodesk.com/pay/pay_8Qm2…?t=…",
"qr_image_url": "https://dalodesk.com/api/public/payments/pay_8Qm2…/qr.svg",
"expiresAt": "2026-10-09T05:19:00.000Z",
"createdAt": "2026-10-09T05:09:00.000Z",
"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;…;scheme=abamobilebank;end;" }
}
Each QR stays open for 5 minutes by default; the store can choose 1–30 minutes in its settings, and a single payment can set its own lifetime with expires_in (seconds). Lifetimes up to 3 days, for an order sent over Messenger that the buyer pays later, are a private beta enabled per store on request. expiresAt is exact. Creating a payment takes about a second. Send the buyer to checkout_url exactly as returned: it carries a short token that lets the page cancel the order; without it the page is view-only. Show qr_image_url for scanning, or render qr_string yourself; on the customer's own phone, send them to deeplink.ios or deeplink.android to open ABA Mobile with the QR pre-filled.
Authentication
Every store has its own API keys. Create one in the dashboard under API & webhooks; it is shown once. Send it as a bearer token:
Authorization: Bearer dk_live_… # real payments
Authorization: Bearer dk_test_… # test mode: simulated payments, no money
Keys belong to one store, so payments created with a key are always for that store's ABA account. Never put a key in browser or mobile app code; call Dalo from your server. Revoke a key from the dashboard if it leaks.
Create a payment
POST /v1/payments
| Field | Required | Notes |
|---|---|---|
amount | yes, unless open_amount | 0.01 to 10000, at most two decimals. String or number. |
open_amount | no | Beta. true creates a QR with no amount: the customer types the amount in their banking app. Send it instead of amount (sending both is a 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. Shown on the checkout page and in webhooks. |
expires_in | no | Seconds the QR stays open, 60 to 1800 (30 minutes). Longer lifetimes, up to 259200 (3 days), are a private beta enabled per store on request; without it they return 403 long_qr_not_enabled. Defaults to the store's QR lifetime. The returned expiresAt is exact. |
success_url | no | HTTPS URL (or a relative path). The checkout page redirects here about 2 seconds after the payment is confirmed. |
metadata | no | Any JSON object you want back in the payment and in webhooks. |
branch | no | A branch id (br_…) of the store, see Branches. Defaults to the API key's branch, if the key is bound to one; otherwise the payment is store-wide. |
The response includes livemode: true for a dk_live_ key, false for a dk_test_ key (see Test mode).
Idempotency. Send an Idempotency-Key header (or idempotency_key field) with a value unique to the order. Repeating the same request returns the original payment with 200 instead of creating a second QR.
One at a time per store. If a QR is still being generated for the same store you get 409 payment_creation_in_progress with a Retry-After header. Wait and retry.
Retrieve and list
GET /v1/payments/{id}
GET /v1/payments?status=paid&limit=50 # newest first, limit up to 100
GET /v1/payments?branch=br_… # one branch only
Polling every 2–3 seconds is fine while a checkout is open. For anything else use webhooks. A key only ever sees payments of its own mode: a live key gets 404 for a test payment and vice versa.
Cancel
POST /v1/payments/{id}/cancel
Only pending or scanned payments can be cancelled; otherwise you get 409 payment_not_cancellable. Cancelling does not reverse money that has already been paid.
Payment statuses
| Status | Meaning |
|---|---|
pending | QR created, waiting for the customer. |
scanned | ABA shows the QR was scanned. The customer still has to confirm in their app. Not proof of payment. |
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 QR's lifetime (5 minutes by default) passed without payment. Create a new payment to try again. |
failed | ABA showed a paid page that did not match the expected merchant, amount or currency. Check your ABA app before fulfilling anything. |
cancelled | Cancelled by you (API or dashboard) or by the customer on the checkout page. |
statusSource tells you what set the status (for example aba_confirmed, expiry_worker or simulated_paid in test mode). approvedAt is set when the payment became paid.
Webhooks
Add an HTTPS endpoint per store in the dashboard. You get a signing secret once (whsec_…). Dalo sends a POST for every status change:
| Event | When |
|---|---|
payment.scanned | QR scanned, not yet paid |
payment.completed | Paid and verified — the only event that should trigger fulfilment |
webhook.test | Sample event sent from the dashboard's Send test button; data.payment.test is true. Never fulfil on it |
payment.expired | Timed out |
payment.failed | Paid page did not match |
payment.cancelled | Cancelled |
POST https://shop.example/webhooks/dalo
X-KHQR-Event: payment.completed
X-KHQR-Delivery: del_…
X-KHQR-Livemode: true
X-KHQR-Signature: t=1760000000,v1=5f8a…
{ "id": "evt_…", "type": "payment.completed", "created": "2026-10-09T05:10:12.000Z", "livemode": true,
"data": { "payment": { "id": "pay_8Qm2…", "status": "paid", "livemode": true, "amount": "1.50",
"branch": { "id": "br_7Qx2", "name": "Riverside" }, … } } }
Test payments send the same events with "livemode": false and X-KHQR-Livemode: false, signed with the same secret. Use them to test your handler; fulfil only when livemode is true.
Verify the signature
Compute HMAC-SHA256 over t + "." + rawBody with your secret and compare it to v1 in constant time. Reject if t is more than 5 minutes old.
// Node.js (Express with raw body)
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header || "");
if (!m) return false;
if (Math.abs(Date.now() / 1000 - 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"));
}
app.post("/webhooks/dalo", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-KHQR-Signature"), process.env.DALO_WEBHOOK_SECRET)) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.type === "payment.completed" && event.livemode === true) fulfil(event.data.payment.reference_id, req.get("X-KHQR-Delivery"));
res.sendStatus(200);
});
Retries and duplicates. Reply with any 2xx within 8 seconds. Otherwise Dalo retries after 5 s, 30 s, 2 min, 10 min and 1 hour, then marks the delivery as dead-lettered (you can retry it from the dashboard). Retries reuse the same X-KHQR-Delivery id, so store it and ignore repeats.
Hosted checkout
checkout_url opens a mobile-friendly page with the QR, the store name and the amount. It refreshes itself every 2 seconds, shows "Payment received" when paid, and redirects to success_url if you set one. Customers can cancel from the page when a success_url is present. You can brand the page colour per store in the dashboard.
If you prefer your own page, embed qr_image_url (an image; PNG for ABA-linked stores) and poll GET /v1/payments/{id} from your server.
For a test payment the page shows a TEST MODE banner, a greyed-out placeholder QR and Simulate scanned / paid / expired buttons; redirects and polling behave exactly as for a live payment.
Errors
Errors return a JSON body with error (a stable code) and message (for humans).
| HTTP | error | What to do |
|---|---|---|
| 400 | invalid_amount, amount_not_allowed, invalid_expires_in, invalid_success_url, unsupported_currency, invalid_metadata, invalid_status | Fix the request. |
| 401 | unauthorized | Missing, revoked or wrong API key. |
| 402 | plan_limit_reached | The store's plan limit for this month is used up. Upgrade in the dashboard. |
| 403 | store_not_active, store_disabled, account_suspended, test_mode_only, long_qr_not_enabled | Finish store verification; the store was removed; contact support; or use a dk_test_ key to simulate. |
| 404 | payment_not_found, branch_not_found | Wrong id, or the payment belongs to another store or to the other mode (live vs test); or the branch is not one of this store's. |
| 409 | payment_creation_in_progress, payment_not_cancellable, payment_not_simulatable, store_not_linked, branch_not_active | Retry after the Retry-After seconds; the payment already ended; the store has no ABA link yet; or the branch's own link still needs its $0.01 verification. |
| 503 | provider_unavailable, provider_busy | ABA's page or the payment service is temporarily unavailable. Retry in a few seconds; show the customer a "try again" message. |
| 502 | provider_error | The QR could not be created. Retry once, then report it. |
Going live checklist
- Build and test with a
dk_test_key (Test mode): simulated payments, real webhooks, no money. - Verify the store with the $0.01 test in the dashboard (the live API returns
403 store_not_activeuntil then). - Create an API key for the store and keep it on your server.
- Add a webhook endpoint, store the secret, and verify signatures.
- Fulfil orders only on
payment.completed/status: "paid"withlivemode: true, never onscannedor on a test event. - Redirects: set a success and failure URL per store in Settings, or pass
success_urlper payment. The checkout appends?status=paid|expired|failed|cancelled&payment_id=…&reference_id=…. Treat the redirect as a hint only; confirm with the webhook orGET /v1/payments/{id}. - Handle
503and409with a retry and a friendly message. - Reconcile against your ABA app or statement for disputes; Dalo reports what ABA displays and is not the bank's ledger.
More
Everything below is optional. The sections above are enough to take a payment. Using an AI assistant to integrate? Jump to Build with an AI assistant.
Test mode
Test mode works like Stripe's: a test key (dk_test_…) uses every endpoint in this document, but every payment it creates is simulated. No ABA QR is generated, no money can move, and nothing it does counts toward your plan, your stats or the revenue in the dashboard. Test payments do fire webhooks, signed exactly like live ones, so you can test your handler end to end before a single cent moves. Test keys work even before the store is verified.
- Get a test key. Dashboard → your store → API keys → tick Test key → Create key. It starts with
dk_test_. - Create payments as usual.
POST /v1/paymentswith the test key returns the normal payment object with"livemode": false,"provider": "test", a 3-minuteexpiresAtand a workingcheckout_url/qr_image_url(a placeholder KHQR; banking apps will not pay it). - Choose the outcome. Either call the simulate endpoint, or open
checkout_url: the page shows a TEST MODE banner and the buttons Simulate scanned, Simulate paid, Simulate expired.
POST /v1/payments/{id}/simulate
Authorization: Bearer dk_test_…
Content-Type: application/json
{ "status": "paid" } # or "scanned", "expired", "failed"
→ 200 { "id": "pay_…", "status": "paid", "livemode": false, "statusSource": "simulated_paid", … }
Simulation is allowed only for test payments and only with a test key of the same store: a live key gets 403 test_mode_only; a payment that already ended gets 409 payment_not_simulatable; an unknown status gets 400 invalid_status. A payment left alone expires by itself after 3 minutes and sends payment.expired.
Telling live from test. Every payment object carries livemode (true or false). Every webhook event carries livemode at the top level and in data.payment, plus the header X-KHQR-Livemode: true|false. Fulfil orders only when livemode is true and the event is payment.completed.
Isolation. Live keys never see test payments and test keys never see live ones: GET, list and cancel are filtered by mode, and the other mode's ids return 404 payment_not_found. Idempotency keys are scoped per mode too. The dashboard shows both, with a Test badge and a Test filter in Payments. Test payments never send Telegram "Payment received" messages.
Usage and quota
GET /v1/usage returns where the account stands this calendar month: the same numbers that decide whether a create call is accepted. Quota is pooled across all stores of the account, so every key returns the same figures. Only paid live payments count; expired, cancelled and test payments never do.
{
"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 }
}
When remaining is 0, creating a live payment returns 402 plan_limit_reached until the next month or an upgrade.
How the QR behaves (what we verified)
- Every payment has its own QR. The callback is tied to that QR, never to the amount. Two customers with open QRs for the same amount (say 23.75 USD each) never get mixed up: paying one marks only that payment
paid. - A QR with no amount also triggers the callback. Verified on 16 October 2026: 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.
- The QR itself outlives
expiresAt.expiresAtis how long Dalo watches the payment (5 minutes by default, 1 to 30 minutes per store, or longer per payment withexpires_in). Lifetimes up to 3 days are a private beta, enabled per store on request: payments after 2.5 hours and after 21 hours are verified, 3 days is being verified. The bank keeps accepting the QR for much longer and Dalo cannot cancel it there. A customer who pays an old screenshot afterexpiredorcancelledpays the merchant, but Dalo will not report it aspaid. Check the ABA statement and use "Mark paid" in the dashboard when that happens.
Open-amount QR (beta)
For orders where the customer chooses the amount (a tip, a donation, a top-up), 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 and "amount_status": "pending". The customer scans the QR and types the amount in their banking app. When it is 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.completedcarries the finalamount. The webhook forunverifiedpayments hasamount: null: never fulfil those automatically.- The customer can pay any amount the bank allows (up to 100,000 USD per transaction), so always compare the paid
amountwith what you expect before fulfilling. - Test mode: create with a
dk_test_key, thenPOST /v1/payments/{id}/simulatewith{"status":"paid","amount":"12.50"}. The hosted checkout's Simulate paid button uses 1.00. - Errors:
400 amount_not_allowed(sent both),409 open_amount_unavailable,503 provider_unavailable(open-amount QR codes have no fallback). - Beta: reading the receipt depends on the bank's receipt layout, and a banking app may occasionally refuse a no-amount QR ("something went wrong, try again later"). Use fixed-amount payments whenever you know the price.
Branches
A store is one shop. If the shop has several branches, add them in the dashboard (Settings → Branches, up to 20 per store; they do not count toward your 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 like the store), and either shares the store's Telegram chat or has its own. The Telegram "Payment received" message names the branch.
- Route a payment to a branch with
"branch": "br_…"inPOST /v1/payments, or bind an API key to a branch in the dashboard so every payment it creates defaults to that branch (an explicitbranchstill wins). A branch with its own link gets its QR on that link; a branch sharing the store's link gets the store's QR. - Every payment carries
"branch": { "id": "br_…", "name": "Riverside" }ornull, in API responses and in webhookdata.payment. List one branch withGET /v1/payments?branch=br_…; the dashboard filters and the CSV export have a branch column. - Errors:
404 branch_not_foundfor an id outside this store,409 branch_not_activewhile the branch's own link is unverified (test keys are exempt).
Build with an AI assistant
Dalo is small enough for an AI coding assistant to integrate in one sitting. Three steps:
- Copy the prompt for your stack. It is a complete brief: every endpoint, field, status and error code, webhook signature verification, redirect handling and the security rules, plus framework-specific instructions. Generated from the same source as this page.
- Paste it into your assistant (Claude Code, Cursor, Copilot, ChatGPT, Windsurf…) inside your project and let it build the three pieces: the server-side create-payment step, the webhook handler and the return page.
- Test, then go live. Create a test API key (
dk_test_) and a webhook endpoint in the dashboard, run your flow end to end with simulated payments (see Test mode), then swap in a live key and run the $0.01 store verification.
Preview the prompt
Loading…Agents that can browse can read llms-full.txt (the full reference as plain text), llms.txt or openapi.json directly.
Questions? Email support@dalodesk.com or message @dalodeskbot on Telegram. See also the Terms of Service and Privacy Policy.