{
  "openapi": "3.1.0",
  "info": {
    "title": "Dalo API",
    "version": "1.3.0",
    "summary": "ABA KHQR payments for Cambodian merchants",
    "description": "Create ABA KHQR payments from your server, send the customer to a hosted checkout page (or show the QR yourself), and learn the outcome through signed webhooks or by polling the payment.\n\nAll requests and responses are JSON. Amounts are strings with two decimals. Currency is USD. **Test mode** (Stripe-style): a `dk_test_...` key from the dashboard uses the same endpoints but creates simulated payments with `livemode: false` that never move money and never count toward plan limits or statistics. Drive them with `POST /v1/payments/{id}/simulate` or the Simulate buttons on the hosted checkout; webhooks fire exactly like live ones with `livemode: false` and `X-KHQR-Livemode: false`. Live keys never see test payments and vice versa. Fulfil orders only on `payment.completed` with `livemode: true`.\n\nNever put an API key in browser or mobile code; call Dalo from your server.\n\n**Branches**: a store (one shop) can have branches, each sharing the store's ABA Payment Link or carrying its own (a separate ABA account) and its own Telegram chat. Pass `branch` (a `br_...` id from the dashboard) when creating a payment, or bind an API key to a branch in the dashboard so its payments default to that branch. Every payment carries `branch` (`{id, name}` or `null`), also in webhooks; `GET /v1/payments?branch=` filters by branch.",
    "contact": {
      "email": "support@dalodesk.com", "url": "https://t.me/dalodeskbot"
    },
    "termsOfService": "https://dalodesk.com/terms"
  },
  "externalDocs": {
    "description": "Human-readable documentation",
    "url": "https://dalodesk.com/docs"
  },
  "servers": [
    {
      "url": "https://dalodesk.com"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "Payments",
      "description": "Create, read, list and cancel payments."
    },
    {
      "name": "Test mode",
      "description": "Simulated payments created with a `dk_test_` key. No money moves; webhooks still fire with `livemode: false`."
    }
  ],
  "paths": {
    "/v1/payments": {
      "post": {
        "tags": [
          "Payments"
        ],
        "operationId": "createPayment",
        "summary": "Create a payment",
        "description": "Returns a real ABA KHQR for the exact amount in about a second. The QR stays open for 5 minutes by default (store setting 1-30 minutes; `expiresAt` is exact), so create the payment when the customer is ready to pay, not when the order is placed. For an order the buyer pays later (for example over Messenger), send `expires_in` to keep the QR open for up to 3 days. Live payments carry `qr_string` and `deeplink` for your own checkout.\n\nSend an `Idempotency-Key` header unique to the order. Repeating a request with the same key returns the original payment with `200` instead of creating a second QR.\n\nOnly one payment can be generated per store at a time: a concurrent request gets `409 payment_creation_in_progress` with a `Retry-After` header.\n\nWith a `dk_test_...` key the payment is simulated: no ABA QR is generated (so the call is instant), `livemode` is `false`, `provider` is `test`, and nothing counts toward the plan. Test keys work before the store is verified.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePaymentRequest"
              },
              "example": {
                "amount": "1.50",
                "currency": "USD",
                "reference_id": "order_1024",
                "success_url": "https://shop.example/orders/1024",
                "metadata": {
                  "customer_id": "c_42"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A payment with the same `Idempotency-Key` already exists for this store; it is returned unchanged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                }
              }
            }
          },
          "201": {
            "description": "Payment created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                },
                "examples": {
                  "pending": {
                    "$ref": "#/components/examples/PaymentPending"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_amount`, `unsupported_currency`, `invalid_expires_in`, `invalid_success_url` or `invalid_metadata`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_amount",
                  "message": "Use at most two decimal places"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "`plan_limit_reached`: the store's monthly paid-transaction limit is used up. Upgrade in the dashboard.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`store_not_active` (finish the 0.01 USD verification in the dashboard), `store_disabled` or `account_suspended`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`payment_creation_in_progress` (retry after `Retry-After` seconds), `store_not_linked`, or `branch_not_active` (verify the branch's own ABA Payment Link with $0.01 in the dashboard first).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`provider_error`: the QR could not be created. Retry once, then report it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "`provider_unavailable` or `provider_busy`: ABA's page or the payment service is temporarily unavailable. Retry after a few seconds and show the customer a friendly message.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "The `branch` id does not belong to this store (`branch_not_found`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "listPayments",
        "summary": "List payments",
        "description": "Payments of the store the API key belongs to, newest first. There is no cursor; use `limit` and `status` to narrow the result. A key only ever sees payments of its own mode (live or test). Pass `branch` to see one branch's payments.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/PaymentStatus"
            },
            "description": "Only payments with this status."
          },
          {
            "name": "branch",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only payments of this branch (`br_...`). `404 branch_not_found` for a branch of another store."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            },
            "description": "Number of payments to return (1-100)."
          }
        ],
        "responses": {
          "200": {
            "description": "The matching payments.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Unknown branch.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments/{id}": {
      "get": {
        "tags": [
          "Payments"
        ],
        "operationId": "getPayment",
        "summary": "Retrieve a payment",
        "description": "Polling every 2-3 seconds is fine while a checkout is open. Fulfil the order only when `status` is `paid`. A live key gets `404` for a test payment and vice versa.",
        "parameters": [
          {
            "$ref": "#/components/parameters/PaymentId"
          }
        ],
        "responses": {
          "200": {
            "description": "The payment.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/payments/{id}/cancel": {
      "post": {
        "tags": [
          "Payments"
        ],
        "operationId": "cancelPayment",
        "summary": "Cancel a payment",
        "description": "Only `pending` or `scanned` payments can be cancelled. Cancelling never reverses money that has already been paid. The response is the payment with `status: \"cancelled\"` and `statusSource: \"merchant_cancelled\"`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/PaymentId"
          }
        ],
        "responses": {
          "200": {
            "description": "The cancelled payment.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`payment_not_cancellable`: the payment already ended (`paid`, `expired`, `failed` or `cancelled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "payment_not_cancellable"
                }
              }
            }
          }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "tags": ["Account"],
        "operationId": "getUsage",
        "summary": "Usage and quota for this month",
        "description": "Where the account stands this calendar month: the same numbers that decide whether a create call is accepted. Pooled across all stores of the account, so every key returns the same figures. Only paid live payments count.",
        "responses": {
          "200": {
            "description": "Current usage.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plan": { "type": "string", "description": "Plan in effect now." },
                    "limit": { "type": "integer", "description": "Paid transactions allowed this month." },
                    "used": { "type": "integer", "description": "Paid live transactions so far this month." },
                    "remaining": { "type": "integer", "description": "Never negative. At 0 a live create returns `402 plan_limit_reached`." },
                    "period_start": { "type": "string", "format": "date-time" },
                    "period_end": { "type": "string", "format": "date-time", "description": "When `used` resets to 0 (first day of next month, UTC)." },
                    "stores": { "type": "object", "properties": { "used": { "type": "integer" }, "limit": { "type": "integer" } } }
                  }
                },
                "example": { "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 } }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/payments/{id}/simulate": {
      "post": {
        "tags": [
          "Payments",
          "Test mode"
        ],
        "operationId": "simulatePayment",
        "summary": "Simulate a test payment outcome",
        "description": "Test mode only. Moves a test payment (`livemode: false`) to `scanned`, `paid`, `expired` or `failed` exactly as ABA would, so webhooks fire (`payment.scanned`, `payment.completed`, `payment.expired`, `payment.failed`, all with `livemode: false` and `X-KHQR-Livemode: false`) and the hosted checkout redirects. Requires a `dk_test_...` key of the same store; a live key gets `403 test_mode_only`. The checkout page of a test payment offers the same outcomes as buttons (Simulate scanned / paid / expired). A test payment left alone expires by itself after 3 minutes.",
        "parameters": [
          {
            "$ref": "#/components/parameters/PaymentId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SimulateRequest"
              },
              "example": {
                "status": "paid"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated test payment (`statusSource` is `simulated_<status>`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Payment"
                },
                "examples": {
                  "paid": {
                    "$ref": "#/components/examples/TestPaymentPaid"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_status`: `status` must be `scanned`, `paid`, `expired` or `failed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid_status"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`test_mode_only`: the key is a live key (`dk_live_`). Simulation needs a test key and a test payment.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "test_mode_only",
                  "message": "Simulation needs a test API key (dk_test_...)."
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`payment_not_simulatable`: the test payment already ended (`paid`, `expired`, `failed` or `cancelled`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "payment_not_simulatable"
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "paymentEvent": {
      "post": {
        "operationId": "receivePaymentEvent",
        "summary": "Payment status change",
        "description": "Dalo sends a `POST` to every enabled webhook endpoint of the store whenever a payment changes status. Events are emitted only on transitions, so a payment can go straight from `pending` to `paid` without a `payment.scanned` event.\n\n**Verify the signature before trusting the body.** `X-KHQR-Signature` is `t=<unix seconds>,v1=<hex>` where `v1` is HMAC-SHA256 over the string `<t>.<raw request body>` using the endpoint's signing secret (`whsec_...`, shown once in the dashboard). Use the raw body bytes exactly as received, compare in constant time, and reject the event if `t` differs from the current time by more than 300 seconds.\n\n**Only `payment.completed` with `livemode: true` means money arrived.** Test payments (created with a `dk_test_` key) send the same events, signed with the same secret, with `livemode: false` in the body and `X-KHQR-Livemode: false`; use them to test your handler and never fulfil on them. `webhook.test` is a sample sent from the dashboard's *Send test* button: it looks like a paid payment but carries `data.payment.test: true` and `X-KHQR-Delivery: test`. Never fulfil an order on it.\n\n**Respond with any 2xx within 8 seconds.** Otherwise Dalo retries after 5 s, 30 s, 2 min, 10 min and 1 hour (6 attempts in total), then marks the delivery dead-lettered (it can be retried from the dashboard). Retries reuse the same `X-KHQR-Delivery` id, so store it and ignore repeats.",
        "security": [],
        "parameters": [
          {
            "name": "X-KHQR-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/EventType"
            },
            "description": "Same as `type` in the body."
          },
          {
            "name": "X-KHQR-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Delivery id (`del_...`). Stable across retries of the same event; `test` for `webhook.test`.",
            "example": "del_7KcZ2qa1"
          },
          {
            "name": "X-KHQR-Livemode",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "`true` for a real payment, `false` for a test payment or a `webhook.test` sample. Same as `livemode` in the body.",
            "example": "true"
          },
          {
            "name": "X-KHQR-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^t=\\d+,v1=[a-f0-9]{64}$"
            },
            "description": "`t=<unix seconds>,v1=<HMAC-SHA256 hex of \"<t>.<raw body>\" with the signing secret>`",
            "example": "t=1760000000,v1=5f8a4c7f0c1f2d6f9c3f2d8e3a6d1c6e7c2b9f0e4d2a1c8b7a6f5e4d3c2b1a09"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEvent"
              },
              "examples": {
                "completed": {
                  "$ref": "#/components/examples/PaymentCompletedEvent"
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged. Any other status, or no answer within 8 seconds, schedules a retry."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "dk_live_... or dk_test_...",
        "description": "A store API key from the dashboard (API keys). Keys belong to one store; payments created with a key are always for that store. `dk_live_...` keys create real ABA payments; `dk_test_...` keys create simulated test payments (`livemode: false`) and are the only keys allowed to call the simulate endpoint. A key only ever sees payments of its own mode. Send it as `Authorization: Bearer ...` from your server only."
      }
    },
    "parameters": {
      "PaymentId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^pay_"
        },
        "description": "Payment id (`pay_...`).",
        "example": "pay_8Qm2kV3xLp9c"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "maxLength": 255
        },
        "description": "A value unique to the order (for example the order id plus an attempt counter). Scoped to the store. A repeated key returns the original payment with `200`. May also be sent as `idempotency_key` in the body.",
        "example": "order_1024"
      }
    },
    "headers": {
      "RetryAfter": {
        "schema": {
          "type": "integer"
        },
        "description": "Seconds to wait before retrying."
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "`unauthorized`: missing, revoked or wrong API key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "unauthorized",
              "message": "Missing or invalid API key"
            }
          }
        }
      },
      "NotFound": {
        "description": "`payment_not_found`: wrong id, or the payment belongs to another store or to the other mode (live vs test).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "payment_not_found"
            }
          }
        }
      }
    },
    "schemas": {
      "PaymentStatus": {
        "type": "string",
        "enum": [
          "pending",
          "scanned",
          "paid",
          "expired",
          "failed",
          "cancelled"
        ],
        "description": "`pending`: QR created, waiting for the customer. `scanned`: ABA shows the QR was scanned; the customer still has to confirm, so this is not proof of payment. `paid`: money arrived; ABA displayed the payment as paid with the expected merchant, amount and currency. Fulfil only on `paid`. `expired`: the window 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 the ABA app before fulfilling anything. `cancelled`: cancelled by the merchant (API or dashboard) or by the customer on the checkout page."
      },
      "EventType": {
        "type": "string",
        "enum": [
          "payment.scanned",
          "payment.completed",
          "payment.expired",
          "payment.failed",
          "payment.cancelled",
          "webhook.test"
        ],
        "description": "`payment.completed` is the only event that should trigger fulfilment. `webhook.test` is a dashboard sample (`data.payment.test` is `true`)."
      },
      "CreatePaymentRequest": {
        "type": "object",
        "properties": {
          "open_amount": {
            "type": "boolean",
            "description": "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`). The payment then has `amount: null` and `amount_status: pending` until it is paid; the amount is read from the bank's receipt."
          },
          "amount": {
            "type": [
              "string",
              "number"
            ],
            "description": "Required unless `open_amount` is true. 0.01 to 10000 with at most two decimals, for example `\"1.50\"`. The response always returns it as a two-decimal string.",
            "examples": [
              "1.50"
            ]
          },
          "currency": {
            "type": "string",
            "enum": [
              "USD"
            ],
            "default": "USD",
            "description": "Must match the store's settlement currency (USD)."
          },
          "reference_id": {
            "type": "string",
            "maxLength": 255,
            "description": "Your order id. 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": {
            "type": "string",
            "description": "Absolute HTTPS URL or a path relative to the Dalo origin. The hosted checkout redirects here about 2 seconds after the payment is `paid`, appending `?status=paid&payment_id=...&reference_id=...`. Falls back to the store's success URL from the dashboard. Returned as `return_url`. Must not contain credentials."
          },
          "metadata": {
            "type": [
              "object",
              "null"
            ],
            "description": "Any JSON object of at most 4 KB (serialised). Returned in the payment and in webhooks.",
            "additionalProperties": true
          },
          "expires_in": {
            "type": [
              "integer",
              "string"
            ],
            "minimum": 60,
            "maximum": 259200,
            "description": "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; otherwise `403 long_qr_not_enabled`. The returned `expiresAt` is exact.",
            "examples": [
              259200
            ]
          },
          "idempotency_key": {
            "type": "string",
            "maxLength": 255,
            "description": "Alternative to the `Idempotency-Key` header."
          },
          "branch": {
            "type": [
              "string",
              "null"
            ],
            "description": "Branch id (`br_...`, from the dashboard: Settings → Branches). The payment belongs to that branch and, when the branch has its own verified ABA Payment Link, its QR is created on that link. Omitted: the API key's branch, if the key is bound to one; otherwise the payment is store-wide (`branch: null`). `404 branch_not_found` for an id outside this store; `409 branch_not_active` while the branch's own link is still unverified."
          }
        }
      },
      "Payment": {
        "type": "object",
        "description": "The payment object, identical in API responses and in webhook `data.payment`.",
        "required": [
          "id",
          "status",
          "livemode",
          "amount",
          "currency",
          "reference_id",
          "store_id",
          "branch",
          "createdAt",
          "updatedAt",
          "expiresAt",
          "checkout_url",
          "qr_image_url"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`pay_...`"
          },
          "status": {
            "$ref": "#/components/schemas/PaymentStatus"
          },
          "statusSource": {
            "type": [
              "string",
              "null"
            ],
            "description": "Diagnostic: what set the current status, for example `created`, `aba_confirmed`, `expiry_worker`, `merchant_cancelled`, `checkout_cancelled` or `simulated_paid` (test mode). Do not branch on it."
          },
          "livemode": {
            "type": "boolean",
            "description": "`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): no money can move, and nothing it does counts toward plan limits or statistics. Fulfil only when `true`."
          },
          "amount": {
            "type": [
              "string",
              "null"
            ],
            "description": "Two-decimal string, for example `\"1.50\"`. `null` for an open-amount payment until it is paid (and stays `null` when `amount_status` is `unverified`)."
          },
          "open_amount": {
            "type": "boolean",
            "description": "`true` for a payment created with `open_amount: true`."
          },
          "amount_status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "pending",
              "receipt",
              "unverified",
              "simulated",
              null
            ],
            "description": "Open-amount payments only. `pending`: not paid yet. `receipt`: paid, amount read from the bank receipt. `unverified`: paid but the receipt could not be read; check the amount in the merchant's ABA app. `simulated`: test mode. `null` for fixed-amount payments."
          },
          "currency": {
            "type": "string",
            "enum": [
              "USD"
            ]
          },
          "reference_id": {
            "type": "string"
          },
          "metadata": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": true
          },
          "provider": {
            "type": "string",
            "enum": [
              "aba",
              "test",
              "demo"
            ],
            "description": "`aba` for real ABA payments. `test` for test-mode payments (placeholder KHQR, `livemode: false`). `demo` only on self-hosted development gateways."
          },
          "store_id": {
            "type": "string",
            "description": "`store_...`"
          },
          "branch": {
            "type": [
              "object",
              "null"
            ],
            "description": "The branch this payment belongs to, or `null` for a store-wide payment. Present in webhooks too.",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "`br_...`"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "verification": {
            "type": "boolean",
            "description": "`true` only for the 0.01 USD store-verification payment made from the dashboard."
          },
          "providerVerification": {
            "type": "object",
            "description": "Present once the provider confirmed the payment; diagnostic details of what ABA displayed.",
            "additionalProperties": true
          },
          "approvedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the payment became `paid`; otherwise `null`."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the QR stops being accepted (5 minutes after creation by default; the store can set 1-30 minutes)."
          },
          "return_url": {
            "type": "string",
            "description": "The effective success URL (from `success_url` or the store setting). Absent when none is set."
          },
          "checkout_url": {
            "type": "string",
            "format": "uri",
            "description": "Hosted checkout page: `https://dalodesk.com/pay/<id>`. Mobile friendly; refreshes itself, shows \"Payment received\" and redirects to `return_url`."
          },
          "qr_image_url": {
            "type": "string",
            "format": "uri",
            "description": "Public image of the QR for your own checkout page. Use it as the `src` of an `<img>`; the response `Content-Type` tells the format (usually SVG, sometimes PNG despite the `.svg` suffix)."
          },
          "qr_string": {
            "type": [
              "string",
              "null"
            ],
            "description": "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": {
            "type": [
              "object",
              "null"
            ],
            "description": "Links that open ABA Mobile with this QR pre-filled, for a customer paying on the same phone. `null` for test payments and when `qr_string` is null.",
            "properties": {
              "ios": {
                "type": "string",
                "format": "uri",
                "description": "Universal link (iOS and desktop): `https://dpayment-epreprod.ababank.com/deep-link?type=payway&qrcode=<url-encoded qr_string>`."
              },
              "android": {
                "type": "string",
                "description": "Android intent URL: `intent://ababank.com?type=payway&qrcode=<url-encoded qr_string>#Intent;package=com.paygo24.ibank;S.browser_fallback_url=...;scheme=abamobilebank;end;`."
              }
            }
          }
        }
      },
      "PaymentList": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Payment"
            }
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "required": [
          "id",
          "type",
          "created",
          "livemode",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`evt_...` (`evt_test_...` for `webhook.test`)."
          },
          "type": {
            "$ref": "#/components/schemas/EventType"
          },
          "created": {
            "type": "string",
            "format": "date-time"
          },
          "livemode": {
            "type": "boolean",
            "description": "`true` for events of real payments, `false` for test payments (and for `webhook.test` samples). Also sent as the `X-KHQR-Livemode` header. Fulfil only when `true`."
          },
          "data": {
            "type": "object",
            "required": [
              "payment"
            ],
            "properties": {
              "payment": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/Payment"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "test": {
                        "type": "boolean",
                        "description": "`true` only on `webhook.test` samples. Never fulfil when present."
                      }
                    }
                  }
                ]
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable code.",
            "enum": [
              "invalid_amount",
              "unsupported_currency",
              "invalid_success_url",
              "invalid_metadata",
              "invalid_expires_in",
              "unauthorized",
              "plan_limit_reached",
              "store_not_active",
              "store_disabled",
              "account_suspended",
              "payment_not_found",
              "payment_creation_in_progress",
              "payment_not_cancellable",
              "store_not_linked",
              "provider_unavailable",
              "provider_busy",
              "provider_error",
              "not_found",
              "internal_error",
              "test_mode_only",
              "invalid_status",
              "payment_not_simulatable",
              "branch_not_found",
              "branch_not_active"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Not always present."
          }
        }
      },
      "SimulateRequest": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "scanned",
              "paid",
              "expired",
              "failed"
            ],
            "description": "The outcome to simulate. `paid` fires `payment.completed`; `scanned` may be followed by another call."
          }
        }
      }
    },
    "examples": {
      "PaymentPending": {
        "summary": "Freshly created payment",
        "value": {
          "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": {
            "id": "br_7Qx2",
            "name": "Riverside"
          },
          "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",
          "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;"
          }
        }
      },
      "PaymentCompletedEvent": {
        "summary": "payment.completed event",
        "value": {
          "id": "evt_3m9Qx1Zb",
          "type": "payment.completed",
          "created": "2026-10-09T05:10:12.000Z",
          "livemode": true,
          "data": {
            "payment": {
              "id": "pay_8Qm2kV3xLp9c",
              "status": "paid",
              "statusSource": "aba_confirmed",
              "livemode": true,
              "amount": "1.50",
              "currency": "USD",
              "reference_id": "order_1024",
              "metadata": {
                "customer_id": "c_42"
              },
              "provider": "aba",
              "store_id": "store_4Hj7s",
              "branch": {
                "id": "br_7Qx2",
                "name": "Riverside"
              },
              "verification": false,
              "approvedAt": "2026-10-09T05:10:12.000Z",
              "createdAt": "2026-10-09T05:09:00.000Z",
              "updatedAt": "2026-10-09T05:10:12.000Z",
              "expiresAt": "2026-10-09T05:19:00.000Z",
              "return_url": "https://shop.example/orders/1024",
              "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;"
              }
            }
          }
        }
      },
      "TestPaymentPaid": {
        "summary": "Test payment after simulate paid",
        "value": {
          "id": "pay_T3stKV3xLp9c",
          "status": "paid",
          "statusSource": "simulated_paid",
          "livemode": false,
          "amount": "1.50",
          "currency": "USD",
          "reference_id": "order_1024",
          "metadata": null,
          "provider": "test",
          "store_id": "store_4Hj7s",
          "branch": {
            "id": "br_7Qx2",
            "name": "Riverside"
          },
          "verification": false,
          "providerVerification": {
            "valid": true,
            "simulated": true
          },
          "approvedAt": "2026-10-12T05:10:12.000Z",
          "createdAt": "2026-10-12T05:09:00.000Z",
          "updatedAt": "2026-10-12T05:10:12.000Z",
          "expiresAt": "2026-10-12T05:12:00.000Z",
          "checkout_url": "https://dalodesk.com/pay/pay_T3stKV3xLp9c?t=0f3c9a1e7b2d4c6a8e1f2b3d",
          "qr_image_url": "https://dalodesk.com/api/public/payments/pay_T3stKV3xLp9c/qr.svg",
          "qr_string": "00020101021229..."
        }
      }
    }
  }
}
