Notion's 2026-04-01 API version shipped two changes that don't look dangerous in a release note and are very dangerous in a long-running integration:
- Pagination cursors became opaque base64 strings.
- The rate-limit reset value changed from a Unix timestamp to a delta in seconds.
Neither produces a schema error. Neither shows up in a diff of your request/response types. Both break code that was correct yesterday, and both fail in the direction that's hardest to notice: silent state corruption and silent loss of backoff.
Change 1: pagination cursors are now opaque base64
Historically, Notion start_cursor values were UUIDs. As of 2026-04-01 they're opaque base64-encoded strings. The compatibility rule is asymmetric, and the asymmetry is the trap:
- Old-format cursors (UUIDs) still work on 2026-04-01. Forward compatibility is fine.
-
New-format (base64) cursors do not work on older API versions. A cursor minted by a 2026-04-01 caller is rejected (400
validation_error) if it's replayed against, say,2025-09-03.
If you paginate in a single request loop on one pinned version, you'll never notice — you mint and consume the cursor in the same context. The failure shows up when a cursor outlives the request that created it:
-
Checkpointed / resumable syncs. A long workspace export persists
next_cursorto a database or queue so it can resume after a crash or across a job boundary. The job that wrote the cursor was on 2026-04-01; the worker that resumes is still pinned to an older version (different service, different deploy cadence). The resume call 400s. Depending on how your retry logic treats a 400, you either hard-fail the sync or — worse — treat "invalid cursor" as "end of results" and silently truncate the export. - Mixed-version fleets. Ingestion service A is upgraded to 2026-04-01; reconciliation worker B still pins the old version. They share a cursor store. Every cursor A writes is poison to B. Nothing logs a version mismatch — it's just a 400 that looks like a transient Notion error.
- Cursor caches keyed by query. Some integrations cache "where did I leave off for this database" by query hash. After the partial upgrade, cached cursors are a coin flip on whether they parse.
The reason this is easy to miss in review: the code that reads the cursor is unchanged and correct. The bug is the combination of a persisted cursor and a version skew between writer and reader. There's no single line you can point at.
Mitigations:
- Do not upgrade your
Notion-Versionheader mid-sync if you persist cursors. Drain in-flight syncs on the old version first, or reset sync state entirely on cutover. - Treat cursors as version-scoped. If you persist a cursor, persist the
Notion-Versionthat minted it next to it, and refuse to replay a cursor under a different version — fail loudly with a clear message instead of letting Notion return an ambiguous 400. - On a forced cursor invalidation, restart that pagination from the beginning. Make sure your code distinguishes "invalid cursor, restart" from "no more pages, done." Conflating the two silently truncates data.
Change 2: RateLimit-Reset is now a seconds delta, not a Unix timestamp
Before 2026-04-01, the reset value Notion returned was a Unix timestamp — the wall-clock time the token bucket refills. As of 2026-04-01 it's a delta in seconds — how many seconds from now until refill.
Almost every backoff helper written against the old behavior looks like this:
reset = int(resp.headers["x-ratelimit-reset"])
sleep_for = reset - time.time() # was: timestamp - now = seconds to wait
if sleep_for > 0:
time.sleep(sleep_for)
Run that against the new format, where reset is now 30 (meaning "30 seconds"):
sleep_for = 30 - 1_775_000_000 ≈ -1.77e9
sleep_for is hugely negative. The if sleep_for > 0 guard fails, the sleep is skipped entirely, and the client immediately retries the request that was just rate-limited. You haven't slowed down — you've removed all backoff at the exact moment Notion told you to back off. The result is a retry storm that escalates you from soft 429s into longer enforced cooldowns or integration-level throttling.
The mirror-image bug is just as quiet: code that interprets the new small number as an absolute timestamp computes a "reset" in 1970 and concludes the bucket is already available — same effect, no wait.
There is no exception, no log line, no failed assertion. Throughput even looks better for a few minutes because you stopped sleeping. Then Notion clamps down and the integration's latency falls off a cliff with no obvious cause, because the cause was a header value that changed type, not shape.
Mitigations:
- Treat the reset value as a duration, not a timestamp:
sleep_for = float(resp.headers["x-ratelimit-reset"]), clamped to a sane max, and honorRetry-Afterwhen present. - Add an upper bound and a lower bound to any computed sleep. A correct backoff should never compute a negative wait or a multi-year wait; if it does, that's a format-mismatch signal — alert on it instead of clamping silently.
- If you can, pin and assert: log the raw header on the first rate-limited response after a version bump and eyeball whether it's ~10^9 (timestamp) or ~10^1–10^2 (delta).
Why these two belong in the same checklist
Both changes share a signature we see constantly on review: the response is structurally identical, but a value's encoding or meaning changed. Your types still compile. Your mocks still pass — a mock returns whatever cursor or reset value you hard-coded, so the test never sees the new format. Your happy-path manual test passes, because it runs on one version in one process and never persists a cursor or hits a 429.
The breakage only appears with real state and real load: a cursor that crosses a version boundary, or a rate-limit response under contention. That's production, not CI.
Migration checklist
- Audit every place a Notion cursor is persisted (DB, queue, cache, checkpoint file). For each, store the minting
Notion-Versionalongside it and reject cross-version replay explicitly. - Upgrade all services that share a cursor store in the same change, or don't share the store across versions. A partial fleet upgrade is the failure mode.
- Ensure your pagination loop distinguishes "invalid/expired cursor → restart" from "no next_cursor → done." Never let a 400 silently end a sync.
- Change rate-limit backoff to treat the reset value as a seconds delta. Add min/max bounds and alert (don't clamp) when a computed sleep is negative or absurdly large.
- Add an integration test that exercises a real (small) paginated query and a forced 429 against the live API on 2026-04-01 — not a mock — before you flip the version in production.
Both of these are date-versioned, so nothing breaks until you bump Notion-Version. That's also the trap: the bump is a one-line change that passes review, passes CI, and detonates later in a resume path or under load, far from the diff that caused it.
FlareCanary watches your third-party APIs and SDKs for breaking changes like these — including value-encoding changes that keep the schema identical while silently corrupting state — and surfaces them before they hit production. Free tier monitors 5 endpoints.










