Medusa Payment Module Provider for CutLuy — Bakong KHQR scan-to-pay (asynchronous payment method).
A Medusa Payment Module Provider for CutLuy — Bakong KHQR scan-to-pay payments.
CutLuy is an asynchronous payment method: the customer scans a KHQR code (or opens a hosted checkout page) and pays from their banking app. The plugin is a standalone Medusa payment provider (like ) and installable in any Medusa v2 application.
1sequenceDiagram2 participant S as Storefront3 participant M as Medusa Backend4 participant C as CutLuy5
6 S->>M: initiate payment session (provider_id = cutluy)7 M->>C: POST /v1/payments (amount in USD, metadata.session_id)8 C-->>M: payment { id, qr_string, checkout_url }9 M-->>S: payment session data (qr_string / checkout_url)10 S->>S: render QR or redirect to checkout_url11 S->>M: place order → authorizePayment → pending_authorization12 C->>M: webhook payment.completed → /hooks/payment/cutluy_cutluy13 M->>M: verify X-CutLuy-Signature → mark captured → complete cart/orderBecause returns , the order is created with an awaiting payment status. When CutLuy fires , Medusa's re-invokes — the provider re-checks the CutLuy payment status and returns once it's paid — which creates the Payment record, captures it, and flips the order to paid/captured. If the QR expires or the payment fails, / are mapped to a action — which Medusa 2.18's webhook processor ignores, so the order remains awaiting and no capture occurs. The storefront must surface CutLuy's own payment status (poll ), and operators see a warning log per event.
Async flow requirement: polls on every call and only returns while the payment is still pending. This is what lets the webhook-driven autocapture flow create and capture the Payment.
A complete end-to-end test harness lives in the directory (sibling of this repo — not part of the package):
When you later point the provider at the real CutLuy API ( + real key), the same script works unchanged.
Dependency & security posture: the published package ships zero runtime dependencies — it only declares the peer (>=2.17.0) that any Medusa app already provides. Scanner findings (CVEs, telemetry, minified files) that appear for this package come from Medusa core's own dependency tree (e.g. , which is opt-out via ), and are identical for every Medusa plugin — they resolve upstream when Medusa updates its dependencies.
From this plugin's directory (pnpm is the package manager for this repo):
1pnpm install2pnpm medusa plugin:publish # pushes to the LOCAL yalc registry (dev only — this is not npm)Then, in your Medusa application:
npx medusa plugin:add medusa-payment-cutluyWhile developing, run in this plugin's directory to watch changes and auto-update the app.
npm install medusa-payment-cutluyIn of your Medusa application, register the provider in the Payment Module's array:
1import { defineConfig } from "@medusajs/framework/utils"2
3module.exports = defineConfig({4 // ...other config5 modules: [6 {7 resolve: "@medusajs/medusa/payment",8 options: {9 providers: [10 {11 // provider installed from the local registry or npm12 resolve: "medusa-payment-cutluy/providers/cutluy",13 id: "cutluy",14 options: {15 apiKey: process.env.CUTLUY_API_KEY,16 webhookSecret: process.env.CUTLUY_WEBHOOK_SECRET,17 // apiUrl: "https://cutluy.com/v1", // optional override18 // timeoutMs: 15000, // optional19 },20 },21 ],22 },23 },24 ],25})Add the environment variables to your application's :
1# apps/backend/.env2CUTLUY_API_KEY=ck_live_...3CUTLUY_WEBHOOK_SECRET=whsec_...The provider's identifier is . Enable it in a region from the Medusa Admin (Settings → Regions → Payment Providers).
| Option | Required | Description |
|---|---|---|
| ✅ | CutLuy secret API key ( / ) | |
| ⚠️ | Signing secret used to verify . Without it webhooks are rejected. | |
| Override the API base URL (default ) | ||
| HTTP request timeout (default ) |
Make sure your CutLuy store has a payment link configured (payment creation returns otherwise).
In the CutLuy dashboard, go to Webhooks.
Add an endpoint pointing at Medusa's built-in payment webhook listener:
https://<your-medusa-backend>/hooks/payment/cutluy_cutluy( is the provider's , repeated for the provider .)
Copy the endpoint's signing secret into .
The provider verifies the header (HMAC-SHA256 of ) before trusting any event. Medusa's built-in listener acks the request with 200 immediately and processes the event asynchronously (~5s delay, up to 3 internal attempts). Invalid or missing signatures are logged and dropped — the request is still acked, so CutLuy does not retry after a 2xx (non-2xx or timeout responses are retried with exponential backoff, up to 8 times); monitor your backend logs for signature failures. Use the dashboard's Send test or resend a delivery to exercise your endpoint.
Before completing, the provider also verifies the webhook's payment against its payment session: the session must exist, the payment must be USD, and the webhook amount must match the session amount cent-exact. On a mismatch or unknown session the event is logged and ignored (the order stays ). The payload is HMAC-authenticated, so this guards against CutLuy-side drift, not forgery.
Because Medusa 2.18's webhook processor ignores / events, an order whose QR expired or whose payment failed would stay forever with no signal. The plugin ships a scheduled job, , that closes the loop:
To load the job, the plugin must be listed in the app's array (the provider itself is registered under ):
plugins: ["medusa-payment-cutluy"],The staleness window is tunable via (default ).
The payment session contains everything the storefront needs:
1{2 "id": "PUETcMUOKStjZsCb6zAl8kg9fMRGM85x",3 "status": "pending",4 "amount": "1.50",5 "currency": "USD",6 "qr_string": "00020101021229...", // render as a QR code7 "checkout_url": "https://cutluy.com/pay/PUETcMUOKStjZsCb6zAl8kg9fMRGM85x",8 "expires_at": "2026-07-09T12:05:00.000Z"9}Choose one of:
After the customer pays, the webhook completes the order automatically — no storefront polling required.
CutLuy's hosted checkout redirects back to your configured success/failure URLs after a terminal payment, appending . Since Medusa drops / webhooks, the redirect (or polling ) is how the storefront learns the order failed — the order itself stays until the sweep job or an operator acts.
1pnpm install # install dependencies2pnpm test # run unit tests (vitest)3pnpm build # medusa plugin:build → outputs to .medusa/server4pnpm dev # watch + push to the local yalc registry for the test appThis repo ships only the plugin. To test it end-to-end, run a Medusa app in Docker and install the plugin into it. Follow the official guide — Install Medusa with Docker — then:
| Medusa provider method | CutLuy API call |
|---|---|
| webhook events (signature verified) | |
| / / / | no-op (not in CutLuy v1 API) |
| throws — not supported by CutLuy yet |
Status mapping: / → pending · → captured · / → error/failed.
MIT