Medusa V2: Payment integration (Stripe/Iyzico/PayTR + payment session + webhook)
In this article, I provide an end-to-end overview of the payment architecture in a Medusa v2-based storefront—including payment collection, payment sessions, provider selection, custom provider development, and order completion via webhooks.
In this article, I’ll cover the end-to-end payment architecture—including payment collection, payment sessions, provider selection, custom provider implementation, and order completion via webhooks—in a Medusa v2-based storefront.
How to Set Up Payment Integration with Medusa V2?
In the previous two articles, we set up the headless architecture and designed the checkout flow. There was one concept that kept coming up but was never fully explained: payment session. For the user reaching the “payment” step of the checkout, the real question now is—the cart is ready, the flow is clear, so how do we connect to an actual payment provider?
Medusa v2, payment is not something the frontend can handle on its own. Just as the amount, tax, discount, and currency calculations are handled in the backend, the status of the payment session is also managed there. The storefront only manages the provider selection and the interface step required by the provider (card entry, 3D Secure redirection). This distinction is critical because it prevents costly errors like double-charging from the very beginning.
Understanding the Payment Architecture
In Medusa, there are three core payment objects: payment collection, payment session, and payment provider. A payment collection holds all payment operations for a single source (typically the cart). This collection contains one or more payment sessions; each session represents an amount to be authorized by a specific provider. The provider can be Stripe, Iyzico, or an integration you’ve built yourself.
Conceptually, the flow proceeds as follows:
Sepet (cart)
-> Payment Collection oluştur
-> Payment Session başlat (provider seçilince)
-> Sağlayıcıda ödeme arayüzü / yönlendirme
-> authorize (senkron) veya webhook (asenkron)
-> Cart Complete -> Order
The most important point here: the storefront does not manually manage these objects one by one. The JS SDK combines the steps of creating a payment collection and initiating a session into a single initiatePaymentSession call. Your job is to select the correct provider and display the appropriate step to the user based on the data returned by the provider.
Stripe Integration (Official Provider)
Stripe comes with Medusa’s out-of-the-box official provider. The only thing you need to do on the backend is add the Stripe provider under the Payment Module and read the keys from environment variables.
// medusa-config.ts
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "@medusajs/medusa/payment-stripe",
id: "stripe",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
},
],
},
},
],
})
After defining the provider, you must also add it to the region; because which payment methods are available in which region is determined by the region setting. This also forms the basis for displaying Iyzico/PayTR in Turkey and Stripe in Europe in a multi-region setup. Make sure the keys never go into the repo; STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET should only exist as environment variables.
Payment Flow on the Storefront Side
The storefront first lists the providers available in that region, and when the user selects one, it initiates a payment session for that provider. We combine these two operations into a single service file so that the components do not need to know the API details.
// lib/payment.ts
import { sdk } from "@/lib/medusa"
import { HttpTypes } from "@medusajs/types"
// Bölgeye göre kullanılabilir ödeme sağlayıcılarını getir
export async function listPaymentProviders(regionId: string) {
const { payment_providers } = await sdk.store.payment.listPaymentProviders({
region_id: regionId,
})
return payment_providers
}
// Sağlayıcı seçilince payment collection + session oluştur
export async function initPayment(
cart: HttpTypes.StoreCart,
providerId: string
) {
return sdk.store.payment.initiatePaymentSession(cart, {
provider_id: providerId,
})
}
Here, provider_id is the ID of the provider returned from the list and is in the format pp_{provider}_{id} — for example, pp_stripe_stripe. A warning: attempting to start a Stripe session for a cart with a balance of 0 will throw an error, because Stripe cannot create a session with a zero balance. Therefore, only initiate the session when the cart total is greater than zero; for free/zero-balance scenarios, it is more appropriate to use Medusa’s default Manual provider.
Payment Interface by Provider
After the session is initiated, the interface displayed to the user varies depending on the selected provider. While Stripe displays a card information field, a redirect-based provider sends the user to the provider’s payment page. Consolidating this branching logic in a single location makes it easier to add new providers in the future.
// components/checkout/payment-ui.tsx
import { HttpTypes } from "@medusajs/types"
export function getPaymentUi(cart: HttpTypes.StoreCart) {
const session = cart.payment_collection?.payment_sessions?.[0]
if (!session) return null
// Stripe: kart girişi arayüzünü göster
if (session.provider_id.startsWith("pp_stripe_")) {
return (
<StripePayment
clientSecret={session.data.client_secret as string}
/>
)
}
// Iyzico / PayTR: sağlayıcının ödeme sayfasına yönlendir
return <RedirectPayment session={session} />
}
For Stripe, @stripe/react-stripe-js renders the card fields; the user enters their card information, and the payment is authorized via client_secret. With redirect-based providers, you redirect the user to the payment page URL returned within session.data. Regardless of the situation, the order is only created once the cart is completed — simply displaying the card interface does not complete the payment.
Cart Completion → Order
When the user completes the payment step, we mark the cart as “complete.” This call authorizes the payment session with the provider and, if successful, converts the cart into an order. It is essential to check the type of the returned response: the result is either an order or a cart that has not yet been completed.
// lib/order.ts
import { sdk } from "@/lib/medusa"
export async function placeOrder(cartId: string) {
const res = await sdk.store.cart.complete(cartId)
if (res.type === "order") {
// Sipariş oluştu: cart_id'yi temizle, onay sayfasına yönlendir
return { orderId: res.order.id }
}
// type === "cart" → tamamlanamadı, kullanıcıya anlaşılır mesaj göster
throw new Error(res.error?.message ?? "Sipariş tamamlanamadı, lütfen tekrar deneyin.")
}
Two points require attention. The first is idempotency: if the user clicks the “Complete Order” button twice or if there is a network retry, a second complete call for the same cart should not trigger a new payment. Disabling the button during the call and waiting for the redirect until cart_id is cleared is the simplest way to ensure this. Second, you must absolutely remove cart_id from localStorage/cookie after a successful response; otherwise, the user will return to a completed cart and encounter a confusing situation.
Writing a Custom Payment Provider (Iyzico / PayTR)
Stripe has an official provider, but there is no ready-made Medusa provider for Iyzico and PayTR, the two most common providers in the Turkish market. In this case, you extend AbstractPaymentProvider to write your own provider. This is the part that most Medusa content skips over, but it’s unavoidable in real-world TR projects.
A provider implements all stages of the payment lifecycle as methods: initiation, authorization, charge, refund, cancellation, status query, and webhook normalization.
// src/modules/iyzico/service.ts
import { AbstractPaymentProvider } from "@medusajs/framework/utils"
import {
InitiatePaymentInput, InitiatePaymentOutput,
AuthorizePaymentInput, AuthorizePaymentOutput,
CapturePaymentInput, CapturePaymentOutput,
RefundPaymentInput, RefundPaymentOutput,
CancelPaymentInput, CancelPaymentOutput,
GetPaymentStatusInput, GetPaymentStatusOutput,
ProviderWebhookPayload, WebhookActionResult,
} from "@medusajs/framework/types"
class IyzicoProviderService extends AbstractPaymentProvider {
static identifier = "iyzico"
// Ödeme oturumu başlat: Iyzico'da bir ödeme isteği oluştur,
// dönen token/URL'i session data'sına yaz.
async initiatePayment(
input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
// const form = await this.client.createCheckoutForm({ amount: input.amount, ... })
return {
id: "iyzico_session_id",
data: {
// token, paymentPageUrl ...
},
}
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
return { status: "authorized", data: input.data }
}
async capturePayment(
input: CapturePaymentInput
): Promise<CapturePaymentOutput> {
return { data: input.data }
}
async refundPayment(
input: RefundPaymentInput
): Promise<RefundPaymentOutput> {
return { data: input.data }
}
async cancelPayment(
input: CancelPaymentInput
): Promise<CancelPaymentOutput> {
return { data: input.data }
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
return { status: "authorized" }
}
// Iyzico/PayTR callback'ini normalize et → Medusa'ya ne yapacağını söyle
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
// 1. İmzayı/hash'i doğrula
// 2. Sağlayıcı durumunu oku (success / fail)
return {
action: "authorized",
data: { session_id: "...", amount: 0 },
}
}
}
export default IyzicoProviderService
You define the provider as a module and connect it to the Payment Module:
// src/modules/iyzico/index.ts
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import IyzicoProviderService from "./service"
export default ModuleProvider(Modules.PAYMENT, {
services: [IyzicoProviderService],
})
Then, you add it to the Payment Module’s providers array in medusa-config.ts, just like Stripe:
{
resolve: "./src/modules/iyzico",
id: "iyzico",
options: {
apiKey: process.env.IYZICO_API_KEY,
secretKey: process.env.IYZICO_SECRET_KEY,
},
}
In initiatePayment, you set up the provider’s client in constructor and call the third-party API; the options parameter is also passed via the constructor. This skeleton turns into a fully functional TR payment provider when you add the actual HTTP calls.
Webhooks and Asynchronous Approval
In Stripe, card authorization is typically completed synchronously; however, with providers like Iyzico and PayTR that use 3D Secure and redirect-based authentication, the payment process is asynchronous. The user completes the transaction on the provider’s page, and the provider notifies you of the result via a webhook.
Medusa provides a built-in webhook route for this: /hooks/payment/{identifier}_{provider}. For example, for the Iyzico provider, this address is /hooks/payment/iyzico_iyzico. This route calls the Payment Module’s getWebhookActionAndData method, which then forwards the event to the relevant provider. When the method returns authorized or captured, Medusa sets the relevant payment session to this state and automatically completes the cart if it hasn’t been finalized yet.
The typical flow in practice is as follows:
Kullanıcı ödeme sayfasına yönlendirilir
-> İşlemi tamamlar
-> Sağlayıcı webhook gönderir -> /hooks/payment/iyzico_iyzico
-> getWebhookActionAndData "authorized" döndürür
-> Medusa session'ı authorize eder ve cart'ı complete eder
-> Sağlayıcı kullanıcıyı return_url'e geri yollar
-> return sayfası sipariş durumunu kontrol eder ve onay ekranı gösterir
The most common mistake here is to rely solely on the user returning to return_url to complete the order. If the user closes the browser, that page won’t work at all; but the webhook will still arrive. Therefore, the source of truth must be the webhook, and the return page should only be responsible for validation and visual confirmation. Do not consider any status reliable without verifying the webhook signature.
Conclusion
The essence of payment integration in Medusa v2 is not “how do I get the card information,” but correctly setting up where the payment status is stored. The amount and session reside in the backend, provider selection and the interface step are handled in the storefront, and asynchronous approval occurs via the webhook. For Stripe, the official provider handles this; for Iyzico and PayTR in the Turkish market, you write your own provider by extending AbstractPaymentProvider. When this trio—the official provider, custom provider, and webhook—is set up correctly, payment ceases to be the most fragile step in e-commerce and transforms into a reliably measurable flow.
Key Details to Consider in Practice
The most costly mistake in the payment side of e-commerce infrastructure is when the cart total and the amount sent for payment are sourced from different places. The amount the user sees, the amount sent to the payment session, and the amount recorded in the order must all come from the same data model. If currency, discount, and tax calculations are not handled in a single centralized location via the region, you will lose user trust at the worst possible moment—on the payment screen.
The second critical point is security. Provider keys should exist only in the backend and as environment variables; only publishable/public keys should be exposed to the storefront. If signature verification is skipped in webhooks, a fake “paid” request could trigger the order. Therefore, every webhook must be verified before processing.
Test Scenarios
Before going live, test not only the successful flow but also the failure paths. What does the user see if the payment is canceled midway? What happens to the cart if there’s a timeout on the 3D Secure screen? If a webhook arrives but the user doesn’t return, is the order still completed? If a complete call is received twice for the same cart, does the second call create a duplicate order? If the payment is successful on the provider’s side but the session cannot be updated on the Medusa side, how is the issue resolved? All these questions must be included in the test plan.
Metrics
Measuring payment success solely by the number of orders is insufficient. The completion rate of users reaching the payment step, the success/failure rate by provider, the 3D Secure abandonment rate, the webhook latency, and the frequency of duplicate charges/refunds should all be monitored together. In particular, the error rate by provider helps you distinguish whether the issue lies with an integration or a specific step in the user experience.
Implementation Plan
When applying this approach to a real project, start with a small but end-to-end working core. The first step should be to get the full flow—session initiation, completion, and confirmation page—working with a single provider (e.g., Stripe or Manual). In the second step, implement webhook-based asynchronous confirmation. In the third step, add a custom provider for TR providers. Proceeding in this order ensures the most critical step is solidified early on while preventing complexity from growing prematurely.
Additionally, every technical decision should be grounded in a user or operational rationale. A provider, a webhook, or a retry mechanism should be added not merely because it is technically correct, but because it makes the payment process safer, more traceable, or results in fewer abandoned transactions. Robust products grow through this discipline.