Words that inspire, ideas that spark,
stories that stay
Medusa v2 Custom Payment Providers: Stripe SEPA Bank Transfer Guide
Ankit Patil
Tuesday, September 8th, 20267 min read
Introduction
If you sell to customers in Europe, credit card payments alone won’t cut it. A huge chunk of European buyers prefer bank transfers - especially for high-value orders or B2B invoices. SEPA (Single Euro Payments Area) is the standard payment network that makes euro transfers work seamlessly across 36 countries.
Medusa v2 has a clean payment provider abstraction that makes it straightforward to plug in any payment method - not just cards. In this post, we’ll walk through a real production implementation of a custom Stripe SEPA Bank Transfer payment provider built for California Flowers, a B2B flower marketplace.
This is not a “hello world” tutorial. We’ll go through every file, every method, and every gotcha we ran into while building this.
Here’s what we’ll cover:
-
The difference between SEPA Direct Debit and SEPA Credit Transfer (they are very different things)
-
How Medusa v2’s AbstractPaymentProvider works
-
A full walkthrough of the custom provider - all 10 required methods
-
Webhook setup and why raw body preservation matters
-
How to test the webhook locally using Postman
Let’s get into it.
SEPA (Single Euro Payments Area): Two Things That Sound the Same but Work Very Differently
Before writing a single line of code, it’s important to understand what kind of SEPA payment you’re actually implementing. There are two: SEPA Direct Debit and SEPA Credit Transfer (Bank Transfer). They work in opposite directions.
The Core Difference
|
SEPA Direct Debit |
SEPA Credit Transfer (Bank Transfer) |
|
|---|---|---|
|
Who sends money? |
Merchant pulls from customer |
Customer pushes to merchant |
|
What the customer provides |
Their IBAN + signs a mandate |
Nothing - Stripe gives them a virtual account |
|
Best for |
Subscriptions, recurring billing |
One-time invoices, high-value B2B orders |
|
Mandate required? |
Yes - customer must authorize |
No |
|
Settlement speed |
1–5 business days |
Seconds (SEPA Instant) or 1–2 days |
|
Stripe payment method |
sepa_debit |
customer_balance + bank_transfer |
|
Dispute window |
Up to 13 months |
Lower risk |
How Each Flow Works
SEPA Direct Debit (Pull)
1. Customer gives IBAN + signs mandate
2. Merchant creates PaymentIntent
3. Stripe debits customer bank
4. Funds arrive in merchant account
SEPA Credit Transfer (Push)
1. Merchant creates PaymentIntent
2. Stripe generates virtual IBAN
3. Customer sends transfer manually
4. Stripe detects transfer - fires webhook
5. Order marked as paid
In this blog, we’re implementing SEPA Credit Transfer (Bank Transfer) - the “push” model. The customer sees a Stripe-generated virtual IBAN and bank reference, then manually initiates the transfer from their bank. Stripe detects the incoming funds and fires a webhook back to Medusa.
References:
How Medusa v2 Payment Providers Work
You’re expected to know Medusa basics, so let’s just cover what matters here.
In Medusa v2, a payment provider is a module whose main service extends AbstractPaymentProvider from @medusajs/framework/utils. The Payment Module calls your provider’s methods at the right times - you just implement the business logic for each operation.
The provider’s ID in the database follows this pattern:
pp_{identifier}_{id}
For example, our provider has:
-
identifier = "stripe-bank-transfer" (set in the class)
-
id = "stripe-bank-transfer" (set in medusa-config.ts)
So its full ID is pp_stripe-bank-transfer_stripe-bank-transfer. You’ll see this used in API calls.
How Medusa Calls Your Provider
Here’s the full lifecycle - from a customer selecting bank transfer at checkout to the order being marked as paid:
1 Frontend → Medusa: POST /store/carts/:id/payment-sessions
2 Medusa → Provider: initiatePayment()
3 Provider → Stripe: Create PaymentIntent (customer_balance)
4 Stripe → Provider: PaymentIntent + virtual IBAN + reference
5 Provider → Medusa: { id, status: "pending_authorization", data: bank_transfer_instructions }
6 Medusa → Frontend: Payment session with bank transfer details
Note: at this point the customer makes the bank transfer manually in their banking app.
1 Stripe → Medusa: POST /hooks/payment/stripe-bank-transfer_stripe-bank-transfer
2 Medusa → Provider: getWebhookActionAndData()
3 Provider → Medusa: { action: SUCCESSFUL, session_id, amount }
4 Medusa: Mark order as paid
Reference: Medusa - Create a Payment Module Provider
Project Structure
Here’s the folder layout for the entire custom provider:
src/modules/stripe-bank-transfer/
├── constants.ts ← Stripe status/event enums + payment type string constants
├── helpers.ts ← Customer creation, status mapping, webhook validation, error builder
├── index.ts ← Module definition (exported for medusa-config.ts)
├── service.ts ← The main class - extends AbstractPaymentProvider (556 lines)
├── types.ts ← TypeScript types for options, PaymentIntent shape, events
└── utils.ts ← EUR minor-unit conversion, country code extractor, type guards
Module Entry Point
// src/modules/stripe-bank-transfer/index.ts
import { Module } from "@medusajs/framework/utils";
import { StripeBankTransferService } from "./service";
export default {
services: [StripeBankTransferService],
};
export const STRIPE_BANK_TRANSFER_MODULE = Module("stripe-bank-transfer", {
service: StripeBankTransferService,
});
Constants & Types
Constants
We pull all Stripe-specific string constants and enums into constants.ts. No magic strings scattered around the codebase.
// src/modules/stripe-bank-transfer/constants.ts
export const CUSTOMER_BALANCE_TYPE = "customer_balance";
export const BANK_TRANSFER_TYPE = "bank_transfer";
export const EU_BANK_TRANSFER_TYPE = "eu_bank_transfer";
export const EU_CURRENCY_CODE = "eur";
export enum StripePaymentStatus {
REQUIRES_PAYMENT_METHOD = "requires_payment_method",
PROCESSING = "processing",
REQUIRES_ACTION = "requires_action",
CANCELED = "canceled",
REQUIRES_CAPTURE = "requires_capture",
SUCCEEDED = "succeeded",
}
export enum StripeWebhookEvent {
PAYMENT_INTENT_CREATED = "payment_intent.created",
PAYMENT_INTENT_PROCESSING = "payment_intent.processing",
PAYMENT_INTENT_CANCELED = "payment_intent.canceled",
PAYMENT_INTENT_PAYMENT_FAILED = "payment_intent.payment_failed",
PAYMENT_INTENT_REQUIRES_ACTION = "payment_intent.requires_action",
PAYMENT_INTENT_PARTIALLY_FUNDED = "payment_intent.partially_funded",
PAYMENT_INTENT_SUCCEEDED = "payment_intent.succeeded",
}
Types
The most important type here is StripePaymentIntent - specifically the next_action.display_bank_transfer_instructions field. That’s where the virtual IBAN, BIC, and payment reference live after the intent is created.
// src/modules/stripe-bank-transfer/types.ts
import { BigNumberInput } from "@medusajs/framework/types";
export type StripeBankTransferOptions = {
apiKey: string;
webhookSecret: string;
};
export type StripePaymentIntent = {
id?: string;
status: string;
amount: number;
amount_received?: number;
amount_capturable?: number;
currency?: string;
customer?: string | null;
metadata?: Record<string, string | null | undefined>;
next_action?: {
display_bank_transfer_instructions?: {
amount_remaining?: number;
currency?: string;
reference?: string;
hosted_instructions_url?: string;
financial_addresses?: unknown[];
};
};
last_payment_error?: unknown;
};
export type StripeEvent = {
type: string;
data: {
object: StripePaymentIntent;
};
};
export type CustomerLike = {
id?: string;
email?: string;
company_name?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
billing_address?: Record<string, unknown> | null;
account_holders?: AccountHolderLike[] | null;
};
export type AmountInput = BigNumberInput;
Utility Functions
Small but important. These utilities keep the main service clean.
// src/modules/stripe-bank-transfer/utils.ts
import { AmountInput } from "./types";
export const EUR_MINOR_UNIT = 100;
// Safe cast to Record - avoids runtime crashes on unknown types
export const asRecord = (value: unknown): Record<string, unknown> | undefined => {
if (!value || typeof value !== "object") return undefined;
return value as Record<string, unknown>;
};
// Safe string extraction - trims and returns undefined if empty
export const extractString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return normalized ? normalized : undefined;
};
// Extracts country code from address objects
// Handles both `country_code` and `country` keys (different parts of Medusa use different names)
export const extractCountryCode = (address: unknown): string | undefined => {
const addressRecord = asRecord(address);
const raw = addressRecord?.country_code ?? addressRecord?.country;
if (typeof raw !== "string") return undefined;
const normalized = raw.trim();
return normalized ? normalized.toUpperCase() : undefined;
};
// €10.00 - 1000 (Stripe expects amounts in smallest currency unit)
export const getSmallestUnit = (amount: AmountInput): number => {
return Math.round(Number(amount) * EUR_MINOR_UNIT);
};
// 1000 - €10.00 (convert back for Medusa)
export const getAmountFromSmallestUnit = (amount: number): number => {
return amount / EUR_MINOR_UNIT;
};
Key point: Stripe always works in the smallest currency unit. For EUR that’s cents. getSmallestUnit and getAmountFromSmallestUnit handle the conversion in both directions.
Helper Functions
These four helpers do the heavy lifting that doesn’t belong inside the main service methods.
6.1 getOrCreateStripeCustomer
Bank transfer PaymentIntents in Stripe require a Customer object. This helper finds one by email or creates a new one - safely, with no duplicates.
// src/modules/stripe-bank-transfer/helpers.ts (excerpt)
export async function getOrCreateStripeCustomer(
stripe: Stripe,
email: string,
name?: string,
country?: string,
idempotencyKey?: string
): Promise<string> {
// Check if a Stripe customer already exists for this email
const existing = await stripe.customers.list({ email, limit: 1 });
if (existing.data.length) {
const customer = existing.data[0];
// Update country if it changed (e.g. customer moved)
if (country && customer.address?.country !== country) {
const updateKey = idempotencyKey ? `${idempotencyKey}_cust_update` : undefined;
await stripe.customers.update(
customer.id,
{ address: { country } },
updateKey ? { idempotencyKey: updateKey } : undefined
);
}
return existing.data[0].id;
}
// Create a new Stripe Customer
const customerPayload: Record<string, unknown> = { email };
if (name) customerPayload.name = name;
if (country) customerPayload.address = { country };
const customerKey = idempotencyKey ? `${idempotencyKey}_cust` : undefined;
const customer = await stripe.customers.create(
customerPayload,
customerKey ? { idempotencyKey: customerKey } : undefined
);
return customer.id;
}
6.2 resolveStripeCustomerId
Before creating a new Stripe Customer, we check if Medusa already has one stored. This helper walks through Medusa’s account_holder context to find a previously-stored Stripe Customer ID.
export function resolveStripeCustomerId(
context: InitiatePaymentInput["context"] | undefined,
customer: CustomerLike | undefined
): string | undefined {
const accountHolderRecord = asRecord(context?.account_holder);
const accountHolderData = asRecord(accountHolderRecord?.data);
const directContextId =
extractString(accountHolderData?.id) ??
extractString(accountHolderRecord?.external_id);
if (directContextId) return directContextId;
const customerAccountHolders = customer?.account_holders ?? [];
for (const holder of customerAccountHolders) {
if (!holder) continue;
const providerId = extractString(holder.provider_id)?.toLowerCase();
if (providerId && !providerId.includes("stripe")) continue;
const holderData = asRecord(holder.data);
const stripeCustomerId =
extractString(holder.external_id) ?? extractString(holderData?.id);
if (stripeCustomerId) return stripeCustomerId;
}
return undefined;
}
6.3 getStatus - Stripe Status to Medusa Status
|
Stripe status |
Condition |
Medusa status |
|---|---|---|
|
requires_payment_method |
no error |
PENDING |
|
requires_payment_method |
has last_payment_error |
ERROR |
|
processing |
- |
AUTHORIZED |
|
requires_action |
- |
PENDING_AUTHORIZATION |
|
canceled |
- |
CANCELED |
|
requires_capture |
- |
AUTHORIZED |
|
succeeded |
- |
CAPTURED |
`requires_action` maps to `pending_authorization` (Medusa v2.17.2+) since it always means "awaiting the customer’s manual transfer" in this bank-transfer-only provider - not a mid-checkout action like 3DS.
export function getStatus(paymentIntent: StripePaymentIntent): {
data: StripePaymentIntent;
status: PaymentSessionStatus;
} {
switch (paymentIntent.status) {
case StripePaymentStatus.REQUIRES_PAYMENT_METHOD:
if (paymentIntent.last_payment_error) {
return { status: PaymentSessionStatus.ERROR, data: paymentIntent };
}
return { status: PaymentSessionStatus.PENDING, data: paymentIntent };
case StripePaymentStatus.PROCESSING:
return { status: PaymentSessionStatus.AUTHORIZED, data: paymentIntent };
case StripePaymentStatus.REQUIRES_ACTION:
// Deferred authorization (Medusa v2.17.2+) - awaiting the bank transfer via webhook
return { status: PaymentSessionStatus.PENDING_AUTHORIZATION, data: paymentIntent };
case StripePaymentStatus.CANCELED:
return { status: PaymentSessionStatus.CANCELED, data: paymentIntent };
case StripePaymentStatus.REQUIRES_CAPTURE:
return { status: PaymentSessionStatus.AUTHORIZED, data: paymentIntent };
case StripePaymentStatus.SUCCEEDED:
return { status: PaymentSessionStatus.CAPTURED, data: paymentIntent };
default:
return { status: PaymentSessionStatus.PENDING, data: paymentIntent };
}
}
6.4 constructWebhookEvent + buildError
// Validates the Stripe HMAC signature and parses the event
export function constructWebhookEvent(
stripe: Stripe,
webhookSecret: string,
data: ProviderWebhookPayload["payload"]
): StripeEvent {
const signature = data.headers["stripe-signature"] as string;
return stripe.webhooks.constructEvent(
data.rawData as string | Buffer,
signature,
webhookSecret
) as StripeEvent;
}
// Consistent error message with Stripe's raw.detail extraction
export function buildError(message: string, error: unknown): Error {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorDetails =
typeof error === "object" && error && "raw" in error
? (error as { raw?: { detail?: string } }).raw
: undefined;
const detail = errorDetails?.detail ? `${errorDetails.detail}` : "";
return new Error(`${message}:${errorMessage}.${detail}`.trim());
}
The Payment Provider Service (All 10 Methods)
This is the main file - service.ts. It extends AbstractPaymentProvider and implements all 10 required methods.
The Class Setup
// src/modules/stripe-bank-transfer/service.ts
export class StripeBankTransferService extends AbstractPaymentProvider<StripeBankTransferOptions> {
static identifier = "stripe-bank-transfer";
protected readonly options_: StripeBankTransferOptions;
protected stripe_: Stripe.Stripe;
constructor(container: Record<string, unknown>, options: StripeBankTransferOptions) {
// @ts-ignore
super(...arguments);
this.options_ = options;
this.stripe_ = new Stripe(this.options_.apiKey);
}
}
Method 1: validateOptions (static)
Called once at startup. Throws if required options are missing - fail fast before any requests happen.
static validateOptions(options: StripeBankTransferOptions): void {
if (!isDefined(options?.apiKey)) {
throw new Error("Required option `apiKey` is missing in Stripe bank transfer provider");
}
if (!isDefined(options?.webhookSecret)) {
throw new Error("Required option `webhookSecret` is missing in Stripe bank transfer provider");
}
}
Method 2: initiatePayment - The Core
This is the most important method. When a customer selects “Bank Transfer” at checkout, Medusa calls initiatePayment. This is where we create the Stripe PaymentIntent and get back the virtual IBAN.
async initiatePayment({
currency_code,
amount,
data,
context,
}: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
// 1. EUR-only guard - SEPA Credit Transfer only works with EUR
if (currency_code.toLowerCase() !== EU_CURRENCY_CODE) {
throw buildError(
"SEPA Credit Transfer only supports EUR",
new Error(`Received currency:${currency_code}`)
);
}
// 2. Convert amount to cents (Stripe smallest unit)
const amountNumeric = getSmallestUnit(amount);
if (!Number.isFinite(amountNumeric) || amountNumeric <= 0) {
throw buildError("Invalid amount for bank transfer", new Error("Invalid amount"));
}
// 3. Build metadata - Medusa uses session_id to route webhooks back
const inputData = (data ?? {}) as Record<string, unknown>;
const metadata: Record<string, string> = {};
const sessionId = inputData.session_id ?? inputData.payment_session_id ?? inputData.resource_id;
if (sessionId) metadata.session_id = String(sessionId);
if (inputData.cart_id) metadata.cart_id = String(inputData.cart_id);
if (context?.customer?.id) metadata.customer_id = context.customer.id;
// 4. Resolve billing country - determines which country IBAN Stripe assigns
const customer = context?.customer as CustomerLike | undefined;
let billingCountry = extractCountryCode(customer?.billing_address);
if (!billingCountry) billingCountry = extractCountryCode(inputData.billing_address);
if (!billingCountry) {
throw buildError(
"Missing billing country for bank transfer",
new Error("No billing country found in context.customer or storefront data")
);
}
// 5. Resolve customer email
let customerEmail = customer?.email;
if (!customerEmail && inputData.email) customerEmail = String(inputData.email);
if (!customerEmail) {
throw buildError("Missing customer email in context", new Error("No customer email found"));
}
// 6. Resolve customer name
let customerName =
`${customer?.first_name ?? ""}${customer?.last_name ?? ""}`.trim() || undefined;
if (!customerName && inputData.customer_name) customerName = String(inputData.customer_name);
// 7. Get or create Stripe Customer (required for bank transfer intents)
let stripeCustomerId = resolveStripeCustomerId(context, customer);
if (!stripeCustomerId) {
stripeCustomerId = await getOrCreateStripeCustomer(
this.stripe_,
customerEmail,
customerName,
billingCountry,
context?.idempotency_key
);
}
// 8. Build and create the PaymentIntent
const intentRequest = {
amount: amountNumeric,
currency: EU_CURRENCY_CODE,
metadata,
payment_method_types: [CUSTOMER_BALANCE_TYPE], // "customer_balance"
payment_method_options: {
customer_balance: {
funding_type: BANK_TRANSFER_TYPE, // "bank_transfer"
bank_transfer: {
type: EU_BANK_TRANSFER_TYPE, // "eu_bank_transfer"
eu_bank_transfer: {
country: billingCountry, // "FR" - French IBAN, "DE" - German IBAN
},
},
},
},
confirm: true, // confirm immediately
payment_method_data: { type: CUSTOMER_BALANCE_TYPE },
customer: stripeCustomerId,
};
const intentIdempotencyKey = context?.idempotency_key
? `${context.idempotency_key}_pi`
: undefined;
const intent = await this.stripe_.paymentIntents.create(
intentRequest as any,
intentIdempotencyKey ? { idempotencyKey: intentIdempotencyKey } : undefined
) as unknown as StripePaymentIntent;
// 9. Extract bank transfer instructions from next_action
const instructions = intent.next_action?.display_bank_transfer_instructions;
return {
id: intent.id ?? (sessionId as string),
...getStatus(intent),
data: {
...intent,
id: intent.id,
payment_intent_id: intent.id,
stripe_customer_id: stripeCustomerId,
bank_transfer_instructions: {
amount_remaining: instructions?.amount_remaining,
currency: instructions?.currency,
reference: instructions?.reference,
hosted_instructions_url: instructions?.hosted_instructions_url,
financial_addresses: instructions?.financial_addresses ?? [],
},
},
};
}
What comes back in `bank_transfer_instructions`:
{
"amount_remaining": 10000,
"currency": "eur",
"reference": "CF-2024-ABCD",
"hosted_instructions_url": "https://payments.stripe.com/bank_transfer_instructions/...",
"financial_addresses": [
{
"type": "iban",
"iban": {
"iban": "NL40CITI7000799556",
"bic": "CITINL2XXXX",
"country": "NL",
"account_holder_name": "California Flowers"
},
"supported_networks": ["sepa"]
}
]
}
References:
Method 3: authorizePayment
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
return this.getPaymentStatus(input);
}
One line. Because bank transfer is asynchronous, authorization doesn’t happen at checkout time. The customer hasn’t transferred money yet - they’re just shown the IBAN. So authorizePayment delegates to getPaymentStatus, which (via the getStatus helper above) now returns pending_authorization.
Note: Since Medusa v2.17.2, pending_authorization is a purpose-built status for deferred/async payment methods - bank transfers, payment links, vouchers like OXXO or boleto. Returning it tells Medusa: don’t create a Payment record yet, but the cart can still complete and the order is created with an “awaiting” payment status. The session later transitions to authorized once the webhook confirms the transfer arrived. On older Medusa versions, fall back to requires_more instead - but note it won’t create the order immediately, so you’ll need to handle that tradeoff differently.
Reference: Medusa - authorizePayment deferred flow
Method 4: capturePayment
async capturePayment({ data }: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: data ?? {} };
}
A no-op. Stripe auto-captures bank transfers the moment funds arrive - there’s nothing to manually capture.
Method 5: cancelPayment
async cancelPayment({ data, context }: CancelPaymentInput): Promise<CancelPaymentOutput> {
try {
const id = data?.id as string | undefined;
if (!id) return { data: data };
const res = await this.stripe_.paymentIntents.cancel(
id,
undefined,
{ idempotencyKey: context?.idempotency_key }
);
return { data: res as unknown as Record<string, unknown> };
} catch (error) {
throw buildError("An error occurred in cancelPayment", error as Error);
}
}
Method 6: deletePayment
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return await this.cancelPayment(input);
}
Delegates to cancelPayment.
Method 7: refundPayment
async refundPayment({ amount, data, context }: RefundPaymentInput): Promise<RefundPaymentOutput> {
const id = data?.id as string | undefined;
if (!id) {
throw buildError("No payment intent ID provided while refunding payment", new Error("No ID"));
}
try {
const refundAmount = getSmallestUnit(amount);
await this.stripe_.refunds.create(
{ amount: refundAmount, payment_intent: id },
{ idempotencyKey: context?.idempotency_key }
);
} catch (e) {
throw buildError("An error occurred in refundPayment", e as Error);
}
return { data: data ?? {} };
}
Method 8: retrievePayment
async retrievePayment({ data }: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
try {
const id = data?.id as string | undefined;
if (!id) throw buildError("No payment intent ID", new Error("No ID"));
const intent = await this.stripe_.paymentIntents.retrieve(id) as unknown as StripePaymentIntent;
// Convert amounts back from cents to EUR
intent.amount = getAmountFromSmallestUnit(intent.amount);
if (intent.amount_received) {
intent.amount_received = getAmountFromSmallestUnit(intent.amount_received);
}
// Re-extract bank transfer instructions if they still exist
const instructions = intent.next_action?.display_bank_transfer_instructions;
const bank_transfer_instructions = instructions
? {
amount_remaining: instructions.amount_remaining,
currency: instructions.currency,
reference: instructions.reference,
hosted_instructions_url: instructions.hosted_instructions_url,
financial_addresses: instructions.financial_addresses ?? [],
}
: (data?.bank_transfer_instructions as Record<string, unknown> | undefined);
return {
data: {
...intent,
payment_intent_id: intent.id,
stripe_customer_id: intent.customer as string | undefined,
bank_transfer_instructions,
} as unknown as Record<string, unknown>,
};
} catch (e) {
throw buildError("An error occurred in retrievePayment", e as Error);
}
}
Method 9: updatePayment
async updatePayment({ data, currency_code, amount, context }: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
if (currency_code.toLowerCase() !== EU_CURRENCY_CODE) {
throw buildError("SEPA Credit Transfer only supports EUR", new Error(`Received:${currency_code}`));
}
const amountNumeric = getSmallestUnit(amount);
// Smart skip: if amount hasn't changed, don't call Stripe at all
if (isPresent(amount) && data && data.amount === amountNumeric) {
const intent = data as unknown as StripePaymentIntent;
const instructions = intent.next_action?.display_bank_transfer_instructions;
const bank_transfer_instructions = instructions
? { /* ... extract fields ... */ }
: (data.bank_transfer_instructions as Record<string, unknown> | undefined);
return {
id: intent.id,
status: getStatus(intent).status,
data: { ...intent, payment_intent_id: intent.id, bank_transfer_instructions },
} as unknown as UpdatePaymentOutput;
}
// Amount changed - update in Stripe
const id = data?.id as string | undefined;
if (!id) throw buildError("No payment intent ID", new Error("No ID"));
const sessionData = await this.stripe_.paymentIntents.update(
id as string,
{ amount: amountNumeric },
{ idempotencyKey: context?.idempotency_key }
) as unknown as StripePaymentIntent;
// ... return updated data with extracted bank_transfer_instructions
}
The smart part: if the amount hasn’t changed, we skip the Stripe API call entirely and return existing data. This saves a network roundtrip when Medusa recalculates the cart.
Method 10: getWebhookActionAndData - The Event Router
This is what makes the async flow work. Stripe fires webhook events when the bank transfer status changes, and this method tells Medusa what to do.
-
Signature invalid → NOT_SUPPORTED
-
Signature valid → branch on event.type:
-
payment_intent.created / payment_intent.processing → PENDING
-
payment_intent.requires_action / payment_intent.partially_funded → REQUIRES_MORE
-
payment_intent.canceled → CANCELED
-
payment_intent.payment_failed → FAILED
-
payment_intent.succeeded → SUCCESSFUL - order marked paid
-
anything else → NOT_SUPPORTED
async getWebhookActionAndData(
webhookData: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
let event: StripeEvent;
try {
event = constructWebhookEvent(this.stripe_, this.options_.webhookSecret, webhookData);
} catch {
return { action: PaymentActions.NOT_SUPPORTED };
}
const intent = event.data.object as StripePaymentIntent;
const sessionId =
intent.metadata?.session_id ??
intent.metadata?.payment_session_id ??
intent.metadata?.resource_id ??
"";
switch (event.type) {
case StripeWebhookEvent.PAYMENT_INTENT_CREATED:
case StripeWebhookEvent.PAYMENT_INTENT_PROCESSING:
return {
action: PaymentActions.PENDING,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(intent.amount) },
};
case StripeWebhookEvent.PAYMENT_INTENT_CANCELED:
return {
action: PaymentActions.CANCELED,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(intent.amount) },
};
case StripeWebhookEvent.PAYMENT_INTENT_PAYMENT_FAILED:
return {
action: PaymentActions.FAILED,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(intent.amount) },
};
case StripeWebhookEvent.PAYMENT_INTENT_REQUIRES_ACTION:
return {
action: PaymentActions.REQUIRES_MORE,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(intent.amount) },
};
case StripeWebhookEvent.PAYMENT_INTENT_PARTIALLY_FUNDED: {
// Customer sent partial payment - return remaining amount
const remaining =
intent.next_action?.display_bank_transfer_instructions?.amount_remaining ??
intent.amount;
return {
action: PaymentActions.REQUIRES_MORE,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(remaining) },
};
}
case StripeWebhookEvent.PAYMENT_INTENT_SUCCEEDED: {
const received = intent.amount_received ?? intent.amount;
return {
action: PaymentActions.SUCCESSFUL,
data: { session_id: sessionId, amount: getAmountFromSmallestUnit(received) },
};
}
default:
return { action: PaymentActions.NOT_SUPPORTED };
}
}
Webhook Middleware (Raw Body is Critical)
One of the most common mistakes when integrating Stripe webhooks: not preserving the raw request body.
Stripe signs the raw bytes of the request body with HMAC-SHA256. If your framework parses the JSON body before the signature check runs, the bytes change slightly. Stripe’s check then fails every time with:
No signatures found matching the expected signature for payload
In Medusa, fix this with one line in middlewares.ts:
// src/api/middlewares.ts
{
matcher: "/webhook/stripe/*",
methods: ["POST"],
bodyParser: {
preserveRawBody: true, // ← without this, every webhook fails
},
},
Medusa’s built-in payment webhook endpoint is:
POST /hooks/payment/{provider_id}
For our provider, that’s:
POST /hooks/payment/stripe-bank-transfer_stripe-bank-transfer
Configure this URL in your Stripe webhook dashboard for production.
Reference: Stripe - Webhook signature verification
Registering in medusa-config.ts
// medusa-config.ts
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
// Official Stripe provider (cards, etc.)
{
resolve: "@medusajs/medusa/payment-stripe",
id: "stripe",
options: {
apiKey: process.env.STRIPE_API_KEY,
capture: true,
},
},
// Custom SEPA bank transfer provider
{
resolve: "./src/modules/stripe-bank-transfer",
id: "stripe-bank-transfer",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_BANK_TRANSFER_WEBHOOK_SECRET,
},
},
],
},
},
Environment Variables to Add
STRIPE_API_KEY=sk_test_...
STRIPE_BANK_TRANSFER_WEBHOOK_SECRET=whsec_...
Enabling in Medusa Admin
Once the provider is registered, enable it per-region:
-
Go to Medusa Admin – Settings – Regions
-
Select the region (e.g., Europe)
-
Under Payment Providers, enable stripe-bank-transfer
-
Save
Testing the Webhook Locally with Postman
Since the Medusa server runs locally, Stripe cannot reach it directly. We use Postman to simulate the webhook with a real HMAC-SHA256 signature so Medusa accepts it.
Endpoint
POST {{baseUrl}}/hooks/payment/stripe-bank-transfer_stripe-bank-transfer
Headers:
Stripe-Signature: {{stripeSignature}}
Content-Type: application/json
Body: {{rawBody}}
Pre-request Script
Add this to the Pre-request Script tab in Postman. It builds the Stripe-compatible signed payload automatically:
const CryptoJS = require('crypto-js');
const payload = {
id: "evt_test_123",
object: "event",
type: "payment_intent.succeeded",
created: Math.floor(Date.now() / 1000),
data: {
object: {
id: pm.collectionVariables.get("payment_intent_id"),
object: "payment_intent",
amount: parseInt(pm.collectionVariables.get("amount")),
amount_received: parseInt(pm.collectionVariables.get("amount")),
currency: "eur",
status: "succeeded",
metadata: {
session_id: pm.collectionVariables.get("payment_session_id")
}
}
}
};
const rawBody = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const secret = pm.collectionVariables.get('stripeWebhookSigningSecret');
// Generate signature the same way Stripe does
const signedPayload = `${timestamp}.${rawBody}`;
const signature = CryptoJS.HmacSHA256(signedPayload, secret).toString(CryptoJS.enc.Hex);
pm.variables.set('rawBody', rawBody);
pm.variables.set('stripeSignature', `t=${timestamp},v1=${signature}`);
A successful call returns 200 OK from Medusa, and the order payment status updates to paid.
Reference: Stripe - Webhook signature verification
Frontend Integration Tips
After the payment session is created (Step 7), show the customer these details:
{
"bank_transfer_instructions": {
"amount_remaining": 10000,
"currency": "eur",
"reference": "CF-2024-ABCD",
"hosted_instructions_url": "https://payments.stripe.com/bank_transfer_instructions/...",
"financial_addresses": [
{
"type": "iban",
"iban": {
"iban": "NL40CITI7000799556",
"bic": "CITINL2XXXX",
"country": "NL",
"account_holder_name": "California Flowers"
},
"supported_networks": ["sepa"]
}
]
}
}
What to show:
-
The IBAN and BIC the customer transfers to
-
The reference (the customer must include this exactly - Stripe uses it to match the transfer)
-
The exact amount
-
Link to hosted_instructions_url - Stripe’s pre-built payment instructions page
For partial payments (payment_intent.partially_funded), show: “We received €40. Please transfer the remaining €60 using the same reference.”
Frontend Flow
1 Customer selects Bank Transfer at checkout
2 POST /store/carts/:id/payment-sessions
3 initiatePayment creates Stripe PaymentIntent
4 Frontend reads bank_transfer_instructions
5 Show IBAN, BIC, Reference, Amount
6 Customer transfers from their banking app
7 Stripe detects transfer, fires webhook
8 Order status changes to Paid
9 Customer gets order confirmation email
Key Gotchas & Lessons Learned
1. EUR only - validate early
SEPA Credit Transfer only supports EUR. Our initiatePayment throws immediately if currency_code !== "eur". Catch it early with a clear error - don’t wait for Stripe to reject it.
2. Stripe Customer is mandatory
Bank transfer PaymentIntents require a Stripe Customer attached. The getOrCreateStripeCustomer helper handles this - but make sure the customer’s email is always available in context.
3. Raw body is non-negotiable
Without preserveRawBody: true on /webhook/stripe/* in middlewares.ts, Stripe’s signature check will fail every single time. Add this before writing any webhook code.
4. Authorization is deferred - don’t expect it immediately
When authorizePayment is called during cart completion, the customer hasn’t transferred money yet. Status is pending_authorization (Medusa v2.17.2+; requires_more on older versions). Medusa places the order in “awaiting” payment and waits for the webhook. This is correct - don’t try to work around it.
5. Idempotency key suffixes matter
If you reuse the same idempotency key across different Stripe API calls, Stripe throws an error because the same key maps to a different endpoint. Suffix per-operation: {key}_pi for the PaymentIntent, {key}_cust for customer creation, {key}_cust_update for customer updates.
6. Billing country = IBAN country
The eu_bank_transfer.country param tells Stripe which country’s virtual bank account to assign. Always get this from the billing address - not shipping.
7. Handle `partially_funded` separately
If a customer sends the wrong amount, Stripe fires payment_intent.partially_funded with amount_remaining. Map it to REQUIRES_MORE and include amount_remaining so the frontend can tell the customer exactly how much is still needed.
8. Use `pending_authorization`, not `requires_more`, for deferred auth
Since Medusa v2.17.2, there’s a dedicated pending_authorization status for payment methods that confirm outside the checkout flow (bank transfers, payment links, vouchers). Using it lets Medusa create the order immediately in an “awaiting” state instead of blocking on requires_more, which is meant for actions like 3DS that happen during checkout. This status isn’t available on older Medusa versions.
References
Struggling to choose the right Headless CMS & Headless Commerce tech stack? We’ll help you pick the best solution for your business! Exclusive Offer: 20 Hours of Free Development & Consultation
Book a MeetingRelated articles
You may also like
Top 5 Shopify and Shopify Plus Agencies in Detroit 2026
Read moreVipul Uthaiah
Monday, July 13th, 2026
6 min readRigby Reviews + 5 Better Alternatives for Medusa.js (2026)
Read moreV.Srinidhi Reddy
Friday, April 17th, 2026
7 min readTop 5 Best Shopify & Shopify Plus Agencies in St. Louis (2026)
Read moreV.Srinidhi Reddy
Monday, April 20th, 2026
6 min read