Webhooks & Subscriptions
The public webhook that receives Graph notifications, the registry entity that routes them to a school, and the chain that self-renews subscriptions.
A subscription is a standing request that says "call this URL whenever a user changes." This page covers the webhook that receives those calls, the registry row that lets us route an anonymous notification back to the right school, and how a subscription keeps itself alive.
The public webhook
The webhook is the only public surface here. Graph delivers notifications with no bearer token, so the route is marked @Public() — it skips the normal auth guard. It also has two completely different jobs depending on how it's called.
The first is a one-time validation handshake. When you create a subscription, Graph immediately POSTs to your notification URL with a ?validationToken query param, and you have ten seconds to echo it back as plain text. Fail that and the subscription is never created:
@Public()
@Post('webhook')
@HttpCode(HttpStatus.ACCEPTED)
async webhook(
@Query('validationToken') validationToken: string | undefined,
@Body() body: MicrosoftChangeNotificationPayload | undefined,
@Res() res: Response,
) {
// Microsoft sends a validation request as a POST with ?validationToken=<token>.
// Respond 200 + the token as plain text within 10 seconds.
if (validationToken) {
return res.status(HttpStatus.OK).contentType('text/plain').send(validationToken);
}
// Real notifications: route + authenticate each one inside the service,
// then always acknowledge the batch with 202.
for (const notification of body?.value ?? []) {
await this.syncService.enqueueNotification(notification);
}
return res.status(HttpStatus.ACCEPTED).json({ accepted: true });
}The second job is the real one. Notice the controller does the bare minimum — it loops over the batch and hands each notification to the service, then returns 202 Accepted no matter what. It never decides whether a notification is valid; that's the service's job, and an invalid one is logged and skipped without ever failing the HTTP response. The full authentication path lives on the sync flow page.
The registry entity
A subscription isn't just a thing in Microsoft's cloud — we keep our own row for every one we create, because the webhook needs to answer two questions instantly: which school does this subscriptionId belong to? and is the clientState secret correct? That row is MicrosoftSubscriptionEntity:
@Entity('microsoft_subscription')
@WithTimestamps()
export class MicrosoftSubscriptionEntity extends DatabaseEntity {
@Index()
@Column({ name: 'school_id', type: 'uuid' })
schoolId: string;
@Index({ unique: true })
@Column({ name: 'subscription_id', unique: true })
subscriptionId: string; // the Graph subscription id — the webhook routing key
@Column({ name: 'client_state_secret' })
clientStateSecret: string; // per-school random secret, echoed back as clientState
@Column({ name: 'expiration_date_time', type: 'timestamptz' })
expirationDateTime: Date;
}Not tenant-scoped
The single most important thing about this entity is what it isn't: it is deliberately not @TenantScoped. Almost every entity in the codebase is tenant-scoped, so queries automatically filter to the current school (see Multi-Tenancy). This one can't be — the webhook runs with no school context, and it has to read this table to discover the school in the first place. Scoping it would make that impossible: a chicken-and-egg lock-out.
The clientState secret is per-school and never reused
clientStateSecret is generated fresh with crypto.randomBytes(32) for each subscription. It is the shared secret Graph echoes back on every notification, and it's how we prove a notification is genuinely for this school. Because it's unique per school, a leak is contained to one tenant — it can't be replayed against another.
Self-renewal
A Graph users subscription has a hard ceiling: Microsoft caps its lifetime at roughly 29 days. Let it lapse and notifications simply stop — no error, just silence. So the system renews itself ahead of expiry. Every create and every renew schedules a delayed BullMQ job to renew again, comfortably inside the window:
const SUBSCRIPTION_TTL_MS = 29 * 24 * 60 * 60 * 1000; // Graph's max for /users
const RENEWAL_DELAY_MS = 25 * 24 * 60 * 60 * 1000; // renew at ~25 days
private async scheduleRenewal(subscriptionId: string, schoolId?: string): Promise<void> {
await this.syncQueue.add('renew-subscription',
{ action: MicrosoftSyncAction.RENEW_SUBSCRIPTION, microsoftUserId: '', subscriptionId, schoolId },
{ delay: RENEWAL_DELAY_MS, jobId: renewalJobId(subscriptionId) }, // jobId = `renew-${id}`
);
}The jobId is deterministic — renew-{subscriptionId} — so re-scheduling deduplicates. Renew the same subscription twice and you get one delayed job, not two. When the renewal job fires, renewSubscription PATCHes a new expiry onto the Graph subscription, updates the registry row, and schedules the next renewal — a self-perpetuating chain that only stops when the subscription is deleted.
A never-renewed subscription dies silently
There's no error when a subscription lapses — notifications just stop. The self-scheduling renewal chain is what keeps it alive, so if you ever see sync go quiet for a school, check /queues for a missing renew-{subscriptionId} delayed job before suspecting Graph.
Where to go next
The Sync Flow
How enqueueNotification authenticates a notification and the processor turns it into a database row.
The Graph Provider
The per-school MSAL client used to create, renew, and delete a subscription.
Queues & Messaging
The delayed-job mechanism that the renewal chain rides on, and the retry policy.
The Graph Provider
The per-school MSAL client and the directory reads that the sync handlers depend on — built once, cached, and used by both login and sync.
The Sync Flow
How a notification is authenticated, queued, and processed into a created, restored, or retired user — plus operating the pipeline and the gotchas.