{
  "openapi": "3.1.0",
  "info": {
    "title": "Medienwerft Punchout API",
    "version": "0.9.0",
    "description": "Multi-tenant punchout connector. Speaks **IDS-Connect 2.5** (German trade: craftsman software punches out into a wholesaler webshop and the finished cart or order is posted back), **SAP OCI 2.0B\u20135.0** (a browser-mediated launch, with the basket returned as flat NEW_ITEM-<FIELD>[n] form fields) and **cXML 1.2.x** (a server-to-server XML setup answered with a StartPage, the basket returned as a PunchOutOrderMessage, and an OrderRequest relay).\n\nA storefront integrates ONCE: it receives a neutral cart and never learns which protocol the buyer speaks.\n\n## Scope\n\nThis document covers the **protocol surface** — everything a craftsman application or a storefront talks to. The tenant-admin endpoints (connections, unit mappings, transfers, hooks, the test console) are driven by the Punchout dashboard and are deliberately not documented here; they are gated by platform JWT scopes and change with the dashboard.\n\nThe protocol endpoints are unauthenticated in the platform sense, and always will be: they are driven by craftsman desktop software and storefronts that hold no platform identity. They are protected by per-connection credentials, an explicit return-URL allowlist, and single-use capability tokens.\n\n## Storefront SDK\n\nA shop does **not** need to implement the browser half of this by hand. Two packages cover it:\n\n- **`@medienwerft/punchout-client`** — framework-neutral, zero dependencies. Reads the handoff token out of the URL fragment, exchanges it, and stores the session in a cookie.\n- **`@medienwerft/punchout-react`** — `<PunchoutResume>` and `<PunchoutCheckout>`, plus a `/next` entry for the App Router.\n\nA typical integration is two files: one resume page and one cart binding. See `packages/punchout-client` and `packages/punchout-react` in the Service Hub monorepo.\n\n## The round trip\n\n1. Craftsman software `POST`s a form to `/ids/{connection}/setup`.\n2. The service answers **303** to `{storefrontUrl}/punchout/resume#t={token}` — the token rides in the *fragment*, so it never reaches the shop's server logs, proxies or `Referer`.\n3. The shop's resume page exchanges the token at `GET /handoff/{token}` and receives the basket plus a `submitToken`.\n4. The visitor shops. When they transfer, the shop `POST`s to `/submit/cart` (or `/submit/order`).\n5. The service answers with a self-submitting HTML form aimed at the craftsman's `hookurl`.\n\nSteps 2 and 5 are **browser navigations**, not fetches — a `fetch` would receive the redirect and the HTML and do nothing with either."
  },
  "externalDocs": {
    "description": "Punchout in the Service Hub — architecture, hooks, and the storefront SDK",
    "url": "https://github.com/medienwerft/emporix-jas-cms-plugin/blob/main/PUNCHOUT.md"
  },
  "tags": [
    { "name": "Meta", "description": "Reachable even when nothing else is — no key, no token." },
    { "name": "Punchout", "description": "What a buyer's system calls. IDS-Connect and OCI are form-encoded; cXML posts text/xml." },
    { "name": "Storefront", "description": "What the shop calls. Covered by @medienwerft/punchout-client." }
  ],
  "paths": {
    "/": {
      "get": {
        "tags": ["Meta"],
        "summary": "Service descriptor",
        "description": "Names the service and links its own docs. Useful when someone lands on the deployment root wondering what it is.",
        "responses": {
          "200": {
            "description": "Descriptor",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Descriptor" } } }
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": ["Meta"],
        "summary": "Liveness and store reachability",
        "description": "Returns 503 when the configured store is unreachable. Unauthenticated by design.",
        "responses": {
          "200": {
            "description": "Healthy",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } }
          },
          "503": { "description": "Store unreachable" }
        }
      }
    },
    "/version": {
      "get": {
        "tags": ["Meta"],
        "summary": "What is deployed here",
        "description": "Unauthenticated on purpose — the question comes up precisely when nobody can authenticate.",
        "responses": {
          "200": {
            "description": "Build and mode",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Version" } } }
          }
        }
      }
    },
    "/ids/{connection}/setup": {
      "post": {
        "tags": ["Punchout"],
        "summary": "A craftsman application starts a punchout",
        "description": "The single entry point, configured once by hand in the craftsman's software. The connection slug is opaque and permanent — renaming a site does not change it.\n\n**Body encoding.** `application/x-www-form-urlencoded` or `multipart/form-data`. ISO-8859-1 is common and handled: the raw bytes are decoded with the charset the sender declares, so umlauts survive.\n\n**Answers differ by action.** `SV` and `LI` return XML. `WKS`/`WKE`, and `AS`/`ADL` when a `hookurl` is present, return **303** with a `Location`. `AS`/`ADL` without a `hookurl` redirect straight to the target — there is no way home, so no session is opened.",
        "parameters": [
          {
            "name": "connection",
            "in": "path",
            "required": true,
            "description": "The connection's opaque slug.",
            "schema": { "type": "string" },
            "example": "mwdemo"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": { "$ref": "#/components/schemas/SetupForm" }
            },
            "multipart/form-data": {
              "schema": { "$ref": "#/components/schemas/SetupForm" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "`SV` or `LI` — an IDS XML document.",
            "content": { "application/xml": { "schema": { "type": "string" } } }
          },
          "303": {
            "description": "Go to the shop. The browser must follow this.",
            "headers": {
              "Location": {
                "description": "`{storefrontUrl}/punchout/resume#t={token}`, or the same with `&next=` for an `AS`/`ADL` entry.",
                "schema": { "type": "string" }
              }
            }
          },
          "400": { "description": "Unknown or missing action, or a missing `hookurl` on a cart action." },
          "403": {
            "description": "The connection is disabled, or the `hookurl` is not on its allowlist. The allowlist is never echoed back."
          },
          "404": { "description": "No such connection. Indistinguishable from one you may not see, on purpose." },
          "501": {
            "description": "An unsupported IDS version (names both sides), or an `AS`/`ADL` with no `search`/`deeplink` hook bound."
          },
          "502": { "description": "A tenant hook threw. The message names the hook point." }
        }
      }
    },
    "/handoff/{token}": {
      "get": {
        "tags": ["Storefront"],
        "summary": "Exchange the handoff token for the basket",
        "description": "Called by the shop — from the browser or from its own server — with the token it found in the URL fragment.\n\n**Single use, and short-lived** (~5 minutes). Spending it returns the basket and mints the `submitToken` for the return leg. Invalid, expired and already-spent all answer 404 with one message, so a token cannot be probed.\n\n`@medienwerft/punchout-client` calls this for you.",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "The value of `t` from the resume URL's fragment.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "The basket, plus the credential for sending it back. `Cache-Control: no-store`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HandoffCart" } } }
          },
          "400": { "description": "No token in the path." },
          "404": { "description": "Invalid, expired, or already used." }
        }
      }
    },
    "/submit/{kind}": {
      "post": {
        "tags": ["Storefront"],
        "summary": "The shop returns the finished basket",
        "description": "A **form POST from the visitor's browser**, not a fetch: the response is a self-submitting HTML page aimed at the craftsman's return address, and only a navigation can follow it.\n\nThe shop presents a **token, not a return address**. It never learns the craftsman's `hookurl` — the service resolves it from the session when the token is spent. Handing the address to the shop and trusting it back is exactly the shape that made the original an open redirect.\n\nThe basket the craftsman receives is rebuilt here, and the `RefItems` cross-references they sent are restored from the stored inbound cart — the shop is never told about them and cannot echo them.",
        "parameters": [
          {
            "name": "kind",
            "in": "path",
            "required": true,
            "description": "`cart` for a basket, `order` for a confirmed order.",
            "schema": { "type": "string", "enum": ["cart", "order"] }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": { "$ref": "#/components/schemas/SubmitForm" }
            },
            "multipart/form-data": {
              "schema": { "$ref": "#/components/schemas/SubmitForm" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A self-submitting HTML form posting the IDS document to the craftsman's `hookurl`.",
            "content": { "text/html": { "schema": { "type": "string" } } }
          },
          "400": { "description": "Missing cart payload (`payload`, or the older `idsgo`) or missing `token`." },
          "404": { "description": "Unknown kind, or a submit token that is invalid, expired or already used." },
          "502": { "description": "A tenant hook threw. The message names the hook point." }
        }
      }
    },
    "/test/return": {
      "post": {
        "tags": ["Storefront"],
        "summary": "Stand-in for the craftsman's software",
        "description": "Renders whatever was posted to it, so a round trip can finish without a desktop listener to point a browser at.\n\nInert by construction: it stores nothing, reads nothing and decides nothing. `handleSetup` treats this exact path on this service's own origin as implicitly allowlisted, so the dashboard's test console works on a new connection without anyone adding it by hand — safe because the POST is made by the visitor's own browser, so the only basket anyone sees is the one they just sent.",
        "parameters": [
          {
            "name": "back",
            "in": "query",
            "required": false,
            "description": "An http(s) URL to render a link back to, so the round trip ends where it started.",
            "schema": { "type": "string" }
          }
        ],
        "requestBody": {
          "required": false,
          "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/SubmitForm" } } }
        },
        "responses": {
          "200": { "description": "An HTML page showing the fields received.", "content": { "text/html": { "schema": { "type": "string" } } } }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Descriptor": {
        "type": "object",
        "properties": {
          "service": { "type": "string" },
          "version": { "type": "string" },
          "description": { "type": "string" },
          "docs": { "type": "string" },
          "openapi": { "type": "string" },
          "health": { "type": "string" }
        }
      },
      "Health": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["ok", "degraded"] },
          "store": { "type": "string", "enum": ["postgres", "emporix"] },
          "dbOk": { "type": "boolean", "nullable": true },
          "version": { "type": "string" }
        }
      },
      "Version": {
        "type": "object",
        "properties": {
          "service": { "type": "string" },
          "version": { "type": "string" },
          "store": { "type": "string" },
          "authMode": { "type": "string", "enum": ["emporix", "oidc"] }
        }
      },
      "SetupForm": {
        "type": "object",
        "description": "IDS field names, exactly as craftsman software sends them. An `onInbound` hook can normalise a vendor that differs.",
        "required": ["action"],
        "properties": {
          "action": {
            "type": "string",
            "enum": ["WKS", "WKE", "AS", "ADL", "SV", "LI"],
            "description": "WKS send cart · WKE receive cart · AS article search · ADL article deeplink · SV interface version · LI login information. (HLS, the heating label, answers 501.)"
          },
          "version": { "type": "string", "description": "IDS version. Checked against the connection's accepted list for cart actions.", "example": "2.5" },
          "hookurl": {
            "type": "string",
            "description": "Where the finished basket goes. Checked against the connection's allowlist before anything is stored. Required for WKS/WKE; on AS/ADL its absence means a plain redirect with no session."
          },
          "warenkorb": { "type": "string", "description": "The IDS XML document. WKS/WKE only." },
          "kndnr": { "type": "string", "description": "Customer number." },
          "name_kunde": { "type": "string", "description": "User name." },
          "pw_kunde": { "type": "string", "description": "Password. Never checked unless an `authenticate` hook is bound — that is the honest default." },
          "searchterm": { "type": "string", "description": "AS only. `suchbegriff` is accepted as an alias." },
          "ghnummer": { "type": "string", "description": "ADL only — the wholesaler's article number. `artnr` is accepted as an alias." }
        }
      },
      "SubmitForm": {
        "type": "object",
        "required": ["token"],
        "properties": {
          "token": { "type": "string", "description": "The `submitToken` from the handoff exchange. Single use." },
          "payload": { "type": "string", "description": "The finished basket as JSON, in the neutral shape the handoff delivered. Send either this or `idsgo`; exactly one is required." },
          "idsgo": { "type": "string", "description": "Deprecated NAME ONLY, and supported permanently: the same value as `payload`, under the name SDK 0.1.x sent. Not deprecated behaviour — there is no plan to remove it." }
        }
      },
      "HandoffCart": {
        "type": "object",
        "required": ["connection", "lines", "submitUrl", "submitToken", "submitExpiresAt"],
        "properties": {
          "connection": { "type": "string" },
          "currency": { "type": "string", "example": "EUR" },
          "lines": { "type": "array", "items": { "$ref": "#/components/schemas/HandoffLine" } },
          "submitUrl": { "type": "string", "description": "Where to POST the finished basket." },
          "submitToken": {
            "type": "string",
            "description": "The credential for sending it back. A TOKEN, not the craftsman's return address — the shop never learns that."
          },
          "submitExpiresAt": { "type": "string", "format": "date-time", "description": "After this the return leg is closed (~8h)." }
        }
      },
      "HandoffLine": {
        "type": "object",
        "required": ["sku", "artNo", "qty"],
        "properties": {
          "sku": { "type": "string", "description": "Resolved shop SKU. Today `passthrough`: the IDS ArtNo IS the SKU." },
          "artNo": { "type": "string" },
          "ean": { "type": "string" },
          "name": { "type": "string" },
          "qty": { "type": "string", "description": "A decimal STRING, not a number — IDS quantities carry trailing zeros that matter." },
          "unit": { "type": "string", "description": "Shop-side unit, after unit-mapping translation." },
          "priceCents": {
            "type": "integer",
            "description": "PER-UNIT price in minor units, already divided by PriceBasis. The shop should never have to know what PriceBasis is."
          },
          "note": { "type": "string" }
        }
      },
      "Error": {
        "type": "object",
        "properties": { "error": { "type": "string" } }
      }
    },
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Platform JWT whose `aud` includes `punchout-api`, carrying `medienwerft.punchout.*` scopes. Admin surface only — not used by anything in this document."
      },
      "shopKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Punchout-API-Key",
        "description": "Per-shop key, stored only as a peppered SHA-256 hash and revealed once at mint time."
      }
    }
  }
}
