---
title: "Limits, errors and polling"
summary: "Exact rate limits, body caps, import caps, the error envelope, pagination, and how to poll because there are no webhooks."
updated: "2026-09-30"
status: stable
origin: official
url: "https://swooshconnect.com/docs/limits/"
---

# Limits, errors and polling

Exact rate limits, body caps, import caps, the error envelope, pagination, and how to poll because there are no webhooks.

## For AI agents

- MUST keep under 60 requests per minute per key. On HTTP 429 wait for `Retry-After` when present, else until `X-RateLimit-Reset`.
- MUST NOT send a REST body over 2.5 MB or an MCP body over 1 MiB. Both answer 413.
- MUST NOT wait for a webhook. There are none. Poll `calls_log` with `since` and `nextCursor`.
- DO read `error.code` from the error envelope. Branch on the code, never on the message text.
- DO keep your last `nextCursor`. When a poll returns a null cursor, keep the old one.
- NEVER retry a 400, 401, 402, 403 or 404 unchanged. Only 429, 502 and 503 are worth a retry, with back-off.

## Rate limit

- 60 requests per minute per API key, on REST and MCP alike. The window slides.
- Before a key is read, each IP address is limited to 10 requests per 10 seconds. A flood without a valid key hits this first.
- Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Reset is a timestamp in milliseconds since the epoch.
- A blocked request answers HTTP 429. It usually carries `Retry-After` in whole seconds. Do not rely on that header being present.

```json
{ "error": "rate_limit_exceeded" }
```

The 429 body from the rate limiter is this short form. The MCP endpoint answers in JSON-RPC form with the same 429 status. A rate_limited error from a platform uses the normal envelope below.

## Body size caps

| Door | Cap | Over the cap |
| --- | --- | --- |
| REST | 2,500,000 bytes (2.5 MB) | 413, code payload_too_large |
| MCP (https://swooshrank.com/api/mcp) | 1,048,576 bytes (1 MiB) | 413, code payload_too_large |

## Import caps

| Tool | Cap |
| --- | --- |
| `dialer_import_contacts`, `csv` | 20,000 contacts per call. The `csv` text is at most 2,000,000 characters. |
| `dialer_import_contacts`, `rows` | 5,000 rows per call. Send either `csv` or `rows`, never both. |

To import more than 5,000 rows as JSON, split them into several calls. The REST body cap still applies to each call.

## Error envelope

Every failure from a tool answers JSON with one stable code and a readable message. A code may add extra fields, for example a reason.

```json
{ "error": { "code": "not_found", "message": "No campaign with that id." } }
```

| Code | HTTP | Meaning |
| --- | --- | --- |
| unauthorized | 401 | The API key is missing, invalid, or revoked. |
| invalid_input | 400 | The request body or parameters failed validation. The issues field lists each problem. |
| not_found | 404 | The account, review, post, campaign, contact or agent referenced doesn't exist for your organization. |
| not_supported | 400 | The action isn't available on this platform. |
| payment_required | 402 | A payment method is needed, or paid accounts are paused until a renewal payment clears. The reason field says which. |
| provider_auth | 401 | The platform no longer accepts the stored authorization. Reconnect the account. |
| provider_permission | 403 | The connected account lacks a permission this action needs on the platform. |
| rate_limited | 429 | Too many requests; retry after the interval in the response. |
| provider_error | 502 | The platform returned an error. |
| transient | 503 | A temporary failure on the platform's side. Retry. |
| payload_too_large | 413 | The request body is over the cap above. |

> **Note:** provider_auth is a 401 about the connected platform account, not about your API key. The fix is to reconnect that account, not to rotate the key.

## Pagination

`list_reviews` and `inbox_list` take an optional `cursor` and return `nextCursor`. Pass the returned `nextCursor` back as `cursor` for the next page. Stop when it is null or missing. `customers_list` takes `max` (1 to 100) and returns `nextCursor` but has no cursor input. `calls_log` uses `since` instead (next section).

## No webhooks. Poll.

SwooshConnect does not send webhooks. To learn about new calls, poll `calls_log`. A call appears once it has ended and has been over for about five minutes, so its outcome is final. Each call arrives exactly once, oldest first.

- First poll: set `since` to the current time as an ISO 8601 date-time. Store it.
- Call `calls_log` with `since` set to your stored value. Use `limit` up to 200. The default is 50.
- Handle every call in `calls`.
- If `nextCursor` is a string, store it as the new `since`. If it is null, nothing new arrived. Keep your old value.
- Sleep at least a minute, then repeat from step 2. If a page was full, repeat at once.

```bash
SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

curl -G -H "Authorization: Bearer $SWOOSHCONNECT_API_KEY" \
  --data-urlencode "since=$SINCE" \
  --data-urlencode "limit=200" \
  https://swooshrank.com/api/v1/connect/calls/log
```

```ts
let since = new Date().toISOString();

async function handle(call: unknown): Promise<void> {
  /* your logic */
}

export async function poll(): Promise<void> {
  const url = new URL("https://swooshrank.com/api/v1/connect/calls/log");
  url.searchParams.set("since", since);
  url.searchParams.set("limit", "200");
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.SWOOSHCONNECT_API_KEY}` },
  });
  if (!res.ok) throw new Error(`calls_log failed: ${res.status}`);
  const { data } = await res.json();
  for (const call of data.calls) await handle(call);
  if (data.nextCursor) since = data.nextCursor;
}
```

> **Must:** A null `nextCursor` means keep your previous cursor. Replacing it with null loses your place. Without `since`, `calls_log` returns a newest-first snapshot that can repeat between calls and includes calls still in progress.
