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 medusa-payment-redsys2# or3yarn add medusa-payment-redsys4# or5pnpm add medusa-payment-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: "medusa-payment-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 ],26 },27 },28 ],29})Enable the Redsys provider in your Medusa admin panel under Settings > Regions and select Redsys as a payment provider.
The provider ID will be:
pp_redsys_redsys| 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.
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 to the payment info map and add a helper function:
1// Inside paymentInfoMap, add:2pp_redsys_redsys: {3 title: "Credit / Debit Card",4 icon: <CreditCard />,5},6
7// Add helper function:8export const isRedsys = (providerId?: string) => {9 return providerId?.startsWith("pp_redsys_")10}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 a component that:
1// Add import:2import { isManual, isRedsys, isStripeLike } from "@lib/constants"3import { completeCartWithoutRedirect, placeOrder } from "@lib/data/cart"4
5// Add case in PaymentButton's switch:6case isRedsys(paymentSession?.provider_id):7 return (8 <RedsysPaymentButton9 notReady={notReady}10 cart={cart}11 data-testid={dataTestId}12 />13 )14
15// Add the component:16const RedsysPaymentButton = ({17 cart,18 notReady,19 "data-testid": dataTestId,20}: {21 cart: HttpTypes.StoreCart22 notReady: boolean23 "data-testid"?: string24}) => {25 const [submitting, setSubmitting] = useState(false)26 const [errorMessage, setErrorMessage] = useState<string | null>(null)27
28 const handlePayment = async () => {29 setSubmitting(true)30
31 const paymentSession = cart.payment_collection?.payment_sessions?.find(32 (s) => s.status === "pending" && isRedsys(s.provider_id)33 )34
35 const redsysData = paymentSession?.data as Record<string, string> | undefined36
37 if (!redsysData?.formUrl || !redsysData?.merchantParams || !redsysData?.signature) {38 setErrorMessage("No se pudieron obtener los datos de pago de Redsys")39 setSubmitting(false)40 return41 }42
43 const cartRes = await completeCartWithoutRedirect()44 .catch((err) => {45 setErrorMessage(err.message)46 setSubmitting(false)47 return null48 })49
50 if (!cartRes || cartRes.type !== "order") {51 setErrorMessage(cartRes ? "Error al crear el pedido" : "")52 setSubmitting(false)53 return54 }55
56 const form = document.createElement("form")57 form.method = "POST"58 form.action = redsysData.formUrl59
60 const fields: Record<string, string> = {61 Ds_SignatureVersion: redsysData.signatureVersion,62 Ds_MerchantParameters: redsysData.merchantParams,63 Ds_Signature: redsysData.signature,64 }65
66 Object.entries(fields).forEach(([name, value]) => {67 const input = document.createElement("input")68 input.type = "hidden"69 input.name = name70 input.value = value71 form.appendChild(input)72 })73
74 document.body.appendChild(form)75 form.submit()76 }77
78 return (79 <>80 <Button81 disabled={notReady || submitting}82 isLoading={submitting}83 onClick={handlePayment}84 size="large"85 data-testid={dataTestId}86 >87 Place order88 </Button>89 <ErrorMessage90 error={errorMessage}91 data-testid="redsys-payment-error-message"92 />93 </>94 )95}Create the page Redsys redirects to after payment. It reads the query param and redirects to the order confirmation page:
1import { retrieveOrder } from "@lib/data/orders"2import { Metadata } from "next"3import { redirect } from "next/navigation"4
5export const metadata: Metadata = {6 title: "Resultado del pago",7 description: "Resultado de la operación con Redsys",8}9
10type Props = {11 searchParams: Promise<{ [key: string]: string | undefined }>12}13
14export default async function RedsysCallbackPage(props: Props) {15 const searchParams = await props.searchParams16 const isError = searchParams.error === "1"17 const orderId = searchParams.orderId18
19 if (isError) {20 return (21 <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8">22 <h1 className="text-2xl font-bold text-red-600">Pago no completado</h1>23 <p className="text-gray-600">24 La operación no se ha completado correctamente.25 </p>26 <a href="/" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">27 Volver a la tienda28 </a>29 </div>30 )31 }32
33 if (orderId) {34 try {35 const order = await retrieveOrder(orderId)36 if (order) {37 const countryCode = order.shipping_address?.country_code?.toLowerCase() || "dk"38 return redirect(`/${countryCode}/order/${orderId}/confirmed`)39 }40 } catch {41 // Order not found, show success anyway42 }43 }44
45 return (46 <div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8">47 <h1 className="text-2xl font-bold text-green-600">Pago procesado</h1>48 <p className="text-gray-600">Tu pago ha sido procesado correctamente.</p>49 <a href="/" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">50 Volver a la tienda51 </a>52 </div>53 )54}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",10 formUrl: "https://sis-t.redsys.es:25443/sis/realizarPago"11}These fields are used in step 3 to build the auto-submitting redirect form.
Medusa automatically exposes a webhook endpoint for the Redsys provider at:
/hooks/payment/redsys_redsysFor 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 |
| 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/medusa-payment-redsysMIT — see LICENSE file for details.
For issues and questions, please open an issue on GitHub.