CyberSource payment provider for Medusa.js v2 with Flex Microform (PCI DSS SAQ-A)
CyberSource payment plugin for Medusa.js v2, built on Flex Microform v2 (PCI DSS SAQ-A compliant).
| Medusa.js | v2 () |
| Node.js | 18+ |
| CyberSource | Business Center account |
npm install medusa-payment-cybersource
Add these to your :
1CYBERSOURCE_MERCHANT_ID=your_merchant_id2CYBERSOURCE_KEY_ID=your_shared_key_id3CYBERSOURCE_SECRET_KEY=your_shared_secret_key4CYBERSOURCE_ENV=sandbox # or "production"5CYBERSOURCE_AUTO_CAPTURE=false # set to "true" for sale/auto-capture mode
Where to find these values: CyberSource Business Center → Account Management → Transaction Security Keys → Security Keys for the HTTP Signature Security Policy
In , add the plugin to both (for the API route) and (for the payment provider):
1import { loadEnv, defineConfig } from "@medusajs/framework/utils"23loadEnv(process.env.NODE_ENV || "development", process.cwd())45module.exports = defineConfig({6 plugins: [7 // Required: registers the built-in /store/cybersource/authorize route8 { resolve: "medusa-payment-cybersource" },9 ],10 projectConfig: {11 // ... your existing config12 },13 modules: [14 {15 resolve: "@medusajs/medusa/payment",16 options: {17 providers: [18 {19 resolve: "medusa-payment-cybersource",20 id: "cybersource",21 options: {22 merchantID: process.env.CYBERSOURCE_MERCHANT_ID,23 merchantKeyId: process.env.CYBERSOURCE_KEY_ID,24 merchantsecretKey: process.env.CYBERSOURCE_SECRET_KEY,25 environment:26 process.env.CYBERSOURCE_ENV === "production"27 ? "production"28 : "sandbox",29 capture: process.env.CYBERSOURCE_AUTO_CAPTURE === "true",30 },31 },32 ],33 },34 },35 ],36})
| Option | Type | Description |
|---|---|---|
| Required. Your CyberSource Merchant ID | ||
| Required. Shared key ID (HTTP Signature) | ||
| Required. Shared secret key (HTTP Signature) | ||
| or . Default: | ||
| Auto-capture on authorization (sale mode). Default: | ||
| Card networks for Flex. Default: |
<script src="https://flex.cybersource.com/microform/bundle/v2/flex-microform.min.js"></script>
When the user selects CyberSource as payment method, generate a unique session ID and load the ThreatMetrix script. The same ID must be sent in the authorize request.
1const fingerprintSessionId = crypto.randomUUID()23const script = document.createElement("script")4script.src = `https://h.online-metrix.net/fp/tags.js?org_id=${ORG_ID}&session_id=${fingerprintSessionId}`5script.async = true6document.head.appendChild(script)78// org_id values:9// Sandbox: 1snn5n9w10// Production: k8vif92e (confirm in Business Center → Decision Manager)
1// captureContext comes from payment_session.data.captureContext2const flex = new Flex(captureContext)3const microform = flex.microform()45const cardNumber = microform.createField("number", { placeholder: "Card number" })6const cvn = microform.createField("securityCode", { placeholder: "CVV" })78cardNumber.load("#card-number-container")9cvn.load("#cvn-container")
Call this before :
1async function authorizePayment(paymentSessionId, fingerprintSessionId) {2 // 1. Get transient token from Flex Microform3 const transientToken = await new Promise((resolve, reject) => {4 microform.createToken({ expirationMonth: "12", expirationYear: "2030" }, (err, token) => {5 if (err) reject(err)6 else resolve(token)7 })8 })910 // 2. Pre-authorize at CyberSource via the built-in plugin route11 const response = await fetch("/store/cybersource/authorize", {12 method: "POST",13 headers: {14 "Content-Type": "application/json",15 "x-publishable-api-key": YOUR_PUBLISHABLE_API_KEY,16 },17 body: JSON.stringify({18 payment_session_id: paymentSessionId,19 transient_token: transientToken,20 fingerprint_session_id: fingerprintSessionId, // same UUID used in the script URL21 bill_to: { // optional but recommended22 firstName: "John",23 lastName: "Doe",24 email: "john@example.com",25 address1: "123 Main St",26 locality: "Guatemala City",27 administrativeArea: "Guatemala",28 postalCode: "01001",29 country: "GT",30 },31 }),32 })3334 const result = await response.json()3536 if (!response.ok) {37 throw new Error(result.message || "Payment declined")38 }3940 return result // { success: true, cs_payment_id, cs_status }41}4243// Usage44await authorizePayment(45 cart.payment_collection.payment_sessions[0].id,46 fingerprintSessionId,47)48await placeOrder() // Medusa completes the order using the stored cs_payment_id
Request body:
| Field | Required | Description |
|---|---|---|
| Yes | Medusa payment session ID | |
| Yes | JWT from | |
| No | Session ID used in the ThreatMetrix script URL | |
| No | Billing address object for AVS/fraud checks |
Success response (200):
{ "success": true, "cs_payment_id": "7278957202756800104005", "cs_status": "AUTHORIZED" }
Declined response (402):
{ "error": "Payment declined", "reason": "INSUFFICIENT_FUND", "cs_status": "DECLINED" }
The plugin sends and to the CyberSource Payments API. The flag is required so that CyberSource looks up ThreatMetrix using the raw session ID (your UUID) instead of the default composite key — which would cause a mismatch with what the frontend script registered.
You can verify fingerprint collection is working by checking a transaction in CyberSource Business Center. A successful integration shows a hash under Device Fingerprint ID instead of "Not Submitted".
CyberSource authorizes the card on order placement. You capture the funds from the Medusa Admin → Orders → Payment panel. Authorization expires in 5–7 days if not captured.
Set . CyberSource processes authorization and capture together; the payment is marked as captured immediately on order placement.
The plugin injects a CyberSource panel into the order detail page of the Medusa admin (). It shows:
| Field | Description |
|---|---|
| Status badge | Derived display status (see table below) |
| Transaction ID | CyberSource authorization ID |
| Capture ID | Only shown if different from Transaction ID (manual capture) |
| Reconciliation ID | CyberSource reconciliation ID |
| Card | Card brand + last 4 digits |
| Last Refund ID | ID of the last refund issued |
| Last Refund Amount | Amount of the last refund |
Status badge logic:
| Condition | Badge |
|---|---|
| is set | 🔵 REFUNDED |
| + set (auto-capture) | 🟢 CAPTURED |
| (no capture ID) | 🟠 AUTHORIZED |
| 🟢 CAPTURED | |
| ⚫ VOIDED | |
| 🔴 DECLINED |
Medusa's default refund UI has a validation that can block refunds in some edge cases. Add this route to your Medusa store for a direct refund bypass:
Create in your Medusa project:
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework"2import { Modules } from "@medusajs/framework/utils"34type RefundRequestBody = {5 payment_id: string6 amount?: number7 note?: string8}910export const POST = async (11 req: MedusaRequest<RefundRequestBody>,12 res: MedusaResponse13) => {14 const { payment_id, amount, note } = req.body1516 if (!payment_id) {17 return res.status(400).json({ error: "payment_id is required" })18 }1920 const paymentModule = req.scope.resolve(Modules.PAYMENT)2122 const payments = await paymentModule.listPayments(23 { id: [payment_id] },24 { relations: ["captures", "refunds"] }25 )26 const payment = payments[0]2728 if (!payment) {29 return res.status(404).json({ error: "Payment not found" })30 }3132 const captured = (payment.captures ?? []).reduce(33 (sum: number, c: any) => sum + Number(c.amount ?? 0),34 035 )36 const alreadyRefunded = (payment.refunds ?? []).reduce(37 (sum: number, r: any) => sum + Number(r.amount ?? 0),38 039 )40 const refundable = captured - alreadyRefunded4142 if (refundable <= 0) {43 return res.status(400).json({ error: "No capturable amount available to refund" })44 }4546 const refundAmount = amount ?? refundable4748 if (refundAmount > refundable) {49 return res.status(400).json({50 error: `Cannot refund ${refundAmount}. Maximum refundable: ${refundable}`,51 })52 }5354 const updatedPayment = await paymentModule.refundPayment({55 payment_id,56 amount: refundAmount,57 created_by: (req as any).auth_context?.actor_id,58 note,59 })6061 return res.json({ payment: updatedPayment })62}
Call it from your admin UI or custom dashboard:
1curl -X POST http://localhost:9000/admin/cybersource/refund \2 -H "Authorization: Bearer <admin_token>" \3 -H "Content-Type: application/json" \4 -d '{ "payment_id": "pay_01...", "amount": 50.00 }'
1# Clone2git clone https://github.com/Eleven-Estudio/medusa-payment-cybersource.git3cd medusa-payment-cybersource45# Install dependencies6npm install78# Build9npm run build1011# Watch mode (backend only)12npm run dev
1# In the plugin directory2npm run build3npx yalc push45# In your Medusa store directory6npx yalc add medusa-payment-cybersource7npx medusa develop
After any plugin change, re-run in the plugin directory, then fully restart the Medusa server (yalc updates , hot-reload won't pick it up).
MIT