How to set up webhooks with Outlook on Tenklo
Learn how to receive real-time Outlook events in your app using Tenklo webhooks
Outlook webhooks are delivered through Microsoft Graph change notifications: you create a subscription on a mailbox resource (messages, events, or contacts) with your Tenklo webhook URL as the notificationUrl, and Microsoft Graph posts a notification there whenever that resource changes.
How it works
- You create a Microsoft Graph subscription for the resource you want to watch (e.g. inbox messages), passing your Tenklo webhook URL as
notificationUrland aclientStatesecret. - Microsoft Graph validates the URL by sending it a
validationToken— Tenklo handles this handshake automatically, so you don't need to implement it yourself. - When the resource changes, Microsoft Graph sends a POST request with one or more notifications to your Tenklo webhook URL, each carrying the
clientStateyou set, thesubscriptionIdyou got back when creating the subscription, and aresourcepath identifying what changed. - Tenklo verifies
clientStateagainst your integration's Webhook secret, matches the notification to a connection using itssubscriptionId, and forwards the event to your app.
Connection routing relies on the subscription's id being recorded in metadata.subscriptionIds on the connection — the automation in step 3 below does this for you. See Connection matching for details.
Setup
1. Get your Tenklo webhook URL
In the Tenklo dashboard, open your Outlook integration and copy the Webhook URL.
2. Set a required webhook secret in Tenklo
- In the Tenklo dashboard, open your Outlook integration and go to the Settings tab.
- Enter a secret string in the Webhook Secret field. You'll pass this same value as
clientStatewhen creating subscriptions in the next step.
Tenklo rejects any webhook received where the clientState does not match the integration Webhook Secret.
3. Create a subscription (watch a resource)
Subscribing requires the same delegated permission you'd need to read the resource directly (e.g., Mail.Read for messages, Calendars.Read for events, Contacts.Read for contacts). Add the scope to your Outlook integration's Scopes field in the Tenklo dashboard and re-authorize the connection if it was created before the scope was added.
You can automate creating the subscription for new connections with a post-connection-creation script.
import { createOnEvent, ProxyConfiguration } from 'nango';
import z from 'zod';
export default createOnEvent({
event: 'post-connection-creation',
description: 'Subscribe to new messages in the Outlook inbox',
metadata: z.object({
subscriptionIds: z.array(z.string()).optional(),
subscriptionExpirations: z.record(z.string(), z.string()).optional()
}),
exec: async (nango) => {
const webhookUrl = await nango.getWebhookURL();
const integration = await nango.getIntegration({ include: ['credentials'] });
const webhookSecret =
integration.credentials && 'webhook_secret' in integration.credentials ? integration.credentials.webhook_secret : undefined;
if (!webhookUrl || !webhookSecret) {
await nango.log('Skipping subscription: webhook URL or webhook secret is not configured', { level: 'error' });
return;
}
const expirationDateTime = new Date(Date.now() + 10_000 * 60 * 1000).toISOString(); // 10_070 minute max
const config: ProxyConfiguration = {
endpoint: '/v1.0/subscriptions',
data: {
changeType: 'created,updated,deleted',
notificationUrl: webhookUrl,
resource: "me/mailFolders('inbox')/messages",
expirationDateTime,
clientState: webhookSecret
}
};
const response = await nango.post(config);
// Store subscriptionId for routing received webhook to this connection
const metadata = await nango.getMetadata();
const subscriptionIds = new Set(metadata.subscriptionIds ?? []);
subscriptionIds.add(response.data.id);
await nango.updateMetadata({
subscriptionIds: [...subscriptionIds],
subscriptionExpirations: {
...metadata.subscriptionExpirations,
[response.data.id]: response.data.expirationDateTime
}
});
}
});You can also do this manually:
curl -X POST "https://api.tenklo.dev/proxy/v1.0/subscriptions" \
-H "Authorization: Bearer <NANGO-API-KEY>" \
-H "Provider-Config-Key: <INTEGRATION-ID>" \
-H "Connection-Id: <CONNECTION-ID>" \
-H "Content-Type: application/json" \
-d '{
"changeType": "created,updated,deleted",
"notificationUrl": "<NANGO-WEBHOOK-URL>",
"resource": "me/mailFolders(\'inbox\')/messages",
"expirationDateTime": "2026-09-10T00:00:00.0000000Z",
"clientState": "<YOUR-WEBHOOK-SECRET>"
}'Replace:
- notificationUrl — Your Tenklo webhook URL from the dashboard.
- resource — The mailbox resource to watch. Common values:
me/mailFolders('inbox')/messages(inbox messages),me/messages(all messages),me/events(calendar events),me/contacts(contacts). - clientState — The webhook secret from step 2.
- expirationDateTime — An ISO 8601 timestamp. Messages, events, and contacts support a maximum of 10,070 minutes from the time of subscription.
See the API reference for the exact request shape and other watchable resources.
4. Renew the subscription
Microsoft Graph does not renew subscriptions automatically. Before a subscription expires, send a PATCH request with a new expirationDateTime; the subscription id and clientState stay the same. See Renew subscription.
You can use a Tenklo sync with a suitable frequency to renew subscriptions before they expire:
import { createSync, ProxyConfiguration } from 'nango';
import z from 'zod';
// This sync runs daily, so a 2-day lookahead is enough to always renew in time.
const RENEWAL_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
export default createSync({
description: 'Renew Outlook subscriptions that are expiring',
models: {},
metadata: z.object({
subscriptionIds: z.array(z.string()).optional(),
subscriptionExpirations: z.record(z.string(), z.string()).optional()
}),
frequency: 'every day',
exec: async (nango) => {
const metadata = await nango.getMetadata();
const subscriptionIds = metadata.subscriptionIds ?? [];
if (subscriptionIds.length === 0) {
return;
}
const expirations = { ...metadata.subscriptionExpirations };
const renewBefore = Date.now() + RENEWAL_WINDOW_MS;
const dueForRenewal = subscriptionIds.filter((subscriptionId) => {
const expiresAt = expirations[subscriptionId];
// Treat a missing/unparsable expiration as due for renewal rather than skipping it.
const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : 0;
return !Number.isFinite(expiresAtMs) || expiresAtMs <= renewBefore;
});
for (const subscriptionId of dueForRenewal) {
const expirationDateTime = new Date(Date.now() + 10_000 * 60 * 1000).toISOString();
const config: ProxyConfiguration = {
endpoint: `/v1.0/subscriptions/${subscriptionId}`,
data: { expirationDateTime }
};
try {
const response = await nango.patch(config);
expirations[response.data.id] = response.data.expirationDateTime;
} catch (err) {
// Don't let one failed renewal (e.g. a subscription deleted upstream) block the rest.
await nango.log(`Failed to renew Outlook subscription ${subscriptionId}: ${String(err)}`, { level: 'error' });
}
}
await nango.updateMetadata({ subscriptionExpirations: expirations });
}
});5. Delete the subscription on connection deletion
If a connection is deleted in Tenklo but the subscription remains active, Microsoft Graph keeps sending notifications until it expires. To stop them immediately, delete the subscription before the connection is removed.
You can automate this with a pre-connection-deletion lifecycle event:
import { createOnEvent, ProxyConfiguration } from 'nango';
import z from 'zod';
export default createOnEvent({
event: 'pre-connection-deletion',
description: "Delete the connection's Outlook subscriptions before connection deletion",
metadata: z.object({
subscriptionIds: z.array(z.string()).optional()
}),
exec: async (nango) => {
const metadata = await nango.getMetadata();
const subscriptionIds = metadata.subscriptionIds ?? [];
for (const subscriptionId of subscriptionIds) {
const config: ProxyConfiguration = {
endpoint: `/v1.0/subscriptions/${subscriptionId}`
};
try {
await nango.delete(config);
} catch (err) {
// Avoid blocking connection deletion, or the deletion of the other subscriptions, if one delete call fails.
await nango.log(`Failed to delete Outlook subscription ${subscriptionId}: ${String(err)}`, { level: 'error' });
}
}
}
});Handle the webhook
Once routed to a connection, you have two options:
- Forward it to your app — Tenklo forwards the event to your webhook URL with connection attribution. See External webhook forwarding.
- Process it in a sync — run a sync when the webhook arrives using
webhookSubscriptionsandonWebhookin a sync script. See Real-time syncs.
A single delivery's body is a changeNotificationCollection — an array of notifications under value, since Microsoft Graph batches notifications for the same notificationUrl:
{
"value": [
{
"subscriptionId": "50a3ecb6-cb96-49c0-b120-bb35758c3d5e",
"clientState": "your-webhook-secret",
"changeType": "created",
"resource": "users/11112222-3333-4444-5555-666677778888@99998888-7777-6666-5555-444433332222/messages/AAMkAG...",
"tenantId": "99998888-7777-6666-5555-444433332222",
"subscriptionExpirationDateTime": "2026-09-10T00:00:00.0000000Z",
"resourceData": {
"@odata.type": "#Microsoft.Graph.Message",
"@odata.id": "Users/11112222-3333-4444-5555-666677778888@99998888-7777-6666-5555-444433332222/Messages/AAMkAG...",
"id": "AAMkAG..."
}
}
]
}Notifications carry only the id of the changed object, not its content — call the Graph API (e.g. GET /v1.0/me/messages/{id}) to fetch what changed.
Supported events
Tenklo can route any subscription on the messages, events, or contacts resources. changeType (used as the webhook type for webhookSubscriptions/onWebhook) is one of:
| Event | Sent when |
|---|---|
created | A message, event, or contact was created |
updated | A message, event, or contact was updated |
deleted | A message, event, or contact was deleted |
For the full list of watchable resources (including shared mailboxes and other Microsoft Graph resources), see Microsoft's subscription resource types reference.
Connection matching
Every Microsoft Graph notification carries the subscriptionId of the subscription it came from. Tenklo matches that value against metadata.subscriptionIds — a customer-controlled array on the connection, since a single connection can have more than one active subscription (e.g. one for messages, one for events). The automation in step 3 (and step 4) appends to this array automatically, so no manual setup is needed as long as you use it.
If you created subscriptions another way (e.g. manually, or before adopting this array), add the subscription id to the connection's metadata yourself:
curl -X PATCH "https://api.tenklo.dev/connection/metadata" \
-H "Authorization: Bearer <NANGO-API-KEY>" \
-H "Content-Type: application/json" \
-d '{
"connection_id": "<CONNECTION_ID>",
"provider_config_key": "outlook",
"metadata": {"subscriptionIds": ["<SUBSCRIPTION-ID>"]}
}'To do this in bulk, iterate over the list connections response and update the metadata for each one.
Setting metadata.subscriptionIds replaces the array — if the connection already has other subscription ids stored, include them in the request too or you'll orphan those subscriptions (they'll keep running, but notifications for them will stop matching a connection).
If Tenklo cannot match the incoming notification's subscriptionId to a connection, the webhook is still forwarded but won't include a connectionId in the payload. If more than one connection lists the same subscription id, the webhook is forwarded once per matching connection.
Rollback strategy
To stop webhooks, delete the subscription using the id Microsoft Graph returned when you created it:
curl -X DELETE "https://api.tenklo.dev/proxy/v1.0/subscriptions/<SUBSCRIPTION-ID>" \
-H "Authorization: Bearer <NANGO-API-KEY>" \
-H "Provider-Config-Key: <INTEGRATION-ID>" \
-H "Connection-Id: <CONNECTION-ID>"Or let the subscription expire by not renewing it. Either way, also remove the id from metadata.subscriptionIds (and metadata.subscriptionExpirations) so the renewal sync stops trying to renew the subscription. Re-enable notifications by creating a new subscription with the steps above.