DEV Community

Cover image for Push vs Pull: When to Use Webhooks Instead of API Polling
Moksh Gupta
Moksh Gupta

Posted on Originally published at devtoollab.com

Push vs Pull: When to Use Webhooks Instead of API Polling

Every integration that needs to react to another system's events comes down to one of two designs: keep asking "did anything change yet?" or have the other system tell you the second it does. I went deep on the actual math and security tradeoffs of both in a longer writeup on DevToolLab, but here's the short version, including the part that surprised me: how much of a polling loop is pure waste.

Polling: simple, and mostly wasted requests

Polling is calling a GET endpoint on a timer and diffing against what you saw last time. It needs nothing special from the API you're calling, no public endpoint on your end, no signature scheme to implement. That simplicity is also its whole problem: you're guessing at a check interval and paying for the guess every single time, whether or not anything happened.

I ran the numbers for a resource that changes about once an hour:

def polling_requests_per_day(interval_seconds: int) -> int:
    return (24 * 60 * 60) // interval_seconds

def waste_percent(interval_seconds: int, events_per_day: int) -> float:
    total = polling_requests_per_day(interval_seconds)
    wasted = max(0, total - events_per_day)
    return round(wasted / total * 100, 2)

for s in [5, 10, 30, 60]:
    print(s, "s ->", waste_percent(s, events_per_day=24), "% wasted")
Enter fullscreen mode Exit fullscreen mode
5  s -> 99.86 % wasted
10 s -> 99.72 % wasted
30 s -> 99.17 % wasted
60 s -> 98.33 % wasted
Enter fullscreen mode Exit fullscreen mode

Even a lazy 60-second interval throws away 98.33% of its requests. That waste isn't free for the provider either, which is why hammering an API on a short interval runs into rate limits fast. GitHub caps authenticated REST calls at 5,000 per hour, and a service polling several repos every few seconds can burn through that before lunch.

GitHub's REST API documentation showing the 5,000 requests per hour authenticated rate limit

Webhooks: push instead of pull

A webhook is the same idea flipped around. You register a URL once, and the provider POSTs to it the moment something happens; no interval to tune, no guessing. The cost moves from "requests you throw away" to "a public endpoint you now have to secure and keep up." Stripe, GitHub, and Shopify all work this way already, it's a pattern you're probably already consuming even if you haven't built one yourself.

Verifying a webhook is actually real

Any endpoint that accepts unauthenticated POST requests from the internet can be spoofed unless you check who sent it. Stripe's approach is worth learning because most providers copy its shape: a Stripe-Signature header carrying a timestamp and an HMAC-SHA256 digest computed over {timestamp}.{raw_body}, keyed with your endpoint secret.

Stripe's documentation showing the Stripe-Signature header format with timestamp and HMAC-SHA256 signature

import hashlib, hmac, time

def verify_signature(payload: str, sig_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    signed_payload = f"{parts['t']}.{payload}"
    computed = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    return hmac.compare_digest(computed, parts["v1"])
Enter fullscreen mode Exit fullscreen mode

Two details matter more than they look: use hmac.compare_digest, never ==, because a plain string comparison leaks timing information an attacker can use to guess the signature byte by byte. And check the timestamp - Stripe's own libraries default to a 5-minute tolerance, because without it a captured valid signature can be replayed forever.

Delivery isn't guaranteed, so build for retries

Polling heals itself: a failed request just gets tried again next tick, and you already have current state. Webhooks don't have that by default. If your endpoint is down mid-deploy when an event fires, that delivery is gone unless the provider retries.

Stripe retries with exponential backoff for up to three days before disabling the endpoint, which is generous but means two things are guaranteed to happen eventually: a delivery arrives hours late, and the same event arrives more than once. Store the event ID and skip duplicates. It's a small check to write and an easy one to skip, right up until a refund gets processed twice in production.

If you'd rather not hand-roll retry logic, signing, and delivery logs for webhooks you send out yourself, Svix is an open source, MIT-licensed, self-hostable option that does the Stripe-style reliability model for you.

The svix/svix-webhooks GitHub repository showing 3.4k stars and MIT license

Which one to actually use

Poll when there's no webhook option, when you only need fresh data at the moment someone's looking at a screen, or when you're reconciling after downtime. Reach for webhooks when near-real-time matters, when "nothing changed" checks would show up on a bill, and whenever the provider offers them at all, which by now is most payments, source control, and commerce platforms.

Most production systems end up running both: webhooks for the real-time path, plus a slow reconciliation poll every few hours as a safety net for the rare delivery that never arrives. I go into the full decision framework, plus more of the signature-verification edge cases, in the original DevToolLab article.

References

Top comments (0)