Words that inspire, ideas that spark, stories that stay

Medusa v2 Custom Payment Providers: Stripe SEPA Bank Transfer Guide

Ankit Patil

Ankit Patil

Tuesday, September 8th, 20267 min read

Medusa v2 Custom Payment Providers: Stripe SEPA Bank Transfer Guide

Table of content

    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:

    1. Go to Medusa Admin – Settings – Regions

    2. Select the region (e.g., Europe)

    3. Under Payment Providers, enable stripe-bank-transfer

    4. 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

    Booking form image

    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 Meeting

    Related articles

    You may also like

    Top 5 Shopify and Shopify Plus Agencies in Detroit 2026

    Top 5 Shopify and Shopify Plus Agencies in Detroit 2026

    Read more
    Author

    Vipul Uthaiah

    Monday, July 13th, 2026

    6 min read
    Rigby Reviews + 5 Better Alternatives for Medusa.js (2026)

    Rigby Reviews + 5 Better Alternatives for Medusa.js (2026)

    Read more
    Author

    V.Srinidhi Reddy

    Friday, April 17th, 2026

    7 min read
    Top 5 Best Shopify & Shopify Plus Agencies in St. Louis (2026)

    Top 5 Best Shopify & Shopify Plus Agencies in St. Louis (2026)

    Read more
    Author

    V.Srinidhi Reddy

    Monday, April 20th, 2026

    6 min read