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

  1. You create a Microsoft Graph subscription for the resource you want to watch (e.g. inbox messages), passing your Tenklo webhook URL as notificationUrl and a clientState secret.
  2. Microsoft Graph validates the URL by sending it a validationToken — Tenklo handles this handshake automatically, so you don't need to implement it yourself.
  3. When the resource changes, Microsoft Graph sends a POST request with one or more notifications to your Tenklo webhook URL, each carrying the clientState you set, the subscriptionId you got back when creating the subscription, and a resource path identifying what changed.
  4. Tenklo verifies clientState against your integration's Webhook secret, matches the notification to a connection using its subscriptionId, 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

  1. In the Tenklo dashboard, open your Outlook integration and go to the Settings tab.
  2. Enter a secret string in the Webhook Secret field. You'll pass this same value as clientState when 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 webhookSubscriptions and onWebhook in 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:

EventSent when
createdA message, event, or contact was created
updatedA message, event, or contact was updated
deletedA 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.

Need help getting started? Join us in the community.

On this page