API documentation

Base URL: https://dalodesk.com (the earlier dalo-desk.dalo-chat.workers.dev address keeps working) · All requests and responses are JSON · Amounts are strings with two decimals · Currency is USD · OpenAPI 3.1 · llms.txt

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

FieldRequiredNotes
amountyes, unless open_amount0.01 to 10000, at most two decimals. String or number.
open_amountnoBeta. 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.
currencynoMust be USD (the store's settlement currency). Defaults to USD.
reference_idnoYour order id, up to 255 characters. Shown on the checkout page and in webhooks.
expires_innoSeconds 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_urlnoHTTPS URL (or a relative path). The checkout page redirects here about 2 seconds after the payment is confirmed.
metadatanoAny JSON object you want back in the payment and in webhooks.
branchnoA 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

StatusMeaning
pendingQR created, waiting for the customer.
scannedABA shows the QR was scanned. The customer still has to confirm in their app. Not proof of payment.
paidMoney arrived. ABA displayed the payment as paid with the expected merchant, amount and currency. Fulfil the order only on this status.
expiredThe QR's lifetime (5 minutes by default) passed without payment. Create a new payment to try again.
failedABA showed a paid page that did not match the expected merchant, amount or currency. Check your ABA app before fulfilling anything.
cancelledCancelled 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:

EventWhen
payment.scannedQR scanned, not yet paid
payment.completedPaid and verified — the only event that should trigger fulfilment
webhook.testSample event sent from the dashboard's Send test button; data.payment.test is true. Never fulfil on it
payment.expiredTimed out
payment.failedPaid page did not match
payment.cancelledCancelled
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).

HTTPerrorWhat to do
400invalid_amount, amount_not_allowed, invalid_expires_in, invalid_success_url, unsupported_currency, invalid_metadata, invalid_statusFix the request.
401unauthorizedMissing, revoked or wrong API key.
402plan_limit_reachedThe store's plan limit for this month is used up. Upgrade in the dashboard.
403store_not_active, store_disabled, account_suspended, test_mode_only, long_qr_not_enabledFinish store verification; the store was removed; contact support; or use a dk_test_ key to simulate.
404payment_not_found, branch_not_foundWrong 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.
409payment_creation_in_progress, payment_not_cancellable, payment_not_simulatable, store_not_linked, branch_not_activeRetry 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.
503provider_unavailable, provider_busyABA's page or the payment service is temporarily unavailable. Retry in a few seconds; show the customer a "try again" message.
502provider_errorThe QR could not be created. Retry once, then report it.

Going live checklist

  1. Build and test with a dk_test_ key (Test mode): simulated payments, real webhooks, no money.
  2. Verify the store with the $0.01 test in the dashboard (the live API returns 403 store_not_active until then).
  3. Create an API key for the store and keep it on your server.
  4. Add a webhook endpoint, store the secret, and verify signatures.
  5. Fulfil orders only on payment.completed / status: "paid" with livemode: true, never on scanned or on a test event.
  6. Redirects: set a success and failure URL per store in Settings, or pass success_url per payment. The checkout appends ?status=paid|expired|failed|cancelled&payment_id=…&reference_id=…. Treat the redirect as a hint only; confirm with the webhook or GET /v1/payments/{id}.
  7. Handle 503 and 409 with a retry and a friendly message.
  8. 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.

  1. Get a test key. Dashboard → your store → API keys → tick Test key → Create key. It starts with dk_test_.
  2. Create payments as usual. POST /v1/payments with the test key returns the normal payment object with "livemode": false, "provider": "test", a 3-minute expiresAt and a working checkout_url / qr_image_url (a placeholder KHQR; banking apps will not pay it).
  3. 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)

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_statusMeaning
pendingNot paid yet. amount is null.
receiptPaid. amount is the total printed on the bank's receipt.
unverifiedPaid, 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.
simulatedTest mode only: you supplied the amount to simulate.

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.

Build with an AI assistant

Dalo is small enough for an AI coding assistant to integrate in one sitting. Three steps:

  1. 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.
  2. 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.
  3. 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.
Download .md
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.