Skip to content

Webhooks and outbound calls

Standard Webhooks in and out, and httpx transports with deadlines, breakers and SSRF checks. In fastapi_alcazaba.webhooks and fastapi_alcazaba.outbound.

In the guide: Signed webhooks, Outbound calls. In an app: the example.

fastapi_alcazaba.webhooks.FastAPIWebhooks

FastAPIWebhooks(secret: WebhookSecrets, *, tolerance: int = 300, cache: Cache | None = None, max_body_size: int = 1048576, namespace: str = 'webhook', fail_open: bool = False)

FastAPI dependency that accepts only webhooks signed with secret, and each delivery once.

webhooks = FastAPIWebhooks(settings.webhook_secret)

@app.post("/webhooks")
async def receive(message: Annotated[WebhookMessage, Depends(webhooks)]) -> None: ...

It reads the raw body (up to max_body_size bytes, a 413 beyond) and leaves it in the request, for message.json() or await request.json(). Validate it there, not with a pydantic body parameter: FastAPI reads and validates those before any dependency, without the size limit and before the signature is checked. It can't read a body the endpoint takes as a form, which consumes it first: webhooks are JSON.

Missing or malformed headers get a 400; a wrong signature, a timestamp more than tolerance seconds away from the server's clock (either way) and a delivery accepted before all get the same 401, which doesn't say which check failed.

Replays: each delivery accepted (its id and timestamp, hashed) is counted in the cache's backend until its timestamp can't be accepted anymore, so every process refuses it again. A retry of the sender has a new timestamp and signature and goes through: Standard Webhooks keeps the id of a message for all its attempts, and a retry after a failed (5xx) attempt must get through. So make the endpoint idempotent on message.id (a unique constraint), as the specification advises, if a message must not be processed twice. Whoever can write to the backend can delete the marks (and allow a replay within tolerance): keep it out of reach.

Parameters:

Name Type Description Default
secret WebhookSecrets

whsec_<base64>, the base64 alone or raw bytes, at least 24 bytes once decoded. A list rotates them: a signature by any of them is accepted.

required
tolerance int

seconds a timestamp may be away from the server's clock, either way.

300
cache Cache | None

the Cache whose backend counts the deliveries (it must support counters); the app's (Cache.install) or FastAPICache's by default.

None
max_body_size int

larger bodies get a 413 without being read to the end.

1048576
namespace str

part of the keys, after the cache's prefix (see Cache.state_key).

'webhook'
fail_open bool

when the backend fails, accept the (correctly signed) webhook without the replay check instead of answering 503. Off by default: senders retry a 503 later, while a replay would run its effects again.

False

verify

verify(headers: Mapping[str, str], body: bytes) -> WebhookMessage

The message, if headers carry a valid signature of body and a fresh timestamp; raises an HTTPException (400 or 401) otherwise. It doesn't check for replays: for tests, scripts and queues that deduplicate on their own.

fastapi_alcazaba.webhooks.WebhookSigner

WebhookSigner(secret: WebhookSecrets)

Signs outgoing webhooks the Standard Webhooks way.

signer = WebhookSigner(settings.webhook_secret)
body = json.dumps(event).encode()
await client.post(url, content=body, headers=signer.sign(body))

Sign the exact bytes you send: json=event would serialize them again, maybe differently.

Parameters:

Name Type Description Default
secret WebhookSecrets

whsec_<base64> (generate_secret()), the base64 alone, or raw bytes; at least 24 bytes once decoded. With a list, every message is signed with each of them, so that receivers that have the old secret and those that have the new one both accept it while it's being rotated.

required

generate_secret staticmethod

generate_secret() -> str

A new random secret (256 bits), as whsec_<base64>.

sign

sign(body: bytes | str, *, message_id: str | None = None, timestamp: float | datetime | None = None) -> dict[str, str]

The headers of a webhook carrying body: webhook-id, webhook-timestamp and webhook-signature. Give the same message_id to every attempt of a message (a random one by default) and leave timestamp (now) alone: each attempt is signed afresh.

fastapi_alcazaba.webhooks.WebhookMessage dataclass

WebhookMessage(id: str, timestamp: datetime, body: bytes)

A webhook whose signature was checked: what FastAPIWebhooks gives the endpoint.

json

json() -> Any

The body, parsed as JSON.

fastapi_alcazaba.outbound.ResilientTransport

ResilientTransport(transport: AsyncBaseTransport | None = None, *, timeout: float | None = 10.0, failure_threshold: int | None = 5, reset_timeout: float = 30.0, failure_statuses: Collection[int] = (500, 502, 503, 504), max_response_size: int | None = None, metrics: Metrics | None = None)

Bases: AsyncBaseTransport

An httpx transport that gives every call a deadline and every origin a circuit breaker.

client = httpx.AsyncClient(transport=ResilientTransport(timeout=5), base_url=...)

Responses are read whole within the deadline before they're returned, so don't stream large downloads through it; max_response_size bounds what it reads.

Parameters:

Name Type Description Default
transport AsyncBaseTransport | None

the transport that sends the requests; httpx.AsyncHTTPTransport() by default (pass your own for its verify, limits, proxy...).

None
timeout float | None

seconds for the whole call, from sending the request to the last byte of the response; httpx.TimeoutException beyond. It applies to each request the transport sends: with follow_redirects, every hop gets a deadline of its own. None for no deadline (httpx's own timeouts still apply).

10.0
failure_threshold int | None

consecutive failures of an origin (scheme, host and port) after which it isn't called for reset_timeout seconds; then a single trial call decides whether it's back. Transport errors and timeouts count; errors of the caller's own (an illegal header value, an unsupported URL scheme, a request body that raises) don't. None disables the breaker.

5
reset_timeout float

seconds an open breaker refuses calls.

30.0
failure_statuses Collection[int]

response statuses that count as failures, besides errors and timeouts. The response is still returned.

(500, 502, 503, 504)
max_response_size int | None

bytes of response body read at most, as they come (before Content-Encoding is decoded); ResponseTooLargeError beyond, which isn't a failure of the origin. None for no limit.

None
metrics Metrics | None

where to count breakers opening and refusing calls (fastapi_alcazaba.outbound.breaker); FastAPICache's by default.

None

state

state(url: str | URL) -> str

The breaker of url's origin: "closed", "open" or "half-open" (a trial may go through).

fastapi_alcazaba.outbound.CircuitOpenError

Bases: TransportError

The origin's circuit breaker is open: the request wasn't sent.

fastapi_alcazaba.outbound.ResponseTooLargeError

Bases: RequestError

The response body was larger than max_response_size: the rest wasn't read. A limit of the client, like httpx.TooManyRedirects, not a transport error: retrying won't help.

fastapi_alcazaba.outbound.SSRFGuardTransport

SSRFGuardTransport(transport: InnerTransport | None = None, *, allow: Iterable[AllowedDestination] = (), ports: Collection[int] | None = None, max_hosts: int = 100, metrics: Metrics | None = None)

Bases: AsyncBaseTransport

An httpx transport that only calls the public internet: no private, loopback, link-local (cloud metadata), multicast or reserved addresses, whatever the URL says.

client = httpx.AsyncClient(transport=ResilientTransport(SSRFGuardTransport()))

It resolves the host itself and refuses the request (BlockedDestinationError) if any of its addresses isn't public, IPv6 forms of IPv4 addresses (::ffff:127.0.0.1, NAT64, 6to4) and numeric spellings (2130706433, 0x7f.1) included. Then it connects to the address it checked: the URL is rewritten to it, the Host header keeps the name, and TLS gets the name for SNI and the certificate check. A DNS server that answers a public address to the check and a private one to the connection (DNS rebinding) gets nowhere. Each hop of a redirect comes through the transport again and is checked like the first.

Wrap it in ResilientTransport rather than the other way round: the deadline then covers the DNS lookup, and each breaker is per host name. Give the transport to the client, not as one of its mounts, and give the client no proxy: those requests wouldn't go through it. A proxy in the inner transport is refused: it would connect on its own (an egress proxy that filters destinations, such as Smokescreen, is the alternative to this transport, not its companion).

Parameters:

Name Type Description Default
transport InnerTransport | None

what sends the requests. By default each host name gets an httpx.AsyncHTTPTransport of its own, sharing one SSL context: connections are pooled by address, and two names on the same address (a CDN, a shared host) must not share one, whose certificate was checked for the other name. Pass a function returning a transport (e.g. lambda: httpx.AsyncHTTPTransport(http2=True)) to make them yourself, or a transport (a mock in tests), to send everything through it.

None
allow Iterable[AllowedDestination]

host names (exact, or *.example.com for its subdomains), networks and addresses that may be reached although they aren't public, e.g. an internal service: allow=["billing.internal", "10.1.2.0/24"]. Requests to them are still pinned to the address checked.

()
ports Collection[int] | None

the only ports allowed (ports=(443,)); None for any.

None
max_hosts int

host names with a transport of their own kept open at most (the least recently used ones without an open response are closed).

100
metrics Metrics | None

where to count refused requests (fastapi_alcazaba.outbound.ssrf.blocked); FastAPICache's by default.

None

check async

check(url: URL | str, *, timeout: float | None = 5.0) -> None

Check url as a request to it would be, without sending anything: its scheme, its port and every address its host resolves to (within timeout seconds). Raises BlockedDestinationError when a request would be refused, and httpx.ConnectError when the host doesn't resolve. For a URL that users give (a webhook's endpoint), when they give it; every request is checked again all the same, as DNS answers change.

fastapi_alcazaba.outbound.BlockedDestinationError

Bases: RequestError

SSRFGuardTransport didn't send the request: its scheme, port or address isn't allowed. A limit of the client, like ResponseTooLargeError: it isn't counted against the origin.