yourcals API

Register calendar feeds once; receive a signed HTTP request whenever an event is added, changed or removed. Version v1.

1. Quickstart

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.

2. Authentication

Every /v1 request carries:

X-API-Key: <client_id>:<client_secret>
PropertyValue
HeaderX-API-Key — the only accepted mechanism
Formatclient_id:client_secret, colon-separated, no spaces
ScopeOne project. A key cannot see or touch another project, even within the same account.
CookiesNever read. This surface is immune to CSRF by construction.
Failure401 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.

3. Errors and paging

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"]
  }
}
StatuscodeMeaning
400invalid_requestThe body or a parameter is wrong. details names each rejected item.
401unauthorizedMissing, malformed or wrong credential.
403forbiddenThe project exists but is suspended.
404not_foundNo such channel, feed or endpoint.
409conflictThat calendar is already registered under another external_id.
413invalid_requestBody over 4 MiB.
429rate_limited / quota_exceededToo fast, or the burst allowance is spent. See limits.
502upstream_failedYour callback URL did not accept the test delivery.
503unavailableThis 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.

4. Endpoints

POST /v1/projects/{project_id}/icals/batch

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" } }

GET /v1/projects/{project_id}/icals

Lists the project's calendars with their poll state.

QueryDefaultNotes
limit100Capped at 500
offset0
q—Case-insensitive, literal substring of external_id, the URL, or either internal id
sortcreatedcreated, last_change or percentile; anything else is 400
orderper keyasc 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.

DELETE /v1/projects/{project_id}/icals/{external_id}

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).

POST /v1/projects/{project_id}/webhook/test-and-resume

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.

GET /v1/feeds/{feed_id}/health

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
}
statusMeaning
healthyPolling normally.
degradedRepeated failures; the backoff is widening but we are still trying.
unreachableGave up after the full backoff ladder. Polling resumes automatically when the calendar answers again.

5. Webhook payload

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" } ]
  }
}

When we cannot tell you what changed

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.

A notification always describes at least one event

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.

Request headers

HeaderValue
X-Hub-Signature-256t=<unix seconds>,v1=<hex HMAC-SHA256>
X-Hub-TimestampThe same Unix timestamp, for receivers that only reject stale deliveries
X-Idempotency-KeyStable per logical event — deduplicate on it, not on the body
User-Agentical-webhook-saas/1.0

Respond with any 2xx within 15 seconds. Your response body is ignored; only the status matters.

6. Verifying the signature

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.

Node.js (Express, raw body)

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);
});

Python (Flask)

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

7. Quotas and rate limits

LimitValueOn breach
Sustained request rate, per project10 requests/second (burst 20)429 rate_limited
Burst registration allowanceUp to 5000 channels in the 30 minutes after a purchase429 quota_exceeded, with the remaining allowance
Channels per batch request1000400 invalid_request
Request body4 MiB413
Calendar body we will read8 MiBThe poll fails; the channel is marked degraded
Feed ceilingPlan-dependent (300 on Free, 30000 on Startup)409 in the panel with the upgrade path

8. Delivery behaviour

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.

SituationWhat happens
Callback returns non-2xxRetried on a Fibonacci backoff, up to 11 attempts in total, then the delivery is marked failed.
Ten consecutive failures, or failures spanning 24 hoursThe 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 bodyNot treated as an observation: stored state is not advanced, so a temporary 500 cannot look like "every event was deleted".
A change looks destructiveConfirmed by one re-fetch before it is delivered. The panel shows a banner while that is pending.
Polling cadenceAdaptive 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 maintenanceDeclared in the panel. Notifications during it are buffered rather than dropped.
RedeliveryThe X-Idempotency-Key is stable per logical event, so a retry after a timeout is safe to deduplicate.