Price lists
Everything else in the prices app configures prices. POST /v1/prices/resolve is the one route that answers them for a buyer, and a storefront reaches it on every listing, every product page and every cart. POST /v1/prices/check explains one of those answers for whoever configures prices; see Explaining a price.
Base path: https://api.revenexx.com/v1/prices.
The list
curl "https://api.revenexx.com/v1/prices/lists?limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
| Field | Meaning |
|---|---|
code | Unique per tenant. What an import, an ERP export and every integration addresses it by, and what the default_price_list_code setting names. |
name, labels, description | Operator-facing. description is never shown to a buyer. |
currency | ISO 4217, and the currency of every amount in the list — entries carry none of their own. |
tax_basis | net or gross. The one fact a price cannot be without. Null inherits the market's answer. |
status | Only active lists are candidates. inactive retires a list without deleting its prices. |
valid_from, valid_until | The whole list's validity window. Outside it, the list is not a candidate at all. |
contact_id | Buyer scope: this one contact. The most specific scope there is. |
organization_id | Buyer scope: buyers of this organization. |
channel_id | Buyer scope: this sales channel. |
requires_auth | When true, the list resolves only for a buyer with a contact or organization context. An anonymous resolve never matches it. |
priority | Tie-break within one specificity group, higher first. |
is_default | The fallback list. Within its group it deliberately sorts last. |
metadata | Free-form, never read by this app. |
tax_included is a legacy mirror of tax_basis. Only true is read as a statement (gross); false is the column default and is not read as anybody having chosen net.
POST /v1/prices/lists/defaults seeds the standard list. POST /v1/prices/lists/{id}/make-default moves the default flag in one operation.
One list
GET /v1/prices/lists/{id} reads the list header only, with the fields above. Its entries have their own route, see Reading and changing entries. The read is not limited to the active market: a list that is invisible in the request's market still comes back, and so does a list in the trash.
curl "https://api.revenexx.com/v1/prices/lists/$LIST_ID" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
PUT /v1/prices/lists/{id} is a partial update: send only the fields that change, and omitted fields keep their value. An empty body answers 400. There is no draft and no publish step; the next resolve call reads what the write stored.
| Field | On a PUT |
|---|---|
code | Changeable. A code another list holds answers 409: unique_violation for a live list, in_trash for one in the trash. |
currency | Changeable only while no entry of the list has a non-zero unit_price. Otherwise 409 with currency_immutable: the amounts carry no currency of their own, so the change is refused rather than converted. |
is_default | Restating the stored value is fine. A change answers 400 with default_via_make_default. Move the flag with POST /v1/prices/lists/{list_id}/make-default. |
id, created_at, updated_at, deleted_at, deleted_by, deleted_by_id, trash_batch, purge_at, purge_blocked_reason | Written by the platform. Sending a different value answers 400 with server_owned_field. |
The API reference still says a currency change re-denominates the whole list without converting; the API refuses it with 409 currency_immutable once an entry holds an amount, as described here.
A valid_until before valid_from answers 400 with validity_inverted, and a value that is not a timestamp answers 400 with invalid_validity.
curl -X PUT "https://api.revenexx.com/v1/prices/lists/$LIST_ID" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"name": "<new name>"}'
400: the path id is not a uuid, the body is empty, or a rule above fails.403withbuyer_not_permitted: the request acts for a contact.404: no price list with that id in this tenant.409: the code is taken, the list is in the trash (in_trash, restore it first), or the currency rule above applies.
Restore a list and empty the trash
The first DELETE /v1/prices/lists/{list_id} moves a list to the trash, where it keeps its entries and prices nothing; a second delete removes it for good. The default list cannot be deleted. A list in the trash comes back with POST /v1/prices/lists/{list_id}/restore. It comes back as it was, entries included, and from the next resolve call it prices again. Lists that were trashed together with it come back too. The call takes the same right as deleting, and the body is {}.
curl -X POST "https://api.revenexx.com/v1/prices/lists/{list_id}/restore" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{}'
The 200 answer is {id, status: "restored", restored: [...]}. restored lists every list this call brought back, including the lists trashed with it. A list that is not in the trash answers 409 with not_in_trash. An id that is not a uuid answers 400 with invalid_value, and an unknown list answers 404.
POST /v1/prices/lists/trash/empty deletes every price list in the trash for good, each one on its own, exactly as a second delete would. A list that something still needs stays in the trash with its reason, for example the default list. Send {} as the body. The 200 answer is {deleted: [...], blocked: [{id, code, reason}]}, so a partial success is visible. Nothing is recoverable afterwards.
Both calls answer 403 with buyer_not_permitted when the request acts for a contact. Price configuration is not reachable on a buyer's behalf.
Reading and filtering the trash. GET /v1/prices/lists takes trashed: exclude (the default) leaves trashed lists out, only returns the trash alone and include returns the live and the trashed lists together. Each list carries the fields below; the list call takes trashed as its only trash filter. A list is trashed as one record: its entries are part of it rather than records of their own.
| Field | What it holds |
|---|---|
deleted_at | When the list went to the trash; null for a live list. A list in the trash prices nothing. |
deleted_by | Who moved it there, as a display name (their e-mail), or system. Attribution for the trash view, not an identity to act on. |
deleted_by_id | The platform subject of that person; null when the system did. |
purge_at | When the daily purge deletes it for good: the trash time plus the tenant's trash retention (90 days unless the tenant set another). |
trash_batch | The trash action the list went in with. Restoring brings back every row that carries it. |
purge_blocked_reason | Why the purge could not delete the list, in a sentence; null when nothing blocked it. The list stays in the trash until the reason is resolved. |
The platform sets these fields when a list is deleted and clears them when it is restored. They are information to read, so do not send them in a write.
The first delete fires price_list.trashed and a restore price_list.restored. price_list.deleted fires only once the record is deleted for good. See Deletes and the trash.
Copy a list
POST /v1/prices/lists/{list_id}/copy makes a new price list from this one, under a new code.
curl -X POST "https://api.revenexx.com/v1/prices/lists/{list_id}/copy" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"code":"dealer-de-2027","parts":["prices"]}'
codeis required and unique per tenant.partsis optional.pricescopies the source's entries, up to 10,000: a larger list is cut there without an error, so comparecopied_parts.priceswith the source's entry count. Absent or empty copies the list header only.- Kept from the source: name, labels, currency, tax basis, buyer scope, priority and validity window.
- Not kept: the sync columns (
external_id,external_refs,source_*), on the list and on each copied entry, so the copy is nobody's import, and the market assignment, so the copy starts global. - Starts
inactive, and never as the default. It prices nothing until you set itsstatustoactive, and a validity window copied from the source still applies. - On failure after the new list was written, the copy is deleted again, entries included (best effort).
The 201 answer is the new list, with copied_parts counting what came along, for example {"prices": 120}.
| Status | Code | Meaning |
|---|---|---|
400 | The path id is not a uuid. | |
400 | code_required | code is missing. |
400 | invalid_parts | parts names something a price list cannot copy. |
403 | buyer_not_permitted | The request acts for a contact. |
404 | No price list with that id in this tenant. | |
409 | code_taken | Another list holds the code. |
409 | in_trash | The source is in the trash, or the code belongs to a trashed list. Restore that one instead. |
The entries
An entry is one rung of one item's quantity ladder.
curl -X POST "https://api.revenexx.com/v1/prices/lists/{list_id}/entries" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"sku":"ACME-4711-BLK","quantity_min":1,"unit_price":19.90,"unit":"pcs"}'
| Field | Meaning |
|---|---|
product_id / sku | An entry needs one of the two. sku is matched exactly — never normalised or case-folded. |
quantity_min | Lowest quantity this price applies from. |
unit_price | Price for one unit of unit, in the list's currency and on the list's tax basis. A decimal in major units (19.90), never minor units. |
unit | Free text — pcs, m, kg, a packaging size. This app neither validates nor converts it. |
price_type | standard is a number. on_request is the explicit no-price marker. |
valid_from, valid_until | The entry's own window. This is how a promo price is expressed: a second rung for the same item and quantity, live only inside its window. |
The ladder for one item is the set of entries sharing its identity, and the rung with the highest quantity_min at or below the requested quantity wins. Below the first rung, the first rung applies.
Reading and changing entries
GET /v1/prices/lists/{list_id}/entries pages through the rungs of one list. An unknown list answers 404, not an empty page.
curl "https://api.revenexx.com/v1/prices/lists/$LIST_ID/entries?price_type=on_request&limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
- Filters are exact matches, not ranges or searches:
id,product_id,sku,price_type,quantity_min,unit_price,unit,price_quantity,price_quantity_unit,discount_percent,description,external_id,source_synced_at,valid_from,valid_until,created_atandupdated_at. skuis not a prefix and not case-insensitive. Prefix matching lives on the bulkadjustroute.product_idorskureturns the whole ladder of one item, the rungs a resolve answer was built from.price_type=on_requestlists the explicit no-price markers.- Dates have no from/until filter. Narrow a period by sorting with
orderand paging. - Paging:
limitdefaults to 50 and is capped at 200;offsetskips rows.page.hasMoresays whether another page exists. ordersorts by a column, ascolumn,column.ascorcolumn.desc. A bare column sorts ascending.- An unknown parameter answers
400withunknown_filter, anddetails.allowedlists the accepted names. An operator prefix such asgte.answers400withunsupported_filter_operator. The answer'sfilterechoes the filters applied, plus theprice_list_idtaken from the path.
Read one rung with GET /v1/prices/lists/{list_id}/entries/{id}. It returns the stored row as it is: nothing is rounded, converted or taxed on the way out.
Update one rung with PUT /v1/prices/lists/{list_id}/entries/{id}. It is a partial update, like the list's. Two edits reach further than their field:
- Moving
quantity_mincan land on a threshold the item already has. Nothing stops it, and both rows then appear in the resolvedtiers. - Setting
price_type: "on_request"on one rung takes the whole item off price: while that rung's own window is open, the item resolveson_request(on_request_entry), even where other rungs of this list, or another list of the same specificity, still carry amounts. See the rule onon_requestentries in the resolution order.
Currency and tax basis belong to the list and are changed there, not on an entry.
Delete one rung with DELETE /v1/prices/lists/{list_id}/entries/{id}. The row is removed for good: entries have no trash of their own. The item keeps its other rungs, and the answer is the deleted id with deleted: true.
- Deleting the "from 1" rung does not leave single units unpriced. Below the first remaining threshold, the first remaining rung's price applies, silently, from the next resolve call.
- Deleting an item's last rung stops this list pricing it. The item falls through to the next candidate list or comes back
on_request, never as0. - To retire a price without losing it, set its
price_typetoon_request, or deactivate the list.
| Status | Code | Meaning |
|---|---|---|
400 | The path id is not a uuid, a PUT names no field to change, or a value breaks the entry's own rules. | |
403 | buyer_not_permitted | The request acts for a contact. |
404 | No entry with that id in this list. An entry of another list also answers 404. | |
409 | in_trash | The list is in the trash. Restore it before writing its entries. |
The API reference still says nothing about in_trash on entry writes, and names only unique_violation for a list code; the API answers 409 in_trash for both, as described here.
Writing entries in bulk
| Route | Does |
|---|---|
PUT /v1/prices/lists/{list_id}/entries | Replace all entries of the list. |
POST /v1/prices/lists/{list_id}/entries/bulk | Bulk-upsert entries. |
POST /v1/prices/lists/{list_id}/entries/adjust | Bulk-change prices — a percentage or absolute move. Only writes the rows whose price actually moved, so updated_at stays a real "the price changed here" marker. |
POST /v1/prices/lists/{list_id}/entries/ladder | Generate a quantity ladder for one item, instead of posting each rung. |
Resolving a price
curl -X POST https://api.revenexx.com/v1/prices/resolve \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "X-Revenexx-Market: de" \
-H "Content-Type: application/json" \
-d '{
"currency": "EUR",
"contact_id": "…",
"organization_id": "…",
"channel_id": "…",
"items": [
{ "sku": "ACME-4711-BLK", "quantity": 12 },
{ "sku": "ACME-9001", "quantity": 1 }
]
}'
Up to 200 items per call — a whole cart or a whole listing in one round trip. The answer holds one entry per item, in request order, with the unit price this buyer pays, the net/gross pair, the tax rate, the list that decided it, and that item's full quantity ladder.
at sets the instant every validity window is evaluated at, which is how a promo price is previewed before it starts.
The resolution order, in full
This is the value of the app, and it is not guessable from the field types.
1. Candidates. A list is a candidate when it is active, its currency equals the currency of the call, the instant at falls inside its validity window, it is visible in the buyer's market, and its buyer scope matches or is open. A requires_auth list is dropped for a buyer with neither contact_id nor organization_id.
currency is not a rounding problem — it is an empty answer.The X-Revenexx-Market header scopes which lists are visible; lists assigned to no market are global and always visible.
2. Specificity decides first, and priority never overrules it.
| Scope | Level |
|---|---|
| contact-scoped | 4 |
| organization-scoped | 3 |
| channel-scoped | 2 |
| open | 0 |
An organization list at priority: 0 therefore wins over an open list at priority: 100.
3. Within one specificity level: priority descending, then non-default before default — the default list is deliberately last, so it prices only what nothing else did.
4. A genuine tie (same specificity, same priority, same default flag) is settled by the tenant's price_list_priority_tiebreak setting — lowest_price, highest_price, newest or code — never by the order the database happened to return rows in. The setting in force is echoed in basis.price_list_priority_tiebreak.
5. The first list that prices the item wins, and the search stops there — even if a later, less specific list is cheaper. Its full tier ladder comes back in tiers.
6. An on_request entry stops the search too, and inside a tie it outranks every price: a list that says "ask us" for this buyer is authoritative and cannot be undercut by a list that happens to sort after it.
7. Nothing found → on_request, never 0, with a reason:
not_priced on_request_entry anonymous_denied no_identity
A storefront shows "price on request". It must never show €0.
Amounts
unit_price is per one unit of the entry's unit, in currency, as a decimal in major units (19.90) — never cents — and on the basis tax_basis names. tax_basis comes from the list's own column, else a legacy tax_included: true on it, else the tenant's answer.
Precision and rounding
Two prices settings decide how a computed amount is rounded. Neither lives on a price list: they are prices-app settings, read through GET /v1/settings/apps/prices.
| Setting | Values | Default | Scope |
|---|---|---|---|
price_precision | Whole number from 0 to 4 — decimal places. A value outside the range is clamped to it. | 2 | Market, falling back to the tenant value |
rounding_mode | half_up (0.005 becomes 0.01), half_even (banker's rounding), up, down | half_up | Market, falling back to the tenant value |
They round derived amounts only: unit_price_net, unit_price_gross and line_total in a resolve answer, total in a check answer, and every price a bulk adjust or a ladder computes, before it snaps to a price ending. A stored unit_price is never rounded by them. It is kept at four decimals and echoed exactly as you wrote it, so a per-1000-piece price keeps its precision.
POST /v1/prices/resolve reads both for the buyer's market, so a market can round differently from the tenant. basis.price_precision and basis.rounding_mode in the answer state the values in force. adjust and ladder read the tenant-level values, and answer precision and rounding_mode in the same way.
An operator changes both in the Cockpit settings form. There is no public write route.
Explaining a price
POST /v1/prices/check answers what one item costs one customer in one market, and why. It runs the same resolution as POST /v1/prices/resolve: the same candidate lists, groups, tier rule and tie-break, so for the buyer context you state, the answer matches what the shop quotes.
It is configuration, not a buyer route: it names lists a buyer must not learn about. A request that acts for a contact gets 403 with buyer_not_permitted. A buyer asks POST /v1/prices/resolve.
A request with neither contact_id nor organization_id is asked as an anonymous buyer: lists with requires_auth do not take part, and with anonymous_resolve_allowed off nothing prices the item (not_priced).
curl -X POST https://api.revenexx.com/v1/prices/check \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "X-Revenexx-Market: de" \
-H "Content-Type: application/json" \
-d '{"sku":"ART-1001","quantity":10,"currency":"EUR","segment_codes":["dealer"],"lang":"de"}'
| Field | Meaning |
|---|---|
product_id / sku | One of the two is required. sku is matched exactly. |
quantity | The quantity the tier is chosen for. Defaults to 1. Must be greater than 0, else invalid_quantity. |
at | The instant validity windows are read at. Defaults to now. |
market | Defaults to the active market of the request. |
currency | Prices in this currency instead of the market's. |
contact_id, organization_id, channel_id | The buyer context, if any. |
segment_codes | Segment codes, taken as stated. They are not looked up from the organization. |
lang | de or en. Without it, the first Accept-Language tag decides, else German. |
The texts follow lang. The *_code fields are stable and are the ones to branch on.
| Field | Meaning |
|---|---|
price | Unit price. null when nothing prices the item, or the winning list says price on request. |
on_request | true when nothing prices the item, or the winning list marks it price on request. |
total | Unit price times quantity, null when price is. Rounded with the market's precision and rounding mode, as in Precision and rounding. |
currency | Currency of the price. |
tier, tier_min | The tier that applies, as text, and its minimum quantity. null when price is. |
price_list | The winning list: id, code and name. null when no list won. |
winning_list | The winning list's name in the answer's language. |
reason, reason_code | Why the list won. The code is one of contact, organization, segment, channel, default, open, on_request_entry, not_priced. |
summary | The question in one line. |
displaced | Every other list that prices the item for this buyer. Each entry has list, price_list (id, code), currency, price (null for an on-request list), on_request, why and why_code. The code is one of less_specific, lower_priority, default_list, tie_break, on_request_wins. |
Errors: 400 when neither product_id nor sku is given, a field is not a uuid, quantity is not above 0 (invalid_quantity), at is not an ISO 8601 timestamp, segment_codes is not a list of up to 50 non-empty codes, or market is not a market code. 403 with buyer_not_permitted as above.
Vocabularies
GET /v1/prices/vocabularies lists the enums this app enforces, each with its name, title and description, and without its values, so a UI can cache that one answer. GET /v1/prices/vocabularies/{name} returns one in full: every permitted value with its key, title, description, tone and final. An unknown name answers 404. The values come from the same definitions the app enforces, so the served set is the enforced set. closed: true means the listed values are the full set a write accepts. A single-entry write with a value outside it answers 400 with check_violation; a replace or bulk write answers 400 with entries[i].price_type invalid.
| Name | Used on | Values |
|---|---|---|
price-types | Entries, price_type | standard, on_request |
list-statuses | Lists, status | active, inactive |
tax-bases | Lists, tax_basis | net, gross |
curl "https://api.revenexx.com/v1/prices/vocabularies/price-types" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Where to go next
- Markets — the currency and tax class a price resolves against.
- Channels — channel scope, specificity level 2.
- Organizations — the
price_listcode on a company. - Carts —
price_snapshot_modeat conversion.