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
- SharePoint posts a notification to an HTTP-triggered function.
- That function validates, drops a message on a storage queue, and returns
200immediately. - A queue-triggered function picks up the message, calls
GetChangesfrom 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
}
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);
}
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
- Validate
clientStateon every notification to reject spoofed calls — it's the shared secret you set at registration. - Secure app-only auth with a certificate-based Azure AD app (not a client secret in code); store credentials in Key Vault.
- Idempotency — design processing so replays/retries don't double-apply.
- Dead-letter handling — Azure Storage queues move poison messages aside automatically after retries; monitor that queue.
- Renewal monitoring — alert if the renewal job fails.
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.