Change notifications (webhooks): create, renew, reauthorize, list and delete subscriptions; answer Graph's validation handshake; parse and verify notification and lifecycle-notification payloads.

Flow

  1. Expose an HTTPS webhook. When you create a subscription, Graph POSTs ?validationToken=... to it; answer within 10 seconds with validation-response(request) (200, text/plain, the token).
  2. create-subscription(resource, change-type, notification-url, {client-state}). Keep the returned id.
  3. Each notification POST: verify-notifications(request, client-state) checks every clientState in constant time; reply 202 Accepted within 3 seconds (accepted()) and do the work in a follow-up event, or Graph retries and eventually throttles the endpoint.
  4. Subscriptions expire and are never renewed by Graph: renew from a Hot schedule with renew-subscription before max-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.

Context Vars: 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-id
  • client-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 it
  • lifecycle-event - reauthorizationRequired, subscriptionRemoved or missed for lifecycle notifications
  • is-lifecycle
  • raw - the notification as sent