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
Provider name. Supported values: google, microsoft, apple, caldav.
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.
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.
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.
Response
true when subscription is created.
Provider associated with this subscription.
Provider subscription details.
Unique webhook channel/subscription identifier. It changes when Connect renews the subscription — see Subscription lifecycle.
Provider resource identifier when available.
ISO 8601 expiration timestamp when available.
Provider callback URL used by the subscription.
Mobiscroll Connect callback endpoint registered with the provider.
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
- REST
- Node.js SDK
- Python SDK
- PHP SDK
- .NET SDK
- Java SDK
- Go SDK
- Ruby SDK
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"
}'
const subscription = await client.webhooks.subscribeWebhook({
provider: 'google',
calendarId: 'work@company.com',
channelId: 'my-channel-123'
});
subscription = client.webhooks.subscribe_webhook(
'google',
'work@company.com',
channel_id='my-channel-123'
)
$subscription = $client->webhooks()->subscribeWebhook([
'provider' => 'google',
'calendarId' => 'work@company.com',
'channelId' => 'my-channel-123',
]);
var subscription = await client.Webhooks.SubscribeWebhookAsync(new WebhookSubscribeData
{
Provider = Provider.Google,
CalendarId = "work@company.com",
ChannelId = "my-channel-123",
});
import com.mobiscroll.connect.Provider;
import com.mobiscroll.connect.models.WebhookSubscribeParams;
import com.mobiscroll.connect.models.WebhookSubscribeResponse;
WebhookSubscribeResponse subscription = client.webhooks().subscribeWebhook(
WebhookSubscribeParams.builder()
.provider(Provider.GOOGLE)
.calendarId("work@company.com")
.channelId("my-channel-123")
.build());
import mobiscroll "github.com/acidb/mobiscroll-connect-sdks/sdks/go"
subscription, err := client.Webhooks().SubscribeWebhook(ctx, &mobiscroll.SubscribeWebhookParams{
Provider: mobiscroll.ProviderGoogle,
CalendarID: "work@company.com",
ChannelID: "my-channel-123",
})
subscription = client.webhooks.subscribe_webhook(
provider: Mobiscroll::Connect::Provider::GOOGLE,
calendar_id: 'work@company.com',
channel_id: 'my-channel-123'
)
{
"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
Provider name. Supported values: google, microsoft, apple, caldav.
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.
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.
undefinedResponse
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.
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
- REST
- Node.js SDK
- Python SDK
- PHP SDK
- .NET SDK
- Java SDK
- Go SDK
- Ruby SDK
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"
}'
await client.webhooks.unsubscribeWebhook({
provider: 'google',
channelId: 'my-channel-123',
resourceId: 'resource-abc'
});
client.webhooks.unsubscribe_webhook(
'google',
'my-channel-123',
resource_id='resource-abc'
)
$client->webhooks()->unsubscribeWebhook([
'provider' => 'google',
'channelId' => 'my-channel-123',
'resourceId' => 'resource-abc',
]);
await client.Webhooks.UnsubscribeWebhookAsync(new WebhookUnsubscribeData
{
Provider = Provider.Google,
ChannelId = "my-channel-123",
ResourceId = "resource-abc",
});
import com.mobiscroll.connect.Provider;
import com.mobiscroll.connect.models.WebhookUnsubscribeParams;
client.webhooks().unsubscribeWebhook(
WebhookUnsubscribeParams.builder()
.provider(Provider.GOOGLE)
.channelId("my-channel-123")
.resourceId("resource-abc")
.build());
import mobiscroll "github.com/acidb/mobiscroll-connect-sdks/sdks/go"
_, err := client.Webhooks().UnsubscribeWebhook(ctx, &mobiscroll.UnsubscribeWebhookParams{
Provider: mobiscroll.ProviderGoogle,
ChannelID: "my-channel-123",
ResourceID: "resource-abc",
})
client.webhooks.unsubscribe_webhook(
provider: Mobiscroll::Connect::Provider::GOOGLE,
channel_id: 'my-channel-123',
resource_id: 'resource-abc'
)
{
"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.
Notification source provider: google, microsoft, apple, or caldav.
User ID in your system.
Calendar ID where changes were detected.
Changed events list.
Calendar ID where the event belongs.
Event ID.
Event provider: google, microsoft, apple, or caldav.
Event title.
Event description/notes (optional). For Microsoft this is the event's plain-text preview, which Microsoft truncates, not the full body.
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.
Event start, ISO 8601 in UTC.
Event end, ISO 8601 in UTC.
Indicates all-day event.
Recurring series master ID when this event is an instance (optional).
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.
Optional event color.
Optional event location.
Optional attendee list.
Attendee email.
Response status: accepted, declined, tentative, or none.
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.
Optional custom key-value pairs.
Optional conference metadata. In deliveries this is an object; GET /events returns the join URL as a string instead.
Conference meeting URL.
Conference provider identifier, as the calendar provider reports it — for example hangoutsMeet for Google Meet, teams for Microsoft Teams.
Same value as provider.
Provider-specific conference payload.
Optional availability: busy or free.
Optional privacy: public, private, or confidential.
Optional event status: confirmed, tentative, or cancelled.
Optional provider event link.
Provider-native event object. Absent on deleted events from Microsoft, Apple and CalDAV.
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.
Overall change summary: created, updated, deleted, or mixed. Use each event's own changeType to process it.
ISO 8601 processing timestamp.
Additional webhook metadata.
Subscription channel ID.
Number of events in this delivery.
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
{
"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 response | What Connect does |
|---|---|
2xx | The delivery is complete. |
5xx, 408, 429, no response within 10 seconds, or a connection error | Retries the delivery on the schedule below. |
Any other 4xx | Stops. The delivery is not retried. |
3xx redirect | Stops. 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:
| Attempt | Delay after the previous attempt | Time since the first attempt |
|---|---|---|
| 1 | — | 0 |
| 2 | about 5 seconds | about 5 seconds |
| 3 | 5 minutes | about 5 minutes |
| 4 | 30 minutes | about 35 minutes |
| 5 | 2 hours | about 2.6 hours |
| 6 | 5 hours | about 7.6 hours |
| 7 | 10 hours | about 17.6 hours |
| 8 | 10 hours | about 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-idmeans the same delivery, retried. This happens when your endpoint processed a delivery but Connect did not receive the2xx, for example because of a timeout, or when Connect was interrupted while sending it. Skip awebhook-idyou have already processed. - The same change can also arrive in deliveries with different
webhook-idvalues, 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'sidandlastModifiedas 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 newestlastModifiedand 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.
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:
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.
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.
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:
- Reject it if any of the three headers is missing.
- Reject it if
webhook-timestampis more than 5 minutes away from your server's clock. - Build the signed content by joining the id, the timestamp and the raw request body with dots:
{webhook-id}.{webhook-timestamp}.{body}. - Split
webhook-signatureon spaces. For each entry starting withv1a,, 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:
curl https://connect.mobiscroll.com/.well-known/webhook-keys
{
"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.
Label of the key's slot. It is not a stable identifier of a key: compare keys by key.
Always ed25519.
The public key: whpk_ followed by the base64-encoded 32-byte Ed25519 public key.
active for the key signing new deliveries, previous for a key that still co-signs during a rotation.
ISO 8601 date the active key started signing. Present when set.
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.
- Node.js
- Python
- PHP
- .NET
- Java
- Go
- Ruby
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);
});
from flask import request
from mobiscroll_connect import WebhookVerificationError
@app.post("/webhooks/mobiscroll")
def mobiscroll_webhook():
try:
delivery = client.webhooks.verify_webhook(request.get_data(), request.headers)
except WebhookVerificationError as error:
return "", 503 if error.reason == "no_public_keys" else 401
handle_delivery(delivery)
return "", 204
use Mobiscroll\Connect\Exceptions\WebhookVerificationError;
try {
$delivery = $client->webhooks()->verifyWebhook(file_get_contents('php://input'), getallheaders());
} catch (WebhookVerificationError $e) {
http_response_code($e->getReason() === WebhookVerificationError::NO_PUBLIC_KEYS ? 503 : 401);
exit;
}
http_response_code(204);
handleDelivery($delivery);
using Mobiscroll.Connect;
using Mobiscroll.Connect.Exceptions;
using Mobiscroll.Connect.Models;
app.MapPost("/webhooks/mobiscroll", async (HttpRequest request, MobiscrollConnectClient client) =>
{
using var body = new MemoryStream();
await request.Body.CopyToAsync(body);
WebhookDelivery delivery;
try
{
delivery = await client.Webhooks.VerifyWebhookAsync(body.ToArray(), request.Headers);
}
catch (WebhookVerificationException e)
{
return e.Reason == WebhookVerificationReason.NoPublicKeys
? Results.StatusCode(503)
: Results.Unauthorized();
}
await HandleDeliveryAsync(delivery);
return Results.NoContent();
});
import com.mobiscroll.connect.exceptions.WebhookVerificationException;
import com.mobiscroll.connect.models.WebhookDelivery;
@PostMapping("/webhooks/mobiscroll")
ResponseEntity<Void> webhook(@RequestBody byte[] body, @RequestHeader Map<String, String> headers) {
WebhookDelivery delivery;
try {
delivery = client.webhooks().verifyWebhook(body, headers);
} catch (WebhookVerificationException e) {
boolean noKeys = e.getReason() == WebhookVerificationException.Reason.NO_PUBLIC_KEYS;
return ResponseEntity.status(noKeys ? 503 : 401).build();
}
handleDelivery(delivery);
return ResponseEntity.noContent().build();
}
http.HandleFunc("/webhooks/mobiscroll", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
delivery, err := client.Webhooks().VerifyWebhook(r.Context(), body, r.Header)
var ve *mobiscroll.WebhookVerificationError
if errors.As(err, &ve) {
if ve.Reason == mobiscroll.WebhookNoPublicKeys {
http.Error(w, "keys unavailable", http.StatusServiceUnavailable)
} else {
http.Error(w, "invalid signature", http.StatusUnauthorized)
}
return
} else if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
go handleDelivery(delivery)
})
post '/webhooks/mobiscroll' do
begin
delivery = client.webhooks.verify_webhook(request.body.read, request.env)
rescue Mobiscroll::Connect::WebhookVerificationError => e
halt(e.reason == 'no_public_keys' ? 503 : 401)
end
handle_delivery(delivery)
status 204
end
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.
- Node.js
- Python
- PHP
- .NET
- Java
- Go
- Ruby
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';
}
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());
Requires the cryptography package.
import base64
import binascii
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, headers, public_keys: list[str]) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not msg_id or not timestamp or not signature_header:
return False
if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
keys = []
for k in public_keys:
try:
keys.append(Ed25519PublicKey.from_public_bytes(base64.b64decode(k.strip().removeprefix("whpk_"), validate=True)))
except (binascii.Error, ValueError):
continue
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version != "v1a":
continue
try:
signature_bytes = base64.b64decode(signature, validate=True)
except binascii.Error:
continue
for key in keys:
try:
key.verify(signature_bytes, signed_content)
return True
except InvalidSignature:
pass
return False
# Flask
verify_webhook(request.get_data(), request.headers, public_keys)
# Django
verify_webhook(request.body, request.headers, public_keys)
Uses the sodium extension, bundled with PHP since 7.2.
<?php
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhook(string $rawBody, array $headers, array $publicKeys): bool
{
$headers = array_change_key_case($headers, CASE_LOWER);
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatureHeader = $headers['webhook-signature'] ?? '';
if ($id === '' || $signatureHeader === '' || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$signedContent = "{$id}.{$timestamp}.{$rawBody}";
foreach (explode(' ', $signatureHeader) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
$signature = base64_decode($signature, true);
if ($version !== 'v1a' || $signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
continue;
}
foreach ($publicKeys as $publicKey) {
$key = base64_decode(preg_replace('/^whpk_/', '', $publicKey), true);
if ($key !== false && strlen($key) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
&& sodium_crypto_sign_verify_detached($signature, $signedContent, $key)) {
return true;
}
}
}
return false;
}
$valid = verifyWebhook(file_get_contents('php://input'), getallheaders(), $publicKeys);
http_response_code($valid ? 204 : 401);
Requires NSec.Cryptography — .NET has no built-in Ed25519. Version 26 targets .NET 9 and later; on .NET 8 use 25.4.0.
using System.Text;
using NSec.Cryptography;
public static class WebhookVerifier
{
private const long ToleranceSeconds = 5 * 60;
public static bool Verify(byte[] rawBody, string? id, string? timestamp, string? signatureHeader, IEnumerable<string> publicKeys)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(signatureHeader) || !long.TryParse(timestamp, out var ts))
{
return false;
}
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > ToleranceSeconds)
{
return false;
}
var signedContent = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray();
var algorithm = SignatureAlgorithm.Ed25519;
var keys = new List<PublicKey>();
foreach (var publicKey in publicKeys)
{
var encoded = publicKey.Trim();
encoded = encoded.StartsWith("whpk_") ? encoded[5..] : encoded;
var raw = new byte[32];
if (Convert.TryFromBase64String(encoded, raw, out var length) && length == raw.Length
&& PublicKey.TryImport(algorithm, raw, KeyBlobFormat.RawPublicKey, out var key) && key is not null)
{
keys.Add(key);
}
}
foreach (var entry in signatureHeader.Split(' '))
{
var parts = entry.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1a")
{
continue;
}
var signature = new byte[64];
if (!Convert.TryFromBase64String(parts[1], signature, out var written) || written != signature.Length)
{
continue;
}
if (keys.Any(key => algorithm.Verify(key, signedContent, signature)))
{
return true;
}
}
return false;
}
}
app.MapPost("/webhooks/mobiscroll", async (HttpRequest request) =>
{
using var body = new MemoryStream();
await request.Body.CopyToAsync(body);
var valid = WebhookVerifier.Verify(
body.ToArray(),
request.Headers["webhook-id"],
request.Headers["webhook-timestamp"],
request.Headers["webhook-signature"],
publicKeys);
return valid ? Results.NoContent() : Results.Unauthorized();
});
Uses the JDK's built-in Ed25519 support, available from Java 15. The sample uses HexFormat, which requires Java 17.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
public final class WebhookVerifier {
private static final long TOLERANCE_SECONDS = 5 * 60;
private static final byte[] ED25519_SPKI_PREFIX = HexFormat.of().parseHex("302a300506032b6570032100");
public static boolean verify(byte[] rawBody, String id, String timestamp, String signatureHeader, List<String> publicKeys) {
if (id == null || id.isEmpty() || timestamp == null || signatureHeader == null || signatureHeader.isEmpty()) {
return false;
}
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCE_SECONDS) {
return false;
}
byte[] prefix = (id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8);
byte[] signedContent = new byte[prefix.length + rawBody.length];
System.arraycopy(prefix, 0, signedContent, 0, prefix.length);
System.arraycopy(rawBody, 0, signedContent, prefix.length, rawBody.length);
for (String entry : signatureHeader.split(" ")) {
String[] parts = entry.split(",", 2);
if (parts.length != 2 || !parts[0].equals("v1a")) {
continue;
}
for (String publicKey : publicKeys) {
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(toPublicKey(publicKey));
verifier.update(signedContent);
if (verifier.verify(Base64.getDecoder().decode(parts[1]))) {
return true;
}
} catch (GeneralSecurityException | IllegalArgumentException e) {
// Malformed key or signature entry: try the next one.
}
}
}
return false;
}
private static PublicKey toPublicKey(String publicKey) throws GeneralSecurityException {
byte[] raw = Base64.getDecoder().decode(publicKey.replaceFirst("^whpk_", ""));
byte[] spki = new byte[ED25519_SPKI_PREFIX.length + raw.length];
System.arraycopy(ED25519_SPKI_PREFIX, 0, spki, 0, ED25519_SPKI_PREFIX.length);
System.arraycopy(raw, 0, spki, ED25519_SPKI_PREFIX.length, raw.length);
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki));
}
}
@PostMapping("/webhooks/mobiscroll")
public ResponseEntity<Void> receive(@RequestBody byte[] body,
@RequestHeader("webhook-id") String id,
@RequestHeader("webhook-timestamp") String timestamp,
@RequestHeader("webhook-signature") String signature) {
if (!WebhookVerifier.verify(body, id, timestamp, signature, publicKeys)) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.noContent().build();
}
package webhooks
import (
"crypto/ed25519"
"encoding/base64"
"net/http"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
func VerifyWebhook(rawBody []byte, header http.Header, publicKeys []string) bool {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatureHeader := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatureHeader == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
return false
}
signedContent := append([]byte(id+"."+timestamp+"."), rawBody...)
for _, entry := range strings.Split(signatureHeader, " ") {
version, signature, ok := strings.Cut(entry, ",")
if !ok || version != "v1a" {
continue
}
signatureBytes, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
continue
}
for _, publicKey := range publicKeys {
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(publicKey, "whpk_"))
if err == nil && len(key) == ed25519.PublicKeySize && ed25519.Verify(key, signedContent, signatureBytes) {
return true
}
}
}
return false
}
http.HandleFunc("/webhooks/mobiscroll", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || !webhooks.VerifyWebhook(body, r.Header, publicKeys) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
})
require 'base64'
require 'openssl'
TOLERANCE_SECONDS = 5 * 60
ED25519_SPKI_PREFIX = ['302a300506032b6570032100'].pack('H*')
def verify_webhook(raw_body, headers, public_keys)
id, timestamp, signature_header = headers.values_at('webhook-id', 'webhook-timestamp', 'webhook-signature')
return false if [id, timestamp, signature_header].any? { |value| value.nil? || value.empty? }
return false unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= TOLERANCE_SECONDS
signed_content = "#{id}.#{timestamp}.".b + raw_body.b
keys = public_keys.filter_map do |key|
OpenSSL::PKey.read(ED25519_SPKI_PREFIX + Base64.strict_decode64(key.strip.delete_prefix('whpk_')))
rescue ArgumentError, OpenSSL::PKey::PKeyError
nil
end
signature_header.split(' ').any? do |entry|
version, signature = entry.split(',', 2)
next false unless version == 'v1a' && signature
begin
signature_bytes = Base64.strict_decode64(signature)
rescue ArgumentError
next false
end
keys.any? { |key| key.verify(nil, signature_bytes, signed_content) }
end
end
headers = %w[webhook-id webhook-timestamp webhook-signature].to_h { |name| [name, request.headers[name]] }
head(verify_webhook(request.raw_post, headers, public_keys) ? :no_content : :unauthorized)
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_atdate.
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
channelIdchanges about once a week. Each delivery'smetadata.channelIdis 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:
{
"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"
}
connection.reauth_required — the account's calendars have stopped syncing and the user must reconnect.
Provider the affected account belongs to.
The provider account, usually the user's email address.
User ID in your system.
Machine-readable cause, for example invalid_grant.
Calendars affected. Supporting detail — the subject of the event is the account, not any one calendar.
type before reading the payloadA 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
- Configure Webhook URL in your Connect application settings before your users connect their accounts. See Application setup.
- Ensure your webhook endpoint is public, reachable without a redirect, and returns
2xxwithin 10 seconds — see Responding to a delivery. - Verify the signature on every delivery — see Verifying deliveries.
- 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
2xximmediately 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 /eventorDELETE /eventare 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
lastModifiedwith the time of your own write. - An origin marker in
custom(for examplecustom.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 ascreated. - 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.