Refresh price caches when a price list changes

Drop exactly the cached prices a price change made stale. Receive the entries-changed and list-level events on your storefront server, narrow the drop by what the event names, cover what no event announces, and decide whether an open cart ever sees the new price.

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.

Environment
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

The loop
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.:

EventFires whenWhat to drop
price_list.entries_changedEntries of a list change, once per request however many rows movedAs much as its scope names (step 3)
price_list.default_changedThe default list moves to another listEvery cached price of the tenant
price_list.updatedA list's own fields change: priority, status, validity window, buyer scope, tax basisEvery cached price of the tenant
price_list.deletedA list is deletedEvery cached price of the tenant
price_list.createdA list is createdNothing. 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_changed drops by item, never by list. It is tempting to tag each cached answer with the list that priced it (every resolved item carries price_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):

operationscope namesSo drop
bulk_upsertwhole_list: true (and mode)The currency's cached prices
replacewhole_list: trueThe currency's cached prices
adjustwhole_list: true, or sku_prefix when the run was filteredThe currency, or the items whose SKU starts with the prefix
ladderproduct_id, sku, or bothThat item
singleonly entry_idRead 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 URLTopics
https://shop.example.com/hooks/prices/<TENANT_SLUG>/<SECRET>/entriesrevenexx.prices.price_list.entries_changed
https://shop.example.com/hooks/prices/<TENANT_SLUG>/<SECRET>/listsrevenexx.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):

Request body
{
  "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.

Cache
// "<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 sku or by product_id, whichever the write used. If you ask prices/resolve by SKU, keep a product_id to SKU map from your own catalog copy, or accept a currency-wide drop for an event that names only a product. A cache keyed by product_id cannot match an adjust that names a sku_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/resolve just 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:

Receiver
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_from or valid_until stops 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 the valid_until of your promo entries, or keep the TTL short enough to live with.
  • A setting. price_precision, rounding_mode, tax_inclusive_default, the tie-break and anonymous_resolve_allowed change what prices/resolve answers for every item. They are tenant settings, read with GET /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_gross with 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:

Read the mode
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:

ModeThe order is booked onA price list change reaches the order when
snapshot (the default)The snapshot price, the line rewritten back to itNever for a line already in a cart, unless you rewrite the snapshot
liveThe line's current unit_priceYou 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:

Reprice a cart 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

  1. 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 }'
    
  2. 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 one entries_changed event follows, with operation: "adjust" and scope.sku_prefix set.
  3. In Cockpit, open the endpoint and read its attempts: a 204 on attempt 1 is the healthy case (the page does not show your response body). On the workflow route, read the run.
  4. 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.
  5. Run an adjust with no sku_prefix. The event now says whole_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

SymptomCauseFix
No delivery after a real changeThe topic is not in the webhook's topic list, or the endpoint is offCheck the list in Cockpit (step 1), or use the workflow route
No event for an adjustIt was a dry run, or no amount moved. Both publish nothingRun it for real, with a change that moves an amount
The old price comes back after a dropA fill that started before the drop stored its answer after itStore only when the generation is unchanged (step 2)
A contract price does not show, though its list has the entryCached answers were dropped by list tag instead of by itemDrop by the item the event names (step 3)
The price is wrong only after midnight or a promo endA validity window passed, which no event announcesShorten the TTL for promo items (step 4)
A buyer sees another buyer's priceThe cache key lacks the buyerKey by tenant, market, channel, currency and buyer (step 2)
The order total differs from the cartprice_snapshot_mode booked the other priceRead the order's pricing block, then reprice the lines (step 5)
Deliveries arrive twiceThe platform delivers at least onceNothing to do: dropping twice is harmless

Where to go next

  • Connect your own frontend: batched prices/resolve calls 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.
Was this page helpful?