Connect a procurement system by punchout

Let a buyer shop your storefront from inside SAP, Ariba or Coupa and send the cart back — a punchout account for OCI or cXML, the probe that proves your storefront answers, a mapping for the fields their system expects, and what to hand their procurement team.

A buyer's purchasing team works in SAP, Ariba or Coupa, and they want to order from your storefront without leaving it. With punchout, they open your catalogue from inside their own system, land signed in on your storefront with their assortment and prices, fill a cart, and send it back to their system with one button. Their system approves and orders on its side.

This guide is for the supplier side: the developer or integrator who runs the storefront and holds an API key for its tenant. It connects one buyer's system: an account for it, a check that your storefront answers the entry call, a mapping for any field their system expects, and the handful of values their procurement team types into their own configuration. The protocols are OCI (Open Catalog Interface, what SAP speaks), cXML (what Ariba and Coupa speak) and IDS (a German wholesale standard, covered in the reference but not walked through here).

Before you start

  • A storefront on a theme built on @revenexx/cover (or @revenexx/cover-theme, which extends it), deployed with the ssr runtime adapter on a host the buyer can reach. See Publishing a theme and Deploy from Git. The theme serves the entry addresses, signs the buyer in and builds the hand-back form, so you don't write any of that yourself.
  • Mailed-link sign-in switched on for the tenant. It is the Customers setting Sign in with a mailed link (login_magic_link) in Cockpit, it is off by default (as of 2026-09), and a buyer arriving by punchout cannot be signed in without it. Set it at tenant level rather than for one market. An organization that has switched it off for itself blocks punchout too. Switching it on also lets buyers sign in, and register a new address, with a link sent by email.
  • The buyer's organization with at least one contact who has a storefront account. Visits that can't be matched to a person shop as that contact.
  • A channel for punchout visits. It decides the assortment and prices the buyer sees. A fresh tenant has only a shop channel. The channels page shows how to create one of type punchout with the code punchout, which the examples here use.
  • From the buyer: which protocol their system speaks.
  • curl and jq, and an API key for the tenant with the punchout.* scope (see which scope covers which app). Prerequisites shows where the tenant slug comes from, and the same page explains how to create an API key.
Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
export SHOP="https://shop.example.com"

Every call goes through the API gateway with the same two headers. The examples use curl -s to stay short. In a script, add --fail-with-body so an error doesn't turn into an id of null.

1. Check the theme's permissions

Serving the entry addresses isn't enough on its own: the theme also has to be allowed to call the punchout capabilities behind them. The cover-theme and custom-theme starting points already declare all five, so check that your theme.json still has them:

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" }
]

The three entry capabilities receive the procurement system's calls, and the two sessions capabilities sign the buyer in and hand the cart back. If one is missing, that leg fails: an entry permission shows up in step 3, and the other two only when a real buyer arrives.

2. Create the account

First, set the storefront address once for the tenant. In Cockpit, open the settings of the punchout App and set Storefront address to https://<your-storefront-host>/punchout/start, the same value as $SHOP/punchout/start. Every account's entry address is derived from its origin, and the buyer lands on exactly this address after entry. Cockpit's hint shows a bare host, but that is not enough: /punchout/start is the page that signs them in, so the bare host leaves every buyer signed out, even though the probe passes. Check the value in force with GET /v1/settings/apps/punchout (Settings). An account's own start_page_url overrides it, which you need only for a buyer who should land somewhere else.

One account is one buyer's procurement system. Create it disabled, because it can't be enabled until the probe in step 3 has passed.

ACCOUNT_ID=$(curl -s -X POST https://api.revenexx.com/v1/punchout/accounts \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "acme-ariba",
    "name": "ACME Corp — Ariba",
    "protocol": "cxml",
    "channel_code": "punchout",
    "organization_id": "<ORGANIZATION_ID>",
    "fallback_contact_id": "<CONTACT_ID>",
    "unknown_user_policy": "fallback",
    "credential_identity": "AN01234567890",
    "credential_domain": "NetworkID",
    "shared_secret": "<A_LONG_RANDOM_SECRET>"
  }' | jq -r .id)

Each block sets ACCOUNT_ID. If you connect both systems, run the steps below once per account.

Who chooses the credentials: for cXML, credential_identity and credential_domain are the Sender identity the buyer's system sends, often their network ID, so ask their team for it. You choose the shared secret. For OCI with auth_strategy: password, credential_identity is the USERNAME their system sends and credential_secret is the PASSWORD. You choose both. No API response ever returns a secret, so keep your copy until step 6.

Set fallback_contact_id. An arriving user is matched to a contact only when their system sends an email address that belongs to the organization. SAP user names are rarely email addresses, so without a fallback contact those visits can't be signed in. unknown_user_policy decides what happens to a name that doesn't match: fallback shops as the fallback contact, refuse ends the visit, and create makes a new contact, but only from an email address.

3. Probe the entry address, then enable

The probe calls your storefront's entry address from outside, the way the buyer's system will, and passes only if the entry route itself answers. A shop page that returns 200 doesn't count.

Probe
curl -s -X POST https://api.revenexx.com/v1/punchout/accounts/$ACCOUNT_ID/probe \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '{passed, entry_probe_status, entry_probe_url, cause}'

entry_probe_url is the address the buyer's system will call: $SHOP/punchout/cxml/acme-ariba or $SHOP/punchout/oci/acme-sap. Every account answer also carries the current address as entry_url. When passed is true, enable the account. A PUT changes only the fields you send, so the secrets stay as they are:

Enable
curl -s -X PUT https://api.revenexx.com/v1/punchout/accounts/$ACCOUNT_ID \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled": true}'

Enabling before the probe has passed answers 409, punchout account '<code>' cannot be enabled until its entry URL answers, followed by the cause. The gate is on by default. It follows the tenant setting entry_probe_required. The statuses you'll meet first:

  • not_found: nothing serves /punchout/…, because the theme is static or isn't built on @revenexx/cover. A static theme with a fallback file shows up as not_entry instead.
  • not_entry: something answered, but not the entry route. A missing entry permission from step 1 shows up here.
  • wrong_host: the entry address redirected off its host, or the host serves another tenant.
  • tls: the certificate was refused.

The reference lists every status. If you change the storefront address or start_page_url later, probe again: an account that is already enabled isn't re-checked.

4. Test an entry the way their system will

The test sends one synthetic entry call with the account's own credentials and returns what the buyer's system would get back:

Test
curl -s -X POST https://api.revenexx.com/v1/punchout/accounts/$ACCOUNT_ID/test \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq .response

For cXML, response.body is a PunchOutSetupResponse with a StartPage URL and Status 200. For OCI, response.status is 302 with a Location on your storefront (for Secure OCI, 200 with a SecureOCI document). For cXML and plain OCI, the StartPage or Location must start with $SHOP/punchout/start?psid=. If it points anywhere else, fix the storefront address before a buyer arrives signed out. A refusal here means the account can't satisfy its own protocol. For example, an OCI account with auth_strategy: token and no login_token is refused. The test visit is revoked straight away and doesn't count toward the lockout.

5. Map the fields their system expects

The App ships default mappings for the standard fields of each protocol. Seed them once. The call is safe to repeat and never overwrites a row you've edited:

Seed defaults
curl -s -X POST https://api.revenexx.com/v1/punchout/defaults \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '{created: (.created | length), existing: (.existing | length)}'

Buyers' systems often want one extra field on every line. A common case is the buyer's cost centre as a cXML Extrinsic, which is cXML's element for a named value outside the standard. Each mapping row fills one target from one source. An Extrinsic needs two rows, one for its name attribute and one for its value. The key in brackets, [CostCenter], tells the two rows they belong to the same element, and it never appears in the document.

This example adds <Extrinsic name="CostCenter"> to each line for this account only. It assumes your storefront stores the buyer's cost centre on the cart line's metadata.cost_center:

Add an Extrinsic
for ROW in \
  '{"target": "ItemDetail/Extrinsic[CostCenter]/@name", "source": "constant", "source_config": {"value": "CostCenter"}}' \
  '{"target": "ItemDetail/Extrinsic[CostCenter]", "source": "line", "source_config": {"field": "metadata.cost_center"}}'
do
  curl -s -X POST https://api.revenexx.com/v1/punchout/field-mappings \
    -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
    -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(jq -c --arg acc "$ACCOUNT_ID" '. + {account_id: $acc, protocol: "cxml", document: "return", scope: "line", target_kind: "path"}' <<< "$ROW")"
done

The name row is a constant, so every line carries the element, empty where a line has no cost centre. You can add elements this way, but you can't reorder the ones the standard names. For OCI, the target is a field name such as NEW_ITEM-CUST_FIELD1 with target_kind: "field". GET /v1/punchout/vocabularies/mapping-sources lists every source and its options.

Now check the result without touching the buyer's system. Build a cart on the punchout channel (see Carts) and preview its hand-back:

Preview
curl -s -X POST https://api.revenexx.com/v1/punchout/accounts/$ACCOUNT_ID/preview \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"cart_id": "<CART_ID>"}' | jq '{target_url, fields, document}'

For OCI, the lines are in fields, one NEW_ITEM-…[n] entry per line. For cXML, the whole PunchOutOrderMessage is in document. The preview writes nothing, so run it as often as you like while you adjust.

6. Hand the connection details to their procurement team

Their team enters these values in their own system. Send the secret through a different channel from the address.

Their systemValues to send
Ariba, Coupa (cXML)The punchout URL $SHOP/punchout/cxml/<code> and the shared secret. The Sender identity is theirs already.
SAP (OCI)The catalogue URL $SHOP/punchout/oci/<code>, plus the parameters USERNAME (your credential_identity) and PASSWORD, or LOGINTOKEN for auth_strategy: token. Their system adds HOOK_URL itself.

Then run one visit together from their system. It's the only test that covers their side.

After the hand-back

The cart goes back as a form the buyer's browser submits, to the HOOK_URL or BrowserFormPost address their system sent with the entry call. Their firewall doesn't need to let anything in from you. The browser does have to accept cookies, because the visit is held in one.

The order does not come back through punchout. For OCI and cXML, and for IDS unless the account sets a purchase mode that lets the buyer check out in your shop (which @revenexx/cover does not offer yet, as of 2026-10), their system turns the returned cart into a purchase order on its own side and sends it to you by whatever route you've agreed: email, EDI, or an integration of your own. cXML OrderRequest is not handled by the punchout App (as of 2026-10). To match an incoming order to its cart, use the hand-back's correlation_key. The seeded cXML defaults send it as a header Extrinsic named CorrelationKey, and GET /v1/punchout/transfers lists every hand-back with its key.

If the buyer's spend must be approved or booked to a cost center, a punchout restriction on the cost center limits it to, or keeps it out of, a punchout account by code. See Set up purchase approvals and cost-center budgets.

Troubleshooting

  • The probe says not_found or not_entry. See step 3. A static theme can't serve entry at all, whatever it declares.
  • The probe passes, but buyers aren't signed in. Match what the buyer sees:
    • The storefront, signed out: the Storefront address setting (or the account's start_page_url) isn't the /punchout/start page.
    • "This punchout session cannot be opened", straight after a fresh entry: the sign-in was refused. Read entry-refusals as in the next bullet. misconfigured there usually means mailed-link sign-in is off (see Before you start).
    • "This shop could not sign you in": check that punchout.sessions.claim is in theme.json (step 1), then retry.
  • The buyer's first click is refused. Their system sent credentials that don't match the account. OCI and IDS answer 401, and cXML answers Status 401 Sender identity or shared secret is invalid. A wrong secret and an unknown account code answer identically, on purpose. GET /v1/punchout/entry-refusals gives the reason: wrong_credential, unknown_account, disabled (step 3's enable didn't happen), misconfigured (the account lacks the credential its protocol needs), or rate_limited once the lockout below has started.
  • OCI entry answers 503 with Retry-After: 300. Ten refused entries for one account code within five minutes lock it out (as of 2026-09). cXML and IDS don't answer differently while locked out, so check entry-refusals for rate_limited. Refused calls during the lockout count too, so their system has to stop retrying until the credentials are fixed.
  • Entry answers 429 or 413. The storefront's own limits: by default 60 requests per minute per source IP plus a burst of 20, and bodies up to 64 KB for OCI, 256 KB for cXML and 2 MB for IDS (as of 2026-09).
  • Entry succeeds, but the buyer isn't signed in. Read entry-refusals for that account:
    • misconfigured: mailed-link sign-in is off, the account has no fallback contact, or the contact has no storefront account.
    • unknown_buyer: no contact matched, and the policy is refuse, or create got a name that isn't an email address. Change the policy.
    • buyer_refused: the contact is blocked, or its registration is still pending.
  • The buyer sees the whole catalogue or the wrong prices. The visit shops in the account's channel_code. Set up that channel's assortment and price list, and see Channels to make it an allow-list.
  • Their system shows an empty cart. For Secure OCI (secure_oci: true) that's expected: the browser posts nothing, and their system fetches the cart with its own RETRIEVEOCI call. Otherwise, preview the same cart (step 5) and check the mapping.
  • Their system asks for something other than a shopping visit. OCI VALIDATE, DETAIL and the other non-shopping functions answer 501, cXML source answers Status 450, and IDS HLS answers 501. These aren't supported. The reference lists the operations that are.

Where to go next

  • Punchout reference — every account field, probe status, mapping source and refusal. Read it next if the buyer asked for Secure OCI or IDS.
  • Channels — the assortment and prices a punchout visit shops in.
  • Set up purchase approvals — cost-center budgets and the punchout restriction that ties a cost center to an account.
  • Theme anatomy — theme.json and its permissions.
Was this page helpful?