Handling Out-of-Order Webhook Events in Inventory Sync
Webhooks arrive in the order the network and your worker pool happen to deliver them, not the order the events actually happened. A sender fires “3 rooms left” then “sold out” a second later, but a retry, a slow TLS handshake, or two concurrent workers can flip them — and if the stale “3 rooms left” lands last, you have just re-opened a sold-out date for booking. Solving this needs an ordering rule that depends on the event’s content, not its arrival time. This guide implements last-write-wins with a per-triplet version guard, the reordering discipline that sits underneath channel manager webhook integration once signatures are verified and payloads are enqueued.
The unit of ordering is the saleable triplet (property_id, room_type_code, rate_plan_code). Events for different triplets are independent and need no ordering relative to each other; events for the same triplet must apply newest-first, and anything older than what has already been applied must be discarded rather than written.
Prerequisites and environment
This logic runs in the apply stage, after a webhook has passed signature verification and been normalized. Pin these:
- Python 3.11+ — for
dataclassslots and modern type hints. pydantic2.x — the event model carries the monotonicversionthe guard compares (v2 API:field_validator,model_dump).redis5.x (or an equivalent atomic store) — the per-triplet high-water mark must be read-and-set atomically so two workers cannot both believe they hold the newest version.structlog24.x — every discarded stale event is logged as a key=value event so a suppressed update is observable, not silently dropped.- A monotonic version source in the payload — either a sender-provided sequence number or a reliable event timestamp. Confirm which your channel manager provides; the guard’s correctness rests entirely on this value being monotonic per triplet.
The version can be a sender sequence (source_seq) or an event timestamp in epoch milliseconds. A sequence is preferable when the sender guarantees it is strictly increasing per triplet; a timestamp is the fallback, with the caveat that two events in the same millisecond need a tiebreaker. Both plug into the same guard.
Step-by-step implementation
Step 1 — Carry a monotonic version on the normalized event
The event model must expose the version and the triplet it orders within. Keep the model frozen so a version cannot be mutated after normalization.
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
class InventoryWebhookEvent(BaseModel):
model_config = ConfigDict(frozen=True)
property_id: str = Field(pattern=r"^[A-Z]{3}-[A-Z]{3}-\d{2}$") # "LON-STJ-01"
room_type_code: str = Field(pattern=r"^[A-Z0-9_]+$") # "DLX_KING"
rate_plan_code: str = Field(pattern=r"^[A-Z0-9_]+$") # "BAR"
stay_date: date
available_units: int = Field(ge=0)
version: int = Field(ge=0) # monotonic per triplet: source_seq or epoch-ms
def triplet(self) -> str:
# The ordering scope — one high-water mark per saleable unit.
return f"{self.property_id}:{self.room_type_code}:{self.rate_plan_code}"
Scoping the version to the triplet rather than to the whole property is deliberate: two different room types changing concurrently are independent, so a global sequence would force false ordering between them and needlessly discard valid concurrent updates.
Step 2 — Guard the apply with an atomic compare-and-set
The high-water mark must be updated atomically, or two workers reading the same current value will both decide their event is newer and both write. A Redis Lua script does the compare-and-set in one round trip; the script advances the mark only if the incoming version is strictly greater and reports whether the caller should apply.
import redis.asyncio as redis
# Atomically: set mark = version and return 1 iff version > current mark, else 0.
_CAS = """
local cur = tonumber(redis.call('GET', KEYS[1]) or '-1')
local ver = tonumber(ARGV[1])
if ver > cur then
redis.call('SET', KEYS[1], ver)
return 1
end
return 0
"""
class OrderingGate:
def __init__(self, r: redis.Redis):
self.r = r
self._cas = r.register_script(_CAS)
async def should_apply(self, event: InventoryWebhookEvent) -> bool:
key = f"invmark:{event.triplet()}"
won = await self._cas(keys=[key], args=[event.version])
return bool(won)
Doing the comparison and the write inside one Lua script makes the check-and-claim a single atomic operation, so during a consumer rebalance two workers briefly handling the same triplet cannot both pass the guard — exactly the concurrency hazard a read-then-write in Python would open.
Step 3 — Apply on win, discard-and-log on loss
Wire the gate into the apply path. A winning event is written to the channel; a losing (stale) event is discarded with a structured log so the suppression is observable and auditable, not silent.
import structlog
import httpx
log = structlog.get_logger()
async def apply_ordered(event: InventoryWebhookEvent, gate: OrderingGate,
client: httpx.AsyncClient) -> str:
if not await gate.should_apply(event):
# Stale: a newer version already won this triplet. Drop it, but record it.
log.info("webhook.discard_stale", triplet=event.triplet(),
version=event.version, units=event.available_units)
return "discarded"
resp = await client.put(
f"/inventory/{event.property_id}/availability",
json=event.model_dump(mode="json"),
)
resp.raise_for_status()
log.info("webhook.applied", triplet=event.triplet(), version=event.version)
return "applied"
Logging the discard with the triplet and version turns “the update did not take effect” from an unexplained ghost into a greppable record showing exactly which older version lost to which newer one — indispensable when a revenue manager asks why a webhook “did nothing.”
Step 4 — Give timestamp-based versions a deterministic tiebreaker
If your version is an event timestamp rather than a strict sequence, two events in the same millisecond compare equal and the guard’s strict > discards the second — which may be the one you wanted. Fold a deterministic tiebreaker into a composite version so equal timestamps resolve consistently across workers.
def composite_version(epoch_ms: int, available_units: int) -> int:
# Pack ms into the high bits and a stable tiebreaker into the low bits.
# When two events share a millisecond, the lower unit count (more restrictive)
# wins deterministically — a stop-sell beats a same-instant "still available".
tiebreak = 9999 - min(available_units, 9999)
return epoch_ms * 10_000 + tiebreak
Encoding “more restrictive wins” into the tiebreaker is a safety bias: when the true order is genuinely ambiguous, resolving toward the lower availability errs on the side of not overselling, which is the cheaper mistake for a hotel to make.
Gotchas and production notes
- Do not reset the high-water mark on redeploy. If the mark lives only in process memory, a restart resets every triplet to zero and the next stale replay is applied as if fresh. Keep it in Redis (or another durable store) with a TTL comfortably longer than the sender’s maximum retry window.
- A version that is not actually monotonic silently corrupts ordering. Some senders reset sequence numbers per connection or per day. Verify monotonicity per triplet against real traffic before trusting
source_seq; if it resets, fall back to a timestamp-based composite version. - Reconciliation must respect the same guard. When a polling sweep re-observes state, its events carry versions too. Route them through the identical gate so a poll snapshot older than a just-applied webhook is discarded, not written — otherwise the two paths fight and the date flickers.
- Discarded does not mean ignored forever. A burst of discards for one triplet can indicate a genuinely stuck sender replaying an old state; alert on discard rate per triplet so a persistent loop surfaces instead of quietly suppressing every update.
Verification snippet
Confirm the two behaviours that define correct ordering — a newer version applies and advances the mark, and a stale version is discarded without writing.
import pytest
from datetime import date
def _ev(version: int, units: int) -> InventoryWebhookEvent:
return InventoryWebhookEvent(
property_id="LON-STJ-01", room_type_code="DLX_KING", rate_plan_code="BAR",
stay_date=date(2026, 8, 14), available_units=units, version=version,
)
@pytest.mark.asyncio
async def test_stale_event_is_discarded(gate): # gate seeded at mark=1010
assert await gate.should_apply(_ev(1010, 3)) is True # advances 0 → 1010
assert await gate.should_apply(_ev(1008, 5)) is False # 1008 ≤ 1010 → stale
assert await gate.should_apply(_ev(1012, 0)) is True # 1012 > 1010 → applies
def test_composite_tiebreak_prefers_restriction():
sold_out = composite_version(1_700_000_000_000, 0)
still_open = composite_version(1_700_000_000_000, 5) # same millisecond
assert sold_out > still_open # stop-sell wins the tie
The three-call sequence is the important assertion: it reproduces the exact out-of-order arrival from the diagram — apply, discard, apply — and proves the sold-out event at v1012 sticks while the late “5 units” at v1008 is dropped. In production, also assert that each discard emits exactly one webhook.discard_stale log so no suppression is invisible.
Related
- Channel Manager Webhook Integration — the parent workflow: signature verification, enqueue, and the apply path this ordering guard protects.
- Verifying Webhook HMAC Signatures — authenticating the events before they ever reach the ordering gate.
- Webhook vs Polling Trade-offs — where the same version guard reconciles webhook hints against polling snapshots.
- Async Polling for Inventory Updates — the reconciliation path whose re-observed events must respect the same high-water mark.
- Error Categorization & Retry Logic — distinguishing a stale discard from a genuine failure that needs a retry.
← Back to Channel Manager Webhook Integration