Refresh price caches when a price list changes
A merchant loads a new contract price, and your storefront keeps showing the old one until a cache entry expires. This guide builds the invalidator that closes that gap: an endpoint on your storefront server that the platform calls when a price list changes, and a cache that can drop one item, one currency or everything, depending on what the event says moved. It also covers the three changes no event announces, and what happens to a cart that already holds the old price.
This is for developers who run a storefront server on their own stack and cache the answers of POST /v1/prices/resolve, as in Connect your own frontend. You need a tenant with the Pricing core app, an API key with the prices.resolve and prices.entries.get scopes (and carts.items.update for step 5), and a way to change a price to test with: Set up B2B pricing writes entries with the calls used in step 6. For the webhook route you also need access to Cockpit, the platform's admin interface at app.revenexx.com, to register an endpoint. New to the platform? Prerequisites covers Cockpit, your tenant slug and an API key.
The platform's gateway does not cache prices/resolve (as of 2026-09), so the only price cache in play is yours. If you do not cache prices at all, you do not need this guide.
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
export PRICE_HOOK_SECRET="<LONG_RANDOM_STRING>" # the path secret from step 1
How the pieces fit
Merchant or ERP changes prices
-> Pricing app publishes ONE event per request (a count, not the rows)
-> a webhook or a workflow carries it to your server
-> your invalidator DROPS cache entries; it never writes a price
-> the next page view misses the cache and asks prices/resolve again
The design rule that makes this simple: the invalidator only ever drops entries. A duplicate delivery drops an already-dropped entry, a reordered delivery does the same, and a delivery you cannot verify can at worst cost you a cache refill. So you need no dedupe table, and you never take a price from the body. prices/resolve stays the only source of a price.
What each event tells you
The Pricing app publishes five events. Their topics start with revenexx.prices.:
| Event | Fires when | What to drop |
|---|---|---|
price_list.entries_changed | Entries of a list change, once per request however many rows moved | As much as its scope names (step 3) |
price_list.default_changed | The default list moves to another list | Every cached price of the tenant |
price_list.updated | A list's own fields change: priority, status, validity window, buyer scope, tax basis | Every cached price of the tenant |
price_list.deleted | A list is deleted | Every cached price of the tenant |
price_list.created | A list is created | Nothing. A new list has no entries yet; its entries announce themselves |
Two things in that table are deliberate:
- The list-level events drop everything. Which list wins for an item depends on the lists' priority, scope, status and window. When one of those changes, any cached answer might now come from a different list. Dropping everything is one line, and a list-level change is rare.
entries_changeddrops by item, never by list. It is tempting to tag each cached answer with the list that priced it (every resolved item carriesprice_list.id) and drop by that tag. That misses the case that matters: a contract list that did not win yesterday is given an entry today, and it now beats the list your cached answer came from. The event names the item that moved, so drop every cached answer for that item, whichever list it came from.
entries_changed carries price_list_id, price_list_code, currency, operation, changed_count, the per-kind counts created, updated and deleted, a scope object and changed_at. A request that changed nothing is silent: a dry run, an adjust that moved no amount, and a write the platform refused publish no event. The scope object is what the change was aimed at, and has nothing to do with a price list's buyer scope (contact_id, organization_id, channel_id):
operation | scope names | So drop |
|---|---|---|
bulk_upsert | whole_list: true (and mode) | The currency's cached prices |
replace | whole_list: true | The currency's cached prices |
adjust | whole_list: true, or sku_prefix when the run was filtered | The currency, or the items whose SKU starts with the prefix |
ladder | product_id, sku, or both | That item |
single | only entry_id | Read the entry to learn its item (step 3) |
The platform sends this event at least once, not exactly once, and not in order (as of 2026-10). The drop-only rule makes that harmless.
1. Get the event to your server
An event reaches your server by one of two surfaces, and both end at the same endpoint from step 3. Which of them works for the prices events on your tenant is something to check, not assume.
A webhook is the simpler route. In Cockpit, open Integration Studio → Webhooks → Outbound and look at the topic list of a new endpoint (Webhooks describes the form). If revenexx.prices.price_list.entries_changed is in it, subscribe. The topic list is set by the platform's operators rather than derived from your installed apps, and whether it carries the prices topics could not be confirmed from public documentation (as of 2026-10). If the topic is missing, use the workflow route below and ask support to add it.
Register two endpoints per tenant, with these URLs, and a long random <SECRET> in each:
| Endpoint URL | Topics |
|---|---|
https://shop.example.com/hooks/prices/<TENANT_SLUG>/<SECRET>/entries | revenexx.prices.price_list.entries_changed |
https://shop.example.com/hooks/prices/<TENANT_SLUG>/<SECRET>/lists | revenexx.prices.price_list.default_changed, revenexx.prices.price_list.updated, revenexx.prices.price_list.deleted |
The tenant belongs in the path because the documented delivery headers carry no tenant. The secret belongs there because you cannot verify the signature yet: Cockpit does not show the signing secret (as of 2026-10), see Security. Your endpoint must answer 2xx within 5 seconds, which a drop does easily. Cockpit does not reject an http:// URL, so check the scheme.
An Integration Studio workflow is the alternative. A workflow subscribes to a topic by its subject, not through that list, and Send low-stock alerts to the ERP shows a non-order topic starting one. That the prices topics start a workflow on your tenant was not confirmed either (as of 2026-10), so the test in step 6 is the proof. Create a workflow with an Event trigger whose subject is revenexx.prices.price_list.entries_changed, and an HTTP Request node that posts to the same /entries URL. Map the fields you need, as in Send low-stock alerts to the ERP maps its event (the event's fields sit under data):
{
"price_list_id": "${{ trigger.payload.data.price_list_id }}",
"currency": "${{ trigger.payload.data.currency }}",
"scope": {
"whole_list": "${{ trigger.payload.data.scope.whole_list }}",
"product_id": "${{ trigger.payload.data.scope.product_id }}",
"sku": "${{ trigger.payload.data.scope.sku }}",
"sku_prefix": "${{ trigger.payload.data.scope.sku_prefix }}",
"entry_id": "${{ trigger.payload.data.scope.entry_id }}"
}
}
The docs do not say whether a boolean or a null survives that mapping as itself, so the receiver in step 3 reads "true" as true and treats "null" and an empty string as absent. Read one run's request in the workflow's run view to see what was sent. Build one more workflow each on revenexx.prices.price_list.default_changed, revenexx.prices.price_list.updated and revenexx.prices.price_list.deleted, posting to /lists (Event triggers explains subjects). Do not use the pattern revenexx.prices.price_list.* for /lists: one * matches every price-list event, entries_changed included. A subject segment may carry an underscore since 2026-09-29, and price_list has one. Integration Studio is experimental and its schemas are drafts (as of 2026-10). Choose an event surface compares the two routes.
2. Make the cache droppable
You can only drop what you can find. The cache below is shaped by what an event names: a tenant and a currency, then an item, then the variants of that item's answer (market, channel, buyer, quantity). Every level is a place to drop.
// "<tenant>:<currency>" -> { gen, items: Map(sku -> Map(variant -> { answer, expires })) }
const buckets = new Map();
const TTL_MS = 5 * 60 * 1000; // the backstop for what no event announces (step 4)
function bucket(tenant, currency) {
const key = `${tenant}:${currency}`;
if (!buckets.has(key)) buckets.set(key, { gen: 0, items: new Map() });
return buckets.get(key);
}
// A variant holds everything the answer depends on besides the item.
const variantOf = ({ market, channel, buyer, quantity }) =>
[market, channel ?? "-", buyer ?? "open", quantity].join("|");
export function readPrice(tenant, currency, sku, ctx) {
const hit = bucket(tenant, currency).items.get(sku)?.get(variantOf(ctx));
return hit && hit.expires > Date.now() ? hit.answer : null;
}
// Pass the generation you read before you called prices/resolve.
export function storePrice(tenant, currency, sku, ctx, answer, genAtStart) {
const b = bucket(tenant, currency);
if (b.gen !== genAtStart) return; // a drop happened while you were fetching
if (!b.items.has(sku)) b.items.set(sku, new Map());
b.items.get(sku).set(variantOf(ctx), { answer, expires: Date.now() + TTL_MS });
}
export const generation = (tenant, currency) => bucket(tenant, currency).gen;
export function dropItems(tenant, currency, matches) {
const b = bucket(tenant, currency);
b.gen += 1;
for (const sku of [...b.items.keys()]) if (matches(sku)) b.items.delete(sku);
}
export function dropCurrency(tenant, currency) {
const b = bucket(tenant, currency);
b.gen += 1;
b.items.clear();
}
export function dropTenant(tenant) {
for (const [key, b] of buckets) {
if (key.startsWith(`${tenant}:`)) { b.gen += 1; b.items.clear(); }
}
}
Three choices in this sketch carry over to whatever store you really use:
- The key holds the tenant and the buyer. A cached price that leaves either out shows one buyer another buyer's contract price. Connect your own frontend has the rule (its cache-key section); Multi-tenant themes has it from the Theme side.
- Price by one identifier. An event names an item by
skuor byproduct_id, whichever the write used. If you askprices/resolveby SKU, keep aproduct_idto SKU map from your own catalog copy, or accept a currency-wide drop for an event that names only a product. A cache keyed byproduct_idcannot match anadjustthat names asku_prefix, so it drops the currency for every such event. - The generation counter closes a race. (The generation is a number per tenant and currency that every drop increments.) A page that asked
prices/resolvejust before the event, and stores its answer just after the drop, would put the old price back. Storing only when the generation has not moved prevents that, and the TTL covers the rest. With a shared store such as Redis, use one hash per item and a counter key per bucket in the same way.
3. Receive the event and drop
One route per endpoint from step 1. Both acknowledge after the drop, and both fail toward dropping more, never less, for any body the webhook route delivers. On the workflow route, a null that renders as some other text than null or empty would read as a real value, so check the first run's request. The /lists drop is instant. The /entries route makes one API call only for a single-entry change, and gives it two seconds:
import express from "express";
import { timingSafeEqual } from "node:crypto";
import { dropItems, dropCurrency, dropTenant } from "./price-cache.js";
const app = express();
app.use(express.json({ limit: "64kb" }));
// One secret per tenant. This sketch serves one tenant, so it also holds one API key.
const SECRETS = { "<TENANT_SLUG>": process.env.PRICE_HOOK_SECRET };
// A workflow may deliver null and false as strings; read both spellings.
const text = (v) => (v && v !== "null" ? String(v) : null);
const yes = (v) => v === true || v === "true";
function authorized(req) {
const expected = SECRETS[req.params.tenant];
const a = Buffer.from(req.params.secret);
const b = Buffer.from(expected ?? "");
return b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
}
app.post("/hooks/prices/:tenant/:secret/lists", (req, res) => {
if (!authorized(req)) return res.sendStatus(404);
dropTenant(req.params.tenant); // priority, scope, status or default moved: any winner may differ
res.sendStatus(204);
});
app.post("/hooks/prices/:tenant/:secret/entries", async (req, res) => {
if (!authorized(req)) return res.sendStatus(404);
const tenant = req.params.tenant;
const body = req.body ?? {};
const currency = text(body.currency);
try {
if (!currency) dropTenant(tenant);
else await dropForEntries(tenant, currency, body);
} catch {
dropTenant(tenant); // any doubt drops, it never keeps
}
res.sendStatus(204);
});
async function dropForEntries(tenant, currency, body) {
const scope = body.scope ?? {};
let sku = text(scope.sku);
let productId = text(scope.product_id);
// A single entry names only its id. Read it to learn the item. If it is
// gone (a delete) or unreadable, fall back to the whole currency.
if (!sku && !productId && text(scope.entry_id) && text(body.price_list_id)) {
const entry = await readEntry(body.price_list_id, scope.entry_id);
sku = text(entry?.sku);
productId = text(entry?.product_id);
}
const prefix = text(scope.sku_prefix);
if (yes(scope.whole_list)) return dropCurrency(tenant, currency);
if (prefix) return dropItems(tenant, currency, (s) => s.startsWith(prefix));
const mapped = productId ? skuOfProduct(productId) : null; // your own catalog lookup
if (sku || mapped) return dropItems(tenant, currency, (s) => s === sku || s === mapped);
return dropCurrency(tenant, currency); // a scope this code does not know: drop more
}
async function readEntry(listId, entryId) {
const res = await fetch(
`https://api.revenexx.com/v1/prices/lists/${encodeURIComponent(listId)}/entries/${encodeURIComponent(entryId)}`,
{ signal: AbortSignal.timeout(2000), // a timeout lands in the caller's catch, which drops the tenant
headers: {
"X-Revenexx-Tenant": process.env.REVENEXX_TENANT,
"X-Revenexx-Api-Key": process.env.REVENEXX_API_KEY,
} },
);
return res.ok ? res.json() : null; // 404 after a delete
}
// Placeholder for your own product_id -> sku lookup.
function skuOfProduct(productId) { return null; }
app.listen(3000);
skuOfProduct is a placeholder for a lookup from your own catalog copy. In production, put a rate limit in front of both routes. The URL secret is the only thing that tells the platform's call from a stranger's, so treat it like a password and rotate it by changing the endpoint's URL. A caller who has it cannot make your storefront show a wrong price, because the receiver never writes one, but can make every page miss the cache.
readEntry needs the prices.entries.get scope. It asks about a single entry only for the single operation, which is one entry written through its own address; a bulk import, an adjust and a ladder never need it. It reads the entry as it is now, so a single update that changed the entry's own SKU leaves the old SKU's cached answers until the TTL.
4. Cover what no event announces
Three kinds of change move a price without a price event. Event-driven drops plus a TTL is the design, and the TTL from step 2 is not optional (as of 2026-10):
- A validity window. A list or an entry with
valid_fromorvalid_untilstops or starts pricing the moment the time passes, and the Pricing app declares no schedule that announces it. A cached answer can outlive the window. If your merchants use promo prices, cache an answer no longer than the nearest boundary: read it from thevalid_untilof your promo entries, or keep the TTL short enough to live with. - A setting.
price_precision,rounding_mode,tax_inclusive_default, the tie-break andanonymous_resolve_allowedchange whatprices/resolveanswers for every item. They are tenant settings, read withGET /v1/settings/apps/prices, and the Pricing app declares no event for a settings change. A merchant who edits one in Cockpit should expect your cache to catch up within the TTL, or you drop the tenant by hand. - A tax rate. The gross price comes from the buyer's market, so a changed rate in the Markets app changes
unit_price_grosswith no price event.
Bulk imports through the shared /v1/io routes write rows and skip what an entity's own routes do on a write. Whether an /v1/io import of price entries publishes entries_changed could not be confirmed (as of 2026-10), so after one, drop the currency yourself or wait out the TTL.
5. Decide what an open cart sees
A cache refresh changes what a buyer is shown on a listing. It does not change a cart that already holds the old price, and which price an order is booked on is a separate decision. The Carts app makes it per tenant, or per market, with price_snapshot_mode. Read the value your tenant has:
curl -s "https://api.revenexx.com/v1/settings/apps/carts?market=de" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq .settings.price_snapshot_mode
The settings read has no documented scope name, so use a development key with * for this call, as which scope covers which app says. There is no public route to change the value; a merchant sets it in Cockpit.
Every cart line holds two prices: the snapshot taken when the line was added, which is what the buyer was shown, and the working unit_price. Conversion with POST /v1/carts/{id}/order never asks prices/resolve for anything. It only chooses between those two:
| Mode | The order is booked on | A price list change reaches the order when |
|---|---|---|
snapshot (the default) | The snapshot price, the line rewritten back to it | Never for a line already in a cart, unless you rewrite the snapshot |
live | The line's current unit_price | You rewrote unit_price after the change, otherwise the old price |
So in both modes the new price reaches a cart only if your storefront puts it there. The platform does not reprice carts on the event. The place to do it is the cart page: when a buyer opens an active cart, price its lines with prices/resolve (a miss now, because the cache was dropped), and for each line whose price differs, update the line:
curl -s -X PUT "https://api.revenexx.com/v1/carts/$CART_ID/items/$ITEM_ID" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "unit_price": 21.90, "snapshot": { "unit_price": 21.90 } }'
Send the snapshot too when you want the new price to be binding in snapshot mode: the conversion reads snapshot.unit_price, or snapshot.price, and a line with no price in its snapshot is never touched in either mode. The snapshot is the storefront's own loose record of the line, and the update merges the fields you send onto the stored line, so send the whole snapshot object your storefront keeps, with the new price inside it. Show the buyer the change before you make it: a price that moves under a cart without a word is a complaint in either direction. line_total is recomputed from quantity × unit_price, and you cannot set it.
The answer of POST /v1/carts/{id}/order has a pricing block with the mode, lines_changed and the subtotal before and after. Read it in your test (step 6) to see which price won.
6. Test the loop
- Resolve one item and note the price. Cache it as your storefront would.Resolve
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 "X-Revenexx-Market: de" \ -H "Content-Type: application/json" \ -d '{ "currency": "EUR", "items": [{ "sku": "217005000P20", "quantity": 1 }] }' \ | jq '.prices[0] | { unit_price, price_list }' - Change that item's price on a throwaway list that prices it, with a dry run first. A dry run publishes nothing, which is the first check that your cache stays put:Adjust one SKU
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": "217005000P20", "dry_run": true }'
A prefix matches every SKU that starts with it, so use one that matches only your test item. Run it again without"dry_run": true. Exactly oneentries_changedevent follows, withoperation: "adjust"andscope.sku_prefixset. - In Cockpit, open the endpoint and read its attempts: a
204on attempt 1 is the healthy case (the page does not show your response body). On the workflow route, read the run. - Resolve the item again. Your cache missed, the call went out, and the price is the new one. If you still see the old price, the drop did not match the item: log what your receiver parsed.
- Run an adjust with no
sku_prefix. The event now sayswhole_list: true, and the whole currency is dropped. This one really changes every standard price in the list, so run this whole test against a throwaway list, never a live one, and a dry run cannot stand in for it because it publishes nothing.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No delivery after a real change | The topic is not in the webhook's topic list, or the endpoint is off | Check the list in Cockpit (step 1), or use the workflow route |
| No event for an adjust | It was a dry run, or no amount moved. Both publish nothing | Run it for real, with a change that moves an amount |
| The old price comes back after a drop | A fill that started before the drop stored its answer after it | Store only when the generation is unchanged (step 2) |
| A contract price does not show, though its list has the entry | Cached answers were dropped by list tag instead of by item | Drop by the item the event names (step 3) |
| The price is wrong only after midnight or a promo end | A validity window passed, which no event announces | Shorten the TTL for promo items (step 4) |
| A buyer sees another buyer's price | The cache key lacks the buyer | Key by tenant, market, channel, currency and buyer (step 2) |
| The order total differs from the cart | price_snapshot_mode booked the other price | Read the order's pricing block, then reprice the lines (step 5) |
| Deliveries arrive twice | The platform delivers at least once | Nothing to do: dropping twice is harmless |
Where to go next
- Connect your own frontend: batched
prices/resolvecalls and the cache-key rule this guide builds on. - Price lists: the resolution order and the write routes that publish the events.
- Event catalog: the Pricing topics next to every other app's.
- Webhooks and Delivery: registering an endpoint, the 5-second answer and the retries.
- Carts: the cart lines, the conversion and its settings.