How-to
Automating Reporting WorkflowsDeep dive

Run a Report When an Excel File Changes in OneDrive

Trigger a report as soon as someone saves a workbook in OneDrive or SharePoint, instead of on a fixed schedule: poll the file's cTag cheaply, use the Graph delta query to find every changed workbook in a folder, or receive webhook notifications and answer Microsoft's validation handshake — with debouncing so a burst of autosaves runs the report once, and the code tested against mocks.

A scheduled job that rebuilds a report every hour does a lot of work for nothing, and is still up to an hour late when the input changes. When the input is a workbook in OneDrive or SharePoint — a sales forecast someone updates, a price list that finance maintains — it is better to react to the change itself. Microsoft Graph offers three ways to notice it, from simplest to most immediate: poll the file's content tag, ask the delta endpoint what changed in a folder since last time, or subscribe to webhook notifications. All three need the same extra care, because Excel autosaves: one editing session can produce a dozen saves, and the report should run once, after the editing stops. This page covers each approach and the debounce. It belongs to Automating Excel Online with Microsoft Graph.

Prerequisites

Bash
pip install requests msal flask responses

A Graph access token with Files.Read (delegated) or Files.Read.All / Sites.Read.All (application) is needed; see Authenticate to Microsoft Graph with MSAL for Excel Automation. Live calls are replaced by the responses mocking library in the examples, and the webhook receiver is exercised with Flask's test client, so everything runs offline.

Step 1 — Poll the file's cTag

The lightest approach is a scheduled poll that only does real work when the file has changed. Every drive item has two version tags: eTag changes on any change, including a rename or a sharing update, while cTag changes only when the content changes. For triggering a report, cTag is the one to watch:

Python
import json
import pathlib
import requests

GRAPH = "https://graph.microsoft.com/v1.0"
STATE = pathlib.Path("last_ctag.json")

def content_changed(token, item_path):
    url = f"{GRAPH}/me/drive/root:/{item_path}?$select=id,name,cTag,lastModifiedDateTime"
    item = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30).json()
    previous = json.loads(STATE.read_text()) if STATE.exists() else {}
    changed = previous.get(item["id"]) != item["cTag"]
    previous[item["id"]] = item["cTag"]
    STATE.write_text(json.dumps(previous))
    return changed, item

The request selects only the fields it needs, so each poll is a tiny response. The last seen cTag per item is stored in a small JSON file; the function reports a change when the tag differs. Run it every few minutes from cron or a scheduler — see Choose a Scheduler for Python Excel Reports — and only rebuild the report when it returns True. Polling one file every five minutes is 288 cheap requests a day, far below any throttling limit.

Python
import responses

with responses.RequestsMock() as mock:
    url = f"{GRAPH}/me/drive/root:/Reports/forecast.xlsx?$select=id,name,cTag,lastModifiedDateTime"
    for ctag in ("{ABC},5", "{ABC},5", "{ABC},6"):
        mock.get(url, json={"id": "ITEM1", "name": "forecast.xlsx", "cTag": f'"c:{ctag}"',
                            "lastModifiedDateTime": "2026-10-02T08:15:00Z"})
    STATE.unlink(missing_ok=True)
    for run in range(3):
        changed, item = content_changed("token", "Reports/forecast.xlsx")
        print(f"poll {run + 1}: changed={changed} cTag={item['cTag']}")

The first poll always counts as a change (nothing was stored yet), the second sees the same tag and does nothing, and the third sees a new tag and triggers the report.

eTag and cTag on a drive item The eTag changes on any change to the item, including renames, moves and sharing changes, so it can trigger reports needlessly. The cTag changes only when the file content changes, which is what a report cares about. eTag changes on any update rename, move, sharing, content triggers reports needlessly cTag changes only with content ignores renames and sharing right trigger for a report

Step 2 — Watch a whole folder with the delta query

When the inputs are many workbooks in a folder, polling each one is wasteful. The delta endpoint returns everything that changed since your last call, and a deltaLink to use next time:

Python
def changed_workbooks(token, folder_id, delta_link=None):
    url = delta_link or f"{GRAPH}/me/drive/items/{folder_id}/delta?$select=id,name,file,deleted,cTag"
    changed = []
    while url:
        page = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30).json()
        for item in page.get("value", []):
            if "deleted" in item or not item.get("name", "").lower().endswith(".xlsx"):
                continue
            changed.append(item["name"])
        url = page.get("@odata.nextLink")
        delta_link = page.get("@odata.deltaLink", delta_link)
    return changed, delta_link

FOLDER = "FOLDER1"
first_url = f"{GRAPH}/me/drive/items/{FOLDER}/delta?$select=id,name,file,deleted,cTag"
with responses.RequestsMock() as mock:
    mock.get(first_url, json={"value": [{"id": "1", "name": "north.xlsx", "file": {}},
                                        {"id": "2", "name": "notes.txt", "file": {}}],
                              "@odata.nextLink": f"{GRAPH}/delta-page-2"})
    mock.get(f"{GRAPH}/delta-page-2", json={"value": [{"id": "3", "name": "south.xlsx", "file": {}}],
                                           "@odata.deltaLink": f"{GRAPH}/delta?token=xyz"})
    mock.get(f"{GRAPH}/delta?token=xyz", json={"value": [{"id": "3", "name": "south.xlsx", "file": {}}],
                                               "@odata.deltaLink": f"{GRAPH}/delta?token=xyz2"})
    names, link = changed_workbooks("token", FOLDER)
    print("first sync:", names, "| next link:", link)
    names, link = changed_workbooks("token", FOLDER, link)
    print("second sync:", names, "| next link:", link)

The first call enumerates the folder's current contents across as many pages as needed (@odata.nextLink) and ends with a @odata.deltaLink; store that link, and the next call returns only what changed since — here just south.xlsx. Deleted items and non-Excel files are skipped. Keep the delta link somewhere durable (a file, a database row); if it is lost, start again with the initial call.

How the delta query keeps a folder in sync The first delta call returns all items over several pages and ends with a deltaLink. The script stores the link. On the next run it calls the deltaLink and receives only items changed since, plus a new deltaLink. Report job Graph delta Link store 1. GET …/delta (first sync) 2. all items + nextLink pages 3. save deltaLink 4. GET deltaLink (next run) 5. only changed items + new deltaLink

Step 3 — Get notified with a webhook

For near-immediate reactions, create a subscription and Graph will POST to your endpoint when the drive changes. Your service must answer a validation handshake: when the subscription is created, Graph calls the URL with a validationToken query parameter, and the endpoint must return that token as plain text within ten seconds:

Python
from flask import Flask, request

app = Flask(__name__)
pending = []

@app.post("/graph-notifications")
def notifications():
    token = request.args.get("validationToken")
    if token:
        return token, 200, {"Content-Type": "text/plain"}
    for note in request.get_json().get("value", []):
        if note.get("clientState") == "expected-secret":
            pending.append(note["resource"])
    return "", 202

client = app.test_client()
r = client.post("/graph-notifications?validationToken=abc123")
print("validation:", r.status_code, r.data.decode())
r = client.post("/graph-notifications", json={"value": [
    {"subscriptionId": "s1", "clientState": "expected-secret", "resource": "me/drive/root", "changeType": "updated"},
    {"subscriptionId": "s2", "clientState": "wrong", "resource": "me/drive/root", "changeType": "updated"}]})
print("notification:", r.status_code, pending)

The handler echoes the validation token, then accepts notifications with 202 — quickly, because Graph expects an answer within a few seconds and retries otherwise. The clientState value, a secret you choose when creating the subscription, is checked to reject forged calls; the second notification is ignored. Notifications for drive items say only that something changed, not what — the usual pattern is to run the delta query from Step 2 when a notification arrives. The subscription itself is created with a POST to /subscriptions (resource /me/drive/root, change type updated, your notificationUrl and clientState, and an expiration date) and must be renewed before it expires; drive subscriptions last at most about 29 days, so a daily renewal job is a safe habit.

Step 4 — Debounce the autosaves

Excel for the web saves continuously while someone types. Each save changes the cTag and can fire a notification. Wait until the file has been quiet for a while before running the report:

Python
import datetime as dt

QUIET = dt.timedelta(minutes=5)

def ready_to_run(last_modified_iso, now):
    last_modified = dt.datetime.fromisoformat(last_modified_iso.replace("Z", "+00:00"))
    return now - last_modified >= QUIET

now = dt.datetime(2026, 10, 2, 8, 18, tzinfo=dt.timezone.utc)
for stamp in ("2026-10-02T08:15:00Z", "2026-10-02T08:10:00Z"):
    print(stamp, "→ run now" if ready_to_run(stamp, now) else "→ wait, still being edited")

The poll or notification records that a change happened; the job runs only when lastModifiedDateTime is at least five minutes old. A save at 08:15 means "still editing" at 08:18; one at 08:10 is quiet enough to run. Pair this with a lock so two triggers cannot start the report at the same time, as in Prevent Overlapping Runs of an Excel Report Job.

One editing session, one report run Saves at 08:02, 08:05, 08:09 and 08:10 each change the file. The job waits until five minutes have passed since the last save and runs the report once at 08:15. 08:02 save change noted 08:05 save still editing 08:09 save still editing 08:10 save last change 08:15 quiet 5 min → run once

Step 5 — Choosing between the three

  • Polling the cTag needs no public endpoint and no subscription maintenance; a five-minute delay is usually acceptable. Best for one or a few files.
  • The delta query scales to folders and whole drives with one request per check, and is the right follow-up to any notification.
  • Webhooks react within seconds but need a public HTTPS endpoint, validation handling, clientState checks and subscription renewal — worth it when minutes matter.

Many teams combine them: a webhook for speed, the delta query to find out what changed, and a daily delta-based sweep as a safety net in case a notification is lost.

Common pitfalls

  • Watching the eTag. Renames and sharing changes trigger reports; use the cTag for content.
  • Running on every autosave. Debounce on lastModifiedDateTime.
  • Losing the delta link. Without it, the next call re-enumerates everything; store it durably.
  • Slow webhook responses. Answer validation and notifications within seconds; do the work in a background job.
  • Expired subscriptions. Drive subscriptions expire within about a month; renew them on a schedule.

Conclusion

To run a report when a workbook in OneDrive or SharePoint changes, poll the file's cTag for a single file, use the Graph delta query and its deltaLink for folders, or subscribe to webhooks for immediate notifications — answering the validationToken handshake, checking clientState, and renewing subscriptions. Whichever trigger you use, debounce on the last-modified time so a burst of autosaves produces one report run.

Frequently asked questions

How do I detect when an Excel file in OneDrive has changed? Read the drive item's cTag through Microsoft Graph and compare it with the value from your last check. The cTag changes only when the file content changes, unlike the eTag, which also changes on renames and sharing updates.

What is the Microsoft Graph delta query? An endpoint on a drive or folder that returns all items on the first call and then, through the deltaLink it hands back, only the items that changed since the previous call. It is the efficient way to watch many files.

How do Microsoft Graph webhooks validate my endpoint? When you create a subscription, Graph sends a request with a validationToken query parameter, and your endpoint must return that token as plain text with status 200 within ten seconds.

How do I avoid running a report many times while someone is still editing? Wait until the file's lastModifiedDateTime is a few minutes old before running, so a series of autosaves leads to a single run, and use a lock to stop two triggers running at once.

More on excel online & microsoft graph

More on excel online & microsoft graph →