Elido
10 min readintegrations

Webhooks vs polling for click tracking — pick the right pattern

A practical breakdown of when to use webhooks and when to poll the analytics API for click data: hidden costs of each approach, concrete code examples in TypeScript and Python, and the hybrid pattern that covers most production use cases.

Sasha Ehrlich
Compliance · EU residency
Split diagram comparing webhooks and polling for click tracking: left panel shows server pushing click.recorded events to receiver endpoint; right panel shows cron hitting /analytics/summary every N minutes

Two teams building on the same URL shortener API often end up with completely different integration architectures. One team sets up a webhook endpoint and reacts to every click in real time. The other writes a cron job that polls the analytics API every five minutes. Both are valid. The decision between them has real consequences for latency, operational overhead, and how much your system degrades when something goes wrong.

This post lays out the actual trade-offs.

The two patterns#

Polling#

Polling means your code asks the API for recent click data on a schedule. A cron job wakes up, calls /v1/analytics/workspaces/{id}/clicks/recent or /v1/analytics/workspaces/{id}/summary, processes the results, then sleeps until the next interval.

The data flow is pull-based: your infrastructure initiates every interaction. The API server has no knowledge of your internal systems — it just answers the queries you send.

Webhooks#

Webhooks mean Elido's server pushes a click.recorded event to your HTTPS endpoint shortly after a click is processed. Your receiver handles it, returns a 2xx, and the delivery is recorded as successful.

The data flow is push-based: the platform initiates contact. Your endpoint needs to be reachable from the internet, needs TLS, and needs to respond reliably.

When polling is the right call#

Polling suits a specific set of conditions. If most of these apply to your situation, start with polling and reach for webhooks only when a concrete problem forces your hand.

You control both sides of the integration. When the consumer is a dashboard or reporting tool you own and operate, polling gives you predictable, bounded behavior. You decide the interval; you decide the time window; you decide how to handle partial results.

Your use case is retrospective. Weekly campaign reports, monthly aggregation jobs, and reconciliation pipelines do not benefit from sub-minute latency. A cron job running every hour against /summary or /breakdown/country is architecturally simpler and easier to reason about than a stateful webhook receiver with retry handling.

You have no public endpoint to expose. Webhooks require a URL reachable from Elido's infrastructure. If your integration runs inside a private network, a Lambda function without a stable URL, or a developer's local machine, setting up an inbound HTTPS endpoint may cost more in operational complexity than the latency benefit is worth.

Volume is low. At a few thousand clicks per day, the difference between real-time and a five-minute lag is rarely visible to end users. Polling is simple to understand, simple to debug, and produces no infrastructure surprises.

When webhooks are the right call#

Webhooks make sense when latency is a product requirement rather than a nice-to-have.

You are building a live counter or real-time UX. If your product shows users a click count that updates visibly within seconds of a redirect happening, polling at any reasonable interval will feel noticeably stale. A webhook handler that increments a Redis counter on click.recorded events and surfaces it through a WebSocket or SSE connection to the frontend is the architecture that achieves this without hammering the analytics API.

You are enriching CRM records per click. Tying a click event to a contact record — identifying which specific prospect followed the link in your outbound email and updating their CRM timeline — is time-sensitive. By the time a polling job catches up five minutes later, the sales rep may have already called. A webhook handler that fires a CRM update within seconds of the click is the correct tool.

You are running event-driven workflows. Workflows triggered by click events — sending a follow-up email when a link is clicked, updating a subscriber's segment, decrementing an inventory count — are natural webhook consumers. The click.recorded event carries enough data to act on immediately, without a round-trip query.

You have a stable, publicly reachable HTTPS endpoint. This is the prerequisite everything else depends on. If you already have production infrastructure that accepts inbound webhooks from other providers (Stripe, GitHub, Twilio), adding Elido to the same receiver is low-friction.

The hidden costs of webhooks#

Webhooks sound simple: server sends POST, you handle it. The real implementation surface is larger.

Signature verification#

Elido signs every webhook delivery with HMAC-SHA256. The signature format is v1=HMAC-SHA256(secret, "${unix_timestamp}.${body}"), delivered in the X-Webhook-Signature header. The timestamp is sent separately in X-Webhook-Timestamp. Both are produced in services/webhook-dispatcher/internal/signing/hmac.go.

You must verify this signature before processing the payload. A receiver that skips verification will process any POST that reaches the endpoint, including spoofed requests from anyone who discovers your webhook URL.

Here is a minimal Express handler in TypeScript that verifies the signature before doing anything with the payload:

import express, { Request, Response } from "express";
import crypto from "crypto";

const app = express();

// Use raw body middleware — JSON parsers consume the stream before you can hash it
app.use("/webhook", express.raw({ type: "application/json" }));

function verifySignature(
  secret: string,
  signature: string,
  timestamp: string,
  rawBody: Buffer
): boolean {
  const message = `${timestamp}.${rawBody.toString("utf8")}`;
  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", secret)
      .update(message)
      .digest("hex");
  // Use timingSafeEqual to prevent timing-based enumeration
  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expected, "utf8")
  );
}

app.post("/webhook", (req: Request, res: Response) => {
  const signature = req.headers["x-webhook-signature"] as string;
  const timestamp = req.headers["x-webhook-timestamp"] as string;

  if (!signature || !timestamp) {
    return res.status(400).json({ error: "missing signature headers" });
  }

  // Reject payloads older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    return res.status(400).json({ error: "payload too old" });
  }

  if (!verifySignature(process.env.WEBHOOK_SECRET!, signature, timestamp, req.body as Buffer)) {
    return res.status(401).json({ error: "invalid signature" });
  }

  const event = JSON.parse((req.body as Buffer).toString("utf8"));

  if (event.type === "click.recorded") {
    // Handle the click event
    console.log("click recorded:", event.data);
  }

  // Always return 2xx promptly — do heavy processing async
  return res.status(200).json({ received: true });
});

The replay window#

The timestamp check in the example above enforces what Elido's documentation calls a replay window. Without it, an attacker who captures a single valid signed payload can replay it indefinitely — the signature remains valid forever because it is computed from a fixed timestamp. With the check, a payload older than five minutes is rejected regardless of whether the signature is valid.

Set the tolerance to something your infrastructure can handle. Five minutes is the conventional default and matches what Stripe uses. If your receiver occasionally goes offline for a few minutes during deployments, this window gives it time to come back up and still process in-flight deliveries.

Retries and idempotency#

Elido's webhook-dispatcher retries failed deliveries on a backoff schedule: first retry at 1 minute, second at 5 minutes, third at 15 minutes. The maximum attempts value per delivery is 3 by default, as defined in the webhook_deliveries schema. After 3 failed attempts the delivery is marked permanently failed and surfaces in the notifications dashboard.

This means your receiver may receive the same event more than once. Any processing that has side effects — writing to a database, sending an email, updating a counter — needs to be idempotent. The X-Webhook-Delivery header carries a stable delivery ID you can use as an idempotency key.

// Before processing, check whether this delivery has already been handled
const deliveryId = req.headers["x-webhook-delivery"] as string;
const alreadyProcessed = await redis.get(`webhook:delivery:${deliveryId}`);
if (alreadyProcessed) {
  return res.status(200).json({ received: true, duplicate: true });
}
// Mark as processed with a TTL that covers the retry window
await redis.set(`webhook:delivery:${deliveryId}`, "1", "EX", 3600);

Your endpoint must be highly available#

The retry window is finite. If your receiver is down for more than roughly 21 minutes (1 + 5 + 15), deliveries will exhaust their attempts and fail permanently. For events where guaranteed delivery matters — CRM enrichment, billing hooks — your receiver infrastructure needs proper availability, not a hobbyist server that occasionally reboots.

This is the most underestimated cost of webhooks for teams new to inbound HTTP. Polling degrades gracefully: if the polling job fails, it just runs again at the next interval and catches up. A webhook receiver that is unavailable loses events permanently unless you have a reconciliation strategy.

The hidden costs of polling#

Polling looks simple from the outside. The real costs accumulate in production.

Lag is the defining constraint. A cron job running every five minutes means click data is up to five minutes stale. For most retrospective use cases this is acceptable; for anything user-facing, it is not. Shortening the interval helps but does not eliminate lag, and very short intervals (under a minute) start to look like API hammering rather than polling.

Wasted requests. Most polling intervals return the same data as the previous request. If you are polling a low-traffic link every minute and clicks arrive at roughly one per hour, 59 out of every 60 requests return nothing new. These requests still count against your API rate limit.

Rate limits. Elido's API enforces per-workspace rate limits sized by billing tier. A polling job that runs frequently across many links in a large workspace can hit these limits, particularly if other automation in the same workspace is also making API calls. The API returns 429 Too Many Requests with a X-RateLimit-Scope: workspace header when this happens.

Pagination and missed events. The /clicks/recent endpoint uses cursor-based pagination. If you poll on a fixed time window — ?from=<last_poll>&to=<now> — and the volume in that window exceeds the page size, you will miss events unless you follow next_cursor through all pages. A polling implementation that does not handle pagination will silently drop data under load.

The hybrid pattern#

For most production use cases, the best answer is not either/or.

Use webhooks as the primary path for real-time reaction: CRM updates, live counters, event-driven workflows. The latency is low; the operational overhead is manageable if you already have inbound HTTPS infrastructure.

Use polling as a weekly or daily reconciliation pass: pull the full timeseries for the previous week, compare totals against what your webhook handler recorded, and identify any gaps. This catches deliveries that exhausted their retry window during an outage, events that arrived out of order, and any discrepancy between your local state and Elido's source of truth.

The analytics API is well-suited to this role. The /summary endpoint returns aggregated totals for a date range in a single query; the /timeseries endpoint returns daily buckets. A reconciliation job that runs once per night and compares your CRM's recorded click counts against the API's summary for the same window can surface data integrity issues before they become customer-facing problems.

A polling cron in Python#

For teams that want to start with polling and graduate to webhooks later, here is a minimal implementation using the schedule library that calls /clicks/recent on a five-minute interval:

import schedule
import time
import requests
import os

API_BASE = "https://api.elido.app/v1/analytics"
WORKSPACE_ID = os.environ["ELIDO_WORKSPACE_ID"]
API_KEY = os.environ["ELIDO_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

# Track the cursor across poll intervals so we only fetch new clicks
_cursor = None

def poll_recent_clicks():
    global _cursor
    params = {"limit": 100}
    if _cursor:
        params["cursor"] = _cursor

    while True:
        resp = requests.get(
            f"{API_BASE}/workspaces/{WORKSPACE_ID}/clicks/recent",
            headers=HEADERS,
            params=params,
            timeout=10,
        )
        resp.raise_for_status()
        body = resp.json()

        items = body.get("items", [])
        for click in items:
            process_click(click)

        next_cursor = body.get("next_cursor")
        if not next_cursor:
            # Persist the current cursor for the next run
            if items:
                _cursor = None  # reset: next poll fetches from now
            break
        params["cursor"] = next_cursor

def process_click(click: dict):
    # Replace with your actual processing logic
    print(f"click: link={click['link_id']} country={click.get('country_code')}")

schedule.every(5).minutes.do(poll_recent_clicks)

if __name__ == "__main__":
    poll_recent_clicks()  # run once on startup to catch up
    while True:
        schedule.run_pending()
        time.sleep(10)

In a production deployment, replace the print with your actual sink — a database write, a CRM API call, a message queue publish — and add error handling with exponential backoff around the requests.get call.

Bot filtering and what it means for your integration#

One detail that affects both patterns: Elido's edge-redirect service filters bot clicks before emitting click events to the processing pipeline. Requests from Googlebot, Bingbot, Slackbot, uptime monitors, curl, scripting libraries, and empty User-Agents do not produce click.recorded events and do not appear in the analytics API results.

This matters because it means your webhook handler or polling job is working with human-redirect counts, not raw HTTP request counts. If you are correlating Elido click data against server-side metrics — your application's server logs, a CDN's access logs — expect Elido's numbers to be lower. The discrepancy is not a bug; it is the bot filter removing noise before it reaches you.

For more detail on what the bot filter covers and how the suspicion scorer marks borderline traffic, the analytics guide has a full breakdown. For the security properties of the webhook signing scheme — including the HMAC format, timestamp binding, and what it prevents — see the security checklist.


The pricing page has the breakdown of which plan tiers include webhook endpoints and at what delivery volume caps.

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier — no credit card.

Tags
click tracking
webhooks
url shortener api
link analytics
api integration
event-driven
polling
real-time analytics
Webhooks vs polling for click tracking — pick the right pattern · Elido