pgdispatch
Reliable webhook delivery for Python apps. It needs only the PostgreSQL you already have.
You publish an event in the same database transaction as your business change. pgdispatch then delivers it to every subscribed endpoint. Each request is signed according to Standard Webhooks. Failed requests are retried for about 27 hours, a circuit breaker protects endpoints that are down, and every attempt is logged so you can replay it. You don't need Redis, Kafka, Celery or a separate webhook service.
async with session.begin():
order.status = "paid"
await webhooks.publish(session, "order.paid", {"id": order.id, "amount": 15000})
# Committed: the event will be delivered. Rolled back: it never existed.
Why
Sending webhooks naively loses events and duplicates them:
order.status = "paid"
await session.commit()
await httpx.post(customer_url, json=payload) # the process dies here, and the event is gone
- Dual write. If the process crashes between
commitandpost, the order is saved but the webhook is lost forever. If you swap the two lines, a customer can be told about an order that was never saved. - No retries, no back-off. A
503or a timeout from the receiver is the end of it. - Dead endpoints slow everyone down. A customer whose server is down for a day keeps tying up your workers.
- No authenticity. Receivers can't prove that a request came from you.
- No audit trail. Support can't answer "did customer X get event Y, and what did they reply?"
pgdispatch solves these problems with a transactional outbox that lives in
your database. A small async worker reads it with FOR UPDATE SKIP LOCKED
and wakes up instantly on LISTEN/NOTIFY.
Features
- Transactional outbox:
publish()joins your transaction. Works with SQLAlchemy 2.0 (async), Django and asyncpg. - At-least-once delivery: workers can be killed at any moment (
kill -9included) and nothing is lost. - Standard Webhooks signatures: receivers verify requests with official SDKs in any language. Secrets can be rotated with zero downtime.
- Retries with jitter and
Retry-Aftersupport. The default schedule has 8 attempts over about 27 hours and can be changed. - Per-endpoint circuit breaker with growing cool-downs. Endpoints that keep failing are disabled automatically.
- Ordered delivery per
ordering_keywhen you need it; full parallelism when you don't. - Idempotent publishing with
idempotency_key. - Multi-tenant: endpoints belong to an
owner_id, and events never cross owners. - SSRF protection: private addresses are blocked, the connection is pinned to the checked IP (which stops DNS rebinding), and redirects are never followed.
- Observability: Prometheus metrics, a Grafana dashboard, alert rules, a CLI and a full attempt log.
- Ready-made FastAPI router that lets your customers manage their endpoints.
- Typed (
mypy --strict), with a small set of core dependencies:asyncpg,aiohttp,pydantic,prometheus-client.
Installation
pip install "pgdispatch[sqlalchemy]" # FastAPI / SQLAlchemy projects
pip install "pgdispatch[django]" # Django projects
pip install pgdispatch # plain asyncpg
You need Python 3.10+ and PostgreSQL 13+.
Quick start: FastAPI + SQLAlchemy
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import create_async_engine
from pgdispatch import Webhooks
from pgdispatch.sqlalchemy import SQLAlchemyBackend
engine = create_async_engine("postgresql+asyncpg://app:secret@localhost/app")
webhooks = Webhooks(SQLAlchemyBackend(engine))
# Create the schema on start-up and deliver webhooks from inside the app.
app = FastAPI(lifespan=webhooks.lifespan(migrate=True))
@app.post("/orders/{order_id}/pay")
async def pay(order_id: int, session: SessionDep) -> None:
async with session.begin():
order = await session.get(Order, order_id)
order.status = "paid"
await webhooks.publish(
session,
"order.paid",
{"id": order.id, "amount": order.amount},
owner_id=order.merchant_id, # deliver only to this merchant's endpoints
ordering_key=f"order:{order.id}", # optional: keep this order's events in sequence
idempotency_key=f"order.paid:{order.id}", # optional: publish at most once
)
Then register an endpoint. You would normally do this from your product's API:
endpoint = await webhooks.endpoints.create(
"https://merchant.example.com/webhooks",
event_types=["order.*"],
owner_id="merchant_42",
)
endpoint.secret # "whsec_..." — show it to the customer once
As traffic grows, take the worker out of the web process and scale it on its own:
pgdispatch worker --dsn postgresql://app:secret@localhost/app --concurrency 100
Quick start: Django
# settings.py
INSTALLED_APPS = [..., "pgdispatch.django"]
PGDISPATCH = {"concurrency": 50} # optional; any WebhooksConfig field
from django.db import transaction
from pgdispatch.django import webhooks
with transaction.atomic():
order.status = "shipped"
order.save()
webhooks.publish("order.shipped", {"id": order.id}, owner_id=order.shop_id)
python manage.py migrate
python manage.py pgdispatch_worker --concurrency 50
What receivers get
POST /webhooks HTTP/1.1
content-type: application/json
webhook-id: 01a1985f-e246-725c-a639-369d5fd0a21b
webhook-timestamp: 1793527200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"order.paid","timestamp":"2026-11-01T10:00:00.123Z","data":{"id":42,"amount":15000}}
Receivers can verify requests with any Standard Webhooks library or with pgdispatch itself:
from pgdispatch import verify_signature
verify_signature(secret, request.headers, await request.body()) # raises InvalidSignatureError
examples/receivers has complete, dependency-free receivers in
Python, Node.js and Go. webhook-id is the same on every retry, so receivers
should use it to deduplicate.
Guarantees
| Guarantee | Yes / No | Details |
|---|---|---|
| An event from a rolled-back transaction is never sent | yes | The event and your data are written in one transaction. |
| A committed event is never lost | yes | If a worker dies, its lease expires and another worker takes the delivery over. |
| Exactly-once delivery | no | A worker can die after the receiver answered 200 but before the result was stored. Deduplicate by webhook-id. |
| Ordering | with ordering_key |
Deliveries with the same key go to an endpoint one at a time, in publish order. Without a key, events can arrive in any order. |
| Delivery while the receiver is down | for about 27 hours | Then the delivery is marked failed, and you can retry or replay it at any time. |
| No extra infrastructure | yes | PostgreSQL 13+ only. |
The test suite proves these guarantees against real PostgreSQL and real worker processes
(tests/chaos):
- A rolled-back transaction creates 0 deliveries.
-
kill -9of a worker with 1,000 deliveries in flight: after a restart all 1,000 arrive and none are lost. - 4 worker processes deliver 10,000 events, each exactly once.
- The endpoint goes down, the circuit breaker opens, and after recovery every event arrives.
- 1,000 events with one
ordering_key, handled by two competing workers, arrive strictly in order.
How it works
your transaction pgdispatch worker(s)
┌──────────────────────────┐ ┌───────────────────────────────────────┐
│ UPDATE orders ... │ │ 1. LISTEN pgdispatch_new / poll │
│ publish(): │ COMMIT │ 2. claim due deliveries │
│ INSERT event │ ───────▶ │ FOR UPDATE SKIP LOCKED + lease │
│ INSERT deliveries │ NOTIFY │ 3. sign + POST (aiohttp, SSRF-safe) │
│ (one per subscriber) │ │ 4. record attempts, schedule retries, │
└──────────────────────────┘ │ update circuit breakers (batched) │
└───────────────────────────────────────┘
All state is kept in the pgdispatch schema: endpoints, events,
deliveries, attempts and workers. A worker claims deliveries with a
short lease instead of holding a lock while it sends a request, so any
worker can be stopped at any time. Run as many workers as you like; they
coordinate only through row locks. See docs/architecture.md.
Performance
These numbers come from one desktop machine (i5-14400F, PostgreSQL 17 in Docker), with a fast local receiver:
| workers | deliveries/s | p50 latency | p99 latency |
|---|---|---|---|
| 1 | 1,083 | 6 ms | 11 ms |
| 2 | 2,707 | 7 ms | 14 ms |
| 4 | 4,995 | 6 ms | 14 ms |
Latency is measured from publish() until the request reaches the receiver.
See benchmarks to reproduce the run on your own hardware.
Comparison
| Form | Infrastructure | Transactional outbox | Signatures | License | |
|---|---|---|---|---|---|
| pgdispatch | Python library | PostgreSQL | yes, in your transaction | Standard Webhooks | MIT |
| Svix | standalone server (Rust) | PostgreSQL, optional Redis | no, called over HTTP | Standard Webhooks | MIT |
| Convoy | standalone server (Go) | PostgreSQL + Redis | no, called over HTTP | own scheme | Elastic License 2.0 |
| Hook0 | standalone server (Rust) | PostgreSQL | no, called over HTTP | own scheme | SSPL v1 |
Standalone servers are a good fit when many services in many languages need to send webhooks. pgdispatch is for Python teams who want webhooks to be part of their own application and database, with no extra service to deploy, monitor and secure. (Licenses were checked in November 2026.)
Operations
pgdispatch status # queue size, delivery lag, open circuit breakers
pgdispatch deliveries --status failed --since 1h
pgdispatch deliveries show 18342 # every attempt with the receiver's response
pgdispatch retry 18342 # retry one delivery
pgdispatch retry --endpoint <id> # retry all failed deliveries of an endpoint
pgdispatch replay --event <id> [--endpoint <id>] # deliver an event again
pgdispatch endpoints list --owner merchant_42
pgdispatch endpoints enable <id> # re-enable and reset the circuit breaker
pgdispatch cleanup --older-than 14d
The same operations are available from Python (webhooks.deliveries.retry(...),
webhooks.events.replay(...), …). Metrics such as
pgdispatch_oldest_pending_age_seconds (delivery lag) are exported to Prometheus.
contrib/ contains a Grafana dashboard and alert rules.
Documentation
- Getting started
- Concepts: events, endpoints, deliveries, attempts, owners, ordering
- Publishing and managing endpoints
- Receiving webhooks: signature verification in any language
- Running workers: deployment, scaling, graceful shutdown
- Configuration
- Reliability and guarantees
- Security: SSRF, secrets, data retention
- Observability: metrics, logs, hooks, CLI
- Architecture
Try it in five minutes
git clone https://github.com/AmirSherov/pgdispatch && cd pgdispatch
docker compose up --build
This starts PostgreSQL, an example shop, a worker, a receiver, Prometheus and Grafana. Follow examples/fastapi_shop to send your first webhook.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md. Please report security problems privately, as described in SECURITY.md.
License
MIT © Amir Sherov. pgdispatch is free for commercial use.
Metadata
Release files for pgdispatch 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pgdispatch-0.1.0.tar.gz | 95.3 kB | With provenance | Details for pgdispatch-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pgdispatch-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for pgdispatch-0.1.0-py3-none-any.whl |
Total release size: 184.8 kB
Release files / pgdispatch-0.1.0.tar.gz
| Download URL | pgdispatch-0.1.0.tar.gz |
|---|---|
| Size | 95.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b50f33821b0529563f5619698a8972e81d129a93e993e0efe19ceb013ade7d70
|
|
BLAKE2b-256 checksum How to use checksums |
e42a014efd9989c40272f7d5106e57944141874874c25697aa62da9c41c2d95f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / pgdispatch-0.1.0-py3-none-any.whl
| Download URL | pgdispatch-0.1.0-py3-none-any.whl |
|---|---|
| Size | 89.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e10c846345592d179cfeba7032591e6436ba4400fd8de6e060dc53aeb445b8fb
|
|
BLAKE2b-256 checksum How to use checksums |
c6fa30eb796749240f781c19d0058bb52cf4d7838877bf7d31b66ef24dc02101
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for pgdispatch 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pgdispatch-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.