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-cybersourceAdd 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 modeWhere 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"2
3loadEnv(process.env.NODE_ENV || "development", process.cwd())4
5module.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: |
1Storefront Backend CyberSource2 | | |3 | select method | |4 |----------------->| |5 | |-- /flex/v2/sessions-->|6 |<-- captureContext JWT ----------------|7 | | |8 | Flex iFrames render (SAQ-A) |9 | Fingerprint script loads in bg |10 | | |11 | microform.createToken() |12 |----------------------------------------->|13 |<-- transient_token (JWT 15 min) ---------|14 | | |15 | POST /store/cybersource/authorize |16 | { payment_session_id, |17 | transient_token, |18 | fingerprint_session_id } |19 |----------------->| |20 | |-- /pts/v2/payments-->|21 | | + deviceInformation |22 | |<-- AUTHORIZED --------|23 |<-- { cs_payment_id } -------------|24 | | |25 | POST /store/carts/:id/complete |26 |----------------->| |27 | | authorizePayment() |28 | | reads cs_payment_id|29 |<-- order created--| |<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()2
3const 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)7
8// 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()4
5const cardNumber = microform.createField("number", { placeholder: "Card number" })6const cvn = microform.createField("securityCode", { placeholder: "CVV" })7
8cardNumber.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 })9
10 // 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 })33
34 const result = await response.json()35
36 if (!response.ok) {37 throw new Error(result.message || "Payment declined")38 }39
40 return result // { success: true, cs_payment_id, cs_status }41}42
43// Usage44await authorizePayment(45 cart.payment_collection.payment_sessions[0].id,46 fingerprintSessionId,47)48await placeOrder() // Medusa completes the order using the stored cs_payment_idRequest 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"3
4type RefundRequestBody = {5 payment_id: string6 amount?: number7 note?: string8}9
10export const POST = async (11 req: MedusaRequest<RefundRequestBody>,12 res: MedusaResponse13) => {14 const { payment_id, amount, note } = req.body15
16 if (!payment_id) {17 return res.status(400).json({ error: "payment_id is required" })18 }19
20 const paymentModule = req.scope.resolve(Modules.PAYMENT)21
22 const payments = await paymentModule.listPayments(23 { id: [payment_id] },24 { relations: ["captures", "refunds"] }25 )26 const payment = payments[0]27
28 if (!payment) {29 return res.status(404).json({ error: "Payment not found" })30 }31
32 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 - alreadyRefunded41
42 if (refundable <= 0) {43 return res.status(400).json({ error: "No capturable amount available to refund" })44 }45
46 const refundAmount = amount ?? refundable47
48 if (refundAmount > refundable) {49 return res.status(400).json({50 error: `Cannot refund ${refundAmount}. Maximum refundable: ${refundable}`,51 })52 }53
54 const updatedPayment = await paymentModule.refundPayment({55 payment_id,56 amount: refundAmount,57 created_by: (req as any).auth_context?.actor_id,58 note,59 })60
61 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-cybersource4
5# Install dependencies6npm install7
8# Build9npm run build10
11# Watch mode (backend only)12npm run dev1# In the plugin directory2npm run build3npx yalc push4
5# In your Medusa store directory6npx yalc add medusa-payment-cybersource7npx medusa developAfter 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