Webhook Intake Adapters for Inbound Work-Order Webhooks
Webhook intake adapters are the push-driven front door of the work order ingestion and parsing pipelines — the stage that accepts an inbound HTTP POST from a tenant portal, vendor system, or building-automation platform and turns it into the same canonical envelope every other intake channel produces.
Where a mailbox poller or a scanned PDF pulls work in on your schedule, a webhook pushes it at you the instant an event fires: a tenant submits a request through a resident app, a vendor’s field-service platform closes out a job, or a building-automation system trips an alarm and calls your endpoint directly. That immediacy is the value — sub-second intake latency and no polling lag — but it also inverts the trust model. You are now running a public endpoint that strangers can POST to, so authenticity, replay protection, and payload discipline become the whole job. This guide implements a webhook adapter end to end: the prerequisites, the request-to-payload data contract, a step-by-step async build on FastAPI, a configuration reference, validation checks, and the failure modes you will actually hit once real senders start retrying.
Prerequisites
The adapter runs as a long-lived ASGI service behind your ingress, distinct from the email intake configuration poller because it is push-driven rather than pull-driven. Before you deploy it, confirm the following are in place.
- Python 3.11+ with
fastapi>=0.110(onstarlette) for the async endpoint,uvicorn>=0.29as the ASGI server, andpydantic>=2.6for request validation. Signature verification uses only the standard-libraryhmacandhashlibmodules — no third-party crypto is required, and adding one is a common source of subtle bugs. - A durable broker client —
redis>=5.0or an equivalent — so the endpoint can hand each accepted webhook to the async batch processing layer instead of parsing inline. The HTTP handler must return in milliseconds; all heavy work happens on a worker. - A shared secret per sender. Each integrating system (tenant portal, vendor, BAS) is provisioned with its own signing secret so a compromised or rotated key affects exactly one source. Secrets live in the environment or a secrets manager, never in source.
- Environment variables:
WEBHOOK_SIGNING_SECRETS(a mapping of sender id to secret, JSON-encoded),WEBHOOK_TIMESTAMP_TOLERANCE_S(default300),WEBHOOK_MAX_BODY_BYTES(default65536), andDEDUP_TTL_S(default86400). A request whose sender id has no configured secret must fail closed at verification rather than being accepted unsigned. - Ingress permissions. The endpoint must be reachable by the sender’s egress ranges over TLS, and the service’s broker credentials must carry only enqueue rights on the intake queue — a webhook handler never needs to read or commit work orders itself.
Architecture and Data Contract
The adapter sits in front of the pipeline’s Ingest stage and enforces four ordered gates before anything is trusted. It never parses business logic inline and it never writes to the CMMS — it authenticates the caller, rejects duplicates, wraps the raw body in a canonical envelope, and enqueues. Keeping these boundaries strict is what stops a forged or replayed POST from ever reaching normalization.
- Transport boundary: the POST is accepted only if it is within the configured body-size ceiling and carries the required signature and timestamp headers; anything larger or malformed is rejected before it is read into memory.
- Authenticity boundary: the raw body is verified against a per-sender HMAC signature using a constant-time comparison. A failed check returns
401and the request is dropped — it never advances. - Idempotency boundary: the sender’s delivery id is checked against a short-lived dedup store. A delivery already seen returns the original
200without re-enqueuing, so a sender’s retry can never create a second work order. - Normalization boundary: the verified body is wrapped in an
IntakeEnvelope— the same shape the email, PDF, and web adapters emit — and enqueued. Mapping the envelope to a canonicalWorkOrderPayloadhappens on the worker, governed by the work order schema standards.
The signature step is deep enough to warrant its own treatment; the mechanics of key selection, header formats, and constant-time comparison are covered in verifying HMAC signatures on inbound work-order webhooks.
The contract is explicit at every boundary. The input is a raw HTTP request: an opaque JSON body plus a small set of headers the sender controls — a signature, a timestamp, a delivery id, and a sender id. The intermediate artifact is the IntakeEnvelope, identical to what every other adapter produces so the parsing core never learns that a request arrived by webhook. The output, produced downstream on the worker, is the canonical WorkOrderPayload carrying the mandatory SLA triple. Encoding the raw body with pydantic at the boundary means a payload missing a required field is rejected with a precise 422 rather than silently producing a half-formed work order.
from datetime import datetime, timezone
from enum import IntEnum
from typing import Any, Optional
from pydantic import BaseModel, Field
class EscalationTier(IntEnum):
"""Routing severity shared with the rest of the pipeline."""
ROUTINE = 0
STANDARD = 1
URGENT = 2
CRITICAL = 3
class IntakeEnvelope(BaseModel):
"""Canonical output of every channel adapter, including this webhook one."""
correlation_id: str
source_channel: str # "webhook" for this adapter
source_signature: str # sender delivery id, used for dedup + audit
ingested_at: datetime
raw_payload: str # untouched original request body
metadata: dict[str, Any] # sender id, headers, remote addr
class WebhookBody(BaseModel):
"""Input contract: the JSON a sender is required to POST."""
external_ref: str = Field(..., min_length=1, max_length=128)
asset_tag: Optional[str] = Field(None, max_length=64)
summary: str = Field(..., min_length=1, max_length=2000)
reported_priority: int = Field(3, ge=1, le=5)
reported_at: datetime
class WorkOrderPayload(BaseModel):
"""Canonical CMMS work order — SLA fields are mandatory site-wide."""
correlation_id: str
asset_tag: Optional[str] = None
failure_code: str
description: str
priority: int = Field(ge=1, le=5)
requested_completion: datetime
escalation_tier: EscalationTier = EscalationTier.STANDARD
source_channel: str = "webhook"
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
Step-by-Step Implementation
1. Load per-sender secrets and adapter configuration
Read the signing secrets and tuning knobs from the environment once at startup, not per request. Storing one secret per sender id means a rotation or compromise is scoped to a single integration, and a POST from a sender with no configured secret is rejected before any body is read.
import json
import os
class AdapterConfig:
"""Adapter configuration resolved from the environment at startup."""
def __init__(self) -> None:
self.secrets: dict[str, str] = json.loads(
os.environ["WEBHOOK_SIGNING_SECRETS"] # {"tenant-portal": "s3cr3t", ...}
)
self.timestamp_tolerance_s = int(
os.environ.get("WEBHOOK_TIMESTAMP_TOLERANCE_S", "300")
)
self.max_body_bytes = int(
os.environ.get("WEBHOOK_MAX_BODY_BYTES", "65536")
)
self.dedup_ttl_s = int(os.environ.get("DEDUP_TTL_S", "86400"))
def secret_for(self, sender_id: str) -> str:
secret = self.secrets.get(sender_id)
if secret is None:
raise KeyError(f"no signing secret configured for sender {sender_id!r}")
return secret
config = AdapterConfig()
2. Verify the HMAC signature in constant time
Recompute the expected signature over the exact raw bytes the sender signed — never the re-serialized JSON, which may differ byte-for-byte — and compare with hmac.compare_digest so a mismatch takes the same time regardless of where it diverges. Bind the timestamp into the signed material and reject anything outside the tolerance window so a captured request cannot be replayed indefinitely.
import hashlib
import hmac
import time
from datetime import datetime, timezone
class SignatureError(Exception):
"""Raised when a webhook fails authenticity or freshness checks."""
def verify_signature(
raw_body: bytes,
sender_id: str,
timestamp: str,
provided_signature: str,
cfg: AdapterConfig,
) -> None:
"""Validate freshness then authenticity; raise SignatureError on any failure."""
try:
sent_at = int(timestamp)
except (TypeError, ValueError) as exc:
raise SignatureError("missing or non-numeric timestamp") from exc
skew = abs(int(time.time()) - sent_at)
if skew > cfg.timestamp_tolerance_s:
raise SignatureError(f"timestamp outside tolerance ({skew}s)")
secret = cfg.secret_for(sender_id).encode("utf-8")
signed_material = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret, signed_material, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, provided_signature):
raise SignatureError("signature mismatch")
3. Deduplicate on the sender’s delivery id
Senders retry, and a well-behaved sender reuses the same delivery id across every retry of one event. Record each delivery id in a short-lived store keyed with a TTL; a first insert means the event is new, and a rejected insert means you have already accepted it. Returning the original 200 on a repeat lets the sender stop retrying without ever creating a duplicate work order.
class DedupStore:
"""Records seen delivery ids with a TTL. Backed by Redis in production."""
def __init__(self, redis_client, ttl_s: int) -> None:
self._redis = redis_client
self._ttl_s = ttl_s
async def mark_new(self, delivery_id: str) -> bool:
"""Return True if this delivery id was not seen before, else False."""
# SET key value NX EX ttl — atomic insert-if-absent with expiry.
stored = await self._redis.set(
f"webhook:delivery:{delivery_id}", "1", nx=True, ex=self._ttl_s
)
return bool(stored)
4. Map the verified body to a canonical WorkOrderPayload
Once a body is authentic and fresh, narrow it onto the canonical shape used everywhere on this site. The webhook only carries a sender’s self-reported priority, so translate it into the SLA triple deterministically — the real intent refinement happens later through NLP intent classification on the worker. Assigning escalation_tier, priority, and requested_completion here guarantees the payload never enters routing without SLA metadata.
from datetime import timedelta
_PRIORITY_TO_TIER = {
1: EscalationTier.CRITICAL,
2: EscalationTier.URGENT,
3: EscalationTier.STANDARD,
4: EscalationTier.ROUTINE,
5: EscalationTier.ROUTINE,
}
_TIER_SLA_HOURS = {0: 168, 1: 72, 2: 24, 3: 4}
def to_work_order(body: WebhookBody, correlation_id: str) -> WorkOrderPayload:
"""Translate a verified webhook body into the canonical payload."""
tier = _PRIORITY_TO_TIER[body.reported_priority]
sla_hours = _TIER_SLA_HOURS[int(tier)]
return WorkOrderPayload(
correlation_id=correlation_id,
asset_tag=body.asset_tag,
failure_code="FC-GEN",
description=body.summary.strip()[:2000],
priority=body.reported_priority,
requested_completion=body.reported_at + timedelta(hours=sla_hours),
escalation_tier=tier,
source_channel="webhook",
)
5. Compose the async FastAPI endpoint and enqueue
Wire the gates into a single async handler that reads the raw body once, runs the size guard, verifies, deduplicates, wraps the body in an IntakeEnvelope, and enqueues for the worker. The handler stays thin on purpose: it does no parsing and touches no CMMS. It returns 202 Accepted the moment the envelope is safely on the broker, so the sender sees a fast, unambiguous acknowledgement.
import uuid
from datetime import datetime, timezone
from fastapi import FastAPI, Header, Request, Response
app = FastAPI()
# dedup_store and broker are wired at startup; broker.enqueue publishes JSON.
@app.post("/webhooks/work-orders")
async def receive_webhook(
request: Request,
response: Response,
x_sender_id: str = Header(...),
x_delivery_id: str = Header(...),
x_timestamp: str = Header(...),
x_signature: str = Header(...),
) -> dict:
raw = await request.body()
if len(raw) > config.max_body_bytes:
response.status_code = 413
return {"error": "payload too large"}
try:
verify_signature(raw, x_sender_id, x_timestamp, x_signature, config)
except SignatureError as exc:
response.status_code = 401
return {"error": f"unauthorized: {exc}"}
if not await dedup_store.mark_new(x_delivery_id):
response.status_code = 200
return {"status": "duplicate", "delivery_id": x_delivery_id}
body = WebhookBody.model_validate_json(raw) # 422 on schema violation
correlation_id = str(uuid.uuid4())
envelope = IntakeEnvelope(
correlation_id=correlation_id,
source_channel="webhook",
source_signature=x_delivery_id,
ingested_at=datetime.now(timezone.utc),
raw_payload=raw.decode("utf-8"),
metadata={"sender_id": x_sender_id, "reported_at": x_timestamp},
)
await broker.enqueue("intake.webhook", envelope.model_dump(mode="json"))
response.status_code = 202
return {"status": "accepted", "correlation_id": correlation_id}
The worker consuming intake.webhook calls to_work_order and drops the resulting payload into the same Normalize and Validate stages every other channel uses. From that point the request is indistinguishable from one that arrived by email or PDF, which is exactly the property a clean adapter is supposed to guarantee.
Configuration Reference
Keep every tunable in a version-controlled configuration registry, not in the handler source. The defaults below are conservative starting points for a general-purpose intake endpoint exposed to third-party senders.
| Parameter | Accepted values | Default | CMMS-specific notes |
|---|---|---|---|
WEBHOOK_SIGNING_SECRETS |
JSON object of sender id to secret | — (required) | One secret per sender so a rotation or leak is scoped to a single integration. |
WEBHOOK_TIMESTAMP_TOLERANCE_S |
60–900 |
300 |
Replay window; wider tolerates clock skew but lengthens the capture-replay window. |
WEBHOOK_MAX_BODY_BYTES |
4096–262144 |
65536 |
Hard ceiling checked before the body is parsed; blocks memory-exhaustion POSTs. |
DEDUP_TTL_S |
3600–604800 |
86400 |
How long a delivery id is remembered; must exceed the sender’s maximum retry horizon. |
secret_rotation |
overlap | hard |
overlap |
With overlap, accept the previous secret during a grace window so in-flight retries still verify. |
signature_header |
header name | X-Signature |
Header carrying the hex HMAC digest; must match what the sender is configured to send. |
enqueue_queue |
broker topic | intake.webhook |
Topic the worker consumes; isolates webhook traffic from other intake channels. |
Secret rotation is the setting operators get wrong most often. Rotate with overlap: publish the new secret to the sender, accept both the old and new secret for a grace window at least as long as DEDUP_TTL_S, then retire the old one. A hard swap rejects every retry still signed with the previous key and manufactures a burst of 401s.
Validation and Testing
Signature verification is pure over its inputs, so the highest-value test asserts that a correctly signed body verifies and any tampering fails closed. A single deterministic pair catches the two mistakes that matter — accepting a forged body and rejecting a genuine one.
def test_valid_signature_passes_and_tamper_fails():
cfg = AdapterConfig.__new__(AdapterConfig)
cfg.secrets = {"tenant-portal": "s3cr3t"}
cfg.timestamp_tolerance_s = 300
body = b'{"external_ref":"REQ-9","summary":"no heat","reported_at":"2026-07-16T09:00:00Z","reported_priority":1}'
ts = str(int(time.time()))
good = hmac.new(b"s3cr3t", ts.encode() + b"." + body, hashlib.sha256).hexdigest()
# A correctly signed body verifies without raising.
verify_signature(body, "tenant-portal", ts, good, cfg)
# A single flipped byte in the body must fail closed.
tampered = body.replace(b"no heat", b"no HEAT")
try:
verify_signature(tampered, "tenant-portal", ts, good, cfg)
assert False, "tampered body must not verify"
except SignatureError:
pass
On a first-seen delivery the endpoint emits 202 Accepted and a fresh correlation_id; a retry of the same X-Delivery-Id returns 200 with status: duplicate and, critically, no new broker message. Assert both the HTTP status and the broker depth in an integration test — a 200 on the retry with the queue unchanged is the canonical signal that deduplication is holding. A forged body should surface a 401 and never touch the dedup store or the queue at all.
Failure Modes and Troubleshooting
Expand each scenario for the root cause, the diagnostic signal, and the fix. The checklist items render as interactive checkboxes — work through them in order.
An unsigned or forged POST is accepted as a work order
A replayed delivery duplicates a work order
An oversized or malformed body crashes or hangs the endpoint
Retry storms from the sender saturate the endpoint
Frequently Asked Questions
Why verify an HMAC signature instead of using an API key or IP allowlist?
An API key travels in a header and proves only that the caller holds the key; it says nothing about whether the body was altered in transit. An HMAC signature is computed over the exact body and a timestamp, so it authenticates the sender and guarantees integrity in one check, and the timestamp binding gives you replay protection an IP allowlist cannot. IP allowlists are a useful outer layer, but senders behind rotating cloud egress ranges make them brittle on their own.
What status code should the endpoint return, and when?
Return 202 Accepted when a new webhook is verified and safely enqueued, 200 with a duplicate marker when a delivery id has already been processed, 401 when the signature or timestamp fails, 413 when the body exceeds the size ceiling, and 422 when a verified body violates the schema. Senders treat any 2xx as success and stop retrying, so never return 2xx before the envelope is durably on the broker.
How is this different from the email and PDF intake channels?
It is a push channel rather than a pull one. The email intake configuration poller fetches work on your schedule and controls its own rate, whereas a webhook arrives whenever the sender fires and must be authenticated because the caller is untrusted. The payoff is that all three channels converge on the same IntakeEnvelope, so downstream normalization, validation, and routing never need to know how a request arrived.
Where are SLA fields assigned for a webhook?
At the mapping step, from the sender’s self-reported priority. The webhook body carries a coarse priority that translates deterministically into escalation_tier, and that tier derives priority and requested_completion before the payload leaves the adapter. The worker can later refine intent, but the payload never enters routing without the mandatory SLA triple already populated.
Related
Route webhook envelopes onto worker pools through async batch processing, refine the sender’s coarse priority with NLP intent classification, compare this push channel with the pull-based email intake configuration, keep the emitted payload aligned with work order schema standards, and drill into key selection and constant-time comparison in verifying HMAC signatures on inbound work-order webhooks.
Part of: Work Order Ingestion & Parsing Pipelines.