Set up B2B pricing
Pricing on this platform is a set of price lists and one call that reads them. This page builds the whole arrangement over the API: a standard list with list prices, a quantity ladder, a contract list for one buyer, a second market in its own currency, a bulk change you preview before it writes, and the prices.resolve call your storefront asks on every listing and every cart.
This is for developers and integrators wiring B2B pricing into a storefront, an ERP sync, or their own service. You need a tenant with the Pricing core app, an API key with the prices.* scope (and markets.* for the market in step 4, see which scope covers which app), and a handful of SKUs already in the catalog — Import a BMECAT catalog gets you those. New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Every call below goes through the API gateway with the same two headers:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The examples capture ids with jq so each step can use what the previous one created.
How a price is decided
One thing shapes every step below, so read it before you write a list: scope beats priority, and priority beats the default flag.
1) contact_id the most specific scope there is
2) organization_id buyers of one organization
3) channel_id one sales channel
4) no scope open to everyone
within one of those groups: higher priority first
is_default sorts LAST, so a default list prices only what nothing else did
An organization-scoped list at priority: 0 still wins over an open list at priority: 100. Priority is a tie-break inside one group, never a way to jump groups.
Currency and validity are not layers in that ladder — they are filters applied before it. Only lists in the currency the call resolves in are candidates at all, and nothing is ever converted. Same for valid_from / valid_until: outside its window, a list is not a candidate rather than a loser.
1. Set list prices on the catalog
Prices live on a price list, never on the product record. Every tenant is seeded with one open list when the Pricing app is installed, so you can start writing entries immediately; POST /v1/prices/lists/defaults is the idempotent re-run for a tenant whose standard list was deleted.
Create your own list when you want to state its currency and tax basis explicitly rather than inherit the tenant defaults:
LIST_ID=$(curl -s -X POST https://api.revenexx.com/v1/prices/lists \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "standard-eur",
"name": "Standard prices",
"currency": "EUR",
"tax_basis": "net"
}' | jq -r .id)
code and name are the only required fields. code is what every import and integration addresses the list by, so pick it once and keep it. tax_basis is the one field worth stating rather than inheriting: it says whether the amounts in this list are net or gross, and a price without it is ambiguous. Omit it and the list inherits the tenant's tax_inclusive_default.
Do not set is_default here. The flag is a single answer for the whole tenant, and writing it on a create leaves two lists claiming it, which hands the tie to row order. Move it with the route that promotes one list and demotes the incumbent in the same call:
curl -s -X POST https://api.revenexx.com/v1/prices/lists/$LIST_ID/make-default \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY"
It writes only the rows whose flag was wrong, so running it against a tenant already in that state is a no-op.
Now load prices. entries/bulk upserts by default, so re-running an ERP export corrects prices instead of duplicating the book. That holds for a list of up to 10,000 entries (as of 2026-09), loaded in calls of at most 5000 rows each; split a larger book across several lists with the same scope, each SKU in exactly one of them:
curl -s -X POST https://api.revenexx.com/v1/prices/lists/$LIST_ID/entries/bulk \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{ "sku": "217005000P20", "unit_price": 24.90, "unit": "pcs" },
{ "sku": "217005000P40", "unit_price": 31.50, "unit": "pcs" }
]
}'
Amounts are decimals in major units — 24.90 is twenty-four euros ninety, never cents. The answer reports created and updated so a sync can log what actually moved. A call takes at most 5000 rows, so send a book of up to 10,000 entries as two calls to the same list.
unit_price defaults to 0. An entry written without one is a free product, not an unpriced one. If you mean "ask us", say so explicitly with price_type: "on_request" — that stops resolution for the item on this list and answers price-on-request instead of a number.
2. Add a quantity ladder
A quantity ladder (Staffelpreis) is not a separate object: it is the set of entries sharing one item's identity, each with its own quantity_min. Resolution picks the rung with the highest quantity_min at or below the requested quantity.
Writing the rungs one at a time works, but entries/ladder does the whole ladder in one call, compounding the discount down the tiers:
curl -s -X POST https://api.revenexx.com/v1/prices/lists/$LIST_ID/entries/ladder \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sku": "217005000P20",
"base_price": 24.90,
"quantities": [1, 10, 50],
"discount_percent": 5
}'
Ladders set the price; to reduce a cart that is already priced, use promotions. discount_percent compounds down the ladder: each tier takes the percentage off the tier above it, not off base_price. The generated prices are rounded to the tenant's precision and snapped to its bulk_adjust_rounding ending, unless you pass rounding yourself — the same treatment a bulk adjust gives.
replace defaults to true: the item's existing entries in this list are removed first, so what comes back is the ladder rather than the old rungs plus the new ones. The answer is the full set of generated rungs, ascending.
3. Add contract pricing for one buyer
B2B buyers rarely pay list. You model a negotiated price as a second list whose scope names the buyer, not as an edit to the product or to the standard list:
CONTRACT_LIST_ID=$(curl -s -X POST https://api.revenexx.com/v1/prices/lists \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "wholesale-eur",
"name": "Wholesale tier EUR",
"currency": "EUR",
"tax_basis": "net",
"organization_id": "<ORGANIZATION_ID>",
"priority": 10,
"requires_auth": true
}' | jq -r .id)
Three scopes are available, and you set one of them:
| Field | Applies to | Beats |
|---|---|---|
contact_id | one contact | everything |
organization_id | every buyer of one organization | channel and open lists |
channel_id | one sales channel | open lists |
Leave all three empty and the list is open to everyone. There is no customer group object to point at — an organization is the group-shaped scope, and a tier several organizations share is a channel or an open list with a higher priority.
requires_auth: true is what keeps a negotiated tier off an anonymous storefront: the list resolves only for a call that carries a contact or organization context, and an anonymous resolve never matches it.
Add its entries exactly as in step 1, against $CONTRACT_LIST_ID. You only need the SKUs whose price differs — anything this list does not price falls through to the standard list underneath.
4. Price a second market
A market is a segment inside the tenant: a country, a brand, a storefront. Create it with a code, because the code — not the id — is what the market scope is keyed on everywhere else:
MARKET_ID=$(curl -s -X POST https://api.revenexx.com/v1/markets \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "ch", "name": "Switzerland", "currency": "CHF" }' | jq -r .id)
curl -s -X POST https://api.revenexx.com/v1/markets/$MARKET_ID/currencies \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "CHF", "is_default": true }'
A market reaches pricing three ways.
Through visibility. A price list is scopeable by market, like the platform's other market-aware records: a list assigned to markets is a candidate only in those markets (see Price lists). Scoping is open by default, so a list assigned to no market (every list in this guide so far) is a candidate everywhere. This is separate from the three buyer scopes in step 3, which decide whose price a list is; market visibility decides where it applies.
Through currency. Only lists in the currency the call resolves in are candidates, and nothing is converted. So "prices for Switzerland" is a price list with "currency": "CHF" — created exactly like the one in step 1 — and it is reached because a buyer in the ch market resolves in CHF, not because the list names the market.
Through tax. Tax classes live on the market (/v1/markets/{market_id}/tax_classes), and they are what turns a stored amount into the net/gross pair a checkout needs. Without a market in context, resolve still answers prices, but tax.resolved is false and the tax fields come back null — unknown, not zero.
5. Change prices without surprises
entries/adjust moves every priced entry of a list at once, either by percent or by a flat amount. Send dry_run: true and it writes nothing and answers the same preview:
curl -s -X POST https://api.revenexx.com/v1/prices/lists/$LIST_ID/entries/adjust \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "percent": -5, "sku_prefix": "217005", "rounding": "ending_99", "dry_run": true }'
The answer reports matched (how many entries the filter selected), updated (0 on a dry run) and a preview array of the first 50 before/after pairs, with preview_truncated telling you whether that was a sample. Drop dry_run to commit; a price that came out unchanged is not rewritten, so updated_at stays a real marker of when a price moved.
Entries marked on_request are never touched — a percentage of "ask us" is not a number — and entries identified only by product_id never match a sku_prefix.
Building an approval step. There is no approval queue for price changes, so the gate is yours to build, and the dry run is what it is built from. Have the job that computes a change post it with dry_run: true and store the preview; have a second, separately triggered job replay it without dry_run once a human has signed off. An Integration Studio workflow with a manual trigger is one way to run the second half; a route in your own service is another. Either way the platform enforces nothing here — the two-step split is the control.
A practical pattern: let in-corridor changes apply straight away, and require the review only when a change crosses a threshold you define — a cut deeper than X %, or any change to a key account's list.
6. Verify the price a buyer sees
prices.resolve is the live price call, and the only route that answers a price rather than configuring one. It takes the buyer context and up to 200 items, and a storefront asks it on every listing, every product page and every cart:
curl -s -X POST https://api.revenexx.com/v1/prices/resolve \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"organization_id": "<ORGANIZATION_ID>",
"currency": "EUR",
"items": [
{ "sku": "217005000P20", "quantity": 25 },
{ "sku": "217005000P40", "quantity": 1 }
]
}'
The organization_id is the same one the contract list was scoped to in step 3, so for the SKUs you priced on that list the answer should name wholesale-eur rather than standard-eur. Anything you did not price there falls through to the standard list.
Name the market with the X-Revenexx-Market header, which carries the market slug — preferred over the market_id uuid the body also accepts. Adding -H "X-Revenexx-Market: ch" resolves the same items in the Swiss market, which means in CHF: only CHF lists are candidates, so until you have created one, every item comes back on_request with not_priced. That is the currency filter from step 4 doing its job, not a bug.
Read three things in the answer before you believe it:
prices[].price_listnames the list that priced the item. This is how you check the ladder from step 2 resolved the way you meant — if a contract price is not showing, this tells you which list won instead.prices[].tiersis the full quantity ladder the winning list holds for the item, ascending. It is what a product page renders as a tier table, so you do not ask again per quantity.basisstates the policy the answer was computed under:currency_source(whether the currency came from the request, the market, the tenant setting or the shipped fallback),price_precisionandrounding_mode(the decimals and rounding of every derived amount — see Precision and rounding), andprice_list_priority_tiebreakfor genuine ties.
An item that could not be priced is still in the answer, with on_request: true and an on_request_reason — never missing, and never 0.
Troubleshooting
- A buyer still sees list price. The call carried no
contact_idororganization_id, so nothing more specific was a candidate. Checkprices[].price_listin the answer: it names the list that actually won. - Everything comes back
on_request. Readon_request_reason.anonymous_deniedmeans the tenant hides prices from anonymous buyers;no_identitymeans the item named neitherskunorproduct_id;not_pricedmeans no candidate list holds the item;on_request_entrymeans a list deliberately marks it ask-us. - The contract list never wins. If it has
requires_auth: true, an anonymous resolve skips it entirely. Otherwise check itscurrencymatches what the call resolves in, and thatstatusisactive— only active lists are candidates. - Prices are right, tax is null.
tax.resolvedisfalseandtax.reasonsays why:market_required,no_markets,no_tax_classesorlookup_failed. Null is unknown, not zero — a checkout must not treat it as no tax. - The wrong currency, or nothing at all, comes back. Nothing converts. Check
basis.currency_sourceto see where the currency was taken from, then confirm a list exists in that currency. - A second import duplicated the ladder.
entries/bulkwas called withmode: "append". The defaultupsertmatches on the item identity andquantity_min, and rewrites the rung in place.
Where to go next
- Run promotions and voucher codes — reduce a priced cart with an offer or a code.
- Market Concept — how market scoping works across the platform, not just in pricing.
- API usage — headers, request ids, errors and pagination for every call above.
- API Explorer — the full Pricing and Markets surface, with every field.
- Integration Studio — scheduling and gating the jobs that write prices.