PIM Product API taxonomy
Canonical wine/offer/listing model and the PIM endpoint surface.
Wine2You — PIM Product API Taxonomy
Version: 2.1 · Owner: Wine2You Platform · Status: model update (supersedes v1.0; v2.1 adds §14 Data-exposure controls) Scope: the Product Database — canonical wines, offers (supply), inventory, roles, and how a partner publishes them. Companion documents: Order API Taxonomy and Partner Onboarding.
Design thesis. A wine is an independent fact about the world — a complete, deduplicated register of every wine Wine2You knows it can source, enriched from external databases. It exists with or without any offer. An offer is one Originator's supply listing on that wine (stock + a willingness-to-sell price + selling permissions). A Seller layers its own price on top of the offers it curates. Three things stay separate and never duplicate: the canonical wine (descriptive), the offer (supply/commercial), and the listing (a Seller's shelf price). Inventory is a fourth, fully decoupled concern.
1. Overview & design principles
| Principle | What it means for the API |
|---|---|
| Canonical wine is independent | wines holds a complete, deduplicated list of known wines from any source (external DBs, estates, feeds). A wine may have zero offers. Never created per-supplier. |
| Deduplication is mandatory | One wine row per real-world wine. Two suppliers of the same wine = two offers, one wine. Matching is by tiered keys (§3), enforced at ingest. |
| Offer = Originator's supply | An offer is this originator will sell N units of wine W at seller_price P, permitted to these buyers. Uniqueness: wine_id + originator_id (+ condition). |
| Seller price sits on top | The waterfall takes P_B2B from the selected offer's seller_price. A Seller's shelf price is a separate listing computed over that. |
| Roles, one onboarding | A single onboarding flow; roles (Originator · Fulfiller · Seller) are enabled capabilities, not separate programs. |
| Inventory is decoupled | Stock lives on (offer × location), not on the wine. ATP = on_hand − allocated − reserved. |
| Estate-first fulfilment | Fulfilment prefers Estate sales when the estate is on W2Y; otherwise routes to other enabled inventories (indirect sourcing, gated by feed-curation). |
| API-first, storage-agnostic | The contract is the API (OpenAPI 3.x DTOs). Payload/Medusa storage can change without breaking partners. |
The three-way split (integrity contract):
wine (canonical, descriptive) ──1:N──► offer (originator supply: qty, seller_price, permissions)
│
├──1:N──► inventory levels (location × condition)
└──N:1──► listing (seller shelf price, on its own channel)
Monitored nightly: wines with no offer, offers with no stock, listings referencing missing offers → ops alert.
2. Resource map
| Resource | Verb endpoints (REST) | Key fields | Notes |
|---|---|---|---|
| Partner (entity) | GET/POST/PATCH /v1/partners, GET /v1/partners/{id} |
id, legal_name, country, roles[], payout_*, status |
The onboarding record. One record, many roles (§7). |
| Wine (canonical) | GET/POST/PATCH /v1/wines, GET /v1/wines/{id}, POST /v1/wines/{id}/publish, POST /v1/wines/match |
see §4–§5 | The independent descriptive master. May have 0 offers. |
| External ID (crosswalk) | GET/POST /v1/wines/{id}/external-ids |
source, external_id, url, confidence |
Match to Vivino/WS/CT/LWIN/VIVC/eAmbrosia (§3). |
| Offer (supply) | GET/POST/PATCH /v1/offers, GET /v1/offers/{id}, POST /v1/offers/{id}/withdraw |
see §6 | Originator's supply listing; carries seller_price. |
| Listing (shelf) | GET/POST/PATCH /v1/listings, GET /v1/listings/{id} |
seller_id, offer_ref, channel_id, shelf_price, status |
A Seller's price over a curated offer. |
| Media (DAM) | POST /v1/media (signed upload), GET /v1/media/{id} |
id, url, mime, width, height, alt, role |
Assets referenced, never inlined. |
| Stock location | GET/POST /v1/stock-locations |
see §9 | Physical siting (cellar, 3PL, bonded…). |
| Inventory item / level | GET /v1/inventory?offer=&location= |
on_hand, allocated, reserved, atp |
Decoupled from wine and offer. |
| Sell permission | GET /v1/offers/{id}/permissions |
allow/block by country[], named_sellers[], default any |
Originator's selling restrictions (§6). |
| Feed sync | POST /v1/sync/push, POST /v1/sync/pull, GET /v1/sync/status |
direction, scope, cursor | Two-way endless-isles feed (§8). |
| Sales channel | GET/POST /v1/sales-channels |
id, seller_id, listings[] |
One storefront/seller shelf; governs visibility. |
| Curated shop | GET/POST /v1/curated-shops |
id, seller_id, markup, layout, channel_id |
Tenant record: markup + layout. |
| Tax/duty context | embedded + GET /v1/tax-context?origin=&destination= |
see §10 | Computed per destination; not stored per wine. |
| Network snapshot | GET /v1/inventory/network-snapshot |
node capability + coords + stock + duty context | Read side of the whole network. Requires read:network scope (§14c). |
3. Identity, external matching & dedup taxonomy
3a. Two-level identity (industry standard)
wine (producer + cuvée) ──1:N──► vintage + format = SKU
Vivino (wine.id vs vintage.id), Liv-ex LWIN 7/11/16, CellarTracker (iWine + vintage) all split this
way. Wine2You models it as wines (wine, vintage-independent identity) → wine_vintages/variants (vintage +
format). One wine row; many vintage/format SKUs.
3b. Tiered match keys (how a wine is matched to external DBs)
| Tier | Key | Use |
|---|---|---|
| 1 | GTIN / EAN-13 (per format, per vintage per GS1 rule 8.2.1) | retail catalogue match; cases = GTIN-14. Bottle size & ABV are GDSN attributes, not in the barcode. |
| 2 | LWIN 7 (+ vintage → LWIN 11, + case → LWIN 16) | fine-wine trade (Liv-ex, Jancis Robinson, merchants). |
| 3 | normalized natural key: producer + cuvée + vintage/NV + volume_ml + country |
universal fallback; slugify, strip diacritics, unify NV, normalize format units. |
| 4 | label-image match | manual-review fallback (Vivino/Delectable pattern). |
3c. External-ID crosswalk (stored per wine, refreshed by re-match)
gtin[], lwin, vivino_wine_id, vivino_vintage_id, wine_searcher_wine_id, cellartracker_iwine,
wine_com_product_id, vivc_variety_number[], eambrosia_gi_id, eambrosia_gi_name.
This is how a wine sourced from an external DB is linked, not re-keyed.
3d. Dedup rule at ingest
Before creating a wine: resolve Tier 1 → 2 → 3. On match, enrich the existing row (never insert). On no
match, create. A POST /v1/wines/match endpoint returns matched | candidate | new with the tier used.
⚠ No "Wine Business Identifier" exists — that is a fabricated SEO artifact. Do not design to it.
4. Canonical wine record (descriptive & compliance)
The canonical wine is independent of offers and holds everything needed to be compliantly described and externally matched.
| Group | Field | Type | Notes |
|---|---|---|---|
| Identity | title |
text | required |
slug |
text, unique, indexed | URL key | |
producer |
ref → producer/estate | who makes it (independent of who sells it) | |
cuvee / label_name |
text | the wine name within the producer's range | |
medusa_product_id |
text, indexed | the join to the commercial record | |
| Vintage | vintage |
int 1900–2099 | per vintage SKU |
is_non_vintage |
bool | NV | |
nv_base_vintage_pct |
number? | NV blend granularity (base-vintage %) | |
| Colour & category | colour |
enum red|white|rose|orange |
new axis — every external DB carries it |
category |
enum → EU Annex VII Part II | wine|sparkling|quality_sparkling|semi_sparkling|fortified|dessert|must|dealcoholised… |
|
dosage_class |
enum? | sparkling sugar class: brut_nature|extra_brut|brut|extra_dry|dry|demi_sec|sweet |
|
| Regulatory | abv |
number 0–22 | drives excise band + LPA |
volume_in_liters |
number (0.375 | 0.75 | 1.5 | …) | flexible formats, not a 3-value enum | |
hs_code |
text (default 2204.21.79) |
TARIC/HS customs code | |
| Terroir | country |
ISO | |
region → sub_region → appellation |
text | hierarchy, multilingual | |
designation |
text | PDO/PGI/traditional term | |
quality_scheme |
enum? PDO|PGI|GI_none |
join target = eAmbrosia | |
grape_varieties[] |
array {variety, vivc_number, percentage} |
VIVC-keyed for cross-border matching | |
| EU compliance block | bottler {name, address} |
object | mandatory label particular |
importer {name, address}? |
object | for EU imports | |
lot_number |
text | Directive 2011/91/EU | |
allergens[] |
enum | sulphites, egg, milk (on physical label) | |
ingredients |
text? | Reg 2021/2117, mandatory from 2023-12-08 | |
energy_kj_per_100ml / energy_kcal_per_100ml |
number? | mandatory; or e_label_url (QR) |
|
nutrition_optional |
object? | fats/carbs/sugars/protein/salt per 100 ml (voluntary) | |
| Attributes | certifications |
enum[] organic|biodynamic|natural|vegan|sustainable |
|
| Storytelling | description |
richText | |
aroma_profile / palate_profile |
text | ||
food_pairings[] |
array {pairing} |
||
main_image / gallery[] |
upload → media | DAM assets | |
| Ratings | ratings[] |
array {source, score, scale, url} |
crosswalk to Vivino/pro scores |
| Workflow | status |
enum draft|published|archived |
publish gate |
EU mandatory particulars (1308/2013 Annex VIII + 2019/33 + 2021/2117): category, ABV %, provenance (PDO/PGI), bottler, importer, nominal volume, lot, sulphites, dosage class, ingredients + energy value (label or e-label QR; allergens always physical). 85% rules for vintage / single-variety / sub-region. Wine labelling law = Reg 1308/2013 + Delegated Reg 2019/33 + Reg 1169/2011 + Reg 2021/2117 (not 2019/787 — that is spirit drinks).
5. Offer taxonomy (the Originator's supply listing)
An offer is what an Originator puts forward: "I will sell N units of wine W at seller_price P, to these buyers."
Uniqueness: wine_id + originator_id (+ condition for mixed vintages of stock). Zero or many offers per wine.
| Field | Type | Notes |
|---|---|---|
offer_id |
string, opaque | |
wine_id |
ref → wine | never duplicates wine data |
originator_id |
ref → partner | who supplies it (Estate / Collector / Stock-importer) |
seller_price |
number | the provider's "willing-to-sell" / export price — pre tax, duties, platform fees, shipping. This is the P_B2B input to the waterfall. |
currency |
ISO | |
qty_available |
int | headline stock (detail lives in inventory levels §9) |
condition |
enum new|bin_end|mixed_vintage? |
|
incoterm |
enum | FCA/EXW/DAP… (who bears freight) |
min_order_qty / case_size |
int? | MOQ / case multiple |
valid_from / valid_to |
timestamp? | price validity window |
permissions |
object | selling restrictions (below) |
status |
enum draft|active|paused|withdrawn |
|
source |
enum direct|feed|external_match |
provenance |
created_at / updated_at |
timestamp |
Sell permissions (default any):
{ "default": "any",
"allowed_countries": ["CH","DE","NO"], // if present, only these
"named_sellers": ["partner_abc"], // if present, only these
"blocked_countries": ["US"] }
Evaluated at listing time and re-checked at order capture.
Inventory is separate from offer — the offer carries headline qty_available; per-location truth is the
inventory level (§9), so a wine can be sold from several physical pools.
6. Listing taxonomy (a Seller's shelf price)
A Seller curates offers and sets its own selling price on top. The listing is that layer — it never re-states wine or offer data.
| Field | Type | Notes |
|---|---|---|
listing_id |
string | |
seller_id |
ref → partner (role Seller) | |
offer_id |
ref → offer | the supply being resold (or the Seller's own if also Originator) |
channel_id |
ref → sales channel | where it appears |
shelf_price |
number + currency |
Seller's own price over the offer |
curation |
object | what the Seller surfaces (featured, notes, pairing) |
status |
enum draft|live|delisted |
Price layering: shelf_price (Seller) is built over seller_price (Originator's offer). The
order-side waterfall consumes seller_price as P_B2B; the Seller's margin is its own listing layer
(M_Curator-equivalent), not a mutation of the offer.
7. Partner roles (single onboarding, enabled capabilities)
One onboarding flow. Roles are enabled flags, not separate programs.
| Role | What it does | Capability flags |
|---|---|---|
| Originator | Provides data to the canonical wines DB and/or inventory to the offer table. Sub-types: Estate, Collector, Stock (e.g. importer). |
provides_wine_data, provides_inventory, permissions (allowed countries / named sellers / any). |
| Fulfiller | An Originator that also does order fulfilment (pick, pack, ship). On by default for Originators; can also feed wines not yet in any offer. | fulfills_orders, feeds_unoffered_wines. |
| Seller | Generates its own sales orders. Sells own stock (if also Originator) and/or others' inventory (curating what it offers). Sets its own shelf price on the offer-feed. | creates_orders, curates_offers, sets_shelf_price. |
A partner record holds roles[] = any combination. Example: an Estate = Originator + Fulfiller (+ Seller if it
sells direct). A dropship curator = Seller (+ Fulfiller). An importer = Originator (Stock).
8. Two-way endless-isles feed (sync taxonomy)
A partner can be a provider of stock and a receiver/curator of the endless-isles feed — both directions, independently enabled.
| Direction | Endpoint | Payload | Use |
|---|---|---|---|
| Outbound (platform → partner) | POST /v1/sync/pull / webhook |
offers, wines, price/stock deltas, cursor |
partner receives & curates the feed |
| Inbound (partner → platform) | POST /v1/sync/push |
new/updated wines + offers, upsert keys | partner supplies stock / wine data |
| Status | GET /v1/sync/status |
last_cursor, pending, errors |
reconciliation |
- Curation: a receiving partner selects which wines/offers to surface (feed-curation module), sets its own shelf price, and can block by country/seller — it does not blindly mirror.
- Indirect sourcing: feed-curation also enables "other inventories" — wines offered by a partner but not yet in any offer can be proposed, then promoted to an offer by an Originator (§ fulfillment).
- Idempotent upserts keyed on
external_id/wine_id+originator_id; at-least-once withIdempotency-Key.
9. Inventory taxonomy (decoupled from the wine)
| Concept | Meaning | Key fields |
|---|---|---|
| Stock location | one physical cellar/warehouse (per originator or 3PL) | see location schema |
| Inventory item | the stock-keeping record, decoupled from wine and offer | sku, offer_id, owner_entity_id |
| Inventory level | (item × location × condition) → quantities | on_hand, incoming, reserved, allocated |
| ATP | what can be sold right now | atp = on_hand − allocated − reserved (calculated) |
Ownership: each originator's stock sits at its own stock_location with owner_entity_id. Availability is
scoped by the sales channel's linked locations (Medusa v2 reality — see §11), which is what enables
cross-seller dropship: Seller B sells Estate A's stock with no warehouse.
⚠
is_network_shareableis not a Medusa v2 concept (verified: no such field). The real mechanisms areproduct ↔ sales_channel(M:N),sales_channel ↔ stock_location(M:N), andvariant ↔ inventory_item(M:N). Wine2You models "shareable across the network" as its own flag on the offer (network_shareable), resolved against channel↔location links — do not cite a Medusa field that does not exist.
10. Location & tax/duty taxonomy
Location (LocationDataSchema): location_id, gln (GS1), location_type
(WINERY_ESTATE|BONDED_WAREHOUSE|DUTY_PAID_WAREHOUSE|RETAIL_STORE|3PL_FULFILLMENT_CENTER|CONSIGNMENT_CELLAR|IN_TRANSIT_CONTAINER),
address, geo_coordinates, tax_jurisdiction {country, state, excise_tax_zone_id, eu_seed_number},
capabilities {supports_d2c_fulfillment, supports_b2b_wholesale, supports_bonded_transfer, temperature_controlled}.
Tax/duty (TaxDutiesContextSchema): hs_tariff_code, country_of_origin,
duty_status (DUTY_PAID|IN_BOND|DUTY_FREE|EXCISE_SUSPENDED|PRE_ARRIVAL),
volumetric_metrics {volume_liters, net_weight_kg, gross_weight_kg, abv_percentage, liters_pure_alcohol},
tax_classification {sales_tax_category_code, vat_rate_type, excise_tax_category},
cross_border_terms {incoterm, import_duty_rate_percent, preferential_trade_agreement_code},
packaging_and_environmental_levies {container_deposit_fee, epr_recycling_category_code}.
LPA = volume_liters × abv_percentage / 100 — the excise base in every destination.
11. Medusa alignment (v2)
| Wine2You concept | Medusa v2 anchor | Note |
|---|---|---|
| Canonical wine | Product (title, handle, status, hs_code, origin_country, metadata) |
Product carries no price, no stock in v2. |
| Vintage + format SKU | ProductVariant (sku, barcode, ean, upc, allow_backorder, manage_inventory) |
allow_backorder lives on the variant, not the level. |
| Buyer-facing price | PriceSet → Price (currency_code, amount, min_quantity/max_quantity, rules) |
dimensions: currency, region, customer-group, quantity — not seller. |
| Seller/Originator price | Wine2You Offer (seller_price) |
beyond Medusa core — a W2Y concept (§5). |
| Stock | InventoryItem → InventoryLevel (stocked_quantity, reserved_quantity, available_quantity = calc) |
per location. |
| Back-to-back / dropship | ReservationItem (reserve at order → deduct at fulfilment → release on cancel) | the dropship primitive. |
| Channel / shelf | SalesChannel (status: published + sales_channels[] link) |
per seller/storefront. |
| Multi-vendor | Marketplace recipe: custom vendor module + links + vendor-admin auth + per-vendor channel + split-orders |
no vendor/ownership entity in core; created_by is audit-only. |
| Split fulfilment | Fulfillment: FulfillmentSet, ServiceZone/GeoZone, ShippingOption, Fulfillment (an order → many) |
routing is app-level. |
| Wine attributes (colour, region tree, EU block) | custom WineProfile module (Product has no EAV) | typed & queryable, not metadata. |
12. Fulfilment logic (estate-first, then other inventories)
At order capture, routing resolves stock by priority:
- Estate direct — if the wine's estate is an Originator on W2Y, prefer estate stock (estate sale).
- Other inventories — otherwise (or if estate ATP is short), route to other enabled offers/stock.
Indirect sourcing is gated by feed-curation: an originator's
permissions(allowed countries / named sellers / default any) andnetwork_shareablemust permit the selling partner + destination. - Split — if no single source covers the quantity, split across sources (each becomes its own fulfilment leg from its own stock location).
Selection inputs: atp, seller_price, incoterm/freight, permissions, destination eligibility, and priority
tier (estate > indirect). The chosen offer's seller_price becomes that line's P_B2B in the waterfall.
13. Webhooks & events taxonomy (proposed)
| Event | Fires when | Consumer |
|---|---|---|
wine.created |
canonical wine inserted (matched or new) | search, dashboards |
wine.matched |
ingest matched an existing wine via a tier | crosswalk refresh |
wine.published |
publish gate passed | search, storefront, channel sync |
offer.created / offer.updated / offer.withdrawn |
supply changes | availability, sellers |
listing.created / listing.price_changed |
seller shelf changes | storefront, cached quotes |
stock.changed |
inventory level moves | availability |
sync.completed |
a feed push/pull lands | reconciliation |
pim.reconciliation.flagged |
nightly join-integrity failure | ops alert |
Delivery: at-least-once, signed, idempotency key = event id.
14. Data-exposure controls (anti-scrape)
Normative detail (C1–C4, enforcement points, OpenAPI requirements):
Wine2You-Data-Exposure-Controls-Spec.md.
Problem. An entity-scoped key + a rate limit does not stop a legitimate partner from page-walking
GET /v1/wines,GET /v1/offersorGET /v1/inventory/network-snapshotuntil it holds the wholewineandoffertables. These controls make partial data the only data a key can ever hold.
13a. Read-path scoping (the primary defence — enforced on every read): Every list/detail read is filtered, server-side, before serialization. A row is returned only if all pass:
network_shareable == trueon the offer (offers not flagged shareable are invisible to any partner that is not the owning entity — see §9).- The caller's entity passes the offer's
permissions(allowed_countries[]/named_sellers[], defaultany) — the same gate the fulfilment logic uses (§12). - The caller's sales-channel / tenant scope admits the row (channel↔location links, §9).
Entity-scoped keys (Unkey + OpenFGA) decide which entity you are; this filter decides what that entity may see. Both are required — neither substitutes for the other.
13b. Pagination is bounded, not infinite:
- Every list endpoint is cursor-paginated with a hard
page_sizecap (default 50, max 200). - A
network_snapshot-class read (the whole-network view, §2) requires the explicitread:networkscope, which ordinary partner keys do not receive.
13c. Per-key row quota (kills page-walking):
- Each key carries a records-returned quota per rolling period (e.g. 50k rows/day) in addition to
the per-key request rate limit. When exceeded →
429withRetry-After, and the counter is per-entity so one partner cannot exhaust another's budget.
13d. Audit & anomaly signal:
- Every read records
key_id,entity_id,endpoint,rows_returned,cursor_span. Sudden volume spikes (a key pulling N× its trailing average) raise an ops alert rather than silently serving the data.
15. Worked example — canonical wine with two offers and one listing
{
"title": "Barolo DOCG — Cantina X",
"slug": "barolo-docg-cantina-x",
"producer": "prod_cantina_x",
"cuvee": "Barolo DOCG",
"medusa_product_id": "prod_01HX…",
"vintage": 2019, "is_non_vintage": false,
"colour": "red", "category": "wine",
"abv": 14.5, "volume_in_liters": 0.75,
"hs_code": "2204.21.79",
"terroir": {
"country": "IT", "region": "Piemonte", "sub_region": "Langhe",
"appellation": "Barolo DOCG", "designation": "DOCG", "quality_scheme": "PDO",
"grape_varieties": [{ "variety": "Nebbiolo", "vivc_number": 8885, "percentage": 100 }]
},
"eu_compliance": {
"bottler": { "name": "Cantina X S.r.l.", "address": "…" },
"lot_number": "L2019-0043", "allergens": ["sulphites"],
"ingredients": "Grapes: Nebbiolo.", "energy_kj_per_100ml": 310, "energy_kcal_per_100ml": 74,
"e_label_url": "https://…/e-label/L2019-0043"
},
"external_ids": [
{ "source": "gtin", "external_id": "8001234567890", "confidence": 1.0 },
{ "source": "lwin", "external_id": "1005767", "confidence": 0.98 },
{ "source": "vivino_wine_id", "external_id": "1105374", "confidence": 0.91 },
{ "source": "eambrosia_gi_id", "external_id": "PDO-IT-B0442", "confidence": 1.0 }
],
"ratings": [{ "source": "vivino", "score": 4.3, "scale": 5 }],
"status": "published",
"offers": [
{ "offer_id": "off_estate_1", "originator_id": "partner_estate_x", "seller_price": 18.00,
"currency": "EUR", "qty_available": 240, "incoterm": "FCA",
"permissions": { "default": "any" }, "network_shareable": true, "status": "active" },
{ "offer_id": "off_importer_1", "originator_id": "partner_importer_ch", "seller_price": 20.50,
"currency": "EUR", "qty_available": 60, "incoterm": "DAP",
"permissions": { "allowed_countries": ["CH","NO"] }, "status": "active" }
],
"listings": [
{ "listing_id": "lst_curator_9", "seller_id": "partner_curator_ch",
"offer_id": "off_estate_1", "channel_id": "ch_shop_ch", "shelf_price": 34.90,
"currency": "CHF", "status": "live" }
]
}
One canonical wine, two originator offers at different seller_price, one seller's shelf price on top — no
duplication of the wine record.
16. Best-practice notes
- Dedup before create: tiered matching (§3) at ingest; never insert a wine that matches an existing key.
- Idempotency: every write takes
Idempotency-Key; bulk feed retries are safe. - Partial updates:
PATCHmerges; commercial values (offer/listing price, stock) update independently of the descriptive version train — a price change never re-publishes the wine description. - Three-way integrity: nightly monitor wines ↔ offers ↔ listings ↔ inventory; orphans flagged.
- Why the canonical wine must be independent: the same wine record serves every storefront, feed, match and customs document — authored once, consumed everywhere. Duplicating it per supplier is where catalogue projects bleed cost and where "the same wine twice" breaks matching.
- Roles over programs: one onboarding, enable capabilities — avoids four parallel integration guides.
- Scoping beats rate-limiting: an entity-scoped key with a generous rate limit can still walk every page.
The read-path filter (
network_shareable+permissions+ channel/tenant scope), a bounded cursor page size and a per-key row quota are what actually keepwineandofferfrom being bulk-extracted (§14).