Guides

Rate limits

Two kinds of limit apply: a request budget on every API key, and the capacity each feature is provisioned for. Both are defaults, and both can be raised.

Request limits

Every endpoint shares one budget, counted per key rather than per organization, so splitting an integration across two keys genuinely doubles what it can spend.

60 / minute
The burst allowance. Comfortable for interactive use and for a job working through a few thousand records; deliberately short of crawling an entire account in one pass.Counted in fixed one-minute windows.
10,000 / day
The daily ceiling. Polling is what usually spends it: the same integration driven by webhooks costs a handful of requests a day.Resets at midnight UTC.

A few endpoints are metered more tightly than the shared budget, because one request can cost far more than another. Where that is the case it is listed under the feature below.

Feature limits

10 areas

The platform is shared, so each feature is provisioned with capacity as well as a request budget. These are the defaults on a standard plan.

Outbound campaigns

reference
50
Concurrent callsAcross every running campaign in the organization.
120 calls / minute
Dial rate
250,000
Contacts per campaign
25
Running campaigns

Call centers

reference
100
Concurrent inbound callsPer organization, across every number and queue.
500
Queued callers
250
Signed-in agents

Help desks

reference
300 / minute
Inbound messages
10,000
Open conversations
120 / minute
Outbound replies
20,000 characters
Message length
50 MB
Attachment sizeUp to 10 attachments on one message.
30
Tags per conversation

Contacts and imports

reference
10,000
Contacts per requestLarger sets go in as a file URL.
100 MB
Import file size
5
Concurrent import jobsFurther imports queue rather than fail.
1,000,000
Contacts per list

Calls and recordings

reference
60 / minute
RequestsThe standard per-key budget.
20 / minute
Recording downloads
15 minutes
Recording URL lifetime

Analytics and reports

reference
20 / minute
RequestsThese queries read across the whole account, so they are metered more tightly.
3
Concurrent report runs
500,000 per export
Export rows

Events and streaming

reference
10 per key
Concurrent streams
2 hours
Stream lifetimeReconnect with the last event id; nothing is lost.

Webhooks

reference
20 per organization
Endpoints
4
Delivery attemptsThe first try, then three retries.
10 seconds
Endpoint timeout

Knowledge bases

reference
10,000
Documents per corpus
25 MB
Document size
60 / minute
Uploads
3
Concurrent runs
500
Cases per run

These are defaults, not ceilings

Limits are set per account and are commonly raised for higher volumes. If your traffic is shaped differently from the defaults above, write to contact@pendral.io with what you expect to run and we will size it with you. Enterprise agreements carry their own limits, agreed in advance.

Reading your budget

Every response carries the state of both windows, so a well-behaved client never has to guess or wait for a refusal to find out where it stands.

X-RateLimit-Limit-RPM
Requests allowed this minute.
X-RateLimit-Remaining-RPM
Requests left in it. Slow down as this approaches zero rather than after it arrives.
X-RateLimit-Reset-RPM
Unix timestamp at which the minute window refills.
X-RateLimit-Limit-TPD
Requests allowed today.
X-RateLimit-Remaining-TPD
Requests left today.
X-RateLimit-Reset-TPD
Unix timestamp of the next midnight UTC.
Retry-After
Seconds to wait. Obey it: it is calculated from the window that actually refused you, and beats any backoff you compute.Sent only on a 429.

Being refused

429 Too Many Requests
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded",
"retryAfter": 30
}
}

The wait is also in the body, for clients that find headers awkward. Retrying straight away on a 429 spends the next window before it opens.

fetch-with-retry.js
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get('Retry-After'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 1000;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error('Rate limited after ' + maxRetries + ' attempts');
}

Spend fewer requests, not more carefully

Import contacts in one request rather than one per row, and take call results from a webhook instead of polling for them. Most integrations that reach the daily ceiling are asking repeatedly for something they could have been told once.