Webhooks vs Polling: The Four Problems Push Hands You

Polling a resource once a minute costs 1,440 requests a day, almost all of them empty. A webhook fixes that by inverting the call, and in exchange hands you signature verification, duplicate events, out-of-order events, and retry storms. Here is what each one actually costs.

Tech Talk News Editorial9 min read
ShareXLinkedInRedditEmail
Webhooks vs Polling: The Four Problems Push Hands You

Key takeaways

  • Polling one resource every 60 seconds costs 1,440 requests per day; if that resource genuinely changes five times a day, 1,435 of those requests return nothing new.
  • A webhook endpoint is a public, unauthenticated HTTP POST route into your system, and Stripe's own documentation warns that without signature verification an attacker can send fake events to trigger order fulfillment, account access, or record changes.
  • Webhook signatures are an HMAC over the raw request body: Stripe signs the string "timestamp.body" with HMAC-SHA256 and its libraries reject anything more than 5 minutes old, Slack signs "v0:timestamp:body" with the same 5-minute window, and GitHub sends X-Hub-Signature-256 and tells you to compare it with crypto.timingSafeEqual rather than a plain equality operator.
  • Delivery is at-least-once and unordered. Stripe states plainly that it does not guarantee events arrive in the order they were generated, and tells receivers to deduplicate on the event ID rather than on the created timestamp, because distinct events can share a timestamp recorded in whole seconds.
  • Response deadlines are short and the penalty is a retry: Slack requires an HTTP 2xx within 3 seconds and retries three times (immediately, after 1 minute, then after 5 minutes), GitHub terminates any delivery that takes longer than 10 seconds, and Stripe retries failed deliveries for up to three days with exponential backoff.

A webhook is an HTTP POST that arrives at your server without you asking for it. That is the whole idea. Instead of your code calling somebody else's API to find out whether a payment settled, their server calls a URL you registered, the moment it settles. Stripe, GitHub, Slack, Shopify, Twilio and roughly every other platform you will integrate with ships this, and the setup is genuinely five minutes: paste a URL into a dashboard, copy a signing secret, done.

Then the interesting part starts. Because inverting the call also inverts who owns the hard problems. When you poll, you decide when the request happens, you know it came from you, and you get exactly one answer per question you ask. When you receive a webhook you get none of those three things, and almost every webhook bug I have watched teams chase comes from assuming otherwise.

I want to walk through both models honestly, do the arithmetic on what polling actually costs, and then be specific about the four things a receiver has to handle that a poller never does. If you have never touched this layer, the piece on what an API actually is is the better starting point.

Polling is you asking. A webhook is them telling.

Polling is a loop. Every N seconds you make a request, compare the answer to the last answer, and act if it changed. It is boring in the best way: the request is authenticated with your own credentials, failures are yours to retry, and if your worker is down for an hour it simply catches up when it wakes.

The cost is that you are paying for questions with no news in them, and you learn about changes an average of half your polling interval late. Providers know this and try to soften it. GitHub's Events API sends an X-Poll-Interval header telling you how often you are allowed to poll, with a default of 60 seconds, and supports conditional requests so an If-None-Match that returns 304 Not Modified leaves your rate limit untouched.[6] That is a real mitigation and it is worth using. It does not remove the round trip, it just makes the round trip cheap.

What it cannot fix is latency. GitHub says of that same endpoint that it is not built for real-time use and event latency can run from 30 seconds to 6 hours depending on load.[6] Polling a system that is itself batching is two queues stacked on top of each other.

The polling bill is mostly empty requests

Do the arithmetic once and you will never argue about intervals again. Poll one resource every 60 seconds and that is 60 requests an hour, 1,440 a day, 43,800 a month. If that resource genuinely changes five times a day, 1,435 of your daily requests returned nothing. That is 287 wasted calls per real event.

1,440
Requests per day, one resource, 60-second interval
287
Wasted requests per real event at 5 events per day
30 sec
Average detection delay at a 60-second interval
4.38M
Requests per month at 100 resources polled every 60 seconds

Takeaway

Polling cost scales with the number of resources you watch, not with how often they change. That is the actual defect. A webhook flips the scaling so you pay per event instead of per interval, which is why the argument for push gets stronger every time your customer count goes up.

Now multiply by resources. A hundred customers whose subscriptions you watch is 4.38 million requests a month to learn about maybe a few thousand real changes. You will hit a rate limit long before you hit a useful interval, which is the same wall described in how API rate limiting actually works. Tightening the interval to catch changes faster makes the ratio worse, linearly, forever.

So webhooks win the cost argument outright. I am not going to pretend otherwise. They just send you the invoice in a different currency.

Your webhook endpoint is a public write path nobody reviewed

Here is the thing I wish somebody had said to me plainly the first time. A webhook receiver is a route on your public internet surface, reachable by anyone who can guess or discover the URL, that accepts a request body and then writes to your database. No session. No API key from the caller. No user. Most teams put it through the same review as a health check, which is to say none, and then it quietly becomes the most powerful unauthenticated endpoint they run.

Stripe puts the consequence in its own documentation, and I think it is the bluntest sentence in the whole page: without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records.[1] Read it as a threat model, because that is what it is. Your handler is a machine that turns a JSON body into an order.

A webhook endpoint is an unauthenticated POST route that writes to your database. The signature check is not a nice-to-have. It is the entire authentication layer.

The signature is what closes it. Providers HMAC the request body with a shared secret and put the digest in a header. Stripe uses Stripe-Signature, signing the timestamp, a period, and the raw JSON body with HMAC-SHA256, and tells you to ignore any scheme that is not v1 so nobody can downgrade you.[1] Slack signs the string v0:timestamp:body with the same function.[4] GitHub sends X-Hub-Signature-256 and keeps the old SHA-1 header around only for backward compatibility.[2]

Webhook receiver, first 50 milliseconds

What has to happen before your code trusts the request

What arrives

  • Raw request bodyBytes, exactly as sent. Do not parse before signing.
  • Signature headerStripe-Signature, X-Hub-Signature-256, or X-Slack-Signature.
  • Signed timestampPart of the signed string, so it cannot be edited.

HMAC-SHA256, then constant-time compare

Recompute the digest with the shared secret and check the timestamp is inside the tolerance window.

What happens next

  • Check the event ID against the ledgerAlready processed means return 200 and stop.
  • Write the raw event to a queuePersist first, do the business logic later.
  • Return 200 immediatelyBefore any downstream call that can be slow.
Rejected at the doorUnsigned or stale requestsBad digest, missing header, or a timestamp outside the tolerance window gets a 400 and no processing.

Signature scheme details from the Stripe, GitHub and Slack webhook documentation.

Takeaway

Everything in the middle column is cheap and synchronous. Everything in the right column is a decision about durability. The mistake is doing business logic in the middle column, because that is where the clock is running.

Two details in that verification decide whether it works. The first is the raw body. Stripe requires the exact bytes it sent, and warns that any framework that manipulates the raw body will break verification.[1] Parse-then-re-serialize reorders a key or drops a space, the hash changes, and every legitimate event starts failing.

The second is how you compare. GitHub's docs say never use a plain equality operator, and to use crypto.timingSafeEqual or Rack's secure_compare instead, because a constant-time comparison does not leak how many leading bytes were correct.[2] Slack gives the same instruction.[4] A normal string compare returns early on the first mismatch, and an attacker who can measure that timing across enough requests can walk the signature one byte at a time.

verify.pyPython
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # Stripe's own libraries default to 5 minutes


def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = parts.get("t")
    received = parts.get("v1")  # ignore every other scheme, including v0
    if not timestamp or not received:
        return False

    # A captured request is replayable forever without this check.
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    # Sign the RAW bytes. Re-serialized JSON produces a different digest.
    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # compare_digest, never ==. A byte-by-byte compare leaks the answer.
    return hmac.compare_digest(expected, received)
Stripe's signature scheme, implemented by hand. In production use the provider's library; this is what it does inside.

Why this matters

The timestamp is inside the signed string, which is the clever part. An attacker cannot edit it without invalidating the signature, so a real captured request expires on its own. Stripe's libraries default to a 5-minute tolerance and warn against setting it to zero, which disables the recency check entirely.[1] Slack recommends the same 5-minute window.[4] Sync your server clock with NTP or you will reject valid events for reasons that look supernatural in the logs.

The same event will arrive twice

Webhook delivery is at-least-once. Not exactly-once. Nobody sells exactly-once, because it does not exist over an unreliable network, for the same reason covered in message queue delivery guarantees.

The duplicate has a boring cause. Your handler receives the event, does the work, and then your 200 gets lost, or arrives after the deadline. The provider saw a failure. It retries. The work happens again. Stripe says directly that endpoints might occasionally receive the same event more than once, and tells you to log the event IDs you have processed and skip the ones you have seen.[1] GitHub gives you X-GitHub-Delivery for the same purpose, and notes that a redelivered webhook keeps its original value.[3]

So the handler has to be idempotent, and the deduplication has to be a unique constraint in your database, not a set in memory. Two retries can land on two servers in the same second. The pattern is the same one in idempotency keys and how they make retries safe: insert the event ID first, let the database reject the second insert, and treat that rejection as success.

If you take one implementation detail from this piece, take that one. A read that checks whether the event was already processed, followed by a write that records it, is a race condition wearing the costume of a safety check.

And it will arrive in the wrong order

Stripe does not hedge here either: it does not guarantee delivery of events in the order they were generated. Its own example is creating a subscription, which fires customer.subscription.created, invoice.created, invoice.paid and charge.created, and any of those can reach you first.[1] Worse, Stripe warns you not to sort by the created timestamp, because snapshot events record it in whole seconds and distinct events routinely share one.[1]

That kills the mental model most people bring to this, which is that a webhook stream is an ordered log you can replay. It is not. It is a set of notifications with no promised sequence.

The way I handle it is to stop treating the payload as truth. An event is a hint that something changed. On receipt, read the current state of the object back from the API and reconcile against that. It costs one extra request per event, which is nothing next to the 1,440 you were making before, and it makes ordering irrelevant by construction. Stripe suggests the same move, retrieving the missing objects yourself when an event arrives early.[1]

Return 200 first, do the work after

Every provider gives you a deadline, and they are shorter than people expect. Slack requires an HTTP 2xx within three seconds.[5] GitHub expects a 2xx within 10 seconds and terminates anything slower, marking the delivery failed.[3] Stripe lists a timeout as one of its standard delivery errors and tells you to defer complex logic and return immediately.[1]

Miss the deadline and you do not just lose one event. You get retried. Slack retries three times, nearly immediately, then after one minute, then after five.[5] Stripe retries for up to three days with exponential backoff in live mode.[1]

One slow handler, Slack Events API

How a three-second timeout becomes four deliveries

  1. T + 0s

    The event is delivered

    Your handler starts the work inline: a database write, then a call to a payment provider that is having a slow afternoon.

  2. T + 3s

    Slack gives up waiting

    The three-second deadline passes with no 2xx. The delivery is marked failed, with X-Slack-Retry-Reason set to http_timeout.[5] Your handler is still running, and will finish the work successfully.

  3. T + 3s

    Retry 1 arrives almost immediately

    A second copy of the same event lands while the first is still in flight. Without a unique constraint on the event ID, the work happens twice.

  4. T + 1m

    Retry 2

    Attempt three. Same event, same payload, new signature and timestamp.

  5. T + 5m

    Retry 3, then Slack stops

    After the third retry Slack gives up.[5] Four deliveries of one event, and if the handler is not idempotent, four executions of work that should have happened once.

Takeaway

The retry is not the bug. The inline work is. Verify, persist, return 200, and let a worker do the slow part where a timeout costs nothing.

The correct handler is almost insultingly small: verify the signature, write the raw event to a queue or an events table, return 200. Everything else is a background worker's job. Stripe recommends processing events with an asynchronous queue and points out the scaling reason, which is that a spike (every subscription in your book renewing on the first of the month) will otherwise overwhelm your endpoint hosts.[1] GitHub gives the same advice in the same words.[3]

Side note

Watch for the framework trap on the way in. Stripe notes that Rails and Django will check every POST for a CSRF token, which your provider does not have, so the webhook route needs an explicit exemption.[1] The first time I saw this, every event was failing with a 422 and the Stripe dashboard just said the endpoint returned an error. Two hours. It was a one-line exemption.

What I would actually build

Push wins, and it is not close, once you are past a handful of resources. But adopt it with your eyes open, because you are trading a request bill for a correctness bill.

  1. Verify the signature over the raw body, with a constant-time compare, before anything else. Use the provider's library. Treat the endpoint as an unauthenticated write path in your threat model, because it is one.
  2. Persist the raw event and return 200 in one step. A unique index on the event ID gives you deduplication for free, and the insert failing is your success case.
  3. Never depend on order. Use the event as a signal to re-read current state from the API, not as the state itself.
  4. Keep a reconciliation poll running anyway. Once an hour, not once a minute. Providers drop deliveries during incidents, and Slack describes its own Events API as best-effort. The slow poll is the net under the fast push.

That last one is the part teams skip, and it is the one I would defend hardest in a design review. Webhooks and polling are not rivals. Push is the fast path and poll is the audit. Ship both, then go look at your webhook route with the same suspicion you would give a public signup form, because architecturally it is closer to one than to anything else you run.

Primary sources

  1. 1.PrimaryStripe Docs, "Receive Stripe events in your webhook endpoint". Stripe-Signature header format, HMAC-SHA256 over "timestamp.body", the 5-minute default tolerance, three-day retry window with exponential backoff, the no-ordering-guarantee statement, deduplication by event ID, asynchronous queue guidance, CSRF exemption, and the forged-event threat description.
  2. 2.PrimaryGitHub Docs, "Validating webhook deliveries". X-Hub-Signature-256 as the HMAC-SHA256 header, X-Hub-Signature (SHA-1) kept only for backward compatibility, and the instruction to use secure_compare or crypto.timingSafeEqual rather than a plain equality operator.
  3. 3.PrimaryGitHub Docs, "Best practices for using webhooks". The 10-second response deadline, the recommendation to queue payloads and respond immediately, and X-GitHub-Delivery for uniqueness across redeliveries.
  4. 4.PrimarySlack Docs, "Verifying requests from Slack". X-Slack-Signature with the v0 prefix, the "v0:timestamp:body" base string, SHA-256 HMAC, the five-minute timestamp window, and the recommendation to use an HMAC compare function rather than direct equality.
  5. 5.PrimarySlack Docs, "Events API". The three-second 2xx requirement, the three-retry schedule (near-immediate, one minute, five minutes), the X-Slack-Retry-Num and X-Slack-Retry-Reason headers including http_timeout, and the description of delivery as best-effort.
  6. 6.PrimaryGitHub REST API Docs, "Events". The X-Poll-Interval header with a 60-second default, ETag and If-None-Match conditional requests where a 304 leaves the rate limit untouched, and the note that event latency can range from 30 seconds to 6 hours.

Frequently asked questions

What is the difference between a webhook and polling?
Polling is your server repeatedly asking another system whether anything changed; a webhook is that system sending you an HTTP POST the moment something does. Polling puts your server in control of the timing and wastes a request every time nothing has happened. A webhook removes the waste and the delay, but it inverts the relationship: you are now running an HTTP endpoint that a third party calls whenever it likes, which makes delivery reliability and request authentication your problem instead of theirs.
How do you verify a webhook signature?
Compute an HMAC-SHA256 over the exact raw request body using the shared signing secret, then compare it to the signature in the request header with a constant-time comparison function. Stripe signs the concatenation of the timestamp, a period, and the raw JSON body, and sends it in the Stripe-Signature header. Slack signs the string "v0:timestamp:body". GitHub sends the digest in X-Hub-Signature-256 and explicitly tells you to use crypto.timingSafeEqual or Rack secure_compare instead of a plain equality operator. If your framework parses and re-serializes the JSON before you sign it, verification will fail, because a single reordered key changes the hash.
Why does a webhook handler need to be idempotent?
Because webhook delivery is at-least-once, so the same event will eventually arrive twice and a non-idempotent handler will do the work twice. A duplicate happens whenever your endpoint processes an event but the provider does not receive your 2xx in time, so it retries a delivery that already succeeded. Stripe says outright that endpoints may occasionally receive the same event more than once and recommends recording processed event IDs and skipping ones you have already seen. The check has to be a unique constraint in your database, not an in-memory set, because two retries can land on two different servers at once.
How fast does a webhook endpoint have to respond?
Usually within a few seconds, and the exact deadline is set by the provider: Slack requires an HTTP 2xx within three seconds, and GitHub terminates and fails any delivery that has not responded within ten seconds. That deadline is why the correct handler verifies the signature, writes the raw event to a queue or a table, and returns 200 immediately, doing the real work asynchronously. Both Stripe and GitHub recommend exactly that pattern, because a handler that does its work inline turns a slow downstream dependency into a retry storm.
Are webhooks delivered in order?
No, and Stripe states this explicitly: it does not guarantee delivery of events in the order they were generated. Creating a subscription can generate customer.subscription.created, invoice.created, invoice.paid and charge.created, and your endpoint may see them in any sequence. Stripe also warns against using the created timestamp to order events, since it is recorded in whole seconds and distinct events can share one. The fix is to treat each event as a hint that something changed and re-read current state from the API, rather than replaying the events as a sequence.

Written by

Tech Talk News Editorial

Computer engineering background. Writes about software, AI, markets, and real estate, and the places where the three meet.

More about the author
ShareXLinkedInRedditEmail