Skip to content

Webhooks

zeython.webhooks sends an HMAC-signed HTTP POST to a third party's URL whenever something happens in your app -- the mirror image of Notifications, which notify a recipient inside your own app instead.

Why this exists

Once your app has integrations, "tell Slack/Zapier/a customer's own backend the moment an order ships" stops being optional -- and building it well means more than a bare httpx.post() at the call site: delivery has to survive a flaky receiver (retries with backoff), a receiver has to be able to trust the payload actually came from you (a signature), and one subscriber's outage can't be allowed to slow down or break the request that triggered the event. zeython.webhooks hands all three off to infrastructure the framework already has and has already tested -- the background job queue for delivery/retries, HMAC-SHA256 for the signature -- rather than reinventing them.

Setup

# main.py
from zeython import Application, DatabaseServiceProvider, QueueServiceProvider, WebhookServiceProvider

from app.Models.webhook_endpoint import WebhookEndpoint
from app.Models.webhook_delivery import WebhookDelivery

app = Application()
app.register(DatabaseServiceProvider)
app.register(QueueServiceProvider)
app.register(WebhookServiceProvider(app, endpoint_model=WebhookEndpoint, delivery_model=WebhookDelivery))

A generated project already has this wired up -- see app/Models/webhook_endpoint.py, app/Models/webhook_delivery.py, and main.py. QueueServiceProvider has to be registered too: every delivery goes through whichever Queue it binds (see Background Jobs).

Your own models

Neither model is mandated by the framework -- the same record_model pattern Audit Logging and Notifications use -- so each project owns its own migration instead of the framework dictating a fixed schema.

endpoint_model is who's subscribed to what -- one row per (url, event) pair, so a single receiving URL that wants several event types just gets several rows:

# app/Models/webhook_endpoint.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from zeython import Model


class WebhookEndpoint(Model):
    __tablename__ = "webhook_endpoints"

    url: Mapped[str] = mapped_column(String(2048))
    event: Mapped[str] = mapped_column(String(255), index=True)
    secret: Mapped[str | None] = mapped_column(String(255), nullable=True)
    active: Mapped[bool] = mapped_column(Boolean, default=True)

delivery_model is optional: if given, every delivery attempt -- including retries -- is logged there, an audit trail of what was sent and whether it arrived, mirroring what Stripe/GitHub show in their own webhook dashboards. Omit it in WebhookServiceProvider(...) if you don't need one.

# app/Models/webhook_delivery.py
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column

from zeython import Model


class WebhookDelivery(Model):
    __tablename__ = "webhook_deliveries"

    endpoint_id: Mapped[int] = mapped_column(Integer, ForeignKey("webhook_endpoints.id"), index=True)
    event: Mapped[str] = mapped_column(String(255))
    status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
    success: Mapped[bool] = mapped_column(Boolean)
    error: Mapped[str | None] = mapped_column(String(2000), nullable=True)

Already generated for you as part of zeython new -- run zeython db revision --autogenerate -m "add webhook tables" once you're ready to migrate them.

There's no admin UI or API for creating a subscription yet -- add one yourself via zeython tinker or a seeder:

import secrets
from app.Models.webhook_endpoint import WebhookEndpoint

await WebhookEndpoint.create(
    url="https://example.com/hooks/zeython",
    event="user.registered",
    secret=secrets.token_hex(32),
    active=True,
)

If you do build a self-service endpoint that lets a user register their own url -- letting a customer point your integrations page at a URL they don't control -- a malicious one pointing at a private/internal address (http://169.254.169.254/..., a cloud metadata endpoint; http://localhost:6379, an internal service with no auth of its own) is exactly the shape of input SSRF guards exist for. WebhookManager rejects a delivery whose url resolves to a private, loopback, link-local, or otherwise non-public address by default (block_private_network_targets=True on WebhookServiceProvider) -- raised as UnsafeWebhookUrlError before any connection is ever made, logged to delivery_model like any other failed attempt if one is configured. This is a real-time resolve-and-check, not a one-time validation at save time, so it also catches a URL whose DNS record changed after it was saved. Pass block_private_network_targets=False only if you've validated endpoint URLs yourself and genuinely need to target an internal service on purpose (local development against http://localhost:..., say) -- see ensure_safe_webhook_url() if you want to run the same check yourself at save time too, for a clearer error at registration instead of at first delivery.

This check resolves the hostname once, up front; it is not a defense against DNS rebinding (a name that resolves safely at check time but to a private address by the time the connection is actually made a moment later) -- closing that fully needs connecting to the exact address already validated rather than letting the HTTP client re-resolve, which this doesn't do. It blocks the overwhelmingly common case (a flat-out private/metadata URL), not a guarantee against a determined, actively-rebinding adversary.

Firing an event

from zeython.webhooks import fire_webhook

await fire_webhook(request, "order.shipped", {"order_id": order.id, "tracking_number": order.tracking_number})

Every active WebhookEndpoint subscribed to "order.shipped" gets a DeliverWebhookJob pushed onto the queue -- the request returns immediately, delivery happens in the background. Outside of a request -- a scheduled task, a console command -- resolve the manager directly instead:

manager = app.container.make(WebhookManager)
await manager.fire("order.shipped", {"order_id": order.id})

A generated project fires "user.registered" from app/Listeners/notify_webhooks_of_registration.py, a listener on the same UserRegistered event Events already dispatches -- add your own listener the same way for any other event you want to notify subscribers about, without touching the code that raises it.

What gets sent

POST https://example.com/hooks/zeython
Content-Type: application/json
X-Webhook-Event: order.shipped
X-Webhook-Signature: 5d41402abc4b2a76b9719d911017c59...

{"event":"order.shipped","data":{"order_id":42,"tracking_number":"1Z..."}}

X-Webhook-Signature is the hex-encoded HMAC-SHA256 of the exact request body under the endpoint's own secret -- proof the payload came from you, not from whoever guessed the URL. An endpoint with no secret set still gets delivered to, unsigned, with a warning logged; set one for anything you'd be upset to see spoofed.

Verifying a signature you receive

If your own app is on the receiving end of someone else's webhook (or another Zeython app's), verify it the same way before trusting the body:

from zeython import ForbiddenException
from zeython.webhooks import verify_signature

async def receive_webhook(request):
    body = await request.body()
    signature = request.headers.get("X-Webhook-Signature", "")
    if not verify_signature(known_secret, body, signature):
        raise ForbiddenException("Bad webhook signature.")
    ...

verify_signature() uses hmac.compare_digest rather than == -- a naive string comparison leaks how many leading bytes matched through response timing, letting an attacker recover the correct signature one byte at a time.

Retries and isolation

Delivery failures (a non-2xx response, a connection error, a timeout) are retried by the queue up to DeliverWebhookJob.max_attempts (default 5) -- under RedisQueue, with the same capped exponential backoff every other job gets (see Background Jobs). If delivery_model is set, every attempt writes its own row, so you can see exactly when a subscriber started failing and whether it eventually came back.

One subscriber's failure never affects another's: fire() queues a separate job per endpoint, and a queuing failure for one endpoint (only possible under SyncQueue, which runs a job inline rather than truly queuing it) is logged and reported, not raised -- the same isolation Notifications applies per channel.

Re-fetching before delivery

WebhookManager.deliver() re-fetches the endpoint from the database rather than trusting the state fire() saw -- an endpoint disabled or deleted in the time a retried delivery sat in the queue is skipped instead of delivered to.