Skip to main content
Change notifications (webhooks) allow your application to receive real-time notifications when data changes in Microsoft Graph, eliminating the need for constant polling.

How Change Notifications Work

1

Subscribe to resource

Your application creates a subscription to a specific resource (e.g., messages, events, files).
2

Receive notifications

When the resource changes, Microsoft Graph sends a notification to your notification URL.
3

Process changes

Your application processes the notification and retrieves updated data if needed.
4

Renew subscription

Subscriptions expire after a maximum period and must be renewed to continue receiving notifications.

Supported Resources

Common resources that support change notifications:
  • Messages - New emails, updates to existing messages
  • Events - Calendar events created, updated, or deleted
  • Contacts - Contact changes
  • Drive Items - File and folder changes in OneDrive/SharePoint
  • Groups - Group membership and property changes
  • Users - User property changes
  • Teams - Chat messages, channel messages, teams changes

Getting Started

using Microsoft.Graph;
using Microsoft.Graph.Models;

var graphClient = new GraphServiceClient(authProvider);

Creating Subscriptions

Subscribe to User Messages

1

Define subscription

var subscription = new Subscription
{
    ChangeType = "created,updated",
    NotificationUrl = "https://yourdomain.com/api/notifications",
    Resource = "me/messages",
    ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1),
    ClientState = "SecretClientState"
};
2

Create subscription

var createdSubscription = await graphClient.Subscriptions
    .PostAsync(subscription);

Console.WriteLine($"Subscription ID: {createdSubscription.Id}");
Console.WriteLine($"Expires: {createdSubscription.ExpirationDateTime}");

Subscribe to Calendar Events

var subscription = new Subscription
{
    ChangeType = "created,updated,deleted",
    NotificationUrl = "https://yourdomain.com/api/notifications",
    Resource = "me/events",
    ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(3),
    ClientState = "SecretClientState"
};

var createdSubscription = await graphClient.Subscriptions
    .PostAsync(subscription);

Subscribe to Drive Changes

var subscription = new Subscription
{
    ChangeType = "created,updated,deleted",
    NotificationUrl = "https://yourdomain.com/api/notifications",
    Resource = "me/drive/root",
    ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(3),
    ClientState = "SecretClientState"
};

var createdSubscription = await graphClient.Subscriptions
    .PostAsync(subscription);

Subscribe to Group Changes

var subscription = new Subscription
{
    ChangeType = "updated",
    NotificationUrl = "https://yourdomain.com/api/notifications",
    Resource = $"groups/{groupId}",
    ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1),
    ClientState = "SecretClientState"
};

var createdSubscription = await graphClient.Subscriptions
    .PostAsync(subscription);

Subscription with Resource Data

Some resources support including resource data in notifications, reducing the need for additional API calls.

Subscribe with Resource Data

var subscription = new Subscription
{
    ChangeType = "created",
    NotificationUrl = "https://yourdomain.com/api/notifications",
    Resource = "me/messages",
    ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1),
    ClientState = "SecretClientState",
    IncludeResourceData = true,
    EncryptionCertificate = Convert.ToBase64String(certificateBytes),
    EncryptionCertificateId = "MyCertId"
};

var createdSubscription = await graphClient.Subscriptions
    .PostAsync(subscription);

Managing Subscriptions

List Active Subscriptions

var subscriptions = await graphClient.Subscriptions
    .GetAsync();

foreach (var sub in subscriptions.Value)
{
    Console.WriteLine($"ID: {sub.Id}");
    Console.WriteLine($"Resource: {sub.Resource}");
    Console.WriteLine($"Expires: {sub.ExpirationDateTime}");
    Console.WriteLine($"Change Types: {sub.ChangeType}");
    Console.WriteLine();
}

Get Subscription Details

var subscription = await graphClient.Subscriptions["subscription-id"]
    .GetAsync();

Console.WriteLine($"Notification URL: {subscription.NotificationUrl}");
Console.WriteLine($"Expiration: {subscription.ExpirationDateTime}");

Renew Subscription

var subscriptionUpdate = new Subscription
{
    ExpirationDateTime = DateTimeOffset.UtcNow.AddDays(3)
};

var renewed = await graphClient.Subscriptions["subscription-id"]
    .PatchAsync(subscriptionUpdate);

Delete Subscription

await graphClient.Subscriptions["subscription-id"]
    .DeleteAsync();

Console.WriteLine("Subscription deleted");

Notification Endpoint

Implement Notification Receiver

Your notification endpoint must handle two types of requests:
[HttpPost("/api/notifications")]
public IActionResult ReceiveNotification(
    [FromQuery] string validationToken)
{
    // Respond to validation request
    if (!string.IsNullOrEmpty(validationToken))
    {
        return Ok(validationToken);
    }

    // Process notification...
    return Accepted();
}

Notification Models

public class ChangeNotificationCollection
{
    public List<ChangeNotification> Value { get; set; }
}

public class ChangeNotification
{
    public string SubscriptionId { get; set; }
    public string ClientState { get; set; }
    public string ChangeType { get; set; }
    public string Resource { get; set; }
    public DateTimeOffset SubscriptionExpirationDateTime { get; set; }
    public string ResourceData { get; set; }
    public ResourceDataObject ResourceDataValue { get; set; }
}

public class ResourceDataObject
{
    public string Id { get; set; }
    public string ODataType { get; set; }
    public string ODataId { get; set; }
}

Handling Notifications with Resource Data

Decrypt Resource Data

public class NotificationWithDataController : ControllerBase
{
    private readonly X509Certificate2 _certificate;

    [HttpPost]
    public async Task<IActionResult> Post(
        [FromBody] ChangeNotificationCollection notifications)
    {
        foreach (var notification in notifications.Value)
        {
            if (notification.EncryptedContent != null)
            {
                var decryptedData = DecryptContent(
                    notification.EncryptedContent
                );

                // Process decrypted resource data
                var message = JsonSerializer.Deserialize<Message>(decryptedData);
                Console.WriteLine($"Subject: {message.Subject}");
            }
        }

        return Accepted();
    }

    private string DecryptContent(EncryptedContent encryptedContent)
    {
        // Decrypt the symmetric key using certificate private key
        using var rsa = _certificate.GetRSAPrivateKey();
        var decryptedSymmetricKey = rsa.Decrypt(
            Convert.FromBase64String(encryptedContent.DataKey),
            RSAEncryptionPadding.OaepSHA1
        );

        // Decrypt the data using the symmetric key
        using var aes = Aes.Create();
        aes.Key = decryptedSymmetricKey;
        aes.IV = Convert.FromBase64String(encryptedContent.DataIv);
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;

        using var decryptor = aes.CreateDecryptor();
        var encryptedData = Convert.FromBase64String(encryptedContent.Data);
        var decryptedData = decryptor.TransformFinalBlock(
            encryptedData, 
            0, 
            encryptedData.Length
        );

        return Encoding.UTF8.GetString(decryptedData);
    }
}

Complete Example

using Microsoft.Graph;
using Microsoft.Graph.Models;

public class ChangeNotificationManager
{
    private readonly GraphServiceClient _graphClient;
    private readonly Dictionary<string, Subscription> _activeSubscriptions;

    public ChangeNotificationManager(GraphServiceClient graphClient)
    {
        _graphClient = graphClient;
        _activeSubscriptions = new Dictionary<string, Subscription>();
    }

    public async Task<Subscription> CreateMonitoredSubscriptionAsync(
        string resource,
        string changeType,
        string notificationUrl,
        TimeSpan duration)
    {
        var subscription = new Subscription
        {
            ChangeType = changeType,
            NotificationUrl = notificationUrl,
            Resource = resource,
            ExpirationDateTime = DateTimeOffset.UtcNow.Add(duration),
            ClientState = GenerateClientState()
        };

        var created = await _graphClient.Subscriptions
            .PostAsync(subscription);

        _activeSubscriptions[created.Id] = created;

        // Start renewal timer
        StartRenewalTimer(created.Id, duration);

        return created;
    }

    private void StartRenewalTimer(string subscriptionId, TimeSpan duration)
    {
        // Renew when 80% of duration has elapsed
        var renewalTime = duration.Multiply(0.8);
        
        var timer = new Timer(async _ =>
        {
            await RenewSubscriptionAsync(subscriptionId);
        }, null, renewalTime, Timeout.InfiniteTimeSpan);
    }

    private async Task RenewSubscriptionAsync(string subscriptionId)
    {
        try
        {
            var update = new Subscription
            {
                ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(1)
            };

            var renewed = await _graphClient.Subscriptions[subscriptionId]
                .PatchAsync(update);

            _activeSubscriptions[subscriptionId] = renewed;

            Console.WriteLine($"Renewed subscription {subscriptionId}");

            // Restart renewal timer
            StartRenewalTimer(subscriptionId, TimeSpan.FromHours(1));
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Failed to renew subscription: {ex.Message}");
        }
    }

    public async Task<List<Subscription>> ListActiveSubscriptionsAsync()
    {
        var subscriptions = await _graphClient.Subscriptions
            .GetAsync();

        return subscriptions.Value.ToList();
    }

    public async Task CleanupExpiredSubscriptionsAsync()
    {
        var subscriptions = await _graphClient.Subscriptions
            .GetAsync();

        foreach (var subscription in subscriptions.Value)
        {
            if (subscription.ExpirationDateTime < DateTimeOffset.UtcNow)
            {
                try
                {
                    await _graphClient.Subscriptions[subscription.Id]
                        .DeleteAsync();
                    
                    Console.WriteLine($"Deleted expired subscription: {subscription.Id}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Failed to delete subscription: {ex.Message}");
                }
            }
        }
    }

    private string GenerateClientState()
    {
        return Convert.ToBase64String(
            Guid.NewGuid().ToByteArray()
        );
    }
}

// Usage
public class Program
{
    public static async Task Main(string[] args)
    {
        var graphClient = new GraphServiceClient(authProvider);
        var manager = new ChangeNotificationManager(graphClient);

        // Subscribe to new messages
        var messagesSub = await manager.CreateMonitoredSubscriptionAsync(
            resource: "me/messages",
            changeType: "created",
            notificationUrl: "https://myapp.com/api/notifications",
            duration: TimeSpan.FromHours(1)
        );

        Console.WriteLine($"Subscribed to messages: {messagesSub.Id}");

        // Subscribe to calendar events
        var eventsSub = await manager.CreateMonitoredSubscriptionAsync(
            resource: "me/events",
            changeType: "created,updated,deleted",
            notificationUrl: "https://myapp.com/api/notifications",
            duration: TimeSpan.FromHours(1)
        );

        Console.WriteLine($"Subscribed to events: {eventsSub.Id}");

        // List all active subscriptions
        var active = await manager.ListActiveSubscriptionsAsync();
        Console.WriteLine($"Active subscriptions: {active.Count}");
    }
}

Best Practices

Validate Client State

Always verify the client state in notifications to prevent unauthorized requests.

Implement Retry Logic

Your notification endpoint should return 202 Accepted quickly. Process notifications asynchronously.

Handle Missed Notifications

Notifications aren’t guaranteed. Implement periodic polling as a backup mechanism.

Renew Proactively

Renew subscriptions before they expire (e.g., at 80% of expiration time).

Secure Your Endpoint

Use HTTPS for notification URLs and validate the client state to ensure security.

Monitor Subscription Health

Track subscription status and handle failures gracefully with alerting and automatic recovery.

Troubleshooting

Subscription Validation Fails

Ensure your endpoint returns the validation token immediately:
[HttpPost]
public IActionResult Post([FromQuery] string validationToken)
{
    if (!string.IsNullOrEmpty(validationToken))
    {
        return Ok(validationToken); // Return token as plain text
    }
    
    // Process notifications...
}

Not Receiving Notifications

  1. Verify subscription is active and not expired
  2. Check that notification URL is publicly accessible
  3. Ensure client state matches
  4. Review your endpoint logs for errors
  5. Test endpoint responds with 202 within 30 seconds

Handling High Notification Volume

[HttpPost]
public IActionResult Post([FromBody] ChangeNotificationCollection notifications)
{
    // Queue for background processing
    foreach (var notification in notifications.Value)
    {
        _backgroundQueue.QueueNotification(notification);
    }

    // Return immediately
    return Accepted();
}

Next Steps

Build docs developers (and LLMs) love