Sooner or later you need a processor nobody has written a module for — a regional acquirer, a high-risk specialist, a B2B invoicing flow. The provider interface is small enough to implement in a day and consequential enough to be worth implementing carefully.
The interface
| Method | When it runs | Must do |
|---|---|---|
| `initiatePayment` | Session created at checkout | Create the intent, return its reference |
| `authorizePayment` | Customer submits payment | Confirm and report status |
| `capturePayment` | At fulfilment, or immediately | Take the money |
| `refundPayment` | Refund issued | Return funds |
| `cancelPayment` | Order cancelled pre-capture | Void the authorisation |
| `deletePayment` | Session abandoned | Clean up remote state |
| `retrievePayment` | Status reconciliation | Fetch current remote state |
| `getPaymentStatus` | Anywhere status is needed | Map remote status to Medusa's |
| `getWebhookActionAndData` | Webhook received | Translate the event |
A working provider
import {
AbstractPaymentProvider,
PaymentSessionStatus,
PaymentActions,
MedusaError,
} from "@medusajs/framework/utils"
import type {
InitiatePaymentInput,
InitiatePaymentOutput,
AuthorizePaymentInput,
AuthorizePaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
RefundPaymentInput,
RefundPaymentOutput,
ProviderWebhookPayload,
WebhookActionResult,
} from "@medusajs/framework/types"
type Options = {
apiKey: string
merchantId: string
baseUrl?: string
}
class AcmePaymentProvider extends AbstractPaymentProvider<Options> {
static identifier = "acme"
protected options_: Options
constructor(container: Record<string, unknown>, options: Options) {
super(container, options)
if (!options?.apiKey || !options?.merchantId) {
throw new Error("[acme] `apiKey` and `merchantId` are required")
}
this.options_ = options
}
private async request(path: string, init: RequestInit = {}) {
const res = await fetch(`${this.options_.baseUrl ?? "https://api.acme.test"}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.options_.apiKey}`,
...init.headers,
},
})
if (!res.ok) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`[acme] ${res.status} on ${path}: ${await res.text()}`,
)
}
return res.json()
}
async initiatePayment({
amount,
currency_code,
context,
}: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const intent = await this.request("/intents", {
method: "POST",
body: JSON.stringify({
merchant_id: this.options_.merchantId,
amount: Number(amount),
currency: currency_code.toUpperCase(),
customer_email: context?.customer?.email,
}),
})
// Everything returned in `data` is persisted on the session and handed
// back to every later call — this is where provider state lives.
return { id: intent.id, data: { intent_id: intent.id, client_token: intent.token } }
}
async authorizePayment({
data,
}: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
const intent = await this.request(`/intents/${data.intent_id}`)
const status =
intent.status === "authorized"
? PaymentSessionStatus.AUTHORIZED
: intent.status === "pending"
? PaymentSessionStatus.PENDING
: PaymentSessionStatus.ERROR
return { status, data: { ...data, status: intent.status } }
}
async capturePayment({ data }: CapturePaymentInput): Promise<CapturePaymentOutput> {
// Idempotent: capturing an already-captured intent must not charge twice.
const existing = await this.request(`/intents/${data.intent_id}`)
if (existing.status === "captured") {
return { data: { ...data, status: "captured" } }
}
const captured = await this.request(`/intents/${data.intent_id}/capture`, {
method: "POST",
})
return { data: { ...data, status: captured.status } }
}
async refundPayment({ data, amount }: RefundPaymentInput): Promise<RefundPaymentOutput> {
const refund = await this.request(`/intents/${data.intent_id}/refunds`, {
method: "POST",
body: JSON.stringify({ amount: Number(amount) }),
})
return { data: { ...data, refund_id: refund.id } }
}
async cancelPayment({ data }: { data: Record<string, unknown> }) {
await this.request(`/intents/${data.intent_id}/cancel`, { method: "POST" })
return { data }
}
async deletePayment({ data }: { data: Record<string, unknown> }) {
return this.cancelPayment({ data })
}
async retrievePayment({ data }: { data: Record<string, unknown> }) {
return { data: await this.request(`/intents/${data.intent_id}`) }
}
async getPaymentStatus({ data }: { data: Record<string, unknown> }) {
const intent = await this.request(`/intents/${data.intent_id}`)
switch (intent.status) {
case "authorized":
return { status: PaymentSessionStatus.AUTHORIZED }
case "captured":
return { status: PaymentSessionStatus.CAPTURED }
case "pending":
return { status: PaymentSessionStatus.PENDING }
default:
return { status: PaymentSessionStatus.ERROR }
}
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"],
): Promise<WebhookActionResult> {
const event = payload.data as { type: string; intent_id: string; amount: number }
switch (event.type) {
case "intent.authorized":
return {
action: PaymentActions.AUTHORIZED,
data: { session_id: event.intent_id, amount: event.amount },
}
case "intent.captured":
return {
action: PaymentActions.SUCCESSFUL,
data: { session_id: event.intent_id, amount: event.amount },
}
case "intent.failed":
return {
action: PaymentActions.FAILED,
data: { session_id: event.intent_id, amount: event.amount },
}
default:
return { action: PaymentActions.NOT_SUPPORTED }
}
}
}
export default AcmePaymentProviderRegistration
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import AcmePaymentProvider from "./service"
export default ModuleProvider(Modules.PAYMENT, {
services: [AcmePaymentProvider],
}){
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/payment-acme",
id: "acme",
options: {
apiKey: process.env.ACME_API_KEY,
merchantId: process.env.ACME_MERCHANT_ID,
},
},
],
},
}The provider id at checkout becomes pp_acme_acme. Then enable it per region in the admin — registration alone does not surface it. How providers are selected.
Idempotency
The rule that matters most. Every method can be called more than once: retries, redelivered webhooks, an impatient customer clicking twice.
- Check remote state before acting. The capture above reads the intent first.
- Send an idempotency key if the processor supports one, derived from the session id rather than random.
- Treat "already done" as success, not an error. A capture that throws because the payment is already captured will fail the order for no reason.
Webhook signatures
Verify before trusting. An unverified payment webhook lets anyone mark an order paid:
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(rawBody: string, signature: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && timingSafeEqual(a, b)
}Use timingSafeEqual, not ===.
Testing
- Unit test the service against a mocked API, including the already-captured and already-refunded paths.
- Integration test the flow: initiate, authorise, capture, refund, cancel.
- Replay webhooks including duplicates and out-of-order delivery.
- Run one real transaction in production before launch.
Testing in Medusa covers the harness.
Handling asynchronous authorisation
Not every method authorises within the request. Bank redirects, 3D Secure challenges and delayed methods resolve minutes later, so authorizePayment must be able to return PENDING:
async authorizePayment({ data }: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
const intent = await this.request(`/intents/${data.intent_id}`)
if (intent.status === "requires_action") {
// Pending is a legitimate terminal state for this call — the webhook
// finishes the job. Returning ERROR here would fail a valid payment.
return {
status: PaymentSessionStatus.PENDING,
data: { ...data, next_action: intent.next_action },
}
}
return {
status: intent.status === "authorized"
? PaymentSessionStatus.AUTHORIZED
: PaymentSessionStatus.ERROR,
data: { ...data, status: intent.status },
}
}Your storefront then shows a pending confirmation page that updates when the webhook lands. Treating pending as failure is the single most common bug in a first custom provider.
A test harness
describe("AcmePaymentProvider", () => {
it("does not capture twice", async () => {
const provider = new AcmePaymentProvider({}, options)
mockIntent({ id: "int_1", status: "captured" })
const spy = jest.spyOn(global, "fetch")
await provider.capturePayment({ data: { intent_id: "int_1" } })
// One GET to check state, zero POSTs to capture.
expect(spy.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0)
})
it("returns PENDING when the intent requires action", async () => {
mockIntent({ id: "int_2", status: "requires_action" })
const result = await provider.authorizePayment({ data: { intent_id: "int_2" } })
expect(result.status).toBe(PaymentSessionStatus.PENDING)
})
})Test the paths that only occur when something goes wrong — double capture, pending authorisation, refund of an already-refunded payment. Those are the ones production will find. Testing Medusa.
We have written providers for processors most people have not heard of. Ask if you need one.
Frequently asked questions
How do I create a custom payment provider in Medusa v2?
Create a module whose service extends `AbstractPaymentProvider`, implement the payment lifecycle methods, export it with `ModuleProvider(Modules.PAYMENT, …)`, register it in the payment module's providers, and enable it per region in the admin.
What methods must a Medusa payment provider implement?
`initiatePayment`, `authorizePayment`, `capturePayment`, `refundPayment`, `cancelPayment`, `deletePayment`, `retrievePayment`, `getPaymentStatus` and `getWebhookActionAndData`. Roughly eight to nine methods, most of them thin wrappers over the processor's API.
Where does provider-specific state live?
In the `data` object returned from each method. Medusa persists it on the payment session and passes it back to every subsequent call, so remote identifiers and tokens belong there.
How do I make a payment provider idempotent?
Read remote state before acting, use idempotency keys derived from the session id where the processor supports them, and treat "already captured" or "already refunded" as success rather than an error.
How are webhooks handled for a custom provider?
Implement `getWebhookActionAndData` to translate the provider's event into a Medusa payment action such as authorised, successful or failed. Verify the signature against the raw body with a timing-safe comparison before processing.
Can I use a payment provider that has no Medusa module?
Yes — that is the point of the abstraction. Any processor with an HTTP API can be wrapped in a provider module, which is what makes Medusa viable for merchants in categories mainstream processors decline.
Building a Marketplace on Medusa: Vendors, Splits and Payouts
Multi-vendor commerce means splitting one customer order into several vendor orders, and moving money to people who are not you. The model and the money mechanics.
Building Subscriptions on Medusa: Billing Cycles, Dunning and Churn
Recurring billing is a scheduling problem with money attached. The data model, the renewal workflow, and the failure handling that determines involuntary churn.
B2B Commerce on Medusa: Companies, Approvals, Quotes and Net Terms
The four capabilities that separate B2B from DTC, modelled as Medusa modules and workflows — with the data model that makes approvals work.



