A Medusa v2 plugin that integrates with PunchCommerce to enable cXML/PunchOut procurement gateway functionality. Procurement systems redirect buyers to your Medusa storefront, where they can browse items and add them to a shopping basket, before transferring the basket back to the procurement system.
sID and uID.punchcommerce auth provider (sdk.auth.login("customer", "punchcommerce", { sID, uID })).sID in cart.metadata.punchcommerce_session_id. The shopper browses as normal — items are added via the standard Store API.GET /store/punchout/basket?cart_id=... to retrieve the PunchOut basket payload + a punchoutUrl, and sends the basket as a multipart/form-data form to this URL.npm install @punchcommerce/punchcommerce-medusa-plugin
The plugin requires two entries in your medusa-config.ts: the plugin itself and an auth provider within the auth module.
Add the plugin to medusa-config.ts:
plugins: [
// ... other plugins
{
resolve: "@punchcommerce/punchcommerce-medusa-plugin",
options: {
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
},
},
]
Register the punchcommerce-auth provider:
// medusa-config.ts
module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/medusa/auth",
options: {
providers: [
// ... other providers
{
resolve: "@punchcommerce/punchcommerce-medusa-plugin/providers/punchcommerce-auth",
id: "punchcommerce",
options: {
punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,
disableSessionValidation: false, // never disable in production
},
},
],
},
},
],
})
| Option | Required | Default | Description |
|---|---|---|---|
punchcommerceUrl |
No | https://www.punchcommerce.de |
Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Must be passed to both entries (plugin and auth provider). |
disableSessionValidation |
No | false |
Auth provider option. If true, the call to GET /gateway/v3/session/validate is skipped and any syntactically correct sID is accepted. Intended for local development without a live PunchCommerce instance — never enable in production. |
Note: The gateway version is currently set to
v3(seesrc/modules/punchcommerce-client/service.ts).
Customers are linked to PunchCommerce via an identity in Medusa’s Auth module.
uID.uID.uID is already linked to another customer, the API will return an error and the widget will display this.In the PunchCommerce dashboard, configure each customer with:
https://my-store.com/<region>/punchcommerce/authenticateuID that you entered in the Medusa Admin.PunchCommerce redirects shoppers to the entry address, appending ?sID={UUID}&uID={identifier} (plus any action parameters).
The plugin is for the backend only. The storefront must orchestrate the PunchOut flow.
Create a route to which PunchCommerce redirects. It must call the Medusa SDK using the punchcommerce provider and persist the auth token + sID.
All examples use the Next.js starter template: https://github.com/medusajs/nextjs-starter-medusa
// app/[region]/punchcommerce/authenticate/route.ts (Next.js)
import { sdk } from "@lib/config"
import { setAuthToken } from "@lib/data/cookies"
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
const sID = request.nextUrl.searchParams.get("sID")
const uID = request.nextUrl.searchParams.get("uID")
if (!sID || !uID) {
// an error page can also be rendered here
return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
}
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
if (typeof token !== "string") {
// a custom error page can also be rendered here
return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
}
await setAuthToken(token)
const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))
return res
}
What this triggers in the backend (see src/providers/punchcommerce-auth/service.ts):
sID is validated against GET /gateway/v3/session/validate (unless disableSessionValidation is set).entity_id = uID. If no customer is linked to this uID, the request fails with "No PunchCommerce Identity found.".After authentication, create a new shopping basket for the PunchOut session and append the sID to its metadata. All Store API operations relating to this shopping basket inherit this association.
const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })
await sdk.store.cart.update(cart.id, {
metadata: { punchcommerce_session_id: sID },
})
Existing shopping baskets that the customer has outside of PunchOut remain unaffected. getPunchOutCartStep ensures that the shopping basket used for each transfer or action has the punchcommerce_session_id set.
Instead of the normal checkout, render a dedicated /punchout page that loads the prepared shopping basket from the backend, displays it to the purchaser for review, and, upon clicking, sends it to PunchCommerce via a form. The buyer never sees the JSON payload — only the shopping basket summary and a ‘Send to procurement system’ button.
Data Loader (server action that calls the Store API):
// lib/data/punchcommerce.ts
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCartId } from "./cookies"
export async function getPunchOutBasket() {
const cartId = await getCartId()
if (!cartId) return null
return sdk.client.fetch<{ basket: PunchOutPosition[]; punchoutUrl: string }>(
`/store/punchout/basket`,
{
method: "GET",
cache: "no-store",
query: { cart_id: cartId },
headers: { ...(await getAuthHeaders()) },
}
)
}
PunchOut page:
// app/[countryCode]/(main)/punchout/page.tsx
export default async function PunchOutPage() {
const data = await getPunchOutBasket()
if (!data) return notFound()
const { basket, punchoutUrl } = data
return (
<div>
<h1>Complete PunchOut</h1>
<ul>
{basket.map((item, i) => (
<li key={i}>
{item.quantity} × {item.product_name} ({item.product_ordernumber})
</li>
))}
</ul>
<form action={punchoutUrl} method="POST">
{/* The hidden field MUST wrap the array in `{ basket }` — this is the
format expected by PunchCommerce’s /gateway/v3/return endpoint. */}
<input type="hidden" name="basket" value={JSON.stringify({ basket })} />
<button type="submit">Send to procurement system</button>
</form>
</div>
)
}
PunchCommerce can append actions[] to the entry URL to instruct the storefront to perform additional steps immediately after authentication. The backend provides GET /store/punchout/actions to process these, and the storefront decides what to do with the response.
| Action | Required parameters | Effect |
|---|---|---|
restore-basket |
items=SKU:QTY,SKU:QTY |
Adds the listed items to the current basket. Missing SKUs are returned as warnings. |
detail |
ordernumber=SKU |
Retrieves the product handle for the SKU. Storefront redirects to the product detail page. |
search |
keyword=… |
Storefront redirects to its own search results page. |
background-search |
keyword=… |
The backend creates a basket from the search results and returns it together with a punchoutUrl (for inline PunchOut sessions that return search results). |
A few things to bear in mind before implementation:
restore-basket is always executed, if present, regardless of other actions. It modifies the basket and may add warning notifications for missing SKUs. It never sets a navigation response.actions[] contains both detail and search, the backend processes the first one and skips the rest.background-search (hyphen), but the response discriminator is background_search (underscore) — always branch based on response.type, not the raw input string.notifications (e.g. “SKU X not found”) persist beyond the action call, even if a redirect follows. Store them in a cookie or a Flash session to display them to the shopper after the redirect.Data Loader (add this alongside getPunchOutBasket in lib/data/punchcommerce.ts):
Note: In the authentication route, the auth token and shopping basket have only just been created, so
getCartId()/getAuthHeaders()may not yet be able to read the newly set cookies. Pass both values explicitly from the route; the default values will still work for other callers (e.g. when loading the loader from the/punchoutpage after the session has been established).
// lib/data/punchcommerce.ts
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCartId } from "./cookies"
type PunchOutActionNotification = { type: "info" | "warning"; message: string }
type PunchOutActionResponse =
| { type: "default" }
| { type: "detail"; product_handle: string }
| { type: "search"; keyword: string }
| { type: "background_search"; basket: PunchOutPosition[]; punchoutUrl: string }
export async function processPunchOutActions(
params: URLSearchParams,
opts: { cartId?: string; authHeaders?: Record<string, string> } = {}
): Promise<{ notifications: PunchOutActionNotification[]; response: PunchOutActionResponse } | null> {
const cartId = opts.cartId ?? (await getCartId())
if (!cartId) return null
const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }
// Pass all action parameters (actions[], items, ordernumber, keyword) plus the shopping basket.
const query = new URLSearchParams(params)
query.set("cart_id", cartId)
return sdk.client.fetch(`/store/punchout/actions?${query.toString()}`, {
method: "GET",
cache: "no-store",
headers,
})
}
Extended authentication route — after setAuthToken and creating the shopping basket (steps 1–2), check for actions and branch based on the result:
// app/[countryCode]/punchcommerce/authenticate/route.ts (extended from Step 1)
import { sdk } from "@lib/config"
import { getCacheTag, setAuthToken, setCartId } from "@lib/data/cookies"
import { processPunchOutActions, PunchOutPosition } from "@lib/data/punchcommerce"
import { NextRequest, NextResponse } from "next/server"
// Renders a page that automatically sends a POST form to PunchCommerce when it loads.
// Used for `background_search`, where the shopper does not manually check the basket.
function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {
// Escape quotation marks to ensure the JSON is safe within an HTML attribute value.
const payload = JSON.stringify({ basket }).replace(/"/g, """)
return `<!doctype html><html><body onload="document.forms[0].submit()">
<form action="${punchoutUrl}" method="POST">
<input type="hidden" name="basket" value="${payload}" />
<noscript><button type="submit">Send to procurement system</button></noscript>
</form>
</body></html>`
}
export async function GET(request: NextRequest, { params }) {
const { countryCode } = await params
const url = request.nextUrl
const sID = url.searchParams.get("sID")
const uID = url.searchParams.get("uID")
if (!sID || !uID) {
return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })
}
const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })
if (typeof token !== "string") {
return NextResponse.json({ error: "Authentication failed" }, { status: 401 })
}
await setAuthToken(token)
// Step 2: Create a new session-based shopping basket with punchcommerce_session_id in the metadata.
const authHeaders = { authorisation: `Bearer ${token}` }
const { cart } = await sdk.store.cart.create(
{ region_id, metadata: { punchcommerce_session_id: sID } },
{},
authHeaders
)
await setCartId(cart.id)
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!
const hasActions = url.searchParams.has("actions[]") || url.searchParams.has("actions")
if (!hasActions) {
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
}
// Pass cart.id and the token explicitly — cookies are not yet readable in this request.
const dispatch = await processPunchOutActions(url.searchParams, {
cartId: cart.id,
authHeaders,
})
const response = dispatch?.response ?? { type: "default" as const }
switch (response.type) {
case "detail":
return NextResponse.redirect(
new URL(`/${countryCode}/products/${response.product_handle}`, baseUrl)
)
case "search":
// Redirect to the shop page with a search term.
return NextResponse.redirect(
new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
)
case "background_search":
// The backend has already created a shopping basket from the results of the keyword search.
// Return a self-submitting page so that the browser sends the shopping basket directly via POST to
// PunchCommerce — the shopping basket MUST be wrapped in { basket } (as in step 3).
return new NextResponse(
renderAutoSubmitForm(response.punchoutUrl, response.basket),
{ headers: { "content-type": "text/html" } }
)
default:
// `restore-basket` was executed (if requested), but did not set a navigation response — go to the shop.
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
}
}
See also https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate
The plugin maps each Medusa shopping basket item to a PunchOutPosition (src/modules/punchcommerce-client/transform.ts):
price_net = the subtotal line (net), price = total (gross), item_price = subtotal / quantity (net unit price)tax_rate is taken from the shopping basket item (decimal, e.g. 0.19)product_name is truncated to 39 characters (OCI/cXML restriction)packaging_unit is fixed at "Piece"; variant-specific unit mapping (PCE, KG, LTR, …) is not yet implemented.multipart/form-data by the storefront to ${punchcommerceUrl}/gateway/v3/return.Following a successful transfer, the Medusa shopping basket is not automatically marked as completed, archived or deleted — it remains in its current state. Recommended storefront behaviour:
sID in its metadata.(The actual order is created later via PunchCommerce / the ERP — Medusa serves only as a catalogue interface for browsing.)
Shopping baskets are scoped per cart_id, not per customer. Therefore, a single customer linked to PunchCommerce can have several independent PunchOut sessions active at the same time.
GET /store/punchout/basketCustomer-authenticated (Bearer or Session). Creates a PunchOut basket from a session-based Medusa basket.
| Query | Required | Description |
|---|---|---|
cart_id |
Yes | Shopping basket whose metadata contains punchcommerce_session_id. |
Response: { basket: PunchOutPosition[], punchoutUrl: string }
GET /store/punchout/actionsCustomer-authenticated. Processes one or more PunchOut entry actions.
| Query | Required | Description |
|---|---|---|
cart_id |
Yes | The basket on which the operation is to be performed. |
actions[] |
Yes | One or more of the following: restore-basket, detail, search, background-search. |
items |
For restore-basket |
Comma-separated SKU:QTY pairs. |
ordernumber |
For detail |
SKU to be looked up. |
keyword |
For search / background-search |
Free-text search term. |
Response: { notifications: PunchOutActionNotification[], response: PunchOutActionResponse } — see src/modules/punchcommerce-client/types.ts.
GET | POST | DELETE /admin/customers/:id/punchcommerce-customerAdmin-authenticated. Supports the customer details widget.
{ punchcommerce_customer: { uid: string } | null }{ uid: string } — Creates or updates the link.All types are exported from punchcommerce/modules/punchcommerce-client/types.
PunchOutPositionA single line item in the PunchOut basket. The plugin creates one line item per Medusa basket line item.
type PunchOutPosition = {
product_ordernumber: string // SKU of the variant; primary key in PunchCommerce
product_name: string // Display name, truncated to 39 characters (OCI/cXML limit)
quantity: number // Integer quantity for this line item
item_price: number // Net unit price (= price_net / quantity)
price: number // Gross line total (including tax) — Medusa’s `line.total`
price_net: number // Net line total (excluding tax) — Medusa’s `line.subtotal`
tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%
type: "product" | "shipping-costs" // "shipping-costs" is reserved; currently, all lines are products
product: PunchOutProduct // Embedded product master data (see below)
}
PunchOutProductProduct data embedded in every PunchOutPosition. Sent to PunchCommerce so that the procurement system can save/display the product, even if the buyer’s catalogue does not contain it.
type PunchOutProduct = {
id: string // Internal product ID (Medusa product_id) — for information purposes only
ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber
brand_ordernumber: string // Manufacturer’s order reference; currently identical to `ordernumber`
title: string // Full, unabbreviated product title
description: string // Product description in plain text
image_url?: string | null // URL to the preview image of the variant or product
price: number // Net unit price (mirrors PunchOutPosition.item_price)
currency: string // ISO 4217 code, lower case (e.g. "eur") — taken from the shopping basket
tax_rate: number // Same decimal value as PunchOutPosition.tax_rate
packaging_unit: string // Currently fixed at "Piece"; future: per-variant mapping
shipping_time: number // Currently fixed at 0
active: "true" | "false" // String (not a Boolean) — PunchCommerce convention
// Optional fields — not yet populated by this plugin, but accepted by PunchCommerce:
brand?: string
customer_ordernumber?: string
category?: string
description_long?: string
purchase_unit?: number
reference_unit?: number
unit?: string // OCI unit code, e.g. "PCE", "KG", "LTR"
unit_name?: string // Human-readable unit name
weight?: number
classification_type?: string
classification?: string
}
PunchOutBasketTop-level shopping basket wrapper. This is the format expected by the PunchCommerce /gateway/v3/return endpoint — when submitting the form, the item array must be wrapped in { basket: [...] }.
type PunchOutBasket = {
basket: PunchOutPosition[]
}
PunchOutActionItemAn item passed to the restore-basket action. The route parses the items=SKU:QTY,SKU:QTY query string into an array of these objects.
type PunchOutActionItem = {
sku: string
quantity: number
}
PunchOutActionNotificationWarning / information message returned alongside an action response (e.g. if an SKU was not found in restore-basket).
type PunchOutActionNotification = {
type: "info" | "warning"
message: string
}
PunchOutActionResponseDiscriminated union returned by GET /store/punchout/actions. The storefront branches based on type to decide what to do next.
type PunchOutActionResponse =
| { type: "default" } // No action returned a result — proceed as normal
| { type: "detail"; product_handle: string } // Redirect the shopper to the PDP under this handle
| { type: "search"; keyword: string } // Redirect to your storefront’s search page
| { // Inline search PunchOut: send the returned shopping basket
type: "background_search"
basket: PunchOutPosition[]
punchoutUrl: string
}