Introduction

ERP integrate with the Marketplace through a set of complementary surfaces. This guide is organized into self-contained areas, each with its own authentication, endpoints, and error handling:

Key principle: stock/availability is ERP-owned. The Bridge API seeds the first availability snapshot when a product is first published. After that, every allotment change must come through the stock.updated webhook — the Bridge availability field is ignored for products that already have availability records.

flowchart LR ERP["ERP"] subgraph MP["Marketplace"] BR["Bridge API\n/api/v1/bridge/*"] WH["Stock Webhook\n/api/marketplace/webhooks/stock"] end ERP -- "publish / update / list products" --> BR ERP -- "stock.updated events" --> WH ERP -. "poll delivery status before retry" .-> WH MP -- "live products & availability" --> B2C["B2C Storefront"]

Getting Started

Environments

EnvironmentBase URLNotes
Cloud / Devhttps://api-marketplace.ezbcloud.spaceShared dev environment
ProductionConfirm with backend teamBridge & webhook routes live in the marketplace app

Credentials to request from EZCore

CredentialUsed forHeader
Bridge service keyAll Bridge API callsX-Api-Key

Authentication

Every call to /api/v1/bridge/* authenticates with a single API-key header:

HeaderValueError if missing/invalid
X-Api-KeyYour bridge service key (BridgeServiceKey)401 Unauthorized

Rate limits: 60 write requests/min, 120 read requests/min per key.

Bridge API — Concepts

Lazy tenant provisioning

If the operator.slug in a publish request does not yet exist, the system auto-provisions a new tenant in pending status. The first publish still returns 201 Created. No pre-provisioning step is required.

Upsert by (operator, legacy_product_id)

The Bridge API is idempotent at the product level. If you POST a product with the same operator and legacy_product_id combination that already exists, the server performs an update instead of creating a duplicate:

Status values

StatusBehaviour
publishedLive on storefront; b2c_visible=true. New bookings accepted.
draftSaved, not visible to customers. Useful for pre-launch preparation.
discontinuedPermanently delisted (irreversible). Existing bookings may complete; new bookings blocked.

Availability seeding — one-time only

The availability[] field in a Bridge publish request seeds availability only if the product has no existing availability records. On subsequent publishes (upserts) of the same product, availability[] is ignored. Ongoing stock management is done exclusively through the stock.updated webhook.

Tip: Include a sensible initial availability[] block in your first publish so the product appears bookable immediately. Use stock.updated to decrement or adjust it as bookings arrive.

Publish a Product

POST /api/v1/bridge/products

Request body — top-level fields

FieldTypeReq?Description
operatorOperator BlockrequiredOperator identity — the tenant / agent (travel supplier). See table below.
legacy_product_idstringrequiredYour ERP's unique product identifier. Upsert key with operator.
typeenumrequiredtour_package | hotel | attraction | ferry | cruise | transport
namestringrequiredProduct display name.
slugstringoptionalURL-friendly identifier. Auto-generated from name if omitted.
descriptionstringrequiredFull description shown to customers.
destinationstringrequiredHuman-readable destination name (e.g. "Bali").
country_codestringrequiredISO 3166-1 alpha-2 (e.g. "ID", "SG").
destination_scopeenumoptionaldomestic | international.
images[]arrayoptionalArray of { "url": "..." } objects.
inclusions[]string[]optionalWhat is included in the product price.
exclusions[]string[]optionalWhat is not included.
statusenumoptionalpublished | draft | discontinued. Defaults to draft.
is_flat_ratebooleanoptionalWhen true, price is per-booking rather than per-pax.
floor_pricenumberoptionalMinimum displayed selling price.
physical_capacitynumberoptionalTotal physical units the operator can fulfil (caps allotment across all ranges).
require_adult_paxbooleanoptionalHotel & Cruise only. When true, bookings that include child or infant passengers must also include at least one adult. Omitting the field (or sending null) defaults to false — no enforcement. On re-publish, omitting the field leaves the existing value unchanged.
passenger_data_requirementstringoptional*Passenger Detail Profile code (max 50 chars) controlling what passenger fields the booking flow collects/requires for this product. Legacy values name_only | full_details | lead_only remain valid codes. Omit to fall back to the platform's default profile cascade (operator+type → operator-universal → platform+type → platform-universal). * Required for cruise and ferry — omitting it for these types returns 422 missing_passenger_detail_profile. On re-publish, omitting the field leaves the existing resolved profile unchanged.
passenger_detail_overridesobjectoptionalPer-product tweaks layered on top of whatever profile passenger_data_requirement resolves to. Only fields.<field_key>.visibility is supported today. Result of profile + overrides is stored as products.passenger_detail_config.
type_dataobjectoptional*Type-specific fields. Shape depends on type. See Product Types.
pricing[]PricingRuleInput[]optionalPrice rules per pax_type / variant_code. See below.
availability[]AvailabilityEntry[]optionalInitial allotment ranges. Seeded ONCE on first publish.
e_ticket_delivery_methodenumoptionalauto_generate (default) | manual | third_party | stock_upload. Controls how the marketplace delivers e-tickets to the customer. Use stock_upload for operators (ferry, cruise, attraction) who issue tickets with proprietary barcodes from their own system — ERP pushes the PDF URL(s) via the Pre-Uploaded Voucher callback after booking confirmation.
cancellation_policyCancellationPolicyInputoptionalRefund rules. See below.
translations[]ProductTranslationInput[]optionalLocale-keyed name/description/type_data overlays, written in the same call. Omit entirely to leave existing translations untouched on a re-publish (never implicitly cleared); when included, it whole-set-replaces the product's translation set — a locale present before but absent from this array is deleted. See table below and DESIGN/MultiLanguage.md §5.

Nested input objects

An operator is a tenant / agent (the travel supplier, e.g. "Ace Tours Pte Ltd"). It maps to a tenant in the EZCore multi-tenant system and is auto-provisioned on the first Bridge publish.

FieldTypeDescription
idUUID stringYour operator UUID from EZCore.
slugstringURL-safe operator slug. Auto-provisions tenant if unknown.
namestringDisplay name for the operator.
FieldTypeDescription
pax_typeenumadult | child | infant | senior
amountnumberPrice in the specified currency. Use 0.01 for "free" (not 0).
currencystringISO 4217 code (e.g. "SGD").
variant_codestringHotels/attractions/ferry/cruise: room/ticket/seat/cabin code.
berthstringCruise only: double | single | extra
component_groupstringTour packages with components: component group code.
option_codestringTour packages with components: option code within the group.
FieldDescription
start_date / end_dateDate range for this allotment block (ISO 8601).
total_allotmentTotal seats/rooms/cabins available in this range.
variant_allotments[]Hotels/attractions/ferry: per-variant breakdown.
cabin_allotments[]Cruises: per-cabin-type breakdown. Optionally includes cabin_numbers[] for named-cabin mode.
component_allotments[]Tour packages with components: per-option breakdown.
FieldDescription
typefree_cancellation | non_refundable | tiered
free_cancellation_hoursHours before departure for free cancellation (when type=free_cancellation).
tiers[]Array of { hours_before, charge_pct } for tiered policies.

One entry per locale. Whole-set replace: any locale present on the product before this call but absent from this array is deleted, so always send the complete desired set, not a partial diff. This is one of two channels that write product_translations — the other is the admin-only PUT /api/v1/admin/products/{id}/translations endpoint, which this Bridge credential cannot reach, so translations[] is the only way the ERP can populate or verify locale content here (it is echoed back in the publish/update response and in GET /api/v1/bridge/products/{legacy_product_id}).

FieldTypeReq?Description
localestringrequiredMust be one of the platform's configured supported locales (currently id, zh; en is the canonical field, not a translation entry). Unsupported locale or a duplicate locale within the array returns HTTP 422.
namestringoptionalTranslated product name. Falls back to the canonical name when omitted or no translation row exists for the requested locale.
descriptionstringoptionalTranslated description. Same fallback behaviour as name.
type_dataobjectoptionalSparse overlay onto type_data — only the fields being translated need to be present. Nested arrays are merged back onto the canonical type_data by code (room_types[]/cabin_types[]) or day (itinerary[]); untranslated entries/fields pass through unchanged.

Sequence diagram

sequenceDiagram participant ERP participant MP as Marketplace (Bridge API) ERP->>MP: POST /api/v1/bridge/products (X-Api-Key) Note over MP: Resolve / auto-provision operator tenant Note over MP: Upsert by (operator, legacy_product_id) Note over MP: Seed availability ONLY if product has none yet alt new product MP-->>ERP: 201 Created + BridgeProduct else existing product MP-->>ERP: 200 OK + BridgeProduct end

Request & Response

curl -X POST https://api-marketplace.ezbcloud.space/api/v1/bridge/products \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: <bridge-service-key>" \
  -d '{
  "operator": {
    "id": "a1b2c3d4-0001-0001-0001-000000000001",
    "slug": "ace-tours",
    "name": "Ace Tours Pte Ltd"
  },
  "legacy_product_id": "BTM-001",
  "type": "tour_package",
  "name": "3D2N Batam Fun Package",
  "description": "All-inclusive Batam escape with your choice of ferry operator and hotel. Perfect for a quick weekend getaway from Singapore.",
  "destination": "Batam",
  "country_code": "ID",
  "destination_scope": "international",
  "images": [
    { "url": "https://picsum.photos/seed/batam1/800/600" },
    { "url": "https://picsum.photos/seed/batam2/800/600" }
  ],
  "inclusions": [
    "2 nights hotel accommodation",
    "Return ferry ticket",
    "Daily breakfast",
    "Batam city tour (half day)"
  ],
  "exclusions": [
    "Personal expenses",
    "Indonesia departure tax",
    "Optional tours"
  ],
  "physical_capacity": 50,
  "passenger_data_requirement": "full_details",
  "type_data": {
    "duration_nights": 2,
    "departure_point": "Singapore",
    "min_pax": 1,
    "max_pax": 10,
    "itinerary": [
      { "day": 1, "title": "Arrival in Batam", "description": "Ferry from Singapore HarbourFront. Check-in and leisure.", "meals": [], "accommodation": "Selected hotel" },
      { "day": 2, "title": "Batam City Tour", "description": "Half-day city tour covering Nagoya Hill and local seafood lunch. Afternoon at leisure.", "meals": ["breakfast"], "accommodation": "Selected hotel" },
      { "day": 3, "title": "Departure", "description": "Breakfast, check-out, return ferry to Singapore.", "meals": ["breakfast"], "accommodation": null }
    ],
    "component_groups": [
      {
        "code": "ferry",
        "label": "Ferry Operator",
        "is_required": true,
        "hold_unit": "per_pax",
        "options": [
          { "code": "batam-fast", "label": "Batam Fast", "is_default": true, "images": [{ "url": "https://picsum.photos/seed/ferry1/800/600" }], "product_id": null, "share_inventory": false, "use_product_pricing": false },
          { "code": "indo-falcon", "label": "Indo Falcon", "is_default": false, "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false }
        ]
      },
      {
        "code": "hotel",
        "label": "Hotel",
        "is_required": true,
        "hold_unit": "per_pax",
        "options": [
          { "code": "harris", "label": "Harris Resort Batam Center", "is_default": true, "images": [{ "url": "https://picsum.photos/seed/harris1/800/600" }], "product_id": null, "share_inventory": false, "use_product_pricing": false },
          { "code": "golden-view", "label": "Golden View Hotel", "is_default": false, "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false }
        ]
      }
    ]
  },
  "pricing": [
    { "pax_type": "adult", "amount": 299.00, "currency": "SGD" },
    { "pax_type": "child", "amount": 199.00, "currency": "SGD" },
    { "component_group": "ferry", "option_code": "batam-fast",  "pax_type": "adult", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "ferry", "option_code": "batam-fast",  "pax_type": "child", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "ferry", "option_code": "indo-falcon", "pax_type": "adult", "amount": 15.00, "currency": "SGD" },
    { "component_group": "ferry", "option_code": "indo-falcon", "pax_type": "child", "amount": 10.00, "currency": "SGD" },
    { "component_group": "hotel", "option_code": "harris",      "pax_type": "adult", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "hotel", "option_code": "harris",      "pax_type": "child", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "hotel", "option_code": "golden-view", "pax_type": "adult", "amount": 30.00, "currency": "SGD" },
    { "component_group": "hotel", "option_code": "golden-view", "pax_type": "child", "amount": 20.00, "currency": "SGD" }
  ],
  "availability": [
    {
      "start_date": "2026-07-01",
      "end_date": "2026-09-30",
      "total_allotment": 50,
      "component_allotments": [
        { "component_group": "ferry", "option_code": "batam-fast",  "total_allotment": 30 },
        { "component_group": "ferry", "option_code": "indo-falcon", "total_allotment": 20 },
        { "component_group": "hotel", "option_code": "harris",      "total_allotment": 25 },
        { "component_group": "hotel", "option_code": "golden-view", "total_allotment": 25 }
      ]
    },
    {
      "start_date": "2026-10-01",
      "end_date": "2026-12-31",
      "total_allotment": 40,
      "component_allotments": [
        { "component_group": "ferry", "option_code": "batam-fast",  "total_allotment": 20 },
        { "component_group": "ferry", "option_code": "indo-falcon", "total_allotment": 20 },
        { "component_group": "hotel", "option_code": "harris",      "total_allotment": 20 },
        { "component_group": "hotel", "option_code": "golden-view", "total_allotment": 20 }
      ]
    }
  ],
  "translations": [
    {
      "locale": "id",
      "name": "Paket Seru 3H2M Batam",
      "description": "Liburan singkat ke Batam, serba termasuk, dengan pilihan operator ferry dan hotel. Cocok untuk akhir pekan singkat dari Singapura.",
      "type_data": {
        "itinerary": [
          { "day": 1, "title": "Tiba di Batam", "description": "Ferry dari Singapore HarbourFront. Check-in dan waktu bebas." }
        ]
      }
    },
    {
      "locale": "zh",
      "name": "巴淡岛3天2晚欢乐套餐",
      "description": "全包式巴淡岛周末游,可自选渡轮公司和酒店,是新加坡出发的理想短途假期。",
      "type_data": {
        "itinerary": [
          { "day": 1, "title": "抵达巴淡岛", "description": "从新加坡港湾中心乘渡轮前往,办理入住手续后自由活动。" }
        ]
      }
    }
  ],
  "status": "published"
}'

translations[] is optional — this example publishes the canonical (en) content above plus id/zh translations in the same call. Omit the field entirely on a routine re-publish to leave existing translations untouched; a locale left out of a re-publish that does include translations[] is deleted, so always send the full desired set.

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "legacy_product_id": "BTM-001",
  "operator": { "id": "a1b2c3d4-0001-0001-0001-000000000001", "slug": "ace-tours", "name": "Ace Tours Pte Ltd" },
  "type": "tour_package",
  "name": "3D2N Batam Fun Package",
  "destination": "Batam",
  "country_code": "ID",
  "destination_scope": "international",
  "physical_capacity": 50,
  "status": "published",
  "b2c_visible": true,
  "translations": [
    {
      "locale": "id",
      "name": "Paket Seru 3H2M Batam",
      "description": "Liburan singkat ke Batam, serba termasuk, dengan pilihan operator ferry dan hotel. Cocok untuk akhir pekan singkat dari Singapura.",
      "type_data": {
        "itinerary": [
          { "day": 1, "title": "Tiba di Batam", "description": "Ferry dari Singapore HarbourFront. Check-in dan waktu bebas." }
        ]
      }
    },
    {
      "locale": "zh",
      "name": "巴淡岛3天2晚欢乐套餐",
      "description": "全包式巴淡岛周末游,可自选渡轮公司和酒店,是新加坡出发的理想短途假期。",
      "type_data": {
        "itinerary": [
          { "day": 1, "title": "抵达巴淡岛", "description": "从新加坡港湾中心乘渡轮前往,办理入住手续后自由活动。" }
        ]
      }
    }
  ],
  "created_at": "2026-07-01T10:00:00Z",
  "updated_at": "2026-07-01T10:00:00Z"
}

The publish/update response always echoes back the product's current translations[], since this Bridge credential cannot call the admin-only translations endpoint to read it any other way.

Update a Product

PUT /api/v1/bridge/products/{legacy-product-id}

Partial update: only the fields you include are changed. Fields you omit keep their existing values.

Pricing replacement: If you include pricing[] in an update request, the entire base-rate pricing block is replaced. You cannot add or remove a single row; you must re-send all pricing rows you want to keep.

Common update patterns

# Change status only
curl -X PUT https://api-marketplace.ezbcloud.space/api/v1/bridge/products/PKG-BALI-5D4N \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: <key>" \
  -d '{ "status": "draft" }'

# Update pricing (entire block replaced)
curl -X PUT https://api-marketplace.ezbcloud.space/api/v1/bridge/products/PKG-BALI-5D4N \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: <key>" \
  -d '{
    "pricing": [
      { "pax_type": "adult",  "amount": 949.00, "currency": "SGD" },
      { "pax_type": "child",  "amount": 749.00, "currency": "SGD" },
      { "pax_type": "infant", "amount": 99.00,  "currency": "SGD" }
    ]
  }'

Unpublish / Delist a Product

DELETE /api/v1/bridge/products/{legacy-product-id}
Original statusResultReversible?
published or draftStatus → unpublished (soft delist). Product hidden from storefront. New bookings blocked.Yes — re-publish via PUT status: published
discontinuedStatus → delisted (permanent removal).No — irreversible
In-progress bookings are not cancelled when a product is unpublished. They complete normally.

List Products (Sync)

GET /api/v1/bridge/products

Returns a paginated list of lightweight BridgeProductListItem objects (no type_data or images). Use for periodic reconciliation.

Query parameters

ParameterTypeDescription
statusstringFilter by status: published, draft, unpublished, discontinued
operator_idUUIDFilter to a single operator.
updated_afterISO 8601 datetimeIncremental sync — only products updated after this timestamp.
pageintegerPage number (1-indexed).
per_pageintegerResults per page. Maximum 200.

Response example

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "legacy_product_id": "PKG-BALI-5D4N",
      "type": "tour_package",
      "name": "Bali Cultural Journey 5D4N",
      "status": "published",
      "b2c_visible": true,
      "updated_at": "2026-07-01T10:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 50,
    "total": 1,
    "last_page": 1
  }
}

Product Types & type_data

Every product has a type field that selects one of six product types. Each type has a distinct type_data shape — a mismatch returns 422 Unprocessable Entity. The subsections below show the type_data fields and a full success-scenario publish example for each type.

Tour Package — type: "tour_package"

type_data fields

FieldTypeDescription
duration_nightsintegerNumber of nights. Use 0 for day trips.
departure_pointstringOrigin city/terminal.
min_paxintegerMinimum passengers required to confirm.
max_paxintegerMaximum passengers allowed.
itinerary[]arrayDay-by-day itinerary. Each entry: day, title, description, meals[] (e.g. ["breakfast","lunch"]), accommodation (string or null).
component_groups[]arrayOptional. Selectable components (e.g. ferry + hotel options). Each group: code, label, is_required, hold_unit (per_pax|per_booking), options[]code, label, is_default, share_inventory, use_product_pricing, product_id.

Pricing note for components: Base pricing is per pax_type. Component surcharges add extra rows keyed by component_group + option_code + pax_type. A surcharge of 0.01 means "no extra cost." Negative amounts are discounts.

Example — Bintan 3D2N with 3 component groups (success scenario)

This package lets the customer select a ferry, a resort, and a room type. Each group carries its own surcharge rows in pricing[] and per-option inventory in availability[].component_allotments[]. Default options are priced at 0.01 (no extra cost); upgrades add a surcharge.

{
  "operator": {
    "id": "a1b2c3d4-0011-0011-0011-000000000011",
    "slug": "sun-holidays",
    "name": "Sun Holidays Travel Pte Ltd"
  },
  "legacy_product_id": "PKG-BIN-3D2N-COMP",
  "type": "tour_package",
  "name": "Bintan Resort Escape 3D2N — Ferry, Hotel & Room Selection",
  "description": "Premium Bintan package with full component flexibility: choose your ferry, resort, and room type. Includes breakfast and a half-day island tour.",
  "destination": "Bintan",
  "country_code": "ID",
  "destination_scope": "international",
  "images": [
    { "url": "https://picsum.photos/seed/bin3d1/800/600" },
    { "url": "https://picsum.photos/seed/bin3d2/800/600" },
    { "url": "https://picsum.photos/seed/bin3d3/800/600" }
  ],
  "inclusions": [
    "2 nights resort (your choice)",
    "Return ferry (your choice)",
    "Daily breakfast",
    "Bintan island tour (half-day)"
  ],
  "exclusions": [
    "Watersports",
    "Dinner",
    "Indonesia departure tax"
  ],
  "physical_capacity": 60,
  "type_data": {
    "duration_nights": 2,
    "departure_point": "Singapore (Tanah Merah Ferry Terminal)",
    "min_pax": 2,
    "max_pax": 12,
    "itinerary": [
      { "day": 1, "title": "Arrival & Resort Check-in", "description": "Ferry from Tanah Merah. Resort check-in from 2 PM. Afternoon beach leisure.", "meals": [],           "accommodation": "Selected resort" },
      { "day": 2, "title": "Island Tour",               "description": "Morning half-day island discovery tour. Afternoon free at resort.",          "meals": ["breakfast"], "accommodation": "Selected resort" },
      { "day": 3, "title": "Departure",                 "description": "Breakfast. Check-out. Return ferry to Singapore.",                            "meals": ["breakfast"], "accommodation": null }
    ],
    "component_groups": [
      {
        "code": "ferry",
        "label": "Ferry",
        "is_required": true,
        "hold_unit": "per_pax",
        "options": [
          { "code": "bintan-resort-ferry", "label": "Bintan Resort Ferry", "is_default": true,  "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false },
          { "code": "majestic-fast",        "label": "Majestic Fast Ferry", "is_default": false, "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false }
        ]
      },
      {
        "code": "resort",
        "label": "Resort",
        "is_required": true,
        "hold_unit": "per_pax",
        "options": [
          { "code": "blag",    "label": "Bintan Lagoon Resort (5-star)",  "is_default": true,  "images": [{ "url": "https://picsum.photos/seed/blag/400/300" }], "product_id": null, "share_inventory": false, "use_product_pricing": false },
          { "code": "nirwana", "label": "Nirwana Gardens Resort (4-star)", "is_default": false, "images": [{ "url": "https://picsum.photos/seed/nirw/400/300" }], "product_id": null, "share_inventory": false, "use_product_pricing": false }
        ]
      },
      {
        "code": "room_type",
        "label": "Room Type",
        "is_required": true,
        "hold_unit": "per_booking",
        "options": [
          { "code": "garden", "label": "Garden View Room", "is_default": true,  "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false },
          { "code": "sea",    "label": "Sea View Room",    "is_default": false, "images": [], "product_id": null, "share_inventory": false, "use_product_pricing": false }
        ]
      }
    ]
  },
  "pricing": [
    { "pax_type": "adult", "amount": 349.00, "currency": "SGD" },
    { "pax_type": "child", "amount": 229.00, "currency": "SGD" },
    { "component_group": "ferry",     "option_code": "bintan-resort-ferry", "pax_type": "adult", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "ferry",     "option_code": "bintan-resort-ferry", "pax_type": "child", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "ferry",     "option_code": "majestic-fast",        "pax_type": "adult", "amount": 8.00,  "currency": "SGD" },
    { "component_group": "ferry",     "option_code": "majestic-fast",        "pax_type": "child", "amount": 5.00,  "currency": "SGD" },
    { "component_group": "resort",    "option_code": "blag",                 "pax_type": "adult", "amount": 50.00, "currency": "SGD" },
    { "component_group": "resort",    "option_code": "blag",                 "pax_type": "child", "amount": 50.00, "currency": "SGD" },
    { "component_group": "resort",    "option_code": "nirwana",              "pax_type": "adult", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "resort",    "option_code": "nirwana",              "pax_type": "child", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "room_type", "option_code": "garden",               "pax_type": "adult", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "room_type", "option_code": "garden",               "pax_type": "child", "amount": 0.01,  "currency": "SGD" },
    { "component_group": "room_type", "option_code": "sea",                  "pax_type": "adult", "amount": 60.00, "currency": "SGD" },
    { "component_group": "room_type", "option_code": "sea",                  "pax_type": "child", "amount": 60.00, "currency": "SGD" }
  ],
  "availability": [
    {
      "start_date": "2026-07-01",
      "end_date": "2026-12-31",
      "total_allotment": 60,
      "component_allotments": [
        { "component_group": "ferry",     "option_code": "bintan-resort-ferry", "total_allotment": 40 },
        { "component_group": "ferry",     "option_code": "majestic-fast",        "total_allotment": 20 },
        { "component_group": "resort",    "option_code": "blag",                 "total_allotment": 30 },
        { "component_group": "resort",    "option_code": "nirwana",              "total_allotment": 30 },
        { "component_group": "room_type", "option_code": "garden",               "total_allotment": 40 },
        { "component_group": "room_type", "option_code": "sea",                  "total_allotment": 20 }
      ]
    }
  ],
  "status": "published"
}

Hotel — type: "hotel"

type_data fields

FieldTypeDescription
star_ratinginteger1–5 stars.
check_in_timestring (HH:MM)Standard check-in time.
check_out_timestring (HH:MM)Standard check-out time.
amenities[]string[]Free-form amenity tags (e.g. "wifi", "pool", "gym").
room_types[]arrayEach entry: code, name, description, max_guests, images[].

Top-level hotel extras (richer than current spec):

FieldDescription
physical_capacityTotal room count in the property.
require_adult_paxWhen true, bookings that include child or infant guests must also include at least one adult guest. Defaults to false (no enforcement) when omitted or null. On re-publish, omitting the field leaves the existing value unchanged.
addons[]Optional upsells. Each: legacy_addon_id, name, unit (per_night|per_booking), price, currency, max_quantity (null = unlimited).

Pricing: keyed by variant_code (room code) × pax_type.
Availability: includes variant_allotments[] with { "variant_code": "std", "total_allotment": 50 }.

Example — HTL-02: 3-star, 2 room types, 1 addon (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0002-0002-0002-000000000002",
    "slug": "marina-hotels",
    "name": "Marina Hotels Group Pte Ltd"
  },
  "legacy_product_id": "HTL-MID-SGP-002",
  "type": "hotel",
  "name": "Orchard View Hotel",
  "description": "3-star hotel on Orchard Road. Modern rooms with city views, rooftop pool, 10-min walk to three MRT stations.",
  "destination": "Singapore",
  "country_code": "SG",
  "images": [
    { "url": "https://cdn.example.com/orchard-view1.jpg" },
    { "url": "https://cdn.example.com/orchard-view2.jpg" }
  ],
  "inclusions": ["Complimentary Wi-Fi", "Rooftop pool access", "Daily housekeeping"],
  "exclusions": ["Breakfast (available as add-on)", "Airport transfers", "Parking"],
  "physical_capacity": 80,
  "type_data": {
    "star_rating": 3,
    "check_in_time": "15:00",
    "check_out_time": "11:00",
    "amenities": ["wifi", "pool", "gym"],
    "room_types": [
      {
        "code": "std",
        "name": "Standard City Room",
        "description": "City view, queen bed, 24 sqm. En-suite with rain shower.",
        "max_guests": 2,
        "images": [{ "url": "https://cdn.example.com/orchard-std.jpg" }]
      },
      {
        "code": "dlx",
        "name": "Deluxe Orchard Room",
        "description": "Orchard Road view, king bed, 30 sqm. Separate soaking tub.",
        "max_guests": 2,
        "images": [{ "url": "https://cdn.example.com/orchard-dlx.jpg" }]
      }
    ]
  },
  "pricing": [
    { "variant_code": "std", "pax_type": "adult", "amount": 148.00, "currency": "SGD" },
    { "variant_code": "std", "pax_type": "child", "amount":   0.01, "currency": "SGD" },
    { "variant_code": "dlx", "pax_type": "adult", "amount": 188.00, "currency": "SGD" },
    { "variant_code": "dlx", "pax_type": "child", "amount":   0.01, "currency": "SGD" }
  ],
  "availability": [
    {
      "start_date": "2026-07-01",
      "end_date": "2026-12-31",
      "total_allotment": 80,
      "variant_allotments": [
        { "variant_code": "std", "total_allotment": 50 },
        { "variant_code": "dlx", "total_allotment": 30 }
      ]
    }
  ],
  "addons": [
    {
      "legacy_addon_id": "OVH-BRKFST",
      "name": "American Breakfast (per person)",
      "unit": "per_night",
      "price": 22.00,
      "currency": "SGD",
      "max_quantity": null
    }
  ],
  "status": "published"
}
Multi-addon pattern (HTL-03): Use the same addons[] array with multiple entries. For example: buffet breakfast per_night, airport transfer per_booking with max_quantity: 1, and valet parking per_night. See 03_Hotel.http, item HTL-03 for the full example.

Attraction — type: "attraction"

type_data fields

FieldTypeDescription
location_namestringAttraction display name.
addressstringFull street address.
latitude / longitudenumberGPS coordinates.
operating_hours[]arrayEach entry: days[] (0=Sun, 1=Mon … 6=Sat), open (HH:MM), close (HH:MM).
ticket_types[]arrayEach: code, name, pax_type.
has_time_slotsbooleantrue → timed-entry; slots are time windows in the Stock webhook's session_time field.
time_slots[]arrayEach: code, label, capacity_per_slot. Empty array when has_time_slots=false.
advance_booking_required_daysintegerMinimum days before visit. 0 = same-day OK.

Pricing: keyed by variant_code (ticket code) × pax_type.
Availability: flat range — no variant breakdown required for simple products.

Example — ATT-01: Universal Studios (no time slots, 4 pax types)

{
  "operator": {
    "id": "a1b2c3d4-0005-0005-0005-000000000005",
    "slug": "sentosa-parks",
    "name": "Sentosa Parks & Attractions Pte Ltd"
  },
  "legacy_product_id": "ATT-USS-SGP",
  "type": "attraction",
  "name": "Universal Studios Singapore — General Admission",
  "description": "A full day of thrills, rides, and live shows at Universal Studios Singapore.",
  "destination": "Singapore",
  "country_code": "SG",
  "images": [{ "url": "https://cdn.example.com/uss1.jpg" }],
  "inclusions": ["Full-day general admission", "Access to all rides", "All live shows"],
  "exclusions": ["Universal Express Pass", "Food and beverages", "Merchandise"],
  "type_data": {
    "location_name": "Universal Studios Singapore",
    "address": "8 Sentosa Gateway, Singapore 098269",
    "latitude": 1.2540,
    "longitude": 103.8239,
    "operating_hours": [
      { "days": [0, 1, 2, 3, 4], "open": "10:00", "close": "21:00" },
      { "days": [5, 6],          "open": "10:00", "close": "22:00" }
    ],
    "ticket_types": [
      { "code": "ADT", "name": "Adult (13+)",   "pax_type": "adult"  },
      { "code": "CHD", "name": "Child (4–12)",  "pax_type": "child"  },
      { "code": "INF", "name": "Infant (0–3)",  "pax_type": "infant" },
      { "code": "SNR", "name": "Senior (60+)",  "pax_type": "senior" }
    ],
    "has_time_slots": false,
    "time_slots": [],
    "advance_booking_required_days": 1
  },
  "pricing": [
    { "variant_code": "ADT", "pax_type": "adult",  "amount": 88.00, "currency": "SGD" },
    { "variant_code": "CHD", "pax_type": "child",  "amount": 68.00, "currency": "SGD" },
    { "variant_code": "INF", "pax_type": "infant", "amount":  0.01, "currency": "SGD" },
    { "variant_code": "SNR", "pax_type": "senior", "amount": 68.00, "currency": "SGD" }
  ],
  "availability": [{ "start_date": "2026-07-01", "end_date": "2026-12-31", "total_allotment": 500 }],
  "status": "published"
}
Timed-entry pattern (ATT-02): Set has_time_slots: true and populate time_slots[] with { "code": "SLOT-1000", "label": "10:00 AM", "capacity_per_slot": 80 } entries. When sending stock updates for this product, use the session_time field in StockChange to address a specific slot.

Ferry — type: "ferry"

type_data fields

FieldTypeDescription
operatorstringFerry operator display name.
origin_port / origin_port_codestringHuman-readable name and short code (e.g. "SGHHF").
destination_port / destination_port_codestringDestination port name and code.
trip_typeenumone_way | return
schedule[]arrayEach departure: departure_time (HH:MM), arrival_time (HH:MM), days_of_week[] (0=Sun … 6=Sat).
seat_classes[]arrayEach: code, name, description, amenities[], is_flat_rate (bool — flat = single price regardless of pax count).

Pricing: keyed by variant_code (seat-class code) × pax_type. Flat-rate classes typically have only an adult row.

Example — FRY-02: Economy flat-rate + VIP per-pax (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0004-0004-0004-000000000004",
    "slug": "batam-fast-pte",
    "name": "Batam Fast Pte Ltd"
  },
  "legacy_product_id": "FRY-BF-SIN-BTM-VIP",
  "type": "ferry",
  "name": "Batam Fast — Singapore HarbourFront → Batam Centre (Economy & VIP)",
  "description": "Choose Economy or VIP class on Batam Fast's daily service. VIP class includes priority boarding, recliner seats, and complimentary refreshments.",
  "destination": "Batam",
  "country_code": "ID",
  "images": [{ "url": "https://cdn.example.com/batamfast-vip1.jpg" }],
  "inclusions": ["Ferry passage (one way)", "Terminal handling fees"],
  "exclusions": ["Indonesia departure tax", "Travel insurance"],
  "type_data": {
    "operator": "Batam Fast",
    "origin_port": "HarbourFront Terminal 1",
    "origin_port_code": "SGHHF",
    "destination_port": "Batam Centre Ferry Terminal",
    "destination_port_code": "BTMCF",
    "trip_type": "one_way",
    "schedule": [
      { "departure_time": "08:00", "arrival_time": "09:00", "days_of_week": [0,1,2,3,4,5,6] },
      { "departure_time": "10:30", "arrival_time": "11:30", "days_of_week": [0,1,2,3,4,5,6] },
      { "departure_time": "14:00", "arrival_time": "15:00", "days_of_week": [0,1,2,3,4,5,6] },
      { "departure_time": "17:00", "arrival_time": "18:00", "days_of_week": [0,1,2,3,4,5,6] }
    ],
    "seat_classes": [
      {
        "code": "ECO",
        "name": "Economy",
        "description": "Standard air-conditioned main deck seating.",
        "amenities": [],
        "is_flat_rate": true
      },
      {
        "code": "VIP",
        "name": "VIP Cabin",
        "description": "Private forward cabin. Recliner seats, priority boarding, bottled water and snack.",
        "amenities": ["priority_boarding", "refreshments", "wider_seats", "power_outlet"],
        "is_flat_rate": false
      }
    ]
  },
  "pricing": [
    { "variant_code": "ECO", "pax_type": "adult", "amount": 32.00, "currency": "SGD" },
    { "variant_code": "VIP", "pax_type": "adult", "amount": 58.00, "currency": "SGD" },
    { "variant_code": "VIP", "pax_type": "child", "amount": 45.00, "currency": "SGD" }
  ],
  "availability": [{ "start_date": "2026-07-01", "end_date": "2026-12-31", "total_allotment": 300 }],
  "status": "published"
}

Note: Economy is is_flat_rate: true with only an adult row. VIP is per-pax with adult and child rows.

Cruise — type: "cruise"

type_data fields

FieldTypeDescription
ship_namestringName of the vessel.
ship_operatorstringCruise line name.
route_namestringHuman-readable route description.
ports_of_call[]arrayEach port: order, port (name), arrival (ISO 8601 datetime or null for first port), departure (ISO 8601 datetime or null for last port).
departure_date / return_datedate stringCruise start and end dates (ISO 8601).
duration_nightsintegerNumber of nights at sea.
booking_unitstringper_cabin (standard).
cabin_types[]arrayEach: code, max_occupancy, min_occupancy, max_adults, max_children, max_infants, positions[]{ "code": "midship", "price_modifier": 50 }.

Top-level cruise extras:

FieldDescription
require_adult_paxWhen true, bookings that include child or infant passengers must also include at least one adult passenger. Defaults to false (no enforcement) when omitted or null. On re-publish, omitting the field leaves the existing value unchanged.

Pricing (richer than current spec): keyed by variant_code (cabin code) × berth (double|single|extra) × pax_type.
Availability: includes cabin_allotments[] with { "variant_code": "INT", "total_allotment": 80 }. For named-cabin mode, add cabin_numbers[] array.

Example — CRU-01: 2-night round-trip, 3 cabin types (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0003-0003-0003-000000000003",
    "slug": "genting-cruises",
    "name": "Genting Cruise Lines"
  },
  "legacy_product_id": "CRU-GD-2N-RT",
  "type": "cruise",
  "name": "Genting Dream — 2N Weekend Getaway (Singapore Round-Trip)",
  "description": "A 2-night weekend cruise aboard Genting Dream. No port calls — pure ocean escape. Departs Friday evening, returns Sunday afternoon.",
  "destination": "International Waters",
  "country_code": "SG",
  "images": [{ "url": "https://cdn.example.com/genting-2n1.jpg" }],
  "inclusions": [
    "2-night cabin accommodation", "All meals at main dining restaurants",
    "Entertainment and shows", "Pool and gym access"
  ],
  "exclusions": ["Specialty dining", "Alcoholic beverages", "Spa treatments", "Gratuities"],
  "type_data": {
    "ship_name": "Genting Dream",
    "ship_operator": "Genting Cruise Lines",
    "route_name": "Singapore – International Waters – Singapore",
    "ports_of_call": [
      { "order": 1, "port": "Singapore (Marina Bay Cruise Centre)", "arrival": null,                  "departure": "2026-08-07T18:00:00" },
      { "order": 2, "port": "Singapore (Marina Bay Cruise Centre)", "arrival": "2026-08-09T14:00:00", "departure": null }
    ],
    "departure_date": "2026-08-07",
    "return_date":    "2026-08-09",
    "duration_nights": 2,
    "booking_unit": "per_cabin",
    "cabin_types": [
      {
        "code": "INT", "max_occupancy": 4, "min_occupancy": 2,
        "max_adults": 2, "max_children": 2, "max_infants": 1,
        "positions": [
          { "code": "forward", "price_modifier": 0  },
          { "code": "midship", "price_modifier": 50 },
          { "code": "aft",     "price_modifier": 30 }
        ]
      },
      {
        "code": "OCV", "max_occupancy": 4, "min_occupancy": 2,
        "max_adults": 2, "max_children": 2, "max_infants": 1,
        "positions": [
          { "code": "forward", "price_modifier": 0  },
          { "code": "midship", "price_modifier": 60 },
          { "code": "aft",     "price_modifier": 40 }
        ]
      },
      {
        "code": "BLC", "max_occupancy": 4, "min_occupancy": 2,
        "max_adults": 2, "max_children": 2, "max_infants": 1,
        "positions": [
          { "code": "forward", "price_modifier": 0  },
          { "code": "midship", "price_modifier": 80 },
          { "code": "aft",     "price_modifier": 60 }
        ]
      }
    ]
  },
  "pricing": [
    { "variant_code": "INT", "berth": "double", "pax_type": "adult",  "amount":  388.00, "currency": "SGD" },
    { "variant_code": "INT", "berth": "single", "pax_type": "adult",  "amount":  718.00, "currency": "SGD" },
    { "variant_code": "INT", "berth": "extra",  "pax_type": "adult",  "amount":  244.00, "currency": "SGD" },
    { "variant_code": "INT", "berth": "extra",  "pax_type": "child",  "amount":  237.00, "currency": "SGD" },
    { "variant_code": "INT", "berth": "extra",  "pax_type": "infant", "amount":  155.00, "currency": "SGD" },
    { "variant_code": "OCV", "berth": "double", "pax_type": "adult",  "amount":  488.00, "currency": "SGD" },
    { "variant_code": "OCV", "berth": "single", "pax_type": "adult",  "amount":  903.00, "currency": "SGD" },
    { "variant_code": "OCV", "berth": "extra",  "pax_type": "adult",  "amount":  307.00, "currency": "SGD" },
    { "variant_code": "OCV", "berth": "extra",  "pax_type": "child",  "amount":  297.00, "currency": "SGD" },
    { "variant_code": "OCV", "berth": "extra",  "pax_type": "infant", "amount":  195.00, "currency": "SGD" },
    { "variant_code": "BLC", "berth": "double", "pax_type": "adult",  "amount":  688.00, "currency": "SGD" },
    { "variant_code": "BLC", "berth": "single", "pax_type": "adult",  "amount": 1273.00, "currency": "SGD" },
    { "variant_code": "BLC", "berth": "extra",  "pax_type": "adult",  "amount":  433.00, "currency": "SGD" },
    { "variant_code": "BLC", "berth": "extra",  "pax_type": "child",  "amount":  420.00, "currency": "SGD" },
    { "variant_code": "BLC", "berth": "extra",  "pax_type": "infant", "amount":  275.00, "currency": "SGD" }
  ],
  "availability": [
    {
      "start_date": "2026-08-07",
      "end_date":   "2026-08-07",
      "total_allotment": 150,
      "cabin_allotments": [
        { "variant_code": "INT", "total_allotment": 80 },
        { "variant_code": "OCV", "total_allotment": 50 },
        { "variant_code": "BLC", "total_allotment": 20 }
      ]
    }
  ],
  "status": "published"
}
Named-cabin mode (CRU-07 pattern): Add cabin_numbers[] inside each cabin_allotments entry, e.g. "cabin_numbers": ["101A", "101B", "102A"]. This switches the booking engine to named-cabin assignment.

Transport — type: "transport"

Transport has three bookable sub-types, discriminated by type_data.transport_subtype: fix_route (fixed A→B private transfer), disposal (hourly charter), and sic (seat-in-coach shared transfer with customer-selected seats). Pricing and allotment for all three come from this Bridge API — there is no marketplace-admin-configured fare for any transport sub-type.

type_data fields (all sub-types)

FieldSub-typeTypeDescription
transport_subtypeAllenumrequired. fix_route | disposal | sic.
vehicle_typeAllenumrequired. sedan | mpv | van | minibus | coach | speedboat.
vehicle_modelAllstringoptional. Model name or "or equivalent" string.
capacity_paxAllintegerrequired. ≥ 1.
capacity_luggageAllintegeroptional.
pricing_methodAllenumrequired. fixed (fix_route) | per_hour (disposal) | per_seat (sic).
trip_typefix_routeenumrequired. one_way | return | charter | multi_city. return/multi_city enable the marketplace's round-trip / multi-city booking (2 legs, or 2–6 legs respectively).
is_cross_borderfix_routebooleanoptional. Opt-in (e.g. genuine SG↔MY routes). When true, publish requires pickup_locations and dropoff_locations to include different country values.
pickup_locations[]fix_route, sicarrayrequired. Each: code, name, address (opt), city (opt), country (opt, ISO alpha-2).
dropoff_locations[]fix_route (opt), sic (req)arraySame shape as pickup_locations[].
time_slots[]sic (req), fix_route (opt)arrayEach: code (display/reference id, unique), pickup_time (HH:MM — the real departure, matched against at booking time), turnaround_minutes (opt).
seat_layout.seats[]sicarrayrequired. Each: number (unique string id), row/col (opt, frontend grid only).
minimum_paxfix_route (dflt 1), sic (dflt 2)integeroptional. sic excludes infants from this count — they ride free.
minimum_hours / maximum_hoursdisposalintegeroptional. Bounds the customer's hours_booked at booking time. maximum_hours defaults to 8 if omitted.
operating_areadisposalstringoptional. Free-text display description.
operating_hoursdisposalobjectoptional. { start, end } (HH:MM) — bounds the customer's requested pickup time.
distance_kmAllnumber|nulloptional. Display only.
features[]Allstring[]optional. e.g. "aircon", "wifi", "bottled_water", "meet_greet", "child_seat".

Pricing:

Example — TRN-01: Fixed MPV, Singapore Changi → Batam Nongsa, round-trip capable (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0004-0004-0004-000000000004",
    "slug": "batam-fast-pte",
    "name": "Batam Fast Pte Ltd"
  },
  "legacy_product_id": "TRN-FIXROUTE-SIN-BTM",
  "type": "transport",
  "name": "Singapore Changi Airport → Batam (Nongsa) — Private MPV",
  "description": "Private air-conditioned MPV transfer from any Changi Airport terminal to Nongsa, Batam. Meet-and-greet at the arrival hall. Up to 7 passengers.",
  "destination": "Batam",
  "country_code": "ID",
  "images": [{ "url": "https://cdn.example.com/mpv-sin-btm1.jpg" }],
  "inclusions": ["Meet & greet at arrival hall", "Up to 7 passengers", "Up to 7 pieces of luggage"],
  "exclusions": ["Ferry ticket (book separately)", "Tolls and parking fees"],
  "type_data": {
    "transport_subtype": "fix_route",
    "vehicle_type": "mpv",
    "vehicle_model": "Toyota Alphard or equivalent",
    "capacity_pax": 7,
    "capacity_luggage": 7,
    "trip_type": "return",
    "pricing_method": "fixed",
    "minimum_pax": 1,
    "is_cross_border": true,
    "pickup_locations": [
      { "code": "SIN_T1", "name": "Changi Airport Terminal 1", "city": "Singapore", "country": "SG" },
      { "code": "SIN_T3", "name": "Changi Airport Terminal 3", "city": "Singapore", "country": "SG" }
    ],
    "dropoff_locations": [
      { "code": "BTM_NONGSA", "name": "Nongsa District, Batam", "city": "Batam", "country": "ID" }
    ],
    "time_slots": [
      { "code": "AM1", "pickup_time": "08:00", "turnaround_minutes": 240 },
      { "code": "PM1", "pickup_time": "14:00", "turnaround_minutes": 240 }
    ],
    "distance_km": null,
    "features": ["aircon", "wifi", "bottled_water"]
  },
  "pricing": [
    { "pax_type": "adult", "amount": 120.00, "currency": "SGD" }
  ],
  "availability": [{ "start_date": "2026-07-01", "end_date": "2026-12-31", "total_allotment": 5 }],
  "status": "published"
}

Note: trip_type: "return" means the marketplace can book this product as a 2-leg round trip (one booking, two ERP holds — one per leg's date). is_cross_border: true is valid here because pickup (SG) and dropoff (ID) resolve to different countries. time_slots[] is optional for fix_route; the customer's requested time must match a slot's pickup_time when present, not the code.

Example — TRN-06: Hourly disposal MPV, Singapore (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0004-0004-0004-000000000004",
    "slug": "batam-fast-pte",
    "name": "Batam Fast Pte Ltd"
  },
  "legacy_product_id": "TRN-DISPOSAL-SGP",
  "type": "transport",
  "name": "Singapore — Hourly Disposal MPV (Min 4 Hours)",
  "description": "Dedicated private MPV for sightseeing or errands. Minimum 4-hour booking.",
  "destination": "Singapore",
  "country_code": "SG",
  "images": [{ "url": "https://cdn.example.com/hourly-mpv1.jpg" }],
  "inclusions": ["Dedicated driver for booked hours", "Fuel, ERP, and CBD parking"],
  "exclusions": ["Parking beyond 2 stops", "Gratuities"],
  "type_data": {
    "transport_subtype": "disposal",
    "vehicle_type": "mpv",
    "vehicle_model": "Toyota Alphard or equivalent",
    "capacity_pax": 7,
    "capacity_luggage": 5,
    "pricing_method": "per_hour",
    "minimum_hours": 4,
    "maximum_hours": 8,
    "operating_area": "Singapore (all districts)",
    "operating_hours": { "start": "07:00", "end": "23:00" },
    "features": ["aircon", "flexible_itinerary"]
  },
  "pricing": [
    { "pax_type": "adult", "amount": 45.00, "currency": "SGD" }
  ],
  "availability": [{ "start_date": "2026-07-01", "end_date": "2026-12-31", "total_allotment": 5 }],
  "status": "published"
}

Note: amount is the per-hour rate; total fare at booking = amount × hours_booked. A disposal product published with no pricing[] row prices at 0.00 — always include a base adult rate.

Example — TRN-09: Seat-in-coach, Sentosa → City Hotels (success scenario)

{
  "operator": {
    "id": "a1b2c3d4-0004-0004-0004-000000000004",
    "slug": "batam-fast-pte",
    "name": "Batam Fast Pte Ltd"
  },
  "legacy_product_id": "TRN-SIC-SENTOSA-CITY",
  "type": "transport",
  "name": "Sentosa → City Hotels — Seat-in-Coach Transfer",
  "description": "Shared coach transfer; select your own seats.",
  "destination": "Singapore",
  "country_code": "SG",
  "images": [{ "url": "https://cdn.example.com/sic-coach1.jpg" }],
  "inclusions": ["Reserved seat", "Air-conditioned coach"],
  "exclusions": ["Meals", "Gratuities"],
  "type_data": {
    "transport_subtype": "sic",
    "vehicle_type": "coach",
    "vehicle_model": "Higer Coach or equivalent",
    "capacity_pax": 49,
    "pricing_method": "per_seat",
    "minimum_pax": 2,
    "pickup_locations": [
      { "code": "SENTOSA", "name": "Sentosa Coach Park", "city": "Singapore", "country": "SG" }
    ],
    "dropoff_locations": [
      { "code": "CITY", "name": "City Hotels", "city": "Singapore", "country": "SG" }
    ],
    "seat_layout": {
      "seats": [
        { "number": "1", "row": 1, "col": "A" },
        { "number": "2", "row": 1, "col": "B" },
        { "number": "3", "row": 1, "col": "C" },
        { "number": "4", "row": 1, "col": "D" }
      ]
    },
    "time_slots": [
      { "code": "PM8", "pickup_time": "20:15", "turnaround_minutes": 60 }
    ],
    "features": ["aircon", "wifi"]
  },
  "pricing": [
    { "pax_type": "adult", "amount": 25.00, "currency": "SGD" },
    { "pax_type": "child", "amount": 18.00, "currency": "SGD" }
  ],
  "availability": [{ "start_date": "2026-07-01", "end_date": "2026-12-31", "total_allotment": 1 }],
  "status": "published"
}

Note: seat_layout.seats[].number must be unique; row/col are display-only (frontend seat-grid). time_slots[] is required for sic, with a unique code. The availability[] block above is only the coarse listing seed — actual seat inventory is per-schedule via stock.updated, see Seat-in-Coach allotment below.

Removed from the earlier design (do not send): fleet_id / fleet registry, the point_to_point sub-type, per_km / zone_pricing, operating_zone_codes, on_demand, and type_data.hourly_rate (disposal pricing is published via pricing[], not type_data).

Errors & Status Codes

StatusMeaningCommon cause
200OK — upsert (product existed)Same operator + legacy_product_id
201Created — new productNew legacy_product_id
401UnauthorizedMissing or invalid X-Api-Key
422Unprocessable EntityValidation failure — check errors object in response. Common: type_data shape mismatch, missing required field
429Too Many RequestsRate limit exceeded (60 writes/min or 120 reads/min)

How Stock Sync Works

The ERP owns availability. The Marketplace maintains a read-only product_availability mirror of whatever the ERP pushes. This mirror drives the "available / sold out" status shown to B2C customers and is the basis for hold/confirm decisions.

Two write paths — which one to use

ActionUse
First publish (seed initial stock)POST /api/v1/bridge/products with availability[]
All ongoing stock changes (decrement on booking, restock, blackout, etc.)POST /api/marketplace/webhooks/stock with stock.updated
Do not use the Bridge API to update stock after the first publish. The availability[] field is silently ignored for products that already have availability records. Always use the webhook for stock changes.

Authentication

The stock webhook uses HMAC-SHA256 request signing — no API key. Request a webhook HMAC secret from EZCore; it is a shared secret and is never sent as a header. Every request must include two extra headers, where the signature is an HMAC-SHA256 digest of the string "{timestamp}.{raw_body}" using that secret.

HeaderValue
X-Webhook-TimestampUnix epoch seconds (server rejects if > 300s in the past)
X-Webhook-Signaturesha256=<hex_digest>

Signing example

TS=$(date +%s)
BODY='{"event":"stock.updated","event_id":"EVT-001","legacy_product_id":"PKG-BALI-5D4N","emitted_at":"2026-07-01T10:00:00Z","changes":[{"date":"2026-08-01","total_allotment":100,"available":95}]}'

SIG=$(printf '%s.%s' "$TS" "$BODY" \
  | openssl dgst -sha256 -hmac "$STOCK_WEBHOOK_SECRET" \
  | sed 's/^.* //')

curl -X POST https://api-marketplace.ezbcloud.space/api/marketplace/webhooks/stock \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $TS" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  -d "$BODY"
<?php

$secret = getenv('STOCK_WEBHOOK_SECRET');
$url    = 'https://api-marketplace.ezbcloud.space/api/marketplace/webhooks/stock';

$ts   = (string) time();
$body = json_encode([
    'event'             => 'stock.updated',
    'event_id'          => 'EVT-001',
    'legacy_product_id' => 'PKG-BALI-5D4N',
    'emitted_at'        => '2026-07-01T10:00:00Z',
    'changes'           => [
        ['date' => '2026-08-01', 'total_allotment' => 100, 'available' => 95],
    ],
], JSON_UNESCAPED_SLASHES);

// Sign "{timestamp}.{raw_body}" with the shared HMAC secret
$signature = hash_hmac('sha256', $ts . '.' . $body, $secret);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'X-Webhook-Timestamp: ' . $ts,
        'X-Webhook-Signature: sha256=' . $signature,
    ],
    CURLOPT_POSTFIELDS     => $body,
]);

$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP {$status}\n{$response}\n";
Anti-replay protection: The server rejects requests where X-Webhook-Timestamp is more than 300 seconds before the server's current time. Always compute a fresh timestamp for each attempt — but keep the same event_id across retries (idempotency key).

stock.updated Webhook

POST /api/marketplace/webhooks/stock

Request body — StockUpdatedRequest

FieldTypeReq?Description
eventstringrequiredAlways "stock.updated"
event_idstringrequiredUnique identifier for this event. Idempotency key — keep constant across retries.
legacy_product_idstringrequiredThe ERP's product ID (must match Bridge publish).
emitted_atISO 8601 datetimerequiredWhen the ERP generated this event. Last-writer-wins key — keep constant across retries.
changes[]StockChange[]requiredMin 1 change entry. See table below.
variant_codestringoptionalTop-level variant filter. Overridden by per-change variant_code.

StockChange — two shapes

Each entry in changes[] is either a single date or a date-range:

FieldShapeDescription
dateflat/dateISO date for single-date updates.
start_date + end_daterangeDate range. Applies the same values to every date in the range.
total_allotmentbothTotal capacity for this date/range.
availablebothCurrent available count after all bookings.
is_blackoutbothIf true, marks date as closed. Omit to leave existing value unchanged. Set false to clear a blackout.
reasonbothb2b_booking_placed | b2b_booking_cancelled | manual_adjustment | erp_sync
referencebothOptional ERP booking/event reference ID.
variant_codebothRoom/seat/cabin code (hotels, ferries, attractions, cruises).
session_time / session_end_timebothTime-slot selector for timed-entry attractions. ISO datetime or HH:MM string.
variant_allotments[]range onlyPer-variant breakdown within the date range.
component_allotments[]range onlyPer-component-option breakdown (tour packages).
cabin_allotments[]range onlyPer-cabin-type breakdown (cruises).

Key behaviours

Idempotency: If you re-send an event with the same event_id that was already processed, the server returns 200 OK with { "duplicate": true } — no rows are written again. Always keep event_id constant across retries.
Last-writer-wins: emitted_at is the conflict-resolution key. An event with an older emitted_at than what is already stored is silently skipped. Keep emitted_at constant across retries — do not refresh it.
Unknown product: An event for a legacy_product_id that does not exist in the marketplace is acknowledged with 200 or 201 but no rows are written. This is intentional (ERP may push stock before the catalog sync).

Stock Webhook — Payload Examples

{
  "event": "stock.updated",
  "event_id": "EVT-PKG-BALI-20260801",
  "legacy_product_id": "PKG-BALI-5D4N",
  "emitted_at": "2026-08-01T09:00:00Z",
  "changes": [
    {
      "date": "2026-08-15",
      "total_allotment": 22,
      "available": 20,
      "reason": "b2b_booking_placed",
      "reference": "BKG-20260801-001"
    }
  ]
}
{
  "event": "stock.updated",
  "event_id": "EVT-USS-BLACKOUT-20261225",
  "legacy_product_id": "ATT-USS-SGP",
  "emitted_at": "2026-07-01T00:00:00Z",
  "changes": [
    {
      "date": "2026-12-25",
      "total_allotment": 0,
      "available": 0,
      "is_blackout": true,
      "reason": "manual_adjustment"
    }
  ]
}
{
  "event": "stock.updated",
  "event_id": "EVT-HTL-MID-20260901",
  "legacy_product_id": "HTL-MID-SGP-002",
  "emitted_at": "2026-09-01T08:00:00Z",
  "changes": [
    { "date": "2026-09-15", "variant_code": "std", "total_allotment": 50, "available": 48, "reason": "erp_sync" },
    { "date": "2026-09-15", "variant_code": "dlx", "total_allotment": 30, "available": 29, "reason": "erp_sync" }
  ]
}
{
  "event": "stock.updated",
  "event_id": "EVT-SEA-SLOT-20260801",
  "legacy_product_id": "ATT-SEA-SGP",
  "emitted_at": "2026-08-01T07:00:00Z",
  "changes": [
    {
      "date": "2026-08-20",
      "variant_code": "ADT",
      "session_time": "10:00",
      "session_end_time": "12:00",
      "total_allotment": 80,
      "available": 75,
      "reason": "b2b_booking_placed"
    }
  ]
}
{
  "event": "stock.updated",
  "event_id": "EVT-HTL-RANGE-Q4",
  "legacy_product_id": "HTL-MID-SGP-002",
  "emitted_at": "2026-07-01T00:00:00Z",
  "changes": [
    {
      "start_date": "2026-10-01",
      "end_date": "2026-12-31",
      "total_allotment": 80,
      "available": 80,
      "reason": "erp_sync",
      "variant_allotments": [
        { "variant_code": "std", "total_allotment": 50, "available": 50 },
        { "variant_code": "dlx", "total_allotment": 30, "available": 30 }
      ]
    }
  ]
}
{
  "event": "stock.updated",
  "event_id": "EVT-SIC-SENTOSA-20260615",
  "legacy_product_id": "TRN-SIC-SENTOSA-CITY",
  "emitted_at": "2026-06-15T02:00:00Z",
  "changes": [
    { "date": "2026-09-01", "session_time": "20:15", "total_allotment": 2, "available": 2, "is_blackout": false, "reason": "manual_allotment_change" },
    { "date": "2026-09-01", "session_time": "22:00", "total_allotment": 1, "available": 1, "is_blackout": false, "reason": "manual_allotment_change" },
    { "date": "2026-09-02", "session_time": "20:15", "total_allotment": 2, "available": 2, "is_blackout": false, "reason": "manual_allotment_change" }
  ]
}

Transport sic (seat-in-coach) needs allotment per departure time, not just per date — each entry carries a session_time (the schedule's pickup_time, HH:MM) alongside date. There is no variant_code for sic (allotment is product-level per schedule; the seat dimension is marketplace-only).

How total_allotment becomes seat stock: for sic, total_allotment on a (date, session_time) row is the fleet quantity for that schedule — the number of coaches running that slot. The marketplace expands it into per-seat inventory: every seat number in the product's type_data.seat_layout.seats[] gets total_allotment units of stock for that schedule. So total_allotment: 2 on the 20:15 departure means seat "1" (and every other seat) has 2 units — up to two different bookings can hold seat "1" before that departure is sold out for that seat. The ERP still only ever receives an aggregate seat quantity on the hold call (quantity = number of seats, session_time = pickup_time) — never individual seat numbers.

fix_route / disposal allotment is per date (and, for fix_route with configured time_slots, optionally per session_time) with total_allotment = number of vehicles — the standard flat-row upsert, no seat expansion.

Response — WebhookDispatchResponse

{
  "event_id": "EVT-PKG-BALI-20260801",
  "status": "processed",
  "id": "wh-dispatch-uuid",
  "duplicate": false
}

Sequence diagram

sequenceDiagram participant ERP participant MP as Marketplace ERP->>MP: POST /api/marketplace/webhooks/stock (X-Webhook-Signature + X-Webhook-Timestamp) Note over MP: Verify HMAC + 300s anti-replay window Note over MP: Idempotency check on event_id alt already processed MP-->>ERP: 200 OK (duplicate: true) else new event Note over MP: Compare emitted_at — last-writer-wins Note over MP: Upsert product_availability rows MP-->>ERP: 201 Created (status: processed) end

Triggers & Verification ERP → Marketplace

The ERP emits a stock.updated event whenever stock moves for any reason — this is the only thing that keeps the marketplace mirror correct against invisible B2B activity. The field-level reference is above in stock.updated Webhook; below are the booking-context triggers and how the marketplace verifies the signature.

Triggers — emit on ALL of these

Triggerreason valueEffect on mirror
B2B booking placedb2b_booking_placedavailable
B2B booking cancelledb2b_booking_cancelledavailable
B2C hold placed (via POST /holds)b2c_hold_placedavailable
B2C hold released / expiredb2c_hold_releasedavailable
B2C booking confirmedb2c_booking_confirmedno net change (hold→booked)
Manual allotment changemanual_allotment_changetotal_allotment + available change
Blackout toggledblackout_toggledis_blackout flips

Required headers

X-Api-Key:           <bridge-api-key>
X-Webhook-Signature: sha256=<hex_hmac>
X-Webhook-Timestamp: <unix_epoch_seconds>
Content-Type:        application/json

How the marketplace verifies the ERP

The receiver runs this check (real code, WebhookVerificationService):

  1. Require X-Webhook-Signature and X-Webhook-Timestamp, else 400.
  2. abs(now − X-Webhook-Timestamp) must be ≤ 300s, else 400 "outside replay window".
  3. computed = HMAC_SHA256("{timestamp}.{raw_body}", STOCK_WEBHOOK_SECRET). Compare against hex in "sha256=<hex>". Mismatch → 401.
  4. Body must be a JSON object with non-empty event and event_id, else 400.

Signing recipe (pseudocode, ERP side)

ts        = current_unix_seconds()
raw_body  = json_encode(payload)          # sign the EXACT bytes sent
to_sign   = ts + "." + raw_body
signature = "sha256=" + hex( hmac_sha256(to_sign, STOCK_WEBHOOK_SECRET) )
# headers: X-Webhook-Timestamp: ts, X-Webhook-Signature: signature
Critical: HMAC is over "{timestamp}.{raw_body}" — the timestamp, a literal dot, then the raw JSON bytes. The ERP must sign the same bytes it transmits (don't re-serialize after signing).
Initial sync / backfill: When a product is first published (or after a marketplace DB restore), the ERP emits a stock.updated for every active future date of that product. Batch into one or more events; same upsert logic. Without this the calendar shows nothing.

Delivery Status Poll

GET /api/marketplace/webhooks/{source}/events/{event_id}

Poll delivery status before retrying a failed webhook. Replace {source} with stock. Auth: X-Api-Key only (no HMAC required for reads). Rate limit: 120 requests/min.

Response — WebhookEvent fields

FieldDescription
statusreceived | processed | failed
rows_appliedNumber of product_availability rows written.
retry_countHow many times this event has been attempted.
error_messageLast error if status is failed.
attempts[]Array of WebhookEventAttempt — one entry per delivery attempt with timestamp and outcome.
emitted_atTimestamp from the original payload.
processed_atWhen the event was successfully processed (null if not yet).

ERP retry decision matrix

Poll resultERP action
status: processedDo not re-send. Event was processed successfully.
status: failed or received AND retry_count < max_retryRe-send with the same event_id and emitted_at. Generate a fresh X-Webhook-Timestamp and signature.
retry_count >= max_retryFurther retries will return 429 Too Many Requests. Dead-letter the event and alert ops.
404 (not found)Event was never received. Safe to re-send (with same event_id).

Sequence diagram

sequenceDiagram participant ERP participant MP as Marketplace ERP->>MP: POST /webhooks/stock (event_id=EVT-001) MP-->>ERP: 500 Transient server error Note over ERP: Before retry — check event status ERP->>MP: GET /webhooks/stock/events/EVT-001 (X-Api-Key) MP-->>ERP: 200 { status, retry_count } alt status == processed Note over ERP: Do NOT resend — already succeeded else retryable (retry_count < max) ERP->>MP: POST /webhooks/stock (SAME event_id=EVT-001, fresh timestamp + signature) MP-->>ERP: 201 Created (status: processed) else retry_count >= max_retry Note over ERP: Dead-letter + alert ops end

Errors & Status Codes

StatusMeaningERP action
201Created — event accepted and processedNo action needed
200OK — either idempotent replay (duplicate: true) or unknown product ackNo action needed
400Bad RequestFix payload: missing required headers, invalid JSON, timestamp outside 300s window, non-parseable body. Not retryable as-is.
401Signature mismatchRecompute HMAC — check secret and signing string format. Not retryable as-is.
422Permanently rejectedFix payload: unsupported event type, missing_changes, missing_emitted_at. Not retryable as-is.
429Retry budget exhaustedDead-letter + alert ops.
500Transient server errorRetryable. Poll status first, then re-send if not already processed.

Booking

The ERP is the single source of truth for all stock. The marketplace never owns inventory. It keeps a display-only mirror of availability and asks the ERP for every real stock decision.

Why this matters: the ERP already serves B2B traffic (agent, corporate, walk-in) that the marketplace cannot see. If the marketplace counted stock locally, it would oversell the moment a B2B booking is taken on the ERP side. So all hold / release / confirm decisions run through the ERP.

The ERP team builds three surfaces:

#SurfaceDirectionSync/AsyncWho calls
1Stock Operations APIMarketplace → ERPSynchronousMarketplace calls the ERP
2Stock Sync WebhookERP → MarketplaceAsynchronousERP calls marketplace
3Booking Webhook receiverMarketplace → ERPAsynchronousMarketplace calls the ERP
Terminology: the source-of-truth system is referred to as ERP throughout (active and under active development). Earlier docs call it "Legacy ERP" — same system.

System Map

Stock decisions are 100% ERP. The marketplace has no on_hold / booked state and no SELECT FOR UPDATE on stock. Payments run through pay.acetours.sg (ERP), Stripe, or FOMO Pay — selectable per tenant (see Payment Providers and Auth & Config).

flowchart LR C["Customer
(browser)"] subgraph MP["MARKETPLACE"] AV["product_availability
(mirror, display only)"] BK["bookings / payments"] end subgraph ERP["ERP — SOURCE OF TRUTH"] AL["allotments / holds"] BB["bookings (B2B + B2C)"] end PAY["Payment Svc
acetours / stripe / fomopay"] B2B["B2B channel bookings
(invisible to MP)"] C -- "HTTPS" --> MP MP -- "Stock Ops API (+X-Api-Key)" --> ERP ERP -- "stock.updated webhook (+HMAC)" --> MP MP -- "booking webhook (+HMAC)" --> ERP MP --> PAY B2B -. "consume same stock" .-> AL

Stock Operations API ERP hosts

Synchronous HTTP API hosted on the ERP side. The marketplace calls it during the booking flow. Three endpoints.

Base: {ERP_BASE_URL}/api/marketplace/stock
ItemValue
AuthAPI key in X-Api-Key header (separate key scoped to stock ops — see Auth & Config).
Client behaviourThe marketplace uses a 3s timeout, a single retry on transient (5xx / network) only, and a circuit breaker. The ERP must respond fast (target ~1s).

Hold lifecycle (ERP side)

flowchart TD H["POST /holds → HOLD created\n(state: held, ttl = expires_at)"] H -->|"booking.confirmed webhook"| B["convert hold → booked\nassign ticket codes if attraction\nreason: b2c_booking_confirmed"] H -->|"DELETE /holds/{id} arrives"| R["release\nreason: b2c_hold_released"] H -->|"ERP's own TTL elapses (safety net)"| A["auto-release\nreason: b2c_hold_released"]

GET /stock/availability Marketplace → ERP

GET {ERP_BASE_URL}/api/marketplace/stock/availability

Real-time stock check. Called at booking creation just before a hold (and optionally for calendar refresh).

Query parameters

ParameterReq?Description
product_idrequiredThe ERP product ID (i.e. legacy_product_id elsewhere).
start_date / end_daterequiredISO date range to query.
variant_codeoptionalRoom type / cabin type / ticket type / seat class. Omit for products with no variant (e.g. transport fix_route/disposal).

Request & Response

GET /api/marketplace/stock/availability
  ?product_id=PKG-001
  &start_date=2026-06-01
  &end_date=2026-06-30
  &variant_code=DLX_DBL
{
  "data": [
    { "date": "2026-06-15", "total_allotment": 6, "available": 6, "is_blackout": false, "variant_code": "DLX_DBL", "session_time": null, "session_end_time": null },
    { "date": "2026-06-16", "total_allotment": 6, "available": 0, "is_blackout": false, "variant_code": "DLX_DBL", "session_time": null, "session_end_time": null },
    { "date": "2026-06-17", "total_allotment": 6, "available": 6, "is_blackout": true,  "variant_code": "DLX_DBL", "session_time": null, "session_end_time": null }
  ]
}

available = real bookable count right now (allotment minus all holds and bookings, B2B + B2C). is_blackout: true means not sellable regardless of count. total_allotment is required on every row; available defaults to it if omitted.

session_time / session_end_time (nullable) carry the scheduled departure/slot for time-slotted products — timed-entry attractions, ferry sailings, and transport fix_route (when the product has configured time_slots[]) or sic (always required). One row per (date, session_time) for these products, not one row per date.

Multi-room / multi-cabin: POST /stock/availability/batch

The GET endpoint above checks one variant_code at a time, so it can't answer "is this exact combination of rooms/cabins available together" in one round trip. For a multi-unit booking (e.g. 1 std + 1 dlx hotel room, or 2 cruise cabins of different types) the marketplace instead calls a batch endpoint just before placing the batch of holds, so it can tell the customer which specific unit is unavailable and let them swap it before committing to a hold.

POST {ERP_BASE_URL}/api/marketplace/stock/availability/batch

Request fields

FieldDescription
product_idThe ERP product ID (same value as legacy_product_id elsewhere).
dateSingle travel date, YYYY-MM-DD. This call is single-date — for a multi-night hotel stay the marketplace calls it once per night, same convention as legacy_hold_ids in booking.confirmed.
unitsArray of { variant_code, quantity }, one entry per room/cabin being checked. quantity is normally 1 per unit (one row = one room/cabin).

Request & Response

{
  "product_id": "HTL-MID-SGP-002",
  "date": "2026-09-10",
  "units": [
    { "variant_code": "std", "quantity": 1 },
    { "variant_code": "dlx", "quantity": 1 }
  ]
}
{
  "units": [
    { "variant_code": "std", "position": 1, "available": true,  "available_qty": 4 },
    { "variant_code": "dlx", "position": 2, "available": false, "available_qty": 0 }
  ]
}

Response is purely per-unit — there's no overall/aggregate status field, the marketplace derives "can this booking proceed" by checking every entry's available. position is the 1-based index of that unit within the request's units array (mirrors unit_position in booking.confirmed) so the marketplace can map a result back to the room/cabin the customer picked, even with repeated variant_codes (e.g. two std rooms). available_qty defaults to 0 if omitted.

This is a read-only pre-hold check, not a reservation — availability can still change between this call and the actual POST /stock/holds/batch (see Build Checklist; not documented as its own section yet). Unlike the hold-batch endpoint, a non-200 here has no per-unit conflict detail; the marketplace treats any non-200 as a generic ERP-unavailable error, not a per-room conflict.
Known gap: as of this writing this endpoint does not exist on the ERP side yet — it's a cross-team dependency, same as POST /stock/holds/batch (used to actually commit the batch of holds once the customer confirms). The marketplace's ErpStockServiceInterface::checkAvailabilityBatch() is fully implemented and tested against the contract shown above; it just has nothing real to call yet. Treat this as the target contract the ERP team should build to, not observed traffic.

POST /stock/holds Marketplace → ERP

POST {ERP_BASE_URL}/api/marketplace/stock/holds

Place a hold. This is the gate — the marketplace creates a booking record only after this returns success.

Request fields

FieldDescription
product_idThe ERP product ID (i.e. legacy_product_id elsewhere).
dateTravel / check-in date. For a multi-night stay (hotel/cruise), the marketplace calls the batch endpoint once per night instead of this single-date call.
quantityUnits to hold — typically 1 per booking, except transport sic where it's the number of selected seats.
variant_codeoptional. Room / cabin / ticket type / seat class. Null for products with no variant.
session_timeoptional. HH:MM — timed-entry attractions, ferry sailings, transport fix_route/sic.
No platform_booking_id or pax at this layer. The ERP hold is placed before the marketplace booking row exists (so there's no booking UUID yet to send), and pax breakdown is tracked booking-side, not sent to the ERP on the hold call — same convention as POST /stock/holds/batch above. The ERP's own hold_id is the only correlation key until booking.confirmed arrives with the full passenger manifest. There is also no request-level expires_at — the marketplace holds for a fixed 15 minutes and releases explicitly (DELETE /stock/holds/{hold_id}); the ERP is expected to enforce its own slightly-longer TTL as an independent safety net.

Request & Response

{
  "product_id": "PKG-001",
  "date": "2026-06-15",
  "quantity": 1,
  "variant_code": "DLX_DBL",
  "session_time": null
}
{
  "hold_id": "HOLD-ERP-00789"
}

hold_id is the ERP's id. The marketplace stores it as legacy_hold_id and sends it back at confirm / release. It must be unique and durable. The marketplace client only reads hold_id from this response — extra fields (e.g. an echoed expires_at or available_after_hold) are accepted but ignored.

{
  "error": "insufficient_stock",
  "available": 1,
  "requested": 2
}
On 409 the marketplace returns HTTP 409 to the customer and creates no booking record. Success is 201, not 200 — any other status (including 200) is treated as a failure by the marketplace client.
After a successful hold, the ERP emits a stock.updated webhook (see Stock Sync → Triggers) with reason: b2c_hold_placed so the mirror reflects the decrease.

Multi-room / multi-cabin: POST /stock/holds/batch

The endpoint above places one hold at a time, so it can't atomically commit a multi-unit booking (e.g. 1 std + 1 dlx hotel room, or several cruise cabins) — a partial success would leave some rooms held and others not. The marketplace instead calls a batch endpoint once per booking (once per night for multi-night stays), right after the availability/batch precheck, so the whole set of holds either succeeds together or fails with per-unit detail the customer can act on.

POST {ERP_BASE_URL}/api/marketplace/stock/holds/batch

Request fields

FieldDescription
product_idThe ERP product ID (same value as legacy_product_id elsewhere). Note this batch call uses product_id / date, not the single hold's legacy_product_id / travel_date — same batch-vs-single naming split as availability/batch.
dateSingle travel date, YYYY-MM-DD. For a multi-night stay the marketplace calls this once per night, same convention as availability/batch.
unitsArray of { variant_code, quantity }, one entry per room/cabin. There's no per-unit pax at this layer — pax breakdown is tracked booking-side, not sent per unit in the hold call.
session_timeoptional. Nullable; not the same field as the single hold's expires_at.

Request & Response

{
  "product_id": "HTL-MID-SGP-002",
  "date": "2026-09-10",
  "units": [
    { "variant_code": "std", "quantity": 1 },
    { "variant_code": "dlx", "quantity": 1 }
  ],
  "session_time": null
}
{
  "hold_id": "BATCH-HOLD-00123",
  "unit_holds": [
    { "variant_code": "std", "unit_hold_id": "UNIT-HOLD-00456" },
    { "variant_code": "dlx", "unit_hold_id": "UNIT-HOLD-00457" }
  ]
}
{
  "unavailable_units": [
    { "variant_code": "dlx", "position": 2 }
  ]
}

hold_id is the batch-level id for the whole set; unit_holds gives one hold id per unit, in request order. On 409, unavailable_units names exactly which unit(s) conflicted — by variant_code and 1-based position in the request's units array — so the marketplace can prompt the customer to swap just that room/cabin instead of failing the whole booking. This is more detail than availability/batch gives on a non-200 (see above); unlike that endpoint, a conflict here is expected to identify the exact unit(s), not just fail generically.

Known gap: as of this writing this endpoint does not exist on the ERP side yet — same cross-team dependency as POST /stock/availability/batch. The marketplace's ErpStockServiceInterface::placeHoldsBatch() is fully implemented and unit-tested against the contract shown above (and wired into real multi-unit booking creation, which converts a conflict into a per-room error for the storefront); it just has nothing real to call yet. Treat this as the target contract the ERP team should build to, not observed traffic.

DELETE /stock/holds/{hold_id} Marketplace → ERP

DELETE {ERP_BASE_URL}/api/marketplace/stock/holds/{hold_id}

Release a hold. Called on: payment failure, 15-min expiry (marketplace cleanup job), or cancellation. No request body — hold_id in path.

Response 200

{ "hold_id": "HOLD-ERP-00789", "status": "released" }

Response (already released / unknown — treat as success)

{ "error": "hold_not_found" }
Idempotency is mandatory. The marketplace cleanup job may call release more than once for the same hold. The ERP must treat hold_not_found as a safe no-op, not an error. After a real release, the ERP emits a stock.updated webhook with reason: b2c_hold_released.

Booking Webhook Receiver Marketplace → ERP

POST {LEGACY_WEBHOOK_URL}/api/marketplace/bookings

The marketplace calls the ERP when a booking is confirmed or cancelled. The ERP hosts this endpoint. Respond within 10s — acknowledge with 202 Accepted and process async if needed.

Auth headers the ERP receives

HeaderValue
X-Webhook-Signaturesha256=<hmac>
X-Webhook-Timestamp<unix_epoch_seconds>
X-Webhook-Eventbooking.confirmed | booking.cancelled
X-Webhook-Delivery-ID<uuid> — unique per attempt

The ERP verifies exactly like the Stock Sync webhook (same "{timestamp}.{raw_body}" HMAC scheme, shared webhook_secret). Reject requests older than 5 min (replay protection).

Event: booking.confirmed

This is where the ERP converts the hold into a real booking, and — for stock_upload products — triggers the Pre-Uploaded Voucher callback.

Example payloads by product type

The envelope is shared across all product types — only a handful of fields (trip_type, check_out_date, units[], open_ticket, transport, e_ticket_delivery_method) vary. Each tab below confirms a booking for the same example product already published via the Bridge Product API in Product Types & type_data, so you can trace one product all the way from publish → book → confirm. payment.method is shown as null in every example — it's reserved for a future PaymentDomain field and is not populated by the current webhook sender regardless of how the customer actually paid.

Single-unit product — units is always [], trip_type and check_out_date are always null. Booking for Bintan Resort Escape 3D2N (PKG-BIN-3D2N-COMP, see Tour Package), 2 adults + 1 child on the default ferry/resort/room-type components — component selections themselves aren't broken out in this envelope, only the resulting total.

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-2a44-7c31-9b10-000000000101",
  "booking_reference": "BK-2026-01001",
  "legacy_product_id": "PKG-BIN-3D2N-COMP",
  "legacy_hold_id": "HOLD-ERP-01001",
  "legacy_hold_ids": null,
  "trip_type": null,
  "travel_date": "2026-08-15",
  "check_out_date": null,
  "pax": { "adults": 2, "children": 1, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "John Doe", "passport": "E1234567", "nationality": "SG", "dob": "1985-03-12" },
    { "type": "adult", "name": "Jane Doe", "passport": "E7654321", "nationality": "SG", "dob": "1988-07-22" },
    { "type": "child", "name": "Tom Doe",  "passport": null,        "nationality": "SG", "dob": "2018-05-10" }
  ],
  "units": [],
  "customer": { "name": "John Doe", "email": "john@example.com", "phone": "+6591234567" },
  "payment": { "amount": 1077.06, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000123" },
  "tax_summary": {
    "destination_scope": "international",
    "tax_lines": [
      { "name": "Zero-rated (Outbound)", "tax_type": "zero_rated", "rate": 0.00, "amount": "0.00", "inclusive": true }
    ],
    "embedded_tax_total": "0.00",
    "added_tax_total":    "0.00",
    "tax_total":          "0.00",
    "tax_note":           "Zero-rated supply — outbound tour package"
  },
  "confirmed_at": "2026-07-16T09:12:00Z",
  "open_ticket": null,
  "transport": null,
  "e_ticket_delivery_method": "auto_generate"
}

Multi-unit product (one row per room) — check_out_date is populated and legacy_hold_ids carries one hold per night (2-night stay ⇒ 2 entries per unit). Per-unit type_data is currently always null — the codebase does not yet snapshot bed configuration/room number onto the unit row. Booking for Orchard View Hotel (HTL-MID-SGP-002, see Hotel), 2 rooms (std + dlx), 2 nights.

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-3b55-7c42-9b10-000000000201",
  "booking_reference": "BK-2026-01002",
  "legacy_product_id": "HTL-MID-SGP-002",
  "legacy_hold_id": "HOLD-ERP-01002",
  "legacy_hold_ids": ["HOLD-ERP-01002", "HOLD-ERP-01003"],
  "trip_type": null,
  "travel_date": "2026-09-10",
  "check_out_date": "2026-09-12",
  "pax": { "adults": 4, "children": 1, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "Alice Tan", "passport": "E1111111", "nationality": "SG", "dob": "1990-01-15" },
    { "type": "adult", "name": "Bob Tan",   "passport": "E2222222", "nationality": "SG", "dob": "1988-06-20" },
    { "type": "adult", "name": "Carol Lim", "passport": "E3333333", "nationality": "SG", "dob": "1992-03-10" },
    { "type": "adult", "name": "David Lim", "passport": "E4444444", "nationality": "SG", "dob": "1989-11-05" },
    { "type": "child", "name": "Ellie Lim", "passport": null,        "nationality": "SG", "dob": "2019-05-02" }
  ],
  "units": [
    {
      "unit_position": 1,
      "variant_code": "std",
      "pax": { "adults": 2, "children": 0, "infants": 0 },
      "passengers": [
        { "type": "adult", "name": "Alice Tan", "passport": "E1111111", "nationality": "SG", "dob": "1990-01-15" },
        { "type": "adult", "name": "Bob Tan",   "passport": "E2222222", "nationality": "SG", "dob": "1988-06-20" }
      ],
      "legacy_hold_id": "HOLD-ERP-01002-U1",
      "legacy_hold_ids": ["HOLD-ERP-01002-U1", "HOLD-ERP-01003-U1"],
      "total_amount": 592.00,
      "type_data": null
    },
    {
      "unit_position": 2,
      "variant_code": "dlx",
      "pax": { "adults": 2, "children": 1, "infants": 0 },
      "passengers": [
        { "type": "adult", "name": "Carol Lim", "passport": "E3333333", "nationality": "SG", "dob": "1992-03-10" },
        { "type": "adult", "name": "David Lim", "passport": "E4444444", "nationality": "SG", "dob": "1989-11-05" },
        { "type": "child", "name": "Ellie Lim", "passport": null,        "nationality": "SG", "dob": "2019-05-02" }
      ],
      "legacy_hold_id": "HOLD-ERP-01002-U2",
      "legacy_hold_ids": ["HOLD-ERP-01002-U2", "HOLD-ERP-01003-U2"],
      "total_amount": 752.02,
      "type_data": null
    }
  ],
  "customer": { "name": "Alice Tan", "email": "alice.tan@example.com", "phone": "+6598765432" },
  "payment": { "amount": 1344.02, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000201" },
  "tax_summary": {
    "destination_scope": "domestic",
    "tax_lines": [
      { "name": "GST (9%)", "tax_type": "gst", "rate": 0.09, "amount": "110.97", "inclusive": true }
    ],
    "embedded_tax_total": "110.97",
    "added_tax_total":    "0.00",
    "tax_total":          "110.97",
    "tax_note":           "Prices inclusive of 9% GST"
  },
  "confirmed_at": "2026-07-16T10:05:00Z",
  "open_ticket": null,
  "transport": null,
  "e_ticket_delivery_method": "auto_generate"
}
Field reference (multi-unit): unit_position (1-based), variant_code (room/cabin code), per-unit pax/passengers (booking-level fields are the aggregate across units), legacy_hold_id/legacy_hold_ids (this unit's own hold — convert independently), total_amount (this unit's tax-inclusive share of payment.amount).
Known gap: the multi-unit booking path (BookingService::createMultiUnit()) does not currently set Booking.type_data on the parent booking row, so in production tax_summary for hotel bookings (and any 2+-cabin cruise booking) actually falls back to the sender's hardcoded zero-rated placeholder rather than the populated 9% GST breakdown shown above. The example reflects the intended/target shape, not current observed traffic — flagged for a follow-up fix in ezcore-api-marketplace.

Multi-unit product (one row per cabin). Cruise holds are single-date, so legacy_hold_ids stays null at both booking and unit level — unlike hotel, there's no per-night array. check_out_date is populated with the sailing's return date (the same date-range convention the mock ERP receiver uses to release stock across the whole sailing). Per-unit type_data is { cabin_code, tax_summary } — a per-cabin tax breakdown, not the room-metadata shape you may see in older integration notes. Booking for Genting Dream — 2N Weekend Getaway (CRU-GD-2N-RT, see Cruise), 2 cabins (INT + BLC). This sailing has no port calls (round-trip from Singapore), so — unlike a Batam-bound ferry/cruise — no port levy applies, just domestic GST. Same caveat as Hotel above applies: multi-cabin cruise bookings hit the same createMultiUnit() gap, so this populated tax_summary is the target shape, not yet what production actually sends.

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-4c66-7c53-9b10-000000000301",
  "booking_reference": "BK-2026-01003",
  "legacy_product_id": "CRU-GD-2N-RT",
  "legacy_hold_id": "HOLD-ERP-01004",
  "legacy_hold_ids": null,
  "trip_type": null,
  "travel_date": "2026-08-07",
  "check_out_date": "2026-08-09",
  "pax": { "adults": 4, "children": 1, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "Marcus Yeo",  "passport": "E5551001", "nationality": "SG", "dob": "1982-02-14" },
    { "type": "adult", "name": "Serene Yeo",  "passport": "E5551002", "nationality": "SG", "dob": "1984-10-09" },
    { "type": "adult", "name": "Ivan Koh",    "passport": "E5551003", "nationality": "SG", "dob": "1979-05-30" },
    { "type": "adult", "name": "Grace Koh",   "passport": "E5551004", "nationality": "SG", "dob": "1981-12-02" },
    { "type": "child", "name": "Bella Koh",   "passport": null,        "nationality": "SG", "dob": "2017-08-19" }
  ],
  "units": [
    {
      "unit_position": 1,
      "variant_code": "INT",
      "pax": { "adults": 2, "children": 0, "infants": 0 },
      "passengers": [
        { "type": "adult", "name": "Marcus Yeo", "passport": "E5551001", "nationality": "SG", "dob": "1982-02-14" },
        { "type": "adult", "name": "Serene Yeo", "passport": "E5551002", "nationality": "SG", "dob": "1984-10-09" }
      ],
      "legacy_hold_id": "HOLD-ERP-01004-U1",
      "legacy_hold_ids": null,
      "total_amount": 776.00,
      "type_data": {
        "cabin_code": "INT",
        "tax_summary": {
          "destination_scope": "domestic",
          "tax_lines": [ { "name": "GST (9%)", "tax_type": "gst", "rate": 0.09, "amount": "64.07", "inclusive": true } ],
          "embedded_tax_total": "64.07",
          "added_tax_total":    "0.00",
          "tax_total":          "64.07",
          "tax_note":           "Prices inclusive of 9% GST"
        }
      }
    },
    {
      "unit_position": 2,
      "variant_code": "BLC",
      "pax": { "adults": 2, "children": 1, "infants": 0 },
      "passengers": [
        { "type": "adult", "name": "Ivan Koh",  "passport": "E5551003", "nationality": "SG", "dob": "1979-05-30" },
        { "type": "adult", "name": "Grace Koh", "passport": "E5551004", "nationality": "SG", "dob": "1981-12-02" },
        { "type": "child", "name": "Bella Koh", "passport": null,        "nationality": "SG", "dob": "2017-08-19" }
      ],
      "legacy_hold_id": "HOLD-ERP-01004-U2",
      "legacy_hold_ids": null,
      "total_amount": 1796.00,
      "type_data": {
        "cabin_code": "BLC",
        "tax_summary": {
          "destination_scope": "domestic",
          "tax_lines": [ { "name": "GST (9%)", "tax_type": "gst", "rate": 0.09, "amount": "148.29", "inclusive": true } ],
          "embedded_tax_total": "148.29",
          "added_tax_total":    "0.00",
          "tax_total":          "148.29",
          "tax_note":           "Prices inclusive of 9% GST"
        }
      }
    }
  ],
  "customer": { "name": "Marcus Yeo", "email": "marcus.yeo@example.com", "phone": "+6591230001" },
  "payment": { "amount": 2572.00, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000301" },
  "tax_summary": {
    "destination_scope": "domestic",
    "tax_lines": [
      { "name": "GST (9%)", "tax_type": "gst", "rate": 0.09, "amount": "212.36", "inclusive": true }
    ],
    "embedded_tax_total": "212.36",
    "added_tax_total":    "0.00",
    "tax_total":          "212.36",
    "tax_note":           "Prices inclusive of 9% GST"
  },
  "confirmed_at": "2026-07-16T11:40:00Z",
  "open_ticket": null,
  "transport": null,
  "e_ticket_delivery_method": "auto_generate"
}

Single-unit product — units is []. trip_type reflects what the customer actually booked. Booking for Batam Fast — Singapore HarbourFront → Batam Centre (FRY-BF-SIN-BTM-VIP, see Ferry), VIP seat class. This product only sells one-way fares (its type_data.trip_type is one_way, with no round-trip pricing rows), so trip_type here is "one_way" and open_ticket is null — this product doesn't define an open-ticket seat class. Because the route touches Batam, the Batam Departure Levy still applies (adults only; children are exempt).

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-5d77-7c64-9b10-000000000401",
  "booking_reference": "BK-2026-01004",
  "legacy_product_id": "FRY-BF-SIN-BTM-VIP",
  "legacy_hold_id": "HOLD-ERP-01005",
  "legacy_hold_ids": null,
  "trip_type": "one_way",
  "travel_date": "2026-09-05",
  "check_out_date": null,
  "pax": { "adults": 1, "children": 1, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "Michael Tan", "passport": "E8888001", "nationality": "SG", "dob": "1980-01-01" },
    { "type": "child", "name": "Nathan Tan",  "passport": null,        "nationality": "SG", "dob": "2015-06-06" }
  ],
  "units": [],
  "customer": { "name": "Michael Tan", "email": "michael.tan@example.com", "phone": "+6591230002" },
  "payment": { "amount": 121.00, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000401" },
  "tax_summary": {
    "destination_scope": "domestic",
    "tax_lines": [
      { "name": "GST (9%)",             "tax_type": "gst",      "rate": 0.09, "amount": "8.50",  "inclusive": true  },
      { "name": "Batam Departure Levy", "tax_type": "port_tax", "rate": 0.00, "amount": "18.00", "inclusive": false }
    ],
    "embedded_tax_total": "8.50",
    "added_tax_total":    "18.00",
    "tax_total":          "26.50",
    "tax_note":           "Prices inclusive of 9% GST. Port levy SGD 18.00/adult passenger (children exempt) collected on behalf of Batam Port Authority."
  },
  "confirmed_at": "2026-07-16T13:22:00Z",
  "open_ticket": null,
  "transport": null,
  "e_ticket_delivery_method": "stock_upload"
}
open_ticket shape (not from this product): for a different ferry product that does define an is_open_ticket: true seat class (see Ferry's seat_classes[] notes), a round-trip open-ticket booking's envelope would instead carry "trip_type": "round_trip" and:
"open_ticket": {
  "is_open_ticket": true,
  "validity_start": "departure_date",
  "valid_until": "2026-12-04"
}

Single-unit product — same shape as tour_package (units: [], trip_type/open_ticket/transport all null). The legacy ERP assigns ticket codes from its own pool on receipt — they are not sent in this payload. Booking for Universal Studios Singapore — General Admission (ATT-USS-SGP, see Attraction), ADT + CHD tickets.

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-6e88-7c75-9b10-000000000501",
  "booking_reference": "BK-2026-01005",
  "legacy_product_id": "ATT-USS-SGP",
  "legacy_hold_id": "HOLD-ERP-01006",
  "legacy_hold_ids": null,
  "trip_type": null,
  "travel_date": "2026-08-20",
  "check_out_date": null,
  "pax": { "adults": 2, "children": 1, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "Wei Ling Ng", "passport": "E9990001", "nationality": "SG", "dob": "1986-04-11" },
    { "type": "adult", "name": "Kok Seng Ng", "passport": "E9990002", "nationality": "SG", "dob": "1984-09-23" },
    { "type": "child", "name": "Mei Ng",      "passport": null,        "nationality": "SG", "dob": "2020-02-14" }
  ],
  "units": [],
  "customer": { "name": "Wei Ling Ng", "email": "weiling.ng@example.com", "phone": "+6591230003" },
  "payment": { "amount": 244.00, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000501" },
  "tax_summary": {
    "destination_scope": "domestic",
    "tax_lines": [
      { "name": "GST (9%)", "tax_type": "gst", "rate": 0.09, "amount": "20.15", "inclusive": true }
    ],
    "embedded_tax_total": "20.15",
    "added_tax_total":    "0.00",
    "tax_total":          "20.15",
    "tax_note":           "Prices inclusive of 9% GST"
  },
  "confirmed_at": "2026-07-16T14:50:00Z",
  "open_ticket": null,
  "transport": null,
  "e_ticket_delivery_method": "auto_generate"
}

Booking for Singapore Changi Airport → Batam — Private MPV (TRN-FIXROUTE-SIN-BTM, see Transport), fixed fare regardless of headcount (up to capacity_pax: 7).

{
  "event": "booking.confirmed",
  "platform_booking_id": "0198f2b1-7f99-7c86-9b10-000000000601",
  "booking_reference": "BK-2026-01006",
  "legacy_product_id": "TRN-FIXROUTE-SIN-BTM",
  "legacy_hold_id": "HOLD-ERP-01007",
  "trip_type": null,
  "travel_date": "2026-08-25",
  "pax": { "adults": 2, "children": 0, "infants": 0 },
  "passengers": [
    { "type": "adult", "name": "Farah Rahman", "passport": "E1230001", "nationality": "SG", "dob": "1991-07-03" },
    { "type": "adult", "name": "Yusuf Rahman", "passport": "E1230002", "nationality": "SG", "dob": "1990-11-19" }
  ],
  "customer": { "name": "Farah Rahman", "email": "farah.rahman@example.com", "phone": "+6591230004" },
  "payment": { "amount": 120.00, "currency": "SGD", "method": null, "provider": "acetours_pay", "reference": "PAY-AT-000601" },
  "tax_summary": {
    "destination_scope": "international",
    "tax_lines": [
      { "name": "Zero-rated (Outbound)", "tax_type": "zero_rated", "rate": 0.00, "amount": "0.00", "inclusive": true }
    ],
    "embedded_tax_total": "0.00",
    "added_tax_total":    "0.00",
    "tax_total":          "0.00",
    "tax_note":           "Zero-rated supply — outbound transport service"
  },
  "confirmed_at": "2026-07-16T15:33:00Z",
  "open_ticket": null,
  "transport": {
    "transport_subtype": "fix_route",
    "trip_type": null,
    "pickup_location": "SIN_T1",
    "dropoff_location": "BTM_NONGSA",
    "session_time": null,
    "selected_seats": null
  },
  "legacy_hold_ids": null,
  "e_ticket_delivery_method": "auto_generate"
}
Fixed 2026-07-21 (previously documented here as a known gap): transport is now populated by BookingWebhookSender::buildTransportDetail() for every transport booking. Shape depends on subtype and whether it's single-leg or multi-leg:
  • fix_route (single-leg) — pickup_location/dropoff_location (location codes from type_data.pickup_locations[]/dropoff_locations[]), session_time (HH:MM or null if the product has no time_slots[]).
  • disposalpickup_location (free text), session_time (requested pickup time), hours_booked.
  • sic (single-leg) — session_time + selected_seats[].
  • Round-trip / multi-city (fix_route or sic), 2–6 legs: instead of the flat shape above, carries legs[] — see the multi-leg example below.

Multi-leg example — sic round trip across two different products

A sic round trip or multi-city itinerary can span different published products (a route is one-directional — see Transport), so each leg carries its own legacy_product_id, resolved independently — do not assume every leg is the top-level legacy_product_id. fix_route round trips reuse one product for every leg, so their legs[] entries omit nothing extra, but the shape is identical.

{
  "event": "booking.confirmed",
  "legacy_product_id": "TRN-SIC-SENTOSA-CITY",
  "legacy_hold_id": "HOLD-ERP-02001",
  "legacy_hold_ids": ["HOLD-ERP-02001", "HOLD-ERP-02002"],
  "transport": {
    "transport_subtype": "sic",
    "trip_type": null,
    "legs": [
      {
        "leg_position": 1,
        "legacy_product_id": "TRN-SIC-SENTOSA-CITY",
        "legacy_hold_id": "HOLD-ERP-02001",
        "travel_date": "2026-09-01",
        "session_time": "20:15",
        "pickup_location": null,
        "dropoff_location": null,
        "selected_seats": ["12", "13"]
      },
      {
        "leg_position": 2,
        "legacy_product_id": "TRN-SIC-CITY-SENTOSA",
        "legacy_hold_id": "HOLD-ERP-02002",
        "travel_date": "2026-09-03",
        "session_time": "09:00",
        "pickup_location": null,
        "dropoff_location": null,
        "selected_seats": ["4", "5"]
      }
    ]
  }
}

leg_position is 1-based, matching leg order in the original booking request. legacy_hold_id at both the top level and per-leg is "leg 1's hold" / "this leg's hold" respectively — convert/release each leg's hold independently using its own legacy_hold_id, not the top-level one, for legs after the first.

Also fixed 2026-07-21: legacy_hold_ids (plural) is now sent on every booking.confirmed/booking.cancelled event (not just transport) — null for a single-hold booking, the full ordered hold-id list for any multi-leg/multi-night booking. This gives the ERP the complete hold list at the booking level. Not yet fixed: the hotel/cruise units[] gap below is unrelated to this and still stands — legacy_hold_ids only carries whole-booking-level holds (multi-night dates or transport legs), not a per-unit/per-room breakdown.

ERP processing:

  1. Locate the hold by legacy_hold_id. Convert hold → confirmed booking.
  2. Assign ticket codes (attraction products) from the ERP pool.
  3. Emit a follow-up stock.updated with reason: b2c_booking_confirmed.
  4. If e_ticket_delivery_method === "stock_upload" — call POST /api/v1/bridge/bookings/{platform_booking_id}/voucher with the pre-generated PDF URL(s). The marketplace will email them to the customer.
  5. If legacy_hold_id is missing / expired / unknown → flag for manual ops review (do not silently drop).

payment.provider{ "acetours_pay", "stripe", "fomopay" }; payment.reference is gateway-specific (e.g. pi_3Pxyz... for Stripe). See Payment Providers.

Event: booking.cancelled

{
  "event": "booking.cancelled",
  "platform_booking_id": "uuid-...",
  "booking_reference": "BK-2026-00123",
  "legacy_product_id": "PKG-001",
  "cancelled_at": "2026-05-13T08:00:00Z",
  "cancellation_reason": "Customer requested cancellation",
  "refund": { "amount": 2497.00, "currency": "SGD", "status": "refunded", "refunded_at": "2026-05-13T08:05:00Z" },
  "tax_summary": { "...": "see tax_summary below" }
}

ERP processing: release the stock for that booking, process refund allocation on the ERP side, emit stock.updated (release reason) to bump the mirror back up.

Open (Q2): cancellation of a confirmed booking may release the hold via this webhook or via a direct DELETE /holds/{id} call. Proposal: use this webhook. Confirm with Architecture before the ERP team builds it.

tax_summary — same structure every case, values differ

"tax_summary": {
  "destination_scope": "domestic",
  "tax_lines": [
    { "name": "GST (9%)",             "tax_type": "gst",      "rate": 0.09, "amount": "82.57", "inclusive": true  },
    { "name": "Batam Departure Levy", "tax_type": "port_tax", "rate": 0.00, "amount": "36.00", "inclusive": false }
  ],
  "embedded_tax_total": "82.57",
  "added_tax_total":    "36.00",
  "tax_total":          "118.57",
  "tax_note":           "Prices inclusive of 9% GST. Port levy collected on behalf of Batam Port Authority."
}

Reconciliation rules for the ERP:

Idempotency & retry (marketplace → ERP)

Pre-Uploaded Voucher Callback ERP → Marketplace

POST /api/v1/bridge/bookings/{platform_booking_id}/voucher

For products with e_ticket_delivery_method: stock_upload, the ERP generates the tickets internally and pushes the PDF URL(s) to the marketplace via this callback. The marketplace stores the URLs and emails the PDFs to the customer as attachments.

When to call: After receiving and processing the booking.confirmed webhook for a stock_upload product. Call as soon as the ERP-side ticket PDF(s) are ready. The marketplace waits up to 10 minutes before sending the confirmation email — if the callback arrives within that window, the PDFs are attached to the first email. If it arrives later, the customer receives a second "Your voucher is ready" email with the PDFs.

Authentication

Same as all Bridge API calls — pass the bridge service key in X-Api-Key.

Request

FieldTypeReq?Description
vouchersarrayrequiredOne entry per ticket PDF. Must contain at least 1 item. Count must equal pax.adults + pax.children for the booking.
vouchers[].urlstring (URL)requiredPublicly accessible URL to the PDF file. Max 2048 characters. No auth required — the marketplace fetches it server-side.
vouchers[].ticket_typeenumoptionaladult | child | senior. Identifies which passenger type the ticket belongs to.

Example request

POST /api/v1/bridge/bookings/0198f2b1-3b55-7c42-9b10-000000000301/voucher
X-Api-Key: <bridge-service-key>
Content-Type: application/json

{
  "vouchers": [
    { "url": "https://erp.example.com/tickets/BK-2026-01003-adult-1.pdf", "ticket_type": "adult" },
    { "url": "https://erp.example.com/tickets/BK-2026-01003-child-1.pdf", "ticket_type": "child" }
  ]
}

Responses

HTTPBodyMeaning
200{"message": "vouchers received"}Vouchers saved. Customer email will include the PDFs.
200{"message": "already received"}Idempotent — vouchers were already stored for this booking. No duplicate rows or emails.
401Missing or invalid X-Api-Key.
404{"error": "booking_not_found"}platform_booking_id does not exist.
422{"error": "delivery_mode_mismatch"}The booking's product is not stock_upload. Do not retry.
422{"error": "voucher_count_mismatch", "expected": N, "received": M}Voucher count doesn't match pax.adults + pax.children. Correct the count and retry.
422{"error": "validation_error", ...}Missing or invalid field in the request body.

Idempotency

The callback is idempotent per booking. If voucher rows already exist for platform_booking_id, the request is a no-op and returns 200 already received. No duplicate rows are inserted and no additional email is sent.

One-shot: Once accepted, the voucher set for a booking is locked. If the ERP accidentally sends the wrong count, the callback is rejected with voucher_count_mismatch — correct the payload and retry. If vouchers were accepted but the wrong URLs were sent, contact the marketplace operations team for manual correction.

Late callback flow

The marketplace dispatches the confirmation email after a configurable wait window (default 10 min) regardless of whether the ERP has called back. If the callback arrives after the email has already been sent:

  1. Voucher rows are saved as normal.
  2. The marketplace automatically dispatches a follow-up "Your voucher is ready" email with the PDF(s) attached.
  3. The customer receives two emails total: the original confirmation (no PDFs) and the follow-up.

End-to-End Flow

Happy path from calendar view to confirmation. Steps 5–7 are identical for all payment providers.

sequenceDiagram participant C as Customer participant MP as Marketplace participant ERP participant PG as Payment Gateway C->>MP: 1. view calendar (reads mirror, no ERP call) MP-->>C: dates + available C->>MP: 2. proceed to pay MP->>ERP: 3. POST /stock/holds (3s timeout, breaker) ERP-->>MP: hold_id + expires_at ERP->>MP: emit stock.updated (mirror −) MP->>MP: 4. INSERT booking (pending_payment, store legacy_hold_id, +15m) MP->>PG: 5. create payment session PG-->>MP: payment_url + token MP-->>C: booking + payment_url C->>PG: 6. pay (acetours / stripe / fomopay) PG-->>MP: payment.succeeded (webhook) MP->>MP: 7. verify sig + amount, status=confirmed (atomic) MP->>ERP: 8. POST /bookings (booking.confirmed + legacy_hold_id, HMAC) ERP->>ERP: 9. convert hold→booked, assign tickets ERP->>MP: stock.updated (b2c_booking_confirmed) MP-->>C: 10. confirmation + voucher + email

Failure branches

BranchBehaviour
Step 3 — insufficient_stockHTTP 409 to customer, no booking record created.
Step 3 — ERP down / timeoutFail closed: customer can browse but cannot pay. No record.
Payment failsDELETE /stock/holds/{id} (sync). status = payment_failed.
15-min timeout (cleanup job / 60s)DELETE /stock/holds/{id}. status = expired. The marketplace does NOT mark expired until release confirmed.
Cancellationbooking.cancelled webhook → ERP releases + refunds.

Open Questions & Build Checklist

Open questions — decide with Marketplace / Architecture before building

#QuestionProposal
Q2Confirmed-booking cancellation: release via booking.cancelled webhook or DELETE /holds?Use the webhook; ERP emits stock.updated back.
Q5Hold TTL ownership — does ERP enforce its own expiry?Yes — ERP enforces a safety-net TTL (~20 min) and emits stock.updated (b2c_hold_released) on auto-expiry.
Q6Stock API auth — reuse bridge key or separate?Separate key/secret scoped to stock ops.
Q3Display exact available count or just Available / Sold out?Product decision; does not affect ERP build.

Surface 1 — Stock Operations API (ERP hosts)

Surface 2 — Stock Sync Webhook (ERP emits)

Surface 3 — Booking Webhook receiver (ERP hosts)

Surface 4 — Pre-Uploaded Voucher Callback (ERP calls Marketplace) — stock_upload only

Payment Providers — Three Options

Payment runs through one of three gateways. The booking flow (steps 5–7 in End-to-End Flow) is identical for all — only the integration details and the payment.provider value differ. Selectable per tenant / config.

OptionproviderIntegrationOwned byNotes
AceTours Payacetours_pay pay.acetours.sg — create payment session → payment_url + token; signed payment.succeeded / payment.failed webhook back to marketplace ERP team Internal provider details (card, PayNow/FPX, settlement) out of scope here; ERP supplies the 2-touch-point contract.
Stripestripe PaymentIntent + client_secret (Stripe Elements, embedded); payment_intent.succeeded / payment_intent.payment_failed webhook Marketplace Direct Stripe integration on the marketplace side. ERP not involved beyond receiving booking.confirmed.
FOMO Payfomopay Create transaction → hosted payment page / QR (card, PayNow, etc.); async payment-result callback (webhook) back to marketplace Marketplace SG gateway. Verify callback per FOMO Pay's signature scheme (RSA/JWS — confirm against their API docs). ERP not involved beyond booking.confirmed.

Common contract the marketplace needs from any gateway

  1. Create a payment for booking.total_amount (SGD) → return something the client can pay with (payment_url + token, or client_secret).
  2. Verify a signed webhook on success → marketplace sets booking confirmed (atomic, amount-validated).
  3. On failure → release hold via DELETE /stock/holds/{id}, booking payment_failed.

Regardless of provider, the ERP always receives the same booking.confirmed webhook; only payment.provider + payment.reference differ.

End-to-End Walkthrough

The full lifecycle, start to finish — every step links to the section that documents it. Four phases: Product SyncStock SyncBookingPayment.

Phase 1 — Product Sync ERP → Marketplace

  1. Get credentials — request your Bridge service key (X-Api-Key) and the webhook HMAC secret (STOCK_WEBHOOK_SECRET) from EZCore. See Authentication.
  2. Publish a productPOST /api/v1/bridge/products with the operator block, type, type_data, pricing[] and an initial availability[] seed. Expect 201 Created with b2c_visible: true. The type_data shape depends on type — see Product Types.
  3. Verify it is liveGET /api/v1/bridge/products?legacy_product_id=... (or check the storefront) to confirm the product shows and stock seeded.
  4. Update as neededPUT /api/v1/bridge/products/{legacy-product-id} for price / detail changes (pricing[] is replaced as a full block).
  5. Unpublish / delistDELETE /api/v1/bridge/products/{legacy-product-id} for a reversible soft delist (unpublished) or permanent removal (delisted).

Phase 2 — Stock Sync ERP → Marketplace

  1. Backfill on publish — right after first publish, emit a stock.updated for every active future date so the mirror matches the ERP.
  2. Emit on every stock move — fire POST /api/marketplace/webhooks/stock on each trigger: b2b_booking_placed / b2b_booking_cancelled, b2c_hold_placed / b2c_hold_released, b2c_booking_confirmed, manual_allotment_change, blackout_toggled. Sign with HMAC-SHA256 over {timestamp}.{raw_body}; send a fresh event_id and current emitted_at. Expect 201 { "status": "processed" } (or 200 { "duplicate": true }).
  3. Handle failures — on non-2xx, poll GET /webhooks/stock/events/{event_id}; if failed and retry_count < max_retry, re-send with the same event_id + emitted_at (fresh timestamp + signature). See Errors.

Phase 3 — Booking Marketplace → ERP

  1. Customer browses — the marketplace reads the display-only mirror; no ERP call yet.
  2. Check stock — at checkout the marketplace calls GET /stock/availability for a real-time count.
  3. Place holdPOST /stock/holds with product_id, date, quantity, variant_code, session_time. 201 returns the ERP hold_id (stored as legacy_hold_id); 409 = insufficient stock and no booking is created. The ERP then emits stock.updated (b2c_hold_placed).
  4. Booking record + payment session — the marketplace creates a pending_payment booking (15-min TTL) and a payment session, then returns a payment_url to the customer.

Phase 4 — Payment, Confirm & Cancel

  1. Customer pays — via one of the three providers (acetours_pay / stripe / fomopay). The provider sends a signed result webhook to the marketplace, which verifies signature + amount.
  2. Confirm — on success the marketplace sets the booking confirmed and sends the booking.confirmed webhook to the ERP (carrying legacy_hold_id, passengers[], payment, tax_summary). The ERP converts the hold to a booking by legacy_hold_id, assigns tickets, and emits stock.updated (b2c_booking_confirmed).
  3. Payment failed / expiry — the marketplace releases the hold via DELETE /stock/holds/{hold_id} (idempotent). The 15-min cleanup job does the same on timeout; the ERP also enforces a safety-net TTL and emits stock.updated (b2c_hold_released).
  4. Cancellation — after confirm, the marketplace sends booking.cancelled (with refund + tax_summary); the ERP releases stock, processes the refund, and emits stock.updated.
  5. Reconciliation — run GET /api/v1/bridge/products?updated_after=<ts> nightly to catch any drift (e.g. admin-side status changes).

Auth & Config Summary

Every surface and its credentials in one place. All webhook signatures use HMAC-SHA256 over {timestamp}.{raw_body} (header X-Webhook-Signature: sha256=<hex> + X-Webhook-Timestamp), with a 300-second replay window — except Stripe and FOMO Pay, which use their own schemes.

SurfaceDirectionHeader(s)Secret / KeyNotes
Bridge API — publish / update / unpublish / listERP → MarketplaceX-Api-KeyBridge service keyIssued by EZCore. See Authentication. Rate-limited per key.
Stock Operations API — availability / holdsMarketplace → ERPX-Api-Keystock-ops API keyProposal: separate key, not the bridge key (Q6). Platform Admin issues. ERP hosts the endpoints.
Stock Sync Webhookstock.updatedERP → MarketplaceX-Webhook-Signature + X-Webhook-TimestampSTOCK_WEBHOOK_SECRETHMAC over {ts}.{raw_body}, 300s replay window. Idempotent on event_id.
Delivery Status PollERP → MarketplaceX-Api-Keystock-ops / bridge keyRead-only poll of /webhooks/stock/events/{event_id}.
Booking Webhookbooking.confirmed / booking.cancelledMarketplace → ERPX-Webhook-Signature + X-Webhook-Timestampshared webhook_secretSame HMAC scheme. ERP verifies + dedupes on (platform_booking_id, event).
Payment — AceTours Pay→ MarketplaceX-Webhook-Signaturepayment-service secretpay.acetours.sg (ERP-owned). Reuses the {ts}.{raw_body} HMAC scheme. Full contract is a separate ERP-owned spec — TBD.
Payment — Stripe→ MarketplaceStripe-SignatureSTRIPE_WEBHOOK_SECRETStripe's own scheme. Marketplace-side. ERP not involved.
Payment — FOMO Pay→ MarketplaceFOMO Pay signature (RSA/JWS)FOMO Pay API key / signing keysMarketplace-side. Verify per FOMO Pay API docs. ERP not involved.
Clock sync: both sides must run NTP. The 300s replay window means a drift > 5 min breaks every webhook.

Glossary

Every term, field, and code used across this guide.

Identity & Products

TermDefinition
operator / tenant / agentThe travel supplier (e.g. "Ace Tours Pte Ltd"). Maps to a tenant in the EZCore multi-tenant system. Auto-provisioned on first Bridge publish.
operator.idThe operator's UUID in EZCore.
operator.slugURL-safe operator slug. Auto-provisions the tenant if unknown.
legacy_product_idYour ERP's identifier for the product. Forms the upsert key together with operator. Never changes once published.
typeProduct type enum: tour_package | hotel | attraction | ferry | cruise | transport.
type_dataType-specific nested object; its shape depends on type. A mismatch returns 422.
status (product)published (live), draft (saved, not visible), unpublished (reversible delist), delisted / discontinued (permanent, no new bookings).
b2c_visibleComputed flag on BridgeProduct: true when status is published and there is active availability. Drives storefront visibility.
destination_scopedomestic | international.
is_flat_rateWhen true, price is per-booking rather than per-pax.
floor_priceMinimum displayed selling price.
physical_capacityTotal physical units the operator can fulfil; caps allotment across all ranges.
cancellation_policyRefund rules: free_cancellation | non_refundable | tiered.

Pricing & Components

TermDefinition
pax_typePricing dimension: adult | child | infant | senior.
variant_codeRoom / cabin / seat / ticket type — a pricing and availability dimension.
component_groupA selectable group in a tour package (e.g. ferry, hotel, room_type): has code, label, is_required, hold_unit, options[].
option_codeA choice within a component_group (e.g. batam-fast).
hold_unitComponent inventory dimension: per_pax | per_booking.
berthCruise pricing: double (2 share a cabin), single (1 adult, supplement), extra (3rd/4th, lower rate).
component surchargeExtra pricing[] row keyed by component_group + option_code + pax_type. 0.01 = "no extra cost"; negative = discount.

Stock & Availability

TermDefinition
product_availability mirrorThe read-only availability table EZCore keeps, updated exclusively by the stock.updated webhook after the initial seed.
total_allotmentThe ceiling — total rooms / seats / cabins for a date or range.
availableCurrent bookable count = allotment minus all holds and bookings (B2B + B2C).
is_blackout / blackoutA date marked unsellable regardless of count. Set with is_blackout: true, cleared with false.
variant_allotments[] / cabin_allotments[] / component_allotments[]Per-variant / per-cabin-type / per-component-option breakdowns inside an availability or stock-change entry.
stock.updatedThe webhook event the ERP emits whenever stock moves. See stock.updated Webhook.
reason (stock change)b2b_booking_placed, b2b_booking_cancelled, b2c_hold_placed, b2c_hold_released, b2c_booking_confirmed, manual_allotment_change, blackout_toggled.
emitted_atWhen the ERP generated the event. Last-writer-wins conflict key; must stay constant across retries.
event_idUnique id of a stock event. Idempotency key — re-sending returns 200 duplicate: true without re-processing.
last-writer-winsConflict rule: for the same date, the newer emitted_at wins; the older is silently discarded.
backfillAfter publish (or DB restore), the ERP emits stock.updated for every active future date.

Booking & Holds

TermDefinition
platform_booking_idThe marketplace's booking UUID. The ERP stores it for reconciliation.
hold / hold_id / legacy_hold_idA temporary reservation on ERP stock from POST /stock/holds. The ERP returns hold_id; the marketplace stores it as legacy_hold_id and returns it at confirm / release.
expires_atThe hold TTL — a 15-minute window from the marketplace. The ERP enforces its own slightly longer safety-net TTL.
available_after_holdThe remaining available count returned right after a hold is placed.
travel_date / check_out_dateTravel / check-in date; check_out_date only for stay-range products (hotel, cruise).
pax / passengers[]{ adults, children, infants } counts; passengers[] carries per-traveller detail on confirm.
booking_referenceMarketplace-generated booking reference (e.g. BK-2026-00123).
booking.confirmed / booking.cancelledThe two booking webhook events the marketplace sends to the ERP. See Booking Webhook.

Payment & Tax

TermDefinition
payment.provideracetours_pay | stripe | fomopay. Selectable per tenant.
payment.referenceGateway-specific reference (e.g. pi_3P... for Stripe, PAY-AT-000123 for AceTours Pay).
tax_summaryTax breakdown on a booking: tax_lines[], embedded_tax_total, added_tax_total, tax_total.
tax_lines[]Each: name, tax_type (e.g. gst, port_tax), rate, amount, inclusive.
embedded_tax_total / added_tax_totalInclusive tax already inside the amount vs. exclusive tax (e.g. port levy) added on top.
refundOn booking.cancelled: amount, status, refunded_at.

Webhooks, Auth & Ops

TermDefinition
X-Api-KeyAPI-key header for the Bridge API and Stock Operations API.
X-Webhook-SignatureHMAC-SHA256 digest in the form sha256=<hex> over {timestamp}.{raw_body}.
X-Webhook-TimestampUnix epoch seconds; rejected if outside the replay window.
X-Webhook-Event / X-Webhook-Delivery-IDEvent type (booking.confirmed / booking.cancelled) and a per-delivery UUID.
HMAC signatureHMAC-SHA256 over {timestamp}.{raw_body} with the shared secret.
replay window300 seconds — requests with a timestamp older than this are rejected.
deduplicationBy event_id (stock) or (platform_booking_id, event) (booking) to avoid double-processing.
reconciliationNightly GET /api/v1/bridge/products?updated_after=<ts> to catch drift.
STOCK_WEBHOOK_SECRET / webhook_secretShared HMAC secrets for the stock and booking webhooks.

Open items — confirm with backend team before publishing