Punchout

The punchout App's surface — accounts for OCI, cXML and IDS 2.5, the entry addresses on your storefront host, the probe that gates enabling, field mappings, the hand-back payload, and every refusal and limit.

This page is the reference for the punchout App, for developers who connect a buyer's procurement system (SAP, Ariba, Coupa or a wholesale system speaking IDS) to a storefront. For a walkthrough, start with Connect a procurement system by punchout.

A punchout visit has three legs. The procurement system calls an entry address with its own credentials, the buyer lands signed in on your storefront and fills a cart, and the cart goes back to the procurement system as a hand-back form the buyer's browser submits. The procurement system then approves and orders on its side. The punchout App never creates an order and reserves no stock, and a hand-back sends the cart as it stands: it is a transfer, not a sale. The one exception is an IDS account whose purchase mode lets the buyer check out in the shop: the order is then an ordinary storefront order, and the App only records it. That needs a storefront that offers the checkout exit, which @revenexx/cover does not do yet (as of 2026-10).

Every admin route below is under https://api.revenexx.com/v1/punchout and takes the usual X-Revenexx-Tenant and X-Revenexx-Api-Key headers.

Protocols

protocolStandardEntry authenticates withHand-back
ociOCI 4.0 / 5.0, Level 1USERNAME + PASSWORD, or LOGINTOKEN (see auth_strategy)A url-encoded form POSTed to the HOOK_URL the entry call named, one NEW_ITEM-…[n] field per line
oci with secure_oci: trueSecure OCIA back-channel FUNCTION=INITIALIZE call answers a SessionId, and the browser arrives with that alone. The handle works once.Nothing from the browser. The procurement system fetches the cart itself with FUNCTION=RETRIEVEOCI on the same entry address.
cxmlcXML PunchOutSetupRequestSender Identity + SharedSecretOne url-encoded form field holding the whole PunchOutOrderMessage, POSTed to the request's BrowserFormPost URL
idsIDS 2.5name_kunde + pw_kunde, and kndnr when the account sets one, in a multipart/form-data bodyA multipart form with the Warenkorb document, POSTed to hookurl

What each protocol does not serve:

  • OCI serves the shopping call only. DETAIL, VALIDATE, SOURCING, BACKGROUND_SEARCH, DOWNLOADJSON, DETAILADD and QUANTITYCHECK answer 501, and an unknown FUNCTION answers 400. OCI parameter names are read in any casing.
  • cXML serves PunchOutSetupRequest with the operations create, edit and inspect. edit and inspect reopen a requisition from the request's lines, and inspect is view-only. source answers cXML Status 450. There is no OrderRequest handling: the purchase order the procurement system later sends does not arrive through this App.
  • IDS serves WKE, ADL, AS, WKS, LI and SV. WKS always imports into a new cart. HLS answers 501.

A cart that comes in (cXML edit and inspect, IDS WKS) is priced at your channel's prices, not at the prices the procurement system sent.

Entry addresses

The procurement system calls your storefront host, not the API gateway:

ProtocolEntry address
OCIhttps://<storefront-host>/punchout/oci/<account-code>
cXMLhttps://<storefront-host>/punchout/cxml/<account-code>
IDShttps://<storefront-host>/punchout/ids, one address for every account. The account is the one whose code equals the name_kunde the call sends.

The storefront passes each call through to the App unchanged and writes the answer back byte for byte. It authenticates nothing itself, because the credentials are the App's business. The spec also lists /v1/punchout/entry/… routes. Those are what the storefront forwards to, they need a tenant credential, and a procurement system cannot call them.

The storefront must run a theme built on @revenexx/cover, which carries the entry routes, the sign-in page at /punchout/start and the hand-back page. @revenexx/cover-theme extends it, so a theme on either layer has them. The theme's theme.json must also be allowed to call the capabilities behind them. The cover-theme and custom-theme starting points declare all five:

theme.json
"permissions": [
  { "capability": "punchout.entry.oci",       "compatible": "^1.0" },
  { "capability": "punchout.entry.cxml",      "compatible": "^1.0" },
  { "capability": "punchout.entry.ids",       "compatible": "^1.0" },
  { "capability": "punchout.sessions.claim",  "compatible": "^1.0" },
  { "capability": "punchout.sessions.return", "compatible": "^1.0" }
]

Two more things have to be true for a buyer to arrive signed in. The Customers setting login_magic_link (Sign in with a mailed link) must be on for the tenant, and not switched off by the buyer's organization. It is off by default. And the buyer must land on /punchout/start, the only page that signs them in, so the tenant's storefront address (below) or the account's own start_page_url must be that page.

A theme deployed with the static runtime adapter has no server, so it can never serve entry. See Theme anatomy for the manifest and Build configuration for the adapter.

The buyer's browser has to accept cookies, because the visit is carried in a cookie.

Tenant settings

Some values are the same for every account, so they are punchout App settings for the tenant rather than account fields. An operator sets them in Cockpit's settings form for the Punchout App, and GET /v1/settings/apps/punchout reads the values in force.

SettingIs
storefront_url (Storefront address)Where the buyer lands after entry, and the origin (scheme, host and port) every entry address is derived from. Set it to https://<storefront-host>/punchout/start. Entry sends the buyer to exactly this address with the visit handle appended as ?psid=…, so the bare host leaves every buyer signed out, even though Cockpit's own hint shows a bare host. Empty by default, and an account with no start page of its own then cannot be probed (unconfigured) or entered (misconfigured).
session_ttl_minutesDefault 60, minimum 1. How long a visit stays open after the procurement system opened it.
entry_probe_requiredDefault true. Whether an account must pass the probe before it can be enabled. See Probe, test and preview.

An account can override the first two, with start_page_url and session_ttl_override_minutes. Leave both empty unless one buyer needs something different.

Accounts

/accounts is plain CRUD: GET and POST on /accounts, and GET, PUT and DELETE on /accounts/{id}. One account is one procurement system of one buyer.

FieldIs
codeRequired. Unique per tenant. It is the last segment of the OCI and cXML entry addresses. For IDS, it must equal the name_kunde the wholesaler's system sends, because that is how the call finds its account.
nameRequired.
protocolRequired. oci, cxml or ids.
channel_codeRequired. The channel the visit shops in. It decides the assortment and prices the buyer sees.
enabledDefault false. Can only become true once the probe has passed (see below).
organization_idThe buyer's organization. Arriving users are matched against its contacts.
fallback_contact_idThe contact a visit shops as when the entry call names nobody, or names someone the policy falls back from.
unknown_user_policyfallback (default), refuse or create. See Who the buyer is.
auth_strategyOCI only. password (default) checks USERNAME against credential_identity and PASSWORD against credential_secret. token checks LOGINTOKEN against login_token. cXML and IDS ignore it.
credential_identityOCI with auth_strategy: password: the expected USERNAME. cXML: the Sender identity. IDS: the expected name_kunde, so the same value as code.
credential_domaincXML: the Sender identity's domain. The test call sends it, but entry checks only the identity and the shared secret.
ids_customer_nameIDS: the expected customer number kndnr. Checked only when set.
credential_secretOCI PASSWORD, IDS pw_kunde. Write-only, never returned.
login_tokenOCI LOGINTOKEN. Write-only.
shared_secretcXML SharedSecret. Write-only.
start_page_urlOverride of the tenant's storefront_url, normally empty. Set it only for a buyer who should land somewhere else, and still on a /punchout/start page, since any other page leaves the buyer signed out. It must be an absolute http(s) address. An invalid one is not replaced by the tenant's address: entry refuses the account as misconfigured. The entry address is derived from its origin.
entry_urlRead-only. The address the procurement system calls, derived from the start page's origin (the account's own or the tenant's), the protocol and code. Give this to the buyer's team. null while neither address is set.
secure_ociDefault false. Switches the OCI account to Secure OCI.
protocol_versionOne of GET /v1/punchout/vocabularies/protocol-versions for the account's protocol, and any other value answers 400. OCI: 4.0 or 5.0 (empty means 5.0). It decides which NEW_ITEM-… fields the hand-back carries. IDS: a version the shop answers SV with, sent as the hand-back's version (empty means 2.5). cXML: leave it empty, because each cXML document states its own version, and any value answers 400.
session_ttl_override_minutesOverride of the tenant's session_ttl_minutes. null, the ordinary case, means the tenant's lifetime.
purchase_modeWhich exit a visit offers: transfer (default, the hand-back only), order (checkout in the shop, nothing handed back) or order_and_transfer (checkout, then the hand-back, which names the order). Ordering modes are IDS only. Any other protocol is saved as transfer whatever you send, without an error. See Purchase mode.
echo_entry_parametersOCI only. Default false. true returns the entry call's own query parameters in the hand-back, for a procurement system that expects them. Credential parameters are never returned.
entry_probe_status, entry_probe_at, entry_probe_url, entry_probe_detailRead-only. Written by the probe, silently dropped on a write.

A null sent for a switch with a default (secure_oci, auth_strategy, echo_entry_parameters, enabled, unknown_user_policy, purchase_mode) is ignored: a new account takes the default, and an existing one keeps its value. A null on an optional field such as start_page_url clears it.

Secrets are compared in constant time. An account missing the credential its protocol needs is refused as misconfigured.

Who the buyer is

When the buyer arrives, the App decides which contact the visit shops as:

  1. The entry call names nobody: the account's fallback_contact_id.
  2. The name looks like an email and matches a contact in the account's organization: that contact.
  3. Anything else follows unknown_user_policy. fallback shops as the fallback contact, and is refused as misconfigured if none is set. refuse ends the visit as unknown_buyer. create creates a contact from the name, but only when the name looks like an email; otherwise it is refused as unknown_buyer.

OCI user names and IDS customer numbers are rarely email addresses, so most OCI and IDS accounts run on the fallback contact. Set one.

Probe, test and preview

RouteDoes
POST /accounts/{id}/probeCalls the account's entry address from outside, with a single-use token, and passes only if the entry route echoes the token back. A shop page answering 200 does not pass.
POST /accounts/{id}/testSends one synthetic entry call with the account's own credentials, as the procurement system would, and returns the protocol's answer whole.
POST /accounts/{id}/previewBody {"cart_id": "…"}. Runs the account's mappings over an existing cart and returns the hand-back it would produce. Writes nothing.

The probe answers {account, entry_probe_status, entry_probe_at, entry_probe_url, entry_probe_detail, passed, cause}. It times out after 5 seconds and follows up to three same-origin redirects.

entry_probe_statusMeans
reachablePassed. The only status that lets the account be enabled.
not_found404 or 405: nothing serves the address. The theme is static or does not carry the entry routes.
not_entrySomething answered, but not the entry route. A theme missing an entry permission lands here.
wrong_hostA redirect left the host, more than three redirects, or the host serves another tenant.
tlsThe certificate was refused.
timeoutNo answer within 5 seconds.
unreachableDNS or connection failure.
unconfiguredNo usable start page, so there is no entry address to call: the account's own start_page_url is not an absolute http(s) address, or it has none and the tenant's storefront_url is empty or not one.

Enabling is gated on the probe. Setting enabled: true without a passed probe on the current entry address answers 409: punchout account '<code>' cannot be enabled until its entry URL answers. <cause> Probe its entry URL, then enable it. The details carry entry_probe_status and cause. A finding counts only for the entry address it was taken on, so changing code, protocol, start_page_url or the tenant's storefront_url makes it stale, and an account that is already enabled is not re-checked. The gate follows the tenant setting entry_probe_required, which defaults to true.

A test visit is revoked straight away, imports no cart and does not count toward the lockout.

Field mappings

A field mapping is one row that fills one target in one document from one source. /field-mappings is plain CRUD, and a row with account_id: null is the tenant default that every account inherits unless it has its own row for the same target.

FieldIs
account_idThe account, or null for the tenant default.
protocoloci, cxml or ids.
documentreturn (default), entry or catalogue.
scopecart (default), line or unserved_line.
target_kindfield for OCI, path for cXML and IDS.
targetThe OCI field name (NEW_ITEM-DESCRIPTION), or a path into the document. Relative to the line element for scope: line.
source, source_configWhere the value comes from, and its options.
mutatorsAn array of {type, config}, applied in order.
emitalways (default), omit_if_empty or never.
position, enabledOrdering among extensions, and an off switch.

A row is unique per account, protocol, document, scope and target. A target the protocol cannot carry is refused with 400 when you write it.

IDS tenants seeded before punchout 1.2.1: the default Order/CustomerInfo/IDNo row there reads credential_identity, which is the user name, where it should read the customer number. Seeding never overwrites an existing row, so running POST /defaults again doesn't fix it. Set that row's source_config.field to ids_customer_name. Tenants seeded on 1.2.1 or later get the corrected row.

Sources (25): constant, cart, line, product, product_attribute, parent_product, parent_attribute, price, quantity, unit, currency, session, account, contact, organization, business_unit, address, order, media, setting, document, unserved, cxml.payload_id, cxml.supplier_part_auxiliary_id, cxml.field.

Mutators (19): trim, upper, lower, max_length, prefix, suffix, number_format, price_format, strip_tags, html_entity_decode, date_iso8601, unix_timestamp, language_alpha3, base64_encode, value_map, strip_punctuation, unit_uncefact, gtin, raw.

GET /vocabularies lists the App's vocabularies (mapping-sources, mapping-mutators, mapping-scopes, entry-probe-outcome, purchase-modes, among others), and GET /vocabularies/{name} returns one. GET /vocabularies/mapping-sources gives every source with its options. Read them there rather than guessing.

You can extend a document but not reorder it. Elements the standard names keep the standard's order. A cXML Extrinsic is addressed by a key in brackets, Extrinsic[CostCenter], which never reaches the document; it tells two Extrinsic rows apart. Write the element's @name as its own row.

POST /defaults seeds the shipped default mappings and is safe to repeat: rows you edited are left alone, and the answer is {created, existing}.

Hand-back

The storefront calls POST /sessions/{psid}/return when the buyer transfers the cart, and the browser submits the form it describes:

Response fieldIs
target_urlWhere the form goes: HOOK_URL, BrowserFormPost or hookurl. null for Secure OCI, where the browser posts nothing.
method, encodingPOST (GET for Secure OCI, where the browser sends nothing); url-encoded for OCI and cXML, multipart/form-data for IDS.
fieldsThe form fields.
documentThe cXML or IDS document, when there is one.
correlation_keyA key you can match the procurement system's later order against. The seeded defaults already send it: as the header Extrinsic named CorrelationKey (cXML), as NEW_ITEM-EXT_QUOTE_ID (OCI), and as RefItems/Supplier, cut to 35 characters (IDS).
transfer_id, transferred_atThe recorded transfer. GET /transfers lists them.
replayedtrue when the visit was already handed back. The same payload comes back and no second transfer is recorded, so a double-submit is harmless.

A theme built on @revenexx/cover makes this call, builds the form and signs the buyer out. You call it yourself only in a storefront of your own.

The purchase mode adds three 409 codes to the hand-back, and each leaves the visit open: hand_back_not_offered (the visit's mode is order, so its exit is the checkout), cart_already_ordered (a transfer visit whose cart was ordered) and order_missing (an order_and_transfer visit whose cart has no order yet).

Purchase mode (IDS)

A wholesaler's trade software speaking IDS can let the buyer order in your shop instead of handing the cart back for approval. purchase_mode on the account decides which exit a visit gets, and the visit keeps the mode it opened with, even if you change the account mid-visit. The claim answers it as purchase_mode, so the storefront knows which button to show.

purchase_modeThe buyerThe storefront then callsThe visit closes as
transferHands the cart back. The only mode for OCI and cXML.POST /sessions/{psid}/returntransferred
orderChecks out in the shop. Nothing goes back to the trade software.POST /sessions/{psid}/orderedordered
order_and_transferChecks out, then hands the ordered cart back. The IDS document names the order (RueckgabeKZ, OrderConfNo).Checkout, then POST /sessions/{psid}/returntransferred

The order is an ordinary order placed through the ordinary checkout for the contact the visit shops as, so the same checks apply as to any other storefront order for that contact. See Give buyer staff the right permissions. A visit that shops as the fallback contact can order only if that contact's role grants orders.create; without it, the buyer can at most request an order.

Purchase modes need punchout 2.5.0. It asks for two new permissions, customers.contacts.permissions and orders.list, so a tenant stays on the previous version, with no purchase_mode and no /ordered, until an admin consents in Cockpit.

POST /sessions/{psid}/ordered takes an empty JSON body {}. psid is the visit's handle, the same one the claim and the hand-back use. The App does not trust the caller: it asks the orders App for the order the visit's cart became, and records it on the visit.

Request
curl -X POST https://api.revenexx.com/v1/punchout/sessions/{psid}/ordered \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'
Response
{
  "psid": "…",
  "status": "ordered",
  "order_id": "…",
  "order_number": "10023",
  "order_status": "placed",
  "ordered_at": "2026-10-12T09:41:00Z",
  "replayed": false
}
  • order_status is pending for an order awaiting the buyer organization's approval and placed otherwise. It is a snapshot taken when the visit closed: read the order itself for its current status. A later decline does not reopen the visit.
  • A retry is safe. A visit already closed this way answers the same body with replayed: true.
  • Checkout may outlast the visit. A visit that ran out while the buyer paid still closes as ordered if the order was created before it ran out.
AnswerMeans
409 conflictThe visit expired, was revoked or handed back, or no visit has that handle. One answer for all of them, because the handle travels through the buyer's system in the clear.
409 order_not_offeredThe visit's mode is not order. An order_and_transfer visit ends with the hand-back. The visit stays open.
409 order_missingNo order exists for the visit's cart yet. The visit stays open.
503The orders App could not be asked. The visit stays open; try again.
IDS tenants seeded before punchout 2.5.0: the default WarenkorbInfo/RueckgabeKZ row there is a constant Warenkorbrückgabe, and the Order/OrderInfo/OrderConfNo row is missing. Run POST /defaults again to add OrderConfNo. Seeding never overwrites a row, so change the RueckgabeKZ row yourself: sourcesession, source_config{"field": "purchase_mode"}, and a value_map mutator mapping order_and_transfer to Warenkorbrückgabe mit Bestellung with the default Warenkorbrückgabe.

@revenexx/cover offers only the hand-back today (as of 2026-10). It does not read purchase_mode or call /ordered, and its starting points do not declare punchout.sessions.ordered. Set order or order_and_transfer only for a theme of your own, built on @revenexx/cover for the entry routes, that reads purchase_mode from the claim, shows the checkout, calls /ordered or /return after it, and lists punchout.sessions.ordered in its theme.json permissions.

/sessions is plain CRUD as well. GET /sessions/{id} reads a visit with its status, purchase_mode and, once ordered, order_id, order_number, order_status and ordered_at.

Refusals and limits

LimitWhereAnswer
Lockout: 10 refused entries for one account code within 5 minutesthe AppOCI: 503 with Retry-After: 300, punchout entry is temporarily unavailable for this account. cXML and IDS: the same answer as a wrong credential.
Request rate: by default 60 per minute per source IP, plus a burst of 20the storefront layer, configurable in the theme429 with Retry-After
Body size: 64 KB OCI, 256 KB cXML, 2 MB IDSthe storefront layer413

Refused calls during a lockout count toward it, so a procurement system that keeps retrying stays locked out. Account codes are visible in the OCI and cXML entry addresses, so anyone who knows one can trigger the lockout. They cannot get in by doing so.

A wrong credential and an unknown account answer identically, and unknown codes are locked out the same way, so a caller cannot probe for account codes. OCI answers 401 punchout entry refused: the credentials are not valid for this account, cXML answers HTTP 200 carrying Status 401 Sender identity or shared secret is invalid, and IDS answers 401. Test calls don't count toward the lockout.

GET /entry-refusals lists every refused entry: account_code, account_id, protocol, reason, origin (entry or tester), client_ip, occurred_at.

reasonMeans
unknown_accountNo account has that code.
wrong_credentialThe credentials did not match.
disabledThe account is not enabled.
rate_limitedThe account is locked out.
misconfiguredAt entry, the account lacks a credential its protocol needs, or has no usable start page: its own start_page_url is invalid, or it has none and the tenant's storefront_url is empty or invalid. On arrival, the buyer could not be signed in because of configuration: login_magic_link is off, the account has no fallback contact, or the contact has no storefront account.
unknown_buyerNobody could be matched, and unknown_user_policy is refuse, or is create with a name that isn't an email address.
buyer_refusedThe contact may not sign in: blocked, or its registration is pending or rejected.
unavailableSign-in on arrival could not be completed. Retry the visit.
Was this page helpful?