Rate Limiting Policy
Rate Limiting Policy
Requests to the Candis API are subject to two independent limits: a request limit, which
applies to every endpoint, and a record limit, which applies to core data import endpoints.
On import endpoints both are evaluated and a request is rejected when either is exceeded.
Limits are applied at the organization level rather than per client or per API key. Where
several integrations import into the same organization, a single allowance is shared between them.
Organizations belonging to a holding are additionally subject to a shared holding allowance.
Request limit
| Scope | Limit |
|---|---|
| Organization | 500 requests per minute |
| Holding | 2,500 requests per minute |
This limit counts requests irrespective of their contents and applies to every Candis API
endpoint — exports, files, invoices, purchase requests and reimbursement items as well as core
data imports.
Every successful response reports the request-limit state in the X-RateLimit-Limit-organization,
X-RateLimit-Remaining-organization and X-RateLimit-Reset-organization headers, with -holding
counterparts when the organization belongs to a holding. X-RateLimit-Reset is the number of
seconds until the window resets, not a Unix timestamp.
Record limit
Import endpoints are additionally metered by the number of records contained in the request
body, as each record generates one unit of downstream processing. A maximum of 100 records may
be submitted per request.
| Endpoint | Per organization | Per holding |
|---|---|---|
| Contacts | 600 records per minute (burst 1,000) | 900 records per minute (burst 1,500) |
| All other core data types | 3,000 records per minute (burst 5,000) | 4,800 records per minute (burst 8,000) |
Contacts are subject to a lower allowance because contact records require more downstream
processing than other core data types.
Allowance replenishment
The record allowance is replenished continuously rather than reset at fixed intervals. Submitted
records reduce the available balance; elapsed time restores it at the sustained rate, up to the
burst ceiling stated above.
Two properties follow. No window boundary applies, so there is no fixed time at which the allowance
resets. The balance does not accumulate beyond the ceiling, so an extended idle period confers no
greater allowance than a short one.
Example — contacts, 100 records per request
| Condition | Result |
|---|---|
| Balance at ceiling following an idle period | 1,000 records available |
| 10 consecutive requests submitted | All accepted |
| An 11th request submitted immediately | Rejected, with Retry-After: 10 |
| A request submitted after 10 seconds | Accepted; 100 records have been replenished |
| Submission continued at that rate | Sustained throughput of one request per 10 seconds |
| Submission suspended for 100 seconds | Balance restored to the 1,000-record ceiling |
Effective limits by batch size
The record limit is the binding constraint for batch imports. The request limit becomes binding only
where requests contain few records.
| Records per request | Contacts | All other types |
|---|---|---|
| 100 (maximum) | 10 requests, then 1 per 10 seconds | 50 requests, then 1 per 2 seconds |
| 10 | 100 requests, then 1 per second | 500 requests, then the request limit binds |
| 1 | Request limit applies: 500 per minute | Request limit applies: 500 per minute |
Each value indicates the number of requests accepted immediately from a full balance, followed by
the sustained rate thereafter.
*At 10 records per request the initial 500-request burst also reaches the 500-per-minute request
limit, so continuing before that minute has elapsed triggers its 60-second lockout. Submitting at
the sustained 5 per second stays below the request limit, and is bound only by the record limit.
Note that the two limits interact. Where the record allowance would permit more than 500 requests
in a minute, the request limit binds first and no further requests are accepted until its lockout
(described below) expires, regardless of remaining record allowance.
Exceeded limits
A request exceeding either limit receives a 429 Too Many Requests response with a
Retry-After header giving the number of whole seconds to wait. What that interval
represents differs by limit:
| Limit exceeded | Retry-After represents |
|---|---|
| Record limit | The time needed to replenish enough allowance for this request |
| Request limit | The time remaining on the lockout |
The response body's limit field (request or record) identifies which limit rejected the
request, and its scope field (organization or holding) identifies whose allowance was
exhausted, so neither needs to be inferred from the message text.
The two behave differently once exceeded. The record allowance replenishes continuously, so
capacity returns gradually. Exceeding the request limit locks the caller out for a full
60-second window (the standard window length), measured from the moment the limit was exceeded
— not merely the remainder of the current window — rather than admitting requests as capacity
frees up, so retrying early does not succeed (though it does not extend the lockout either). In
both cases waiting the stated interval is the fastest route to a successful retry.
Rejected requests leave no import state behind in either case. No records are imported, no partial
import is recorded, and no import history entry is created. The identical request body may be
resubmitted once the Retry-After interval has elapsed; no deduplication or reconciliation is
required.
Rejected by the record limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 10
{
"errorCode": "TOO_MANY_REQUEST",
"message": "Import record budget exhausted. Retry after 10 seconds.",
"limit": "record",
"scope": "organization",
"requestId": "..."
}
Rejected by the request limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
{
"errorCode": "TOO_MANY_REQUEST",
"message": "Too Many Requests",
"limit": "request",
"scope": "organization",
"requestId": "..."
}
Implementation guidance
- Observe the
Retry-Afterheader. It is returned on every 429 and is derived from the
actual remaining interval rather than a fixed backoff schedule, so a retry submitted before it
has elapsed is rejected again. Exponential backoff is neither required nor helpful for these
responses and will delay completion unnecessarily. - Base pacing on the header rather than on the published values. The limits stated above are
current values and are subject to change. Integrations that pace onRetry-Afterremain
compliant if they are adjusted. - Submit full batches. A request containing 100 records consumes the allowance considerably
more efficiently than 100 requests containing one record each. - Account for concurrent importers. Where several processes import into the same organization
the allowance is shared, and repeated pacing should be expected. Waiting the stated interval
admits the retry unless another request consumes the replenished allowance first. - Submit large migrations at a steady rate. A one-off import of several hundred thousand
records will be paced. Steady submission completes in the same total time as submission in
concentrated bursts, with fewer rejections.
Import status
Requests to the import status endpoint contain no records and are subject to the request limit only.
Updated 19 days ago