CutLuy API
CutLuy lets you accept Bakong KHQR payments with a simple REST API, real-time webhooks, and a hosted, branded checkout page. Create a payment, show the customer a QR code, and get notified the moment it's paid.
The API is organized around a single resource — the payment. All requests use HTTPS, are authenticated with an API key, and return JSON. Amounts are in USD.
Authentication
Authenticate every request with your secret API key in the Authorization header as a bearer token. Create and manage keys under Dashboard → API keys. Each key belongs to one store; the key alone identifies which store (and payment link) a request acts on.
curl https://cutluy.com/v1/payments/PUETcMUOKStjZsCb6zAl8kg9fMRGM85x \
-H "Authorization: Bearer ck_live_..."Keep secret keys (ck_live_…) on your server — never in client-side code. Missing or invalid keys return 401 unauthorized.
Quickstart
Create a payment for $1.50. You don't need a payment-link id — CutLuy uses your store's link automatically.
curl https://cutluy.com/v1/payments \
-H "Authorization: Bearer ck_live_..." \
-H "Content-Type: application/json" \
-d '{ "amount": 1.50, "reference_id": "order_1024" }'Redirect the customer to checkout_url (a hosted, branded page) or render qr_string as a QR in your own UI. When they pay, the payment's status becomes paid and a webhook fires.
Create a payment
Creates a KHQR payment and returns it with a QR string and checkout URL.
| Parameter | Type | Description |
|---|---|---|
| amount | number, required | Amount to charge, in USD. Minimum 0.01. |
| reference_id | string, optional | Your own order id (≤ 255 chars). Echoed back and included in webhooks. |
| metadata | object, optional | Arbitrary JSON, returned as-is on the payment. |
| idempotency_key | string, optional | Safely retry without creating duplicates (≤ 255). May also be sent as the Idempotency-Key header. |
| payment_link_id | string, optional | Defaults to your store's single payment link — only needed if you manage multiple links. |
Request
curl https://cutluy.com/v1/payments \
-H "Authorization: Bearer ck_live_..." \
-H "Content-Type: application/json" \
-d '{ "amount": 1.50, "reference_id": "order_1024" }'Response · 201 Created (or 200 on an idempotent replay)
{
"id": "PUETcMUOKStjZsCb6zAl8kg9fMRGM85x",
"status": "pending",
"amount": "1.50",
"currency": "USD",
"reference_id": "order_1024",
"qr_string": "00020101021229...6304AB12",
"checkout_url": "https://cutluy.com/pay/PUETcMUOKStjZsCb6zAl8kg9fMRGM85x",
"approved_at": null,
"created_at": "2026-07-09T12:00:00.000Z",
"expires_at": "2026-07-09T12:05:00.000Z"
}Retrieve a payment
Fetch the current state of a single payment — poll this if you're not using webhooks.
curl https://cutluy.com/v1/payments/PUETcMUOKStjZsCb6zAl8kg9fMRGM85x \
-H "Authorization: Bearer ck_live_..."List payments
Returns your store's payments, newest first, under a data array.
| Query param | Type | Description |
|---|---|---|
| status | string, optional | Filter by status (e.g. paid). |
| limit | number, optional | How many to return (default 20, max 100). |
curl "https://cutluy.com/v1/payments?status=paid&limit=20" \
-H "Authorization: Bearer ck_live_..."The payment object
| Field | Type | Description |
|---|---|---|
| id | string | Unique payment id (used in checkout_url and lookups). |
| status | string | One of pending, scanned, paid, expired, failed. |
| amount | string | Decimal string, e.g. "1.50". |
| currency | string | Always "USD". |
| reference_id | string | null | The reference you supplied. |
| qr_string | string | Raw KHQR (EMV) payload — render as a QR for scan-to-pay. On create/list. |
| checkout_url | string | Hosted branded checkout page. On create/list. |
| metadata | object | null | Metadata you attached. On retrieve. |
| approved_at | string | null | ISO 8601 time the payment was paid. |
| created_at | string | ISO 8601 creation time. |
| expires_at | string | ISO 8601 expiry (~5 minutes after creation). |
Payment statuses
| Status | Terminal | Meaning |
|---|---|---|
| pending | no | Created; waiting for the customer to scan and pay. |
| scanned | no | The QR was scanned; the customer is confirming in their banking app. |
| paid | yes | Paid successfully. Counts toward your quota and fires payment.completed. |
| expired | yes | The QR expired unpaid (~5 minutes). |
| failed | yes | The payment failed. |
Hosted checkout & redirects
Every payment has a checkout_url — a mobile-friendly, branded page (your store logo, colors, and support email) that shows the KHQR, a countdown, and live status. It even deep-links into banking apps on mobile.
Set success and failure redirect URLs under Settings. After a terminal payment CutLuy sends the customer there, appending ?status=success|failed&payment_id=…&reference_id=… so your page knows which order it was.
Webhooks
Add endpoints under Dashboard → Webhooks. CutLuy POSTs a JSON event to each enabled endpoint when a payment changes state.
| Event | Fires when | Payment status |
|---|---|---|
| payment.completed | A payment is paid | paid |
| payment.scanned | The QR is scanned | scanned |
| payment.expired | A payment expires unpaid | expired |
| payment.failed | A payment fails | failed |
Each request carries an X-CutLuy-Event header and an X-CutLuy-Signature header. Respond with any 2xx to acknowledge; anything else (or a timeout) is retried with exponential backoff up to 8 times. You can also resend any delivery from the dashboard, or fire a test event with Send test.
Event payload
{
"id": "b3f1c2a0-9e2d-4a1b-8c7f-1e2d3c4b5a6f",
"type": "payment.completed",
"created": "2026-07-09T12:03:11.000Z",
"data": {
"payment": {
"id": "PUETcMUOKStjZsCb6zAl8kg9fMRGM85x",
"status": "paid",
"amount": "1.50",
"currency": "USD",
"reference_id": "order_1024",
"metadata": null,
"approved_at": "2026-07-09T12:03:10.000Z"
}
}
}Verifying webhook signatures
The X-CutLuy-Signature header looks like t=1720526591,v1=<hex>. Compute an HMAC-SHA256 of `${t}.${rawBody}` with your endpoint's signing secret and compare it to v1. Always use the raw request body, compare in constant time, and reject events with an old timestamp.
import express from "express"
import crypto from "node:crypto"
const app = express()
const SECRET = process.env.CUTLUY_WEBHOOK_SECRET
// IMPORTANT: verify against the RAW request body.
app.post(
"/webhooks/cutluy",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-CutLuy-Signature") ?? ""
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const rawBody = req.body.toString("utf8")
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${rawBody}`)
.digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300 // 5 min
const valid =
parts.v1 &&
fresh &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
if (!valid) return res.status(400).send("invalid signature")
const event = JSON.parse(rawBody)
if (event.type === "payment.completed") {
// ✅ fulfill the order → event.data.payment.reference_id
}
res.sendStatus(200)
},
)Errors
Errors return the right HTTP status and a JSON body with a machine-readable error code and a human message.
{ "error": "amount_too_low", "message": "Minimum amount is 0.01" }| Code | HTTP | When |
|---|---|---|
| unauthorized | 401 | Missing or invalid API key. |
| quota_exceeded | 402 | Monthly transaction limit reached for your plan. |
| invalid_request | 400 | The request body failed validation. |
| amount_too_low | 400 | Amount is below 0.01. |
| amount_too_high | 400 | Amount is above the link's maximum. |
| payment_link_not_found | 404 | No payment link for your store. |
| payment_link_disabled | 400 | The store's payment link is disabled. |
| payment_not_found | 404 | No such payment for your store. |
| method_not_allowed | 405 | Wrong HTTP method for the endpoint. |
| payment_provider_error | 502 | The payment provider was unreachable — retry. |
Rate limits & plans
A successful (paid) payment counts as one transaction. Quota is monthly and shared across all stores on your account. When you hit the limit, creating a payment returns 402 quota_exceeded until the next month or a plan upgrade.
| Plan | Included / month | Price |
|---|---|---|
| Free | 100 transactions | $0 |
| Starter | 1,000 transactions | $9 / mo |
| Pro | 5,000 transactions | $29 / mo |
Upgrade any time under Billing — paid with Bakong KHQR, of course.