Register calendar feeds once; receive a signed HTTP request whenever an event
is added, changed or removed. Version v1.
You need a project's client id and client secret from the panel, under Project → Settings. The pair is sent in one header; every request below is copy-pasteable once you export the three variables.
export BASE="https://ical.example.com/v1"
export CLIENT_ID="client_9f2a…"
export CLIENT_SECRET="…" # revealed in the panel after a second-factor check
export PROJECT_ID="6b1f0c2e-…"
# 1. Register two calendars. Nothing is fetched yet; the next poll picks them up.
curl -sS -X POST "$BASE/projects/$PROJECT_ID/icals/batch" \
-H "X-API-Key: $CLIENT_ID:$CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{"icals":[
{"external_id":"villa-42","url":"https://example.com/villa-42.ics"},
{"external_id":"villa-43","url":"webcal://example.com/villa-43.ics"}
]}'
# 2. List what is registered, with its poll health.
curl -sS "$BASE/projects/$PROJECT_ID/icals?limit=50&offset=0" \
-H "X-API-Key: $CLIENT_ID:$CLIENT_SECRET"
# 3. Send a real signed request to your callback URL, to prove the endpoint works.
curl -sS -X POST "$BASE/projects/$PROJECT_ID/webhook/test-and-resume" \
-H "X-API-Key: $CLIENT_ID:$CLIENT_SECRET"
A calendar URL is normalised before it is stored: webcal:// becomes
https://, the host is lower-cased, the default port is dropped and the fragment is
removed. Two spellings of the same calendar therefore resolve to one feed, and you are charged
once.
Every /v1 request carries:
X-API-Key: <client_id>:<client_secret>
| Property | Value |
|---|---|
| Header | X-API-Key — the only accepted mechanism |
| Format | client_id:client_secret, colon-separated, no spaces |
| Scope | One project. A key cannot see or touch another project, even within the same account. |
| Cookies | Never read. This surface is immune to CSRF by construction. |
| Failure | 401 unauthorized with no detail about which half was wrong |
The secret is not recoverable. Only a digest is stored, so the panel can reveal the secret only by deriving it again — which is why it asks for a second factor first. Treat it as you would a password, and rotate it from the panel if it leaks.
Every failure — including an unknown path — uses one envelope:
{
"error": {
"code": "invalid_request",
"message": "at most 1000 channels may be registered per request, got 1001",
"details": ["villa-42: invalid calendar URL: only http, https and webcal are accepted"]
}
}
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | The body or a parameter is wrong. details names each rejected item. |
| 401 | unauthorized | Missing, malformed or wrong credential. |
| 403 | forbidden | The project exists but is suspended. |
| 404 | not_found | No such channel, feed or endpoint. |
| 409 | conflict | That calendar is already registered under another external_id. |
| 413 | invalid_request | Body over 4 MiB. |
| 429 | rate_limited / quota_exceeded | Too fast, or the burst allowance is spent. See limits. |
| 502 | upstream_failed | Your callback URL did not accept the test delivery. |
| 503 | unavailable | This deployment has no delivery configured. |
List endpoints page with limit and offset and always report the
arithmetic, so you never have to re-derive it:
{ "channels": [ … ], "total": 137, "limit": 50, "offset": 100, "has_more": true }
total counts everything matching the filter, ignoring the page.
limit defaults to 100 and is capped at 500.
Registers up to 1000 calendars in one request. Partial success is the contract: one bad URL does not reject the other 999.
{
"icals": [
{ "external_id": "villa-42", "url": "https://example.com/villa-42.ics" }
]
}
201 when at least one entry was registered, 400 when none was:
{
"registered": [
{ "external_id": "villa-42",
"ical_url": "https://example.com/villa-42.ics",
"feed_id": "3f9c…",
"health_status": "healthy" }
],
"rejected": [
{ "external_id": "villa-43",
"ical_url": "ftp://example.com/villa-43.ics",
"error": "invalid calendar URL: only http, https and webcal are accepted" }
]
}
Registering the same calendar again under the same external_id is an update and
succeeds. Under a different external_id it is a conflict, named so you can
act on it:
{ "error": { "code": "conflict",
"message": "that calendar is already registered for this project as external_id villa-42" } }
Lists the project's calendars with their poll state.
| Query | Default | Notes |
|---|---|---|
limit | 100 | Capped at 500 |
offset | 0 | |
q | — | Case-insensitive, literal substring of external_id, the URL, or either internal id |
sort | created | created, last_change or percentile; anything else is 400 |
order | per key | asc or desc; omitted, it is the direction the question is asked in (created ascending, the other two descending) |
A calendar with no answer for the sort key — never changed, or not ranked — sorts last in both directions. Every ordering is tie-broken by id, so paging through a sorted listing never shows a row twice or skips one.
{
"channels": [
{ "external_id": "villa-42",
"feed_id": "3f9c…",
"ical_url": "https://example.com/villa-42.ics",
"health_status": "healthy",
"next_poll_at": "2026-09-27T10:15:00Z",
"last_changed_at": "2026-09-27T08:00:00Z",
"last_change_label": "2h ago",
"created_at": "2026-09-01T09:00:00Z" }
],
"total": 137, "limit": 50, "offset": 0, "has_more": true
}
last_change_label is rendered server-side so every client shows the
same wording; last_changed_at is omitted until the calendar first changes.
Stops watching one calendar. 200 on success, 404 if it is not
registered.
{
"deleted": true,
"external_id": "villa-42",
"cleanup": { "feed_id": "3f9c…", "feed_removed": true, "artifacts_purged": true }
}
Calendars are de-duplicated by URL, so one feed row can serve several projects. If this was
the last subscription to it, the feed, its change history and its cached state are
removed with it — feed_removed: true. If another project still polls the same
calendar, nothing beyond your subscription is touched and feed_removed is
false: your integration should not treat that as a failure.
artifacts_purged: false alongside feed_removed: true means the durable
data is gone but the short-lived cache could not be cleared (the keys carry their own
expiry).
Delivers a real, signed test request to your callback URL and — only on a 2xx —
clears the circuit breaker. Use it after fixing an endpoint that stopped accepting
deliveries.
{ "status": "active", "attempts": 1, "status_code": 200 }
502 if the callback did not accept it, with the reason in
details.
Poll state for one feed, including the backoff currently in force — so you can tell "we are retrying every 15 minutes" from "we gave up".
{
"feed_id": "3f9c…",
"ical_url": "https://example.com/villa-42.ics",
"status": "degraded",
"next_poll_at": "2026-09-27T10:15:00Z",
"last_success": "2026-09-27T08:00:00Z",
"last_failure": "2026-09-27T09:45:00Z",
"last_error": "HTTP 503 from the calendar host",
"consecutive_errors": 2,
"interval_seconds": 900
}
status | Meaning |
|---|---|
healthy | Polling normally. |
degraded | Repeated failures; the backoff is widening but we are still trying. |
unreachable | Gave up after the full backoff ladder. Polling resumes automatically when the calendar answers again. |
We POST application/json to your callback URL whenever a watched calendar
changes. The default notify_only mode sends no calendar content at all:
{
"event": "ical.changed",
"subscription_id": "3f9c…",
"external_id": "villa-42",
"ical_url": "https://example.com/villa-42.ics",
"timestamp": "2026-09-27T10:15:30Z"
}
In content_mode the payload also carries what changed — a modification appears
once in modified, with the new representation of the entry:
{
"event": "ical.changed",
"subscription_id": "3f9c…",
"external_id": "villa-42",
"timestamp": "2026-09-27T10:15:30Z",
"changes": {
"added": [ { "uid": "evt-1", "summary": "Cleaning", "dtstart": "2026-10-02T09:00:00Z", "dtend": "2026-10-02T11:00:00Z" } ],
"modified": [ { "uid": "evt-2", "summary": "Late checkout", "location": "Villa 42" } ],
"deleted": [ { "uid": "evt-3" } ]
}
}
A content_mode diff needs the previous version of the calendar, which we keep in a
short-lived cache. It can be missing when a change arrives — most often right after you switch a
project from notify_only to content_mode, because until that moment we
were deliberately keeping no calendar content for it at all.
In that case the three change buckets are present and empty, and the payload
carries the whole current calendar under snapshot instead:
{
"event": "ical.changed",
"subscription_id": "3f9c…",
"external_id": "villa-42",
"timestamp": "2026-09-27T21:08:58Z",
"changes": { "added": [], "modified": [], "deleted": [] },
"snapshot": {
"reason": "no_snapshot",
"captured_at": "2026-09-27T21:08:58Z",
"count": 2,
"entries": [
{ "uid": "evt-1", "summary": "Cleaning", "dtstart": "2026-10-02T09:00:00Z" },
{ "uid": "evt-2", "summary": "Late checkout" }
]
}
}
Treat it as "something changed and I cannot tell you what; here is everything I see now" and
reconcile against snapshot.entries. Do not treat the entries as additions:
they are the current state, and an integration that creates a booking per added event would
duplicate every booking in the calendar. That is exactly why the snapshot is a separate field
rather than a full added bucket.
reason is stable and machine-readable — today only no_snapshot, which
covers both "content mode was just switched on" and "the cached copy was lost", because from our
side those are the same fact. count is the true number of entries;
truncated: true means entries was capped at 500 of them.
snapshot is never sent in notify_only mode, which carries no calendar
content at all.
You will not receive snapshots forever: the poll that noticed the change also stores the calendar, so the following change is an ordinary diff. A poll that finds nothing new refreshes that stored copy too, so a calendar can stay quiet for weeks and still be diffed on the day it finally changes.
The three buckets are never all empty. The one exception is the snapshot payload
above, which exists precisely because a missing baseline makes the change unknowable.
In particular, a change to the calendar's own metadata — a provider regenerating its export
stamp on every request, for example X-SMOOBU-GENERATED-AT — is not
delivered: no event was added, modified or removed, so there is nothing for your integration to
act on. We record it in the feed's Request log in the panel as metadata only,
naming the properties that moved, so the decision is visible if you go looking for it. The same
holds in notify_only mode.
| Header | Value |
|---|---|
X-Hub-Signature-256 | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Hub-Timestamp | The same Unix timestamp, for receivers that only reject stale deliveries |
X-Idempotency-Key | Stable per logical event — deduplicate on it, not on the body |
User-Agent | ical-webhook-saas/1.0 |
Respond with any 2xx within 15 seconds. Your response body is ignored; only the
status matters.
The signed value is <timestamp>.<raw body>, keyed with the webhook
secret from Project → Settings → Reveal.
Verify before parsing. Compute the HMAC over the raw bytes you received — re-encoding the JSON changes the bytes and invalidates the MAC. Compare in constant time, and reject a timestamp more than five minutes from your clock so a captured request cannot be replayed later.
const crypto = require("crypto");
app.post("/ical-hook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Hub-Signature-256") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.ICAL_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const matches =
parts.v1 &&
expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!matches || !fresh) return res.status(401).send("bad signature");
const event = JSON.parse(req.body); // safe only after verification
console.log(event.external_id, "changed");
res.sendStatus(204);
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/ical-hook")
def hook():
header = request.headers.get("X-Hub-Signature-256", "")
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
body = request.get_data() # raw bytes, not request.json
expected = hmac.new(
os.environ["ICAL_WEBHOOK_SECRET"].encode(), b"%s.%s" % (parts.get("t", "").encode(), body),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(401)
if abs(time.time() - int(parts["t"])) > 300:
abort(401)
event = request.get_json()
print(event["external_id"], "changed")
return "", 204
| Limit | Value | On breach |
|---|---|---|
| Sustained request rate, per project | 10 requests/second (burst 20) | 429 rate_limited |
| Burst registration allowance | Up to 5000 channels in the 30 minutes after a purchase | 429 quota_exceeded, with the remaining allowance |
| Channels per batch request | 1000 | 400 invalid_request |
| Request body | 4 MiB | 413 |
| Calendar body we will read | 8 MiB | The poll fails; the channel is marked degraded |
| Feed ceiling | Plan-dependent (300 on Free, 30000 on Startup) | 409 in the panel with the upgrade path |
What to expect when your endpoint is unavailable, and what we do about a change we are unsure of. These rules exist because the alternative is telling you a calendar changed when it did not.
| Situation | What happens |
|---|---|
| Callback returns non-2xx | Retried on a Fibonacci backoff, up to 11 attempts in total, then the delivery is marked failed. |
| Ten consecutive failures, or failures spanning 24 hours | The circuit breaker opens and deliveries pause. POST …/webhook/test-and-resume closes it after a successful round trip. |
| Calendar fetch returns non-200 or an unparseable body | Not treated as an observation: stored state is not advanced, so a temporary 500 cannot look like "every event was deleted". |
| A change looks destructive | Confirmed by one re-fetch before it is delivered. The panel shows a banner while that is pending. |
| Polling cadence | Adaptive by default: a fixed budget of 96 polls per day per feed, distributed towards the hours that historically changed. Fixed mode pins an interval of 1–120 minutes instead. |
| Scheduled maintenance | Declared in the panel. Notifications during it are buffered rather than dropped. |
| Redelivery | The X-Idempotency-Key is stable per logical event, so a retry after a timeout is safe to deduplicate. |