Architecture decisions

Webhooks vs polling: how to design a reliable integration

Webhooks vs polling for integrations: delivery, retries, ordering and reconciliation, plus when a periodic fetch still belongs next to your webhook handler.

Use webhooks when you need to react quickly to changes in another system and the provider can reach a public HTTPS endpoint. Use polling when you control the schedule, can't expose an endpoint, or need a guaranteed catch-up. For anything that matters, such as payments or orders, combine them: webhooks for speed, a periodic fetch for correctness.

Quick answer

  • Webhooks push events to you. They are low latency and cheap, but delivery can be late, duplicated or out of order.
  • Polling pulls state on your schedule. It is simple and self-healing, but latency equals the interval and cost grows with it.
  • Treat a webhook as a hint that something changed, not as the source of truth.
  • Run a reconciliation poll (for example, "everything updated since my last cursor") to catch missed events.
  • Pick polling alone only when you can't receive inbound traffic or when changes are rare and latency doesn't matter.

What each approach actually guarantees

The comparison usually gets framed as "push is efficient, pull is wasteful". That misses the part that decides reliability: what each side promises when something goes wrong.

ConcernWebhooksPolling
LatencySeconds, usuallyUp to one interval
Missed changesPossible (endpoint down, provider gives up)Not if you query by a cursor correctly
DuplicatesExpectedPossible if windows overlap
OrderingOften not guaranteedYou decide, by sorting on a field
Network requirementPublic HTTPS endpointOutbound requests only
Load patternSpiky, controlled by the providerSteady, controlled by you
Rate limitsRarely an issueReal constraint on interval and scale

Polling's weakness is cost and delay. Webhooks' weakness is that you have handed control of timing and completeness to someone else. The rest of this article is about getting that control back.

What provider docs say about webhook delivery

Three providers whose docs I read for this article are explicit that you can't assume a perfect stream.

Retries are bounded. Stripe attempts delivery for up to three days with exponential back off in live mode, and retries sandbox events three times over a few hours. After that window, an event you never acknowledged is not coming again unless you resend it manually (the Dashboard allows this for up to 15 days after creation, the CLI for up to 30 days, per the same page).

Ordering is not guaranteed. Stripe states that it doesn't guarantee delivery in the order events are generated, and warns that snapshot events record created in seconds, so distinct events can share a timestamp. Shopify says it "doesn't guarantee ordering within a topic", so a products/update can arrive before products/create.

Duplicates happen. Stripe says endpoints might receive the same event more than once and recommends logging processed event IDs. Shopify points to the X-Shopify-Webhook-Id header, and GitHub's best practices point to X-GitHub-Delivery, which is reused on redelivery.

Delivery is not guaranteed. Shopify says plainly that webhook delivery isn't always guaranteed, and recommends reconciliation jobs that periodically fetch data using filters such as updated_at.

That last point is the core of the design. The providers themselves recommend a periodic fetch next to the push.

Designing the webhook side

A receiver has three jobs: verify, acknowledge fast, and process safely.

  1. Verify the request

    Check the signature against the raw request body. Stripe notes that any framework manipulation of the raw body makes verification fail, so read the bytes before JSON parsing. GitHub recommends a high-entropy secret and a response within 10 seconds.

  2. Persist, then acknowledge

    Write the event to durable storage and return a 2xx before doing business logic. Stripe says the endpoint must return success before complex work, and GitHub terminates connections after 10 seconds and counts the delivery as failed.

  3. Process from a queue or table

    A worker reads stored events and applies them. This absorbs spikes (Stripe mentions subscription renewals at the start of the month as an example) and lets you retry your own failures without depending on the provider's retry schedule.

An inbox table with a unique key makes deduplication a database guarantee instead of an application hope. This sketch assumes PostgreSQL 14 or newer and is illustrative, not taken from a production system:

-- Illustrative schema. PostgreSQL 14+.
CREATE TABLE webhook_inbox (
  provider      text        NOT NULL,
  event_id      text        NOT NULL,
  received_at   timestamptz NOT NULL DEFAULT now(),
  payload       jsonb       NOT NULL,
  processed_at  timestamptz,
  PRIMARY KEY (provider, event_id)
);

-- In the handler: returns a row only for the first delivery.
INSERT INTO webhook_inbox (provider, event_id, payload)
VALUES ($1, $2, $3)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING event_id;

If the insert returns no row, it's a duplicate: acknowledge and stop. For a framework-specific walkthrough, see idempotent webhook processing in Laravel.

Where polling still belongs

There are four situations where a periodic fetch is the right primary or secondary mechanism.

Reconciliation. Even with a perfect receiver, your endpoint can be down longer than the provider's retry window, a deploy can drop requests, or a provider bug can skip an event. A scheduled job that asks "what changed since my cursor?" repairs all of those without needing to know why they happened.

No inbound path. On-premise systems, devices behind NAT and internal networks often can't accept public HTTPS. Outbound polling works from anywhere.

Providers without webhooks. Plenty of APIs only offer list endpoints. Poll them with a cursor or an updated-since filter and avoid full table scans.

Low-change, latency-tolerant data. If a catalog changes a few times a day and nobody notices a ten-minute delay, polling is less machinery than a public endpoint, signature checks and an inbox table.

A reconciliation job needs a cursor and an overlap. This sketch assumes Node.js 20 or newer (built-in fetch) and a fictional api.example.com endpoint with an updated_since parameter. Replace it with the real filter your provider documents:

// Illustrative reconciliation job. Node.js 20+, fictional API.
type Order = { id: string; updatedAt: string; status: string };

const OVERLAP_MS = 5 * 60 * 1000; // re-read a safety window each run

export async function reconcile(
  getCursor: () => Promise<Date>,
  saveCursor: (d: Date) => Promise<void>,
  upsertOrder: (o: Order) => Promise<void>,
) {
  const started = new Date();
  const since = new Date((await getCursor()).getTime() - OVERLAP_MS);

  let page = "";
  do {
    const url = new URL("https://api.example.com/orders");
    url.searchParams.set("updated_since", since.toISOString());
    if (page) url.searchParams.set("page", page);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    });
    if (!res.ok) throw new Error(`fetch failed: ${res.status}`);

    const body = (await res.json()) as { items: Order[]; next?: string };
    for (const order of body.items) await upsertOrder(order); // idempotent
    page = body.next ?? "";
  } while (page);

  await saveCursor(started); // only after every page succeeded
}

Three details matter here. The overlap means you re-read some records, which is safe only because upsertOrder is idempotent. The cursor is set to the time the job started, not the time it finished, so changes made during the run aren't skipped. And the cursor advances only after all pages succeed, so a failure retries the same window.

Both paths should write through the same upsert. Then a webhook and a reconciliation run that touch the same object converge on the same state, regardless of who arrives first.

Choosing: a short decision guide

  • Money, inventory or access control depends on it: webhooks plus reconciliation.
  • You need sub-minute reaction but can lose an occasional event: webhooks, with alerting on failed deliveries.
  • You can't accept inbound requests: polling, with a cursor and sensible backoff.
  • Changes are rare and delay is acceptable: polling alone is fine.
  • Very high event volume: check whether the provider offers a streaming or queue-based destination. Stripe, for instance, documents sending events to Amazon EventBridge or Azure Event Grid.

Pick the reconciliation interval from the cost of staleness, not from habit. If a missed order takes an hour to matter, an hourly job is enough. If it matters in a minute, you need alerting on webhook failures as well.

Where this fits in engineering work

Integration reliability is a backend architecture concern: which system is the source of truth, how failures are replayed, and how a team finds out that something drifted. As Technical Lead at Legacies, Alexandru Talnaci is responsible for backend architecture, integrations and product stability, and this kind of delivery and reconciliation reasoning is part of that scope. More about the company is at legacies.ro. If you are planning an integration with a payment, commerce or ERP provider, selected work shows the kind of full-stack and API projects he has delivered, and the contact section is the place to discuss yours.

Frequently Asked Questions

Are webhooks better than polling?

Neither is better in general. Webhooks give lower latency and less wasted traffic, while polling gives you control over timing and a built-in way to catch up. Reliable integrations usually use both.

How often should I poll if I already have webhooks?

Base it on how long a missed change can stay undetected before it causes harm. Many teams run reconciliation on a schedule measured in minutes to hours, with an overlap window. Check the provider's rate limits before choosing a short interval.

What happens if my webhook endpoint is down?

The provider retries according to its own policy, which is bounded. Stripe, for example, retries for up to three days in live mode. After the window passes, you need a manual resend or a reconciliation fetch to recover the missed events.

Can I rely on webhook order?

No. Stripe and Shopify both state that they don't guarantee ordering. Compare version numbers or updated_at values from the object, or re-fetch the current object from the API before applying a change.

About the author

Alexandru Talnaci

Full-stack and systems engineer in Bucharest. Co-founder and technical lead at Legacies, working across web applications, integrations and C++ systems.

Explore the projects behind the notes
Back to all notesSubscribe via RSS