How to Map Room Types Across Booking.com and Expedia
This page is the build guide for the one job every distribution integration has to get right first: binding a single physical room configuration to its Booking.com and Expedia identifiers so inventory and rates stay in parity across both channels. It sits under OTA channel mapping strategies, which frames mapping as a stateful, referential-integrity problem rather than a one-off spreadsheet — here we implement the registry, the validation boundary, the drift check, and the idempotent write that turn that framing into running code.
Prerequisites & environment
Room mapping looks like static configuration but behaves like a continuous synchronization pipeline: the two OTAs expose different taxonomies (room_type_id + rate_plan_id on Booking.com’s Connectivity API; property_room_type_id + rate_plan_id on Expedia’s EQC/Partner Central), and either side can drift out from under you via a front-desk override or a cache flush. Pin these versions so the deterministic-ID and validation behaviour below is reproducible:
- Python 3.11+ — for stable
uuid.uuid5behaviour and thedatetime.UTCalias used in the audit log. pydantic2.6+ — the mapping contract uses the v2 API (field_validator,model_dump), not the v1validator/dict()calls.httpx0.27+ andtenacity8.x — resilient async writes with retry against transient channel errors.polars0.20+ — vectorized comparison when you diff a whole property’s room set at once.structlog24.x — JSON audit telemetry keyed byproperty_id,ota, andcorrelation_id.- API access — write scopes on both channels, with credentials rotated through OAuth2 token refresh so a
401never interrupts a mapping push mid-flight.
Every room_type_code and rate_plan_code you map must already conform to your rate plan taxonomy and the shared standardized JSON payload shape; mapping onto codes that violate those contracts pushes the inconsistency straight to the OTA.
Step-by-step implementation
The mapping pipeline is four parts: a canonical registry that gives each physical room one stable identity, a validation gate that catches malformed payloads before the OTA does, a drift detector that spots divergence, and an idempotent write that reconciles it safely. Wire them in this order.
Step 1 — Give each physical room one deterministic identity
Build a canonical room-type registry in your middleware and mint a stable key for each physical configuration with a namespaced UUIDv5, derived from property_id plus a normalized room descriptor. Because UUIDv5 is a pure hash of its inputs, the same room always resolves to the same key without a central sequence — so two workers mapping the same room never mint competing IDs.
import uuid
import unicodedata
# One fixed namespace per deployment; the canonical id is a pure function of
# (property_id, normalized descriptor), so it is reproducible across workers
# and requires no coordinating database sequence.
NAMESPACE_ROOMS = uuid.UUID("6f9619ff-8b86-d011-b42d-00cf4fc964ff")
def canonical_room_id(property_id: str, descriptor: str) -> uuid.UUID:
# NFKC-normalize so "Deluxe King" and "Deluxe King" collapse to one key.
norm = unicodedata.normalize("NFKC", descriptor).strip().casefold()
return uuid.uuid5(NAMESPACE_ROOMS, f"{property_id}:{norm}")
canonical = canonical_room_id("prop_0a1b2c3d", "Deluxe King Room")
mapping = {
"canonical_room_id": str(canonical),
"booking_com": {"room_type_id": "BK-1029", "rate_plan_id": "BK-STD-FLEX"},
"expedia": {"property_room_type_id": "EXP-55210", "rate_plan_id": "EXP-BAR"},
}
NFKC normalization before hashing is the load-bearing detail: OTA extranets and PMS exports disagree constantly on non-breaking spaces, full-width characters, and casing, and without it the “same” room mints two canonical keys and every downstream diff reports a phantom mismatch. Persist these bindings in a junction table with a UNIQUE index on (canonical_room_id, ota) and ON DELETE RESTRICT, so a mapping can never be orphaned from the room it describes.
Step 2 — Validate and shape each OTA payload at the boundary
Both channels reject malformed payloads with rigid, channel-specific errors — Booking.com returns 400 INVALID_ROOM_TYPE_CONFIGURATION when max_occupancy or bed configuration exceeds property limits, and Expedia returns RATE_PLAN_NOT_FOUND when the mapped rate_plan_code is not in its taxonomy. Catch those locally with a Pydantic v2 model so a bad record is rejected at your edge instead of consuming an OTA round-trip and a rate-limit token.
from pydantic import BaseModel, field_validator
from typing import Literal
import uuid
class RoomMapping(BaseModel):
canonical_room_id: uuid.UUID
ota: Literal["booking_com", "expedia"]
ota_room_code: str
rate_plan_code: str
max_occupancy: int
bed_configuration: str
status: Literal["active", "inactive", "temporarily_closed"]
@field_validator("max_occupancy")
@classmethod
def occupancy_within_ota_limits(cls, v: int) -> int:
if not 1 <= v <= 12:
raise ValueError("max_occupancy must be 1..12 to satisfy both OTAs")
return v
@field_validator("ota_room_code", "rate_plan_code")
@classmethod
def normalize_code(cls, v: str) -> str:
# Trim + upper + hard length cap: both channels silently truncate long
# codes, which would desync the local mapping from the stored one.
return v.strip().upper()[:64]
def to_channel_payload(self) -> dict:
base = self.model_dump(mode="json", include={"max_occupancy", "status"})
base["bed_types"] = self.bed_configuration
id_field = "room_type_id" if self.ota == "booking_com" else "property_room_type_id"
base[id_field] = self.ota_room_code
base["rate_plan_id"] = self.rate_plan_code
return base
Using model_dump(mode="json") rather than a hand-built dict means enums and UUIDs serialize to their wire form once, in one place, so the payload you validate is byte-identical to the payload you send. Wrap construction in a try/except ValidationError and route failures to a dead-letter queue for manual review; align the retry decision on any resulting transport error with categorizing 4xx vs 5xx sync errors so a 400 is quarantined, not retried.
Step 3 — Detect drift with a SHA-256 field digest
Remote state diverges from your registry through manual extranet edits, partial write timeouts, and cache propagation lag. Poll each channel’s room-type endpoint on an interval and compare a digest of only the fields that matter for parity — comparing a hash rather than the raw JSON ignores field ordering and cosmetic whitespace, so you alert on real divergence instead of formatting noise.
import hashlib
import json
from typing import Any
CRITICAL_FIELDS = ("name", "max_occupancy", "bed_configuration", "status", "rate_plan_code")
def parity_digest(room: dict[str, Any]) -> str:
subset = {k: room.get(k) for k in CRITICAL_FIELDS}
# sort_keys makes the digest order-independent; ensure_ascii=False keeps
# NFKC-normalized unicode stable so the same room hashes identically here
# and in the remote comparison.
canonical = json.dumps(subset, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def has_drifted(local: dict[str, Any], remote: dict[str, Any]) -> bool:
return parity_digest(local) != parity_digest(remote)
Restricting the digest to CRITICAL_FIELDS is deliberate: OTA responses carry volatile metadata (last-viewed counters, cache timestamps) that would flip the hash on every poll if included, drowning genuine parity breaks in false positives. When has_drifted fires, enqueue a reconciliation write; for near-real-time coverage between polls, pair this with async polling for inventory updates or a channel manager webhook integration.
Step 4 — Push idempotent, logged reconciliation writes
Every reconciliation write must be safe to retry, because a network stutter between your PUT and the channel’s acknowledgment is indistinguishable from a failure. Attach a deterministic idempotency key derived from the payload plus a correlation ID, retry only transient 5xx/429 responses with exponential backoff, and log every attempt before and after so the mapping is auditable.
import hashlib
import json
import httpx
import structlog
from datetime import datetime, UTC
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
log = structlog.get_logger()
@retry(
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(httpx.HTTPStatusError),
reraise=True,
)
async def push_mapping(client: httpx.AsyncClient, ota: str, endpoint: str,
payload: dict, correlation_id: str) -> httpx.Response:
# Key on (correlation_id, payload) so an identical retry collapses server-side
# into the same write instead of creating a duplicate room-type update.
idem = hashlib.sha256(f"{correlation_id}:{json.dumps(payload, sort_keys=True)}"
.encode()).hexdigest()
headers = {"X-Idempotency-Key": idem, "X-Correlation-ID": correlation_id}
log.info("mapping_push_start", ota=ota, correlation_id=correlation_id,
payload_sha256=idem, ts=datetime.now(UTC).isoformat())
resp = await client.put(endpoint, json=payload, headers=headers)
if resp.status_code in (429, 500, 502, 503, 504):
resp.raise_for_status() # transient → tenacity retries with backoff
log.info("mapping_push_done", ota=ota, correlation_id=correlation_id,
http_status=resp.status_code)
return resp
Deriving the idempotency key from the payload hash rather than a random UUID means a retry of the same mapping reuses the key (the channel dedupes it), while an edited mapping produces a new key (it applies) — you get both replay safety and correct updates from one rule. Size the concurrency of these writes to your OTA API rate limits and reuse the shared exponential backoff profile so a burst of reconciliations never trips a channel-wide 429.
Gotchas & production notes
- Bed configuration is not a shared enum. Booking.com and Expedia describe beds with different vocabularies and cardinalities, so a one-to-one string map is fragile. Normalize both to your canonical descriptor before hashing in Step 3, or every poll flags drift on a room that is actually in parity.
- Occupancy limits are property-scoped, not global. The same “Deluxe King” can carry different
max_occupancycaps at two properties. Validate against the property’s limit (Step 2), never a hard-coded constant, or Booking.com bounces the payload withINVALID_ROOM_TYPE_CONFIGURATIONfor the stricter property. - A closed room is a status, not a deletion. Mapping “temporarily closed” to an inventory delete drops the binding and forces a re-map when the room reopens. Keep the
status: temporarily_closedmapping live so the canonical key survives the closure — deletion should only follow anON DELETE RESTRICT-checked decommission. - Rate-plan mismatches masquerade as room-mapping bugs. An
EXP-BARplan that exists in your registry but was renamed on the extranet surfaces asRATE_PLAN_NOT_FOUNDon a room push. Resolve plan codes against your rate plan naming conventions before mapping, so a stale plan alias fails validation locally instead of at the OTA.
Verification snippet
Confirm the two guarantees the pipeline rests on — that canonical IDs are deterministic and that the drift digest ignores cosmetic noise but catches real changes — before trusting a green sync log.
def test_canonical_id_is_deterministic_and_unicode_stable() -> None:
a = canonical_room_id("prop_0a1b2c3d", "Deluxe King Room")
b = canonical_room_id("prop_0a1b2c3d", "deluxe king room") # NBSP + casing
assert a == b # NFKC + casefold collapse them to one key
def test_digest_ignores_noise_but_catches_real_drift() -> None:
local = {"name": "Deluxe King", "max_occupancy": 2, "bed_configuration": "1 KING",
"status": "active", "rate_plan_code": "BK-STD-FLEX", "views_today": 91}
same = {**local, "views_today": 4} # volatile field only
changed = {**local, "max_occupancy": 3} # a real parity break
assert not has_drifted(local, same)
assert has_drifted(local, changed)
test_canonical_id_is_deterministic_and_unicode_stable()
test_digest_ignores_noise_but_catches_real_drift()
The first assertion directly tests the idempotency of identity — if it ever fails, the same room mints two keys and the whole junction table fractures. In production, also assert that every push_mapping call emits both mapping_push_start and mapping_push_done with a matching correlation_id, so a write that vanished mid-flight is provable from the audit log alone.
Related
- OTA channel mapping strategies — the parent workflow: referential integrity, schema versioning, and the canonical registry model this page implements
- Rate Plan Taxonomy Design — the naming and structure the
rate_plan_codemappings must conform to - Standardizing JSON Payloads for Channel Managers — the shared payload shape both OTA writes derive from
- Async Polling for Inventory Updates — the near-real-time drift path that complements interval polling in Step 3
- Handling OTA API Rate Limits — how to size the concurrency of the reconciliation writes in Step 4
← Back to OTA Channel Mapping Strategies