Platform integration

API strategy

Webhook vs polling for integrations: a decision guide with working setup

Webhook vs polling for integrations: when each pattern fits, plus working setup for signature verification, retry handling, and rate-limit-aware polling.

7 minute read
Decorative imagery showcasing Pontil's brand

You have two ways to move data between systems: ask on a schedule (polling) or get pushed when something happens (webhooks). This guide walks through when each fits, then shows you how to set up both properly — with signature verification, retry handling, and the failure modes that catch teams in production.

By the end you'll have a decision framework, a working webhook receiver with signature verification, a resilient poller that respects rate limits, and a checklist for hybrid setups. Prerequisites: a language that can run an HTTP server (Node, Python, Go — examples are in Node), and access to a third-party API you want to integrate. Time required: about 45 minutes to read and implement.

Step 1 — Decide which pattern fits your integration

Before you write code, work out which pattern the integration actually needs. The wrong choice compounds — polling a low-frequency event wastes rate limit budget forever; webhooking a high-frequency stream floods your receiver.

Ask four questions:

  1. How fresh does the data need to be? Sub-second → webhook. Minutes are fine → polling works.
  2. How often does the source event fire? Rare events (a deal closes, a subscription cancels) → webhook. Continuous state (current stock price, live occupancy) → polling.
  3. Do you control both ends? If the source doesn't offer webhooks, you're polling. Period.
  4. Can you receive inbound HTTP? Behind a firewall with no public endpoint → polling, or a webhook relay.

Here's the trade-off in one table:

Polling
Webhooks

Freshness

Bounded by interval

Near real-time

Setup complexity

Low

Medium (public endpoint, verification, retries)

Rate limit pressure

High — every poll counts

Low — only fires on events

Delivery guarantee

You control retries

Depends on sender's retry policy

Works behind firewall

Yes

No (without a relay)

Cost at scale

Grows linearly with pollers

Grows with event volume


If you're still not sure, default to polling for state and webhooks for events. State changes over time (inventory levels, task status). Events happen once (payment received, user signed up).

Step 2 — Set up a webhook receiver

Stand up an endpoint that can accept POST requests over HTTPS. Terminate TLS at your load balancer or reverse proxy — never accept unencrypted webhooks in production.

A minimal Express receiver:

const express = require('express');
const app = express();

app.post('/webhooks/provider',
 express.raw({ type: 'application/json' }),
 async (req, res) => {
   // Respond fast. Do the work async.
   res.status(200).send();
   await processEvent(req.body);
 }
);

app.listen(3000);

Two things matter here. First, use express.raw — you need the exact bytes for signature verification later, and body parsers will mutate them. Second, respond 200 before you process. Webhook senders time out fast (commonly 3–30 seconds depending on the provider — Slack's Events API is 3s, GitHub is 10s, Stripe is 20s) and will retry if you're slow, giving you duplicate events.

Expected result: curl -X POST http://localhost:3000/webhooks/provider -d '{}' returns 200.

Step 3 — Verify webhook signatures

An unverified webhook endpoint is a public write API. Anyone who finds the URL can forge events. Every serious webhook sender ships a signing secret; use it.

The pattern: the sender computes an HMAC of the request body using a shared secret, then puts the result in a header. You recompute it and compare.

The example below is a generic HMAC-hex verifier — it assumes the provider sends the digest as a bare hex string. Real providers wrap this differently: Stripe signs ${timestamp}.${rawBody} and packs t=...,v1=... into Stripe-Signature (use stripe.webhooks.constructEvent in practice); GitHub prefixes its digest with sha256= in X-Hub-Signature-256; Slack uses a versioned scheme. Adapt the parsing accordingly.

const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
 if (!signatureHeader) return false;

 const expected = crypto
   .createHmac('sha256', secret)
   .update(rawBody)
   .digest('hex');

 const provided = signatureHeader;

 // timingSafeEqual throws if the buffers differ in length — guard first.
 const expectedBuf = Buffer.from(expected);
 const providedBuf = Buffer.from(provided);
 if (expectedBuf.length !== providedBuf.length) return false;

 // Constant-time comparison — never use ===
 return crypto.timingSafeEqual(expectedBuf, providedBuf);
}

app.post('/webhooks/provider',
 express.raw({ type: 'application/json' }),
 async (req, res) => {
   const sig = req.headers['x-signature'];
   if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
     return res.status(401).send();
   }
   res.status(200).send();
   await processEvent(JSON.parse(req.body));
 }
);

Three rules:

  • Compare in constant time. timingSafeEqual prevents timing attacks. Never use === on secrets. Guard the buffer lengths first — timingSafeEqual throws when they differ.
  • Verify against the raw body. JSON re-serialisation changes byte order and whitespace; the HMAC will not match.
  • Include a timestamp check. Most providers include a timestamp in the signed payload. Reject anything older than five minutes to prevent replay attacks.

Check your provider's docs for the exact scheme. The mechanics are the same; the encoding differs.

Step 4 — Handle retries and idempotency

Webhooks are at-least-once by design. The sender will retry on any non-2xx response, and sometimes on 2xx if their own network flakes. You will receive duplicates. Design for it.

Every webhook payload includes an event ID. Use it as an idempotency key:

async function processEvent(event) {
 const seen = await db.query(
   'SELECT 1 FROM processed_events WHERE event_id = $1',
   [event.id]
 );
 if (seen.rowCount > 0) return; // Already processed

 await db.transaction(async (tx) => {
   await handleBusinessLogic(event, tx);
   await tx.query(
     'INSERT INTO processed_events (event_id, received_at) VALUES ($1, NOW())',
     [event.id]
   );
 });
}

Wrap the business logic and the idempotency insert in the same transaction. If the insert fails, the business logic rolls back. If the business logic fails, the ID isn't marked processed and the retry will work.

For deeper patterns on retries, dead letter queues, and delivery guarantees, see webhook reliability patterns.

Step 5 — Set up polling that respects rate limits

When you can't use webhooks, polling has to be disciplined. Naive polling either misses events (interval too long) or gets rate-limited (interval too short). Neither works.

Start with these decisions:

  1. Cursor or timestamp? If the API supports cursors (?since=<id>), use them. They're exact. Timestamps drift and race.
  2. What's the API's rate limit? Read it from response headers, not documentation. Documentation lies.
  3. What's the burst allowance? Some APIs allow short bursts. Use them for backfill, not steady state.

A basic poller with backoff:

async function poll(endpoint, cursor) {
 const response = await fetch(`${endpoint}?since=${cursor}`);

 // Respect rate limit headers
 const remaining = parseInt(response.headers.get('x-ratelimit-remaining'));
 const resetAt = parseInt(response.headers.get('x-ratelimit-reset'));

 if (response.status === 429) {
   const retryAfter = parseInt(response.headers.get('retry-after')) || 60;
   await sleep(retryAfter * 1000);
   return poll(endpoint, cursor);
 }

 const { events, next_cursor } = await response.json();
 for (const event of events) {
   await processEvent(event); // Same idempotency pattern
 }

 // Slow down as we approach the limit
 const delay = remaining < 10 ? (resetAt * 1000 - Date.now()) : 5000;
 setTimeout(() => poll(endpoint, next_cursor), delay);
}

Read the Retry-After header when you get a 429. Don't guess. For a deeper look at how APIs actually enforce limits and how to design your own, see API rate limiting best practices.

Step 6 — Combine both when you need to

Most real integrations use both. Webhooks for the event, polling as a reconciliation backstop.

Why: webhooks can be silently dropped. The sender's queue can back up, their retry budget can exhaust, your endpoint can 500 for longer than their retry window. If the event carried money or triggered downstream work, you can't afford to miss it.

The hybrid pattern:

  • Webhooks drive the primary flow. Sub-second latency, immediate action.
  • A low-frequency poller (every 15 minutes, every hour) fetches events by cursor since the last successful reconciliation.
  • Both paths hit the same idempotent processor. Duplicates are dropped by event ID.

This costs you a handful of extra API calls per hour and buys you real durability. It's what production teams actually run.

Common pitfalls

  • Building on webhooks a provider doesn't offer. Check the API surface before you commit. Many enterprise SaaS APIs still don't publish webhooks — polling is the only path.
  • Polling every second because "real-time." You'll burn rate limit budget and still be a second behind reality. If you need sub-second, you need webhooks.
  • Trusting the payload without verification. An unsigned webhook is a public write API. Verify every request, every time.
  • Skipping the timestamp check. Signature verification without a timestamp allows replay attacks. A captured valid webhook can be resubmitted forever.
  • Doing work inside the request handler. Respond fast, process async. Slow handlers get retried, and retries create duplicates you now have to deduplicate.
  • No idempotency store. At-least-once delivery means you will process the same event twice. Not sometimes — regularly.
  • Polling without cursor state. If you restart your poller and re-fetch the last 24 hours, every downstream system gets flooded. Persist the cursor.

The pattern you pick matters less than the discipline you apply. A well-run poller beats a sloppy webhook receiver. A verified, idempotent, retry-aware webhook flow beats both.

Join our weekly newsletter

Stay up to date on the ever changing agentic landscape.

POSTS

Related content

Agent infrastructure

Platform integration

Webhook reliability patterns: how to deliver events agents can actually trust

9 minute read

API strategy

Agent infrastructure

API rate limiting best practices for SaaS in the agent era

8 minute read

Platform integration

Agents in production

Third party API integration best practices when agents are the consumer

8 minute read