Punchout
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
protocol | Standard | Entry authenticates with | Hand-back |
|---|---|---|---|
oci | OCI 4.0 / 5.0, Level 1 | USERNAME + 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: true | Secure OCI | A 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. |
cxml | cXML PunchOutSetupRequest | Sender Identity + SharedSecret | One url-encoded form field holding the whole PunchOutOrderMessage, POSTed to the request's BrowserFormPost URL |
ids | IDS 2.5 | name_kunde + pw_kunde, and kndnr when the account sets one, in a multipart/form-data body | A 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,DETAILADDandQUANTITYCHECKanswer501, and an unknownFUNCTIONanswers400. OCI parameter names are read in any casing. - cXML serves
PunchOutSetupRequestwith the operationscreate,editandinspect.editandinspectreopen a requisition from the request's lines, andinspectis view-only.sourceanswers cXML Status450. There is noOrderRequesthandling: the purchase order the procurement system later sends does not arrive through this App. - IDS serves
WKE,ADL,AS,WKS,LIandSV.WKSalways imports into a new cart.HLSanswers501.
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:
| Protocol | Entry address |
|---|---|
| OCI | https://<storefront-host>/punchout/oci/<account-code> |
| cXML | https://<storefront-host>/punchout/cxml/<account-code> |
| IDS | https://<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:
"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.
| Setting | Is |
|---|---|
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_minutes | Default 60, minimum 1. How long a visit stays open after the procurement system opened it. |
entry_probe_required | Default 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.
| Field | Is |
|---|---|
code | Required. 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. |
name | Required. |
protocol | Required. oci, cxml or ids. |
channel_code | Required. The channel the visit shops in. It decides the assortment and prices the buyer sees. |
enabled | Default false. Can only become true once the probe has passed (see below). |
organization_id | The buyer's organization. Arriving users are matched against its contacts. |
fallback_contact_id | The contact a visit shops as when the entry call names nobody, or names someone the policy falls back from. |
unknown_user_policy | fallback (default), refuse or create. See Who the buyer is. |
auth_strategy | OCI 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_identity | OCI with auth_strategy: password: the expected USERNAME. cXML: the Sender identity. IDS: the expected name_kunde, so the same value as code. |
credential_domain | cXML: the Sender identity's domain. The test call sends it, but entry checks only the identity and the shared secret. |
ids_customer_name | IDS: the expected customer number kndnr. Checked only when set. |
credential_secret | OCI PASSWORD, IDS pw_kunde. Write-only, never returned. |
login_token | OCI LOGINTOKEN. Write-only. |
shared_secret | cXML SharedSecret. Write-only. |
start_page_url | Override 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_url | Read-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_oci | Default false. Switches the OCI account to Secure OCI. |
protocol_version | One 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_minutes | Override of the tenant's session_ttl_minutes. null, the ordinary case, means the tenant's lifetime. |
purchase_mode | Which 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_parameters | OCI 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_detail | Read-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:
- The entry call names nobody: the account's
fallback_contact_id. - The name looks like an email and matches a contact in the account's organization: that contact.
- Anything else follows
unknown_user_policy.fallbackshops as the fallback contact, and is refused asmisconfiguredif none is set.refuseends the visit asunknown_buyer.createcreates a contact from the name, but only when the name looks like an email; otherwise it is refused asunknown_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
| Route | Does |
|---|---|
POST /accounts/{id}/probe | Calls 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}/test | Sends 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}/preview | Body {"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_status | Means |
|---|---|
reachable | Passed. The only status that lets the account be enabled. |
not_found | 404 or 405: nothing serves the address. The theme is static or does not carry the entry routes. |
not_entry | Something answered, but not the entry route. A theme missing an entry permission lands here. |
wrong_host | A redirect left the host, more than three redirects, or the host serves another tenant. |
tls | The certificate was refused. |
timeout | No answer within 5 seconds. |
unreachable | DNS or connection failure. |
unconfigured | No 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.
| Field | Is |
|---|---|
account_id | The account, or null for the tenant default. |
protocol | oci, cxml or ids. |
document | return (default), entry or catalogue. |
scope | cart (default), line or unserved_line. |
target_kind | field for OCI, path for cXML and IDS. |
target | The OCI field name (NEW_ITEM-DESCRIPTION), or a path into the document. Relative to the line element for scope: line. |
source, source_config | Where the value comes from, and its options. |
mutators | An array of {type, config}, applied in order. |
emit | always (default), omit_if_empty or never. |
position, enabled | Ordering 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.
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 field | Is |
|---|---|
target_url | Where the form goes: HOOK_URL, BrowserFormPost or hookurl. null for Secure OCI, where the browser posts nothing. |
method, encoding | POST (GET for Secure OCI, where the browser sends nothing); url-encoded for OCI and cXML, multipart/form-data for IDS. |
fields | The form fields. |
document | The cXML or IDS document, when there is one. |
correlation_key | A 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_at | The recorded transfer. GET /transfers lists them. |
replayed | true 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_mode | The buyer | The storefront then calls | The visit closes as |
|---|---|---|---|
transfer | Hands the cart back. The only mode for OCI and cXML. | POST /sessions/{psid}/return | transferred |
order | Checks out in the shop. Nothing goes back to the trade software. | POST /sessions/{psid}/ordered | ordered |
order_and_transfer | Checks out, then hands the ordered cart back. The IDS document names the order (RueckgabeKZ, OrderConfNo). | Checkout, then POST /sessions/{psid}/return | transferred |
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.
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 '{}'
{
"psid": "…",
"status": "ordered",
"order_id": "…",
"order_number": "10023",
"order_status": "placed",
"ordered_at": "2026-10-12T09:41:00Z",
"replayed": false
}
order_statusispendingfor an order awaiting the buyer organization's approval andplacedotherwise. 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
orderedif the order was created before it ran out.
| Answer | Means |
|---|---|
409 conflict | The 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_offered | The visit's mode is not order. An order_and_transfer visit ends with the hand-back. The visit stays open. |
409 order_missing | No order exists for the visit's cart yet. The visit stays open. |
503 | The orders App could not be asked. The visit stays open; try again. |
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
| Limit | Where | Answer |
|---|---|---|
| Lockout: 10 refused entries for one account code within 5 minutes | the App | OCI: 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 20 | the storefront layer, configurable in the theme | 429 with Retry-After |
| Body size: 64 KB OCI, 256 KB cXML, 2 MB IDS | the storefront layer | 413 |
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.
reason | Means |
|---|---|
unknown_account | No account has that code. |
wrong_credential | The credentials did not match. |
disabled | The account is not enabled. |
rate_limited | The account is locked out. |
misconfigured | At 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_buyer | Nobody could be matched, and unknown_user_policy is refuse, or is create with a name that isn't an email address. |
buyer_refused | The contact may not sign in: blocked, or its registration is pending or rejected. |
unavailable | Sign-in on arrival could not be completed. Retry the visit. |
Related
- Channels — a punchout channel that shows only its assigned assortment.
- Organizations and contacts — who a visit shops as.
- API Explorer — every route, under Procurement → Punchout.