# Microsoft Graph rate limits for Outlook mail

Source: https://developer.nylas.com/docs/cookbook/email/microsoft-graph-rate-limits/

A Microsoft 365 mailbox can reject your request with `429 Too Many Requests` after a handful of sends. The message reads `Application is over its MailboxConcurrency limit`, and it shows up at volumes far below any published rate. Microsoft Graph enforces this limit per app ID and mailbox pair, and it counts requests in flight, not requests per minute. This page covers the numbers, why sending hits the limit first, and how to pace requests so the error stops.

## What are the Microsoft Graph rate limits for Outlook mailboxes?

Microsoft Graph applies three limits to every app ID and mailbox combination: 10,000 requests per 10-minute window, 4 concurrent requests, and 150 MB of uploads per 5-minute window. Microsoft's [throttling limits documentation](https://learn.microsoft.com/en-us/graph/throttling-limits#outlook-service-limits) states the scope directly: "The Outlook service applies limits to each app ID and mailbox combination".

| Limit | Value | Scope |
|-----------------------|-------|-------|
| Request volume | 10,000 requests per 10 minutes | One app ID against one mailbox |
| Concurrency | 4 requests in flight | One app ID against one mailbox |
| Upload volume | 150 MB per 5 minutes (`POST`, `PATCH`, `PUT`) | One app ID against one mailbox |
| Message rate | 30 messages per minute | One mailbox, all senders |
| Recipient rate | 10,000 recipients per 24 hours | One mailbox, all senders |

The first three rows come from Microsoft Graph. The last two are Exchange Online limits that apply to the mailbox itself, whether the message leaves through Graph, Outlook, or SMTP. Exceeding one mailbox's limit doesn't affect other mailboxes, so 500 connected accounts get 500 independent budgets. The number that bites in practice is the concurrency cap of 4, because it's the only one a low-volume integration can hit.

## Why do I get 429 MailboxConcurrency errors at low volume?

A `MailboxConcurrency` 429 means 4 Graph requests were already in flight against that mailbox under the same app ID when a fifth arrived. It measures overlap, not throughput. An integration sending 3 messages a minute can trigger it if those 3 sends run in parallel, while an integration sending 25 a minute sequentially rarely will.

Sending is where this surfaces first. One `POST /v3/grants/{grant_id}/messages/send` request makes several Graph calls, and messages with attachments make more, so each send keeps a slot busy for longer than a single read does. The `Nylas-Provider-Request-Count` response header reports the exact count for each request. Two or three sends dispatched at the same moment to the same grant, on top of other traffic under the same app ID, can fill all 4 slots, and Microsoft rejects the next call.

Every request under the same app ID counts, not only the ones your code makes. Nylas makes Graph calls for every grant on an ongoing basis to keep it in sync, and those calls use the same 4 slots. A `429` can appear even on the first send after a quiet period. Retrying after a short backoff usually clears it.

## Which app ID counts against the MailboxConcurrency limit?

The app ID is the Azure application the grant authenticated through. Microsoft scopes the limit to the app ID and mailbox pair, so the user's Outlook desktop client, Outlook on the web, and mobile apps don't consume your 4 slots. They authenticate through Microsoft's own app IDs. Only traffic under your app ID counts against that mailbox's 4 slots.

Which Azure app applies depends on how you authenticate. Grants created through a [custom connector](/docs/cookbook/use-cases/build/custom-oauth-connector/) use your own Azure app registration, so each mailbox gets a pool of 4 dedicated to your integration. Grants created with default credentials use the shared Azure app behind hosted authentication. If another integration also connected the same mailbox with default credentials, the two share that mailbox's 4 slots. In both cases the limit is scoped per mailbox, and one throttled mailbox never slows another. The [hosted vs custom OAuth comparison](/docs/cookbook/use-cases/build/hosted-vs-custom-oauth/) covers the other reasons to register your own app.

## How does the Nylas API change Microsoft Graph rate limit handling?

Connecting Microsoft accounts through Nylas Connect removes most of the Graph calls you would otherwise make yourself. One `POST /v3/grants/{grant_id}/messages/send` call replaces the draft, attachment upload, and send steps you would otherwise run against Graph. Reads from every provider return the same `429` shape, so one retry loop works for Microsoft, Google, and IMAP grants.

The bigger saving is on reads. A polling integration that checks a mailbox every 5 seconds makes 120 requests per 10 minutes before it does any real work, and more when each check makes several Graph calls. [Webhooks](/docs/cookbook/use-cases/build/webhooks-vs-polling/) deliver `message.created` and other events without your app making requests against the mailbox.

What doesn't change is the ceiling itself. The 4-slot concurrency cap and the 10,000-request window belong to Microsoft, and no layer in front of Graph can raise them. The platform also applies its own [API rate limits](/docs/dev-guide/platform/rate-limits/), but the Microsoft limits are the ones a Microsoft integration hits first, so they're the ones to design around.

## How does the Nylas API return Microsoft throttling errors?

When Microsoft throttles a Graph call, the API returns `429`. On reads, `error.type` is `rate_limit_error` and Microsoft's own error is in the `provider_error` field. If Microsoft says how long to wait, the read response includes a `Retry-After` header with that number of seconds, rounded up. Don't retry early. Microsoft's [throttling guidance](https://learn.microsoft.com/en-us/graph/throttling) warns that "Microsoft Graph continues to log resource usage while a client is being throttled". When a `429` has no `Retry-After` header, your own backoff sets the delay.

The read request below shows the shape of a throttled response. The `message` field is the API's summary, and `provider_error.error.message` is Microsoft's text verbatim. Check Microsoft's text to tell a `MailboxConcurrency` rejection apart from other per-mailbox limits, such as `IncomingBytes`, or from an [Exchange account throttle](/docs/api/errors/400-response/#error-429---exchange-account-throttled).

```bash
curl -i --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=20' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```http
HTTP/2 429
content-type: application/json
retry-after: 30

{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "error": {
    "type": "rate_limit_error",
    "message": "Microsoft rate-limited on application level. Please read provider error message in this response for more details. Learn more: https://developer.nylas.com/docs/dev-guide/best-practices/rate-limits/ and https://learn.microsoft.com/en-us/graph/throttling-limits#limits-per-mailbox",
    "provider_error": {
      "error": {
        "code": "ApplicationThrottled",
        "message": "Application is over its MailboxConcurrency limit."
      }
    }
  }
}
```

Read `Nylas-Provider-Request-Count` on successful responses too, to see which operations keep the mailbox's slots busiest.

## How do I avoid MailboxConcurrency errors when sending?

Serialize sends per grant. One send in flight per mailbox eliminates `MailboxConcurrency` errors for most integrations, because sends that never overlap each other leave room in the 4 slots for sync and your other requests. Rate limiters that cap requests per second don't help here. The limit is on overlap, so the fix is a concurrency limit of 1 per grant, not a slower rate.

A few more rules cover the remaining cases. Leave 2 to 5 seconds between consecutive sends on the same grant when messages carry attachments, since each upload adds Graph calls. After a send `429`, wait the number of seconds in `Retry-After` if the response has it, and otherwise back off with a doubling delay and some jitter. And keep retry counts low for sends, because aggressive retry loops on a mailbox that's also syncing keep colliding with the same 4 slots. Send with an [`Idempotency-Key`](/docs/v3/email/idempotent-send/), and follow that page's guidance on when to reuse or replace the key after an error. A `429` on a [raw MIME send](/docs/v3/email/headers-mime-data/#send-messages-with-mime-data) doesn't guarantee the message wasn't delivered, so check the mailbox's Sent Items before you resend one.

The examples below wrap the send endpoint in a per-grant queue that allows exactly 1 request in flight per mailbox. On a `429` it waits for `Retry-After` when the response has one, and otherwise backs off with jitter. Different grants still run in parallel, so total throughput scales with the number of mailboxes rather than dropping to 1 send at a time. The backoff function referenced here is the one from [handling 429 rate limit errors](/docs/cookbook/use-cases/build/handle-rate-limit-errors/).

```js [serialSend-Node.js]
// One send in flight per grant. Other grants proceed independently.
const queues = new Map();

function enqueue(grantId, task) {
  const prev = queues.get(grantId) ?? Promise.resolve();
  const next = prev.catch(() => {}).then(task);
  queues.set(grantId, next);
  return next;
}

async function sendSerialized(grantId, apiKey, message, maxRetries = 3) {
  return enqueue(grantId, async () => {
    for (let attempt = 0; ; attempt++) {
      const res = await fetch(
        `https://api.us.nylas.com/v3/grants/${grantId}/messages/send`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${apiKey}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify(message),
          signal: AbortSignal.timeout(30_000),
        },
      );
      if (res.status !== 429 || attempt >= maxRetries) return res;

      // Use Retry-After when the response has it; otherwise compute the delay.
      const retryAfter = Number.parseInt(res.headers.get("retry-after"), 10);
      const delayMs = Number.isNaN(retryAfter)
        ? backoffWithJitter(attempt)
        : retryAfter * 1000;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  });
}
```

```python
# One send in flight per grant. Other grants proceed independently.


from collections import defaultdict


locks = defaultdict(threading.Lock)


def send_serialized(grant_id, api_key, message, max_retries=3):
    url = f"https://api.us.nylas.com/v3/grants/{grant_id}/messages/send"
    headers = {"Authorization": f"Bearer {api_key}"}

    with locks[grant_id]:
        for attempt in range(max_retries + 1):
            res = requests.post(url, headers=headers, json=message, timeout=30)
            if res.status_code != 429 or attempt == max_retries:
                return res

            # Use Retry-After when the response has it; otherwise compute the delay.
            retry_after = res.headers.get("Retry-After")
            if retry_after and retry_after.isdigit():
                time.sleep(int(retry_after))
            else:
                time.sleep(backoff_with_jitter(attempt))
```

If you also make read requests against the same grant while sending, route them through the same per-grant queue. A message list that overlaps a send counts against the same 4 slots.

## What are the Exchange Online sending limits?

Exchange Online caps every mailbox at 30 messages per minute and 10,000 recipients per 24 hours, and these limits sit underneath Graph's concurrency rules. Microsoft's [Exchange Online limits](https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits#sending-limits) apply them "per user to all outbound and internal messages", so a person's own Outlook activity and your integration draw down the same daily recipient budget.

A few details change how you plan. The per-message recipient limit [defaults to 500](https://techcommunity.microsoft.com/blog/exchange/customizable-recipient-limits-in-office-365/1183228) and admins can set it anywhere from 1 to 1,000, so a message to a large distribution list can fail on one tenant and succeed on another. For SMTP AUTH submissions, Microsoft says messages above 30 per minute are [throttled and carried over](https://learn.microsoft.com/en-us/troubleshoot/exchange/send-emails/smtp-submission-improvements#throttling-limit-for-concurrent-connections-that-submit-messages) into the following minutes, so over-rate sending can show up as slow delivery before it shows up as errors. A separate tenant-wide external recipient cap, which Microsoft sizes by license count, applies on top. For bulk or high-volume email to external recipients, Microsoft's [Exchange Online limits](https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits#sending-limits) recommend Azure Communication Services Email rather than a user mailbox. The [send email at scale recipe](/docs/cookbook/email/send-email-at-scale/) covers queueing across many mailboxes within these caps.

## When should you send from an Agent Account instead?

Move app-owned sending off user mailboxes when the traffic isn't really that person's mail. Order confirmations, support intake, and agent-generated replies can run on a [Nylas Agent Account](/docs/v3/agent-accounts/), a hosted mailbox with its own address. It isn't in a Microsoft tenant, so it doesn't use any user mailbox's 4 concurrent slots or 30-messages-per-minute limit.

Connected Microsoft grants stay right when a real person's identity must be on the message, such as a sales rep's follow-up or a reply from a shared support mailbox the customer already knows. The split is about who owns the address. Agent Accounts have their own [usage limits by plan](/docs/v3/agent-accounts/send-limits/), and the [Agent Accounts quickstart](/docs/v3/getting-started/agent-accounts/) sends a first message in under 5 minutes.

## What's next

- [Handle 429 rate limit errors](/docs/cookbook/use-cases/build/handle-rate-limit-errors/) for the shared backoff and jitter functions these examples call.
- [Nylas API and provider rate limits](/docs/dev-guide/platform/rate-limits/) for the platform's own per-grant request limits.
- [How to send Outlook email](/docs/cookbook/email/send-outlook-email/) for the full send request with attachments.
- [How to list Microsoft email messages](/docs/cookbook/email/messages/list-messages-microsoft/) for the read side, including folder names and message IDs.
- [Webhooks vs polling](/docs/cookbook/use-cases/build/webhooks-vs-polling/) for replacing scheduled reads with change notifications.
- [Microsoft Graph API alternative](/docs/cookbook/email/microsoft-graph-api-alternative/) for the wider Azure setup and admin consent tradeoffs.
- [Client error responses 400-499](/docs/api/errors/400-response/) for every `429` variant and its fix.
- [Gmail API quotas and limits](/docs/cookbook/email/gmail-api-quotas/) for how Google's quota-unit model differs.