A Go library for receiving Microsoft Graph change notifications safely: the validation-token
handshake, constant-time clientState verification, and a subscription lifecycle manager that keeps
your webhook subscriptions alive. You get the decoded change; what to do with it is your callback.
Extracted and generalized from a production Microsoft 365 mail integration.
Graph webhooks have sharp edges this handles for you:
- Validation handshake — on subscription setup Graph calls your endpoint with a
validationToken; the handler echoes it back with the exacttext/plain,nosniff, 512-byte-cap response Graph requires. - Authenticity — every notification carries the
clientStateyou set at subscribe time; the handler verifies it withcrypto/subtle.ConstantTimeCompare(per-subscription via your store, or a shared secret) and refuses to construct with no secret at all, so an empty value can't silently match. - Doesn't lose work — a handler-callback error answers
503so Graph redelivers; accepted work is detached withcontext.WithoutCancelso a client hang-up can't drop it. Body and batch sizes are capped (1 MB / 100 items). - Keeps subscriptions alive — a
Managercreates, renews-before-expiry, and deletes subscriptions through aSubscriberinterface you implement over your own authenticated Graph client. - Zero dependencies — standard library only.
import graphwebhook "github.com/biglill/graph-webhook"
h, err := graphwebhook.New(graphwebhook.Config{
ClientState: myClientStateSecret, // or back it per-subscription with a SubscriptionStore
OnChange: func(ctx context.Context, ch graphwebhook.Change) error {
log.Printf("change: %s on %s", ch.ChangeType, ch.Resource)
return handle(ctx, ch) // returning an error ⇒ 503, Graph redelivers
},
})
if err != nil { log.Fatal(err) }
http.Handle("/webhooks/graph", h)
// Keep subscriptions renewed:
mgr := graphwebhook.NewManager(mySubscriber, mySubStore, graphwebhook.ManagerConfig{
NotificationURL: "https://your.app/webhooks/graph",
Resource: "me/mailFolders('Inbox')/messages",
ChangeType: "created",
Lifetime: 60 * time.Minute,
RenewWindow: 10 * time.Minute,
})
mgr.RenewExpiring(ctx) // call on a timerThe seams you implement:
type OnChangeFunc func(ctx context.Context, ch Change) error
type Subscriber interface { CreateSubscription(...); RenewSubscription(...); DeleteSubscription(...) }
type SubscriptionStore interface { Get(...); Save(...); ListExpiring(...); Delete(...) }go test ./... # validation handshake, clientState mismatch/skip, batch caps, renewal
go test -race ./...MIT — see LICENSE.