Run a Report When an Excel File Changes in OneDrive
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
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:
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.
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.
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:
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.
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:
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:
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.
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,
clientStatechecks 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
cTagfor 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.
Related
- Up one level: Automating Excel Online with Microsoft Graph — working with cloud workbooks.
- Read an Excel File from SharePoint or OneDrive into pandas — reading the changed file.
- Watch a Folder and Process New Excel Files — the same idea for local folders.
- Add a Chart to an Excel Online Workbook with Microsoft Graph — updating the workbook after the report runs.
More on excel online & microsoft graph
- How-toSend an Excel Report with the Microsoft Graph API
- How-toAdd a Chart to an Excel Online Workbook with Microsoft Graph
- How-toAdd, Rename and Delete Worksheets with Microsoft Graph
- How-toAppend Rows to an Excel Online Table with Microsoft Graph
- How-toAuthenticate to Microsoft Graph with MSAL for Excel Automation
- How-toCall Excel Worksheet Functions Through Microsoft Graph