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 :
CYBERSOURCE_MERCHANT_ID=your_merchant_idCYBERSOURCE_KEY_ID=your_shared_key_idCYBERSOURCE_SECRET_KEY=your_shared_secret_keyCYBERSOURCE_ENV=sandbox # or "production"CYBERSOURCE_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):
import { loadEnv, defineConfig } from "@medusajs/framework/utils"loadEnv(process.env.NODE_ENV || "development", process.cwd())module.exports = defineConfig({plugins: [// Required: registers the built-in /store/cybersource/authorize route{ resolve: "medusa-payment-cybersource" },],projectConfig: {// ... your existing config},modules: [{resolve: "@medusajs/medusa/payment",options: {providers: [{resolve: "medusa-payment-cybersource",id: "cybersource",options: {merchantID: process.env.CYBERSOURCE_MERCHANT_ID,merchantKeyId: process.env.CYBERSOURCE_KEY_ID,merchantsecretKey: process.env.CYBERSOURCE_SECRET_KEY,environment:process.env.CYBERSOURCE_ENV === "production"? "production": "sandbox",capture: process.env.CYBERSOURCE_AUTO_CAPTURE === "true",},},],},},],})
| 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.
const fingerprintSessionId = crypto.randomUUID()const script = document.createElement("script")script.src = `https://h.online-metrix.net/fp/tags.js?org_id=${ORG_ID}&session_id=${fingerprintSessionId}`script.async = truedocument.head.appendChild(script)// org_id values:// Sandbox: 1snn5n9w// Production: k8vif92e (confirm in Business Center → Decision Manager)
// captureContext comes from payment_session.data.captureContextconst flex = new Flex(captureContext)const microform = flex.microform()const cardNumber = microform.createField("number", { placeholder: "Card number" })const cvn = microform.createField("securityCode", { placeholder: "CVV" })cardNumber.load("#card-number-container")cvn.load("#cvn-container")
Call this before :
async function authorizePayment(paymentSessionId, fingerprintSessionId) {// 1. Get transient token from Flex Microformconst transientToken = await new Promise((resolve, reject) => {microform.createToken({ expirationMonth: "12", expirationYear: "2030" }, (err, token) => {if (err) reject(err)else resolve(token)})})// 2. Pre-authorize at CyberSource via the built-in plugin routeconst response = await fetch("/store/cybersource/authorize", {method: "POST",headers: {"Content-Type": "application/json","x-publishable-api-key": YOUR_PUBLISHABLE_API_KEY,},body: JSON.stringify({payment_session_id: paymentSessionId,transient_token: transientToken,fingerprint_session_id: fingerprintSessionId, // same UUID used in the script URLbill_to: { // optional but recommendedfirstName: "John",lastName: "Doe",email: "john@example.com",address1: "123 Main St",locality: "Guatemala City",administrativeArea: "Guatemala",postalCode: "01001",country: "GT",},}),})const result = await response.json()if (!response.ok) {throw new Error(result.message || "Payment declined")}return result // { success: true, cs_payment_id, cs_status }}// Usageawait authorizePayment(cart.payment_collection.payment_sessions[0].id,fingerprintSessionId,)await 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:
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"import { Modules } from "@medusajs/framework/utils"type RefundRequestBody = {payment_id: stringamount?: numbernote?: string}export const POST = async (req: MedusaRequest<RefundRequestBody>,res: MedusaResponse) => {const { payment_id, amount, note } = req.bodyif (!payment_id) {return res.status(400).json({ error: "payment_id is required" })}const paymentModule = req.scope.resolve(Modules.PAYMENT)const payments = await paymentModule.listPayments({ id: [payment_id] },{ relations: ["captures", "refunds"] })const payment = payments[0]if (!payment) {return res.status(404).json({ error: "Payment not found" })}const captured = (payment.captures ?? []).reduce((sum: number, c: any) => sum + Number(c.amount ?? 0),0)const alreadyRefunded = (payment.refunds ?? []).reduce((sum: number, r: any) => sum + Number(r.amount ?? 0),0)const refundable = captured - alreadyRefundedif (refundable <= 0) {return res.status(400).json({ error: "No capturable amount available to refund" })}const refundAmount = amount ?? refundableif (refundAmount > refundable) {return res.status(400).json({error: `Cannot refund ${refundAmount}. Maximum refundable: ${refundable}`,})}const updatedPayment = await paymentModule.refundPayment({payment_id,amount: refundAmount,created_by: (req as any).auth_context?.actor_id,note,})return res.json({ payment: updatedPayment })}
Call it from your admin UI or custom dashboard:
curl -X POST http://localhost:9000/admin/cybersource/refund \-H "Authorization: Bearer <admin_token>" \-H "Content-Type: application/json" \-d '{ "payment_id": "pay_01...", "amount": 50.00 }'
# Clonegit clone https://github.com/Eleven-Estudio/medusa-payment-cybersource.gitcd medusa-payment-cybersource# Install dependenciesnpm install# Buildnpm run build# Watch mode (backend only)npm run dev
# In the plugin directorynpm run buildnpx yalc push# In your Medusa store directorynpx yalc add medusa-payment-cybersourcenpx 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