Hosting a Webhook Receiver — 10 Seconds, a Signature, and a Redeploy

·5 min read·Updated
webhooksgithubapisecuritydeploymenthow-to

A webhook receiver is one of the smallest useful things you can host: a public HTTPS endpoint that accepts a POST, checks a signature, and returns 2XX quickly. The three constraints that shape it are a response deadline (GitHub wants a 2XX within 10 seconds), a signature you must verify in constant time, and retries that will deliver the same event twice. A small container handles all three well — there's no cold start eating your response budget, and HTTPS with a valid certificate is included.

Why a small container suits this

Webhook endpoints are the classic serverless example, and functions do work. But two properties of a continuously running process fit the job better than you'd expect:

No cold start. GitHub expects a 2XX within 10 seconds (GitHub docs). That's a generous budget until a scale-to-zero function has to boot a runtime first, and cold starts are worst exactly when traffic is sporadic — which is what webhook traffic is.

HTTPS with a real certificate. Nearly every provider requires it, and self-signed won't do. An HTTPS port binding gets a subdomain with a certificate issued and renewed automatically, so there's nothing to buy or rotate.

The workload itself is tiny and idle between deliveries, which is exactly the shape that suits a small quota.

Verify the signature — in constant time

Your endpoint is a public URL. Anyone who finds it can POST to it, so the signature is the only thing distinguishing a real delivery from a forged one.

GitHub signs deliveries with HMAC-SHA256 and sends the result in the X-Hub-Signature-256 header. Recompute it from the raw request body using your webhook secret and compare.

The part people get wrong is the comparison. GitHub's documentation is explicit: never use a plain ==, and instead use something like secure_compare or crypto.timingSafeEqual, which compares in constant time to mitigate timing attacks (validating deliveries).

import hmac, hashlib

expected = "sha256=" + hmac.new(
    secret.encode(), raw_body, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(expected, received_header):   # not ==
    return 401

Two details that break signature checks in practice:

  • Use the raw body, not the parsed JSON. Re-serializing changes whitespace and key order, and the hash won't match. Capture the bytes before your framework parses them.
  • Keep the secret in an environment variable, never in the repo.

Other providers use the same shape with different header names, so the pattern transfers.

Respond fast, then do the work

The obvious implementation does everything inline and returns when it's finished. That breaks the moment a delivery involves anything slow — an API call, an image fetch, a database write behind a lock — and a timeout means the provider treats it as failed and retries.

GitHub's own recommendation is to acknowledge immediately and process the payload in the background rather than blocking future deliveries. So:

  1. Verify the signature.
  2. Persist the payload.
  3. Return 2XX.
  4. Process it after.

Note step 2. It's the one most write-ups skip, and on a container platform it's the one that bites.

The redeploy hazard

Here's the part specific to running this in a container: when you redeploy, your process gets roughly one second between the stop signal and being killed. Background work in flight does not finish.

So the naive version of "ack fast, process later" loses events:

verify → return 200 → schedule background task → [redeploy] → task never ran

The provider got its 2XX, so it will never retry. The event is simply gone.

The fix is ordering. Persist before you acknowledge, so the record of the event outlives the process:

verify → write payload to a volume or database → return 200 → process → mark done

Then on startup, pick up anything persisted but not marked done. That turns a redeploy from data loss into a short delay. It also makes the whole thing resilient to crashes, which are the same failure with a different cause — and more on why the writable layer isn't enough: the payload has to land on a volume, not next to your code.

If you'd rather not build that, the alternative is honest and simple: do the work inline and stay well inside the 10-second budget. That's fine for a relay that posts a Discord message and nothing more.

Expect duplicates

Retries and manual redeliveries mean the same event can arrive more than once, and GitHub notes that a redelivery carries the same X-GitHub-Delivery identifier as the original.

That header is your dedupe key. Store the IDs you've processed and skip repeats — otherwise a redelivered "issue opened" posts your Discord notification twice, and a redelivered payment event does something worse.

Idempotency is cheaper to build in from the start than to retrofit after someone notices the double-posting.

Sizing and scale

A webhook receiver is small and mostly idle, so it fits comfortably in a modest allocation. Two things to watch:

  • Bursts. Some providers deliver a lot at once — pushing 50 commits, a bulk import. Acknowledging fast and processing after is what keeps a burst from becoming a queue of timeouts.
  • Payload size. Some payloads are large. If you persist every one, that disk adds up, and total disk overruns stop every app in the account. Prune processed payloads on a schedule.

FAQ

Do I need HTTPS for a webhook endpoint?

Effectively yes — providers require it and won't accept self-signed certificates. An HTTPS port binding provides a valid certificate automatically.

Is serverless better for webhooks?

Not necessarily. Cold starts eat into a response deadline precisely when traffic is sporadic, which is typical webhook traffic. A continuously running container answers immediately.

How fast do I have to respond?

GitHub expects a 2XX within 10 seconds. Acknowledge first and do slow work afterwards.

How do I verify a GitHub webhook signature?

Recompute HMAC-SHA256 over the raw request body with your secret and compare against X-Hub-Signature-256 using a constant-time comparison, never ==.

Why does my signature check always fail?

Usually because you're hashing re-serialized JSON instead of the raw bytes. Capture the body before parsing.

Will I lose webhooks when I redeploy?

Only if you acknowledge before persisting. The container is killed about a second after the stop signal, so background work in flight doesn't finish — write the payload down first, then process.

How do I avoid processing the same event twice?

Store the delivery ID (X-GitHub-Delivery for GitHub) and skip ones you've already handled. Retries and manual redeliveries reuse it.

Wrapping up

A webhook receiver looks trivial and has three sharp edges: a deadline, a signature, and at-least-once delivery. None is hard, but all three are easy to skip on the way to something that appears to work. Verify in constant time, write the event down before you say "got it", and treat every delivery as one you might have seen before — then a redeploy is a pause rather than a hole in your event history.