Set up purchase approvals and cost-center budgets

Route B2B checkouts through approval before they become orders — buyer roles, cost centers with budgets and rollover, restrictions, approval rules, the submit call your checkout makes, and how a request is decided.

In a B2B shop, a buyer's cart often must not become an order straight away. Someone in the buyer's company has to sign it off first, and the spend has to be booked against a cost center with a budget. This guide sets that up. At the end, your checkout submits the cart once, and the platform answers with one of three results: an order placed at once, a purchase request waiting for its approvers, or a refusal naming the rule that stopped it. The budget is held while the request waits and released if the request is declined or cancelled.

This is for developers wiring a B2B storefront's checkout and the back office that configures it. You need a tenant with the Procurement, Cost Centers, Customers and Orders apps, organizations and contacts for your buyers (see Contacts), and an API key for your checkout server with the procurement.*, cost_centers.* and customers.* scopes (see which scope covers which app). New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Every call goes through the API gateway:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

This is not the order-value threshold of the Orders app, which accepts an order as pending instead of placing it (see Orders). A purchase request is a separate record, owned by the Procurement app, and there is no order until it is approved. Leave that Orders threshold off on a tenant that approves through Procurement: an order Procurement places above it is held as pending a second time.

How a submission is decided

Your checkout sends the cart to POST /v1/procurement/submit instead of placing an order. The Procurement app then:

  1. Reads the buyer. It takes the contact's role from the Customers app, and each line's cost center from the cart.
  2. Evaluates every active approval rule. Each rule has a condition, an effect and an approver.
  3. Answers with one outcome. If a rule prevents the purchase, nothing is created. If one or more rules require approval, a purchase request is created with one pending approval per rule. If no rule fires, the order is placed at once.
  4. Moves the budget. A direct order spends it on the spot. A purchase request reserves it, and the reservation becomes spend when the order is placed or goes back to the budget when the request is declined or cancelled.

1. Give buyers the right role

Procurement decides what a buyer may do from one field, the contact's role. It honours four roles. The middle two columns are the permissions your approval rules check in step 5, place-order and approve-order. A role routes nothing to approval on its own: without a rule, every cart becomes an order.

Roleplace-orderapprove-orderAlso
requesterNoNoCan fill a cart and submit it
buyerYesNo
approverYesYes
adminYesYesMay approve a whole request at once, and cancel anyone's request

viewer, and any custom role your tenant defines in the Customers app, grant nothing in Procurement. A contact holding one can submit, but a rule like "missing permission to place an order" fires for them every time. The mapping above is fixed (as of 2026-10). It does not follow the role-to-permission table you edit in Roles and permissions. Give buyer staff the right permissions walks the whole buyer side.

Set the role on the contact:

Set the buyer's role
curl -s -X PUT "https://api.revenexx.com/v1/customers/contacts/<CONTACT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "role": "approver" }'

2. Create a cost center

A cost center is what a cart line is booked to. code and name are required, and code is unique on the tenant. The code is what your storefront and the buyer's ERP see, so use the one the buyer's company already uses.

Create the cost center
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/cost-centers" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "code": "CC-4711",
        "name": "Maintenance, plant 2",
        "organization_id": "<ORGANIZATION_ID>",
        "accountable_contact_id": "<CONTACT_ID>",
        "currency": "EUR"
      }'
  • accountable_contact_id is the person responsible for this cost center. An approval rule whose approver is default resolves to this contact (see step 5), so give this contact the approver or admin role, or nobody but an admin override can decide those approvals.
  • currency defaults to the tenant's default currency. Every amount booked to the center is in this currency, and nothing converts: a cart submitted in another currency is refused.
  • budget_type is monetary (the default) or tracking_only. A tracking-only center records which cost center a line was booked to, but holds no budget. You cannot change budget_type after the center is created.

3. Give the cost center a budget

A budget holds an amount for a period. A cost center can have several, and it has no money of its own without one: a cart that books to a monetary center with no active budget is refused with 409.

Give it a budget
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/budgets" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "cost_center_id": "<COST_CENTER_ID>",
        "name": "Q4 2026",
        "initial_value": 25000,
        "sequence": 0,
        "period_start": "2026-10-01",
        "period_length": 3,
        "recurring": true,
        "takeover": { "mode": "limited", "limit": 5000 }
      }'
  • initial_value is the amount. The budget takes its currency from the cost center.
  • period_start and period_length set the period, in whole months. The end date is derived for you and returned as period_end.
  • sequence decides the spending order when a center has several active budgets. The lowest is spent first, and each one gives up what it has left before the next is touched. The last one takes whatever remains, and it may go negative.
  • recurring: true starts a new period when this one ends. It requires period_length.
  • takeover decides what the new period inherits from the old one. never (the default) carries nothing over. always carries the whole unspent amount. limited carries the unspent amount up to limit. An overspent budget carries nothing, not a debt.

The rollover runs once a day (as of 2026-10). It closes the ended period (active: false), opens the next one with initial_value plus what was carried over, and writes off the rest. To run it on demand, for example after correcting a period, call POST /v1/cost-centers/budgets/rollover/run. It is safe to repeat.

To correct a budget by hand, call POST /v1/cost-centers/budgets/<BUDGET_ID>/adjust with actor and either amount (a signed change) or target (the new remaining value). Every movement, whether manual, from an order or from a rollover, is recorded in GET /v1/cost-centers/budget-changes with its reason, old and new value, and the order or purchase request behind it. A budget that has moved is never deleted: retire it with active: false. A draft nobody has used can be discarded instead, as described below.

Discard a draft budget or archive a cost center

A budget that has not been used yet can be discarded. Discarding is only for a draft, one that holds just its initial ledger entry and no rollover chain. The call sets the budget to zero, stamps it with discarded_at and makes it inactive. It answers 200 with budget and change, the same shape as adjust. Once anything has moved, the call is refused with 409 and budget_in_use. Discarding a budget that is already discarded answers 409 with budget_discarded.

Discard a draft budget
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/budgets/<BUDGET_ID>/discard" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Send a JSON body; {} is enough. actor and note are both optional, and anything you send is written onto the discarded entry. Discarded drafts are left out of GET /v1/cost-centers/budgets by default. Pass discarded=include to list them among the rest, or discarded=only to list nothing but them. Any other value is refused with 400.

To stop a cost center from taking bookings, archive it. Archiving sets it inactive, so nothing books to it. The call is idempotent and takes no body. It answers 200 with the cost center. Use it for a cost center that has ever held a budget, since deleting such a cost center is not the way out.

Archive a cost center
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/cost-centers/<COST_CENTER_ID>/archive" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"

4. Decide who may book to the cost center

A cost center is open by default: any buyer may book any product to it. Restrictions narrow that. Each restriction has a type and a parameters object:

typeparameters
contact{ "contact_ids": [...] }
role{ "roles": [...] }
product{ "product_ids": [...] }
category{ "category_ids": [...] }
catalog{ "catalog_ids": [...] }
punchout{ "mode": "only" | "except", "account_codes": [...] }
Restrict who may book
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/restrictions" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "cost_center_id": "<COST_CENTER_ID>",
        "type": "role",
        "parameters": { "roles": ["requester", "buyer", "approver", "admin"] }
      }'

Values of one type are alternatives, and types combine. Two role restrictions allow either role. A role restriction and a category restriction allow only a buyer with that role, booking a product in that category. A type with no restriction is unrestricted. punchout with except excludes accounts, and an exclusion wins. The account codes are the ones you create in Connect a procurement system by punchout.

Your storefront asks which cost centers a buyer may pick with POST /v1/cost-centers/usable. Send the buyer and the cart lines, and it answers the usable centers per line. Send roles as the contact's role from the Customers app, because the call does not look it up, and note that organization_id limits the answer to that organization's centers. Each usable center comes with its id and code. When exactly one fits, its id is returned as auto_select, so the picker can be skipped. Submit takes the code, not the id (see step 6):

List the cost centers a buyer may use
curl -s -X POST "https://api.revenexx.com/v1/cost-centers/usable" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "organization_id": "<ORGANIZATION_ID>",
        "contact_id": "<CONTACT_ID>",
        "roles": ["buyer"],
        "lines": [ { "ref": "1", "product_id": "<PRODUCT_ID>", "category_id": "<CATEGORY_ID>" } ]
      }'

A monetary center with no active budget is left out of the answer.

Personal limits are the other per-person control. A contact limit (POST /v1/cost-centers/contact-limits with contact_id, monetary_limit and currency) is the most one contact may submit in one purchase. It applies per submission and has no period. It is only checked by an approval rule with the personalLimit condition (see step 5), and a contact without a limit is not limited.

5. Write the approval rules

A rule says: when this condition is true, apply this effect, and send the approval to this approver. Rules are created in Cockpit under Approval rules, or over the API:

Create an approval rule
curl -s -X POST "https://api.revenexx.com/v1/procurement/approval-rules" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Over 1,000 needs the cost-center owner",
        "cost_center_id": "<COST_CENTER_ID>",
        "condition": "constantLimit",
        "condition_parameters": { "limit": 1000 },
        "effect": "pendingOrder",
        "approver_type": "default",
        "sequence": 10,
        "active": true
      }'

Conditions measure the net line total: quantity × unit price, before tax and shipping, for the whole cart or for the rule's cost-center lines.

conditioncondition_parametersTrue when
alwaysnoneEvery submission
constantLimit{ "limit": 1000 }The amount is above limit. Exactly limit does not fire.
contactHasRole{ "role": "requester" } or { "roles": [...] }The buyer holds any of the roles
contactMissingPermission{ "permission": "place-order" } or { "permissions": [...] }The buyer lacks any of them. The values are place-order and approve-order, granted by the roles in step 1.
availableBudgetnoneThe cost center's active budgets do not cover the amount. Needs cost_center_id on the rule.
personalLimitnoneThe amount is above the buyer's contact limit from step 4

Effects

effectWhat happens
pendingOrderA pending approval is created for this rule's approver.
preventThe submission is refused with 409 and nothing is created. Put the text the buyer should see in effect_parameters.reason. prevent beats every other rule.
sendEmailAdds effect_parameters to the emails list in the submit answer, and, when a purchase request is created, records a notification on it. Deliver the mail from your own code.

Approvers are set with approver_type:

  • contact — one person, named in approver_contact_id.
  • role — anyone holding the role in approver_role.
  • default — the cost center's accountable_contact_id. On a global rule, or a center without one, anyone who may approve can decide it.

Global or per cost center. A rule with no cost_center_id is global and measures the whole cart. A rule with cost_center_id measures only the lines booked to that center, and is skipped when the cart books nothing to it. Within one cost center, approvals run in sequence order, so a low sequence is the first sign-off. Rules with no cost center form their own chain, which runs alongside the cost-center chains rather than before them.

A common starting set, created once per cost center with its cost_center_id: contactMissingPermission / place-order → pendingOrder → default, so every requester's cart goes to that center's owner, and constantLimit 10,000 → pendingOrder → contact with a named approver_contact_id and a higher sequence, as a second layer for large purchases. default only reaches the owner on a rule scoped to a cost center.

6. Submit the cart from your checkout

At checkout, your server calls submit instead of placing the order. cart_id and items are required, and each item needs name and quantity. sender_country is optional: the country the goods are sent from, as an ISO 3166-1 alpha-2 code. It decides how the frozen address lines print in capitals (the destination's capitalised fields when the request goes abroad, none at home), and anything that is not a country's code is a 400. When you omit it, the market's ship-from country counts, else the market code when it is a country's code. The request stores it with its market. The order an approval places, and a direct order, is handed it. PUT /v1/procurement/purchase-requests/{id} accepts it too, only to print a correction and never stored; omitted there, the sender country the request was submitted with counts (for a request stored without one, the market the correction is made in), and a body with only sender_country is a 400. Details are on Orders. Book each line to a cost center with cost_center, the center's code:

Submit the cart
curl -s -X POST "https://api.revenexx.com/v1/procurement/submit" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "cart_id": "<CART_ID>",
        "contact_id": "<CONTACT_ID>",
        "organization_id": "<ORGANIZATION_ID>",
        "currency": "EUR",
        "customer_order_number": "PO-88213",
        "items": [
          { "name": "Safety gloves, size 9", "sku": "SG-9", "product_id": "<PRODUCT_ID>",
            "quantity": 200, "unit_price": 7.5, "cost_center": "CC-4711" }
        ],
        "billing_address": { "…": "…" },
        "shipping_address": { "…": "…" }
      }'
Take contact_id from the session your server holds, never from the browser. The contact decides which rules fire and which roles apply, so a buyer who could change it could submit as their own approver. See Sign a buyer in.

The answer tells you which way it went, in outcome:

StatusoutcomeBodyShow the buyer
201directorderThe order confirmation
201approvalpurchase_request, pending_approvals"Sent for approval", with purchase_request.number
409preventprevent.rule_id, prevent.reasonprevent.reason
409no_budgetno_budget.cost_center_code, no_budget.reasonThat the cost center has no budget
409currency_mismatchcurrency_mismatch.requested, .actualThat the cart's currency does not match the cost center
409decidedpurchase_request, decided.statusThat this cart was already declined or cancelled. Start a new cart.

Submitting the same cart again is safe. cart_id identifies the submission: a repeat answers 200 with idempotent: true and the request or order the first call created, and completes any step the first call did not finish. So on a timeout, retry with the same cart_id rather than a new one.

A 400 means cart_id or items is missing, or a line names a cost center code that does not exist, or that is excluded for this punchout session. A 502 names another app that refused the call, in error.

7. Let approvers decide

Each pending approval belongs to one cost center's chain, and the chain runs in order:

  • Only the first pending approval in a chain can be decided. The next one opens when it is approved. Chains of different cost centers run side by side.
  • One decline ends the whole request. It becomes declined, and its reserved budget goes back.
  • The order is placed when the last approval is approved. The request moves to approved, then ordered, and its order_id points at the new order. The reserved budget becomes spend.

A decision is a person's act. The approver decides signed in as themselves, which is why the approval screen belongs in your storefront's account area (the block catalog has an account_approvals block). Only the contact a contact approval names may decide it, and only a holder of the role for a role approval. Both need approver or admin. A decision may carry a reason. A decline's reason is stored on the request as decline_reason.

In Cockpit, a merchant's operator has two overrides on a purchase request: Super-approve clears every remaining approval and places the order, and Cancel request ends a pending request and releases its budget. A buyer can also cancel their own pending request.

A purchase request moves through these statuses:

statusMeansNext
pendingWaiting for approvalsapproved, declined, cancelled
approvedEvery approval given, order being placedordered
orderedThe order exists, see order_idfinal
declinedAn approver declinedfinal
cancelledCancelled by the buyer, an admin contact or the merchantfinal

To react to a decision, for example to email the buyer, subscribe to purchase_request_event.created and read name: purchase_request.requested, .approved, .declined, .cancelled, .ordered or .notification, plus budget.confirmed and budget.withdrawn for the budget moves. Each event's payload holds a snapshot of the request: purchase_request_id, number, status, contact_id, organization_id, currency, grand_total, order_id and cart_id. Subscribe from an App or a webhook endpoint. The same history is readable at GET /v1/procurement/purchase-request-events.

Find the open requests for a product

Before you delete a product for good, check which purchase requests still name it. A request that names a deleted product would turn into an order line that points at nothing. GET /v1/procurement/open-request-items lists the lines of requests that are still going somewhere, meaning waiting for approval, or approved and not yet ordered:

List the open lines for a product
curl -s "https://api.revenexx.com/v1/procurement/open-request-items?product_id=<PRODUCT_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
  • product_id is required and must be a UUID. Without it, or with a value that is not a UUID, the call answers 400.
  • status sets which request states count, as a comma-separated list of pending, approved, ordered, declined and cancelled. The default is pending,approved, the requests still going somewhere; add ordered, declined or cancelled to see finished ones too. Any other value answers 400.
  • Each item carries its request's request_number and request_status, and the list is ordered by request number, then position. An item's purchase_request_id is the request to open.
  • limit (default 50, max 200) and offset (default 0) page through the list. page.hasMore tells you whether another page exists.

No item in items means no request in the counted states names the product. The call is not narrowed for a storefront contact, so it returns every buyer's lines to any caller with the key: run it from your server, as with the other calls in this guide. Deleting a product for good is refused with 409 while a line of an open purchase request holds it, and this call is how you find those lines. Besides 400, it can answer 401 (missing or invalid authentication), 403 (not permitted), 429 (rate limit exceeded) or 502 (an upstream service failed).

Troubleshooting

  • Every cart is ordered directly, even with rules. Check that the rules are active, and that a per-cost-center rule's cost_center_id is the center the lines book to. A scoped rule is skipped for a cart that books nothing to its center.
  • A requester's cart goes through without approval. Nothing requires approval for a role on its own: add a contactMissingPermission rule with place-order, or a contactHasRole rule for requester.
  • 409 with outcome: "no_budget". The center is monetary and has no active budget. Create one, or check that the current period's budget is active after the rollover.
  • 409 with outcome: "currency_mismatch". The cart's currency differs from the cost center's, or from the buyer's contact limit. Nothing converts. Always send currency, set to the cost center's currency.
  • 400 unknown cost centre code. The line's cost_center is not a code on this tenant, or it is excluded for this punchout session. Offer only what POST /v1/cost-centers/usable returns.
  • 409 on approve: an earlier approval in this cost centre is still pending. The chain is in order. Decide the lower-sequence approval first.
  • 403 on approve or decline. The call was not made as the approving contact, or that contact is not approver or admin.
  • The request is approved but has no order. Placing the order failed after the decision, and the decision stands. The platform retries every hour (as of 2026-10). To complete it at once, call POST /v1/procurement/purchase-requests/<ID>/order, which is safe to repeat.

Where to go next

Was this page helpful?