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.

FastAPI re-route webhook request lifecycle An incoming event flows through HMAC verification and an idempotency check, both of which can short-circuit to a rejection or cached response. A valid, new event gets a 202 Accepted immediately while a background task recomputes the route via OSRM or Valhalla and posts the result to the fleet dispatch callback. Synchronous request path — target under 150ms regardless of engine latency 1. Event source POST /reroute 2. Verify HMAC X-Signature-256 3. Idempotency key check 4. 202 Accepted + BackgroundTask queued 5. Engine recompute OSRM / Valhalla 401 Unauthorized bad or missing signature 200 duplicate key seen within TTL Caller unblocks here response already sent Callback POST route-update → fleet device Async — runs after 202 already returned

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

  1. Duplicate suppression. Send two identical requests with the same idempotency_key inside the TTL window; confirm the second returns {"status": "duplicate", ...} and does not trigger a second background task.
  2. Signature rejection. Sign a payload, mutate one byte of the body after signing, and confirm the endpoint returns 401 — not 422 and not a silent pass-through.
  3. Missing header handling. Omit X-Signature-256 entirely and confirm 401, distinguishing it in logs from a mismatched signature for auditability.
  4. Schema rejection. Send a reason value outside the enum and confirm FastAPI returns 422 with a Pydantic validation error, not a 500.
  5. 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 202 response time should stay flat regardless.
  6. Background failure visibility. Kill the routing engine mid-request and confirm the exception inside recompute_and_push is 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.