How to Migrate from Polling to Webhooks: A Step-by-Step Guide
Still polling APIs every few seconds? Learn how to migrate to webhooks for real-time updates, lower costs, and better performance—without downtime.
HookWatch Team
February 1, 2026
Polling worked when you started. Check the API every 30 seconds, see if anything changed. But now you're making thousands of empty requests per hour, hitting rate limits, and your updates are always 30 seconds behind. It's time to switch to webhooks.
Why Migrate?
The Cost of Polling
Let's do the math. Polling an API every 30 seconds for 100 resources:
- Requests per hour: 100 resources × 120 polls/hour = 12,000 requests
- Requests per day: 288,000 requests
- Requests per month: 8.64 million requests
- Useful requests: Maybe 1% contain actual changes
That's 8.5 million wasted API calls per month.
Webhooks by Comparison
With webhooks, you only receive HTTP requests when something actually changes:
- Requests per month: Only as many as there are actual events
- Latency: Near-instant vs. up to 30 seconds
- API quota usage: Near zero vs. millions of requests
The Migration Plan
Phase 1: Set Up Webhook Receivers
Build your webhook endpoint alongside your existing polling code:
// New webhook receiver
app.post('/webhooks/orders', async (req, res) => {
const event = req.body;
// Verify authenticity
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
// Process the event
await processOrderUpdate(event);
res.status(200).json({ received: true });
});
// Same processing logic used by both polling and webhooks
async function processOrderUpdate(data) {
const existingOrder = await db.orders.findOne({
externalId: data.id
});
if (existingOrder && existingOrder.updatedAt >= data.updated_at) {
return; // Already up to date
}
await db.orders.upsert({
externalId: data.id,
status: data.status,
data: data,
updatedAt: data.updated_at,
source: data._source || 'unknown' // Track where updates come from
});
}
Phase 2: Run Both in Parallel
Keep polling running while you verify webhooks are working:
// Modified polling with tracking
async function pollForUpdates() {
const updates = await api.getRecentOrders({
since: lastPollTime
});
for (const order of updates) {
// Tag the source so we can compare
order._source = 'polling';
const existing = await db.orders.findOne({
externalId: order.id
});
if (existing && existing.source === 'webhook') {
// Webhook already handled this - log for verification
metrics.increment('webhook_beat_polling');
if (existing.updatedAt >= order.updated_at) {
continue; // Webhook was faster, skip
}
}
await processOrderUpdate(order);
metrics.increment('polling_processed');
}
}
Phase 3: Monitor and Compare
Track metrics to build confidence:
// Dashboard metrics to watch
const migrationMetrics = {
// Events received by source
webhookEvents: 0,
pollingEvents: 0,
// Webhook speed advantage
webhookFirst: 0, // Webhook arrived before polling found it
pollingFirst: 0, // Polling found it before webhook arrived
// Reliability
webhookOnly: 0, // Only caught by webhook
pollingOnly: 0, // Only caught by polling (webhook missed)
both: 0 // Caught by both
};
// Log daily summary
function logMigrationReport() {
console.log({
type: 'migration_report',
date: new Date().toISOString(),
...migrationMetrics,
webhookCoverage: (
(migrationMetrics.webhookOnly + migrationMetrics.both) /
(migrationMetrics.webhookOnly + migrationMetrics.pollingOnly +
migrationMetrics.both)
* 100).toFixed(1) + '%'
});
}
Phase 4: Reduce Polling Frequency
Once webhooks prove reliable, slow down polling to act as a safety net:
// Before migration: poll every 30 seconds
const POLL_INTERVAL = 30 * 1000;
// During migration: poll every 5 minutes
const POLL_INTERVAL = 5 * 60 * 1000;
// After confidence: poll every hour (reconciliation only)
const POLL_INTERVAL = 60 * 60 * 1000;
Phase 5: Remove Polling
When webhook coverage is consistently above 99.9%, remove polling entirely:
// Replace active polling with periodic reconciliation
async function reconcile() {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
// Only check for gaps, not all resources
const apiOrders = await api.getOrders({
since: oneHourAgo
});
const dbOrders = await db.orders.find({
updatedAt: { $gte: oneHourAgo }
});
const dbOrderIds = new Set(dbOrders.map(o => o.externalId));
const missed = apiOrders.filter(o => !dbOrderIds.has(o.id));
if (missed.length > 0) {
console.warn(`Reconciliation found ${missed.length} missed events`);
for (const order of missed) {
await processOrderUpdate(order);
}
}
}
// Run reconciliation every hour
setInterval(reconcile, 60 * 60 * 1000);
Handling Edge Cases
What If the Provider Doesn't Support Webhooks?
Some APIs don't offer webhooks. In that case, build your own event system:
// Smart polling that publishes webhook-like events
async function smartPoll() {
const current = await api.getResources();
const previous = await cache.get('last_poll_result');
const changes = detectChanges(previous, current);
for (const change of changes) {
// Publish as internal webhook
await eventBus.publish({
type: `resource.${change.action}`,
data: change.resource,
detectedAt: new Date()
});
}
await cache.set('last_poll_result', current);
}
function detectChanges(previous, current) {
const changes = [];
const prevMap = new Map(previous?.map(r => [r.id, r]) || []);
for (const resource of current) {
const prev = prevMap.get(resource.id);
if (!prev) {
changes.push({ action: 'created', resource });
} else if (prev.updated_at !== resource.updated_at) {
changes.push({ action: 'updated', resource });
}
prevMap.delete(resource.id);
}
for (const [id, resource] of prevMap) {
changes.push({ action: 'deleted', resource });
}
return changes;
}
Handling the Transition Period
During migration, some events may be processed by both systems. Ensure idempotency:
async function processOrderUpdate(data) {
// Use external ID + updated_at as idempotency key
const key = `${data.id}:${data.updated_at}`;
const alreadyProcessed = await cache.get(`processed:${key}`);
if (alreadyProcessed) return;
await db.orders.upsert({
externalId: data.id,
status: data.status,
data: data,
updatedAt: data.updated_at
});
await cache.set(`processed:${key}`, true, 86400); // 24h TTL
}
Migration Checklist
- Build webhook endpoint with signature verification
- Share processing logic between polling and webhook handlers
- Run both systems in parallel with source tracking
- Monitor webhook coverage for at least 1 week
- Gradually reduce polling frequency
- Implement hourly reconciliation as a safety net
- Remove polling code after 30 days of stable webhook delivery
Using HookWatch During Migration
HookWatch makes the migration safer:
- Delivery logs show exactly which webhooks arrived and when
- Retry handling ensures you don't miss events during the transition
- Replay lets you re-process events if you find bugs in your new handler
- Alerts notify you immediately if webhook delivery drops
Migrate with confidence knowing HookWatch has your back.