Verifying Webhook HMAC Signatures on Inbound Channel-Manager Events
An unverified webhook endpoint is an open write path into your inventory: anyone who learns the URL can POST a forged availability update and set a room to zero, or replay a captured stop-sell to knock a rate offline. Verifying an HMAC-SHA256 signature on every inbound payload is what turns that endpoint from a trust-anyone door into an authenticated channel, and it is the first thing the receiver must do — before parsing, before enqueuing, before any business logic. This guide implements that verification correctly for a channel-manager webhook, and is the security companion to channel manager webhook integration, where the receiver’s downstream apply path is defined.
Three mistakes make signature checks worthless in practice: comparing against a re-serialized body instead of the exact bytes received, using a non-constant-time string compare that leaks the signature one byte at a time, and omitting a timestamp tolerance so a captured-and-replayed request still validates. This page closes all three.
Prerequisites and environment
The verifier runs at the ASGI boundary, before the request body is deserialized. Pin these so the digest and comparison behave identically across workers:
- Python 3.11+ — for
hmac.compare_digestand modern type hints. fastapi/starlette(or any ASGI framework) — the key requirement is access to the raw request body, which most frameworks expose viaawait request.body().structlog24.x — every rejection is logged as a key=value event keyed byproperty_idso a forged-payload probe is greppable, not buried.- A shared secret per channel manager — provisioned out of band and stored in a secrets manager, never in code. Rotation is handled per the security and authentication boundaries.
- Clock discipline — the receiver’s host must run NTP; the replay window depends on the two clocks being within a few seconds.
The signature scheme assumed here is the common one: the sender computes HMAC-SHA256(secret, f"{timestamp}.{raw_body}") and sends the hex digest plus the timestamp in headers (X-CM-Signature, X-CM-Timestamp). Confirm your channel manager’s exact signing string from its docs — whether the timestamp is inside the signed material is the detail that most often breaks a first integration.
Step-by-step implementation
Step 1 — Capture the raw body before anything parses it
The signature covers the exact bytes the sender hashed. If any middleware deserializes the JSON and you re-serialize it to verify, key ordering, whitespace, and Unicode escaping will differ and every signature will fail. Read the raw body first and hold it.
from fastapi import FastAPI, Request, Response
import structlog
app = FastAPI()
log = structlog.get_logger()
@app.post("/webhooks/channel-manager")
async def receive(request: Request) -> Response:
raw_body: bytes = await request.body() # the exact bytes the sender signed
signature = request.headers.get("X-CM-Signature", "")
timestamp = request.headers.get("X-CM-Timestamp", "")
if not verify_signature(raw_body, signature, timestamp):
log.warning("webhook.rejected", reason="bad_signature")
return Response(status_code=401) # never parse an unverified body
# ... only now is it safe to json.loads(raw_body) and enqueue
return Response(status_code=200)
Reading await request.body() before any JSON access is the single most important line: it captures the bytes verbatim, so the digest you compute is over the same material the sender hashed rather than a lossy round-trip through a parser.
Step 2 — Enforce a timestamp tolerance to stop replay
A valid signature proves the payload came from someone holding the secret, but it does not prove the request is recent. An attacker who captures one legitimate signed request can resend it forever. Reject any request whose signed timestamp is outside a tight tolerance — 300 seconds is a common ceiling.
import time
REPLAY_TOLERANCE_SECONDS = 300 # 5 minutes; tighten if your clocks are well-synced
def timestamp_is_fresh(timestamp: str) -> bool:
try:
sent = int(timestamp)
except (ValueError, TypeError):
return False
# Reject both the distant past (replay) and the far future (clock/forgery).
return abs(time.time() - sent) <= REPLAY_TOLERANCE_SECONDS
Checking abs(now - sent) rather than only now - sent rejects far-future timestamps too, which matters because a forged timestamp set ahead of your clock would otherwise sail through a one-sided window and reopen the replay hole you just closed.
Step 3 — Recompute the HMAC and compare in constant time
Compute HMAC-SHA256 over the signed string (timestamp.raw_body) with the shared secret, then compare against the header using hmac.compare_digest. A naive == on strings short-circuits on the first differing byte, and the timing difference leaks the correct signature to an attacker who measures it — a constant-time compare closes that side channel.
import hmac
import hashlib
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
if not signature or not timestamp_is_fresh(timestamp):
return False
secret = load_channel_secret() # from secrets manager, bytes
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
# Constant-time: comparison cost does not depend on where the strings differ.
return hmac.compare_digest(expected, signature)
Using hmac.compare_digest instead of expected == signature makes the comparison take the same time whether the mismatch is in the first byte or the last, so a remote attacker cannot binary-search the valid digest by timing thousands of forged requests.
Step 4 — Fold verification into a reusable dependency
To guarantee no future route skips the check, express it as a dependency (or middleware) that runs before the handler and rejects with 401 on failure, returning the verified raw body to the handler so it is not read twice.
from fastapi import Depends, HTTPException
async def verified_body(request: Request) -> bytes:
raw_body = await request.body()
if not verify_signature(
raw_body,
request.headers.get("X-CM-Signature", ""),
request.headers.get("X-CM-Timestamp", ""),
):
raise HTTPException(status_code=401, detail="signature verification failed")
return raw_body
@app.post("/webhooks/channel-manager/inventory")
async def inventory_hook(body: bytes = Depends(verified_body)) -> Response:
payload = json_loads_inventory(body) # safe: bytes are authenticated
await enqueue(payload)
return Response(status_code=200)
Expressing verification as a dependency means a new endpoint added next year cannot accidentally omit the check — the type signature forces every handler to receive an already-verified body rather than a raw request it might forget to authenticate.
Gotchas and production notes
- A framework that auto-parses the body will break your signature. Some setups read and cache the JSON before your code runs, and
await request.body()afterwards may return empty or a re-encoded form. Confirm you are getting the original bytes; if the framework consumes the stream, register verification as middleware that reads the body first and stashes it on the request scope. - The timestamp must be inside the signed material, or the tolerance is decorative. If the signature covers only the body, an attacker can replay the body with a fresh timestamp header and pass both gates. Verify from the docs that your channel manager signs
timestamp.body(or equivalent); if it does not sign the timestamp, replay protection needs a nonce cache instead. - Rotate secrets with an overlap window. When a shared secret rotates, in-flight webhooks may still be signed with the old one. Accept either secret during a short overlap — try the current secret, then the previous — so a rotation does not reject a burst of legitimate deliveries and push them to the dead-letter path.
- Reject unauthenticated payloads with
401, not200. Returning200to a forged request tells the sender nothing, but returning401/403also signals a genuine sender to retry a transient misconfiguration; never return200for a payload you did not process, or a real dropped event is silently lost.
Verification snippet
Confirm the two security-critical behaviours — a tampered body is rejected and a stale timestamp is rejected — with assertions rather than a live sender.
import hmac, hashlib, time
_SECRET = b"test-secret"
def _sign(body: bytes, ts: str) -> str:
return hmac.new(_SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
def test_valid_signature_passes(monkeypatch):
monkeypatch.setattr("__main__.load_channel_secret", lambda: _SECRET)
body, ts = b'{"available_units":3}', str(int(time.time()))
assert verify_signature(body, _sign(body, ts), ts) is True
def test_tampered_body_is_rejected(monkeypatch):
monkeypatch.setattr("__main__.load_channel_secret", lambda: _SECRET)
ts = str(int(time.time()))
sig = _sign(b'{"available_units":3}', ts)
assert verify_signature(b'{"available_units":0}', sig, ts) is False # body changed
def test_stale_timestamp_is_rejected(monkeypatch):
monkeypatch.setattr("__main__.load_channel_secret", lambda: _SECRET)
old = str(int(time.time()) - 3600) # one hour old → outside window
body = b'{"available_units":3}'
assert verify_signature(body, _sign(body, old), old) is False
The tampered-body case is the one that matters most: it proves that flipping a single field — available_units from 3 to 0, a forged stop-sell — invalidates the signature, so an attacker cannot alter the inventory an authentic-looking request carries. In production, also assert that the 401 path emits exactly one structured webhook.rejected log per forged request so probing attempts are observable.
Related
- Channel Manager Webhook Integration — the parent workflow: the receiver’s normalize, enqueue, and apply path this verifier guards.
- Handling Out-of-Order Webhook Events — ordering authenticated events correctly once they are past the signature gate.
- Webhook vs Polling Trade-offs — where webhook ingestion fits against scheduled polling in the wider sync design.
- Security & Authentication Boundaries — where the shared signing secrets are provisioned, scoped, and rotated.
- Error Categorization & Retry Logic — deciding when a rejected webhook should ask the sender to retry versus dead-letter.
← Back to Channel Manager Webhook Integration