Overview
Icon for PunchCommerce

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

  1. Buyer clicks a PunchOut link in their ERP → PunchCommerce redirects to your storefront's entry route with Copy to clipboardsID and Copy to clipboarduID query parameters
  2. Storefront authenticates the buyer via the Medusa SDK using the Copy to clipboardpunchcommerce auth provider (Copy to clipboardsdk.auth.login("customer", "punchcommerce", { sID, uID }))
  3. Storefront creates a fresh cart for the session and stores Copy to clipboardsID in Copy to clipboardcart.metadata.punchcommerce_session_id. The buyer shops normally — items are added through the standard Store API.
  4. On checkout, the storefront calls Copy to clipboardGET /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-data form to that URL.

Installation

Requires Medusa v2.13.6 or newer (any 2.x release).

npm 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:

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
},
},
],
},
},
],

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.

  1. In PunchCommerce: create a customer and copy the Customer identification — this is the Copy to clipboarduID.
  2. 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 clipboarduID.
    • If the same Copy to clipboarduID is 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.

PunchCommerce Configuration

In the PunchCommerce dashboard, configure each customer with:

  • Entry address: your storefront's PunchOut landing route, e.g. Copy to clipboardhttps://my-store.com/<region>/punchcommerce/authenticate
  • Customer identification: the same Copy to clipboarduID you 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
// 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 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") {
// you can also render a custom error-page here
return 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):

  1. The Copy to clipboardsID is validated against Copy to clipboardGET /gateway/v3/session/validate (unless Copy to clipboarddisableSessionValidation is set).
  2. The provider identity is looked up by Copy to clipboardentity_id = uID. If no customer has that Copy to clipboarduID linked, the request fails with Copy to clipboard"No PunchCommerce Identity found.".
  3. 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.

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 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):

// 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 }` — 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 clipboardrestore-basket always runs when present, regardless of other actions. It mutates the cart and may add Copy to clipboardwarning notifications for missing SKUs. It never sets a navigation response.
  • Only the first result-producing action wins. If Copy to clipboardactions[] contains both Copy to clipboarddetail and Copy to clipboardsearch, the backend processes the first one and skips the rest.
  • The input action name is Copy to clipboardbackground-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 clipboardnotifications (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/punchout page after the session is 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()) }

Extended authenticate route — after Copy to clipboardsetAuthToken and cart creation (Steps 1–2), check for actions and branch 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 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, "&quot;")
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 clipboardprice_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 clipboardtax_rate is forwarded from the cart line (decimal, e.g. Copy to clipboard0.19)
  • Copy to clipboardproduct_name is truncated to 39 characters (OCI/cXML constraint)
  • Copy to clipboardpackaging_unit is 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/return as Copy to clipboardmultipart/form-data by 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 clipboardsID in 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.

  • GETCopy 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.

type PunchOutPosition = {
product_ordernumber: string // SKU of the variant; primary key in PunchCommerce
product_name: string // Display name, truncated to 39 chars (OCI/cXML limit)
quantity: number // Whole-unit count for this line
item_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 products
product: 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.

type PunchOutProduct = {
id: string // Internal product id (Medusa product_id) — informational
ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber
brand_ordernumber: string // Manufacturer ordering reference; currently same as `ordernumber`
title: string // Full untruncated product title
description: string // Plain-text product description
image_url?: string | null // Variant or product thumbnail URL
price: number // Net unit price (mirrors PunchOutPosition.item_price)
currency: string // ISO 4217 code, lowercase (e.g. "eur") — taken from the cart
tax_rate: number // Same decimal value as PunchOutPosition.tax_rate
packaging_unit: string // Hardcoded "Piece" today; future: per-variant mapping
shipping_time: number // Hardcoded 0 today
active: "true" | "false" // String (not boolean) — PunchCommerce convention
// Optional fields — not populated by this plugin yet, but accepted by PunchCommerce:
brand?: string
customer_ordernumber?: string
category?: string
description_long?: string
purchase_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: [...] }.

type 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.

type PunchOutActionItem = {
sku: string
quantity: 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).

type 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.

type 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 basket
type: "background_search"
basket: PunchOutPosition[]
punchoutUrl: string
}

You may also like

Browse all integrations

Build your own

Develop your own custom integration

Build your own integration with our API to speed up your processes. Make your integration available via npm for it to be shared in our Library with the broader Medusa community.

gift card interface

Ready to build your custom commerce setup?