Webhooks are the reliable way to react to SharePoint list changes — but only if you build the receiver correctly. SharePoint gives you about five seconds to acknowledge a notification, retries if you fail, and expects you to figure out what changed yourself. The proven pattern is a two-stage architecture: a fast HTTP receiver that just queues, and a separate worker that does the real work. Azure Functions are a perfect fit for both.

The architecture

  1. SharePoint posts a notification to an HTTP-triggered function.
  2. That function validates, drops a message on a storage queue, and returns 200 immediately.
  3. A queue-triggered function picks up the message, calls GetChanges from the last change token, and processes exactly what changed.

This separation is the whole game: the receiver stays under the timeout no matter how slow the downstream work is, and the queue gives you retries, buffering, and parallelism for free.

Stage 1: the validation handshake

When you register a subscription, SharePoint immediately calls your URL with a validationtoken query string. You must echo it back as text/plain within 5 seconds or the subscription is rejected. The same function also handles real notifications:

[FunctionName("SPWebhook")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
    [Queue("sp-changes")] IAsyncCollector<string> queue,
    ILogger log)
{
    // 1. Validation handshake
    string validationToken = req.Query["validationtoken"];
    if (!string.IsNullOrEmpty(validationToken))
    {
        return new ContentResult {
            Content = validationToken,
            ContentType = "text/plain",
            StatusCode = 200
        };
    }

    // 2. Real notification — queue and return fast
    using var reader = new StreamReader(req.Body);
    string body = await reader.ReadToEndAsync();
    var payload = JsonConvert.DeserializeObject<NotificationList>(body);

    foreach (var n in payload.Value)
        await queue.AddAsync(JsonConvert.SerializeObject(n));

    return new OkResult();   // ack within seconds
}
Never do real work in the HTTP receiver. The notification payload tells you only the list and subscription id — not what changed. If you try to call back into SharePoint synchronously here, a slow tenant pushes you past the 5-second window, SharePoint marks the delivery failed, retries, and eventually disables your subscription after repeated failures.

Stage 2: resolving the changes

The notification says "list X changed." To learn what changed you keep a change token per list (in table storage, a database, or a config list) and call GetChanges from that token. PnP Core / CSOM makes this clean:

[FunctionName("ProcessChange")]
public static async Task Run(
    [QueueTrigger("sp-changes")] string message, ILogger log)
{
    var notification = JsonConvert.DeserializeObject<Notification>(message);

    using var ctx = GetAppOnlyContext(notification.SiteUrl);
    var list = ctx.Web.Lists.GetById(new Guid(notification.Resource));

    // Load the last token we stored for this list
    string lastToken = await LoadChangeToken(notification.Resource);

    var query = new ChangeQuery(false, true) { Item = true, Add = true, Update = true };
    if (lastToken != null)
        query.ChangeTokenStart = new ChangeToken { StringValue = lastToken };

    var changes = list.GetChanges(query);
    ctx.Load(changes);
    ctx.ExecuteQuery();

    foreach (ChangeItem change in changes.OfType<ChangeItem>())
    {
        // idempotent processing of change.ItemId …
        log.LogInformation($"Item {change.ItemId} {change.ChangeType}");
    }

    // Persist the new token for next time
    if (changes.Count > 0)
        await SaveChangeToken(notification.Resource,
            changes.LastChangeToken.StringValue);
}
The change token is the heart of reliability. Storing it means that even if several edits batch into one notification — or a delivery is retried — you process the exact delta since you last succeeded, with no gaps and no double-processing. Make your handler idempotent so a replayed message is harmless.

Subscription management

Webhook subscriptions expire after a maximum of 180 days and must be renewed before they lapse. Register with a PATCH to extend the expiry, and run a timer function to keep them alive:

# Register a subscription (REST)
POST .../_api/web/lists('list-guid')/subscriptions
{
  "resource": "https://contoso.sharepoint.com/_api/web/lists('list-guid')",
  "notificationUrl": "https://contoso-fn.azurewebsites.net/api/SPWebhook?code=…",
  "expirationDateTime": "2021-01-24T00:00:00+00:00",
  "clientState": "shared-secret"
}

# Renew (PATCH) before it expires
PATCH .../_api/web/lists('list-guid')/subscriptions('sub-id')
{ "expirationDateTime": "2021-07-24T00:00:00+00:00" }

A daily timer-triggered function that lists subscriptions and renews any nearing expiry is essential — a lapsed subscription silently stops delivering, and you won't notice until someone asks why the integration "stopped working."

Hardening checklist

Why this scales

Because the receiver only queues, it can absorb bursts without ever timing out, and the queue-triggered worker scales out across messages automatically. Add more lists, more sites, more events, and the same two functions handle it — the queue smooths the load. This pattern has been the backbone of reliable SharePoint event processing for years, and it maps almost one-to-one onto the Microsoft Graph change-notification model when you're ready to move up the stack.

DS

Dhaval Shah

Cloud Solution Architect — 18+ years across SharePoint, Microsoft 365, and Azure AI.