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:

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.

Room-type mapping data flow from one canonical registry key to two OTA payloads One physical room in the canonical registry, keyed by a deterministic UUIDv5 derived from property_id plus a normalized descriptor, fans out into two per-OTA lanes. The Booking.com lane carries room_type_id and rate_plan_id; the Expedia lane carries property_room_type_id and rate_plan_id. Each lane passes through a Pydantic v2 validation gate, then a SHA-256 field-digest drift check comparing local state against the remote OTA state, then an idempotent PUT stamped with an X-Idempotency-Key that retries only transient 5xx and 429 responses. When the digest drifts, a dashed reconciliation loop re-writes and re-polls parity; when validation fails, a dashed branch routes the record to a dead-letter queue for manual review. One canonical room key → two OTA payloads Validate, digest-check for drift, then write idempotently — per channel, from a single deterministic identity. Booking.com lane · room_type_id · rate_plan_id Expedia lane · property_room_type_id · rate_plan_id drift → reconcile → re-poll parity drift → reconcile → re-poll parity ValidationError Canonical registry one physical room UUIDv5(property_id + normalized descriptor) UNIQUE (id, ota) Pydantic gate RoomMapping (v2) field_validator SHA-256 digest CRITICAL_FIELDS local vs remote Idempotent PUT X-Idempotency-Key retry 5xx / 429 only Pydantic gate RoomMapping (v2) field_validator SHA-256 digest CRITICAL_FIELDS local vs remote Idempotent PUT X-Idempotency-Key retry 5xx / 429 only Dead-letter queue quarantine · manual review

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.

python
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.

python
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.

python
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.

python
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

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.

python
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.

← Back to OTA Channel Mapping Strategies