> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brew.new/llms.txt
> Use this file to discover all available pages before exploring further.

# Rate limits

> Per-route rate limit policies, response headers, and the canonical 429 retry cookbook for the Brew Public API v1.

Brew rate-limits every public v1 endpoint to keep the platform fast and fair. Limits are **per API key, per route, per rolling 60-second window**, surfaced on every response, and recoverable via standard `Retry-After` semantics.

## Headers (on every response)

Every successful AND failed response — not just 429s — carries the same three headers so you can preemptively slow down before a window exhausts:

```
X-RateLimit-Limit:     <window limit>
X-RateLimit-Remaining: <calls left>
X-RateLimit-Reset:     <unix epoch seconds when the window resets>
```

`429 RATE_LIMITED` additionally sets:

```
Retry-After: <seconds>
```

`Retry-After` is the wall-clock seconds to wait. SDKs and clients should honor it verbatim; if you need a longer back-off, take the max of `Retry-After` and your own exponential value.

## Per-route policies

Each route declares a named policy in `lib/api/rate_limit/policy.ts`. The same key the policy uses appears in the `error.code` and on observability dashboards, so you can correlate.

| Policy                  | Per-min limit | Routes                                                                                     |
| ----------------------- | ------------- | ------------------------------------------------------------------------------------------ |
| `triggers.read`         | 100           | `GET /v1/automations/triggers`                                                             |
| `triggers.write`        | 60            | `POST/PATCH/DELETE /v1/automations/triggers`                                               |
| `automations.read`      | 100           | `GET /v1/automations`                                                                      |
| `automations.write`     | 60            | `POST/PATCH/DELETE /v1/automations` (publish/unpublish is `PATCH … { published }`)         |
| `automation.runs.read`  | 100           | `GET /v1/automations/runs`                                                                 |
| `automation.runs.write` | 60            | `POST /v1/automations/{automationId}/test`                                                 |
| `emails.read`           | 100           | `GET /v1/emails`                                                                           |
| `emails.generate`       | 20            | `POST /v1/emails`, `POST /v1/emails/import`                                                |
| `emails.edit`           | 20            | `PATCH/DELETE /v1/emails/{emailId}` (incl. `/restore`)                                     |
| `sends.read`            | 100           | `GET /v1/analytics/sends`                                                                  |
| `sends.write`           | 10            | `POST /v1/sends` (campaign + `{ test: true }`)                                             |
| `contacts.read`         | 100           | `POST /v1/contacts/search`                                                                 |
| `contacts.write_single` | 100           | `POST/PATCH/DELETE /v1/contacts` with a single-row body                                    |
| `contacts.write_batch`  | 10            | `POST/DELETE /v1/contacts` with a batch body                                               |
| `audiences.read`        | 100           | `GET /v1/audiences`                                                                        |
| `audiences.write`       | 60            | `POST/PATCH/DELETE /v1/audiences`                                                          |
| `domains.read`          | 100           | `GET /v1/domains`                                                                          |
| `domains.write`         | 20            | `POST/PATCH/DELETE /v1/domains` (hits the sending provider)                                |
| `analytics.read`        | 100           | `GET /v1/analytics/*` (`campaigns`, `automations`, `events`, `sends`, `trigger-instances`) |
| `brand.read`            | 100           | `GET /v1/brand`                                                                            |
| `brand.write`           | 60            | `PATCH /v1/brand`                                                                          |
| `usage.read`            | 100           | `GET /v1/usage`                                                                            |
| `fields.read`           | 100           | `GET /v1/fields`                                                                           |
| `fields.write`          | 100           | `POST/DELETE /v1/fields`                                                                   |
| `templates.read`        | 100           | `GET /v1/templates`                                                                        |
| `content.generate`      | 20            | `POST /v1/content/generate`                                                                |
| `content.transform`     | 20            | `POST /v1/content/transform`                                                               |

### Session traffic — `300/min` ceiling

UI-backed session traffic (dashboard, Brew chat orchestrator) gets a flat `300/min` ceiling across every policy above so a busy operator never blocks themselves while editing. API-key traffic uses the strict per-route limits in the table.

### Generosity & burst behaviour

* The window is a **rolling 60 seconds**, not a fixed wall-clock minute. A spike at the top of a minute does NOT get a free second budget.
* The store uses Redis sorted-sets (when available); when Redis is unavailable, every endpoint **fails open** with `X-RateLimit-Remaining: <limit>` and `X-RateLimit-Reset: now+60`. You will not see spurious 429s during a Redis blip.
* Test mode bypass: `BREW_DISABLE_API_RATE_LIMIT=1` on the dev server skips the gate entirely (test-only env var; never set in production).

## `429` recovery cookbook

The canonical retry loop:

```ts theme={null}
import { setTimeout as sleep } from 'node:timers/promises'

async function withRetry<T>(fn: () => Promise<Response>): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const res = await fn()
    if (res.status !== 429) {
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
      return (await res.json()) as T
    }
    const retryAfterSec = Number(res.headers.get('Retry-After') ?? '1')
    const backoffSec = Math.max(retryAfterSec, 2 ** attempt)
    await sleep(backoffSec * 1000)
  }
  throw new Error('Exhausted retries after repeated 429 responses')
}

const sendResponse = await withRetry<{ status: string; runId: string }>(() =>
  fetch('https://brew.new/api/v1/sends', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BREW_API_KEY!}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `send-promo-${Date.now()}`,
    },
    body: JSON.stringify({
      emailId: 'eml_abc',
      domainId: 'dom_brand_primary',
      audienceId: 'aud_subscribers',
      subject: 'Black Friday — 40% off',
    }),
  })
)
```

Key invariants:

* **Always honor `Retry-After`.** Take the max of `Retry-After` and your own back-off so you don't out-aggressive yourself.
* **Reuse the same `Idempotency-Key` on every retry.** The first successful response is cached for 24h and your retries get the original payload back instead of double-firing — see [Idempotency](/api-reference/api/idempotency).
* **Preemptively pause when `X-RateLimit-Remaining` hits 0.** The header ships on every response, so a well-behaved client can throttle itself before a 429 ever fires.

## `429 RATE_LIMITED` error envelope

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "type": "rate_limit",
    "message": "Rate limit exceeded for emails.generate. Retry after 12s.",
    "suggestion": "Back off and retry after the duration in `Retry-After`.",
    "docs": "https://docs.brew.new/api-reference/api/rate-limits",
    "retryAfter": 12
  }
}
```

`error.retryAfter` mirrors the `Retry-After` header for clients that don't read response headers.

## Server-side observability

Every rate-limited response logs a structured warning with the policy name, the API key id, and the remaining count. If you see persistent 429s in production that don't match your expected QPS, contact support with the `x-request-id` from any 429 response — we can look up the per-key window state and diagnose.

## See also

* [Idempotency](/api-reference/api/idempotency) — pair with `Idempotency-Key` so retries are safe.
* [Errors](/api-reference/api/errors) — full error envelope + every `error.code`.
* [Response headers](/api-reference/api/response-headers) — every header Brew sets.
* [Async jobs & polling](/api-reference/api/async-jobs) — for `POST /v1/sends` and `POST /v1/automations/triggers/{triggerEventId}/fire`, you don't need to call the API again to wait for delivery — poll the run.

## Need Help?

Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:

<Tabs>
  <Tab title="Self-Service Tools">
    <CardGroup cols="2">
      <Card title="Search Documentation" icon="magnifying-glass" color="#c44925">
        Type in the "Ask any question" search bar at the top left to instantly find relevant documentation pages.
      </Card>

      <Card title="ChatGPT/Claude Integration" icon="robot" color="#c44925">
        Click "Open in ChatGPT" at the top right of any page to analyze documentation with ChatGPT or Claude for deeper insights.
      </Card>
    </CardGroup>
  </Tab>

  <Tab title="Talk to Our Team">
    <CardGroup cols="2">
      <Card title="Schedule a Call" icon="calendar" color="#c44925" href="https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ1iYoRUG1J792XQpbuQLjSRRDupr7MwraFK-HQRCtTYdBmrQi8nZu2qXfzKQigb8gbKJK3KN3-R">
        Book time with our founders for personalized guidance on strategy, best practices, or complex implementation questions.
      </Card>

      <Card title="Call Us Directly" icon="phone" color="#c44925">
        Need immediate assistance? Reach us at **+1-(332)-203-2145** for urgent issues or time-sensitive questions.
      </Card>

      <Card title="Slack Channel" icon="slack" color="#c44925">
        Our preferred support channel. You'll receive an invite after signup for direct founder support and fast responses.
      </Card>

      <Card title="Email Support" icon="envelope" color="#c44925" href="mailto:support@brew.new">
        Contact us at **[support@brew.new](mailto:support@brew.new)** for detailed inquiries or if you prefer not to use Slack.
      </Card>
    </CardGroup>
  </Tab>
</Tabs>
