Redsys / Sermepa TPV Virtual payment provider plugin for MedusaJS v2
Redsys / Sermepa TPV Virtual payment provider plugin for MedusaJS v2.
This plugin enables payment processing through Redsys' hosted payment page (TPV Virtual) via redirect flow. Customers are redirected to the Redsys secure payment page to complete their transaction.
Production-proven: This plugin is derived from a live production Medusa store processing real Redsys payments.
1npm install @jsm406/medusa-plugin-redsys2# or3yarn add @jsm406/medusa-plugin-redsys4# or5pnpm add @jsm406/medusa-plugin-redsysAdd the following to your file:
1REDSYS_SECRET_KEY=sq7Hj....2REDSYS_MERCHANT_CODE=9990088813REDSYS_TERMINAL=0014REDSYS_ENVIRONMENT=sandbox5REDSYS_NOTIFICATION_URL=https://your-api.com/hooks/payment/redsys_redsys6REDSYS_SUCCESS_URL=https://your-store.com/checkout/redsys-callback7REDSYS_ERROR_URL=https://your-store.com/checkout/redsys-callback?error=1For sandbox testing, use the following test credentials from Redsys:
1Merchant Code: 9990088812Terminal: 0013Secret Key: sq7Hj.......4Environment: sandboxIn your :
1import { defineConfig } from "@medusajs/framework/config"2
3export default defineConfig({4 modules: [5 {6 resolve: "@medusajs/medusa/payment",7 options: {8 providers: [9 {10 resolve: "@jsm406/medusa-plugin-redsys/providers/redsys",11 id: "redsys",12 options: {13 secretKey: process.env.REDSYS_SECRET_KEY,14 merchantCode: process.env.REDSYS_MERCHANT_CODE,15 terminal: process.env.REDSYS_TERMINAL || "001",16 environment:17 process.env.REDSYS_ENVIRONMENT || "sandbox",18 notificationUrl:19 process.env.REDSYS_NOTIFICATION_URL,20 successUrl: process.env.REDSYS_SUCCESS_URL,21 errorUrl: process.env.REDSYS_ERROR_URL,22 transactionType: "0", // "0" = immediate capture, "1" = pre-authorization23 },24 },25 // Bizum provider (optional - uses same credentials)26 {27 resolve: "@jsm406/medusa-plugin-redsys/providers/redsys-bizum",28 id: "redsys-bizum",29 options: {30 secretKey: process.env.REDSYS_SECRET_KEY,31 merchantCode: process.env.REDSYS_MERCHANT_CODE,32 terminal: process.env.REDSYS_TERMINAL || "001",33 environment:34 process.env.REDSYS_ENVIRONMENT || "sandbox",35 notificationUrl:36 process.env.REDSYS_BIZUM_NOTIFICATION_URL || process.env.REDSYS_NOTIFICATION_URL,37 successUrl: process.env.REDSYS_SUCCESS_URL,38 errorUrl: process.env.REDSYS_ERROR_URL,39 transactionType: "0",40 },41 },42 ],43 },44 },45 ],46})Enable the Redsys provider(s) in your Medusa admin panel under Settings > Regions:
You can enable one or both providers depending on which payment methods you want to offer.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| string | Yes | — | Redsys HMAC-SHA256 secret key | |
| string | Yes | — | Redsys merchant code (FUC) | |
| string | No | Terminal number | ||
| string | No | or | ||
| string | No | — | Webhook URL for Redsys to POST transaction results | |
| string | No | — | URL to redirect after successful payment (URLOK) | |
| string | No | — | URL to redirect after failed payment (URLKO) | |
| string | No | = immediate capture, = pre-authorization |
This plugin's returns for sessions with status and . This is intentional for the redirect flow: the real authorization happens on Redsys TPV and is confirmed via webhook. Without this, would fail with a 400 error because Medusa requires the payment session to be authorized before completing the cart.
The plugin generates a 12-character alphanumeric (e.g. ) used as Redsys' merchant order reference. When the order is completed via , Medusa generates its own order ID (e.g. ). These are different IDs.
The callback URL from Redsys only contains the Redsys order ID, not the Medusa order ID. To bridge this gap, the storefront stores the mapping → in before redirecting to the TPV. The callback page reads this value to redirect to the correct order confirmation page.
Redsys is a redirect-based payment method (no card input in your storefront — the customer enters card data on Redsys' secure TPV). You must adapt your Medusa Next.js storefront with the changes below.
Add Redsys and Bizum to the payment info map and add helper functions:
1// Inside paymentInfoMap, add:2pp_redsys_redsys: {3 title: "Credit / Debit Card",4 icon: <CreditCard />,5},6pp_redsys_redsys_bizum: {7 title: "Bizum",8 icon: <Smartphone />,9},10
11// Add helper functions:12export const isRedsys = (providerId?: string) => {13 return providerId?.startsWith("pp_redsys_redsys") && !providerId?.includes("bizum")14}15
16export const isRedsysBizum = (providerId?: string) => {17 return providerId?.startsWith("pp_redsys_redsys_bizum")18}Add a function. The standard does a (server-side), but Redsys needs to redirect the browser to the TPV instead. This function completes the cart, creates the order, but returns the result so the client can handle the TPV redirect:
1export async function completeCartWithoutRedirect(cartId?: string) {2 const id = cartId || (await getCartId())3
4 if (!id) {5 throw new Error("No existing cart found when completing cart")6 }7
8 const headers = {9 ...(await getAuthHeaders()),10 }11
12 const cartRes = await sdk.store.cart13 .complete(id, {}, headers)14 .then(async (cartRes) => {15 const cartCacheTag = await getCacheTag("carts")16 revalidateTag(cartCacheTag)17 return cartRes18 })19 .catch(medusaError)20
21 if (cartRes?.type === "order") {22 const orderCacheTag = await getCacheTag("orders")23 revalidateTag(orderCacheTag)24 removeCartId()25 }26
27 return cartRes28}Add payment button components for both Redsys (card) and Bizum. Both use the same redirect flow but with different provider IDs.
1// Add imports:2import { isManual, isRedsys, isRedsysBizum, isStripeLike } from "@lib/constants"3import { completeCartWithoutRedirect, placeOrder } from "@lib/data/cart"4
5// Add cases in PaymentButton's switch:6case isRedsysBizum(paymentSession?.provider_id):7 return (8 <RedsysBizumPaymentButton9 notReady={notReady}10 cart={cart}11 data-testid={dataTestId}12 />13 )14
15case isRedsys(paymentSession?.provider_id):16 return (17 <RedsysPaymentButton18 notReady={notReady}19 cart={cart}20 data-testid={dataTestId}21 />22 )23
24// Redsys Card Payment Button:25const RedsysPaymentButton = ({26 cart,27 notReady,28 "data-testid": dataTestId,29}: {30 cart: HttpTypes.StoreCart31 notReady: boolean32 "data-testid"?: string33}) => {34 const [submitting, setSubmitting] = useState(false)35 const [errorMessage, setErrorMessage] = useState<string | null>(null)36
37 const handlePayment = async () => {38 setSubmitting(true)39
40 const paymentSession = cart.payment_collection?.payment_sessions?.find(41 (s) => s.status === "pending" && isRedsys(s.provider_id)42 )43
44 const redsysData = paymentSession?.data as Record<string, string> | undefined45
46 if (!redsysData?.formUrl || !redsysData?.merchantParams || !redsysData?.signature) {47 setErrorMessage("No se pudieron obtener los datos de pago de Redsys")48 setSubmitting(false)49 return50 }51
52 const cartRes = await completeCartWithoutRedirect()53 .catch((err) => {54 setErrorMessage(err.message)55 setSubmitting(false)56 return null57 })58
59 if (!cartRes || cartRes.type !== "order") {60 setErrorMessage(cartRes ? "Error al crear el pedido" : "")61 setSubmitting(false)62 return63 }64
65 const medusaOrderId = cartRes.order.id66 const redsysOrderId = redsysData.orderId || ""67 const countryCode = cart.shipping_address?.country_code?.toLowerCase() || "dk"68 sessionStorage.setItem(69 `redsys_map_${redsysOrderId}`,70 JSON.stringify({ medusaOrderId, countryCode })71 )72
73 const form = document.createElement("form")74 form.method = "POST"75 form.action = redsysData.formUrl76
77 const fields: Record<string, string> = {78 Ds_SignatureVersion: redsysData.signatureVersion,79 Ds_MerchantParameters: redsysData.merchantParams,80 Ds_Signature: redsysData.signature,81 }82
83 Object.entries(fields).forEach(([name, value]) => {84 const input = document.createElement("input")85 input.type = "hidden"86 input.name = name87 input.value = value88 form.appendChild(input)89 })90
91 document.body.appendChild(form)92 form.submit()93 }94
95 return (96 <>97 <Button98 disabled={notReady || submitting}99 isLoading={submitting}100 onClick={handlePayment}101 size="large"102 data-testid={dataTestId}103 >104 Place order105 </Button>106 <ErrorMessage107 error={errorMessage}108 data-testid="redsys-payment-error-message"109 />110 </>111 )112}113
114// Bizum Payment Button (identical flow, different provider check):115const RedsysBizumPaymentButton = ({116 cart,117 notReady,118 "data-testid": dataTestId,119}: {120 cart: HttpTypes.StoreCart121 notReady: boolean122 "data-testid"?: string123}) => {124 const [submitting, setSubmitting] = useState(false)125 const [errorMessage, setErrorMessage] = useState<string | null>(null)126
127 const handlePayment = async () => {128 setSubmitting(true)129
130 const paymentSession = cart.payment_collection?.payment_sessions?.find(131 (s) => s.status === "pending" && isRedsysBizum(s.provider_id)132 )133
134 const redsysData = paymentSession?.data as Record<string, string> | undefined135
136 if (!redsysData?.formUrl || !redsysData?.merchantParams || !redsysData?.signature) {137 setErrorMessage("No se pudieron obtener los datos de pago de Bizum")138 setSubmitting(false)139 return140 }141
142 const cartRes = await completeCartWithoutRedirect()143 .catch((err) => {144 setErrorMessage(err.message)145 setSubmitting(false)146 return null147 })148
149 if (!cartRes || cartRes.type !== "order") {150 setErrorMessage(cartRes ? "Error al crear el pedido" : "")151 setSubmitting(false)152 return153 }154
155 const medusaOrderId = cartRes.order.id156 const redsysOrderId = redsysData.orderId || ""157 const countryCode = cart.shipping_address?.country_code?.toLowerCase() || "dk"158 sessionStorage.setItem(159 `redsys_map_${redsysOrderId}`,160 JSON.stringify({ medusaOrderId, countryCode })161 )162
163 const form = document.createElement("form")164 form.method = "POST"165 form.action = redsysData.formUrl166
167 const fields: Record<string, string> = {168 Ds_SignatureVersion: redsysData.signatureVersion,169 Ds_MerchantParameters: redsysData.merchantParams,170 Ds_Signature: redsysData.signature,171 }172
173 Object.entries(fields).forEach(([name, value]) => {174 const input = document.createElement("input")175 input.type = "hidden"176 input.name = name177 input.value = value178 form.appendChild(input)179 })180
181 document.body.appendChild(form)182 form.submit()183 }184
185 return (186 <>187 <Button188 disabled={notReady || submitting}189 isLoading={submitting}190 onClick={handlePayment}191 size="large"192 data-testid={dataTestId}193 >194 Pagar con Bizum195 </Button>196 <ErrorMessage197 error={errorMessage}198 data-testid="redsys-bizum-payment-error-message"199 />200 </>201 )202}Create a client component page that Redsys redirects to after payment. It reads the query param (Redsys internal ID), looks up the real Medusa order ID from , and redirects to the order confirmation page:
1"use client"2
3import { useRouter, useSearchParams } from "next/navigation"4import { useEffect, useState } from "react"5
6export default function RedsysCallbackPage() {7 const searchParams = useSearchParams()8 const router = useRouter()9 const [status, setStatus] = useState<"loading" | "error" | "success">("loading")10
11 const isError = searchParams?.get("error") === "1"12 const redsysOrderId = searchParams?.get("orderId")13
14 useEffect(() => {15 if (isError) {16 setStatus("error")17 return18 }19
20 if (!redsysOrderId) {21 setStatus("success")22 return23 }24
25 const stored = sessionStorage.getItem(`redsys_map_${redsysOrderId}`)26
27 if (stored) {28 let orderData: { medusaOrderId: string; countryCode: string }29 try {30 orderData = JSON.parse(stored)31 } catch {32 orderData = { medusaOrderId: stored, countryCode: "dk" }33 }34 sessionStorage.removeItem(`redsys_map_${redsysOrderId}`)35 router.replace(36 `/${orderData.countryCode}/order/${orderData.medusaOrderId}/confirmed`37 )38 return39 }40
41 setStatus("success")42 }, [isError, redsysOrderId, router])43
44 if (status === "loading") {45 return (46 <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8">47 <p className="text-gray-600">Procesando pago...</p>48 </div>49 )50 }51
52 if (status === "error") {53 return (54 <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8">55 <h1 className="text-2xl font-bold text-red-600">Pago no completado</h1>56 <p className="text-gray-600">57 La operación no se ha completado correctamente.58 </p>59 <a href="/" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">60 Volver a la tienda61 </a>62 </div>63 )64 }65
66 return (67 <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8">68 <h1 className="text-2xl font-bold text-green-600">Pago procesado</h1>69 <p className="text-gray-600">Tu pago ha sido procesado correctamente.</p>70 <a href="/" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">71 Volver a la tienda72 </a>73 </div>74 )75}If your storefront uses middleware to enforce region/country code prefixes in URLs (as the default Medusa Next.js storefront does), add a bypass so is not redirected. Add this early in the function:
1// Redsys callback URL — bypass region redirect2if (request.nextUrl.pathname.startsWith("/checkout/redsys-callback")) {3 return NextResponse.next()4}Ensure your storefront domain is allowed in CORS:
1projectConfig: {2 http: {3 storeCors: "http://localhost:8000,https://your-store.com",4 },5}The payment session field returned by :
1{2 orderId: "1234ABCD5678",3 amount: "2550",4 currency: "978",5 status: "pending",6 transactionType: "0",7 merchantParams: "base64...", // Base64-encoded merchant parameters8 signature: "hmac...", // HMAC-SHA256 signature9 signatureVersion: "HMAC_SHA256_V1", // Normal - version identifier from redsys-es library10 formUrl: "https://sis-t.redsys.es:25443/sis/realizarPago"11}Note: The identifier in the URL callback is the value returned by the library and is normal. This does not indicate a problem - the actual signature computation follows the Redsys v4.1 specification. The value is informational in the callback URL.
These fields are used in step 3 to build the auto-submitting redirect form.
Medusa automatically exposes webhook endpoints for the Redsys providers at:
1/hooks/payment/redsys_redsys (Card payments)2/hooks/payment/redsys_redsys_bizum (Bizum payments)For local development with sandbox, you must expose your backend to the internet (e.g., via ngrok) so Redsys can reach the webhook. Set to the ngrok URL.
Important: Redsys sends the notification to but the signature verification and payment status update happens through the Medusa webhook handler — make sure points to the same endpoint or forward notifications accordingly.
| Card Number | Brand | Behavior |
|---|---|---|
| 4548810000000003 | VISA | 3DS v2 approved |
| 5576441563045037 | Mastercard | 3DS v2 approved |
| 4548814479727229 | VISA | 3DS frictionless |
| 4548817212493017 | VISA | 3DS challenge |
| Any + CVV 999 | Any | Payment declined |
Important: In sandbox, Bizum transactions cannot exceed 10€. Use a discount coupon or low-price test product.
| Field | Value |
|---|---|
| Phone number | |
| PIN | |
| SMS code |
Test scenarios by amount:
| Amount | Result |
|---|---|
| < 5€ | Payment approved |
| 5€ - 10€ | Payment approved |
| 10€ - 15€ | Payment declined (exceeds sandbox limit) |
| > 15€ | Payment declined (no Bizum user) |
| Code | Type | Description |
|---|---|---|
| Payment | Authorization + immediate capture (default) | |
| Pre-authorization | Reserve funds only | |
| Confirmation | Capture pre-authorized funds | |
| Refund | Full or partial refund | |
| Cancellation | Cancel/void a transaction |
The plugin includes built-in numeric currency codes for all major currencies. If your currency is not listed, it defaults to EUR (). See for the full list.
1# Install dependencies2npm install3
4# Build5npm run build6
7# Run tests8npm test9
10# Watch mode (for local plugin development)11npm run dev1# From your plugin directory2npm run dev3
4# In your Medusa project directory:5npx medusa plugin:add ../path-to/@jsm406/medusa-plugin-redsysMIT — see LICENSE file for details.
For issues and questions, please open an issue on GitHub.