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:
- Product Sync — the Bridge API: catalog push/sync (publish, update, list products). Auth:
X-Api-Key. - Booking — the live booking surfaces: Stock Operations API (holds) and the Booking Webhook receiver. Auth:
X-Api-Key+ HMAC-SHA256. - Stock Sync — the
stock.updatedwebhook that keeps availability up-to-date. Auth: HMAC-SHA256 signing. - Payment — the three selectable gateways (AceTours Pay, Stripe, FOMO Pay) and their auth/config.
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.
Getting Started
Environments
| Environment | Base URL | Notes |
|---|---|---|
| Cloud / Dev | https://api-marketplace.ezbcloud.space | Shared dev environment |
| Production | Confirm with backend team | Bridge & webhook routes live in the marketplace app |
Credentials to request from EZCore
| Credential | Used for | Header |
|---|---|---|
| Bridge service key | All Bridge API calls | X-Api-Key |
Authentication
Every call to /api/v1/bridge/* authenticates with a single API-key header:
| Header | Value | Error if missing/invalid |
|---|---|---|
X-Api-Key | Your 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:
- New product: HTTP
201 Created - Existing product: HTTP
200 OK
Status values
| Status | Behaviour |
|---|---|
published | Live on storefront; b2c_visible=true. New bookings accepted. |
draft | Saved, not visible to customers. Useful for pre-launch preparation. |
discontinued | Permanently 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.
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
/api/v1/bridge/products
Request body — top-level fields
| Field | Type | Req? | Description |
|---|---|---|---|
operator | Operator Block | required | Operator identity — the tenant / agent (travel supplier). See table below. |
legacy_product_id | string | required | Your ERP's unique product identifier. Upsert key with operator. |
type | enum | required | tour_package | hotel | attraction | ferry | cruise | transport |
name | string | required | Product display name. |
slug | string | optional | URL-friendly identifier. Auto-generated from name if omitted. |
description | string | required | Full description shown to customers. |
destination | string | required | Human-readable destination name (e.g. "Bali"). |
country_code | string | required | ISO 3166-1 alpha-2 (e.g. "ID", "SG"). |
destination_scope | enum | optional | domestic | international. |
images[] | array | optional | Array of { "url": "..." } objects. |
inclusions[] | string[] | optional | What is included in the product price. |
exclusions[] | string[] | optional | What is not included. |
status | enum | optional | published | draft | discontinued. Defaults to draft. |
is_flat_rate | boolean | optional | When true, price is per-booking rather than per-pax. |
floor_price | number | optional | Minimum displayed selling price. |
physical_capacity | number | optional | Total physical units the operator can fulfil (caps allotment across all ranges). |
require_adult_pax | boolean | optional | Hotel & 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_requirement | string | optional* | 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_overrides | object | optional | Per-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_data | object | optional* | Type-specific fields. Shape depends on type. See Product Types. |
pricing[] | PricingRuleInput[] | optional | Price rules per pax_type / variant_code. See below. |
availability[] | AvailabilityEntry[] | optional | Initial allotment ranges. Seeded ONCE on first publish. |
e_ticket_delivery_method | enum | optional | auto_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_policy | CancellationPolicyInput | optional | Refund rules. See below. |
translations[] | ProductTranslationInput[] | optional | Locale-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.
| Field | Type | Description |
|---|---|---|
id | UUID string | Your operator UUID from EZCore. |
slug | string | URL-safe operator slug. Auto-provisions tenant if unknown. |
name | string | Display name for the operator. |
| Field | Type | Description |
|---|---|---|
pax_type | enum | adult | child | infant | senior |
amount | number | Price in the specified currency. Use 0.01 for "free" (not 0). |
currency | string | ISO 4217 code (e.g. "SGD"). |
variant_code | string | Hotels/attractions/ferry/cruise: room/ticket/seat/cabin code. |
berth | string | Cruise only: double | single | extra |
component_group | string | Tour packages with components: component group code. |
option_code | string | Tour packages with components: option code within the group. |
| Field | Description |
|---|---|
start_date / end_date | Date range for this allotment block (ISO 8601). |
total_allotment | Total 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. |
| Field | Description |
|---|---|
type | free_cancellation | non_refundable | tiered |
free_cancellation_hours | Hours 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}).
| Field | Type | Req? | Description |
|---|---|---|---|
locale | string | required | Must 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. |
name | string | optional | Translated product name. Falls back to the canonical name when omitted or no translation row exists for the requested locale. |
description | string | optional | Translated description. Same fallback behaviour as name. |
type_data | object | optional | Sparse 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
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
/api/v1/bridge/products/{legacy-product-id}
Partial update: only the fields you include are changed. Fields you omit keep their existing values.
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
/api/v1/bridge/products/{legacy-product-id}
| Original status | Result | Reversible? |
|---|---|---|
published or draft | Status → unpublished (soft delist). Product hidden from storefront. New bookings blocked. | Yes — re-publish via PUT status: published |
discontinued | Status → delisted (permanent removal). | No — irreversible |
List Products (Sync)
/api/v1/bridge/products
Returns a paginated list of lightweight BridgeProductListItem objects (no type_data or images). Use for periodic reconciliation.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status: published, draft, unpublished, discontinued |
operator_id | UUID | Filter to a single operator. |
updated_after | ISO 8601 datetime | Incremental sync — only products updated after this timestamp. |
page | integer | Page number (1-indexed). |
per_page | integer | Results 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
| Field | Type | Description |
|---|---|---|
duration_nights | integer | Number of nights. Use 0 for day trips. |
departure_point | string | Origin city/terminal. |
min_pax | integer | Minimum passengers required to confirm. |
max_pax | integer | Maximum passengers allowed. |
itinerary[] | array | Day-by-day itinerary. Each entry: day, title, description, meals[] (e.g. ["breakfast","lunch"]), accommodation (string or null). |
component_groups[] | array | Optional. 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
| Field | Type | Description |
|---|---|---|
star_rating | integer | 1–5 stars. |
check_in_time | string (HH:MM) | Standard check-in time. |
check_out_time | string (HH:MM) | Standard check-out time. |
amenities[] | string[] | Free-form amenity tags (e.g. "wifi", "pool", "gym"). |
room_types[] | array | Each entry: code, name, description, max_guests, images[]. |
Top-level hotel extras (richer than current spec):
| Field | Description |
|---|---|
physical_capacity | Total room count in the property. |
require_adult_pax | When 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"
}
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
| Field | Type | Description |
|---|---|---|
location_name | string | Attraction display name. |
address | string | Full street address. |
latitude / longitude | number | GPS coordinates. |
operating_hours[] | array | Each entry: days[] (0=Sun, 1=Mon … 6=Sat), open (HH:MM), close (HH:MM). |
ticket_types[] | array | Each: code, name, pax_type. |
has_time_slots | boolean | true → timed-entry; slots are time windows in the Stock webhook's session_time field. |
time_slots[] | array | Each: code, label, capacity_per_slot. Empty array when has_time_slots=false. |
advance_booking_required_days | integer | Minimum 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"
}
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
| Field | Type | Description |
|---|---|---|
operator | string | Ferry operator display name. |
origin_port / origin_port_code | string | Human-readable name and short code (e.g. "SGHHF"). |
destination_port / destination_port_code | string | Destination port name and code. |
trip_type | enum | one_way | return |
schedule[] | array | Each departure: departure_time (HH:MM), arrival_time (HH:MM), days_of_week[] (0=Sun … 6=Sat). |
seat_classes[] | array | Each: 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
| Field | Type | Description |
|---|---|---|
ship_name | string | Name of the vessel. |
ship_operator | string | Cruise line name. |
route_name | string | Human-readable route description. |
ports_of_call[] | array | Each 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_date | date string | Cruise start and end dates (ISO 8601). |
duration_nights | integer | Number of nights at sea. |
booking_unit | string | per_cabin (standard). |
cabin_types[] | array | Each: code, max_occupancy, min_occupancy, max_adults, max_children, max_infants, positions[] → { "code": "midship", "price_modifier": 50 }. |
Top-level cruise extras:
| Field | Description |
|---|---|
require_adult_pax | When 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"
}
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)
| Field | Sub-type | Type | Description |
|---|---|---|---|
transport_subtype | All | enum | required. fix_route | disposal | sic. |
vehicle_type | All | enum | required. sedan | mpv | van | minibus | coach | speedboat. |
vehicle_model | All | string | optional. Model name or "or equivalent" string. |
capacity_pax | All | integer | required. ≥ 1. |
capacity_luggage | All | integer | optional. |
pricing_method | All | enum | required. fixed (fix_route) | per_hour (disposal) | per_seat (sic). |
trip_type | fix_route | enum | required. 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_border | fix_route | boolean | optional. 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, sic | array | required. Each: code, name, address (opt), city (opt), country (opt, ISO alpha-2). |
dropoff_locations[] | fix_route (opt), sic (req) | array | Same shape as pickup_locations[]. |
time_slots[] | sic (req), fix_route (opt) | array | Each: code (display/reference id, unique), pickup_time (HH:MM — the real departure, matched against at booking time), turnaround_minutes (opt). |
seat_layout.seats[] | sic | array | required. Each: number (unique string id), row/col (opt, frontend grid only). |
minimum_pax | fix_route (dflt 1), sic (dflt 2) | integer | optional. sic excludes infants from this count — they ride free. |
minimum_hours / maximum_hours | disposal | integer | optional. Bounds the customer's hours_booked at booking time. maximum_hours defaults to 8 if omitted. |
operating_area | disposal | string | optional. Free-text display description. |
operating_hours | disposal | object | optional. { start, end } (HH:MM) — bounds the customer's requested pickup time. |
distance_km | All | number|null | optional. Display only. |
features[] | All | string[] | optional. e.g. "aircon", "wifi", "bottled_water", "meet_greet", "child_seat". |
Pricing:
- fix_route — one flat
adultrow = the whole-vehicle fare per direction, applied regardless of pax count. Do not publish a separate "return total" rule — round trip is booked as two legs, each resolving this same per-direction fare independently. - disposal — one
adultrow = the per-hour rate. Booking fare =amount × hours_booked. There is notype_data.hourly_rate— the rate comes from thispricing[]array, same channel as every other sub-type. - sic — one row per
pax_type(age-tiered; infants ride free — omit or sendamount: 0.00). Addvariant_code= a slot'spickup_timeto vary the fare per departure; omitvariant_codefor one uniform fare across all slots.
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.
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
| Status | Meaning | Common cause |
|---|---|---|
200 | OK — upsert (product existed) | Same operator + legacy_product_id |
201 | Created — new product | New legacy_product_id |
401 | Unauthorized | Missing or invalid X-Api-Key |
422 | Unprocessable Entity | Validation failure — check errors object in response. Common: type_data shape mismatch, missing required field |
429 | Too Many Requests | Rate 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
| Action | Use |
|---|---|
| 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 |
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.
| Header | Value |
|---|---|
X-Webhook-Timestamp | Unix epoch seconds (server rejects if > 300s in the past) |
X-Webhook-Signature | sha256=<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";
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
/api/marketplace/webhooks/stock
Request body — StockUpdatedRequest
| Field | Type | Req? | Description |
|---|---|---|---|
event | string | required | Always "stock.updated" |
event_id | string | required | Unique identifier for this event. Idempotency key — keep constant across retries. |
legacy_product_id | string | required | The ERP's product ID (must match Bridge publish). |
emitted_at | ISO 8601 datetime | required | When the ERP generated this event. Last-writer-wins key — keep constant across retries. |
changes[] | StockChange[] | required | Min 1 change entry. See table below. |
variant_code | string | optional | Top-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:
| Field | Shape | Description |
|---|---|---|
date | flat/date | ISO date for single-date updates. |
start_date + end_date | range | Date range. Applies the same values to every date in the range. |
total_allotment | both | Total capacity for this date/range. |
available | both | Current available count after all bookings. |
is_blackout | both | If true, marks date as closed. Omit to leave existing value unchanged. Set false to clear a blackout. |
reason | both | b2b_booking_placed | b2b_booking_cancelled | manual_adjustment | erp_sync |
reference | both | Optional ERP booking/event reference ID. |
variant_code | both | Room/seat/cabin code (hotels, ferries, attractions, cruises). |
session_time / session_end_time | both | Time-slot selector for timed-entry attractions. ISO datetime or HH:MM string. |
variant_allotments[] | range only | Per-variant breakdown within the date range. |
component_allotments[] | range only | Per-component-option breakdown (tour packages). |
cabin_allotments[] | range only | Per-cabin-type breakdown (cruises). |
Key behaviours
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.
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.
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
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
| Trigger | reason value | Effect on mirror |
|---|---|---|
| B2B booking placed | b2b_booking_placed | available ↓ |
| B2B booking cancelled | b2b_booking_cancelled | available ↑ |
| B2C hold placed (via POST /holds) | b2c_hold_placed | available ↓ |
| B2C hold released / expired | b2c_hold_released | available ↑ |
| B2C booking confirmed | b2c_booking_confirmed | no net change (hold→booked) |
| Manual allotment change | manual_allotment_change | total_allotment + available change |
| Blackout toggled | blackout_toggled | is_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):
- Require
X-Webhook-SignatureandX-Webhook-Timestamp, else400. abs(now − X-Webhook-Timestamp)must be ≤ 300s, else400"outside replay window".computed = HMAC_SHA256("{timestamp}.{raw_body}", STOCK_WEBHOOK_SECRET). Compare against hex in"sha256=<hex>". Mismatch →401.- Body must be a JSON object with non-empty
eventandevent_id, else400.
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
"{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).
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
/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
| Field | Description |
|---|---|
status | received | processed | failed |
rows_applied | Number of product_availability rows written. |
retry_count | How many times this event has been attempted. |
error_message | Last error if status is failed. |
attempts[] | Array of WebhookEventAttempt — one entry per delivery attempt with timestamp and outcome. |
emitted_at | Timestamp from the original payload. |
processed_at | When the event was successfully processed (null if not yet). |
ERP retry decision matrix
| Poll result | ERP action |
|---|---|
status: processed | Do not re-send. Event was processed successfully. |
status: failed or received AND retry_count < max_retry | Re-send with the same event_id and emitted_at. Generate a fresh X-Webhook-Timestamp and signature. |
retry_count >= max_retry | Further 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
Errors & Status Codes
| Status | Meaning | ERP action |
|---|---|---|
201 | Created — event accepted and processed | No action needed |
200 | OK — either idempotent replay (duplicate: true) or unknown product ack | No action needed |
400 | Bad Request | Fix payload: missing required headers, invalid JSON, timestamp outside 300s window, non-parseable body. Not retryable as-is. |
401 | Signature mismatch | Recompute HMAC — check secret and signing string format. Not retryable as-is. |
422 | Permanently rejected | Fix payload: unsupported event type, missing_changes, missing_emitted_at. Not retryable as-is. |
429 | Retry budget exhausted | Dead-letter + alert ops. |
500 | Transient server error | Retryable. 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:
| # | Surface | Direction | Sync/Async | Who calls |
|---|---|---|---|---|
| 1 | Stock Operations API | Marketplace → ERP | Synchronous | Marketplace calls the ERP |
| 2 | Stock Sync Webhook | ERP → Marketplace | Asynchronous | ERP calls marketplace |
| 3 | Booking Webhook receiver | Marketplace → ERP | Asynchronous | Marketplace calls the ERP |
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).
(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
| Item | Value |
|---|---|
| Auth | API key in X-Api-Key header (separate key scoped to stock ops — see Auth & Config). |
| Client behaviour | The 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)
GET /stock/availability Marketplace → ERP
{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
| Parameter | Req? | Description |
|---|---|---|
product_id | required | The ERP product ID (i.e. legacy_product_id elsewhere). |
start_date / end_date | required | ISO date range to query. |
variant_code | optional | Room 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.
{ERP_BASE_URL}/api/marketplace/stock/availability/batch
Request fields
| Field | Description |
|---|---|
product_id | The ERP product ID (same value as legacy_product_id elsewhere). |
date | Single 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. |
units | Array 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.
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.
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
{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
| Field | Description |
|---|---|
product_id | The ERP product ID (i.e. legacy_product_id elsewhere). |
date | Travel / 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. |
quantity | Units to hold — typically 1 per booking, except transport sic where it's the number of selected seats. |
variant_code | optional. Room / cabin / ticket type / seat class. Null for products with no variant. |
session_time | optional. HH:MM — timed-entry attractions, ferry sailings, transport fix_route/sic. |
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
}
201, not 200 — any other status (including
200) is treated as a failure by the marketplace client.
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.
{ERP_BASE_URL}/api/marketplace/stock/holds/batch
Request fields
| Field | Description |
|---|---|
product_id | The 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. |
date | Single travel date, YYYY-MM-DD. For a multi-night stay the marketplace calls this once per night, same convention as availability/batch. |
units | Array 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_time | optional. 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.
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
{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" }
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
{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
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<hmac> |
X-Webhook-Timestamp | <unix_epoch_seconds> |
X-Webhook-Event | booking.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"
}
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).
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"
}
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 fromtype_data.pickup_locations[]/dropoff_locations[]),session_time(HH:MMornullif the product has notime_slots[]).disposal—pickup_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.
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:
- Locate the hold by
legacy_hold_id. Convert hold → confirmed booking. - Assign ticket codes (attraction products) from the ERP pool.
- Emit a follow-up
stock.updatedwithreason: b2c_booking_confirmed. - If
e_ticket_delivery_method === "stock_upload"— callPOST /api/v1/bridge/bookings/{platform_booking_id}/voucherwith the pre-generated PDF URL(s). The marketplace will email them to the customer. - If
legacy_hold_idis 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.
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:
embedded_tax_total— inclusive tax already insidepayment.amount. Does not add to the total.added_tax_total— exclusive port levy added on top. Already reflected inpayment.amount.- Port tax is never GST-taxed. The ERP must not apply GST to
added_tax_total.
Idempotency & retry (marketplace → ERP)
- The marketplace retries failed deliveries: 1min → 5min → 15min → 1h → 4h, then dead-letters and alerts its admin.
- Each attempt has a fresh
X-Webhook-Delivery-ID, but the sameplatform_booking_id+event. - The ERP must dedupe on
(platform_booking_id, event)— the marketplace does NOT suppress retries. - Under load, the ERP responds
503withRetry-Afterrather than timing out.
Pre-Uploaded Voucher Callback ERP → Marketplace
/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.
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
| Field | Type | Req? | Description |
|---|---|---|---|
vouchers | array | required | One entry per ticket PDF. Must contain at least 1 item. Count must equal pax.adults + pax.children for the booking. |
vouchers[].url | string (URL) | required | Publicly accessible URL to the PDF file. Max 2048 characters. No auth required — the marketplace fetches it server-side. |
vouchers[].ticket_type | enum | optional | adult | 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
| HTTP | Body | Meaning |
|---|---|---|
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. |
401 | — | Missing 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.
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:
- Voucher rows are saved as normal.
- The marketplace automatically dispatches a follow-up "Your voucher is ready" email with the PDF(s) attached.
- 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.
Failure branches
| Branch | Behaviour |
|---|---|
Step 3 — insufficient_stock | HTTP 409 to customer, no booking record created. |
| Step 3 — ERP down / timeout | Fail closed: customer can browse but cannot pay. No record. |
| Payment fails | DELETE /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. |
| Cancellation | booking.cancelled webhook → ERP releases + refunds. |
Open Questions & Build Checklist
Open questions — decide with Marketplace / Architecture before building
| # | Question | Proposal |
|---|---|---|
| Q2 | Confirmed-booking cancellation: release via booking.cancelled webhook or DELETE /holds? | Use the webhook; ERP emits stock.updated back. |
| Q5 | Hold 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. |
| Q6 | Stock API auth — reuse bridge key or separate? | Separate key/secret scoped to stock ops. |
| Q3 | Display exact available count or just Available / Sold out? | Product decision; does not affect ERP build. |
Surface 1 — Stock Operations API (ERP hosts)
GET /api/marketplace/stock/availability— per-date available + blackoutPOST /api/marketplace/stock/availability/batch— not yet built; per-unit availability check for multi-room/cabin bookings, see GET /stock/availabilityPOST /api/marketplace/stock/holds— create hold, returnhold_id, 409 on insufficientPOST /api/marketplace/stock/holds/batch— not yet built; atomic multi-room/cabin hold, 409 withunavailable_unitson partial conflict, see POST /stock/holdsDELETE /api/marketplace/stock/holds/{hold_id}— idempotent release (hold_not_found= no-op)X-Api-Keyauth on all five- Respond within ~1s (marketplace timeout is 3s)
- Safety-net TTL auto-expiry on holds (Q5)
Surface 2 — Stock Sync Webhook (ERP emits)
- Emit
stock.updatedon every trigger in the triggers table (especially B2B movements) - Correct HMAC signing:
sha256=+ hmac over{ts}.{raw_body} - Unique
event_id, accurateemitted_at - Retry with backoff on non-2xx
- Initial backfill on product publish
Surface 3 — Booking Webhook receiver (ERP hosts)
POST /api/marketplace/bookingsacceptingbooking.confirmed+booking.cancelled- Verify HMAC signature + 5-min replay window
booking.confirmed: convert hold bylegacy_hold_id, assign tickets, emit follow-upstock.updatedbooking.cancelled: release stock, process refund- Dedupe on
(platform_booking_id, event) - Respond
202within 10s; flag missing / expiredlegacy_hold_idfor manual review - If
e_ticket_delivery_method === "stock_upload"— proceed to Surface 4
Surface 4 — Pre-Uploaded Voucher Callback (ERP calls Marketplace) — stock_upload only
POST /api/v1/bridge/bookings/{platform_booking_id}/voucherwithX-Api-Key- One entry per ticket PDF:
{ url, ticket_type } - Voucher count must equal
pax.adults + pax.children— retry with corrected count on422 voucher_count_mismatch - Idempotent —
200 already receivedon duplicate calls - Call as soon as PDFs are ready; the marketplace waits up to 10 min before sending the confirmation email
- Late callback (after email fires) triggers a follow-up "voucher ready" email automatically
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.
| Option | provider | Integration | Owned by | Notes |
|---|---|---|---|---|
| AceTours Pay | acetours_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. |
| Stripe | stripe |
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 Pay | fomopay |
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
- Create a payment for
booking.total_amount(SGD) → return something the client can pay with (payment_url+ token, orclient_secret). - Verify a signed webhook on success → marketplace sets booking
confirmed(atomic, amount-validated). - On failure → release hold via
DELETE /stock/holds/{id}, bookingpayment_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 Sync → Stock Sync → Booking → Payment.
Phase 1 — Product Sync ERP → Marketplace
- Get credentials — request your Bridge service key (
X-Api-Key) and the webhook HMAC secret (STOCK_WEBHOOK_SECRET) from EZCore. See Authentication. - Publish a product —
POST /api/v1/bridge/productswith theoperatorblock,type,type_data,pricing[]and an initialavailability[]seed. Expect201 Createdwithb2c_visible: true. Thetype_datashape depends ontype— see Product Types. - Verify it is live —
GET /api/v1/bridge/products?legacy_product_id=...(or check the storefront) to confirm the product shows and stock seeded. - Update as needed —
PUT /api/v1/bridge/products/{legacy-product-id}for price / detail changes (pricing[]is replaced as a full block). - Unpublish / delist —
DELETE /api/v1/bridge/products/{legacy-product-id}for a reversible soft delist (unpublished) or permanent removal (delisted).
Phase 2 — Stock Sync ERP → Marketplace
- Backfill on publish — right after first publish, emit a
stock.updatedfor every active future date so the mirror matches the ERP. - Emit on every stock move — fire
POST /api/marketplace/webhooks/stockon 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 freshevent_idand currentemitted_at. Expect201 { "status": "processed" }(or200 { "duplicate": true }). - Handle failures — on non-2xx, poll
GET /webhooks/stock/events/{event_id}; iffailedandretry_count < max_retry, re-send with the sameevent_id+emitted_at(fresh timestamp + signature). See Errors.
Phase 3 — Booking Marketplace → ERP
- Customer browses — the marketplace reads the display-only mirror; no ERP call yet.
- Check stock — at checkout the marketplace calls
GET /stock/availabilityfor a real-time count. - Place hold —
POST /stock/holdswithproduct_id,date,quantity,variant_code,session_time.201returns the ERPhold_id(stored aslegacy_hold_id);409= insufficient stock and no booking is created. The ERP then emitsstock.updated(b2c_hold_placed). - Booking record + payment session — the marketplace creates a
pending_paymentbooking (15-min TTL) and a payment session, then returns apayment_urlto the customer.
Phase 4 — Payment, Confirm & Cancel
- 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. - Confirm — on success the marketplace sets the booking
confirmedand sends thebooking.confirmedwebhook to the ERP (carryinglegacy_hold_id,passengers[],payment,tax_summary). The ERP converts the hold to a booking bylegacy_hold_id, assigns tickets, and emitsstock.updated(b2c_booking_confirmed). - 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 emitsstock.updated(b2c_hold_released). - Cancellation — after confirm, the marketplace sends
booking.cancelled(withrefund+tax_summary); the ERP releases stock, processes the refund, and emitsstock.updated. - 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.
| Surface | Direction | Header(s) | Secret / Key | Notes |
|---|---|---|---|---|
| Bridge API — publish / update / unpublish / list | ERP → Marketplace | X-Api-Key | Bridge service key | Issued by EZCore. See Authentication. Rate-limited per key. |
| Stock Operations API — availability / holds | Marketplace → ERP | X-Api-Key | stock-ops API key | Proposal: separate key, not the bridge key (Q6). Platform Admin issues. ERP hosts the endpoints. |
Stock Sync Webhook — stock.updated | ERP → Marketplace | X-Webhook-Signature + X-Webhook-Timestamp | STOCK_WEBHOOK_SECRET | HMAC over {ts}.{raw_body}, 300s replay window. Idempotent on event_id. |
| Delivery Status Poll | ERP → Marketplace | X-Api-Key | stock-ops / bridge key | Read-only poll of /webhooks/stock/events/{event_id}. |
Booking Webhook — booking.confirmed / booking.cancelled | Marketplace → ERP | X-Webhook-Signature + X-Webhook-Timestamp | shared webhook_secret | Same HMAC scheme. ERP verifies + dedupes on (platform_booking_id, event). |
| Payment — AceTours Pay | → Marketplace | X-Webhook-Signature | payment-service secret | pay.acetours.sg (ERP-owned). Reuses the {ts}.{raw_body} HMAC scheme. Full contract is a separate ERP-owned spec — TBD. |
| Payment — Stripe | → Marketplace | Stripe-Signature | STRIPE_WEBHOOK_SECRET | Stripe's own scheme. Marketplace-side. ERP not involved. |
| Payment — FOMO Pay | → Marketplace | FOMO Pay signature (RSA/JWS) | FOMO Pay API key / signing keys | Marketplace-side. Verify per FOMO Pay API docs. ERP not involved. |
Glossary
Every term, field, and code used across this guide.
Identity & Products
| Term | Definition |
|---|---|
| operator / tenant / agent | The 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.id | The operator's UUID in EZCore. |
operator.slug | URL-safe operator slug. Auto-provisions the tenant if unknown. |
legacy_product_id | Your ERP's identifier for the product. Forms the upsert key together with operator. Never changes once published. |
type | Product type enum: tour_package | hotel | attraction | ferry | cruise | transport. |
type_data | Type-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_visible | Computed flag on BridgeProduct: true when status is published and there is active availability. Drives storefront visibility. |
destination_scope | domestic | international. |
is_flat_rate | When true, price is per-booking rather than per-pax. |
floor_price | Minimum displayed selling price. |
physical_capacity | Total physical units the operator can fulfil; caps allotment across all ranges. |
cancellation_policy | Refund rules: free_cancellation | non_refundable | tiered. |
Pricing & Components
| Term | Definition |
|---|---|
pax_type | Pricing dimension: adult | child | infant | senior. |
variant_code | Room / cabin / seat / ticket type — a pricing and availability dimension. |
component_group | A selectable group in a tour package (e.g. ferry, hotel, room_type): has code, label, is_required, hold_unit, options[]. |
option_code | A choice within a component_group (e.g. batam-fast). |
hold_unit | Component inventory dimension: per_pax | per_booking. |
berth | Cruise pricing: double (2 share a cabin), single (1 adult, supplement), extra (3rd/4th, lower rate). |
| component surcharge | Extra pricing[] row keyed by component_group + option_code + pax_type. 0.01 = "no extra cost"; negative = discount. |
Stock & Availability
| Term | Definition |
|---|---|
| product_availability mirror | The read-only availability table EZCore keeps, updated exclusively by the stock.updated webhook after the initial seed. |
total_allotment | The ceiling — total rooms / seats / cabins for a date or range. |
available | Current bookable count = allotment minus all holds and bookings (B2B + B2C). |
is_blackout / blackout | A 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.updated | The 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_at | When the ERP generated the event. Last-writer-wins conflict key; must stay constant across retries. |
event_id | Unique id of a stock event. Idempotency key — re-sending returns 200 duplicate: true without re-processing. |
| last-writer-wins | Conflict rule: for the same date, the newer emitted_at wins; the older is silently discarded. |
| backfill | After publish (or DB restore), the ERP emits stock.updated for every active future date. |
Booking & Holds
| Term | Definition |
|---|---|
platform_booking_id | The marketplace's booking UUID. The ERP stores it for reconciliation. |
hold / hold_id / legacy_hold_id | A 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_at | The hold TTL — a 15-minute window from the marketplace. The ERP enforces its own slightly longer safety-net TTL. |
available_after_hold | The remaining available count returned right after a hold is placed. |
travel_date / check_out_date | Travel / 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_reference | Marketplace-generated booking reference (e.g. BK-2026-00123). |
booking.confirmed / booking.cancelled | The two booking webhook events the marketplace sends to the ERP. See Booking Webhook. |
Payment & Tax
| Term | Definition |
|---|---|
payment.provider | acetours_pay | stripe | fomopay. Selectable per tenant. |
payment.reference | Gateway-specific reference (e.g. pi_3P... for Stripe, PAY-AT-000123 for AceTours Pay). |
tax_summary | Tax 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_total | Inclusive tax already inside the amount vs. exclusive tax (e.g. port levy) added on top. |
refund | On booking.cancelled: amount, status, refunded_at. |
Webhooks, Auth & Ops
| Term | Definition |
|---|---|
X-Api-Key | API-key header for the Bridge API and Stock Operations API. |
X-Webhook-Signature | HMAC-SHA256 digest in the form sha256=<hex> over {timestamp}.{raw_body}. |
X-Webhook-Timestamp | Unix epoch seconds; rejected if outside the replay window. |
X-Webhook-Event / X-Webhook-Delivery-ID | Event type (booking.confirmed / booking.cancelled) and a per-delivery UUID. |
| HMAC signature | HMAC-SHA256 over {timestamp}.{raw_body} with the shared secret. |
| replay window | 300 seconds — requests with a timestamp older than this are rejected. |
| deduplication | By event_id (stock) or (platform_booking_id, event) (booking) to avoid double-processing. |
| reconciliation | Nightly GET /api/v1/bridge/products?updated_after=<ts> to catch drift. |
STOCK_WEBHOOK_SECRET / webhook_secret | Shared HMAC secrets for the stock and booking webhooks. |
Open items — confirm with backend team before publishing
- Production ERP-facing base URL for Bridge + webhook routes.