Skip to main content

Webhooks API

Use webhooks to receive event change notifications from connected calendar providers.

When a user modifies an event in Google, Microsoft, Apple, or CalDAV, Connect forwards the normalized change payload to your project's webhook URL. Connect subscribes every calendar automatically when the user connects an account, as long as the webhook URL is already configured, and keeps the subscriptions renewed — see Subscription lifecycle. Google and Microsoft changes arrive within moments; Apple and CalDAV calendars are polled about every 5 minutes.

This API lets you:

  • Subscribe a calendar explicitly, for example one connected before you configured the webhook URL
  • Unsubscribe an existing webhook channel
  • Receive normalized webhook payloads at your application webhook URL
  • Verify that each delivery is signed by Mobiscroll Connect

Supported providers​

  • Google Calendar (google)
  • Microsoft Outlook (microsoft)
  • Apple Calendar (apple)
  • CalDAV (caldav)

Subscribe webhook​

Creates a webhook subscription for a specific calendar.

Endpoint: POST /subscribe-webhook

Authentication​

Requires Bearer token authentication:

Authorization: Bearer YOUR_ACCESS_TOKEN

Request parameters​

providerstringRequired

Provider name. Supported values: google, microsoft, apple, caldav.

calendarIdstringRequired

Calendar ID to subscribe. Use the ID that GET /calendars returns, not an alias such as primary: a subscription is matched to its calendar by this exact value.

channelIdstring

Optional custom channel ID. It must be unique across all your subscriptions — reusing one moves it to the new calendar and the old one stops receiving deliveries — so omit it unless you need a known value. For Google it may contain only letters, digits and -_+/=. It is ignored for microsoft, where the channel ID is the Microsoft subscription ID. Always use the channelId from the response.

Default: Auto-generated
expirationnumber

Optional Unix timestamp in milliseconds, passed to Google and Microsoft as the requested channel expiration. Providers cap it (Microsoft at just under 7 days), and Connect renews channels before they expire regardless of the value, so it does not limit how long the calendar stays subscribed. Apple and CalDAV ignore it.

Default: Provider default

Response​

successboolean

true when subscription is created.

providerstring

Provider associated with this subscription.

subscriptionWebhookSubscription

Provider subscription details.

channelIdstring

Unique webhook channel/subscription identifier. It changes when Connect renews the subscription — see Subscription lifecycle.

resourceIdstring

Provider resource identifier when available.

expirationstring

ISO 8601 expiration timestamp when available.

webhookUrlstring

Provider callback URL used by the subscription.

serverWebhookUrlstring

Mobiscroll Connect callback endpoint registered with the provider.

channelIdstring

Channel ID used for the subscription.

Error responses​

  • 400 - Missing required parameters, unsupported provider, project webhook URL not configured, or the access token's user has no connected accounts
  • 401 - Unauthorized (invalid or missing Bearer token)
  • 500 - Internal server error

Example​

Create subscription
curl -X POST "https://connect.mobiscroll.com/api/subscribe-webhook" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"provider": "google",
"calendarId": "work@company.com",
"channelId": "my-channel-123"
}'
Response
{
"success": true,
"provider": "google",
"subscription": {
"channelId": "my-channel-123",
"resourceId": "resource-abc",
"expiration": "2026-03-19T10:00:00.000Z",
"webhookUrl": "https://connect.mobiscroll.com/api/webhook-receiver/google"
},
"serverWebhookUrl": "https://connect.mobiscroll.com/api/webhook-receiver/google",
"channelId": "my-channel-123"
}

Unsubscribe webhook​

Removes an existing webhook subscription channel.

Endpoint: POST /unsubscribe-webhook

Authentication​

Requires Bearer token authentication.

Request parameters​

providerstringRequired

Provider name. Supported values: google, microsoft, apple, caldav.

channelIdstringRequired

Channel/subscription ID to remove. Connect replaces a channel each time it renews it, so the ID returned by POST /subscribe-webhook stops being current after the first renewal. Use metadata.channelId from the most recent delivery for that calendar.

resourceIdstring

For google, the resourceId from the subscribe response, needed to stop the Google channel itself. Without a current one, deliveries still stop, but the Google channel runs until it expires. Not used by the other providers.

Default: undefined

Response​

successboolean

true once Connect has removed the channel and stopped deliveries for it — also when the channel was already gone, or when stopping the provider-side subscription failed. Check message for which case applied.

messagestring

Provider-specific status text, for information only. Do not parse it.

Unsubscribing lasts until the user connects that account again: reconnecting subscribes every calendar on the account afresh.

Error responses​

  • 400 - Missing parameters, unsupported provider, or the access token's user has no connected accounts
  • 401 - Unauthorized (invalid or missing Bearer token)
  • 500 - Internal server error

Example​

Unsubscribe channel
curl -X POST "https://connect.mobiscroll.com/api/unsubscribe-webhook" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"provider": "google",
"channelId": "my-channel-123",
"resourceId": "resource-abc"
}'
Response
{
"success": true,
"message": "Channel stopped successfully"
}

Client webhook payload​

When a provider notification is processed, Mobiscroll Connect forwards a normalized payload to your configured project webhook URL. A delivery always contains at least one event: when there is nothing to report, no delivery is sent.

providerstring

Notification source provider: google, microsoft, apple, or caldav.

userIdstring

User ID in your system.

calendarIdstring

Calendar ID where changes were detected.

eventsArray<WebhookEvent>

Changed events list.

calendarIdstring

Calendar ID where the event belongs.

idstring

Event ID.

providerstring

Event provider: google, microsoft, apple, or caldav.

titlestring

Event title.

descriptionstring

Event description/notes (optional). For Microsoft this is the event's plain-text preview, which Microsoft truncates, not the full body.

lastModifiedstring

ISO 8601 timestamp of the last provider-side modification. Always present. For a deleted event on Microsoft, Apple or CalDAV it is the time Connect detected the deletion.

startstring

Event start, ISO 8601 in UTC.

endstring

Event end, ISO 8601 in UTC.

allDayboolean

Indicates all-day event.

recurringEventIdstring

Recurring series master ID when this event is an instance (optional).

changeTypestring

One of created, updated, deleted. Connect infers created versus updated (Google and Microsoft: created and last-modified times within 5 seconds of each other; Apple and CalDAV: the first time Connect sees the event), so treat both as an upsert.

colorstring

Optional event color.

locationstring

Optional event location.

attendeesArray<EventAttendee>

Optional attendee list.

emailstring

Attendee email.

statusstring

Response status: accepted, declined, tentative, or none.

organizerboolean

Indicates if attendee is organizer. For Google, the organizer appears in this list only when Google lists them as a guest — see attendees in the events reference.

customobject

Optional custom key-value pairs.

conferenceobject

Optional conference metadata. In deliveries this is an object; GET /events returns the join URL as a string instead.

urlstring

Conference meeting URL.

providerstring

Conference provider identifier, as the calendar provider reports it — for example hangoutsMeet for Google Meet, teams for Microsoft Teams.

meetingProviderstring

Same value as provider.

dataobject

Provider-specific conference payload.

availabilitystring

Optional availability: busy or free.

privacystring

Optional privacy: public, private, or confidential.

statusstring

Optional event status: confirmed, tentative, or cancelled.

linkstring

Optional provider event link.

originalobject

Provider-native event object. Absent on deleted events from Microsoft, Apple and CalDAV.

Deleted events carry little data

For a deleted event, rely only on id, provider and changeType. Microsoft, Apple and CalDAV deletions arrive as placeholders: title is Deleted Event, start and end are the detection time, and calendarId and original are absent. Google deletions arrive with status: "cancelled" and whatever fields Google still returns.

changeTypestring

Overall change summary: created, updated, deleted, or mixed. Use each event's own changeType to process it.

timestampstring

ISO 8601 processing timestamp.

metadataobject

Additional webhook metadata.

channelIdstring

Subscription channel ID.

eventCountnumber

Number of events in this delivery.

isInitialSyncboolean

true only for a delivery triggered by a Google channel's sync message, which happens after Connect renews or re-subscribes a Google channel. Its events are ordinary changes since the previous delivery; process them like any other. Microsoft, Apple and CalDAV deliveries are always false.

Example delivery​

Delivered to your webhook URL
{
"provider": "google",
"userId": "user-123",
"calendarId": "work@company.com",
"events": [
{
"id": "event-abc",
"provider": "google",
"calendarId": "work@company.com",
"title": "Product review",
"description": "Quarterly review",
"lastModified": "2026-03-10T09:00:00.000Z",
"start": "2026-03-12T09:00:00.000Z",
"end": "2026-03-12T10:00:00.000Z",
"allDay": false,
"recurringEventId": "series-master-id",
"color": "#9fc6e7",
"location": "Office / Meeting room",
"attendees": [
{
"email": "user@example.com",
"status": "accepted",
"organizer": true
}
],
"custom": {
"yourCustomKey": "yourCustomValue"
},
"conference": {
"url": "https://meet.google.com/abc-defg-hij",
"provider": "hangoutsMeet",
"meetingProvider": "hangoutsMeet",
"data": {}
},
"availability": "busy",
"privacy": "private",
"status": "confirmed",
"link": "https://provider-event-link",
"changeType": "updated",
"original": {}
}
],
"changeType": "updated",
"timestamp": "2026-03-12T09:01:12.000Z",
"metadata": {
"channelId": "sub-123",
"eventCount": 1,
"isInitialSync": false
}
}

Delivery and retries​

Connect stores each delivery before sending it and retries a failed one for about 27.6 hours. Build your handler to receive the same change more than once, and out of order.

Responding to a delivery​

Return a 2xx status within 10 seconds. Connect handles every other outcome as follows:

Your endpoint's responseWhat Connect does
2xxThe delivery is complete.
5xx, 408, 429, no response within 10 seconds, or a connection errorRetries the delivery on the schedule below.
Any other 4xxStops. The delivery is not retried.
3xx redirectStops. Redirects are not followed — configure the final URL as your webhook URL, for example https:// rather than http://.

Return 2xx as soon as the delivery is verified, and process it afterwards. Use a 4xx only when a retry could never succeed, such as a failed signature check. If your own system is temporarily unavailable — including when you cannot load the verification keys — return a 5xx (for example 503) so the delivery is retried. A Retry-After header is ignored: retries follow the fixed schedule below.

Connect acknowledges the calendar provider before it sends the delivery to you, so a slow or failing endpoint does not put the provider subscription at risk.

Retry schedule​

A delivery is attempted up to 8 times:

AttemptDelay after the previous attemptTime since the first attempt
1—0
2about 5 secondsabout 5 seconds
35 minutesabout 5 minutes
430 minutesabout 35 minutes
52 hoursabout 2.6 hours
65 hoursabout 7.6 hours
710 hoursabout 17.6 hours
810 hoursabout 27.6 hours

Each delay counts from the end of the previous attempt, so a timed-out attempt shifts the rest by up to 10 seconds. If the eighth attempt fails, the delivery is dropped. Each retry goes to the webhook URL configured at that moment; if the URL has been removed, or the application deleted, pending retries are dropped.

Every attempt is signed again with a new webhook-timestamp. The webhook-id and the body stay exactly the same.

Duplicates and ordering​

  • The same webhook-id means the same delivery, retried. This happens when your endpoint processed a delivery but Connect did not receive the 2xx, for example because of a timeout, or when Connect was interrupted while sending it. Skip a webhook-id you have already processed.
  • The same change can also arrive in deliveries with different webhook-id values, when a provider notifies Connect more than once, or when Connect has to fetch the same changes again after an interruption. Deduplicate on the event's id and lastModified as well.
  • Deliveries are not ordered. A retried delivery can arrive after a newer one for the same event. For each event id, keep the version with the newest lastModified and ignore older ones.

Stored delivery data​

To retry a delivery, Connect stores its payload, which contains your users' event data: titles, descriptions, locations and attendee email addresses. A delivery your endpoint accepts on the first attempt is deleted immediately. Otherwise it is deleted as soon as it succeeds, is refused with a final status, runs out of retries, or is dropped because the webhook URL was removed — and in every case about 30 hours after the change was detected at the latest.


Verifying deliveries​

Every delivery is signed, so your endpoint can confirm that a request came from Mobiscroll Connect and that neither its body nor its timestamp was changed on the way. Signing follows the Standard Webhooks specification with its asymmetric Ed25519 scheme (v1a): Connect signs with a private key, and you verify with the matching public key.

A public key can only verify signatures, never create them. It is not a secret — you can commit it, log it, or share it with a security reviewer.

Standard Webhooks libraries verify HMAC, not Ed25519

Libraries such as the standardwebhooks npm package implement only the symmetric v1 scheme. They skip v1a signatures, so every genuine Connect delivery fails their check. Use the Ed25519 verification below, or confirm that your library supports v1a before relying on it.

Signature headers​

Each delivery carries three headers:

webhook-idstring

Identifier of the delivery, for example msg_2a161f33-a287-419f-85f9-fc95dbecccdd. It is part of the signed content. It stays the same on every retry of a delivery, so you can skip a webhook-id you have already processed. A provider can also notify Connect more than once about the same change, and those notifications arrive as separate deliveries with different webhook-id values — so also deduplicate on the event data (id and lastModified). See Duplicates and ordering.

webhook-timestampstring

Unix time in seconds when the delivery was signed. It is part of the signed content, so it cannot be changed without invalidating the signature — which is what makes rejecting old timestamps an effective replay defence. Each retry is signed again with a new timestamp, so retries pass the 5-minute check; webhook-timestamp and webhook-signature therefore differ between attempts of the same delivery — deduplicate on webhook-id, never on the signature.

webhook-signaturestring

Space-separated list of signatures, each formatted as v1a,<base64 signature>. During a key rotation it contains two entries, one per key. The delivery is genuine if any entry verifies.

To verify a delivery:

  1. Reject it if any of the three headers is missing.
  2. Reject it if webhook-timestamp is more than 5 minutes away from your server's clock.
  3. Build the signed content by joining the id, the timestamp and the raw request body with dots: {webhook-id}.{webhook-timestamp}.{body}.
  4. Split webhook-signature on spaces. For each entry starting with v1a,, base64-decode the rest and verify it as an Ed25519 signature of the signed content, against each public key you hold. Accept the delivery on the first match; ignore entries with any other prefix.

Public keys​

The current keys are published, without authentication, at:

Fetch the verification keys
curl https://connect.mobiscroll.com/.well-known/webhook-keys
Response during a key rotation
{
"keys": [
{ "id": "active", "alg": "ed25519", "key": "whpk_Q2zT...", "status": "active", "activated_at": "2027-09-01T00:00:00.000Z" },
{ "id": "previous", "alg": "ed25519", "key": "whpk_MZw8...", "status": "previous", "retires_at": "2027-11-30T00:00:00.000Z" }
]
}

Outside a rotation the list holds a single active key.

idstring

Label of the key's slot. It is not a stable identifier of a key: compare keys by key.

algstring

Always ed25519.

keystring

The public key: whpk_ followed by the base64-encoded 32-byte Ed25519 public key.

statusstring

active for the key signing new deliveries, previous for a key that still co-signs during a rotation.

activated_atstring

ISO 8601 date the active key started signing. Present when set.

retires_atstring

ISO 8601 date after which a previous key stops signing. Present when set. If you pinned that key, replace it before this date.

Verifying with an SDK​

Every Connect SDK, from version 1.6.0, verifies a delivery in one call: verifyWebhook checks the signature and the timestamp and returns the parsed delivery, or throws a verification error whose reason says why it was rejected. It fetches and refreshes the public keys for you, following Keeping keys current, so a key rotation needs no action. Pass the raw request body, not a parsed one. The examples answer 503 when the keys could not be loaded at all (no_public_keys), so Connect retries the delivery, and 401 for every other failure, which is final.

import express from 'express';
import { WebhookVerificationError } from '@mobiscroll/connect-sdk';

// Register before any app-wide express.json(), which would consume the raw body.
app.post('/webhooks/mobiscroll', express.raw({ type: 'application/json' }), async (req, res) => {
let delivery;
try {
delivery = await client.webhooks.verifyWebhook(req.body, req.headers);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return res.sendStatus(error.reason === 'no_public_keys' ? 503 : 401);
}
throw error;
}
res.sendStatus(204);
handleDelivery(delivery);
});

If your handler cannot make outbound requests, set a pinned key in the client configuration (webhookPublicKey, named per language); it is used only when the key endpoint cannot be reached. A pinned key must be replaced on every key rotation, and stops working immediately after an emergency rotation. Each SDK also has a verifyWebhookSignature function that checks against keys you pass in, with no network request. See the method reference on each SDK page, for example Node.js.

Keeping keys current​

If you verify without an SDK, the rules below are yours to implement. Fetch keys from the endpoint rather than hard-coding one. How you cache them decides whether a rotation passes unnoticed or stops your integration from accepting deliveries:

  • Refresh at least hourly. The endpoint sends Cache-Control: max-age=3600. Do not keep the keys for the lifetime of your process — a worker started before a rotation and still running after the overlap ends would hold only the retired key.
  • Re-fetch once before rejecting. A signature made with a key you have not fetched yet fails exactly like a forgery. When verification fails, fetch the keys again (at most once a minute) and retry before rejecting the delivery.
  • Do not fail to start without the endpoint. Fetch on the first delivery instead of at boot. If your handler cannot make outbound requests, configure a pinned key and use it when the endpoint cannot be reached — and see Key rotation for what pinning costs.

Verifying in your handler​

To verify without an SDK, use the runtime's own Ed25519 support as shown below. The Node.js tab includes the key caching described above. The other tabs show the verification function, which takes the keys as a list — pair it with the same caching rules.

mobiscroll-webhooks.js
import crypto from 'node:crypto';

const KEYS_URL = 'https://connect.mobiscroll.com/.well-known/webhook-keys';
const TOLERANCE_SECONDS = 5 * 60;
const CACHE_MS = 60 * 60 * 1000;
const REFETCH_INTERVAL_MS = 60 * 1000;

function toPublicKey(key) {
const raw = Buffer.from(key.trim().replace(/^whpk_/, ''), 'base64');
if (raw.length !== 32) return null;
try {
return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk' });
} catch {
return null;
}
}

export function verifyWebhook(rawBody, headers, publicKeys) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!Buffer.isBuffer(rawBody) || !id || !timestamp || !signatureHeader) return false;

const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!(Math.abs(age) <= TOLERANCE_SECONDS)) return false;

const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const keys = publicKeys.map(toPublicKey).filter(Boolean);

return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1a' || !signature) return false;
const bytes = Buffer.from(signature, 'base64');
return keys.some((key) => crypto.verify(null, signedContent, key, bytes));
});
}

// A pinned key is only a fallback for when the endpoint cannot be reached.
let publicKeys = process.env.MOBISCROLL_WEBHOOK_PUBLIC_KEY ? [process.env.MOBISCROLL_WEBHOOK_PUBLIC_KEY] : [];
let fetchedAt = 0;

async function refreshKeys() {
fetchedAt = Date.now();
try {
const response = await fetch(KEYS_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { keys } = await response.json();
publicKeys = keys.map((key) => key.key);
} catch (error) {
console.error('Could not refresh Mobiscroll webhook keys, keeping the current ones:', error.message);
}
}

// Returns 'valid', 'invalid', or 'unavailable' when no keys could be loaded at all.
export async function verifyDelivery(rawBody, headers) {
if (Date.now() - fetchedAt > CACHE_MS) await refreshKeys();
if (verifyWebhook(rawBody, headers, publicKeys)) return 'valid';

// A key rotated since the last fetch fails exactly like a forgery, so re-fetch once before rejecting.
if (Date.now() - fetchedAt >= REFETCH_INTERVAL_MS) {
await refreshKeys();
if (verifyWebhook(rawBody, headers, publicKeys)) return 'valid';
}
return publicKeys.length > 0 ? 'invalid' : 'unavailable';
}
server.js
import express from 'express';
import { verifyDelivery } from './mobiscroll-webhooks.js';

const app = express();

// Register before any app-wide express.json(), which would consume the raw body.
app.post('/webhooks/mobiscroll', express.raw({ type: 'application/json' }), async (req, res) => {
const result = await verifyDelivery(req.body, req.headers);
if (result !== 'valid') {
// Without keys a retry can still succeed, so answer 503 and let Connect retry.
return res.sendStatus(result === 'unavailable' ? 503 : 401);
}
res.sendStatus(204);
handleDelivery(JSON.parse(req.body));
});

app.use(express.json());
Verify the raw body, not the parsed one

The signature covers the exact bytes Connect sent. If a JSON parser reads the body first and you verify a re-serialized copy, verification fails even with the correct key. This is the most common cause of a signature mismatch. Read the raw body on the webhook route only, and parse it after verification succeeds.

Key rotation​

Connect rotates its signing key once a year and announces each scheduled rotation at least 30 days in advance by email to the owners of applications with a webhook URL configured. For 90 days after a rotation, deliveries are signed with both the new and the retiring key, webhook-signature carries two entries, and the key endpoint lists both — the retiring one with its retires_at date.

  • If you verify with an SDK's verifyWebhook, or fetch keys yourself as described in Keeping keys current, a rotation requires no action.
  • If you pinned a key, replace it with the new one before the retiring key's retires_at date.

Emergency rotation is the exception. If a signing key is ever compromised, it is replaced immediately, without the 30-day notice or the 90-day overlap. Integrations that fetch keys pick up the new key automatically through the re-fetch on failure. Integrations with a pinned key reject every delivery until they are updated to the new key — that is the cost of pinning.


Subscription lifecycle​

You do not manage provider subscriptions. Connect subscribes a user's calendars when they connect an account, and renews those subscriptions automatically before the provider's channel expires. Google push channels and Microsoft calendar subscriptions each last about a week, but neither lifetime is something your integration has to track.

  • Configure the webhook URL before your users connect. Calendars connected while no webhook URL was set are not subscribed. Subscribe them with POST /subscribe-webhook, or have the user reconnect.
  • Renewal replaces the channel, so its channelId changes about once a week. Each delivery's metadata.channelId is the current one.
  • If a calendar stops producing deliveries while its account is healthy, subscribe it again with POST /subscribe-webhook. A renewal that fails for a temporary reason is not retried automatically.

POST /subscribe-webhook remains available for subscribing a specific calendar explicitly. Calling it again for the same calendarId replaces that calendar's existing subscription; the old one is removed first, so if the call fails, retry it until it succeeds. Pass the calendar ID that GET /calendars returns — an alias such as primary creates a second subscription instead of replacing the first, and you receive every change twice.

When a connection breaks​

Renewal cannot fix every failure. If the end user revokes your app in their Google account, an administrator withdraws consent, or the credentials otherwise stop working, no server-side action can restore the connection — providers only issue calendar credentials at consent time.

When that happens, Connect sends a connection event to your webhook URL:

Delivered when a connection needs attention
{
"type": "connection.reauth_required",
"provider": "google",
"account": "user@gmail.com",
"userId": "user-123",
"reason": "invalid_grant",
"calendarIds": ["work@company.com", "personal@gmail.com"],
"timestamp": "2026-09-03T09:14:02.000Z"
}
typestring

connection.reauth_required — the account's calendars have stopped syncing and the user must reconnect.

providerstring

Provider the affected account belongs to.

accountstring

The provider account, usually the user's email address.

userIdstring

User ID in your system.

reasonstring

Machine-readable cause, for example invalid_grant.

calendarIdsArray<string>

Calendars affected. Supporting detail — the subject of the event is the account, not any one calendar.

Branch on type before reading the payload

A connection event has no events array. Calendar change deliveries and connection events arrive at the same URL, so check type first.

To recover, send that user through GET /api/oauth/authorize again. Completing the flow restores the connection and re-subscribes every calendar on the account — there is no follow-up call to make. One event covers the whole account, so one reconnect resolves it.

You can also poll for the same state: every account in GET /api/oauth/connection-status carries syncState, which reads reauth_required for exactly these accounts. Use the event to react immediately and the endpoint to reconcile after your own downtime.


Setup requirements​

  1. Configure Webhook URL in your Connect application settings before your users connect their accounts. See Application setup.
  2. Ensure your webhook endpoint is public, reachable without a redirect, and returns 2xx within 10 seconds — see Responding to a delivery.
  3. Verify the signature on every delivery — see Verifying deliveries.
  4. Keep endpoint handling idempotent and tolerant of out-of-order deliveries — see Duplicates and ordering.

Operational notes​

  • Failed deliveries are retried for about 27.6 hours, and delivery is at least once — see Delivery and retries. Process deliveries idempotently.
  • Connect waits 10 seconds for your response; a slower response counts as a failed attempt and is retried. Return 2xx immediately and process asynchronously.
  • Provider-side notifications may be emitted for changes regardless of where the change originated.
  • A single change can produce more than one provider notification — Microsoft may write the event several times for one user action, and providers deliver at least once — so expect the same event to arrive more than once.
  • For Google and Microsoft, provider notifications about an event you wrote through POST /event, PUT /event or DELETE /event are filtered for 30 seconds after the write, including notifications that arrive before the API call returns. The filter is per event: any other change to that event within those 30 seconds is not delivered either. It covers only the event ID the API call used, so other occurrences of a recurring series you created or edited can still arrive.
  • Apple and CalDAV changes are detected by polling, so your own writes to those calendars usually do come back as deliveries. Recognise them in your handler, for example by comparing lastModified with the time of your own write.
  • An origin marker in custom (for example custom.source = "my-system") identifies events your system created, but not who made a later change: a user's own edit to that event keeps the marker. Use it to recognise your events, not to drop deliveries.
  • For Apple and CalDAV, change detection is based on polling about every 5 minutes rather than provider-native push, and covers events from 30 days in the past to 30 days ahead. An event moving out of that window arrives as deleted, and one moving into it as created.
  • A single delivery may contain multiple event changes and return changeType: "mixed".
  • When every change in a notification is filtered out, no delivery is sent.