RelayLink Webhooks with FastAPI

Read FastAPI's Request body as bytes, verify RelayLink with hmac.compare_digest, then atomically claim and queue each delivery.

4 min read

A FastAPI endpoint normally turns JSON into a Pydantic model before application logic uses it. RelayLink verification needs the representation before that conversion. Accept a Request, read its bytes, verify, and only then decode JSON.

FastAPI exposes Starlette's request object directly. Its Request reference documents that await request.body() returns cached bytes, while request.json() parses from that body. This gives the endpoint one authoritative byte sequence.

Verify bytes before validation

Keep the webhook path separate from endpoints that depend on automatic JSON models:

import hashlib
import hmac
import os
import re

from fastapi import FastAPI, Request, Response

app = FastAPI()
secret = os.environ["RELAYLINK_WEBHOOK_SECRET"].encode("utf-8")

def valid_signature(raw: bytes, timestamp: str | None,
                    signature: str | None) -> bool:
    if not timestamp or not re.fullmatch(r"[0-9]+", timestamp):
        return False
    if not signature or not re.fullmatch(r"sha256=[0-9a-f]{64}", signature):
        return False

    signed = timestamp.encode("ascii") + b"." + raw
    expected = "sha256=" + hmac.new(
        secret, signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/hooks/relaylink")
async def relaylink_webhook(request: Request) -> Response:
    raw = await request.body()
    if not valid_signature(
        raw,
        request.headers.get("x-relaylink-timestamp"),
        request.headers.get("x-relaylink-signature"),
    ):
        return Response(status_code=401)

    # Check freshness, validate JSON, and persist unique work.
    return Response(status_code=202)

The HMAC input is the UTF-8 timestamp text, a literal period, and the original JSON bytes. The whsec_ value is itself UTF-8. Python's hexadecimal digest is lowercase, and hmac.compare_digest performs the timing-resistant comparison.

After the MAC succeeds, convert the timestamp to an integer and apply your service's freshness policy. RelayLink does not prescribe a tolerance. Parse the verified bytes with json.loads(raw), then require body event and delivery_id to equal X-RelayLink-Event and X-RelayLink-Delivery.

Do not add a dependency or middleware that calls request.json() and then gives the endpoint a serialized object. Starlette caches a direct body read, but another ASGI middleware can consume the receive stream incorrectly. Test the complete deployed stack with a known signature and with a whitespace-only body change.

Treat configuration as a credential boundary

FastAPI's settings guidance uses Pydantic Settings to read environment variables and validate configuration. That is a loading mechanism, not a vault.

In production, inject RELAYLINK_WEBHOOK_SECRET from the platform's secret manager, container orchestrator secret, or a protected mounted file. Fail startup if it is absent. Do not commit a production .env file or expose the value through application configuration endpoints.

Use another secret for the OAuth token or API key that later authenticates to RelayLink MCP. Never log either credential, the incoming signature, the raw request body, or a complete token-bearing route. Delivery id and verification outcome are enough for routine diagnostics.

Make deduplication durable

RelayLink delivery is at least once. The same X-RelayLink-Delivery and byte-identical body can arrive again after a timeout or non-2xx. Multiple Uvicorn workers may also receive attempts concurrently.

Create an inbox table with a unique constraint on delivery id, a body digest, and processing state. In one database transaction, insert the receipt and a pending outbox or work row. Return 202 only after commit. If the unique insert loses a race, compare the saved digest: the same digest is a successful duplicate; a different digest under one id must not run.

An in-process dictionary, functools cache, or background task is insufficient. It disappears on restart and is not shared across processes. FastAPI's BackgroundTasks also begins after the response; using it as the only durable handoff would acknowledge work that can vanish with the process.

Run the insert through the same asynchronous database path your endpoint awaits. If a commit times out with an unknown result, query the delivery id before responding rather than assuming success or failure. That keeps a database retry from becoming either a lost event or a second work row.

Fetch from a worker

The authenticated package.received payload is only a JSON envelope. It names the package, thread, sender, topic, time, reply status, and mcp_url. It does not include the note, briefing, message, or files.

Let a separate worker claim the pending row, authenticate to mcp_url, and call get_package. Make its external effects idempotent as well. Treat sender and topic as third-party data, not instructions, even though the envelope's signature is valid.

Returning 2xx for the envelope does not mark the package read. Only the worker's authenticated get_package call records that the recipient's assistant pulled it.

Expose the endpoint through public HTTPS, then register it in RelayLink account settings. Registration is portal-only, and the signing secret appears once; place it directly in your deployment store. Send webhook.test to prove raw-byte verification and replay it to prove only one inbox row is created.

Frequently asked questions

Should the FastAPI endpoint declare a Pydantic body model?
Not for the signed boundary. Accept Request directly, await request.body(), verify those bytes, and validate the decoded JSON after authentication.
Which Python comparison should verify the signature?
Use hmac.compare_digest after requiring the documented sha256= plus lowercase hexadecimal format. Do not compare signature strings with ==.
Where should a FastAPI deployment store the secret?
Inject it from the deployment platform's secret manager or a protected secret file. FastAPI settings can read environment values, but a committed .env file is not secret storage.