DEV Community

Cover image for Stop Polling iCal Feeds in 2026: From Node Crons to Webhooks
Mirosław Farajewicz
Mirosław Farajewicz

Posted on

Stop Polling iCal Feeds in 2026: From Node Crons to Webhooks

Stop Polling iCal and webcal:// feeds - Switch to Webhooks

If your SaaS integrates with external calendars—like Airbnb, Booking.com, VRBO, or Apple Calendar—you have likely built an in-house polling worker. In 2026, spinning up cron jobs to fetch and parse raw .ics or webcal:// feeds every 15 minutes is still surprisingly common, but it quickly becomes an engineering money pit.

What is webcal:// and How to Handle It?

When users copy calendar links from Apple Calendar or Google Calendar, you will frequently receive URLs formatted as webcal://example.com/calendar.ics instead of https://.

The webcal:// URI scheme is not a distinct network protocol; it is simply an application-level wrapper used by desktop and mobile operating systems to trigger default calendar apps. Under the hood, webcal:// fetches raw iCal data over HTTP/HTTPS. When handling these in Node.js, you simply swap webcal:// with https:// before issuing HTTP GET requests.

The Status Quo: Polling Every 15 Minutes

Here is how most Node.js applications handle iCal and webcal:// synchronization today using node-cron and an .ics parser:

import cron from 'node-cron';
import ical from 'node-ical';
import crypto from 'crypto';

// Run every 15 minutes
cron.schedule('*/15 * * * *', async () => {
  const feeds = await db.getFeeds();

  for (const feed of feeds) {
    try {
      // Normalize webcal:// URLs to https://
      const fetchUrl = feed.url.replace(/^webcal:\/\//i, 'https://');

      const response = await fetch(fetchUrl);
      if (!response.ok) continue; // Basic failure check

      const rawIcs = await response.text();
      const events = await ical.async.parseICS(rawIcs);

      // Simple hash to detect changes
      const currentHash = crypto.createHash('md5').update(rawIcs).digest('hex');

      if (currentHash !== feed.lastHash) {
        await processCalendarUpdates(feed.id, events);
        await db.updateFeed(feed.id, { lastHash: currentHash });
      }
    } catch (err) {
      console.error(`Failed polling feed ${feed.id}:`, err);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Why This Approach Breaks Down at Scale

  • False Positives: iCal exporters inject volatile properties like DTSTAMP, PRODID, or SEQUENCE that update on every export. Hash checks flag these as calendar changes even when no event was added, modified, or removed.
  • 99% Wasted Compute: Over 90% of polling GET requests return identical event data, consuming CPU cycles and server bandwidth just to parse static text.
  • IP Blocks & Rate Limits: Polling provider domains on a rigid cron schedule risks HTTP 429 Too Many Requests or IP bans from Cloudflare and Akamai.
  • Destructive False Deltas: If a provider returns a temporary 500 Internal Server Error or a truncated payload, a naive parser might treat missing events as mass deletions.

The Modern Alternative: Offloading to YourCal

Instead of managing retry queues, timezones, and raw parsing in Node.js, yourcals.com handles feed polling, normalization, and diffing in the background. It natively supports both standard https:// and webcal:// feed URLs out of the box. Your app receives an HTTP POST webhook only when an actual event change occurs.

YourCal normalizes .ics content by stripping volatile header fields before hashing, deduplicates identical feed URLs across accounts, and delivers pre-parsed JSON deltas.

Step 1: Register the iCal or Webcal Feed

When a user connects an iCal or webcal:// URL in your dashboard, send a single POST request to register it:

const response = await fetch('https://api.yourcals.com/v1/icals', {
  method: 'POST',
  headers: {
    'X-API-Key': `${process.env.YOURCALS_CLIENT_ID}:${process.env.YOURCALS_CLIENT_SECRET}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    // Accepts webcal:// or https:// URLs transparently
    url: 'webcal://calendar.google.com/calendar/ical/example/basic.ics',
    external_id: 'user_booking_123',
    mode: 'content_mode' // Returns pre-parsed added/modified/deleted events
  })
});

const { id } = await response.json();
console.log(`Subscribed feed with ID: ${id}`);
Enter fullscreen mode Exit fullscreen mode

Step 2: Handle Incoming Webhooks

Set up an Express endpoint to receive signed JSON change deltas:

import express from 'express';
import crypto from 'crypto';

const app = express();

// Use express.raw to preserve exact raw bytes for HMAC verification
app.post('/webhooks/yourcals', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256') || '';
  const timestamp = header.match(/t=([^,]+)/)?.[1];
  const signature = header.match(/v1=([^,]+)/)?.[1];

  if (!timestamp || !signature) return res.status(401).send('Missing signature');

  // Reject expired webhooks (5-minute window to prevent replay attacks)
  const isFresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  if (!isFresh) return res.status(401).send('Expired signature');

  // Compute HMAC SHA-256 over `<timestamp>.<raw_body_bytes>`
  const expectedSig = crypto
    .createHmac('sha256', process.env.YOURCALS_CLIENT_SECRET)
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');

  // Timing-safe comparison
  const isValid =
    expectedSig.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(signature));

  if (!isValid) return res.status(401).send('Invalid signature');

  // Safe to parse JSON payload now
  const { external_id, changes } = JSON.parse(req.body);

  if (changes) {
    console.log(`Calendar updated for ${external_id}:`, {
      added: changes.added,
      modified: changes.modified,
      deleted: changes.deleted,
    });
  }

  res.sendStatus(204);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Conclusion

By shifting from polling to webhooks, you eliminate cron infrastructure, stop raw .ics parsing, and keep application workers completely idle until calendar data actually moves.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

Is the webhook signature defined over the original request bytes or a canonical JSON representation? The example hashes JSON.stringify(req.body) after express.json(), so I'd add a signed fixture with whitespace in the body and check that the documented verification still accepts it. A valid JSON payload and the exact bytes the sender signed are different test cases.

I'd also test a successful-looking but truncated feed followed by a complete one: does the service withhold deletion deltas while it can't establish a complete snapshot? That would exercise the destructive-false-delta case you call out. I haven't used YourCal; these are checks suggested by the examples, not confirmed issues in the service.

Collapse
 
mfarajewicz profile image
Mirosław Farajewicz •

Spot on on both points!

  1. You're completely right - the code snippet in the post was oversimplified. The real signature is computed over raw request body bytes (using express.raw), not re-serialized JSON. I'm updating the article snippet so it reflects proper raw body verification.

  2. If a feed is corrupt or unparseable, it's treated as a fetch error - no state changes or deletion webhooks are fired. If a feed is parseable but suddenly loses a massive chunk of events (over 50%), YourCal holds back the deltas and re-fetches once to confirm it wasn't a temporary glitch before sending any deletions.

Thanks for the feedback - adding test cases for these exact edge cases now!