The Payload Contract & Worked Examples

What is actually inside a webhook notification, and how to read it correctly.

What is actually inside a webhook notification, and how to read it correctly. This is the part of the integration most worth reading twice: get the sections rule wrong and you will silently apply stale or incomplete data. For how to turn this into working code, including the exact apply algorithm, see Delivery, Idempotency & Suspension.


The payload

Every notification embeds the complete current data for each section it names in changes.

{
  "event": "product.updated",
  "version": 1,
  "delivery_id": "5f2b7c4e-9a1d-4f3e-8c2b-1d0e5a7c9b31",
  "occurred_at": "2026-08-08T12:34:56Z",
  "retailer_account_id": 40975,
  "changes": ["core", "skus"],
  "data": {
    "brand_id": 73516,
    "product": {
      "id": 12345,
      "version_at": "2026-08-08T12:33:10Z",
      "name": "Versilia 105",
      "product_number": "E000264MEDOKID",
      "colors": [
        {
          "id": 456, "code": "BLK", "name": "Black",
          "swatch": { "id": 9901, "url": "https://cdn.jooraccess.com/..." },
          "images": [ { "id": 9902, "url": "https://cdn.jooraccess.com/..." } ],
          "skus": [
            {
              "size_id": 789, "size_name": "36", "upc": "8033963132116",
              "prices": [
                { "price_type_id": 5, "price_type_name": "EUR Wholesale", "wholesale_currency": "EUR", "wholesale_price": 340.00, "retail_currency": "EUR", "suggested_retail_price": 790.00 }
              ]
            }
          ]
        }
      ],
      "prices": []
    },
    "links": { "self": "https://api.jooraccess.com/v4/retailers/products/12345" }
  }
}

In that example the product level prices array is empty on purpose: this product is priced only at SKU level. It is part of the skus section, so it is a complete current set like everything else, and an empty array means there are no product level prices to keep.

FieldMeaning, and the trap
eventOne of the three event types
versionPayload version, currently 1. Additive fields can appear within a version. Ignore unknown keys rather than failing
delivery_idStable across the retries of one notification. This is your idempotency key
occurred_atWhen we emitted the notification, not when the product changed. Changes can be batched briefly before emitting, so this can be well after the edit. Do not use it to order or age your data
retailer_account_idWhich of your accounts this concerns. Essential if one callback serves several
changesWhich sections are embedded, from core, skus, images, collection_membership. Absent on product.deleted
data.product.version_atThe version to compare on. See Delivery, Idempotency & Suspension, "Idempotency and the shape of your handler"
data.links.selfValid, and optional. A convenience if you ever want to re pull instead of applying what is embedded. Absent on product.deleted

data.links.self as printed is not a request you can send. Every request must carry ?account=, and you will almost always want &collection_id= too, so append them before using it. The value is a base to build on, not a ready made URL.

The sections rule

This is the part to read twice.

SituationInterpretation
Section named in changes, value non emptyReplace your whole copy of that section with this. It is the complete current set, not a diff
Section named in changes, value []That section was emptied. Everything in it was removed
Section not named in changesUnchanged. Keep your copy
An id present in a shown arrayCurrent
An id missing from a shown arrayRemoved. The array is always the full current set

What each section renders:

SectionFields it carries
corename, product_number, code, description, categories, fabrication, materials, silhouette, tags, badges, and the other product level fields
imagesimages[], the product level images
skuscolors[], each with its swatch, its own images and its skus[], plus the product level prices[]
collection_membershipcollections[], as {id, name} pairs

data.product.id and data.product.version_at are always present. data.brand_id and data.links.self are always present on created and updated.

Two things about this table are less precise than the sections rule needs, and both are open:

  • "and the other product level fields" in core does not enumerate what is included, so you cannot tell which of your columns a core replacement is allowed to clear. Ask your JOOR contact for the full field list before you write the apply logic for this section.
  • The skus example carries size_id, size_name, upc and prices but not the delivery_start and delivery_end you get from the Read API, while delivery window changes are supposed to notify (see Coverage). Ask your JOOR contact whether this is an omission in the example or a real gap in what the section carries.

collections[] has no equivalent in the Read API responses (see Connections & Collections), so during a reconciliation you derive current membership the other way round: walk each collection's products and record which collections each product.id appeared in.

What differs per event

Eventchangesdata.productWhat to do
product.createdAll four sectionsEvery section embeddedApply everything and insert
product.updatedOnly the sections that changedJust those, each completeApply each named section. The rest is untouched
product.deletedAbsentAbsent. Id only: data.product_idRemove it from your sellable catalog

The most common integration bug. A handler that reads payload["changes"] or payload["data"]["product"] unconditionally raises on every single deletion, and deletions are the events you least want to drop. Branch on event first.

{
  "event": "product.deleted",
  "version": 1,
  "delivery_id": "e1...",
  "occurred_at": "2026-08-08T14:40:00Z",
  "retailer_account_id": 40975,
  "data": { "brand_id": 73516, "product_id": 12345 }
}

Note the two shapes side by side: on created and updated the id is at data.product.id, on deleted it is at data.product_id. Different paths, same integer.

Worked examples

Only a price changed

changes: ["skus"], with the full colors tree and product level prices.

Replace both colors and prices with what is in the payload. core, images and collection_membership are absent, so leave them exactly as they were.

All product images removed

changes: ["images"], "images": [].

The product has no product level images any more. Clear yours. Note this is different from images being absent, which means nothing changed.

All colors removed, but the product still has a price

changes: ["skus"], "colors": [], "prices": [ ... one entry ... ].

No colors or SKUs any more, but the product itself is still priced. This is exactly what the product level price array exists for. The product still exists: this is a product.updated, not a product.deleted.

One color removed among several

changes: ["skus"], with colors containing only color 456.

Color 457 is gone, because the array is the complete current set. There is no explicit "removed" marker and there does not need to be.

A size kept but all its prices removed

changes: ["skus"], with size 789 present and "prices": [] inside it.

The size still exists and is now unpriced. Depending on your model that may make it unsellable, which is your decision, not ours.

Four sections at once

changes: ["core", "images", "skus", "collection_membership"].

Several edits can be coalesced into one notification: a description change, all images removed, a color deleted and a size added, and the product removed from one of its two collections. Apply each named section as a complete replacement. Sections are independent.

Updated and then deleted in the same window

You receive only the product.deleted. Delete wins; you never get a spurious update first.