BlueByte
HTTP 429Fixed

Notion API returns 429 rate_limited on bulk writes

By Haneul SeoUpdated August 30, 20263 min

Hi, it's BlueByte. If a script that creates or updates a lot of Notion pages has started failing with 429, you are not doing anything wrong — you are just sending faster than Notion will accept. Let's read what it is telling you, then slow the client down in three small ways so the errors stop.

What a 429 from Notion means

Your bulk job fails like this:

{ "object": "error", "status": 429, "code": "rate_limited",
  "message": "You have been rate limited." }

Under heavy load you may also see 529 with code service_overload. For your code they mean the same thing: back off. This is purely about pace — a bad payload or token returns different errors, so you do not need to touch either.

Why bulk writes cross the limit so fast

Notion limits each integration to an average of three requests per second, with short bursts allowed above that. The limit is per connection (integration token), so requests you fire in parallel from one token share a single budget — parallelism does not buy throughput, it reaches the ceiling faster. A tight loop of page creates, or a Promise.all over hundreds of items, trips it almost immediately.

Read the response before you change anything

Do not guess which limit you hit — the response tells you. A 429 carries a Retry-After header (seconds to wait) and an additional_data.rate_limit_reason field:

curl -si -X POST https://api.notion.com/v1/pages \
  -H "Authorization: Bearer $NOTION_TOKEN" \
  -H "Notion-Version: 2022-06-28" -H "Content-Type: application/json" \
  -d @page.json | grep -i "retry-after\|rate_limit_reason"

If Retry-After is present, you are being throttled — not blocked by a bad token (401) or payload (400). If you never see it, log the full body; a validation_error there means the payload, not the rate.

Honor Retry-After, then throttle and batch

Three changes, cheapest first.

  1. Respect Retry-After. On a 429, sleep exactly that long before retrying — never retry immediately.
async function notionFetch(url, options, attempt = 0) {
  const res = await fetch(url, options);
  if (res.status === 429 && attempt < 5) {
    const retryAfter = Number(res.headers.get("retry-after") ?? 1);
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
    return notionFetch(url, options, attempt + 1);
  }
  return res;
}
  1. Throttle below the limit. Send serially with a ~350 ms gap instead of in parallel; staying under three per second means you rarely see a 429 at all.

  2. Batch the work. One request can append up to 100 block children within a 500 KB payload — group blocks into one call instead of one request per block.

A real case: importing 1,000 rows

Say you import 1,000 rows, each a page with ten blocks. Done naively that is 1,000 creates plus thousands of appends, and it 429s in seconds. Restructured, you create each page with its ten blocks in one request (well under the 100-block ceiling) and send those creates serially ~350 ms apart. The job now runs at roughly three pages per second with no 429s, and if one slips through, the Retry-After handler waits and continues instead of failing the import.

Confirm the job runs clean

Run it and watch the status codes. With throttling and Retry-After in place you should see a stream of 200s and, at most, the occasional 429 that recovers on its own — no un-retried 429s and no 529s.

How this differs from a payload or auth error

A 400 validation_error is a payload problem (too many blocks, a field over its limit) and retrying will not help. A 401 unauthorized is a token or sharing problem. Only 429/529 are about pace, and only those carry Retry-After. Put the throttle and retry in one shared client so no future code path can bypass it, and the next bulk job just works.

Related questions

Is the limit per integration or per workspace?

Both. The three-requests-per-second average is per connection; there is also a separate workspace-wide limit shared across all connections, scaled to the workspace's plan.

There is no Retry-After header on my 429. What delay should I use?

That is rare, but fall back to a short exponential backoff — for example 1s, 2s, 4s — capped at a handful of attempts.

Does retrying in a tight loop make it worse?

Yes. Retrying a rate-limited request immediately adds load and can escalate to a 529 service_overload. Always wait for the header's delay and cap the attempts.

How many blocks can one request carry?

Up to 100 block children, within a 500 KB payload. Batching to that ceiling is the single biggest way to cut your request count.

Will using multiple integration tokens get me more throughput?

It sidesteps the per-connection limit but not the workspace-wide one, and it complicates auth and auditing. Throttling and batching one integration is the simpler, supported path.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

5.7.26Fixed

Gmail rejects your mail with 550-5.7.26: unauthenticated email is not accepted due to the domain's DMARC policy

Gmail enforced the DMARC policy your own domain publishes: the message failed both SPF and DKIM alignment against the header From: domain, so a p=quarantine or p=reject policy turned it into a hard bounce. The Authentication-Results header names the failing check, and three dig queries against your SPF, DMARC and DKIM records name the cause. A near-identical bounce about authenticating with SPF or DKIM is a different problem — Gmail's baseline sender requirements, not your policy.

Gmail / Google Workspace
AADSTS50011Fixed

Microsoft Entra ID: AADSTS50011, the redirect URI specified in the request does not match the redirect URIs configured for the application

Sign-in completes and then Entra ID refuses the last hop, because the redirect_uri your app sent is not a byte-for-byte match for any URI registered on the app. The match is case-sensitive, counts the trailing slash, requires https outside localhost, and treats the port as significant everywhere except localhost. It also splits by platform: web, spa and publicClient are three separate lists, and URIs added to the service principal instead of the application object can disappear. Read the URI and the app ID out of the error, compare them with az ad app show, add the exact string with az ad app update or a Graph PATCH, then wait three to five minutes.

Microsoft Entra ID
535 5.7.139Workaround

Exchange Online: 535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled

A scanner, script or app that sends through smtp.office365.com gets 535 5.7.139 because the SMTP AUTH protocol is switched off for the tenant, for that mailbox, or by an authentication policy or security defaults that block Basic authentication. Read the wording (Tenant, Mailbox, or 'did not meet the criteria'), confirm with Get-TransportConfig, Get-CASMailbox and Get-AuthenticationPolicy, then open SMTP AUTH on the one mailbox that needs it rather than tenant-wide. Treat Basic SMTP AUTH as a bridge: Microsoft disables it by default for existing tenants at the end of December 2026, so move the sender to OAuth, High Volume Email or a relay connector.

Exchange Online (Microsoft 365)
Error code: 5003Fixed

Zoom: "Unable to connect" error code 5003 — the desktop app can't reach Zoom while the browser can

Error 5003 is the Zoom desktop app failing to complete its connection to Zoom's servers while the web client on the same machine joins fine. The app needs more than a browser does: Zoom's firewall article lists TCP 443/8801/8802 and UDP 3478/3479/8801–8810 for meetings, a set of CA hosts for certificate validation, and it asks that zoom.us and *.zoom.us be exempted from proxy or SSL inspection. A port test, a curl issuer check, and the app's built-in Network Connectivity Tool (Ctrl+Alt+Shift+D / Cmd+Option+Shift+D) show which of those is cut; fix that layer, and reinstall only when a single machine fails while its neighbours join.

Zoom
Slack cannot connect. / Last updated less than a minute ago…Fixed

Slack: "Slack cannot connect" and the grey "Last updated…" banner behind a corporate proxy

Slack loads channels over ordinary HTTPS but delivers new messages over a persistent WebSocket on port 443 to the three wss-*.slack.com hosts Slack names (primary, backup, mobile). When a proxy or firewall passes the HTTP side and blocks the upgrade — most often because SSL decryption is on for the wss hosts, or the allowlist stops at slack.com — the app shows the grey "Last updated…" banner or "Slack cannot connect." while the browser seems fine. Two curl probes from the affected machine show which layer is blocked; exempt the three wss hosts from decryption, allow every domain on my.slack.com/help/urls, and confirm with my.slack.com/help/test.

Slack
locked for editingFixed

Word: "The document is locked for editing by another user"

Word found a lock — an owner file — for the document and assumed someone else has it open, so it offers only a read-only copy. Usually no one does: a crash left the lock behind, or a hidden Word process is still holding the file. Confirm which, close every Word instance, delete the stale ~$ owner file, and the document opens for editing again.

Microsoft Word