@punchcommerce/punchcommerce-medusa-plugin
v0.2.2
Published
PunchOut in minutes — no prior experience required. Connects Medusa v2 to procurement systems via OCI, cXML and IDS-Connect.
Maintainers
Readme
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
sIDanduIDquery parameters - Storefront authenticates the buyer via the Medusa SDK using the
punchcommerceauth provider (sdk.auth.login("customer", "punchcommerce", { sID, uID })) - Storefront creates a fresh cart for the session and stores
sIDincart.metadata.punchcommerce_session_id. The buyer shops normally — items are added through the standard Store API. - On checkout, the storefront calls
GET /store/punchout/basket?cart_id=...to receive the PunchOut basket payload + apunchoutUrl, then submits the basket as amultipart/form-dataform to that URL.
Installation
Requires Medusa v2.13.6 or newer (any 2.x release).
npm install @punchcommerce/punchcommerce-medusa-pluginConfiguration
The plugin requires you to add two entries to your medusa-config.ts: the plugin itself and an auth provider inside 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
},
},
],
},
},
],
})Options
| Option | Required | Default | Description |
| --- | --- | --- | --- |
| punchcommerceUrl | No | https://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. |
| disableSessionValidation | No | false | Auth-provider option. When true, skips the call to GET /gateway/v3/session/validate and accepts any well-formed sID. Intended for local development without a live PunchCommerce instance — never enable in production. |
Note: the gateway version is currently pinned to
v3(seesrc/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
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
uID. - If the same
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
PunchCommerce Configuration
In the PunchCommerce dashboard, configure each customer with:
- Entry address: your storefront's PunchOut landing route, e.g.
https://my-store.com/<region>/punchcommerce/authenticate - Customer identification: the same
uIDyou entered in the Medusa admin
PunchCommerce will redirect buyers to the entry address with ?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 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) {
// 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)
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):
- The
sIDis validated againstGET /gateway/v3/session/validate(unlessdisableSessionValidationis set). - The provider identity is looked up by
entity_id = uID. If no customer has thatuIDlinked, the request fails with"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 sID 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. getPunchOutCartStep enforces that the cart used for any transfer/action has punchcommerce_session_id set.
3. PunchOut Page (replaces checkout)
Instead of the normal checkout, render a dedicated /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
shape PunchCommerce's /gateway/v3/return endpoint expects. */}
<input type="hidden" name="basket" value={JSON.stringify({ basket })} />
<button type="submit">Submit to procurement</button>
</form>
</div>
)
}4. PunchOut Actions (optional)
PunchCommerce can append actions[] to the entry URL to ask the storefront to perform additional steps right after authentication. The backend exposes GET /store/punchout/actions to process them and the storefront decides what to do with the response.
| Action | Required params | Effect |
| --- | --- | --- |
| restore-basket | items=SKU:QTY,SKU:QTY | Adds the listed items to the current cart. Missing SKUs return as warning notifications. |
| detail | ordernumber=SKU | Looks up 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=… | Backend builds a basket from search results and returns it together with a punchoutUrl (for inline PunchOut sessions that submit search results back). |
A few things to keep in mind before implementing:
restore-basketalways runs when present, regardless of other actions. It mutates the cart and may addwarningnotifications for missing SKUs. It never sets a navigation response.- Only the first result-producing action wins. If
actions[]contains bothdetailandsearch, the backend processes the first one and skips the rest. - The input action name is
background-search(hyphen) but the response discriminant isbackground_search(underscore) — always branch onresponse.type, not the raw input string. 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 getPunchOutBasket in lib/data/punchcommerce.ts):
Note: In the authenticate route the auth token and cart were just created, so
getCartId()/getAuthHeaders()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/punchoutpage 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()) }
// Forward all action params (actions[], items, ordernumber, keyword) plus the cart.
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 authenticate route — after setAuthToken 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, """)
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 }) {
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-scoped cart with punchcommerce_session_id in metadata.
const authHeaders = { authorization: `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 store page with a search keyword.
return NextResponse.redirect(
new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)
)
case "background_search":
// The backend already built a basket from the keyword search results.
// Return an auto-submitting page so the browser POSTs the basket straight to
// PunchCommerce — the basket MUST be wrapped in { basket } (same as Step 3).
return new NextResponse(
renderAutoSubmitForm(response.punchoutUrl, response.basket),
{ headers: { "content-type": "text/html" } }
)
default:
// restore-basket ran (if requested) but set no navigation response — go to the store.
return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))
}
}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 PunchOutPosition (src/modules/punchcommerce-client/transform.ts):
price_net= the linesubtotal(net),price=total(gross),item_price=subtotal / quantity(net unit price)tax_rateis forwarded from the cart line (decimal, e.g.0.19)product_nameis truncated to 39 characters (OCI/cXML constraint)packaging_unitis hardcoded to"Piece"; per-variant unit mapping (PCE,KG,LTR, …) is not yet implemented- The basket is submitted to
${punchcommerceUrl}/gateway/v3/returnasmultipart/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
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 cart_id, not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.
REST API Reference
GET /store/punchout/basket
Customer-authenticated (bearer or session). Builds a PunchOut basket from a session-scoped Medusa cart.
| Query | Required | Description |
| --- | --- | --- |
| cart_id | Yes | Cart whose metadata contains punchcommerce_session_id. |
Response: { basket: PunchOutPosition[], punchoutUrl: string }
GET /store/punchout/actions
Customer-authenticated. Processes one or more PunchOut entry actions.
| Query | Required | Description |
| --- | --- | --- |
| cart_id | Yes | Cart to operate on. |
| actions[] | Yes | One or more of restore-basket, detail, search, background-search. |
| items | For restore-basket | Comma-separated SKU:QTY pairs. |
| ordernumber | For detail | SKU to look 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-customer
Admin-authenticated. Backs the customer-detail widget.
- GET →
{ punchcommerce_customer: { uid: string } | null } - POST body
{ uid: string }— upserts the link. - DELETE — removes the link.
Types Reference
All types are exported from punchcommerce/modules/punchcommerce-client/types.
PunchOutPosition
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)
}PunchOutProduct
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
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
}PunchOutBasket
Top-level basket wrapper. This is the shape the PunchCommerce /gateway/v3/return endpoint expects — when submitting the form, wrap the position array in { basket: [...] }.
type PunchOutBasket = {
basket: PunchOutPosition[]
}PunchOutActionItem
Item passed to the restore-basket action. The route parses the items=SKU:QTY,SKU:QTY query string into an array of these.
type PunchOutActionItem = {
sku: string
quantity: number
}PunchOutActionNotification
Warning / info message returned alongside an action response (e.g. when a SKU in restore-basket was not found).
type PunchOutActionNotification = {
type: "info" | "warning"
message: string
}PunchOutActionResponse
Discriminated union returned by GET /store/punchout/actions. The storefront branches on type 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
}