I built a service that watches a few shared mailboxes and reacts when mail arrives from (or goes to) specific partner addresses. Classic Graph webhook territory: subscribe to the folder, get a notification per message, done.
Except a basic Graph notification is nearly empty. You get a message ID and not much else. To find out who sent it, you call Graph. And 99% of the mail in those mailboxes is noise, so I’d be making a GET per message just to conclude “don’t care” - burning through throttling budget to ignore things.
The fix is to make the notification itself carry enough data to decide. Graph calls this rich notifications, and combined with $select it turns the webhook payload into a pre-filter.
TL;DR
Create the subscription withincludeResourceData = true and a $select on the resource URL listing the fields your rule needs (sender, recipients, subject…). Graph then delivers those fields inside the notification, encrypted with a certificate you provide. Run your rule on the decrypted payload, and only call Graph for the body when a message actually matches. Zero API calls for the noise.The Subscription Is the Contract
Everything is decided at subscription time. The $select on the resource URL is what your notifications will contain, no more, no less:
var subscription = new Subscription
{
ChangeType = "created",
NotificationUrl = $"{baseUrl}/api/notifications",
LifecycleNotificationUrl = $"{baseUrl}/api/lifecycle",
Resource = $"/users/{mailbox}/mailFolders('{folder}')/messages" +
"?$select=id,subject,from,toRecipients,ccRecipients," +
"receivedDateTime,hasAttachments,importance,internetMessageId",
IncludeResourceData = true,
EncryptionCertificate = publicKeyBase64, // Graph encrypts the payload with this
EncryptionCertificateId = "notifications-2026",
ClientState = clientState,
ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(23)
};
await graph.Subscriptions.PostAsync(subscription);
What’s happening here?
IncludeResourceData = trueupgrades the subscription from “something changed” pings to payloads carrying the selected fields of the message itself.- The
$selectis the whole trick: addressing fields, subject, flags - everything my rule needs, and nothing heavy. Graph refuses$select=Bodyhere; the body can never travel in a notification. - Rich payloads arrive encrypted, so you hand Graph a public key at subscription time (a self-signed cert is fine) and keep the private key to decrypt. That’s a post of its own; here it’s one method call.
- I create one of these per mailbox for
inboxand one forsentitems, so both directions of the conversation are covered.
The Gate
When a notification lands (validated, decrypted - hand-waved here), the payload is just the message JSON with my selected fields. The decision runs entirely on it:
string json = decryptor.Decrypt(ec.Data, ec.DataKey, ec.DataSignature, cert);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
string? sender = GetAddress(root, "from");
var recipients = GetAddresses(root, "toRecipients")
.Concat(GetAddresses(root, "ccRecipients")).ToList();
if (!policy.ShouldReadBody(sender, recipients)) return; // 99% stop here. Free.
// Only a match earns a Graph call - and only for what the payload can't carry.
var full = await graph.Users[mailbox].Messages[messageId].GetAsync(rc =>
{
rc.QueryParameters.Select = new[] { "subject", "body" };
rc.Headers.Add("Prefer", "outlook.body-content-type=\"text\"");
});
The policy itself is boring on purpose: read the body if the sender or any recipient matches a configured address or domain. One rule works for both folders - on received mail the interesting address is the sender, on sent mail it’s a recipient, and checking all of them covers both without branching on folder.
That Prefer header saves a second round of work: Graph converts the body to plain text server-side, so a rule engine or an LLM downstream doesn’t choke on 40 KB of Outlook HTML.
What This Buys You
With basic notifications, a mailbox receiving 500 mails a day where 5 matter costs 500 GETs. With the payload gate it costs 5. The other 495 decisions happen in memory on data that was pushed to you anyway.
It also changes what a throttling incident looks like. Message fetches now correlate with interesting mail volume, not total mail volume, and interesting mail is what your business case already sized for.
Gotchas
- Rich subscriptions live under a day. Basic Outlook subscriptions get up to 7 days; set
includeResourceData = trueand the max drops to 1440 minutes. You need a renewal loop (a timedPATCHonexpirationDateTime) and alifecycleNotificationUrlfor the events that tell you when renewal isn’t enough. - The body is not an option.
$select=Body,UniqueBodyis explicitly rejected on the resource URL, along with$top,$skip,$orderbyand most$expand. The payload gate can never be “check if the body contains X” - design the rule around addressing fields and subject. - Answer the webhook fast, think later. Graph wants a quick 202; do the decrypt-gate-fetch work on a background queue, not in the request handler.
Mail.Readas an application permission is tenant-wide. Scope it down with an Exchange Application Access Policy so the app can only touch the mailboxes it watches. Do this before someone asks in a security review, not after.- New messages only. A subscription delivers changes from creation onward. For history, or after a
missedlifecycle event, reconcile with a delta query per folder.
Wrapping Up
Put your filter’s inputs in the subscription’s $select, and the notification becomes the filter. Fetch the full message only for the few that pass. When the data you need to decide is small and the data you need to act is big, let the webhook carry the first and pay an API call for the second only when it’s earned.
