# Create and update offers

This guide shows you how to consume [`OFFER_UPSERT`](/content/product/connect-channel-platform/webhooks/webhook/webhooks/offerupsertevent) events end to end:

1. Receive the event.
2. Deduplicate and validate it.
3. Map it to your channel's offer model.
4. Create or update the offer.
5. Report the resulting status back, so that Mirakl Connect knows what to send next.


Follow it, and a seller's offer moves from "not on the channel" to live, or to a blocked state that the seller can understand and fix.

## Business context

A seller's catalog is worth something only once buyers can act on it.
An **offer** is the sellable listing that puts one of a seller's products up for sale under a store on a channel.
It carries the pricing and the available stock that a buyer sees at the moment they decide to purchase.

`OFFER_UPSERT` is how a seller's assortment reaches the channel.
When Mirakl Connect decides that an offer must be created or updated on the channel, it sends you this event.
Your connector is what turns the event into a real listing.

For the seller, the experience is simple when it works and frustrating when it does not.
A seller enables a channel, selects products, sets prices and stock, and expects those offers to appear for sale.
Every `OFFER_UPSERT` you handle correctly is one more offer live and earning.
Every event you drop, apply twice, or fail silently is an offer that never appears, or that appears wrong, and the seller gets no signal about why.
The status you report back is what closes that gap.
It is how a stuck offer becomes a fixable problem instead of a silent gap in the seller's storefront.

Read the [Catalog flow](/content/product/connect-channel-platform/developer-guide/catalog-flow) first for the definitions this guide builds on: offer against product, the lifecycle states, and the feedback channel.
The [Concepts and glossary](/content/product/connect-channel-platform/getting-started/concepts-and-glossary) covers the standard price, the discount price, the stock breakdown, and the SKU that keys an offer.

## When to use this

Handle `OFFER_UPSERT` when Mirakl Connect asks you to **create or update an offer definition** on the channel.
The event's `use_case` field tells you which one:

- **`OFFER_CREATION`**: the offer does not exist on the channel yet, so build it.
You receive this use case only once you have reported the offer as `OFFER_DOES_NOT_EXIST`.
That status is what asks Mirakl Connect for a creation attempt, and an offer whose status you never reported gets none.
Read [Report feedback](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback).
- **`OFFER_UPDATE`**: the definition of an **already active** offer has changed.
The change can affect its offer attributes, its identifiers, or the configuration that shapes them.
You receive this use case only for offers you have reported as `OFFER_ACTIVE`.


**The channel's catalog configuration gates `OFFER_UPSERT`**.
Mirakl Connect emits it only for the offer use cases a channel has declared it supports.
If a channel's configuration does not enable offer creation, or offer update, you do not receive these events for it.
Enabling that configuration belongs to the [Catalog configuration](/content/product/connect-channel-platform/developer-guide/catalog-configuration) step.

**When not to use this.**
`OFFER_UPSERT` never carries the steady-state price and stock of a live offer.
Those arrive as [`PRICE_STOCK_UPSERT`](/content/product/connect-channel-platform/webhooks/webhook/webhooks/pricestockupsertevent), which you handle with [Sync price and stock](/content/product/connect-channel-platform/developer-guide/catalog/sync-price-and-stock).
Reporting an offer as `OFFER_ACTIVE` stops the offer **creations** for it.
It does not stop `OFFER_UPSERT` altogether, because `OFFER_UPDATE` applies from that point on.

Creating the underlying **product** on the channel is also out of scope here.
A separate product event drives it, covered in [Create or update products](/content/product/connect-channel-platform/developer-guide/catalog/create-products), and the product is a prerequisite for an offer to go live.
Read [Handle unknown products](#5-handle-unknown-products).

## How it works

Mirakl Connect emits the event.
A transport delivers it.
Your connector validates it, deduplicates it, maps it, and applies it on the channel.
You then report the outcome.

The status you report drives what Mirakl Connect does next.
The whole flow is a feedback loop, not a single delivery.

```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

    P--)T: OFFER_UPSERT (use_case = OFFER_CREATION)
    T--)I: Deliver event (at-least-once)
    I->>I: Validate and deduplicate on envelope id
    I->>C: Create the offer (mapped to channel fields)

    alt Offer goes live
        C-->>I: Offer accepted
        I->>P: updateStoreCatalogItems (offer_status = OFFER_ACTIVE)
        Note over P,I: Creations stop, PRICE_STOCK_UPSERT and OFFER_UPDATE flow from now on
    else Offer cannot go live yet
        C-->>I: Rejected, for example a missing required attribute
        I->>P: updateStoreCatalogItems (offer_status = ACTION_REQUIRED + diagnostics)
        Note over P,C: Seller fixes the issue in Mirakl Connect
        I->>P: updateStoreCatalogItems (offer_status = OFFER_DOES_NOT_EXIST)
        P--)T: New OFFER_UPSERT (creation attempted again)
    end
```

Two properties of this flow shape everything below:

- **Delivery is at-least-once**, so the same event can arrive more than once.
- **There is no synchronous acknowledgement of success.**
To acknowledge the message on the queue confirms receipt only.
You communicate whether the offer went live separately, through the feedback operation.


## Step by step

### 1. Receive the event

You receive `OFFER_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 offer payload.
For how to set one up, read [Receiving events](/content/product/connect-channel-platform/developer-guide/receiving-events).

```json
{
  "id": "01jt31mw7wy3x4zs55kawgg1xe",
  "time": "2024-05-20T10:15:30.500Z",
  "type": "OFFER_UPSERT",
  "data": {
    "use_case": "OFFER_CREATION",
    "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" },
      { "type": "NUMERIC", "id": "warranty", "value": 12 },
      { "type": "LIST", "id": "color", "values": [ { "value": "red" }, { "value": "blue" } ] }
    ],
    "identifiers": [
      { "type": "GTIN", "value": "1234567890123" }
    ]
  }
}
```

Key fields in `data`:

- **`use_case`**: `OFFER_CREATION` or `OFFER_UPDATE`.
- **`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 3), and it is the identifier you report feedback against (step 6).
- **`standard_price`** and **`discount_price`**: the two prices, each with an `action_type` and a `{ currency, amount }` value.
- **`stock`**: the quantity for each location, which is the model to build against.
**`inventory`** is the legacy aggregated form, kept for backward compatibility.
- **`offer_attributes`**: the channel-specific attributes, each one typed `TEXT`, `NUMERIC`, `BOOLEAN`, or `LIST` (step 4).
- **`identifiers`**: the standard product codes, such as a GTIN.


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.
An acknowledgement is **not** a statement that the offer went live.
You report that separately in step 6.
Read [Acknowledging a message](/content/product/connect-channel-platform/developer-guide/receiving-events#acknowledging-a-message).

### 2. Validate and deduplicate on the envelope `id`

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.
Record the ids you have processed.
This small store is the most important piece of state your connector owns for correctness.

Then validate the shape of the payload, and reject early anything you cannot map, such as a missing required attribute for the channel or a price you cannot parse.
Do not blindly retry a validation failure.
**Report** it as a blocked offer in step 6, so that the seller can fix it.

Each field carries its own `action_type`:

- `UPDATE` sets the value.
- `DELETE` clears it.
- `IGNORE` leaves it unchanged.


Apply the event as an **upsert**, and honour the `action_type` of each field.
A single event can set the price and leave the stock untouched.
Applying the same complete event twice must leave the offer in the same state as applying it once.

### 3. Handle ordering by `product_id`

Events that share a `product_id` are delivered **in order** relative to one another.
There is **no ordering guarantee across different products**.
Events for two SKUs can arrive in any order, and often in parallel.
For how the transports preserve the ordering for each object, read [Ordering per destination](/content/product/connect-channel-platform/developer-guide/receiving-events#ordering-per-destination).

In practice, keep the processing of one `product_id` sequential, so that a later event for a SKU does not overtake an earlier one.
Process different SKUs concurrently.
Per-SKU ordering, together with the idempotent upserts of step 2, is enough to converge on the correct state even when a duplicate or a retry slips in.

### 4. Map the offer to the channel

Translate the event's fields into your channel's offer model.
Keep the distinction between the offer and the product clear.
This event is about the **offer**: its pricing, its stock, its identifiers, and its offer attributes.
It does **not** create the product, and the product must already exist on the channel (step 5).

Map the `offer_attributes` by their `type`.
Each attribute has an `id`, which is the attribute key on the channel, and a value whose JSON shape depends on the type:

| `type` | Value field | JSON shape |
|  --- | --- | --- |
| `TEXT` | `value` | a string, such as `"My first SKU"` |
| `NUMERIC` | `value` | a number, such as `12` |
| `BOOLEAN` | `value` | a boolean, such as `true` |
| `LIST` | `values` | an array of `{ "value": "..." }` objects |


Map the entries of `stock.breakdown` by their `type` too.
Each entry carries its own `quantity`:

- `SELLER_CENTRALIZED`: no location.
- `SELLER_WAREHOUSE`: a `warehouse.id`.
- `FULFILLMENT_PARTNER`: a `partner.id`.


Mirakl Connect sends the event only when the offer carries every field that the use case lists in `required`.
On an update, a field that the use case lists in neither `required` nor `optional` does not reach you (read [What required and optional change](/content/product/connect-channel-platform/developer-guide/catalog-configuration#what-required-and-optional-change) from the Catalog Configuration guide).

Validate the other fields against the channel's rules, so that you catch a missing attribute before the channel rejects it.

### 5. Handle unknown products

An offer sells a product, and it **cannot go live before that product exists on the channel**.
Product creation is a separate flow, covered in [Create or update products](/content/product/connect-channel-platform/developer-guide/catalog/create-products).
Its own [`ProductUpsertEvent`](/content/product/connect-channel-platform/webhooks/webhook/webhooks/productupsertevent) drives it, and its own `product_status` reports it.
Because the ordering is guaranteed only for each `product_id`, and because the two flows are independent, you can receive an `OFFER_UPSERT` for a product that is not yet created on the channel.

When that happens, do not drop the event and do not fail silently.
Report the item's **`product_status` as `PRODUCT_DOES_NOT_EXIST`** (step 6), with a product diagnostic that explains that the product is not yet on the channel.
That is the one blocked state Mirakl Connect resumes on its own.
It parks the offer as waiting for its product, and the moment you report `product_status: PRODUCT_CREATED`, it sends a fresh `OFFER_UPSERT` so that the offer can go live.

Do not report an offer-level `ACTION_REQUIRED` for this case and then wait.
That status makes the problem visible to the seller, but it does **not** queue another creation attempt.
The offer stays parked until you report `OFFER_DOES_NOT_EXIST` again.
The product status is what carries an automatic resume.

### 6. Report feedback

After you act, close the loop and report the outcome for each store catalog item with the [`updateStoreCatalogItems`](/content/product/connect-channel-platform/rest/connect/openapi3/product-feedback/updatestorecatalogitems) operation.
This is what advances the offer lifecycle, because Mirakl Connect acts on the `offer_status` you report.

```
POST https://miraklconnect.com/api/channel-platform/v1/channel-catalog/001/store-catalog-items/2005
```

Report:

- **`offer_status`**: one of `OFFER_ACTIVE`, `OFFER_DOES_NOT_EXIST`, or `ACTION_REQUIRED`.
Report `OFFER_ACTIVE`, and Mirakl Connect stops the creations and switches to `PRICE_STOCK_UPSERT` plus `OFFER_UPDATE`.
Report `ACTION_REQUIRED`, and it surfaces the problem to the seller but attempts no further creation.
You get a new attempt when you report `OFFER_DOES_NOT_EXIST` once the blocker clears.
[What your report triggers](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback#what-your-report-triggers) specifies which combination of statuses triggers which event.
- **`offer_diagnostics`**: the offer's currently active problems.
Each one is a `message` that a human can read, with an optional `channel_attribute_id` that points at the attribute at fault.
The list is **full-replace**: send the complete set of active diagnostics every time, and send an empty list to clear them.
Diagnostics never change a status, so send them alongside the status they describe.


The item's `id` is the `product_id`, the SKU from the event.
When an offer is blocked, for example because the channel rejected it for a missing required attribute, report it like this:

```json
{
  "store_catalog_items": [
    {
      "id": "SKU_123456",
      "offer_status": "ACTION_REQUIRED",
      "offer_diagnostics": [
        {
          "message": "The required offer attribute \"condition\" is missing. Set it so the offer can be published.",
          "channel_attribute_id": "condition"
        }
      ]
    }
  ]
}
```

The operation is asynchronous (`202 Accepted`) and applies to each item separately, so one blocked offer does not affect the other items in the same request.
An unknown store returns `404`.
A malformed body returns `400`.
Mirakl Connect silently ignores the products it does not know.
For how to batch feedback efficiently across many items, read [Report feedback](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback).

## Complete example

One offer, start to finish.
The channel's catalog configuration already enables offer creation, and the product `SKU_123456` already exists on the channel.

**1. The event arrives.**
Mirakl Connect sends an `OFFER_UPSERT` with `use_case: OFFER_CREATION` for store `2005` on channel `001`:

```json
{
  "id": "01jt31mw7wy3x4zs55kawgg1xe",
  "time": "2024-05-20T10:15:30.500Z",
  "type": "OFFER_UPSERT",
  "data": {
    "use_case": "OFFER_CREATION",
    "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" },
      { "type": "NUMERIC", "id": "warranty", "value": 12 },
      { "type": "LIST", "id": "color", "values": [ { "value": "red" }, { "value": "blue" } ] }
    ],
    "identifiers": [
      { "type": "GTIN", "value": "1234567890123" }
    ]
  }
}
```

**2. Your connector acts.**
In order:

- Acknowledge the message on the queue.
- Check the envelope `id` `01jt31mw7wy3x4zs55kawgg1xe` against your deduplication store.
It is new, so continue and record it.
- Validate the payload: `SKU_123456` exists on the channel, and every required offer attribute is present.
- Map the offer into your channel's offer model, then create the offer on the channel.
The offer carries a standard price of `29.99 EUR`, a discount price of `24.99 EUR` valid for one month, `500` units split into `300` at `WH_PARIS` and `200` at `WH_LYON`, the three offer attributes, and the GTIN.
- The channel accepts the offer, and the offer goes live.


**3. You report the outcome.**
Post the offer's new status against store `2005` on channel `001`, keyed by the SKU:

```
POST https://miraklconnect.com/api/channel-platform/v1/channel-catalog/001/store-catalog-items/2005
```

```json
{
  "store_catalog_items": [
    {
      "id": "SKU_123456",
      "offer_status": "OFFER_ACTIVE",
      "offer_diagnostics": []
    }
  ]
}
```

**4. The lifecycle advances.**
Mirakl Connect records the offer as `OFFER_ACTIVE`.
From now on it sends **no more offer creations** for this SKU.
Ongoing price and stock changes arrive as `PRICE_STOCK_UPSERT`, which you handle with [Sync price and stock](/content/product/connect-channel-platform/developer-guide/catalog/sync-price-and-stock).
A later change to the offer's definition arrives as an `OFFER_UPSERT` with `use_case: OFFER_UPDATE`.
The empty `offer_diagnostics` list clears any earlier problem, which confirms that the offer has none.

If a missing required attribute had blocked the offer instead, you would report `offer_status: ACTION_REQUIRED` with a diagnostic, as in [step 6](#6-report-feedback), so that the seller could see why.
You would then report `OFFER_DOES_NOT_EXIST` once the data was corrected, to trigger a fresh attempt.
If a product that is not on the channel had blocked it, you would report `product_status: PRODUCT_DOES_NOT_EXIST` instead, as in [step 5](#5-handle-unknown-products), and Mirakl Connect would resume on its own.

## Common mistakes

### Treating handling as non-idempotent

**What it looks like:** the handler creates or increments something on the channel on every delivery, and assumes that each event is seen exactly once.

**The failure:** because delivery is at-least-once, an event delivered again produces a duplicate offer, a change applied twice, or a corrupted quantity.
Duplicates surface intermittently, and they are hard to trace back to a repeat delivery.

**The fix:** deduplicate on the envelope `id`, and make the handler an idempotent upsert keyed on `product_id` that honours the `action_type` of each field.
Applying the same event twice must equal applying it once.

### Assuming `OFFER_UPSERT` is delivered exactly once for each change

**What it looks like:** the connector treats each event as a unique, guaranteed delta, one event for each change, and relies on receiving every event exactly once to stay consistent.

**The failure:** Mirakl Connect provides **at-least-once** delivery, not exactly-once, and it orders events only for each `product_id`.
A design that assumes exactly-once delivery applies a change twice on a repeat delivery.
A design that assumes global ordering misorders changes across SKUs.
Neither problem shows up until production load.

**The fix:** treat every event as a full upsert of the state it carries, not as an incremental delta.
Keep the processing of each `product_id` sequential, and let idempotency absorb the duplicates.
For how the transports preserve the ordering for each object, read [Ordering per destination](/content/product/connect-channel-platform/developer-guide/receiving-events#ordering-per-destination).

### Letting one bad event block the rest

**What it looks like:** the connector retries a single unprocessable event forever at the head of the queue, such as an attribute it cannot map or a payload that throws, and stalls every later event behind it.
Because the ordering is per `product_id`, one poison event can hold up a whole partition of unrelated SKUs.

**The failure:** the offers of many products stop updating because the event of one product cannot be processed.
The blockage is silent from the seller's point of view.
Their other offers simply stop moving.

**The fix:** isolate the failing item instead of blocking the batch.
Report that offer as `ACTION_REQUIRED` with a diagnostic, set it aside for reconciliation, and let the remaining events proceed.
Never stake the progress of a whole partition on the one item you cannot handle.

### Failing silently instead of reporting `ACTION_REQUIRED`

**What it looks like:** when an offer cannot go live, the connector logs the error and moves on without reporting anything back, or it silently skips events that lack an expected field such as an identifier.
The offer can be blocked by a missing required attribute, by a product that is not yet on the channel, or by a rejection from the channel.

**The failure:** Mirakl Connect never learns that the offer is blocked, so it keeps the item where it is.
The seller sees an offer that never appears, with no explanation.
The problem is invisible to everyone who could fix it.

**The fix:** always close the feedback loop.
Report `offer_status: ACTION_REQUIRED` with an `offer_diagnostics` message the seller can act on, and reference the `channel_attribute_id` at fault where you can, so that the reason is visible.
Then report `OFFER_DOES_NOT_EXIST` once the blocker clears, which is what asks for a new creation attempt.
Reserve a silent skip for genuine no-ops, never for a blocked offer.

### Waiting for a creation attempt you never asked for

**What it looks like:** the connector reports what it knows about a product, most often `product_status: PRODUCT_CREATED` after it created the product on the channel, and then waits for an `OFFER_UPSERT` to build the offer.

**The failure:** no offer event ever arrives.
`PRODUCT_CREATED` unblocks an offer that was already waiting for its product.
It does not ask for an offer to be created.
Because `PRICE_STOCK_UPSERT` keeps flowing for the item, nothing looks broken, and the offer never appears for the seller.

**The fix:** report `offer_status: OFFER_DOES_NOT_EXIST`, in the same call as the product status.
It is the only status that asks Mirakl Connect for a creation attempt.
For the full matrix, read [What your report triggers](/content/product/connect-channel-platform/developer-guide/catalog/report-feedback#what-your-report-triggers).

## Related

- **[Catalog flow](/content/product/connect-channel-platform/developer-guide/catalog-flow)**: offer against product, price and stock, the lifecycle states, and the feedback channel this guide relies on.
- **[Create or update products](/content/product/connect-channel-platform/developer-guide/catalog/create-products)**: how to create the product an offer needs, for the channels and the sellers where that step applies.
- **[Sync price and stock](/content/product/connect-channel-platform/developer-guide/catalog/sync-price-and-stock)**: how ongoing price and stock changes arrive once an offer is `OFFER_ACTIVE`.
- **[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.
- **[Catalog configuration](/content/product/connect-channel-platform/developer-guide/catalog-configuration)**: the configuration that enables `OFFER_UPSERT`.
- **[Receiving events](/content/product/connect-channel-platform/developer-guide/receiving-events)**: the transports, the ordering, and the delivery behaviour behind these events.