PunchOut за считаные минуты, без предварительного опыта. Подключает Medusa v2 к системам закупок через OCI, cXML и IDS-Connect.
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.
Requires Medusa v2.13.6 or newer (any 2.x release).
npm install @punchcommerce/punchcommerce-medusa-pluginThe plugin requires you to add two entries to your : the plugin itself and an auth provider inside the Auth module.
Add the plugin to :
1plugins: [2 // ... other plugins3 {4 resolve: "@punchcommerce/punchcommerce-medusa-plugin",5 options: {6 punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,7 },8 },9 ]Register the punchcommerce-auth-Provider:
1// medusa-config.ts2module.exports = defineConfig({3 modules: [4 {5 resolve: "@medusajs/medusa/auth",6 options: {7 providers: [8 // ... other providers9 {10 resolve: "@punchcommerce/punchcommerce-medusa-plugin/providers/punchcommerce-auth",11 id: "punchcommerce",12 options: {13 punchcommerceUrl: process.env.PUNCHCOMMERCE_URL,14 disableSessionValidation: false, // never disable in production15 },16 },17 ],18 },19 },20 ],21})| Option | Required | Default | Description |
|---|---|---|---|
| No | Base URL of the PunchCommerce gateway. Override for staging or self-hosted instances. Pass to both the plugin entry and the auth-provider entry. | ||
| No | Auth-provider option. When , skips the call to and accepts any well-formed . Intended for local development without a live PunchCommerce instance — never enable in production. |
Note: the gateway version is currently pinned to (see ).
Customers are linked to PunchCommerce via an identity in Medusa's Auth module.
In the PunchCommerce dashboard, configure each customer with:
PunchCommerce will redirect buyers to the entry address with appended (plus any action parameters).
The plugin is backend-only. The storefront must orchestrate the PunchOut flow.
Create a route that PunchCommerce redirects to. It must call the Medusa SDK with the provider and persist the auth token + .
All examples use the Next.js Starter Template: https://github.com/medusajs/nextjs-starter-medusa
1// app/[region]/punchcommerce/authenticate/route.ts (Next.js)2import { sdk } from "@lib/config"3import { setAuthToken } from "@lib/data/cookies"4import { NextRequest, NextResponse } from "next/server"5
6export async function GET(request: NextRequest) {7 const sID = request.nextUrl.searchParams.get("sID")8 const uID = request.nextUrl.searchParams.get("uID")9 if (!sID || !uID) {10 // you can also render a error-page here 11 return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })12 }13
14 const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })15 if (typeof token !== "string") {16 // you can also render a custom error-page here 17 return NextResponse.json({ error: "Authentication failed" }, { status: 401 })18 }19
20 await setAuthToken(token)21 const res = NextResponse.redirect(new URL("/store", process.env.NEXT_PUBLIC_BASE_URL))22 23 return res24}What this triggers in the backend (see ):
After authentication, create a new cart for the PunchOut session and attach the to its metadata. All Store API operations referencing this cart inherit the link.
1const { cart } = await sdk.store.cart.create({ region_id, currency_code: "eur" })2await sdk.store.cart.update(cart.id, {3 metadata: { punchcommerce_session_id: sID },4})Existing carts the customer owns outside of PunchOut are untouched. enforces that the cart used for any transfer/action has set.
Instead of the normal checkout, render a dedicated 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):
1// lib/data/punchcommerce.ts2"use server"3import { sdk } from "@lib/config"4import { getAuthHeaders, getCartId } from "./cookies"5
6export async function getPunchOutBasket() {7 const cartId = await getCartId()8 if (!cartId) return null9
10 return sdk.client.fetch<{ basket: PunchOutPosition[]; punchoutUrl: string }>(11 `/store/punchout/basket`,12 {13 method: "GET",14 cache: "no-store",15 query: { cart_id: cartId },16 headers: { ...(await getAuthHeaders()) },17 }18 )19}PunchOut Page:
1// app/[countryCode]/(main)/punchout/page.tsx2export default async function PunchOutPage() {3 const data = await getPunchOutBasket()4 if (!data) return notFound()5
6 const { basket, punchoutUrl } = data7
8 return (9 <div>10 <h1>Complete PunchOut</h1>11 <ul>12 {basket.map((item, i) => (13 <li key={i}>14 {item.quantity} × {item.product_name} ({item.product_ordernumber})15 </li>16 ))}17 </ul>18
19 <form action={punchoutUrl} method="POST">20 {/* The hidden field MUST wrap the array in `{ basket }` — that is the21 shape PunchCommerce's /gateway/v3/return endpoint expects. */}22 <input type="hidden" name="basket" value={JSON.stringify({ basket })} />23 <button type="submit">Submit to procurement</button>24 </form>25 </div>26 )27}PunchCommerce can append to the entry URL to ask the storefront to perform additional steps right after authentication. The backend exposes to process them and the storefront decides what to do with the response.
| Action | Required params | Effect |
|---|---|---|
| Adds the listed items to the current cart. Missing SKUs return as warning notifications. | ||
| Looks up the product handle for the SKU. Storefront redirects to the product-detail page. | ||
| Storefront redirects to its own search results page. | ||
| Backend builds a basket from search results and returns it together with a (for inline PunchOut sessions that submit search results back). |
A few things to keep in mind before implementing:
Data loader (add alongside in ):
Note: In the authenticate route the auth token and cart were just created, so / 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 page after the session is established).
1// lib/data/punchcommerce.ts2"use server"3import { sdk } from "@lib/config"4import { getAuthHeaders, getCartId } from "./cookies"5
6type PunchOutActionNotification = { type: "info" | "warning"; message: string }7type PunchOutActionResponse =8 | { type: "default" }9 | { type: "detail"; product_handle: string }10 | { type: "search"; keyword: string }11 | { type: "background_search"; basket: PunchOutPosition[]; punchoutUrl: string }12
13export async function processPunchOutActions(14 params: URLSearchParams,15 opts: { cartId?: string; authHeaders?: Record<string, string> } = {}16): Promise<{ notifications: PunchOutActionNotification[]; response: PunchOutActionResponse } | null> {17 const cartId = opts.cartId ?? (await getCartId())18 if (!cartId) return null19 const headers = opts.authHeaders ?? { ...(await getAuthHeaders()) }20
21 // Forward all action params (actions[], items, ordernumber, keyword) plus the cart.22 const query = new URLSearchParams(params)23 query.set("cart_id", cartId)24
25 return sdk.client.fetch(`/store/punchout/actions?${query.toString()}`, {26 method: "GET",27 cache: "no-store",28 headers,29 })30}Extended authenticate route — after and cart creation (Steps 1–2), check for actions and branch on the result:
1// app/[countryCode]/punchcommerce/authenticate/route.ts (extended from Step 1)2import { sdk } from "@lib/config"3import { getCacheTag, setAuthToken, setCartId } from "@lib/data/cookies"4import { processPunchOutActions, PunchOutPosition } from "@lib/data/punchcommerce"5import { NextRequest, NextResponse } from "next/server"6
7// Renders a page that auto-submits a POST form to PunchCommerce on load.8// Used for background_search, where the buyer never reviews the basket manually.9function renderAutoSubmitForm(punchoutUrl: string, basket: PunchOutPosition[]) {10 // Escape double-quotes so the JSON is safe inside an HTML attribute value.11 const payload = JSON.stringify({ basket }).replace(/"/g, """)12 return `<!doctype html><html><body onload="document.forms[0].submit()">13 <form action="${punchoutUrl}" method="POST">14 <input type="hidden" name="basket" value="${payload}" />15 <noscript><button type="submit">Submit to procurement</button></noscript>16 </form>17 </body></html>`18}19
20export async function GET(request: NextRequest, { params }) {21 const { countryCode } = await params22 const url = request.nextUrl23 const sID = url.searchParams.get("sID")24 const uID = url.searchParams.get("uID")25 if (!sID || !uID) {26 return NextResponse.json({ error: "Missing sID or uID" }, { status: 400 })27 }28
29 const token = await sdk.auth.login("customer", "punchcommerce", { sID, uID })30 if (typeof token !== "string") {31 return NextResponse.json({ error: "Authentication failed" }, { status: 401 })32 }33
34 await setAuthToken(token)35
36 // Step 2: create a new session-scoped cart with punchcommerce_session_id in metadata.37 const authHeaders = { authorization: `Bearer ${token}` }38 const { cart } = await sdk.store.cart.create(39 { region_id, metadata: { punchcommerce_session_id: sID } },40 {},41 authHeaders42 )43 await setCartId(cart.id)44
45 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL!46 const hasActions = url.searchParams.has("actions[]") || url.searchParams.has("actions")47
48 if (!hasActions) {49 return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))50 }51
52 // Pass cart.id and the token explicitly — cookies are not yet readable in this request.53 const dispatch = await processPunchOutActions(url.searchParams, {54 cartId: cart.id,55 authHeaders,56 })57 const response = dispatch?.response ?? { type: "default" as const }58
59 switch (response.type) {60 case "detail":61 return NextResponse.redirect(62 new URL(`/${countryCode}/products/${response.product_handle}`, baseUrl)63 )64
65 case "search":66 // Redirect to the store page with a search keyword.67 return NextResponse.redirect(68 new URL(`/${countryCode}/store?q=${encodeURIComponent(response.keyword)}`, baseUrl)69 )70
71 case "background_search":72 // The backend already built a basket from the keyword search results.73 // Return an auto-submitting page so the browser POSTs the basket straight to74 // PunchCommerce — the basket MUST be wrapped in { basket } (same as Step 3).75 return new NextResponse(76 renderAutoSubmitForm(response.punchoutUrl, response.basket),77 { headers: { "content-type": "text/html" } }78 )79
80 default:81 // restore-basket ran (if requested) but set no navigation response — go to the store.82 return NextResponse.redirect(new URL(`/${countryCode}/store`, baseUrl))83 }84}Also see https://www.punchcommerce.de/swagger#/E-Commerce-Integration/post_punchcommerce_authenticate
The plugin maps each Medusa cart line item to a ():
After a successful transfer, the Medusa cart is not automatically marked complete, archived, or deleted — it remains in its current state. Recommended storefront behavior:
(The actual purchase order is created later through PunchCommerce / the ERP — Medusa is only the catalog browsing surface.)
Carts are scoped per , not per customer, so a single PunchCommerce-linked customer can have multiple independent PunchOut sessions in flight.
Customer-authenticated (bearer or session). Builds a PunchOut basket from a session-scoped Medusa cart.
| Query | Required | Description |
|---|---|---|
| Yes | Cart whose metadata contains . |
Response:
Customer-authenticated. Processes one or more PunchOut entry actions.
| Query | Required | Description |
|---|---|---|
| Yes | Cart to operate on. | |
| Yes | One or more of , , , . | |
| For | Comma-separated pairs. | |
| For | SKU to look up. | |
| For / | Free-text search term. |
Response: — see .
Admin-authenticated. Backs the customer-detail widget.
All types are exported from .
A single line in the PunchOut basket. The plugin builds one position per Medusa cart line item.
1type PunchOutPosition = {2 product_ordernumber: string // SKU of the variant; primary key in PunchCommerce3 product_name: string // Display name, truncated to 39 chars (OCI/cXML limit)4 quantity: number // Whole-unit count for this line5 item_price: number // Net unit price (= price_net / quantity)6 price: number // Gross line total (with tax) — Medusa's `line.total`7 price_net: number // Net line total (without tax) — Medusa's `line.subtotal`8 tax_rate: number // Decimal tax rate, e.g. 0.19 for 19%9 type: "product" | "shipping-costs" // "shipping-costs" reserved; currently all lines are products10 product: PunchOutProduct // Embedded product master data (see below)11}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.
1type PunchOutProduct = {2 id: string // Internal product id (Medusa product_id) — informational3 ordernumber: string // SKU — duplicates PunchOutPosition.product_ordernumber4 brand_ordernumber: string // Manufacturer ordering reference; currently same as `ordernumber`5 title: string // Full untruncated product title6 description: string // Plain-text product description7 image_url?: string | null // Variant or product thumbnail URL8 price: number // Net unit price (mirrors PunchOutPosition.item_price)9 currency: string // ISO 4217 code, lowercase (e.g. "eur") — taken from the cart10 tax_rate: number // Same decimal value as PunchOutPosition.tax_rate11 packaging_unit: string // Hardcoded "Piece" today; future: per-variant mapping12 shipping_time: number // Hardcoded 0 today13 active: "true" | "false" // String (not boolean) — PunchCommerce convention14
15 // Optional fields — not populated by this plugin yet, but accepted by PunchCommerce:16 brand?: string17 customer_ordernumber?: string18 category?: string19 description_long?: string20 purchase_unit?: number21 reference_unit?: number22 unit?: string // OCI unit code, e.g. "PCE", "KG", "LTR"23 unit_name?: string // Human-readable unit name24 weight?: number25 classification_type?: string26 classification?: string27}Top-level basket wrapper. This is the shape the PunchCommerce endpoint expects — when submitting the form, wrap the position array in .
1type PunchOutBasket = {2 basket: PunchOutPosition[]3}Item passed to the action. The route parses the query string into an array of these.
1type PunchOutActionItem = {2 sku: string3 quantity: number4}Warning / info message returned alongside an action response (e.g. when a SKU in was not found).
1type PunchOutActionNotification = {2 type: "info" | "warning"3 message: string4}Discriminated union returned by . The storefront branches on to decide what to do next.
1type PunchOutActionResponse =2 | { type: "default" } // No action produced a result — proceed normally3 | { type: "detail"; product_handle: string } // Redirect the buyer to the PDP at this handle4 | { type: "search"; keyword: string } // Redirect to your storefront's search page5 | { // Inline-search PunchOut: submit the returned basket6 type: "background_search"7 basket: PunchOutPosition[]8 punchoutUrl: string9 }