PunchCommerce
B2B PunchOut procurement via cXML, OCI, and IDS-Connect
PunchCommerce Plugin for Medusa
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 and add items to a cart, then transfer the cart back to the procurement system.
How It Works
- Buyer clicks a PunchOut link in their ERP → PunchCommerce redirects to your storefront's entry route with Copy to clipboard
sIDand Copy to clipboarduIDquery parameters - Storefront authenticates the buyer via the Medusa SDK using the Copy to clipboard
punchcommerceauth provider (Copy to clipboardsdk.auth.login("customer", "punchcommerce", { sID, uID })) - Storefront creates a fresh cart for the session and stores Copy to clipboard
sIDin Copy to clipboardcart.metadata.punchcommerce_session_id. The buyer shops normally — items are added through the standard Store API. - On checkout, the storefront calls Copy to clipboard
GET /store/punchout/basket?cart_id=...to receive the PunchOut basket payload + a Copy to clipboardpunchoutUrl, then submits the basket as a Copy to clipboardmultipart/form-dataform to that URL.
Installation
Requires Medusa v2.13.6 or newer (any 2.x release).
1npm install @punchcommerce/punchcommerce-medusa-plugin
Configuration
The plugin requires you to add two entries to your Copy to clipboardmedusa-config.ts: the plugin itself and an auth provider inside the Auth module.
Add the plugin to Copy to clipboardmedusa-config.ts:
123456789plugins: [// ... other plugins{resolve: "@punchcommerce/punchcommerce-medusa-plugin",options: {punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,},},]
Register the punchcommerce-auth-Provider:
1234567891011121314151617181920// medusa-config.tsmodule.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},},],},},],
Options
Option Required Default Description Copy to clipboardpunchcommerceUrl No Copy to clipboardhttps://www.punchcommerce.de Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Pass to both the plugin entry and the auth-provider entry. Copy to clipboarddisableSessionValidation No Copy to clipboardfalse Auth-provider option. When Copy to clipboardtrue, skips the call to Copy to clipboardGET /gateway/v3/session/validate and accepts any well-formed Copy to clipboardsID. Intended for local development without a live PunchCommerce instance — never enable in production.
Note: the gateway version is currently pinned to Copy to clipboardv3(see Copy to clipboardsrc/modules/punchcommerce-client/service.ts).
Customer Setup
Customers are linked to PunchCommerce via an identity in Medusa's Auth module.
- In PunchCommerce: create a customer and copy the Customer identification — this is the Copy to clipboard
uID. - In Medusa admin: open the customer's detail page. On the right sidebar, the PunchCommerce widget shows the current link.
- Click Add (or the pencil icon) and paste the Copy to clipboard
uID. - If the same Copy to clipboard
uIDis already linked to another customer, the API returns an error and the widget displays it. - Use the trash icon to unlink. The link is also auto-removed when the customer is deleted.
- Click Add (or the pencil icon) and paste the Copy to clipboard
PunchCommerce Configuration
In the PunchCommerce dashboard, configure each customer with:
- Entry address: your storefront's PunchOut landing route, e.g. Copy to clipboard
https://my-store.com/<region>/punchcommerce/authenticate - Customer identification: the same Copy to clipboard
uIDyou entered in the Medusa admin
PunchCommerce will redirect buyers to the entry address with Copy to clipboard?sID={UUID}&uID={identifier} appended (plus any action parameters).
Storefront Requirements
The plugin is backend-only. The storefront must orchestrate the PunchOut flow.
1. Authentication route
Create a route that PunchCommerce redirects to. It must call the Medusa SDK with the Copy to clipboardpunchcommerce provider and persist the auth token + Copy to clipboardsID.
All examples use the Next.js Starter Template: https://github.com/medusajs/nextjs-starter-medusa
1234567891011121314151617181920// 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) {// you can also render a error-page herereturn NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })}const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })if (typeof token !== "string") {// you can also render a custom error-page herereturn NextResponse.json({ error: "Authentication failed" }, { status: 401 })}await setAuthToken(token)
What this triggers in the backend (see Copy to clipboardsrc/providers/punchcommerce-auth/service.ts):
- The Copy to clipboard
sIDis validated against Copy to clipboardGET /gateway/v3/session/validate(unless Copy to clipboarddisableSessionValidationis set). - The provider identity is looked up by Copy to clipboard
entity_id = uID. If no customer has that Copy to clipboarduIDlinked, the request fails with Copy to clipboard"No PunchCommerce Identity found.". - On success, Medusa returns an auth token scoped to the linked customer.
2. Session-scoped cart
After authentication, create a new cart for the PunchOut session and attach the Copy to clipboardsID to its metadata. All Store API operations referencing this cart inherit the link.
1234const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })await sdk.store.cart.update(cart.id, {metadata: { punchcommerce_session_id: sID },})
Existing carts the customer owns outside of PunchOut are untouched. Copy to clipboardgetPunchOutCartStep enforces that the cart used for any transfer/action has Copy to clipboardpunchcommerce_session_id set.
3. PunchOut Page (replaces checkout)
Instead of the normal checkout, render a dedicated Copy to clipboard/punchout page that loads the prepared basket from the backend, shows it to the buyer for review, and submits it to PunchCommerce via a form on click. The buyer never sees the JSON payload — only the cart summary and a "Submit to procurement" button.
Data loader (server action that hits the Store API):
12345678910111213141516171819// 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 nullreturn sdk.client.fetch<{ basket: PunchOutPosition[]; punchoutUrl: string }>(`/store/punchout/basket`,{method: "GET",cache: "no-store",query: { cart_id: cartId },headers: { ...(await getAuthHeaders()) },})}
PunchOut Page:
1234567891011121314151617181920// app/[countryCode]/(main)/punchout/page.tsxexport default async function PunchOutPage() {const data = await getPunchOutBasket()if (!data) return notFound()const { basket, punchoutUrl } = datareturn (<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 }` — that is the
4. PunchOut Actions (optional)
PunchCommerce can append Copy to clipboardactions[] to the entry URL to ask the storefront to perform additional steps right after authentication. The backend exposes Copy to clipboardGET /store/punchout/actions to process them and the storefront decides what to do with the response.
Action Required params Effect Copy to clipboardrestore-basket Copy to clipboarditems=SKU:QTY,SKU:QTY Adds the listed items to the current cart. Missing SKUs return as warning notifications. Copy to clipboarddetail Copy to clipboardordernumber=SKU Looks up the product handle for the SKU. Storefront redirects to the product-detail page. Copy to clipboardsearch Copy to clipboardkeyword=… Storefront redirects to its own search results page. Copy to clipboardbackground-search Copy to clipboardkeyword=… Backend builds a basket from search results and returns it together with a Copy to clipboardpunchoutUrl (for inline PunchOut sessions that submit search results back).
A few things to keep in mind before implementing:
- Copy to clipboard
restore-basketalways runs when present, regardless of other actions. It mutates the cart and may add Copy to clipboardwarningnotifications for missing SKUs. It never sets a navigation response. - Only the first result-producing action wins. If Copy to clipboard
actions[]contains both Copy to clipboarddetailand Copy to clipboardsearch, the backend processes the first one and skips the rest. - The input action name is Copy to clipboard
background-search(hyphen) but the response discriminant is Copy to clipboardbackground_search(underscore) — always branch on Copy to clipboardresponse.type, not the raw input string. - Copy to clipboard
notifications(e.g. "SKU X not found") survive the action call even when a redirect follows. Store them in a cookie or flash session to surface them to the buyer after the redirect.
Data loader (add alongside Copy to clipboardgetPunchOutBasket in Copy to clipboardlib/data/punchcommerce.ts):
Note: In the authenticate route the auth token and cart were just created, so Copy to clipboardgetCartId()/Copy to clipboardgetAuthHeaders()may not yet read the freshly-set cookies. Pass both values explicitly from the route; the defaults still work for other callers (e.g. loading the loader from the Copy to clipboard/punchoutpage after the session is established).
1234567891011121314151617181920// 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 nullconst headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }
Extended authenticate route — after Copy to clipboardsetAuthToken and cart creation (Steps 1–2), check for actions and branch on the result:
1234567891011121314151617181920// 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 auto-submits a POST form to PunchCommerce on load.// Used for background_search, where the buyer never reviews the basket manually.function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {// Escape double-quotes so the JSON is safe inside 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">Submit to procurement</button></noscript></form></body></html>`}export async function GET(request: NextRequest, { params }) {
Also see https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate
Cart Mapping
The plugin maps each Medusa cart line item to a Copy to clipboardPunchOutPosition (Copy to clipboardsrc/modules/punchcommerce-client/transform.ts):
- Copy to clipboard
price_net= the line Copy to clipboardsubtotal(net), Copy to clipboardprice= Copy to clipboardtotal(gross), Copy to clipboarditem_price= Copy to clipboardsubtotal / quantity(net unit price) - Copy to clipboard
tax_rateis forwarded from the cart line (decimal, e.g. Copy to clipboard0.19) - Copy to clipboard
product_nameis truncated to 39 characters (OCI/cXML constraint) - Copy to clipboard
packaging_unitis hardcoded to Copy to clipboard"Piece"; per-variant unit mapping (Copy to clipboardPCE, Copy to clipboardKG, Copy to clipboardLTR, …) is not yet implemented - The basket is submitted to Copy to clipboard
${punchcommerceUrl}/gateway/v3/returnas Copy to clipboardmultipart/form-databy the storefront
Cart Lifecycle
After a successful transfer, the Medusa cart is not automatically marked complete, archived, or deleted — it remains in its current state. Recommended storefront behavior:
- Start the next PunchOut session by creating a brand-new cart with the new Copy to clipboard
sIDin its metadata
(The actual purchase order is created later through PunchCommerce / the ERP — Medusa is only the catalog browsing surface.)
Parallel Sessions
Carts are scoped per Copy to clipboardcart_id, not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.
REST API Reference
Copy to clipboardGET /store/punchout/basket
Customer-authenticated (bearer or session). Builds a PunchOut basket from a session-scoped Medusa cart.
Query Required Description Copy to clipboardcart_id Yes Cart whose metadata contains Copy to clipboardpunchcommerce_session_id.
Response: Copy to clipboard{ basket: PunchOutPosition[], punchoutUrl: string }
Copy to clipboardGET /store/punchout/actions
Customer-authenticated. Processes one or more PunchOut entry actions.
Query Required Description Copy to clipboardcart_id Yes Cart to operate on. Copy to clipboardactions[] Yes One or more of Copy to clipboardrestore-basket, Copy to clipboarddetail, Copy to clipboardsearch, Copy to clipboardbackground-search. Copy to clipboarditems For Copy to clipboardrestore-basket Comma-separated Copy to clipboardSKU:QTY pairs. Copy to clipboardordernumber For Copy to clipboarddetail SKU to look up. Copy to clipboardkeyword For Copy to clipboardsearch / Copy to clipboardbackground-search Free-text search term.
Response: Copy to clipboard{ notifications: PunchOutActionNotification[], response: PunchOutActionResponse } — see Copy to clipboardsrc/modules/punchcommerce-client/types.ts.
Copy to clipboardGET | POST | DELETE /admin/customers/:id/punchcommerce-customer
Admin-authenticated. Backs the customer-detail widget.
- GET → Copy to clipboard
{ punchcommerce_customer: { uid: string } | null } - POST body Copy to clipboard
{ uid: string }— upserts the link. - DELETE — removes the link.
Types Reference
All types are exported from Copy to clipboardpunchcommerce/modules/punchcommerce-client/types.
Copy to clipboardPunchOutPosition
A single line in the PunchOut basket. The plugin builds one position per Medusa cart line item.
1234567891011type PunchOutPosition = {product_ordernumber: string // SKU of the variant; primary key in PunchCommerceproduct_name: string // Display name, truncated to 39 chars (OCI/cXML limit)quantity: number // Whole-unit count for this lineitem_price: number // Net unit price (= price_net / quantity)price: number // Gross line total (with tax) — Medusa's `line.total`price_net: number // Net line total (without tax) — Medusa's `line.subtotal`tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%type: "product" | "shipping-costs" // "shipping-costs" reserved; currently all lines are productsproduct: PunchOutProduct // Embedded product master data (see below)}
Copy to clipboardPunchOutProduct
Product-Data embedded in each PunchOutPosition. Sent to PunchCommerce so the procurement system can store/display the product even if the buyer's catalog doesn't have it.
1234567891011121314151617181920type PunchOutProduct = {id: string // Internal product id (Medusa product_id) — informationalordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumberbrand_ordernumber: string // Manufacturer ordering reference; currently same as `ordernumber`title: string // Full untruncated product titledescription: string // Plain-text product descriptionimage_url?: string | null // Variant or product thumbnail URLprice: number // Net unit price (mirrors PunchOutPosition.item_price)currency: string // ISO 4217 code, lowercase (e.g. "eur") — taken from the carttax_rate: number // Same decimal value as PunchOutPosition.tax_ratepackaging_unit: string // Hardcoded "Piece" today; future: per-variant mappingshipping_time: number // Hardcoded 0 todayactive: "true" | "false" // String (not boolean) — PunchCommerce convention// Optional fields — not populated by this plugin yet, but accepted by PunchCommerce:brand?: stringcustomer_ordernumber?: stringcategory?: stringdescription_long?: stringpurchase_unit?: number
Copy to clipboardPunchOutBasket
Top-level basket wrapper. This is the shape the PunchCommerce Copy to clipboard/gateway/v3/return endpoint expects — when submitting the form, wrap the position array in Copy to clipboard{ basket: [...] }.
123type PunchOutBasket = {basket: PunchOutPosition[]}
Copy to clipboardPunchOutActionItem
Item passed to the Copy to clipboardrestore-basket action. The route parses the Copy to clipboarditems=SKU:QTY,SKU:QTY query string into an array of these.
1234type PunchOutActionItem = {sku: stringquantity: number}
Copy to clipboardPunchOutActionNotification
Warning / info message returned alongside an action response (e.g. when a SKU in Copy to clipboardrestore-basket was not found).
1234type PunchOutActionNotification = {type: "info" | "warning"message: string}
Copy to clipboardPunchOutActionResponse
Discriminated union returned by Copy to clipboardGET /store/punchout/actions. The storefront branches on Copy to clipboardtype to decide what to do next.
123456789type PunchOutActionResponse =| { type: "default" } // No action produced a result — proceed normally| { type: "detail"; product_handle: string } // Redirect the buyer to the PDP at this handle| { type: "search"; keyword: string } // Redirect to your storefront's search page| { // Inline-search PunchOut: submit the returned baskettype: "background_search"basket: PunchOutPosition[]punchoutUrl: string}

