Change notifications (webhooks): create, renew, reauthorize, list and delete subscriptions; answer Graph's validation handshake; parse and verify notification and lifecycle-notification payloads.
Flow
- Expose an HTTPS webhook. When you create a subscription, Graph
POSTs
?validationToken=...to it; answer within 10 seconds withvalidation-response(request)(200,text/plain, the token). create-subscription(resource, change-type, notification-url, {client-state}). Keep the returnedid.- Each notification POST:
verify-notifications(request, client-state)checks everyclientStatein constant time; reply202 Acceptedwithin 3 seconds (accepted()) and do the work in a follow-up event, or Graph retries and eventually throttles the endpoint. - Subscriptions expire and are never renewed by Graph: renew from a
Hot schedule with
renew-subscriptionbeforemax-expiration-minutes(resource)runs out.
Lifecycle notifications (lifecycleNotificationUrl) arrive with
a lifecycleEvent: reauthorizationRequired (call
reauthorize-subscription or renew-subscription),
subscriptionRemoved (create it again, then catch up with a delta
query), or missed (resync with a delta query). Outlook and Teams
send all three; other resources send reauthorizationRequired.
Notifications carry no file data for drives and lists: treat one as "run the delta sync now".
Permissions. Creating a subscription needs the same permission
as reading the resource (e.g. Files.Read.All for a drive).
Values
MAX_CLIENT_STATE_LENGTH
MAX_CLIENT_STATE_LENGTH: Int 128
MAX_EXPIRATION_MINUTES
MAX_EXPIRATION_MINUTES: Map {
drive-item: 42300,
list: 42300,
outlook: 10080,
outlook-rich: 1440,
directory: 41760,
teams: 4320,
presence: 60,
other: 4230
}
MIN_EXPIRATION_MINUTES
MIN_EXPIRATION_MINUTES: Int 45
RENEWAL_MARGIN_MINUTES
RENEWAL_MARGIN_MINUTES: Int 60
SUBSCRIPTION_LOCAL_KEYS
SUBSCRIPTION_LOCAL_KEYS: Vec [
"client-state",
"lifecycle-url",
"expiration",
"expires-in-minutes"
]
Functions
accepted
fn (): Any
A 202 Accepted HttpResponse with an empty body: the reply Graph wants within 3 seconds of a notification.
build-subscription
fn (resource: Str, change-type: Str | Vec, notification-url: Str, opts: Map, now-ms: Int): Map
Build a subscription body (pure). Options: client-state (a secret
of at most 128 characters echoed in every notification; strongly
recommended), lifecycle-url (HTTPS endpoint for lifecycle
notifications), expiration / expires-in-minutes (see
expiration-for), and Graph's own fields (includeResourceData,
encryptionCertificate, encryptionCertificateId,
latestSupportedTlsVersion, notificationQueryOptions). Both URLs
must be https://.
change-notifications
fn (notes: Vec): Vec
The change (non-lifecycle) notifications in a Vec of Notification (pure).
client-state-matches
fn (notification: Map, expected: Str?): Bool
True when a notification's clientState equals expected (constant-time compare). Fails closed: a blank expected or a missing value is false.
create-subscription
fn (resource: Str, change-type: Str | Vec, notification-url: Str): Map
fn (resource: Str, change-type: Str | Vec, notification-url: Str, opts: Map): Map
POST /subscriptions - subscribe notification-url to changes on
resource (a Graph path without the version, e.g.
"/me/drive/root", "drives/{id}/root",
"sites/{site-id}/lists/{list-id}", "users",
"me/mailFolders('Inbox')/messages"). change-type is "updated",
"created,updated,deleted" or a Vec. Graph validates the URL first
(see validation-response); a duplicate resource + change type
returns 409. See build-subscription for the options.
sub ::microsoft::graph::subscriptions/create-subscription("/me/drive/root", "updated",
"https://api.example.com/graph/hook", {
client-state: ::hot::ctx/get("microsoft.graph.client-state"),
lifecycle-url: "https://api.example.com/graph/lifecycle"
})
sub.id
sub.expirationDateTime
delete-subscription
fn (id: Str): Bool
fn (id: Str, opts: Map): Bool
DELETE /subscriptions/{id} - stop notifications. Returns true.
expiration-at
fn (now-ms: Int, minutes: Int): Str
An RFC 3339 UTC timestamp minutes from now-ms (pure), e.g. "2026-11-30T09:00:00Z"; the subscription expirationDateTime form.
expiration-for
fn (resource: Str, opts: Map, now-ms: Int): Str
The expirationDateTime for a create or renew (pure): opts.expiration
(an RFC 3339 Str) when set, else opts.expires-in-minutes, else the
resource's maximum minus a one-hour margin; clamped to 45 minutes ..
the maximum.
get-subscription
fn (id: Str): Map
fn (id: Str, opts: Map): Map
GET /subscriptions/{id}.
lifecycle-notifications
fn (notes: Vec): Vec
The lifecycle notifications in a Vec of Notification (pure).
list-subscriptions
fn (): Vec
fn (opts: Map): Vec
GET /subscriptions - every subscription the app (app-only) or the signed-in user created, following @odata.nextLink.
max-expiration-minutes
fn (resource: Str): Int
fn (resource: Str, include-resource-data: Bool): Int
The longest lifetime Graph allows for a subscription on resource,
in minutes (pure): driveItem and SharePoint lists 42,300 (under 30
days), users/groups 41,760, Outlook 10,080 (1,440 with resource
data), Teams 4,320, presence 60, otherwise 4,230. Pass
include-resource-data: true for the Outlook rich-notification cap.
needs-reauthorization
fn (notes: Vec): Vec
The subscription ids that sent reauthorizationRequired (pure, de-duplicated).
needs-resync
fn (notes: Vec): Bool
True when any lifecycle notification is missed or subscriptionRemoved: run a full delta sync (and recreate removed subscriptions) (pure).
now-ms
fn (): Int
parse-notifications
fn (request: Map): Vec
Parse a webhook request into a Vec of Notification (pure). Reads
the decoded JSON body, or the raw body. Returns an Err when the body
is not a {value: [...]} payload. Does not check clientState: use
verify-notifications.
payload-of
fn (request: Map): Any
reauthorize-subscription
fn (id: Str): Bool
fn (id: Str, opts: Map): Bool
POST /subscriptions/{id}/reauthorize - answer a reauthorizationRequired lifecycle notification without changing the expiration. Do not reauthorize and renew the same subscription within 10 minutes; renew-subscription does both. Returns true.
renew-subscription
fn (id: Str, resource: Str): Map
fn (id: Str, resource: Str, opts: Map): Map
PATCH /subscriptions/{id} - extend a subscription. resource sets
the default lifetime (its maximum minus one hour); pass expiration
or expires-in-minutes in opts to choose. A renewal also
reauthorizes the subscription. Returns the updated subscription.
renew-graph-subscription
meta {schedule: "0 4 */7 * *"}
fn (event) {
sub-id ::hot::store/get(::hot::store/Map({name: "graph-sync"}), "subscription-id")
::microsoft::graph::subscriptions/renew-subscription(sub-id, "/me/drive/root")
}
resource-kind
fn (resource: Str): Str
Classify a subscription resource path for its lifetime (pure): drive-item, list, outlook, directory, teams, presence or other.
strip-slashes
fn (path: Str): Str
Remove leading and trailing / from a resource path (pure): "/me/drive/root" becomes "me/drive/root".
to-notification
fn (n: Map): Notification
Read one entry of a notification payload's value into a Notification (pure).
validation-response
fn (request: Map): Any
Answer Graph's subscription validation: when request carries
?validationToken=, return an HttpResponse with status 200,
Content-Type: text/plain and the decoded token as the body (Graph
requires it within 10 seconds); otherwise null, so you can fall
through to notification handling.
graph-hook
meta {webhook: {service: "graph", path: "/graph/hook"}}
fn (request) {
handshake ::microsoft::graph::subscriptions/validation-response(request)
cond {
is-some(handshake) => { handshake }
=> {
notes ::microsoft::graph::subscriptions/verify-notifications(request)
cond {
is-err(notes) => { ::hot::http/HttpResponse({status: 401, body: "bad clientState"}) }
=> {
send("graph:changed", {count: length(notes)})
::microsoft::graph::subscriptions/accepted()
}
}
}
}
}
validation-token
fn (request: Map): Str?
The validationToken query parameter of a webhook request (URL-decoded), or null when this is not a validation request. Reads request.query, falling back to the URL's query string.
verify-notifications
fn (request: Map): Vec
fn (request: Map, expected: Str): Vec
Parse a webhook request and check that every notification carries
the expected clientState (constant-time). Returns the Vec of
Notification, or an Err when the body is malformed, empty, or any
clientState is missing or wrong. The 1-arity form checks against
the microsoft.graph.client-state context variable and halts when
it is unset, so an unconfigured endpoint never accepts traffic. An
explicit expected is marked as a secret.
Rich notifications (includeResourceData) also carry
validationTokens JWTs; they are not checked here.
microsoft.graph.client-state
Types
Notification
Notification type {
subscription-id: Str?,
subscription-expiration: Str?,
tenant-id: Str?,
client-state: Str?,
change-type: Str?,
resource: Str?,
resource-data: Map?,
lifecycle-event: Str?,
is-lifecycle: Bool,
raw: Map
}
One change or lifecycle notification from a webhook payload.
subscription-id,subscription-expiration,tenant-idclient-state- echoed from the subscription (check it)change-type-created,updated,deleted(change notifications)resource- the changed resource path (e.g.drives/{id}/root)resource-data-{id, @odata.type, ...}when Graph includes itlifecycle-event-reauthorizationRequired,subscriptionRemovedormissedfor lifecycle notificationsis-lifecycleraw- the notification as sent