2 MIN READ

Building a Custom Payment Provider for Medusa

The provider interface method by method, a working implementation, and the idempotency and webhook details that separate a demo from something you can take money with.

BY RAHUL MEHTAUPDATED
Illustration for “Building a Custom Payment Provider for Medusa” — Payments

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

MethodWhen it runsMust do
`initiatePayment`Session created at checkoutCreate the intent, return its reference
`authorizePayment`Customer submits paymentConfirm and report status
`capturePayment`At fulfilment, or immediatelyTake the money
`refundPayment`Refund issuedReturn funds
`cancelPayment`Order cancelled pre-captureVoid the authorisation
`deletePayment`Session abandonedClean up remote state
`retrievePayment`Status reconciliationFetch current remote state
`getPaymentStatus`Anywhere status is neededMap remote status to Medusa's
`getWebhookActionAndData`Webhook receivedTranslate the event

A working provider

src/modules/payment-acme/service.tsts
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 AcmePaymentProvider

Registration

src/modules/payment-acme/index.tsts
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import AcmePaymentProvider from "./service"

export default ModuleProvider(Modules.PAYMENT, {
  services: [AcmePaymentProvider],
})
medusa-config.tsts
{
  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:

ts
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

  1. Unit test the service against a mocked API, including the already-captured and already-refunded paths.
  2. Integration test the flow: initiate, authorise, capture, refund, cancel.
  3. Replay webhooks including duplicates and out-of-order delivery.
  4. 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:

ts
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

src/modules/payment-acme/__tests__/service.spec.tsts
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.

[ Keep reading ]