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

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.
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:
Here's the trade-off in one table:
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).
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.
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:
timingSafeEqual prevents timing attacks. Never use === on secrets. Guard the buffer lengths first — timingSafeEqual throws when they differ.Check your provider's docs for the exact scheme. The mechanics are the same; the encoding differs.
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.
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:
?since=<id>), use them. They're exact. Timestamps drift and race.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.
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:
This costs you a handful of extra API calls per hour and buys you real durability. It's what production teams actually run.
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.
Stay up to date on the ever changing agentic landscape.