Cost centers

[object Object]

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

  • currency is 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). evaluate reports it as not satisfied, and usable leaves the centre out.
  • A tracking-only centre (budget_type: tracking_only) records spend against no money. reserve, reserve/adjust, confirm, withdraw, commit and release skip it and list it under skipped. evaluate answers satisfied: true with tracking_only: true. consume and 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 the purchase_request_id or order_id before you send it again, and repair a partial reservation with reserve/adjust and the whole target shape.

Cost centers

OperationPathUse
ListGET /v1/cost-centers/cost-centersThe centres. Filter with external_id and punchout_account_code.
CreatePOST /v1/cost-centers/cost-centersAdd a centre. Needs code and name.
ReadGET /v1/cost-centers/cost-centers/{id}One centre.
UpdatePUT /v1/cost-centers/cost-centers/{id}Edit a centre.
DeleteDELETE /v1/cost-centers/cost-centers/{id}Refused once the centre has held any budget, even a discarded draft. Archive it instead.
ArchivePOST /v1/cost-centers/cost-centers/{id}/archiveSets the centre inactive, so usable no longer offers it. Idempotent. The way out for a centre that once held a budget.
ConsumePOST /v1/cost-centers/cost-centers/{id}/consumeSpends across the centre's budgets in sequence. Needs amount.
  • Key fields: code (unique per tenant), name, organization_id (null offers the centre to every organization), accountable_contact_id, budget_type, currency, active, external_id and metadata.
  • budget_type is monetary (the default) or tracking_only. It is set on create and cannot change afterwards: a different value on update is refused with 409 budget_type_immutable.
  • punchout_account_code on the list leaves out the centres that a punchout restriction keeps from that account. The page.total counts only what is returned.
  • Consume carries no idempotency key, so a retried call spends again.

Restrictions

OperationPathUse
ListGET /v1/cost-centers/restrictionsThe restrictions.
CreatePOST /v1/cost-centers/restrictionsAdd a restriction. Needs cost_center_id, type and parameters.
ReadGET /v1/cost-centers/restrictions/{id}One restriction.
UpdatePUT /v1/cost-centers/restrictions/{id}Edit a restriction.
DeleteDELETE /v1/cost-centers/restrictions/{id}Removes the restriction.
  • Types: contact, role, product, category, catalog and punchout. 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 an except row denies and a denial outranks a grant.
  • A punchout restriction needs a valid mode and account codes. Otherwise it is refused with 400 punchout_restriction_invalid.

Budgets

OperationPathUse
ListGET /v1/cost-centers/budgetsThe budgets. Discarded drafts are left out unless discarded=include or only.
CreatePOST /v1/cost-centers/budgetsAdd 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.
ReadGET /v1/cost-centers/budgets/{id}One budget.
UpdatePUT /v1/cost-centers/budgets/{id}Edit a budget. A period change on a budget in use is refused (409 budget_in_use).
AdjustPOST /v1/cost-centers/budgets/{id}/adjustA manual change: exactly one of a delta amount or an absolute target, plus the required actor.
DiscardPOST /v1/cost-centers/budgets/{id}/discardSets aside a draft budget that has never moved.
Roll overPOST /v1/cost-centers/budgets/rollover/runRuns 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, recurring and takeover.
  • 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, the rolled budgets with rolled_count, stale, and skipped, which is a count, not a list.

Budget changes

OperationPathUse
ListGET /v1/cost-centers/budget-changesThe ledger, paged.
ReadGET /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

OperationPathUse
ListGET /v1/cost-centers/contact-limitsThe limits.
CreatePOST /v1/cost-centers/contact-limitsAdd a limit for a person. Needs contact_id. Send monetary_limit too: it defaults to 0, which reports every spend as not satisfied.
ReadGET /v1/cost-centers/contact-limits/{id}One limit.
UpdatePUT /v1/cost-centers/contact-limits/{id}Edit a limit.
DeleteDELETE /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.

CallPathRequired bodyWhat it does
EvaluatePOST /v1/cost-centers/evaluateamount, and cost_center_id unless conditions is only personalLimitReports the facts behind a rule: availableBudget and personalLimit. Moves nothing.
UsablePOST /v1/cost-centers/usablelinesLists the centres each line may book to, with auto_select when only one fits.
ReservePOST /v1/cost-centers/reservepurchase_request_id (not with dry_run), allocationsHolds the amounts for a purchase request. Keyed on purchase_request_id.
ReshapePOST /v1/cost-centers/reserve/adjustpurchase_request_id, allocationsMoves a held reservation to its new shape. Send the whole shape. Only the difference moves.
ConfirmPOST /v1/cost-centers/confirmpurchase_request_idRecords the held budget as an order. Name it with the optional order_id. Nothing moves.
WithdrawPOST /v1/cost-centers/withdrawpurchase_request_idCredits back the net that is held.
CommitPOST /v1/cost-centers/commitorder_id (not with dry_run), allocationsSpends directly for an order with no request, keyed on order_id.
ReleasePOST /v1/cost-centers/releaseorder_id, keyGives back what a cancelled order booked, once per key.
  • An allocation is { cost_center_id, amount }. reserve, reserve/adjust, commit and release resolve every allocation before they write, so a refusal found in that check moves nothing.
  • allocations on confirm and withdraw is 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.
  • allocations on release is optional. It gives back part of an order, capped per centre at what the order still has booked there.
  • key on release is 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 and idempotent: true.
  • Buyer fields on usable (contact_id, roles, organization_id, punchout_account_code) and on evaluate (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_id or order_id, status, idempotent, changes[] (the ledger rows) and skipped[]; a repeated confirm or withdraw answers without changes[]. release adds released[] ({ cost_center_id, amount }). A dry run adds dry_run: true. evaluate answers results[], one per condition, each with driver (availableBudget or personalLimit), currency, available or limit, required and satisfied (a tracking-only centre's availableBudget result has satisfied: true and tracking_only: true instead of amounts), plus a top-level satisfied. That field reports the fact, not the rule: a procurement rule fires on !satisfied.
  • dry_run: true on reserve and commit runs the same checks, writes nothing and claims no key. The purchase request or order id can then be left out.
  • skipped lists the centres a call passed over: tracking-only centres, and on release a 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

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

OperationPathUse
IndexGET /v1/cost-centers/vocabulariesThe names of the value sets, without their values.
One vocabularyGET /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.

Was this page helpful?