Signed webhooks for Python, done the boring, correct way: HMAC-SHA256 signatures,
replay protection, zero-downtime secret rotation, retries that are safe to repeat,
and receiver-side duplicate suppression. One dependency (httpx).
receiver: simulating an outage, answering 503
receiver: verified evt_demo_1, processing
sender: delivered=True attempts=2 history=[503, 200]
sender: replaying the same event (as a retry after a lost ack would)
receiver: event evt_demo_1 already processed, ignoring
That is the real output of examples/demo.py.
Most webhook bugs are not about the HMAC. They are about everything around it:
| Problem | What hooksign does |
|---|---|
| Receiver re-serialises JSON before checking, signature never matches | Verifies the raw body bytes and says so in the API |
| Captured request is replayed later | Timestamp is inside the signed string; stale or future timestamps are rejected (default 300s) |
| Rotating a secret breaks every receiver at once | Sender can sign with several secrets; receiver can accept several. Rotate in two steps, no cutover |
| Sender retries, receiver processes the event twice | Same event id and body on every attempt, plus an idempotency store on the receiver |
| Retrying a 400 forever | Only 429, 5xx and network errors are retried, with capped exponential backoff and jitter; Retry-After is honoured |
| Forged requests fill the dedupe store | The duplicate check runs only after the signature is valid |
| Timing attacks on comparison | hmac.compare_digest |
pip install git+https://github.com/DaksCoder23/hooksignfrom hooksign import build_event, deliver
event = build_event("invoice.paid", {"amount": 4200})
result = deliver("https://customer.example.com/hooks", event, "whsec_...")
print(result.delivered, result.attempts, result.history)Headers sent:
Webhook-Id: evt_...
Webhook-Signature: t=<unix seconds>,v1=<64 hex chars>
The signature is HMAC_SHA256(secret, "<t>.<raw body>") as lowercase hex, so a receiver
in any language can verify it in a few lines.
from hooksign import DuplicateEventError, MemoryIdempotencyStore, VerificationError, verify_request
store = MemoryIdempotencyStore()
def handle(raw_body: bytes, headers: dict) -> int:
try:
event_id = verify_request(raw_body, headers, ["whsec_new", "whsec_old"], store=store)
except DuplicateEventError:
return 200 # already processed; 2xx stops the sender retrying
except VerificationError:
return 400
enqueue(event_id, raw_body) # return fast, do the work elsewhere
return 200A FastAPI version is in examples/fastapi_receiver.py.
MemoryIdempotencyStore is per process; with several workers, back the same
check_and_remember method with Redis SET key 1 NX EX ttl or a unique database index.
- Receiver: accept
[new, old]. - Sender: sign with
[new, old](the header carries twov1values). - Sender: sign with
newonly. - Receiver: drop
old.
At no point does a correctly configured side reject a valid delivery.
echo '{"x":1}' | hooksign sign --secret whsec_demo
echo '{"x":1}' | hooksign verify --secret whsec_demo --header "t=...,v1=..."
hooksign test-fire --url https://staging.example.com/hooks --secret whsec_demotest-fire sends a signed test.ping event, which is the quickest way for a customer
to check their endpoint before going live.
python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]" # bin/ on macOS/Linux
python -m pytest # 28 tests, all offline (httpx.MockTransport)
python -m ruff check .
python examples/demo.py- The bundled idempotency store is in-memory and per process.
- Delivery is synchronous; for many endpoints, run
deliverfrom a worker queue. - No asymmetric (Ed25519) signatures yet; every receiver shares a secret with the sender.
MIT