Cost centers
The Cost centers App holds the money side of B2B procurement. A cost centre is a booking account a buyer's spend is charged to. Each monetary centre draws on one or more budgets, and every movement of a budget is written to a read-only ledger. The App also keeps the restrictions that decide who may book to a centre, and contact limits that cap what one person may spend. It has no approval concept: a procurement flow asks this App whether a spend is allowed, holds the money while a request waits, and spends it once an order exists.
For the step-by-step flow, see Set up purchase approvals and cost-center budgets. For the other Procurement Apps, see Procurement.
Base path: https://api.revenexx.com/v1/cost-centers. The calls take the usual X-Revenexx-Tenant and X-Revenexx-Api-Key headers.
Scopes
cost_centers.*, with an underscore although the URL has a hyphen, covers every call on this page, writes included. See Which scope covers which app.
Amounts and currency
currencyis an optional ISO 4217 code on every money call. Left out, the amount is read in the cost centre's own currency, or the personal limit's. A different code is refused (409 currency_mismatch).- A movement (
reserve,reserve/adjust,commit,consume) on a monetary centre with no active budget is refused (409 no_active_budget).evaluatereports it as not satisfied, andusableleaves the centre out. - A tracking-only centre (
budget_type: tracking_only) records spend against no money.reserve,reserve/adjust,confirm,withdraw,commitandreleaseskip it and list it underskipped.evaluateanswerssatisfied: truewithtracking_only: true.consumeand creating a budget on it are refused (409 tracking_only). budget_contended(409) means a budget kept changing while the App was moving money, through every retry. Nothing was written for that budget, but a call over several budgets or allocations is not one transaction: the ones before it may already be written. Read the ledger for thepurchase_request_idororder_idbefore you send it again, and repair a partial reservation withreserve/adjustand the whole target shape.
Cost centers
| Operation | Path | Use |
|---|---|---|
| List | GET /v1/cost-centers/cost-centers | The centres. Filter with external_id and punchout_account_code. |
| Create | POST /v1/cost-centers/cost-centers | Add a centre. Needs code and name. |
| Read | GET /v1/cost-centers/cost-centers/{id} | One centre. |
| Update | PUT /v1/cost-centers/cost-centers/{id} | Edit a centre. |
| Delete | DELETE /v1/cost-centers/cost-centers/{id} | Refused once the centre has held any budget, even a discarded draft. Archive it instead. |
| Archive | POST /v1/cost-centers/cost-centers/{id}/archive | Sets the centre inactive, so usable no longer offers it. Idempotent. The way out for a centre that once held a budget. |
| Consume | POST /v1/cost-centers/cost-centers/{id}/consume | Spends across the centre's budgets in sequence. Needs amount. |
- Key fields:
code(unique per tenant),name,organization_id(nulloffers the centre to every organization),accountable_contact_id,budget_type,currency,active,external_idandmetadata. budget_typeismonetary(the default) ortracking_only. It is set on create and cannot change afterwards: a different value on update is refused with409 budget_type_immutable.punchout_account_codeon the list leaves out the centres that a punchout restriction keeps from that account. Thepage.totalcounts only what is returned.- Consume carries no idempotency key, so a retried call spends again.
Restrictions
| Operation | Path | Use |
|---|---|---|
| List | GET /v1/cost-centers/restrictions | The restrictions. |
| Create | POST /v1/cost-centers/restrictions | Add a restriction. Needs cost_center_id, type and parameters. |
| Read | GET /v1/cost-centers/restrictions/{id} | One restriction. |
| Update | PUT /v1/cost-centers/restrictions/{id} | Edit a restriction. |
| Delete | DELETE /v1/cost-centers/restrictions/{id} | Removes the restriction. |
- Types:
contact,role,product,category,catalogandpunchout. A centre with no restriction is open to everyone. - Across types the restrictions must all pass. Within a type a match on any row is enough, except for
punchout, where anexceptrow denies and a denial outranks a grant. - A
punchoutrestriction needs a valid mode and account codes. Otherwise it is refused with400 punchout_restriction_invalid.
Budgets
| Operation | Path | Use |
|---|---|---|
| List | GET /v1/cost-centers/budgets | The budgets. Discarded drafts are left out unless discarded=include or only. |
| Create | POST /v1/cost-centers/budgets | Add a budget to a monetary centre. Needs cost_center_id and name. initial_value funds it: the ledger lifts the balance from 0 to that value at create. active defaults to true. |
| Read | GET /v1/cost-centers/budgets/{id} | One budget. |
| Update | PUT /v1/cost-centers/budgets/{id} | Edit a budget. A period change on a budget in use is refused (409 budget_in_use). |
| Adjust | POST /v1/cost-centers/budgets/{id}/adjust | A manual change: exactly one of a delta amount or an absolute target, plus the required actor. |
| Discard | POST /v1/cost-centers/budgets/{id}/discard | Sets aside a draft budget that has never moved. |
| Roll over | POST /v1/cost-centers/budgets/rollover/run | Runs one rollover pass. A rolled period is skipped. |
- Key fields:
initial_value,remaining_value(read-only, a cache of the ledger),active,sequence,period_length,period_start,recurringandtakeover. - There is no delete. A draft is discarded instead. Once anything has moved, a discard is refused (
409 budget_in_use). - Rollover also runs on a daily schedule. Its answer carries
ran_for, therolledbudgets withrolled_count,stale, andskipped, which is a count, not a list.
Budget changes
| Operation | Path | Use |
|---|---|---|
| List | GET /v1/cost-centers/budget-changes | The ledger, paged. |
| Read | GET /v1/cost-centers/budget-changes/{id} | One ledger row. |
The ledger is read-only. Each row holds old_value, new_value, a signed amount and a reason. The reasons are initialized, reservation, order, withdrawal, manual, rollover, lapsed, cancellation and discarded (as of 2026-10).
Contact limits
| Operation | Path | Use |
|---|---|---|
| List | GET /v1/cost-centers/contact-limits | The limits. |
| Create | POST /v1/cost-centers/contact-limits | Add a limit for a person. Needs contact_id. Send monetary_limit too: it defaults to 0, which reports every spend as not satisfied. |
| Read | GET /v1/cost-centers/contact-limits/{id} | One limit. |
| Update | PUT /v1/cost-centers/contact-limits/{id} | Edit a limit. |
| Delete | DELETE /v1/cost-centers/contact-limits/{id} | Removes the limit. |
There is one limit per contact. monetary_limit is a gate, not a wallet: evaluate compares against it and nothing is ever deducted from it. A contact with no limit is ungated. The limit carries its own optional currency and metadata. The field quantity_limit is not served.
The reservation flow
A purchase request moves through these calls. Reserve, reserve/adjust, confirm, withdraw, commit and release are idempotent on their key, so a retry after a lost response is answered from the ledger and moves nothing. The check is a read, so send one call per key at a time: two first attempts with the same key at the same moment can both apply. Consume is not idempotent. purchase_request_id and order_id are UUIDs.
These calls do not refuse a spend for lack of money: the last budget in sequence takes the remainder and may go negative. evaluate is the check that a budget or a personal limit covers the amount.
| Call | Path | Required body | What it does |
|---|---|---|---|
| Evaluate | POST /v1/cost-centers/evaluate | amount, and cost_center_id unless conditions is only personalLimit | Reports the facts behind a rule: availableBudget and personalLimit. Moves nothing. |
| Usable | POST /v1/cost-centers/usable | lines | Lists the centres each line may book to, with auto_select when only one fits. |
| Reserve | POST /v1/cost-centers/reserve | purchase_request_id (not with dry_run), allocations | Holds the amounts for a purchase request. Keyed on purchase_request_id. |
| Reshape | POST /v1/cost-centers/reserve/adjust | purchase_request_id, allocations | Moves a held reservation to its new shape. Send the whole shape. Only the difference moves. |
| Confirm | POST /v1/cost-centers/confirm | purchase_request_id | Records the held budget as an order. Name it with the optional order_id. Nothing moves. |
| Withdraw | POST /v1/cost-centers/withdraw | purchase_request_id | Credits back the net that is held. |
| Commit | POST /v1/cost-centers/commit | order_id (not with dry_run), allocations | Spends directly for an order with no request, keyed on order_id. |
| Release | POST /v1/cost-centers/release | order_id, key | Gives back what a cancelled order booked, once per key. |
- An allocation is
{ cost_center_id, amount }.reserve,reserve/adjust,commitandreleaseresolve every allocation before they write, so a refusal found in that check moves nothing. allocationsonconfirmandwithdrawis optional. Send the same shape as the reserve when the request was booked only to tracking-only centres, so it settles instead of being refused.allocationsonreleaseis optional. It gives back part of an order, capped per centre at what the order still has booked there.keyonreleaseis the idempotency key of this cancellation within the order. The caller chooses it: one per cancellation, or one per cancelled line under its own key. A repeat under the same key is answered with the stored result andidempotent: true.- Buyer fields on
usable(contact_id,roles,organization_id,punchout_account_code) and onevaluate(contact_id,punchout_account_code) are taken from the body as given. The App checks no buyer session, so the caller derives them on its server and never copies them from the browser. - Responses: a movement answers
purchase_request_idororder_id,status,idempotent,changes[](the ledger rows) andskipped[]; a repeatedconfirmorwithdrawanswers withoutchanges[].releaseaddsreleased[]({ cost_center_id, amount }). A dry run addsdry_run: true.evaluateanswersresults[], one per condition, each withdriver(availableBudgetorpersonalLimit),currency,availableorlimit,requiredandsatisfied(a tracking-only centre'savailableBudgetresult hassatisfied: trueandtracking_only: trueinstead of amounts), plus a top-levelsatisfied. That field reports the fact, not the rule: a procurement rule fires on!satisfied. dry_run: trueonreserveandcommitruns the same checks, writes nothing and claims no key. The purchase request or order id can then be left out.skippedlists the centres a call passed over: tracking-only centres, and onreleasea closed budget period, which receives a zero entry instead of money.- Order of states for a request: reserved, then confirmed or withdrawn. A confirmed request cannot be withdrawn, a withdrawn one cannot be confirmed, and neither can be adjusted (
409).
Example: reserve budget for a purchase request
curl -X POST https://api.revenexx.com/v1/cost-centers/reserve \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{
"purchase_request_id": "<PURCHASE_REQUEST_ID>",
"allocations": [
{ "cost_center_id": "<COST_CENTER_ID>", "amount": 1200 }
]
}'
Vocabularies
| Operation | Path | Use |
|---|---|---|
| Index | GET /v1/cost-centers/vocabularies | The names of the value sets, without their values. |
| One vocabulary | GET /v1/cost-centers/vocabularies/{name} | Every value of one set, with the order the store enforces. An unknown name is answered 404. |
The App serves four sets: budget-types, restriction-types, punchout-modes and budget-change-reasons (as of 2026-10). Read them instead of keeping a copy of the list.