Guide
Microsoft Graph Delta Query Explained
Microsoft Graph delta query is the API that turns repeated full mailbox reads into incremental sync: each response carries a cursor, and the final one carries a deltaLink you persist and replay to fetch only what changed. This reference explains the skipToken paging cursor, the @odata.deltaLink, the 410 Gone resync, and how the Nylas CLI removes the raw cursor handling.
Written by Pouya Sanooei Software Engineer
Reviewed by Qasim Muhammad
Command references used in this guide: nylas email list, nylas webhook create, and nylas auth status.
What is Microsoft Graph delta query?
Microsoft Graph delta query is the API that tracks changes to a mailbox so you fetch only what moved since your last read. Instead of pulling every message each cycle, you call the delta function on a folder once, then replay a saved cursor on each later sync. Graph returns added, updated, and deleted items, keeping local state in line with the server.
The difference matters at scale. A mailbox with 50,000 messages costs many paged reads on a full sync; a delta sync of the same mailbox five minutes later might return a single page of 3 changed items. Microsoft documents the pattern across resources in the Graph delta query overview, and the mail-specific behavior in the delta query for messages reference. Delta query is read-only state tracking, not a push channel: you poll on your own schedule, and each poll is cheap because the server already knows your last position.
# First delta call against the Inbox folder (Microsoft Graph REST)
GET https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta
Authorization: Bearer <access-token>
# Response includes a page of items plus a cursor link:
# "@odata.nextLink": ".../delta?$skiptoken=..." (more pages now)
# "@odata.deltaLink": ".../delta?$deltatoken=..." (saved for next sync)How does the skipToken and deltaLink cursor work?
The cursor is a token Graph hands back inside a URL, and there are two kinds. A skipToken arrives in @odata.nextLink and means “more pages exist right now”; a deltaToken arrives in @odata.deltaLink on the final page and means “you are caught up, save this and come back later.” You never build these URLs yourself; you follow them verbatim.
The loop is deterministic. Keep following @odata.nextLink while it appears, paging through the current backlog. Graph does not fix the page size; you control it with the Prefer: odata.maxpagesize request header, so a large backlog pages through several rounds. When Graph stops returning nextLink and returns @odata.deltaLink instead, the round is finished. You persist that single deltaLink string — not the items, just the cursor — and on the next cycle you issue a GET against it. That second call returns only messages added, changed, or removed since the deltaLink was minted, which is why a busy mailbox still syncs in one short page after the initial backfill.
# Page through "now", then store the deltaLink for "later"
cursor="https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta"
while [ -n "$cursor" ]; do
resp=$(curl -s -H "Authorization: Bearer $TOKEN" "$cursor")
echo "$resp" | jq -c '.value[]' # process this page
cursor=$(echo "$resp" | jq -r '."@odata.nextLink" // empty')
done
# Last response carried @odata.deltaLink — persist it as the next start point
echo "$resp" | jq -r '."@odata.deltaLink"' > .graph_delta_cursorWhy must you persist the @odata.deltaLink?
You persist the @odata.deltaLink because it is the only thing that lets the next sync start where the last one stopped. The deltaLink encodes the server-side change-tracking position for that folder. Lose it and Graph has no way to know what you have already seen, so your only option is a full resync of the entire folder — the exact cost delta query exists to avoid.
Store the cursor in durable state, keyed by folder, the moment a round completes. A process restart, a deploy, or a crash mid-loop must not drop it; an in-memory variable is not enough for a job that runs every 5 minutes for weeks. Treat the deltaLink as opaque: do not parse it, trim it, or URL-decode it, because the $deltatoken inside is Microsoft's to format. One stored string per folder is the entire persistence model, and a single row in a database or a single file on disk is sufficient.
# Durable cursor state, one row per (mailbox, folder)
# mailbox folder delta_link
# user@contoso.com inbox https://graph.microsoft.com/v1.0/.../delta?$deltatoken=AbC...
# Next sync reads the saved cursor and asks Graph "what changed since this?"
cursor=$(cat .graph_delta_cursor)
curl -s -H "Authorization: Bearer $TOKEN" "$cursor" | jq '.value | length'
# => 3 (only the items that moved since the cursor was minted)What is the 410 Gone resync in delta query?
A 410 Gone with the error code syncStateNotFound means your saved cursor has expired and Graph can no longer replay changes from it. Delta cursors are not permanent; if a folder sits unsynced too long, the server discards the tracking state. The required recovery is to discard your stored deltaLink and start a fresh delta cycle from the base delta URL.
Handle the 410 by reconciling, not by trusting your local copy. The fresh delta returns the folder's current state as a series of pages; you compare it against what you have and delete anything locally that the resync did not include, because items removed during the gap will not appear as tombstones. The other status worth catching is the same throttling code every Graph endpoint can return — a 429 TooManyRequests carries a Retry-After header you must honor, documented in the Graph throttling guidance. The delta loop tracks position with OData $deltatoken and $skiptoken cursors — not HTTP entity tags — so the cursor string, not an ETag, is what you persist between runs.
# 410 Gone => cursor expired; drop it and re-seed from the base delta URL
# HTTP/1.1 410 Gone
# { "error": { "code": "syncStateNotFound", "message": "..." } }
#
# Pseudocode:
# resp = GET(saved_delta_link)
# if resp.status == 410:
# saved_delta_link = null
# full_resync(base_delta_url) # then reconcile local state
# elif resp.status == 429:
# sleep(resp.headers["Retry-After"]); retry()How does the Nylas CLI remove raw cursor handling?
The Nylas CLI removes delta-cursor handling because the sync engine owns the deltaLink, the paging loop, and the 410 reset on the server side. You never store a $deltatoken or parse an @odata.nextLink; the tool returns normalized messages, and the same commands work on Gmail and IMAP backends that have no delta query at all. One command stands in for the entire cursor state machine described above.
Two patterns cover most needs. For a scheduled pull, nylas email list auto-paginates the way a delta loop does, and nylas auth status confirms the grant is healthy before a long job. For change-driven sync, register a webhook once and receive a message.created or message.updated callback within seconds of the event — the push equivalent of replaying a deltaLink, without a polling timer or an expired-cursor failure mode. Microsoft's own push channel, documented in the Graph change notifications overview, requires a public HTTPS endpoint and subscription renewal that the CLI also handles for you.
# Scheduled incremental pull — no skipToken, no deltaLink to persist
nylas auth status
nylas email list --json --limit 50 | jq '.[] | {id, subject, unread}'
# Or go push-based: one webhook replaces the whole poll-and-cursor loop
nylas webhook create \
--url https://example.com/graph-sync \
--triggers message.created,message.updated \
--description "Incremental mailbox sync"Next steps
- Microsoft Graph Batch Requests Explained — How Microsoft Graph $batch packs 20 requests into one call
- Microsoft Graph error codes — what 401, 403, 429, and 503 mean and how to fix them
- Add email sync without IMAP — provider-native sync over a single CLI
- IMAP IDLE explained — the push model for protocols without delta query
- Load email into Snowflake — pipe incremental mailbox changes into a warehouse
- Create GitHub issues from email — act on each new message with a webhook
- Full command reference — every flag and subcommand documented