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.
| Field | Meaning, and the trap |
|---|---|
event | One of the three event types |
version | Payload version, currently 1. Additive fields can appear within a version. Ignore unknown keys rather than failing |
delivery_id | Stable across the retries of one notification. This is your idempotency key |
occurred_at | When 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_id | Which of your accounts this concerns. Essential if one callback serves several |
changes | Which sections are embedded, from core, skus, images, collection_membership. Absent on product.deleted |
data.product.version_at | The version to compare on. See Delivery, Idempotency & Suspension, "Idempotency and the shape of your handler" |
data.links.self | Valid, 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.
| Situation | Interpretation |
|---|---|
Section named in changes, value non empty | Replace 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 changes | Unchanged. Keep your copy |
| An id present in a shown array | Current |
| An id missing from a shown array | Removed. The array is always the full current set |
What each section renders:
| Section | Fields it carries |
|---|---|
core | name, product_number, code, description, categories, fabrication, materials, silhouette, tags, badges, and the other product level fields |
images | images[], the product level images |
skus | colors[], each with its swatch, its own images and its skus[], plus the product level prices[] |
collection_membership | collections[], 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
coredoes not enumerate what is included, so you cannot tell which of your columns acorereplacement is allowed to clear. Ask your JOOR contact for the full field list before you write the apply logic for this section. - The
skusexample carriessize_id,size_name,upcandpricesbut not thedelivery_startanddelivery_endyou 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
| Event | changes | data.product | What to do |
|---|---|---|---|
product.created | All four sections | Every section embedded | Apply everything and insert |
product.updated | Only the sections that changed | Just those, each complete | Apply each named section. The rest is untouched |
product.deleted | Absent | Absent. Id only: data.product_id | Remove 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.
