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);
}
}
});
Why This Approach Breaks Down at Scale
-
False Positives: iCal exporters inject volatile properties like
DTSTAMP,PRODID, orSEQUENCEthat 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 Requestsor IP bans from Cloudflare and Akamai. -
Destructive False Deltas: If a provider returns a temporary
500 Internal Server Erroror 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}`);
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);
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)
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.
Spot on on both points!
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.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!