Medusa Plugin

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.

How it works

  1. The buyer clicks on a PunchOut link in their ERP → PunchCommerce redirects them to the entry route of your storefront with the query parameters sID and uID.
  2. The storefront authenticates the buyer via the Medusa SDK using the punchcommerce auth provider (sdk.auth.login("customer", "punchcommerce", { sID, uID })).
  3. Storefront creates a new shopping basket for the session and stores the sID in cart.metadata.punchcommerce_session_id. The shopper browses as normal — items are added via the standard Store API.
  4. At checkout, Storefront calls 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.

Installation

npm install @punchcommerce/punchcommerce-medusa-plugin

Configuration

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

Options

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 (see src/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 uID.
  2. In Medusa Admin: Open the customer’s details page. In the right-hand sidebar, the PunchCommerce widget displays the current link.
    • Click Add (or the pencil icon) and enter the uID.
    • If the same uID is already linked to another customer, the API will return an error and the widget will display this.
    • Use the bin icon to unlink the customer. The link will also be removed automatically if the customer is deleted.

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 (Customer identification): The same uID that you entered in the Medusa Admin.

PunchCommerce redirects shoppers to the entry address, appending ?sID={UUID}&uID={identifier} (plus any action parameters).

Storefront requirements

The plugin is for the backend only. The storefront must orchestrate the PunchOut flow.

1. Authentication route

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

  1. The sID is validated against GET /gateway/v3/session/validate (unless disableSessionValidation is set).
  2. The provider identity is looked up via entity_id = uID. If no customer is linked to this uID, the request fails with "No PunchCommerce Identity found.".
  3. If successful, Medusa returns an auth token that is restricted to the associated customer.

2. Session-based shopping basket

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.

3. PunchOut page (replaces checkout)

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

4. PunchOut actions (optional)

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.
  • Only the first action that produces results takes precedence. If actions[] contains both detail and search, the backend processes the first one and skips the rest.
  • The name of the input action is 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 /punchout page 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, "&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">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

Cart Mapping

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.
  • The shopping basket is sent as multipart/form-data by the storefront to ${punchcommerceUrl}/gateway/v3/return.

Shopping Basket Lifecycle (Cart Lifecycle)

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:

  • Start the next PunchOut session by creating a brand-new shopping basket with the new sID in its metadata.

(The actual order is created later via PunchCommerce / the ERP — Medusa serves only as a catalogue interface for browsing.)

Parallel Sessions

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.

REST API Reference

GET /store/punchout/basket

Customer-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/actions

Customer-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-customer

Admin-authenticated. Supports the customer details widget.

  • GET{ punchcommerce_customer: { uid: string } | null }
  • POST Body { uid: string } — Creates or updates the link.
  • DELETE — Removes the link.

Type Reference

All types are exported from punchcommerce/modules/punchcommerce-client/types.

PunchOutPosition

A 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)
}

PunchOutProduct

Product 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
}

PunchOutBasket

Top-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[]
}

PunchOutActionItem

An 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
}

PunchOutActionNotification

Warning / 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
}

PunchOutActionResponse

Discriminated 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
    }
PunchCommerce® ist ein Produkt der Netzdirektion GmbH