Price lists

Price lists and entries on revenexx, the bulk and ladder writes, and POST /v1/prices/resolve — the one route that answers a buyer's price, with the full resolution order.

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

Request
curl "https://api.revenexx.com/v1/prices/lists?limit=50" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."
FieldMeaning
codeUnique 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, descriptionOperator-facing. description is never shown to a buyer.
currencyISO 4217, and the currency of every amount in the list — entries carry none of their own.
tax_basisnet or gross. The one fact a price cannot be without. Null inherits the market's answer.
statusOnly active lists are candidates. inactive retires a list without deleting its prices.
valid_from, valid_untilThe whole list's validity window. Outside it, the list is not a candidate at all.
contact_idBuyer scope: this one contact. The most specific scope there is.
organization_idBuyer scope: buyers of this organization.
channel_idBuyer scope: this sales channel.
requires_authWhen true, the list resolves only for a buyer with a contact or organization context. An anonymous resolve never matches it.
priorityTie-break within one specificity group, higher first.
is_defaultThe fallback list. Within its group it deliberately sorts last.
metadataFree-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.

Request
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.

FieldOn a PUT
codeChangeable. A code another list holds answers 409: unique_violation for a live list, in_trash for one in the trash.
currencyChangeable 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_defaultRestating 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_reasonWritten 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.

Request
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.
  • 403 with buyer_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 {}.

Request
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.

FieldWhat it holds
deleted_atWhen the list went to the trash; null for a live list. A list in the trash prices nothing.
deleted_byWho 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_idThe platform subject of that person; null when the system did.
purge_atWhen the daily purge deletes it for good: the trash time plus the tenant's trash retention (90 days unless the tenant set another).
trash_batchThe trash action the list went in with. Restoring brings back every row that carries it.
purge_blocked_reasonWhy 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.

Request
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"]}'
  • code is required and unique per tenant.
  • parts is optional. prices copies the source's entries, up to 10,000: a larger list is cut there without an error, so compare copied_parts.prices with 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 its status to active, 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}.

StatusCodeMeaning
400The path id is not a uuid.
400code_requiredcode is missing.
400invalid_partsparts names something a price list cannot copy.
403buyer_not_permittedThe request acts for a contact.
404No price list with that id in this tenant.
409code_takenAnother list holds the code.
409in_trashThe 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.

Request
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"}'
FieldMeaning
product_id / skuAn entry needs one of the two. sku is matched exactly — never normalised or case-folded.
quantity_minLowest quantity this price applies from.
unit_pricePrice 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.
unitFree text — pcs, m, kg, a packaging size. This app neither validates nor converts it.
price_typestandard is a number. on_request is the explicit no-price marker.
valid_from, valid_untilThe 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.

Request
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_at and updated_at.
  • sku is not a prefix and not case-insensitive. Prefix matching lives on the bulk adjust route.
  • product_id or sku returns the whole ladder of one item, the rungs a resolve answer was built from.
  • price_type=on_request lists the explicit no-price markers.
  • Dates have no from/until filter. Narrow a period by sorting with order and paging.
  • Paging: limit defaults to 50 and is capped at 200; offset skips rows. page.hasMore says whether another page exists.
  • order sorts by a column, as column, column.asc or column.desc. A bare column sorts ascending.
  • An unknown parameter answers 400 with unknown_filter, and details.allowed lists the accepted names. An operator prefix such as gte. answers 400 with unsupported_filter_operator. The answer's filter echoes the filters applied, plus the price_list_id taken 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_min can land on a threshold the item already has. Nothing stops it, and both rows then appear in the resolved tiers.
  • Setting price_type: "on_request" on one rung takes the whole item off price: while that rung's own window is open, the item resolves on_request (on_request_entry), even where other rungs of this list, or another list of the same specificity, still carry amounts. See the rule on on_request entries 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 as 0.
  • To retire a price without losing it, set its price_type to on_request, or deactivate the list.
StatusCodeMeaning
400The path id is not a uuid, a PUT names no field to change, or a value breaks the entry's own rules.
403buyer_not_permittedThe request acts for a contact.
404No entry with that id in this list. An entry of another list also answers 404.
409in_trashThe 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

RouteDoes
PUT /v1/prices/lists/{list_id}/entriesReplace all entries of the list.
POST /v1/prices/lists/{list_id}/entries/bulkBulk-upsert entries.
POST /v1/prices/lists/{list_id}/entries/adjustBulk-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/ladderGenerate a quantity ladder for one item, instead of posting each rung.

Resolving a price

Request
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.

This is the designated tenant override point of the prices app. A tenant whose pricing really lives elsewhere replaces this one capability and keeps the rest. See From manifest to installed.

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.

Nothing is ever converted. A list in another currency simply does not price the item. A wrong 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.

ScopeLevel
contact-scoped4
organization-scoped3
channel-scoped2
open0

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:

text
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.

SettingValuesDefaultScope
price_precisionWhole number from 0 to 4 — decimal places. A value outside the range is clamped to it.2Market, falling back to the tenant value
rounding_modehalf_up (0.005 becomes 0.01), half_even (banker's rounding), up, downhalf_upMarket, 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).

Request
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"}'
FieldMeaning
product_id / skuOne of the two is required. sku is matched exactly.
quantityThe quantity the tier is chosen for. Defaults to 1. Must be greater than 0, else invalid_quantity.
atThe instant validity windows are read at. Defaults to now.
marketDefaults to the active market of the request.
currencyPrices in this currency instead of the market's.
contact_id, organization_id, channel_idThe buyer context, if any.
segment_codesSegment codes, taken as stated. They are not looked up from the organization.
langde 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.

FieldMeaning
priceUnit price. null when nothing prices the item, or the winning list says price on request.
on_requesttrue when nothing prices the item, or the winning list marks it price on request.
totalUnit price times quantity, null when price is. Rounded with the market's precision and rounding mode, as in Precision and rounding.
currencyCurrency of the price.
tier, tier_minThe tier that applies, as text, and its minimum quantity. null when price is.
price_listThe winning list: id, code and name. null when no list won.
winning_listThe winning list's name in the answer's language.
reason, reason_codeWhy the list won. The code is one of contact, organization, segment, channel, default, open, on_request_entry, not_priced.
summaryThe question in one line.
displacedEvery 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.

NameUsed onValues
price-typesEntries, price_typestandard, on_request
list-statusesLists, statusactive, inactive
tax-basesLists, tax_basisnet, gross
Request
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_list code on a company.
  • Carts — price_snapshot_mode at conversion.
Was this page helpful?