A reroute webhook is the entry point for every event-driven correction in a fleet-routing platform: a traffic incident closes a road, a driver misses a turn, or a delivery window shifts, and something upstream needs to push a new route to a moving vehicle within seconds. This page implements that entry point as the foundation of webhook-driven dynamic re-routing, the part of routing API automation and fleet integration responsible for reacting to live events rather than pre-computed schedules. The constraint that makes this harder than a typical CRUD endpoint is timing: the caller expects acknowledgement in milliseconds, while route recomputation against OSRM or Valhalla can take anywhere from tens of milliseconds to several seconds depending on graph size and vehicle count.
Three failure modes dominate in production: a duplicate webhook delivery triggers two conflicting reroutes for the same vehicle, an unauthenticated caller injects a forged reroute event, and a slow recomputation blocks the request thread long enough that the upstream dispatcher times out and retries — compounding the first problem. The implementation below addresses all three with Pydantic payload validation, an idempotency-key store, HMAC-SHA256 signature verification, and FastAPI BackgroundTasks to decouple acknowledgement from computation.
When to Use This Approach
A dedicated webhook receiver is the right shape when reroute events originate from external, push-based systems — a traffic-incident feed, a transportation-management platform, a driver’s mobile app — rather than from a schedule you control. If your only trigger is a periodic recheck, a polling loop against the routing engine is simpler and does not need signature verification or idempotency handling at all.
Use BackgroundTasks when recomputation reliably completes in under roughly two seconds and request volume is modest. BackgroundTasks run in the same worker process and event loop as the request that scheduled them, so a burst of reroute events competes with regular request handling for the same resources. Beyond that threshold — or under bursty incident-driven load where dozens of vehicles need rerouting at once — move recomputation to a real task queue such as Celery, RQ, or arq, and have the webhook only enqueue a job.
Use idempotency keys whenever the upstream system has at-least-once delivery semantics, which is the default for nearly every webhook provider and message broker. Without deduplication, a single network retry can produce two independent route recomputations and two conflicting pushes to the same vehicle within seconds of each other.
Use HMAC verification on any endpoint reachable from the public internet. Skip it only for traffic confined to an internal service mesh where mutual TLS already authenticates the caller — in that case the signature check is redundant, not wrong.
Implementation
The endpoint below validates the payload, verifies the signature against the raw request body, deduplicates by idempotency key, and hands recomputation to a background task so the caller gets an immediate 202. Graph construction, engine deployment, and general FastAPI project layout are covered in the parent guide — this snippet focuses on the webhook contract itself.
# requires: fastapi, pydantic, aiohttp, uvicorn (pip install fastapi pydantic aiohttp "uvicorn[standard]")
# Python 3.9+ | fastapi >= 0.110
import hashlib
import hmac
import os
import time
from typing import Optional
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, status
from pydantic import BaseModel, Field
app = FastAPI(title="reroute-webhook")
WEBHOOK_SECRET = os.environ["REROUTE_WEBHOOK_SECRET"].encode("utf-8")
IDEMPOTENCY_TTL_S = 600
# Per-process only — see Integration Points for the multi-worker replacement.
_seen_keys: dict[str, float] = {}
class RerouteEvent(BaseModel):
vehicle_id: str
trip_id: str
reason: str = Field(pattern="^(traffic_incident|missed_turn|window_shift|manual)$")
current_lat: float
current_lon: float
destination_lat: float
destination_lon: float
idempotency_key: str
def verify_signature(raw_body: bytes, signature: Optional[str]) -> None:
if signature is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing signature")
expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "signature mismatch")
def is_duplicate(idempotency_key: str) -> bool:
now = time.time()
for key, seen_at in list(_seen_keys.items()):
if now - seen_at > IDEMPOTENCY_TTL_S:
_seen_keys.pop(key, None)
if idempotency_key in _seen_keys:
return True
_seen_keys[idempotency_key] = now
return False
async def recompute_and_push(event: RerouteEvent) -> None:
"""Runs after the response is sent — recomputes the route and posts it downstream."""
import aiohttp # imported here to keep it off the hot request path at module load
coords = (
f"{event.current_lon},{event.current_lat};"
f"{event.destination_lon},{event.destination_lat}"
)
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://osrm-router:5000/route/v1/driving/{coords}",
params={"overview": "full", "geometries": "geojson"},
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
route = await resp.json()
await session.post(
f"http://dispatch-service/vehicles/{event.vehicle_id}/route-update",
json={
"vehicle_id": event.vehicle_id,
"trip_id": event.trip_id,
"reason": event.reason,
"route": route.get("routes", [{}])[0],
},
timeout=aiohttp.ClientTimeout(total=5),
)
@app.post("/reroute", status_code=status.HTTP_202_ACCEPTED)
async def reroute(
event: RerouteEvent,
request: Request,
background_tasks: BackgroundTasks,
x_signature: Optional[str] = Header(default=None, alias="X-Signature-256"),
):
raw_body = await request.body()
verify_signature(raw_body, x_signature)
if is_duplicate(event.idempotency_key):
return {"status": "duplicate", "vehicle_id": event.vehicle_id}
background_tasks.add_task(recompute_and_push, event)
return {"status": "accepted", "vehicle_id": event.vehicle_id, "trip_id": event.trip_id}
Note the ordering: request.body() is read before the model is validated so verify_signature operates on the exact bytes the sender signed, not a re-serialized copy — HMAC digests do not survive re-encoding, and comparing against a reformatted json.dumps(event.dict()) will fail even for a legitimate caller.
Key Parameters and Tuning
| Parameter | Purpose | Typical value | Notes |
|---|---|---|---|
IDEMPOTENCY_TTL_S |
window during which duplicate deliveries are suppressed | 300–900 seconds | should exceed the sender’s documented retry window |
WEBHOOK_SECRET |
HMAC key shared with the event source | 32+ random bytes | load from environment or a secrets manager, never hardcode |
X-Signature-256 |
header carrying the hex-encoded HMAC-SHA256 digest | one per request | verify against the raw body bytes, not a re-serialized payload |
status_code=202 |
tells the caller work is queued, not complete | 202 Accepted | prevents the sender’s own timeout from firing while the engine call is in flight |
background_tasks vs. queue |
where recomputation actually executes | BackgroundTasks under ~2s; Celery/RQ/arq beyond |
BackgroundTasks share the request worker’s event loop and CPU budget |
reason |
routes the event to a costing profile | traffic_incident / missed_turn / window_shift / manual |
ties directly into per-event cost-function selection downstream |
Integration Points
The routing engine call in recompute_and_push targets OSRM’s /route endpoint; swapping to Valhalla means posting to /route with a costing block instead of building a coordinate string, but the surrounding webhook logic — signature check, dedupe, 202, background task — is engine-agnostic. The reason field is deliberately part of the schema so the recompute step can select a costing profile at request time; see custom cost functions for routing solvers for building the composable weight callables this field is meant to dispatch into.
When reason is traffic_incident, this endpoint is typically not called directly by the traffic feed — it is called by an upstream handler that has already resolved which vehicles are affected. Traffic-incident-triggered re-routing covers that upstream step: geofencing the incident polygon, spatial-joining it against active routes, and calling this webhook once per affected vehicle with idempotency_key set to a value derived from the incident ID and vehicle ID so a re-broadcast incident does not trigger a second reroute for a vehicle already redirected.
The callback in recompute_and_push posts to a generic dispatch-service; in practice this is either an internal service that fans out to driver apps over push notification, or an MQTT publish to a per-vehicle topic if your fleet devices hold a persistent connection. If the engine’s OSRM instance has been customized with live traffic weights, confirm the reroute reflects them by cross-checking against integrating custom traffic weights into OSRM — a stale traffic layer will produce a “recomputed” route identical to the one that just failed.
Finally, the _seen_keys dict is process-local. Any deployment running more than one uvicorn worker or more than one pod needs a shared idempotency store — Redis with SET key value NX EX <ttl> gives an atomic check-and-set that a plain dict cannot provide across processes.
Validation Checklist
- Duplicate suppression. Send two identical requests with the same
idempotency_keyinside the TTL window; confirm the second returns{"status": "duplicate", ...}and does not trigger a second background task. - Signature rejection. Sign a payload, mutate one byte of the body after signing, and confirm the endpoint returns
401— not422and not a silent pass-through. - Missing header handling. Omit
X-Signature-256entirely and confirm401, distinguishing it in logs from a mismatched signature for auditability. - Schema rejection. Send a
reasonvalue outside the enum and confirm FastAPI returns422with a Pydantic validation error, not a500. - Latency independent of engine speed. Load-test the endpoint with the downstream OSRM call artificially delayed (e.g., a mock returning after 3 seconds); the
202response time should stay flat regardless. - Background failure visibility. Kill the routing engine mid-request and confirm the exception inside
recompute_and_pushis logged and, ideally, retried or surfaced to an alerting channel — a failed background task never reaches the caller, so silent failures here are easy to miss without explicit logging.
Why does the client still see a slow response even with BackgroundTasks?
BackgroundTasks only start after the response object is returned. If anything in the endpoint body before the return statement blocks — synchronous parsing of an oversized payload, a non-async database call, or signature verification on an unusually large body — the client waits for that work regardless of where recomputation happens. Profile everything ahead of the return line, not just the background function.
How do you prevent duplicate reroutes across multiple uvicorn workers?
An in-memory dict is scoped to a single process and cannot deduplicate across replicas or uvicorn --workers N. Replace it with a shared store that supports an atomic check-and-set, such as Redis SET key value NX EX <ttl>. Atomicity matters here — a plain get-then-set issued from two workers can both observe a miss and both proceed to reroute the same vehicle.
What HTTP status should a rejected duplicate delivery return?
Return a 2xx status, typically 200 or 202, with a body indicating the event was already processed. Many webhook providers treat any non-2xx response as delivery failure and retry indefinitely, which turns a correctly rejected duplicate into a retry storm.
Should HMAC verification run before or after payload validation?
Ideally before. In the pattern above, Pydantic parses and validates the body while resolving the event parameter, so verification runs after parsing completes. For high-throughput public endpoints, move signature verification into a dependency that reads the raw body first via Request.body(), rejecting forged payloads before spending CPU on model parsing.
Related
- Webhook-Driven Dynamic Re-Routing — the full event-schema, idempotency, and device-push guide this webhook implements
- Traffic-Incident-Triggered Re-Routing — geofenced incident detection that calls this endpoint per affected vehicle
- Custom Cost Functions for Routing Solvers — building the composable weight callables selected by the
reasonfield - Integrating Custom Traffic Weights into OSRM — keeping the OSRM instance this webhook calls current with live traffic