Unofficial Peach Payments payment provider for Medusa v2
An unofficial Peach Payments (Checkout V2) payment provider for Medusa v2. It was extracted from a production Medusa 2.13 store that has been taking live Peach traffic, and it carries over the parts of that integration worth keeping: checkout creation, authorize-on-return completion with an amount-integrity check, webhook handling that re-confirms the outcome server-to-server before trusting it, and V1 HMAC refunds.
This project is not affiliated with, endorsed by, or supported by Peach Payments. It is a community plugin maintained independently. If you hit a Peach API question that isn't about this plugin's code, go to developer.peachpayments.com or Peach's own support channels.
npm install medusa-payment-peach-paymentsPayment providers in Medusa v2 are registered inside the Payment module's array, not the top-level array. Add this to :
1import { defineConfig } from "@medusajs/framework/utils"2
3module.exports = defineConfig({4 modules: [5 {6 resolve: "@medusajs/medusa/payment",7 options: {8 providers: [9 {10 resolve: "medusa-payment-peach-payments/providers/peach",11 id: "peach",12 options: {13 // "sandbox" or "production". Default "sandbox". Drives which Peach14 // hosts (OAuth, Checkout API, SDK script) the provider talks to.15 mode: process.env.PEACH_MODE,16
17 // OAuth credentials from the Peach Dashboard (Checkout API access).18 clientId: process.env.PEACH_CLIENT_ID,19 clientSecret: process.env.PEACH_CLIENT_SECRET, // secret20 merchantId: process.env.PEACH_MERCHANT_ID,21
22 // The Checkout entity id. Doubles as the `authentication.entityId`23 // sent to Peach and the `key` the storefront passes to the SDK's24 // Checkout.initiate() call, so it goes to the browser (not a secret,25 // but treat it as semi-public rather than committing it to source).26 entityId: process.env.PEACH_ENTITY_ID,27
28 // HMAC signing key used for webhook verification AND the V1 refund29 // endpoint. Refunds need this even if you never wire up webhooks.30 secretToken: process.env.PEACH_SECRET_TOKEN, // secret31
32 // The storefront origin Peach allowlists. Sent as the Origin/Referer33 // headers on checkout creation. Peach validates the domain, not an34 // IP. No trailing slash.35 referer: process.env.PEACH_REFERER,36
37 // Your webhook URL, registered with Peach (see Webhooks below).38 // Medusa derives the actual route from the provider id; this option39 // just tells Peach where to send its POST.40 notificationUrl: process.env.PEACH_NOTIFICATION_URL,41
42 // Where Peach returns the shopper after paying (embedded and hosted43 // both use this).44 shopperResultUrl: process.env.PEACH_SHOPPER_RESULT_URL,45
46 // Where Peach sends the shopper if they cancel a hosted-redirect47 // payment. Not used by the embedded widget.48 cancelUrl: process.env.PEACH_CANCEL_URL,49
50 // "DB" = capture immediately (the common case for physical goods).51 // "PA" = pre-authorise, capture later. Default "DB".52 paymentType: process.env.PEACH_PAYMENT_TYPE,53
54 // Display name shown in the Peach checkout UI.55 merchantName: process.env.PEACH_MERCHANT_NAME,56
57 // Fallback currency used only when a payment session or refund58 // carries none of its own. With no session currency and no default59 // set, the provider throws rather than guessing. Peach's business is60 // ZAR-centric, but this option does not default to ZAR: set it61 // explicitly for your store.62 defaultCurrency: process.env.PEACH_DEFAULT_CURRENCY,63
64 // Fallback billing country (ISO alpha-2, e.g. "ZA") used when a cart65 // address has none. Left unset, the country field is omitted66 // entirely rather than guessed.67 defaultCountryCode: process.env.PEACH_DEFAULT_COUNTRY_CODE,68
69 // Optional per-result-code overrides, checked before the built-in70 // mapping. Not a plain string, so there's no single env var for it:71 // set it directly in code if you need it, e.g.:72 // resultCodeOverrides: { "000.400.101": "authorized" }73 // Overriding a code the built-in map treats as a decline/error logs74 // a warning once, since it loosens a fail-closed default.75 },76 },77 ],78 },79 },80 ],81})The runtime provider id Medusa registers is . With the identifier fixed at and as above, that's . If you pick a different (e.g. ), the provider id changes to match (). Whatever it resolves to is the your storefront selects when creating a payment session, and it's also the segment Medusa uses to build the webhook URL (see below). For the exact shape and defaults of every option, read the JSDoc on the exported type:
import type { PeachOptions } from "medusa-payment-peach-payments/providers/peach"Note that a bare (or ) is not exported, by design: there is no meaningful package root for a Medusa plugin. Always import the provider subpath, , as shown in the registration snippet.
None of these are required by Medusa itself, they're just the convention this README uses above. Name them however fits your project.
| Env var | Option | Notes |
|---|---|---|
| or , default | ||
| secret | ||
| sent to the browser, not a secret | ||
| secret; webhook HMAC + refund HMAC | ||
| your storefront origin, no trailing slash | ||
| your webhook URL, see below | ||
| where the shopper returns to after paying | ||
| hosted-redirect cancel target | ||
| or , default | ||
| fallback only, no ZAR default baked in | ||
| fallback only |
There are two independent paths to completing a payment, and only one of them needs to work for an order to go through:
Both paths converge on the same call as the source of truth for the outcome and the amount, so neither one can complete an order on the strength of an unverified webhook body or a client-reported success alone.
No custom route is needed on your end. Medusa auto-mounts for every registered payment provider and routes incoming POSTs to the provider's . With the registration above (), that's , so the URL to register with Peach is:
https://your-backend.example.com/hooks/payment/pp_peach_peachRegister that URL in the Peach Dashboard/Console as your checkout's webhook endpoint, and set the same value as in the provider options so Peach knows to expect it. Peach then surfaces a signing secret when you add the webhook: that becomes your .
You don't need to configure a body parser for this route. Medusa parses the incoming webhook body itself, and the provider handles both cases: verifying the signature from the raw body when it's preserved, and reconstructing the same signed message from Medusa's already-parsed body when it isn't. Either way, verification fails closed: an unverifiable webhook is ignored, never trusted. See for the signature scheme and the full verification path. Peach's own reference is at developer.peachpayments.com/docs/checkout-webhooks.
Refunds go through Peach's older V1 endpoint (), signed with the same HMAC key as webhooks. That means is required for refunds even in a setup that never receives a webhook. The refund body is sent as flat, form-encoded key-value pairs (not nested JSON) with a V1 HMAC signature over the sorted keys. Peach's checkout API and its refund endpoint are genuinely two different signing schemes, which is easy to trip over if you're building this from scratch. Peach also returns HTTP 200 for a declined refund, so the provider checks the result code itself and throws rather than reporting a declined refund as a success.
Refunds need the original payment's transaction id (not the ). The provider stores it on the payment session's data after a successful authorization, and reads it back for the refund call. If that id is missing (for example, a payment authorised before this provider recorded it), throws a clear error rather than guessing or silently no-oping.
Set and use your sandbox entity's credentials. Peach's sandbox bypasses the success screen and OTP/challenge prompts for known test cards, redirecting straight to . A few of the published test cards (any future expiry date; CVV is 3 digits for Visa/Mastercard, 4 for Amex):
| Scheme | Card number | Outcome |
|---|---|---|
| Visa | frictionless success | |
| Mastercard | frictionless success | |
| Amex | frictionless success |
The full list, including challenge-flow and decline scenarios, is in Peach's test and go-live reference.
This provider does a few things beyond the minimum contract, carried over from running against live traffic:
None of this is exotic. It's what you'd want from any payment integration, but it's worth being explicit about, since it's the main reason to reach for a maintained provider instead of a quick custom one.
This package is backend-only; there's no npm-installable storefront SDK because Peach's Checkout widget itself has none (it's a script-tag global, not an npm package). has reference React/Next.js code adapted from a real integration: reading the session off the cart, mounting the embedded widget, and handling the return redirect. See for the full flow and what to adapt.
MIT, see . This is an independent, community-maintained project with no affiliation to Peach Payments; use it at your own risk and test thoroughly against your own Peach account before taking live payments.