DEV Community

Alessio
Alessio

Posted on Edited on Originally published at py-forge.com

Never Lose a Stripe Webhook Again: a Self-Hosted Retry Queue and DLQ in One Python File

Your Stripe webhook endpoint returns a 503 for the 40 seconds a deploy takes. Stripe will retry, on its own schedule, but you can't see that schedule or the failed payload, and you can't replay it once you've fixed the bug. GitHub doesn't retry failed deliveries automatically at all; you redeliver them by hand. The usual answer is "put a queue in front of it", and that usually means Redis, a worker framework and a dashboard you have to build yourself.

HookSentry CLI is that queue in a single Python file with no dependencies. It receives the webhook, checks the signature, writes the payload to SQLite and answers 202 Accepted straight away. Then a worker delivers it to your app, retries with exponential backoff, and moves it to a dead-letter queue (DLQ) when it gives up. Everything below was run locally with the shipped hooksentry.py on Python 3.13; the outputs are copied from the terminal.

The setup

provider --POST--> HookSentry /webhook --verify--> SQLite --worker--> your app
                                                      |
                                  retries with backoff, then DLQ + alert, replay from CLI
Enter fullscreen mode Exit fullscreen mode

There's nothing to install beyond Python 3.9+. Unzip the download and run it:

python3 hooksentry.py --help
Enter fullscreen mode Exit fullscreen mode

For the demo I used a tiny "app" on port 5000 that answers 503 to its first two calls (pretend it's mid-deploy), 400 on /strict, and 200 otherwise. HookSentry sits in front of it:

export STRIPE_WEBHOOK_SECRET=whsec_local_test_secret_123
python3 hooksentry.py serve --port 8080 --host 127.0.0.1 --db-path ./hooks.db \
  --target http://127.0.0.1:5000/webhook \
  --provider stripe --secret-env STRIPE_WEBHOOK_SECRET \
  --backoff-base 1 --backoff-factor 2
Enter fullscreen mode Exit fullscreen mode

--backoff-base 1 just keeps the demo short. The defaults are 2 s, factor 2 and a 1-hour cap, with up to 8 attempts.

Sending a correctly signed Stripe event

HookSentry ships a sign command so you can test without the Stripe CLI:

echo '{"id":"evt_1001","type":"checkout.session.completed"}' > event.json
SIG=$(python3 hooksentry.py sign --provider stripe --secret-env STRIPE_WEBHOOK_SECRET --file event.json | cut -d' ' -f2-)
curl -i -X POST http://127.0.0.1:8080/webhook -H "Content-Type: application/json" \
  -H "Stripe-Signature: $SIG" --data-binary @event.json
Enter fullscreen mode Exit fullscreen mode
HTTP/1.0 202 Accepted
Server: HookSentry/1.0.0 Python/3.13.5
Content-Type: application/json; charset=utf-8

{"accepted": true, "id": 1, "event_id": "evt_1001", "duplicate": false}
Enter fullscreen mode Exit fullscreen mode

The provider gets its answer in milliseconds whether your app is up or not. Here's the server log while the app was "deploying":

[retry] #1 evt_1001 attempt 1 failed (HTTP 503 Service Unavailable); next in 0.8s
[retry] #1 evt_1001 attempt 2 failed (HTTP 503 Service Unavailable); next in 2.2s
[deliver] #1 evt_1001 -> 200 in 2 ms (attempt 3)
Enter fullscreen mode Exit fullscreen mode

And the app's own log shows the attempt header that HookSentry adds:

APP /webhook attempt=1 event=evt_1001 -> 503
APP /webhook attempt=2 event=evt_1001 -> 503
APP /webhook attempt=3 event=evt_1001 -> 200
Enter fullscreen mode Exit fullscreen mode

list and stats read the same SQLite file:

python3 hooksentry.py list --db-path hooks.db
Enter fullscreen mode Exit fullscreen mode
    ID  EVENT ID                       ROUTE      STATUS     TRIES  HTTP  RECEIVED             LAST ERROR
     1  evt_1001                       default    SUCCESS    3/8    200   2026-10-20 01:55:39
Enter fullscreen mode Exit fullscreen mode

Bad signatures, replays and duplicates

Three requests you don't want reaching your app:

# signature from an old timestamp
curl -s -X POST http://127.0.0.1:8080/webhook -H "Stripe-Signature: t=1,v1=deadbeef" --data-binary @event.json
{"error": "signature verification failed", "reason": "timestamp outside tolerance (possible replay)"}

# fresh timestamp, wrong signature
curl -s -X POST http://127.0.0.1:8080/webhook -H "Stripe-Signature: t=$(date +%s),v1=deadbeef" --data-binary @event.json
{"error": "signature verification failed", "reason": "signature mismatch"}

# the same valid event sent twice
{"accepted": true, "id": 1, "event_id": "evt_1001", "duplicate": true}
Enter fullscreen mode Exit fullscreen mode

The first two get a 401 and are never stored. The duplicate is acknowledged (so the provider stops retrying) but not delivered again, because the table has a UNIQUE(route, event_id) constraint. For Stripe the event id is the id in the body. GitHub uses X-GitHub-Delivery and Shopify X-Shopify-Webhook-Id; any other provider can send X-Event-Id.

The dead-letter queue and replay

Not every failure deserves a retry. A 400 usually means your handler rejected the payload, and sending the same bytes eight more times won't help. I pointed a second instance at /strict, which always answers 400:

[dlq] #1 evt_2002 moved to DLQ: non-retryable response: HTTP 400 Bad Request
Enter fullscreen mode Exit fullscreen mode
    ID  EVENT ID                       ROUTE      STATUS     TRIES  HTTP  RECEIVED             LAST ERROR
     1  evt_2002                       default    DLQ        1/8    400   2026-10-20 01:56:01  HTTP 400 Bad Request
Enter fullscreen mode Exit fullscreen mode

After the fix ships, you replay it, optionally to a different target:

python3 hooksentry.py replay --db-path hooks2.db --event-id evt_2002 --target http://127.0.0.1:5000/fixed
Replay of #1 (evt_2002) -> http://127.0.0.1:5000/fixed: SUCCESS (HTTP 200)
Enter fullscreen mode Exit fullscreen mode

replay-dlq requeues every DLQ event at once, and --alert-url (Slack or Discord) or --telegram-token/--telegram-chat-id send an alert whenever something lands in the DLQ. There's also a dashboard at /_hooksentry/ with a Replay button. It only accepts connections from localhost unless you set --admin-token.

How it works

Signature checks follow each provider's documented scheme, and every comparison is constant-time. This is the Stripe branch from verify_signature():

if tolerance and abs((now or time.time()) - ts_int) > tolerance:
    return False, "timestamp outside tolerance (possible replay)"
expected = _hmac(secret, f"{ts}.".encode() + body).hex()
ok = any(hmac.compare_digest(expected, s) for s in sigs)
Enter fullscreen mode Exit fullscreen mode

The retry rules fit in two small functions:

def backoff_delay(attempt, base, factor, cap, jitter=0.2):
    delay = min(cap, base * (factor ** max(0, attempt - 1)))
    if jitter:
        delay *= 1 + random.uniform(-jitter, jitter)
    return max(0.0, delay)

def is_retryable(status_code):
    """Network errors/timeouts (None), 5xx, 408 and 429 are retried; other 4xx go to the DLQ at once."""
    return status_code is None or status_code >= 500 or status_code in (408, 409, 425, 429)
Enter fullscreen mode Exit fullscreen mode

The jitter matters when 300 events failed during the same outage: they don't all come back in the same second. HookSentry forwards the exact original body, keeps the provider headers and adds X-HookSentry-Event-Id and X-HookSentry-Attempt. Events still marked in flight when the process stops are requeued on the next start.

Several providers on one port

A routes.json file maps paths to targets, providers and secrets:

{
  "routes": {
    "stripe": {"target": "http://app:5000/webhooks/stripe", "provider": "stripe", "secret_env": "STRIPE_WEBHOOK_SECRET"},
    "github": {"target": "http://app:5000/webhooks/github", "provider": "github", "secret_env": "GITHUB_WEBHOOK_SECRET"}
  }
}
Enter fullscreen mode Exit fullscreen mode

Providers then post to /hooks/stripe and /hooks/github.

Limitations

  • It's one process with SQLite. That's plenty for webhook volumes on a typical SaaS, but there's no clustering. Run one instance per database file.
  • It doesn't terminate TLS. Put it behind Caddy, nginx or a Cloudflare Tunnel.
  • The forwarded request keeps the original signature header. If your app checks the Stripe signature again, a retry that arrives more than five minutes later will fail Stripe's timestamp check. Either let HookSentry do the verification and keep the hop between them private, or widen the tolerance in your handler.

FAQ

Does it need Redis or Docker? No. It uses only the standard library: sqlite3, http.server and urllib.

What happens if HookSentry itself restarts? The payload is committed to SQLite before the 202 goes out, and anything still in flight is requeued on startup.

Can I use it for providers other than Stripe, GitHub and Shopify? Yes. --provider generic checks an HMAC-SHA256 in any header (hex, sha256=hex or base64), and --provider none skips verification for internal traffic.

Does it come with tests? Yes, test_hooksentry.py is in the ZIP. Run it with python3 test_hooksentry.py.


HookSentry CLI is a one-time purchase that includes the source, README and test suite: get HookSentry CLI ($39). The full feature list is on the HookSentry CLI product page.

Top comments (0)