The integration worked for eight months. Then finance ran the quarter-end batch, the loader fired every invoice it had been holding, and the first few hundred calls went through. The rest came back 429. The job did what jobs do — it retried, immediately, all of them, and got 429 again. By the time someone looked, the batch had been running for two hours and had processed less than it would have processed by going slowly on purpose.
Nothing was broken. The API answered every request correctly. The document AI rate limit did exactly what it is for, and the integration had simply never decided what to do when it arrived.
That decision is the subject here. Not the number — the behaviour around it.
A limit is a clause, not an incident
Most integration code treats the limit as an exception path: something that happens when things go wrong, handled next to timeouts and malformed payloads. That framing is why it is usually handled badly. A timeout is a failure — something that should have worked did not. A refusal at the limit is the opposite: it is the contract being honoured. You asked for more throughput than you are entitled to, and you were told so, precisely, in a machine-readable way, before anything was charged.
Read as a clause, the limit is one of the few parts of an API that tells you the shape of your own capacity. It says how much work you may push through a credential per unit of time, what happens to the surplus, and how you find out. An integration that has read that clause behaves differently under load than one that has only read the happy path — not because it retries better, but because it never generates the surplus in the first place.
A fixed window is not a token bucket
The mechanism matters more than the magnitude, because it determines what your client should do. Cogneris publishes a fixed window on the document surfaces: the allowance resets on the minute rather than refilling gradually. That is stated plainly on the Environments page, and it is not a detail — a fixed window and a token bucket punish opposite behaviours.
A token bucket refills continuously. A client that paces itself evenly is rewarded, because a token is always becoming available, and a client that saves up its calls gains nothing beyond the burst size. A fixed window has no memory and no gradient. The allowance is spent or it is not, and at the boundary it is replaced whole. Two consequences follow, and both bite integrations that were written against a bucket in someone's head.
- The boundary is a cliff, not a slope — an allowance spent in the first seconds of a window leaves the rest of that window with nothing, however idle the service is.
- Two windows can touch — a client that spends its full allowance at the end of one window and again at the start of the next has issued twice the nominal rate across a span of seconds, entirely within the rules.
- Backing off to the boundary is a herd — every client that decides to "wait for the next minute" arrives at the same instant, and the ones that arrive last are refused again.
The third is the one that turns a brief refusal into a sustained one. It is also the one that is trivial to avoid: honour the Retry-After the response carries instead of computing a wait yourself, and add jitter so that your own workers do not synchronise with each other. The server already knows how long the window has left. Guessing is strictly worse than reading.
429 and 402 are not the same refusal
Rate and quota get discussed as one topic and are two different clauses with two different remedies. The API keeps them apart, and code that collapses them will retry forever against a wall that is not going to move.
| Response | What it means | What the caller should do |
|---|---|---|
429 | Too much traffic in the current window for this key. | Wait out the window and retry. The work is still yours to do. |
402 | Billing quota exhausted. The Portal surface carries the reason in detail, with an upgrade URL where one applies. | Stop retrying. Nothing about waiting changes the answer; a human has to decide something. |
413 | The payload is over the size limit for that endpoint. | Split the document or move it to an async job. Retrying the same bytes is pointless. |
The distinction has an operational shape: a rate limit is a statement about time, and a quota is a statement about entitlement. Time passes on its own. Entitlement does not. An alert that fires on "limit errors" without separating the two will page someone at 3 a.m. for a condition that resolves in forty seconds, and stay quiet for the one that will not resolve at all until a contract changes.
The allowance is attached to the key
The rate is published per API key. That single word decides more integration architecture than it appears to, because it means the credential is also the capacity unit. How you split keys is how you split throughput.
This is where limit design stops being an infrastructure concern and becomes a credential one. If a single key serves the interactive path that a customer is waiting on and the nightly reconciliation job that nobody is waiting on, then the batch can consume the allowance the interactive path needs, and the customer-facing request is the one that gets refused. Separating them is not primarily a security decision — though it is a good one for that reason too, which is the subject of a credential's blast radius. It is a capacity decision that the key boundary happens to enforce.
- Interactive work gets its own key — so that no background job can spend the allowance a waiting user depends on.
- Bulk work gets a key and a pace — the batch is the traffic shape that discovers the limit, so it is the one that should be deliberately slowed.
- Per-tenant keys make the noisy neighbour visible — when the refusals are attributable, the conversation is about one customer's batch rather than about "the API being slow".
Note the direction of travel here. This is the integrator deciding how to spend an allowance. The other side of that boundary — a platform deciding what allowance each tenant gets, and where that policy is stored — is a different problem with a different owner, and it is treated separately in the control plane piece. The limit you are handed and the limit you set are not the same engineering problem.
Sizing before you are barred
Capacity planning against a fixed window is unusually tractable, because the arithmetic is not statistical. You are not estimating a percentile. You are asking whether a known number of requests fits inside a known number of windows, and the only hard part is being honest about the shape of the arrival rate rather than its average.
Averages are what make integrations fail this test. A daily volume divided by the minutes in a day always fits. The same volume arriving in the eleven minutes after a nightly export completes does not. Three questions produce a usable answer:
- What is the largest burst, not the mean? — find the worst arrival minute in a month of real traffic, and size against that.
- How long may the surplus wait? — a queue that may drain over an hour is a very different design from one that may not.
- What else shares the key? — the allowance is consumed by every caller holding that credential, including the ones you did not write.
If the answer is that the peak genuinely does not fit, the remedy is a conversation rather than a code change: limits are tunable on Enterprise plans. Discovering that in a planning meeting is inexpensive. Discovering it during a month-end run is not.
The caller's queue is the real answer
The instinct when work does not fit is to retry harder. It is precisely wrong, and it is worth being explicit about why: a retry does not create capacity. It re-presents work that has already been refused, and under a fixed window it re-presents it into the same window that just said no. Retrying is how a client converts a small surplus into a large one.
The structure that works is a queue on your side of the boundary, drained at a rate you control. The queue is what turns the limit from a failure mode into a scheduling parameter. Once it exists, the refusal stops being an error condition at all — it becomes feedback about the drain rate, which is exactly what backpressure means.
Two mechanics make this safe rather than merely slower. Send an Idempotency-Key on anything that creates, so that a retry resolves to the work you already submitted instead of doing it twice — the Portal documents this explicitly, including that the replay does not repeat the URL and does not charge a second time. And keep the retried request byte-identical, because an idempotency key over a changed payload is not a safety mechanism, it is a way of making two different operations look like one.
The bulk case is documented with unusual bluntness: creating links in bulk is the usual way to hit the limit, and the published advice is to spread a large campaign across windows and send a key so that a refusal is safe to retry. That is the whole pattern in one sentence, and it generalises to every surface.
When the answer is not a retry at all
Some work does not belong on the synchronous surface, and no amount of pacing changes that. When a document is large, or the operation is long, the platform offers a different shape: submit a job to POST /api/v1/document-jobs and collect the result later. The response is 202 with a Location header and a Retry-After hint, and you either poll that URL or subscribe an endpoint and let the completion event come to you.
Read as a capacity decision rather than a convenience, this is the most useful move available. It converts request-rate pressure into job-queue pressure — and the job queue is the platform's problem, sized by the platform, rather than yours to pace. The quickstart shows the shape; the point here is when to reach for it, which is earlier than most integrations do. If you are pacing a loop to squeeze synchronous calls under a per-minute allowance, the async surface is usually the design you actually wanted.
Backpressure runs in both directions
The limit is easy to think of as a one-way constraint, imposed on you. It is not. When Cogneris calls your endpoint with a completion event, your service is the one with capacity, and the retry schedule is published: 1 minute, 5 minutes, 30 minutes, then 2 hours after the preceding attempt, after which the delivery stops. Retryable responses are 408, 425, 429 and any 5xx; everything else is treated as final.
Three things follow for whoever owns the receiving endpoint, and they are worth writing into the runbook rather than rediscovering during an outage.
- Your downtime budget is finite and knowable — the four attempts span a little over two and a half hours from the first delivery. An outage longer than that loses deliveries rather than delaying them.
- Returning
429is legitimate — if your handler is saturated, refusing with a retryable status is better than accepting a request you will drop, because it keeps the delivery in the schedule. - Exhaustion has a recovery path — deliveries that ran out of attempts during an outage are replayed through the replay endpoint, not by asking for a resend.
Acknowledging fast and processing asynchronously is the standard advice, and the reason is a capacity one: a slow handler burns the request timeout and is then retried as a failure, so a handler that is merely slow is indistinguishable from one that is down. The signing and idempotency mechanics of that callback are their own subject, covered in the webhook contract piece; what belongs here is only the observation that both ends of the integration have a limit, and only one of them is usually written down in your design docs.
It is a different problem, incidentally, from an autonomous agent flooding a third-party system it does not control — that failure mode, and the token bucket that fixes it, sits in the always-on agents piece. Here the limit is published, the mechanism is documented, and the pacing is a design choice rather than a discovered constraint.
What this looks like when it is done
None of this is elaborate. An integration that has taken the limit seriously differs from one that has not in about five observable ways.
- It separates rate from quota —
429and402take different code paths and page different people, or nobody. - It honours
Retry-After— with jitter, rather than computing its own wait and arriving in a herd. - It queues rather than retries — the surplus waits somewhere it can be measured and drained at a chosen rate.
- It sends idempotency keys — so that the retry which does happen cannot produce a duplicate or a second charge.
- It knows which key it is spending — and does not let a background batch consume the allowance an interactive path depends on.
The allowance itself belongs on the Environments page, where the current figures for your plan are published alongside the regional endpoints — that is the number to design against, and it is the one to check rather than remember, because it is the kind of number that changes. The behaviour above does not change when it does. That is rather the point of building against the clause instead of against the figure.
The batch at the start of this piece did eventually finish. What fixed it was not a higher limit or a smarter retry — it was a queue, a rate, and someone deciding, once, how fast the work was allowed to go.
