This guide shows you how to consume PriceStockUpsertEvent events end to end. This is the continuous, high-frequency stream of price and stock changes for offers that are already live:
- Receive the event.
- Deduplicate and validate it.
- Read the action of each field.
- Apply the changes in the right order, and never let a stale update win.
- Push the result to your channel in batches, not one call for each event.
Follow it, and the price and the available quantity of a live offer stay accurate on the channel under real load, without oversell and without exhausting the channel's rate limits.
Once an offer is live, its pricing and its available stock are what change most often. They also matter most at the moment a buyer decides to purchase. Stock moves every time the seller makes a sale, restocks, or rebalances a warehouse. Prices move with promotions, repricing rules, and currency changes.
PRICE_STOCK_UPSERT is how every one of those changes reaches the channel. It is the highest-frequency and highest-stakes flow in this domain. The frequency is high because a busy store emits a steady stream of updates. The stakes are high because a buyer sees at once what you get wrong.
The two failure modes are concrete:
- Oversell. A buyer purchases units the seller no longer has, so the order is cancelled later. This happens when the stock on your channel lags behind reality, because updates were dropped, applied out of order, or delivered too slowly. It is a bad buyer experience, a lost sale, and, on many channels, a seller penalty or a suppressed listing.
- Stale price. A price change that does not land makes the seller sell too cheaply and lose margin, or too expensively and lose the sale. Both cases are silent for the seller: the listing looks correct in Mirakl Connect, but the channel shows something else.
Handling this flow correctly, fast enough, in order, and idempotently, is what keeps the channel's view of an offer up to date.
The Catalog flow defines the offer lifecycle and the feedback channel this guide builds on. The Concepts and glossary defines the standard price, the discount price, and the stock breakdown against the legacy inventory.
Handle PRICE_STOCK_UPSERT for ongoing price and stock changes to an offer that is already active on the channel. It is the steady-state event for price and stock. Once you have reported an offer as OFFER_ACTIVE, Mirakl Connect stops sending offer creations for it and sends every later price or stock change as a PRICE_STOCK_UPSERT.
You also receive it before an offer is active. It is the first event Mirakl Connect sends when a seller selects a product, on the assumption that the offer is already live on the channel. That case is not a steady-state update, and When not to use this below covers it.
Mirakl Connect always emits PRICE_STOCK_UPSERT for active offers, and you cannot disable it. Unlike OFFER_UPSERT, the channel's catalog configuration does not gate it, and there is no toggle for it. If an offer is active, its price and stock changes flow to you. Treat that as a fact when you design your connector: you cannot opt out of this stream, so build it to absorb the stream at volume.
The event also has no use_case field. There is no distinction between a creation and an update here, as there is for OFFER_UPSERT. Every PRICE_STOCK_UPSERT carries the offer's current price and stock as an update in place, never a choice between a creation and an update.
When not to use this. PRICE_STOCK_UPSERT does not create an offer. It never carries the descriptive product data or the offer definition that you need to build one. OFFER_UPSERT carries the creation of an offer, the move to OFFER_ACTIVE, and the price and stock the offer launches with. Read Create and update offers for everything before the offer is active.
Receiving a PRICE_STOCK_UPSERT for an offer that is not yet active is expected, not an error. It is Mirakl Connect's initial probe when a seller first selects a product, sent on the assumption that the offer is already live on the channel. The right response is not to create the offer from this event. Report the offer's true status through feedback, either OFFER_DOES_NOT_EXIST or the product's status. That is what makes Mirakl Connect send the OfferUpsertEvent, or the ProductUpsertEvent, that creates it.
For the same reason, do not read this stream as a sign that an item is progressing. It keeps flowing for items whose offer status you never reported, and for offers you reported as ACTION_REQUIRED. Neither of them will ever receive a creation attempt. What your report triggers sets out which statuses move an item forward.
Mirakl Connect emits price and stock changes as they happen. A transport delivers them. Your connector deduplicates them and applies each event to a working state for each offer. Then, on a schedule rather than once for each event, it drains that state to the channel in batches.
The rhythm that matters here is the burst: many events for many products arrive close together. The correct shape is to absorb them quickly, then reconcile to the channel efficiently.
Three properties of this flow shape everything below:
- Delivery is at-least-once, and ordered only for each
product_id. The same event can arrive twice, and events for different SKUs can overtake one another. - Forwarding each event straight to the channel does not scale, because the volume is high. The value is in buffering the events, collapsing them to the latest state for each offer, and exporting in batches.
- One offer can produce several events for one change, when the channel prices or stocks per sub-channel. Read Offers priced and stocked per sub-channel.
A channel can declare that its prices and its stock are per sub-channel, instead of once for the store. Price and stock lifecycle holds the declaration, and Sub-channels holds the list a store carries.
Three things change for your connector.
Mirakl Connect sends one event for each pair of offer and sub-channel. A store with three sub-channels produces three events for one price change, and each event names its sub-channel in the sub_channel block. Read that block first, and apply its values to the sub-channel it names.
Several events now share one ordering key. The partition key stays the product_id, so the events of one offer arrive in order across its sub-channels. Key your working state on the pair of product_id and sub_channel.code, and not on the product_id alone.
The stock breakdown moves into the block. When the stock is per sub-channel, the store-level stock arrives as IGNORE and sub_channel.stock carries the breakdown. Stock has no store-level fallback, so a connector that reads the store-level stock alone sees no quantity at all.
The sub_channel block holds four fields:
code: the sub-channel this event applies to. It is theidyour connector declared for that sub-channel on the store.standard_price,discount_price, andstock: the same three dimensions as at the store level, each one with its ownaction_type.
The per_store_default the channel declared decides what the store-level prices mean next to the block. Under UNSUPPORTED they arrive as IGNORE, and under REQUIRED and OPTIONAL they carry the store's default price.
You receive PRICE_STOCK_UPSERT on the queue or the topic your subscription delivers to: AWS SQS, Google Pub/Sub, or Azure Service Bus. The body is identical on all three, and it arrives in the standard event envelope: id, type, time, and a data object with the price and stock payload.
{
"id": "01jv299c9vvxkrjbq1cf58fn1v",
"time": "2024-05-20T10:15:30.500Z",
"type": "PRICE_STOCK_UPSERT",
"data": {
"channel_id": "001",
"store_id": "2005",
"product_id": "SKU_123456",
"inventory": {
"action_type": "UPDATE",
"quantity": 500,
"delivery_partner": { "name": "__self__" }
},
"stock": {
"action_type": "UPDATE",
"breakdown": [
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_PARIS" }, "quantity": 300 },
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_LYON" }, "quantity": 200 }
]
},
"standard_price": {
"action_type": "UPDATE",
"price": { "currency": "EUR", "amount": 29.99 }
},
"discount_price": {
"action_type": "UPDATE",
"price": { "currency": "EUR", "amount": 24.99 },
"start_date": "2024-05-20T10:15:30.500Z",
"end_date": "2024-06-20T10:15:30.500Z"
},
"offer_attributes": [
{ "type": "TEXT", "id": "label", "value": "My first SKU" }
],
"identifiers": [
{ "type": "GTIN", "value": "1234567890123" }
]
}
}Key fields in data:
channel_idandstore_id: the channel and the seller store this offer belongs to.product_id: the product reference, also called the SKU. It identifies the offer within the store, it is the partition key for the ordering (step 4), and it is the identifier you report feedback against (step 6).stock: the quantity for each location, which is the model to build against.inventoryis the legacy aggregated form, kept for backward compatibility (step 3).standard_priceanddiscount_price: the two prices, each with anaction_typeand a{ currency, amount }value. Both are optional on this event (step 2).offer_attributes: the channel-specific attributes, each one typedTEXT,NUMERIC,BOOLEAN, orLIST. This field is present on everyPRICE_STOCK_UPSERT(step 2).identifiers: the standard product codes, such as a GTIN.sub_channel: the price and the stock of the one sub-channel this event applies to. It is present only when the channel prices or stocks per sub-channel, and it is omitted otherwise. Read Offers priced and stocked per sub-channel.
Acknowledge the message on your queue once you have stored the event. A message you leave unacknowledged returns to the queue, and you receive it again. Read Acknowledging a message.
Delivery is at-least-once, so the same event arrives more than once from time to time. Before you act, deduplicate on the envelope id, a ULID that identifies the event. Make a repeat delivery a no-op.
Then read the payload as a set of independent actions, one for each field, rather than as a single blob. Each of inventory, stock, standard_price, and discount_price carries its own action_type:
UPDATE: set this dimension to the value in the event.DELETE: clear this dimension.IGNORE: leave this dimension exactly as it is on the channel, because the event does not change it.
This is why a single event can change one dimension and leave the others alone. A PRICE_STOCK_UPSERT can carry only a stock change, with the stock as UPDATE and the prices as IGNORE. It can carry only a price change, with the prices as UPDATE and the stock as IGNORE. It can also carry both. A dimension you must not touch arrives as IGNORE, and because the price fields are optional on this event, it can also be absent. The rule is the same in both cases: do not clear what the event did not ask you to change.
The natural DELETE case is the end of a discount price. A discount_price with action_type: DELETE clears the promotional price, and the standard price applies again. A standard price, by contrast, is only ever set with UPDATE or left unchanged with IGNORE, because an active offer always has a list price.
Two field requirements on this event differ from OFFER_UPSERT:
- The prices are optional.
standard_priceanddiscount_priceare not required fields on aPRICE_STOCK_UPSERT. A change to the stock alone does not have to carry them at all. offer_attributesis required. EveryPRICE_STOCK_UPSERTcarries the offer'soffer_attributes, even when the event changes only the stock or the price. Read them as the attributes' current state. Do not treat their presence as a signal that the attributes changed.
Applying the same complete event twice must leave the offer in the same state as applying it once. Honour the action_type of each field, and your handler stays idempotent on top of the deduplication store above.
The availability arrives in two forms in the same event, and only one of them is the model to build against:
stockis the current model. It holds abreakdownof entries, one for each location, and each entry carries its ownquantity. Thetypeof each entry says which of three kinds it is:SELLER_CENTRALIZED: stock that is not tied to a location, as{ type, quantity }.SELLER_WAREHOUSE: stock in a specific warehouse, as{ type, warehouse: { id }, quantity }.FULFILLMENT_PARTNER: stock held by a fulfillment partner, as{ type, partner: { id }, quantity }.
Sum or map the entries to your channel's stock model, as the channel expects.
inventoryis the legacy aggregated form. It is a singlequantity, kept for backward compatibility. Build againststock, and treatinventoryas the aggregated view that some existing integrations still consume.
Both stock and inventory carry their own action_type, so apply a stock change only when its action_type is UPDATE (step 2). When stock.action_type is UPDATE, the breakdown is the offer's complete, current set of quantities for each location. Apply it as the offer's stock, not as a delta to add.
Two independent mechanisms keep a high-frequency stream correct. You need both.
The ordering is guaranteed only for each product_id. Events that share a product_id are delivered in order relative to one another. There is no ordering guarantee across different products, so events for two SKUs can arrive in any order, and often in parallel. In practice, keep the processing of one product_id sequential, so that a later event for a SKU never overtakes an earlier one. Process different SKUs concurrently, so that the throughput keeps up with the burst. Do not force everything through one global queue. That trades a correctness problem you do not have, because there is no cross-key ordering to preserve, for a throughput problem you cannot afford on the highest-frequency flow.
Guard every write with the event time. Because delivery is at-least-once, a duplicate or a retry of an older event can arrive after you have already applied a newer one. Protect against it with a last-write-wins rule keyed on the envelope time. Apply an update to a product_id only when its time is newer than the last update you applied for that SKU, and drop it otherwise. Together with the per-product_id ordering and the deduplication on id from step 2, this makes replays and out-of-order duplicates safe. A stale event can never overwrite newer state.
This guard is internal to your connector, and it is keyed on the event's time field. It is not a timestamp you send back to Mirakl Connect, and this domain has no anti-replay watermark that you attach to the calls you make. The correctness story here is the envelope id, the per-product_id ordering, and your own guard on the event time.
Key the guard on the pair of product_id and sub_channel.code when the channel prices or stocks per sub-channel. One change then produces several events that share a product_id and carry the same time, so a guard keyed on the product_id alone keeps the first event and drops every other sub-channel.
The most important design choice on this flow is to decouple the consumption from the export. Do not push one channel update for each event you receive. Instead:
- Absorb the events into a working state for each offer. As events arrive, apply them (steps 2 to 4) to a durable record, keyed on
product_id, of the offer's latest price and stock. Key it on the pair ofproduct_idandsub_channel.codewhen the channel prices or stocks per sub-channel. A burst of events for one SKU collapses into one current state. - Drain on a schedule, in batches. On a cadence you control, take the offers whose state changed since your last export and send them to the channel in one batched update that covers many SKUs, rather than one call for each SKU. Ten changed offers become one channel call, not ten.
- Drop the no-op events before you export. An event whose price, discount, and stock are all
IGNOREchanges nothing. Filter it out, so that it never produces an empty channel update. - Retry with rate-limit awareness. Channels enforce their own rate limits. Back off on the channel's throttling signals and retry with exponential backoff, so that a burst never turns into a wall of rejected calls.
Because you collapse to the latest state for each offer before you export, the intermediate values within a burst never need to reach the channel. You send where the offer ended up, not every step it passed through. That is what lets a high-frequency stream stay accurate and stay within the channel's limits. For how to batch feedback and channel writes at scale, read Report feedback.
Most PRICE_STOCK_UPSERT events need no reply. You apply the change and move on.
The channel can still reject an update: a price below a channel floor, a quantity it will not accept, or a rule the offer now violates. When that happens, do not let the rejection disappear into a log. Surface it through the same updateStoreCatalogItems feedback operation, with an offer_diagnostics message the seller can act on, exactly as for a blocked offer creation.
POST https://miraklconnect.com/api/channel-platform/v1/channel-catalog/001/store-catalog-items/2005The product_id, the SKU, keys the item. The offer_diagnostics list is full-replace: send the complete set of the offer's currently active problems, and send an empty list to clear them once you resolve the issue. The mechanics of this operation are the same as in Create and update offers and Report feedback. Use them here, so that a rejected price or stock change does not become an invisible gap between the channel and Mirakl Connect.
A realistic burst: three PRICE_STOCK_UPSERT events for two offers, arriving close together, and one offer changes twice. Both offers are already OFFER_ACTIVE on channel 001 for store 2005.
1. The burst arrives. In quick succession you receive three events.
Event A: SKU_123456 is restocked. The stock is updated, and the prices are left alone.
{
"id": "01jv299c9vvxkrjbq1cf58fn1v",
"time": "2024-05-20T10:15:30.500Z",
"type": "PRICE_STOCK_UPSERT",
"data": {
"channel_id": "001",
"store_id": "2005",
"product_id": "SKU_123456",
"inventory": { "action_type": "UPDATE", "quantity": 500 },
"stock": {
"action_type": "UPDATE",
"breakdown": [
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_PARIS" }, "quantity": 300 },
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_LYON" }, "quantity": 200 }
]
},
"standard_price": { "action_type": "IGNORE" },
"discount_price": { "action_type": "IGNORE" },
"offer_attributes": [ { "type": "TEXT", "id": "label", "value": "My first SKU" } ],
"identifiers": [ { "type": "GTIN", "value": "1234567890123" } ]
}
}Event B: SKU_777888 gets a new list price. The stock is left alone.
{
"id": "01jv299d2xk8m4p0r7t6y5w3qs",
"time": "2024-05-20T10:15:31.000Z",
"type": "PRICE_STOCK_UPSERT",
"data": {
"channel_id": "001",
"store_id": "2005",
"product_id": "SKU_777888",
"inventory": { "action_type": "IGNORE" },
"stock": { "action_type": "IGNORE" },
"standard_price": { "action_type": "UPDATE", "price": { "currency": "EUR", "amount": 49.99 } },
"discount_price": { "action_type": "IGNORE" },
"offer_attributes": [ { "type": "TEXT", "id": "label", "value": "Second SKU" } ],
"identifiers": [ { "type": "GTIN", "value": "9876543210987" } ]
}
}Event C: a wave of sales drops the stock of SKU_123456 again. This event is newer than event A.
{
"id": "01jv299e7bn1c9h5f2d8g6k4za",
"time": "2024-05-20T10:15:31.200Z",
"type": "PRICE_STOCK_UPSERT",
"data": {
"channel_id": "001",
"store_id": "2005",
"product_id": "SKU_123456",
"inventory": { "action_type": "UPDATE", "quantity": 460 },
"stock": {
"action_type": "UPDATE",
"breakdown": [
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_PARIS" }, "quantity": 280 },
{ "type": "SELLER_WAREHOUSE", "warehouse": { "id": "WH_LYON" }, "quantity": 180 }
]
},
"standard_price": { "action_type": "IGNORE" },
"discount_price": { "action_type": "IGNORE" },
"offer_attributes": [ { "type": "TEXT", "id": "label", "value": "My first SKU" } ],
"identifiers": [ { "type": "GTIN", "value": "1234567890123" } ]
}
}2. Your connector acts. In order:
- Acknowledge each message on the queue.
- Deduplicate on the envelope
id. All three ids are new, so record them and continue. - Group the events by
product_id.SKU_123456owns events A and C, andSKU_777888owns event B. Process the two SKUs concurrently, and process A and C forSKU_123456in order. SKU_123456: both A and C update the stock and leave the prices asIGNORE. A sets the stock to300 + 200. C has the newertime,31.200Zagainst30.500Z, and sets the stock to280 + 180. Under last-write-wins keyed ontime, C wins, so the working state ends at 460 units, 280 in Paris and 180 in Lyon. If a duplicate of event A arrived now, its oldertimewould fail the guard and you would drop it, so it could not restore the stale value of 500 units.SKU_777888: event B leaves the stock asIGNOREand sets the standard price to 49.99 EUR. The offer's stock on the channel is untouched.
3. You export once, in a batch. On your export schedule, and not once for each event, you drain the two changed offers and send the channel one batched update that covers both SKUs: SKU_123456 at 460 units, and SKU_777888 at 49.99 EUR. Three events for two products became a single channel call.
4. The outcome. The channel shows SKU_123456 with 460 available units and SKU_777888 priced at 49.99 EUR. Your working state superseded the intermediate state of 500 units before it ever reached the channel, so you never published a stale quantity and you never opened an oversell window. If the channel had rejected either change, you would report it against that SKU with an offer_diagnostics message (step 6), so that the seller could act.
What it looks like: the handler pushes a channel update the moment each event arrives, one synchronous channel call for each event received.
The failure: price and stock is the highest-frequency flow, so a burst of events becomes a burst of channel calls. You reach the channel's rate limits, the channel throttles or rejects the calls, the latency climbs, and the connector falls behind. While it is behind, the stock on the channel is stale, which is exactly the oversell you wanted to prevent.
The fix: decouple the consumption from the export. Absorb the events into a working state for each offer, collapse them to the latest value for each product_id, and drain on a schedule into batched channel updates that cover many SKUs at once. Drop the no-op events, where every field is IGNORE, before you export, and retry with a backoff that is aware of the rate limits.
What it looks like: either the connector pushes every event through one global lock or queue to be safe, or it fans the events out to workers with no attention to the SKU they belong to, so a later update for a SKU can be applied before an earlier one.
The failure: a single serial pipeline cannot keep up with a burst, so the throughput collapses, the connector falls behind, and the channel goes stale. In the other case, an older event overwrites a newer one for the same SKU, and the channel shows a stale quantity, which is an oversell, or a stale price. Nothing signals that it went wrong.
The fix: serialize the processing for each product_id, and run different SKUs concurrently. The ordering is guaranteed only for each product_id, never across keys, so there is no global order to preserve and no reason to pay for one.
What it looks like: the handler reads an absent price field, or an action_type of IGNORE, as an instruction to remove the value. Or, the other way round, it treats DELETE as a no-op.
The failure: an event that carries only a stock change, with the prices as IGNORE, wipes the offer's price on the channel, so the offer becomes mispriced or unsellable. Or a discount_price set to DELETE is skipped, so an expired promotion keeps running. Both failures are silent, and both land on the buyer.
The fix: honour the action_type of each field. IGNORE means leave the current value on the channel untouched. UPDATE means set it. DELETE means clear it, so a discount_price set to DELETE ends a promotion and the standard price applies again. A PRICE_STOCK_UPSERT can carry only the stock, only the prices, or both, and the dimensions it does not change arrive as IGNORE or are absent. Note that this is the opposite of the feedback operation you call, which is full-replace. On an event you receive, a field you were not sent is not a cleared field.
What it looks like: the handler writes whatever it received last to its store and to the channel, with no check on how old the event is.
The failure: because delivery is at-least-once, a duplicate or a retry of an older event can arrive after you applied a newer one. With no guard, that stale event overwrites the current stock with an out-of-date quantity. The channel then shows more units than exist, and an oversell window stays open until the next event happens to correct it.
The fix: guard each write with the event's envelope time. Apply an update for a product_id only when its time is newer than the last one you applied for that SKU, which is last-write-wins, and drop it otherwise. Together with the per-product_id ordering and the deduplication on id, this makes replays and out-of-order duplicates safe. The guard is internal to your connector and keyed on the event's own time. It is not a timestamp you send back to Mirakl Connect.
- Catalog flow: the lifecycle states and the feedback channel this guide relies on.
- Concepts and glossary: the standard price, the discount price, the stock breakdown against the legacy inventory, and the
action_typeof each field. - Create and update offers: how an offer is created and reaches
OFFER_ACTIVEbefore anyPRICE_STOCK_UPSERTflows. - Report feedback: every status value, the event each pair of statuses triggers, and how to batch the export and report the statuses and diagnostics at scale.
- Ordering per destination: the transports that deliver events, AWS SQS, Google Pub/Sub, and Azure Service Bus, and their ordering options.
- Event APIs reference: the full payload schema for PriceStockUpsertEvent and every other event.