# Sync price and stock

This guide shows you how to consume [PriceStockUpsertEvent](/content/product/connect-channel-platform/webhooks/webhook/webhooks/pricestockupsertevent) events end to end.
This is the continuous, high-frequency stream of price and stock changes for offers that are already live:

1. Receive the event.
2. Deduplicate and validate it.
3. Read the action of each field.
4. Apply the changes in the right order, and never let a stale update win.
5. 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.

## Business context

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](/content/product/connect-channel-platform/developer-guide/catalog-flow) defines the offer lifecycle and the feedback channel this guide builds on.
The [Concepts and glossary](/content/product/connect-channel-platform/getting-started/concepts-and-glossary) defines the standard price, the discount price, and the stock breakdown against the legacy inventory.

## When to use this

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](/content/product/connect-channel-platform/developer-guide/catalog/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](/content/product/connect-channel-platform/webhooks/webhook/webhooks/offerupsertevent), or the [ProductUpsertEvent](/content/product/connect-channel-platform/webhooks/webhook/webhooks/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](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback#what-your-report-triggers) sets out which statuses move an item forward.

## How it works

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.

```mermaid
sequenceDiagram
    box rgb(219,234,254) Mirakl
    participant P as Mirakl Connect
    end
    box rgb(209,250,229) Middleware
    participant T as Transport
    participant I as Channel Connector
    end
    box rgb(254,240,199) Channel
    participant C as Channel
    end

    Note over P: An active offer's price or stock changes
    P--)T: PRICE_STOCK_UPSERT (bursts, many per product_id)
    T--)I: Deliver events (at-least-once, ordered per product_id)
    I->>I: Deduplicate on envelope id
    I->>I: Apply per-field action_type into a per-offer working state
    Note over I: Serialize per product_id, newer event time wins within a key

    loop On a schedule, not per event
        I->>I: Drain state, collapse to the latest value per product_id
        I->>C: One batched update (many SKUs, one call)
        alt Channel accepts
            C-->>I: Applied
        else Channel rejects a SKU
            C-->>I: Rejected, for example a price below a channel floor
            I->>P: updateStoreCatalogItems (offer_diagnostics for the seller)
        end
    end
```

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](#offers-priced-and-stocked-per-sub-channel).


## 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](/content/product/connect-channel-platform/developer-guide/catalog-configuration#price-and-stock-lifecycle) holds the declaration, and [Sub-channels](/content/product/connect-channel-platform/developer-guide/stores#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 the `id` your connector declared for that sub-channel on the store.
- `standard_price`, `discount_price`, and `stock`: the same three dimensions as at the store level, each one with its own `action_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.

## Step by step

### 1. Receive the event

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.

```json
{
  "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_id`** and **`store_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.
**`inventory`** is the legacy aggregated form, kept for backward compatibility (step 3).
- **`standard_price`** and **`discount_price`**: the two prices, each with an `action_type` and a `{ currency, amount }` value.
Both are **optional** on this event (step 2).
- **`offer_attributes`**: the channel-specific attributes, each one typed `TEXT`, `NUMERIC`, `BOOLEAN`, or `LIST`.
This field is **present on every `PRICE_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](#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](/content/product/connect-channel-platform/developer-guide/receiving-events#acknowledging-a-message).

### 2. Deduplicate, then read each field's `action_type`

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_price` and `discount_price` are not required fields on a `PRICE_STOCK_UPSERT`.
A change to the stock alone does not have to carry them at all.
- **`offer_attributes` is required.**
Every `PRICE_STOCK_UPSERT` carries the offer's `offer_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.

### 3. Apply the stock model

The availability arrives in two forms in the same event, and only one of them is the model to build against:

- **`stock`** is the current model.
It holds a `breakdown` of entries, one for each location, and each entry carries its own `quantity`.
The `type` of 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.
- **`inventory`** is the legacy aggregated form.
It is a single `quantity`, kept for backward compatibility.
**Build against `stock`**, and treat `inventory` as 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.

### 4. Preserve per-`product_id` ordering and guard against stale writes

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.

### 5. Batch and throttle toward the 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:

1. **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 of `product_id` and `sub_channel.code` when the channel prices or stocks per sub-channel.
A burst of events for one SKU collapses into one current state.
2. **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.
3. **Drop the no-op events before you export.**
An event whose price, discount, and stock are all `IGNORE` changes nothing.
Filter it out, so that it never produces an empty channel update.
4. **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](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback).

### 6. Surface rejections through 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](/content/product/connect-channel-platform/rest/connect/openapi3/product-feedback/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/2005
```

The `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](/content/product/connect-channel-platform/developer-guide/catalog/create-and-update-offers) and [Report feedback](/content/product/connect-channel-platform/developer-guide/catalog/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.

## Complete example

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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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_123456` owns events A and C, and `SKU_777888` owns event B.
Process the two SKUs concurrently, and process A and C for `SKU_123456` **in order**.
- **`SKU_123456`:** both A and C update the stock and leave the prices as `IGNORE`.
A sets the stock to `300 + 200`.
C has the newer `time`, `31.200Z` against `30.500Z`, and sets the stock to `280 + 180`.
Under last-write-wins keyed on `time`, 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 older `time` would 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 as `IGNORE` and 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.

## Common mistakes

### Forwarding every event to the channel one at a time

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

### Mishandling the ordering, by serializing globally or reordering within a key

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

### Treating a missing or `IGNORE` field as "clear this value"

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

### Applying updates without a stale-write guard

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

## Related

- [Catalog flow](/content/product/connect-channel-platform/developer-guide/catalog-flow): the lifecycle states and the feedback channel this guide relies on.
- [Concepts and glossary](/content/product/connect-channel-platform/getting-started/concepts-and-glossary): the standard price, the discount price, the stock breakdown against the legacy inventory, and the `action_type` of each field.
- [Create and update offers](/content/product/connect-channel-platform/developer-guide/catalog/create-and-update-offers): how an offer is created and reaches `OFFER_ACTIVE` before any `PRICE_STOCK_UPSERT` flows.
- [Report feedback](/content/product/connect-channel-platform/developer-guide/catalog/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](/content/product/connect-channel-platform/developer-guide/receiving-events#ordering-per-destination): the transports that deliver events, AWS SQS, Google Pub/Sub, and Azure Service Bus, and their ordering options.
- [Event APIs reference](/content/product/connect-channel-platform/webhooks/webhook): the full payload schema for [PriceStockUpsertEvent](/content/product/connect-channel-platform/webhooks/webhook/webhooks/pricestockupsertevent) and every other event.