Webhook vs Polling: Choosing an Inventory Sync Ingestion Strategy
Every inventory sync pipeline starts with the same fork: does the channel manager tell you an allotment changed, or do you ask it on a schedule? Pick webhooks alone and a single dropped delivery leaves a room silently oversold until a guest complains; pick polling alone and you either burn quota checking a property that has not changed or you learn about a sold-out date ninety seconds too late. The revenue manager sees the same symptom either way — a parity gap or an overbooking — but the root cause traces back to an ingestion decision an engineer made months earlier without a framework for it. This guide is that framework: a concrete comparison of event-driven webhooks against scheduled polling for inventory ingestion, and the hybrid most production systems converge on, where webhooks act as low-latency hints and polling acts as the reconciliation floor that guarantees eventual correctness. It sits inside the broader API sync and data ingestion domain, whose idempotency and partitioning model both transports must respect.
The audience is the engineer who owns the sync worker and the ops lead who has to explain a stale rate. It covers the six axes that actually differentiate the two transports — latency, delivery guarantees, cost, ordering, back-pressure, and failure recovery — then shows how to run both together so neither’s weakness reaches the OTA.
Architecture and prerequisites
The two transports are not interchangeable implementations of one interface; they have opposite failure modes. A webhook is a push: the channel manager opens an HTTP connection to your endpoint the moment an allotment changes, so latency is near-zero, but delivery is best-effort — a deploy, a timeout, or a 500 on your side means that event is gone unless the sender retries, and many do not retry indefinitely. Polling is a pull: you call the channel manager’s inventory endpoint on a fixed cadence, so you control the delivery guarantee (a poll that fails just runs again next cycle) at the cost of latency bounded by the interval and quota spent on cycles where nothing changed. The comparison below is the decision surface.
Both transports feed the same downstream contract, so the decision is purely about ingestion economics, not about what happens after. Prerequisites for the reference implementation:
- Inputs: a channel manager that emits inventory-change webhooks (HMAC-signed) and exposes an availability snapshot endpoint you can page through.
- Outputs: a stream of normalized inventory events keyed on
(property_id, room_type_code, rate_plan_code), each carrying a monotonic version so duplicates and stale writes collapse. - Runtime: Python 3.11+,
fastapior any ASGI app for the webhook receiver,httpx0.27 for the polling client,pydantic2.x for the event contract,structlog24.x for telemetry, andpolars1.x for the reconciliation diff. - Assumptions: the channel manager is an eventually-consistent replica of the PMS; webhook order is not guaranteed; and every applied change is idempotent, so re-applying a webhook already caught by a poll is a no-op.
The one architectural rule is that neither transport is authoritative on its own. A webhook is a hint that something changed; a poll is a measurement of current state. The hybrid trusts the measurement and uses the hint only to trigger it sooner.
Implementation
Step 1 — Receive the webhook as a low-latency hint, not the source of truth
The webhook receiver’s job is to validate, normalize, and enqueue fast, then return 200 before doing any real work — a slow handler is the most common cause of dropped deliveries, because senders time out and give up. Signature verification is mandatory; the mechanics of constant-time HMAC comparison are covered in verifying webhook HMAC signatures.
import structlog
from fastapi import FastAPI, Request, Response
app = FastAPI()
log = structlog.get_logger()
@app.post("/webhooks/channel-manager/inventory")
async def receive_inventory_hint(request: Request) -> Response:
raw = await request.body() # capture raw bytes before JSON parse
if not verify_hmac(raw, request.headers.get("X-CM-Signature", "")):
return Response(status_code=401) # reject unsigned/forged payloads
event = normalize_inventory_event(raw, source="webhook")
# Enqueue and ACK immediately; the heavy apply happens off the request path.
await INVENTORY_QUEUE.put(event.model_dump())
log.info("webhook.enqueued", partition=event.partition_key(), version=event.version)
return Response(status_code=200)
Returning 200 the instant the event is durably enqueued — not after it is applied — decouples the sender’s timeout from your processing latency, which is what keeps a slow OTA push from causing the channel manager to mark the delivery failed and stop retrying.
Step 2 — Poll on a cadence tuned to volatility, paging the snapshot
Polling is the correctness floor. Rather than a fixed global interval, tune the cadence to booking velocity — a high-demand date polled every 60 seconds, a shoulder-season date every 15 minutes — so quota concentrates where drift is most expensive. The paging mechanics reuse the patterns in async polling for inventory updates.
import httpx
async def poll_inventory(client: httpx.AsyncClient, property_id: str) -> list[dict]:
events, cursor = [], None
while True:
params = {"property_id": property_id, "limit": 500}
if cursor:
params["cursor"] = cursor
resp = await client.get("/inventory/snapshot", params=params)
resp.raise_for_status()
body = resp.json()
# Each row is normalized through the SAME function the webhook path uses.
events.extend(normalize_inventory_event(row, source="poll") for row in body["rows"])
cursor = body.get("next_cursor")
if not cursor: # exhausted the snapshot page set
break
return events
Both transports funnel through one normalize_inventory_event, so a change seen by a webhook and later re-observed by a poll produce byte-identical events and the same idempotency key — the deduplication is automatic rather than a special case in each path.
Step 3 — Merge both streams behind one idempotent apply gate
The hybrid coordinator drains the queue (fed by webhooks) and the poll results into a single apply function guarded by a per-partition version high-water mark. A webhook that beats the poll wins on latency; the poll that follows is deduped as a no-op; a webhook that was dropped is recovered by the poll.
APPLIED_VERSION: dict[tuple[str, str, str], int] = {}
async def apply_event(event: "InventoryEvent", client: httpx.AsyncClient) -> str:
key = event.partition_key()
# Version guard: only newer state advances the partition — stale writes drop.
if event.version <= APPLIED_VERSION.get(key, 0):
log.info("apply.skip_stale", partition=key, version=event.version)
return "skipped"
resp = await client.put(
f"/channels/{event.channel}/availability",
json=event.model_dump(mode="json"),
headers={"Idempotency-Key": event.idempotency_key()},
)
resp.raise_for_status()
APPLIED_VERSION[key] = event.version
log.info("apply.ok", partition=key, source=event.source, version=event.version)
return "applied"
The version guard is the single mechanism that lets two unsynchronized transports feed one destination safely: whichever arrives second is compared against the high-water mark and dropped if it is not strictly newer, so a slow poll can never overwrite a fresher webhook and vice versa.
Schema and data contracts
Both paths must emit the identical normalized shape or the deduplication breaks. This Pydantic v2 model is the canonical inventory event; the source field records provenance for observability but is deliberately excluded from the idempotency key so a webhook and a poll of the same state collapse to one write.
import hashlib
from datetime import date, datetime, timezone
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class InventoryEvent(BaseModel):
"""Normalized inventory-change event, transport-agnostic."""
model_config = ConfigDict(frozen=True) # immutable once normalized
property_id: str = Field(pattern=r"^[A-Z]{3}-[A-Z]{3}-\d{2}$") # e.g. "LON-STJ-01"
room_type_code: str = Field(pattern=r"^[A-Z0-9_]+$") # e.g. "DLX_KING"
rate_plan_code: str = Field(pattern=r"^[A-Z0-9_]+$") # e.g. "BAR"
channel: str
stay_date: date
available_units: int = Field(ge=0) # allotment; 0 means stop-sell
version: int = Field(ge=0) # monotonic per partition (seq or epoch ms)
source: Literal["webhook", "poll"] # provenance only — NOT in the key
observed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@field_validator("channel")
@classmethod
def known_channel(cls, v: str) -> str:
allowed = {"booking_com", "expedia", "agoda", "hostelworld"}
if v not in allowed:
raise ValueError(f"unknown channel slug: {v}")
return v
def partition_key(self) -> tuple[str, str, str]:
return (self.property_id, self.room_type_code, self.rate_plan_code)
def idempotency_key(self) -> str:
# Business identity only: two transports observing the same state → one key.
basis = (f"{self.property_id}:{self.room_type_code}:{self.rate_plan_code}"
f":{self.channel}:{self.stay_date}:{self.available_units}")
return hashlib.sha256(basis.encode()).hexdigest()
Excluding source and observed_at from idempotency_key is the load-bearing choice: the key hashes only the intended state, so the same allotment observed by both transports at slightly different wall-clock times yields one key and one applied write, while a genuinely different available_units produces a new key that does propagate. This contract is the local specialization of the canonical envelope defined across API sync and data ingestion.
Error handling and retry strategy
The two transports fail differently, so their error handling diverges even though they share an apply gate. The retryable-versus-terminal split follows the pipeline-wide error categorization and retry logic.
- Webhook receiver faults are the sender’s problem to retry, within limits. If your handler cannot durably enqueue (broker down), return
503so the channel manager retries; if the payload is malformed or the signature fails, return4xxand do not ask for a retry — a forged or corrupt event should never be reprocessed. Never return200for an event you failed to persist, or the sender considers it delivered and it is gone. - A dropped webhook is not an error you can catch — it is an absence. This is precisely why polling exists in the hybrid: the reconciliation sweep is the backstop that surfaces the change the webhook never delivered, capping worst-case staleness at one poll interval regardless of webhook reliability.
- Polling transport faults (
429,503) are retried with backoff. Apply exponential backoff with jitter against the shared limiter, and honourRetry-After. A failed poll cycle is harmless — it simply runs again — so polling tolerates aggressive backoff that would be unacceptable for a latency-critical push. - Apply-stage
409 Conflictmeans the partition moved under you; re-read the current version and re-evaluate the guard rather than blind-retrying. - Idempotency: every apply carries
sha256(property_id|room_type_code|rate_plan_code|channel|stay_date|available_units)as its key, so a webhook-then-poll double delivery is deduplicated by the destination, not by fragile per-transport bookkeeping.
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter
import httpx
class TransientPollError(Exception): ...
@retry(retry=retry_if_exception_type(TransientPollError),
wait=wait_exponential_jitter(initial=1, max=30),
stop=stop_after_attempt(5), reraise=True)
async def poll_with_retry(client: httpx.AsyncClient, property_id: str) -> list[dict]:
try:
return [e.model_dump() for e in await poll_inventory_events(client, property_id)]
except httpx.HTTPStatusError as exc:
if exc.response.status_code in (429, 500, 502, 503, 504):
raise TransientPollError(exc.response.status_code) from exc
raise # 4xx is terminal for this poll — dead-letter, do not retry
Retrying only the transient status classes for the poll — while letting the receiver push retry responsibility back to the sender — keeps each transport’s recovery model aligned with its guarantee: the pull owns its own reliability, the push delegates it. When a whole channel goes dark, the same isolation principle behind fallback routing for downtime applies — degrade that channel to poll-only and stop trusting its silent webhooks.
Verification and testing
Prove the two properties that make the hybrid correct: that duplicate deliveries across transports collapse to one write, and that a stale event never overwrites a newer one. Assert on the apply outcome and the version high-water mark rather than on HTTP mocks alone.
import pytest
from datetime import date
def _event(units: int, version: int, source: str) -> InventoryEvent:
return InventoryEvent(
property_id="LON-STJ-01", room_type_code="DLX_KING", rate_plan_code="BAR",
channel="booking_com", stay_date=date(2026, 8, 14),
available_units=units, version=version, source=source,
)
def test_webhook_and_poll_of_same_state_share_one_key():
wh = _event(3, 1010, "webhook")
poll = _event(3, 1010, "poll")
assert wh.idempotency_key() == poll.idempotency_key() # dedupe across transports
def test_stale_event_is_skipped():
APPLIED_VERSION[( "LON-STJ-01", "DLX_KING", "BAR")] = 1010
stale = _event(5, 1005, "poll") # older version
assert stale.version <= APPLIED_VERSION[stale.partition_key()]
def test_changed_units_produce_new_key():
assert _event(3, 1010, "webhook").idempotency_key() != _event(2, 1011, "poll").idempotency_key()
These three assertions map to the three ways a hybrid silently breaks: cross-transport keys that fail to match (double writes), a missing version guard (a slow poll clobbering a fresh webhook), and an over-eager key that dedupes a genuine change (a stop-sell never applied). Every apply should emit apply.ok or apply.skip_stale carrying partition, source, and version, so an operator can reconcile which transport actually won each write. That reconciliation loop is formalized in batch reconciliation workflows.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Room oversold despite webhooks configured | A webhook delivery was dropped during a deploy and nothing polled that partition | Ensure every partition is covered by a polling sweep; treat webhooks as hints only, never the sole source |
| Same rate written twice, wasting quota | Webhook and poll emit different shapes, so idempotency keys diverge | Route both transports through one normalize_inventory_event; exclude source/observed_at from the key |
| A stop-sell reappears as available | A stale poll snapshot overwrote a newer webhook stop-sell | Enforce the per-partition version guard; only strictly newer versions advance the high-water mark |
| Channel manager stops sending webhooks | Handler was slow and returned late, so the sender marked deliveries failed and backed off | Enqueue and return 200 immediately; move the apply off the request path |
Poll storm trips 429 across the fleet |
Every property polled on one global fixed interval | Tune cadence to booking velocity and pace polls through the shared rate limiter |
Frequently Asked Questions
If webhooks are faster, why keep polling at all?
Because webhooks are best-effort. A deploy, a timeout, or a transient 500 on your endpoint can drop a delivery, and many channel managers stop retrying after a few attempts. A dropped webhook is an absence you cannot detect from the webhook path alone. A polling sweep measures current state on a cadence, so it recovers any change a webhook failed to deliver and caps worst-case staleness at one interval — which is exactly why the hybrid keeps async polling as the correctness floor.
How do webhook and polling events avoid being applied twice?
Both transports are normalized to the same InventoryEvent shape, and the idempotency key hashes only the business intent — property, room type, rate plan, channel, stay date, and available units — excluding provenance and timestamps. A change seen by a webhook and later re-observed by a poll therefore produce the same key, so the second delivery is deduplicated by the destination as a no-op.
How do you stop a slow poll from overwriting a fresh webhook?
A per-partition version high-water mark. Each event carries a monotonic version, and the apply gate only advances the partition when the incoming version is strictly greater than the last applied one. A stale poll snapshot compared against a newer webhook version is skipped. The same ordering discipline is developed in depth for the push path in handling out-of-order webhook events.
What poll interval should a hybrid use?
Tune it to booking velocity rather than a single global value. High-demand or nearly sold-out dates justify a 60-second sweep because drift there is expensive; shoulder-season dates can poll every 10 to 15 minutes. Because webhooks already carry the latency-critical changes, polling can run slower than a polling-only system would need, which conserves OTA quota.
When is polling-only actually the better choice?
When the channel manager does not offer webhooks, when its webhook reliability is poor enough that you would poll defensively anyway, or when inventory changes slowly and a minute of latency is acceptable. Polling-only has one failure mode and is simpler to reason about; adopt the hybrid only when latency requirements justify the extra receiver and its signature-verification surface.
Related
- Channel Manager Webhook Integration — the receiver side of the push transport: signatures, replay protection, and dead-lettering.
- Async Polling for Inventory Updates — the pull transport that serves as the reconciliation floor in the hybrid.
- Error Categorization & Retry Logic — the retryable-versus-terminal taxonomy each transport’s error handling specializes.
- Fallback Routing for Downtime — degrading a channel to poll-only when its webhooks go dark.
- Handling Out-of-Order Webhook Events — the version-guard ordering the hybrid depends on, built out for the push path.
← Back to API Sync & Data Ingestion Workflows