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.

POSThttps://cutluy.com/v1/…

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

POST/v1/payments

Creates a KHQR payment and returns it with a QR string and checkout URL.

ParameterTypeDescription
amountnumber, requiredAmount to charge, in USD. Minimum 0.01.
reference_idstring, optionalYour own order id (≤ 255 chars). Echoed back and included in webhooks.
metadataobject, optionalArbitrary JSON, returned as-is on the payment.
idempotency_keystring, optionalSafely retry without creating duplicates (≤ 255). May also be sent as the Idempotency-Key header.
payment_link_idstring, optionalDefaults 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

GET/v1/payments/:id

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

GET/v1/payments

Returns your store's payments, newest first, under a data array.

Query paramTypeDescription
statusstring, optionalFilter by status (e.g. paid).
limitnumber, optionalHow 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

FieldTypeDescription
idstringUnique payment id (used in checkout_url and lookups).
statusstringOne of pending, scanned, paid, expired, failed.
amountstringDecimal string, e.g. "1.50".
currencystringAlways "USD".
reference_idstring | nullThe reference you supplied.
qr_stringstringRaw KHQR (EMV) payload — render as a QR for scan-to-pay. On create/list.
checkout_urlstringHosted branded checkout page. On create/list.
metadataobject | nullMetadata you attached. On retrieve.
approved_atstring | nullISO 8601 time the payment was paid.
created_atstringISO 8601 creation time.
expires_atstringISO 8601 expiry (~5 minutes after creation).

Payment statuses

StatusTerminalMeaning
pendingnoCreated; waiting for the customer to scan and pay.
scannednoThe QR was scanned; the customer is confirming in their banking app.
paidyesPaid successfully. Counts toward your quota and fires payment.completed.
expiredyesThe QR expired unpaid (~5 minutes).
failedyesThe 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.

EventFires whenPayment status
payment.completedA payment is paidpaid
payment.scannedThe QR is scannedscanned
payment.expiredA payment expires unpaidexpired
payment.failedA payment failsfailed

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" }
CodeHTTPWhen
unauthorized401Missing or invalid API key.
quota_exceeded402Monthly transaction limit reached for your plan.
invalid_request400The request body failed validation.
amount_too_low400Amount is below 0.01.
amount_too_high400Amount is above the link's maximum.
payment_link_not_found404No payment link for your store.
payment_link_disabled400The store's payment link is disabled.
payment_not_found404No such payment for your store.
method_not_allowed405Wrong HTTP method for the endpoint.
payment_provider_error502The 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.

PlanIncluded / monthPrice
Free100 transactions$0
Starter1,000 transactions$9 / mo
Pro5,000 transactions$29 / mo

Upgrade any time under Billing — paid with Bakong KHQR, of course.