Set up purchase approvals and cost-center budgets
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:
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:
- Reads the buyer. It takes the contact's role from the Customers app, and each line's cost center from the cart.
- Evaluates every active approval rule. Each rule has a condition, an effect and an approver.
- 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.
- 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.
| Role | place-order | approve-order | Also |
|---|---|---|---|
requester | No | No | Can fill a cart and submit it |
buyer | Yes | No | |
approver | Yes | Yes | |
admin | Yes | Yes | May 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:
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.
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_idis the person responsible for this cost center. An approval rule whose approver isdefaultresolves to this contact (see step 5), so give this contact theapproveroradminrole, or nobody but an admin override can decide those approvals.currencydefaults 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_typeismonetary(the default) ortracking_only. A tracking-only center records which cost center a line was booked to, but holds no budget. You cannot changebudget_typeafter 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.
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_valueis the amount. The budget takes its currency from the cost center.period_startandperiod_lengthset the period, in whole months. The end date is derived for you and returned asperiod_end.sequencedecides 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: truestarts a new period when this one ends. It requiresperiod_length.takeoverdecides what the new period inherits from the old one.never(the default) carries nothing over.alwayscarries the whole unspent amount.limitedcarries the unspent amount up tolimit. 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.
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.
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:
type | parameters |
|---|---|
contact | { "contact_ids": [...] } |
role | { "roles": [...] } |
product | { "product_ids": [...] } |
category | { "category_ids": [...] } |
catalog | { "catalog_ids": [...] } |
punchout | { "mode": "only" | "except", "account_codes": [...] } |
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):
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:
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.
condition | condition_parameters | True when |
|---|---|---|
always | none | Every 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. |
availableBudget | none | The cost center's active budgets do not cover the amount. Needs cost_center_id on the rule. |
personalLimit | none | The amount is above the buyer's contact limit from step 4 |
Effects
effect | What happens |
|---|---|
pendingOrder | A pending approval is created for this rule's approver. |
prevent | The 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. |
sendEmail | Adds 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 inapprover_contact_id.role— anyone holding the role inapprover_role.default— the cost center'saccountable_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:
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": { "…": "…" }
}'
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:
| Status | outcome | Body | Show the buyer |
|---|---|---|---|
201 | direct | order | The order confirmation |
201 | approval | purchase_request, pending_approvals | "Sent for approval", with purchase_request.number |
409 | prevent | prevent.rule_id, prevent.reason | prevent.reason |
409 | no_budget | no_budget.cost_center_code, no_budget.reason | That the cost center has no budget |
409 | currency_mismatch | currency_mismatch.requested, .actual | That the cart's currency does not match the cost center |
409 | decided | purchase_request, decided.status | That 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, thenordered, and itsorder_idpoints 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:
status | Means | Next |
|---|---|---|
pending | Waiting for approvals | approved, declined, cancelled |
approved | Every approval given, order being placed | ordered |
ordered | The order exists, see order_id | final |
declined | An approver declined | final |
cancelled | Cancelled by the buyer, an admin contact or the merchant | final |
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:
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_idis required and must be a UUID. Without it, or with a value that is not a UUID, the call answers400.statussets which request states count, as a comma-separated list ofpending,approved,ordered,declinedandcancelled. The default ispending,approved, the requests still going somewhere; addordered,declinedorcancelledto see finished ones too. Any other value answers400.- Each item carries its request's
request_numberandrequest_status, and the list is ordered by request number, then position. An item'spurchase_request_idis the request to open. limit(default 50, max 200) andoffset(default 0) page through the list.page.hasMoretells 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'scost_center_idis 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
contactMissingPermissionrule withplace-order, or acontactHasRolerule forrequester. 409withoutcome: "no_budget". The center is monetary and has no active budget. Create one, or check that the current period's budget isactiveafter the rollover.409withoutcome: "currency_mismatch". The cart'scurrencydiffers from the cost center's, or from the buyer's contact limit. Nothing converts. Always sendcurrency, set to the cost center's currency.400 unknown cost centre code. The line'scost_centeris not a code on this tenant, or it is excluded for this punchout session. Offer only whatPOST /v1/cost-centers/usablereturns.409on approve: an earlier approval in this cost centre is still pending. The chain is in order. Decide the lower-sequenceapproval first.403on approve or decline. The call was not made as the approving contact, or that contact is notapproveroradmin.- The request is
approvedbut 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, callPOST /v1/procurement/purchase-requests/<ID>/order, which is safe to repeat.
Where to go next
- Roles and permissions — the roles contacts hold, and what they grant in the other commerce apps.
- Sign a buyer in — the server-held session your checkout takes
contact_idfrom. - Orders — the order a purchase request becomes.
- Set up B2B pricing — the prices on the lines you submit.
- Turn an accepted quote into an order — the other way a B2B order waits for someone: a buyer and a seller agree a price first.
- Connect a procurement system by punchout — the buyer sessions a
punchoutrestriction applies to.