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

# Limits, quotas, and errors

> Testing plan quotas, rate and size limits, and every error code the webhook inbox and email testing APIs return, with the fix for each

Webhook inboxes and email testing share one set of quotas per organization. Every error code the testing APIs return is in the [error reference](#error-reference), with its fix.

## Testing plans

| Testing plan | Inboxes | Email domains | Custom domains | Retention ceiling (days) | Events per month |
| - | - | - | - | - | - |
| Free | 3 | 1 | No | 3 | 300 |
| Developer | 10 | 1 | No | 7 | 3,000 |
| Team | 100 | 3 | Yes | 30 | 100,000 |
| Business | 500 | 10 | Yes | 90 | 300,000 |
| Enterprise | Unlimited | Unlimited | Yes | 365 | Unlimited |

Quotas apply per organization. Captured webhook events and received emails share the monthly event count. Testing plans are separate from monitoring plans.

* **An organization with no testing subscription is on Free.** Your monitoring plan does not change these numbers.
* **Inboxes and email domains are counted across the whole organization**, not per workspace. On Free, an email domain in one workspace blocks a second workspace from getting one. Disabled domains count.
* **Monthly events** count every captured webhook request and every received or injected email. At the cap, webhook senders and inject get `403` and nothing is stored until the next month. Real mail sent over the cap can be accepted by the mail server and then dropped.
* **The monthly counter updates asynchronously.** A burst that arrives at the cap can go slightly over it before refusals start.
* **Retention ceiling** is the highest `retentionDays` an inbox accepts, and the default. DevHelm does not currently delete captured data on a schedule. See [Security and data](/testing/security#retention-and-cleanup).

The limits on [Rate limits](/patterns/rate-limits) belong to the monitoring plans, a separate ladder.

## Rate and size limits

| Limit | Value |
| - | - |
| Webhook ingest | 120 requests per minute per sender IP, 600 per inbox, 1,200 per workspace |
| Wait calls | 40 per minute per organization, shared by webhook and email waits |
| Wait timeout | `timeoutMs` defaults to 30000. Values above 120000 are treated as 120000 |
| Request or email size | 1 MiB. Larger bodies get `413` |
| Query string | 8,192 characters |
| Headers | 128 headers, 32 values per header, 8,192 characters per value |
| Body text in API responses | 256 KB, then `bodyTruncated` is `true`. The raw download has every byte |
| Mock reply | Status 200–599, body up to 65,536 bytes, delay up to 30 seconds, up to 32 headers |
| Raw download links | Expire after 60 seconds |

Also per inbox: `maxEvents` (1 to 100000, default 10000) caps stored events. When the inbox is full, the oldest event is dropped as a new one arrives.

### How the limits apply

* **Ingest** checks the per-IP window first, then resolves the token, then checks the per-inbox and per-workspace windows and the monthly cap. Requests to unknown tokens count against the IP window.
* **The wait limit counts calls, not time spent waiting.** One 60-second wait costs the same as a 1-second one. Webhook and email waits share the 40 per minute across every workspace in the organization.
* **Size limits refuse the whole request.** Nothing is stored or clipped to fit.
* **Email inject** has no testing-specific rate limit. It counts against your organization's general API rate limit and shares the size limits and the monthly cap.

### Rate-limit responses

Every `429` carries `Retry-After` in seconds and the standard error body:

```text theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 18
X-RateLimit-Limit: 40
X-RateLimit-Remaining: 0
```

```json theme={null}
{
  "status": 429,
  "code": "RATE_LIMITED",
  "message": "Rate limit exceeded. Retry after 1791377760.",
  "timestamp": 1791377742000,
  "requestId": "a1b2c3d4-5678-90ab-cdef-111111111111"
}
```

Neither SDK retries on its own. Wait `Retry-After` seconds, then call again. Retrying a wait with the same `receivedAfter` is safe: wait does not consume what it returns.

## Error reference

Errors share one body shape. Branch on `code`; `message` is for people.

```json theme={null}
{
  "status": 408,
  "code": "WAIT_TIMEOUT",
  "message": "No matching inbound item arrived before timeout",
  "timestamp": 1791377739341,
  "requestId": null
}
```

| Status | `code` | When | Fix |
| - | - | - | - |
| 400 | `WORKSPACE_REQUIRED` | A testing endpoint was called without `x-phelm-workspace-id` | Send the header, or set `DEVHELM_WORKSPACE_ID` for the CLI and SDKs |
| 400 | None guaranteed | Ingest rejected the request line: control characters in the method; a backslash, NUL, CR, LF, or malformed percent-escape in the path; or a path that resolves outside `/api/v1/ingest` | Send a plain path after the token. Nothing was stored |
| 400 | `INBOUND_INJECT_DOMAIN_DISABLED` | Inject into a domain whose status is `disabled` | Re-enable it: `PATCH` status `active` for an assigned domain, or verify a custom domain |
| 403 | `INBOUND_EVENTS_MONTHLY_LIMIT_REACHED` | The organization reached its monthly event cap. Webhook senders and inject get this | Wait for the next month or move to a larger testing plan. An inject accepted right at the cap can be dropped, and its wait then returns `408` |
| 403 | `INBOUND_WEBHOOK_INBOX_LIMIT_REACHED` | Creating an inbox past the plan's inbox count | Delete unused inboxes in any workspace of the organization, or share one inbox per CI job with a path per test |
| 403 | `INBOUND_EMAIL_DOMAIN_LIMIT_REACHED` | Creating an email domain past the plan's domain count, including from `address()` | Reuse the existing assigned domain. Disabled domains count, so delete one you no longer use |
| 403 | `INBOUND_CUSTOM_MX_PLAN_REQUIRED` | Creating a custom email domain below the Team testing plan | Use the assigned domain, or move to Team or above |
| 403 | `INBOUND_RETENTION_ABOVE_PLAN` | `retentionDays` above the plan ceiling on inbox create or update | Omit `retentionDays`, or send a value at or below the ceiling |
| 404 | `NOT_FOUND`, "Workspace doesn't exist" | The workspace ID is wrong. The CLI and SDKs default to workspace `1` when `DEVHELM_WORKSPACE_ID` is unset | Set `DEVHELM_WORKSPACE_ID` to your workspace ID |
| 404 | `NOT_FOUND`, "Inbox not found" | Ingest to a token that never existed, belongs to a deleted inbox, or belongs to a disabled inbox. The three look the same | Check the URL, or set the inbox `status` back to `active` |
| 408 | `WAIT_TIMEOUT` | No matching event or message arrived before `timeoutMs` | Pass a `receivedAfter` captured before the trigger, check your filters, and raise `timeoutMs` (up to 120000) |
| 413 | `PAYLOAD_TOO_LARGE` | Body or email over 1 MiB, query over 8,192 characters, more than 128 headers, more than 32 values for one header, or a header value over 8,192 characters | Send less. Nothing was stored |
| 429 | `RATE_LIMITED` | An ingest window (IP, inbox, or workspace) or the 40 per minute wait limit was exceeded | Wait `Retry-After` seconds and retry. Spread waits across time in parallel CI |
| 503 | `SERVICE_UNAVAILABLE` | DevHelm could not store the body or queue it for processing | Retry the send. Nothing was stored |

Field validation errors (for example a mock reply status outside 200–599) return `400` with a field-specific `code` and an `errors[]` entry naming the field. The `403` quota codes carry the same `errors[]` entry.

### `408` that should have matched

A wait returns the oldest item with `receivedAt` at or after `receivedAfter`. The CLI and both SDKs default `receivedAfter` to the client clock when the call starts, so an item that arrived first is missed. Capture the time before you trigger the send and pass it:

<CodeGroup>
  ```typescript TypeScript theme={null}
  const since = new Date().toISOString();
  await triggerWebhook();
  const event = await client.inboxes.wait(inboxId, { receivedAfter: since, timeoutMs: 30_000 });
  ```

  ```python Python theme={null}
  from datetime import datetime, timezone

  since = datetime.now(timezone.utc)
  trigger_webhook()
  event = client.inboxes.wait(inbox_id, received_after=since, timeout_ms=30_000)
  ```
</CodeGroup>

Wait on a disabled inbox or domain is not an error. It times out with `408`.

## Errors in the SDKs and CLI

| Status | TypeScript | Python |
| - | - | - |
| 400, 408, 413 | `DevhelmApiError` | `DevhelmApiError` |
| 403 | `DevhelmAuthError` | `DevhelmAuthError` |
| 404 | `DevhelmNotFoundError` | `DevhelmNotFoundError` |
| 429 | `DevhelmRateLimitError`, with `retryAfter` | `DevhelmRateLimitError`, with `retry_after` |
| 503 | `DevhelmServerError` | `DevhelmServerError` |

Every class extends `DevhelmApiError` and carries `status` and `code`. A quota `403` is a `DevhelmAuthError`, so check `code` to tell it apart from a bad key. There is no timeout class: a `408` is a `DevhelmApiError` with `code` `WAIT_TIMEOUT`. Catch `DevhelmRateLimitError` before `DevhelmApiError`.

The CLI prints the error class, message, and code, and exits non-zero (`11` for a wait timeout):

```text theme={null}
DevhelmNotFoundError: Workspace doesn't exist
Code: NOT_FOUND
```

[TypeScript errors](/sdk/typescript/error-handling#wait-timeouts) and [Python errors](/sdk/python/error-handling#wait-timeouts) show the catch order in code.

## Next steps

<CardGroup cols={2}>
  <Card title="Testing in CI" icon="github" href="/guides/testing-in-ci">
    Fit parallel jobs into the inbox cap and the wait budget.
  </Card>

  <Card title="Security and data" icon="shield" href="/testing/security">
    Who can read captured data and how to clean it up.
  </Card>

  <Card title="Wait for a webhook" icon="code" href="/testing/webhooks/assert-in-tests">
    Wait semantics, `receivedAfter`, and negative assertions.
  </Card>

  <Card title="Wait for an email" icon="envelope" href="/testing/email/wait">
    One address per test and repeat sends.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.