Rate limits

Every request spends a token from a bucket. The bucket refills continuously, so two numbers describe what you can do: the sustained rate you can hold indefinitely, and the burst you can spend at once after a quiet period.

Limits are per tenant and per environment. Every key you hold in one environment spends from the same bucket, so a second key buys no extra budget and a batch job shares with your production traffic. Live and sandbox are counted separately.

The classes

Endpoints are grouped. There is no separate limit per endpoint.

ClassSustainedBurstRoughly, evenly paced
Portfolio ingest50/s5003,000/min
Reads20/s2001,200/min
Valuation5/s50300/min
Money2/s20120/min
Platform config1/s1060/min
Simulate1/s1060/min

Portfolio ingest is PUT /v1/borrowers/{borrowerId}/portfolio and nothing else. It is sized high on purpose: see below.

Reads is every GET, including the event log — except the two below.

Valuation is GET /v1/facilities/{facilityId} and GET /v1/facilities/{facilityId}/collateral. Both run your borrower's whole portfolio through CLC's risk engine on every call — pricing each position, revaluing the collateral, recomputing LTV and the per-asset headroom — so they cost far more than the GET next to them in the table and sit apart from the other reads.

Poll them on the cadence a person actually looks at a screen, not on the cadence a loop can manage. If you need to know the moment something changes, the webhooks tell you: facility.decisioned, margin_warning.raised, draw.completed. A tight poll spends this budget to learn nothing new, and then the one call you needed during a margin warning is the one that gets the 429.

Money is anything that creates a borrower, a signature, a facility, a draw, a repayment, or closes a facility. These are low volume by nature, since a decision sits behind each one.

Platform config is webhook registration, the signing secret, and the portfolio source. Configuration changes, not traffic.

Simulate is POST /v1/webhooks/simulate, which is bounded separately because it makes CLC open a connection to a URL you choose.

How the bucket works

Picture a bucket with a tap running into it. The bucket's capacity is the burst. The tap's rate is the sustained figure. Every request takes one token out, and a request that finds the bucket empty gets a 429.

Take Reads, which refills at 20 per second up to a ceiling of 200.

After a quiet period the bucket is full. Fire 200 requests at once and all 200 go through. The bucket is now empty, and from here you are held to the tap: 20 per second.

At exactly 20 per second you never run out. You spend 20 and gain 20 each second, so the level never moves. You can hold that rate forever.

At 30 per second you lose 10 a second. Starting from full, that is 200 ÷ 10 = 20 seconds before the first rejection, not an immediate one. The burst is your cushion, and it drains gradually.

Stopping refills it. From empty, 200 tokens at 20 per second is 10 seconds back to full.

The same shape at a smaller scale, using Money (2/s, burst 20): a nightly batch that wakes up and submits 20 repayments gets all 20 through immediately, then settles to one every half second. If your batch is 20 items or fewer, the burst absorbs the whole thing.

The two numbers answer different questions:

The question it answers
SustainedHow fast can I go indefinitely?
BurstHow much can I do right now, in one go?

Why not a single per-minute figure

A per-minute number tells you how much you may send but not when, and the when is what decides whether a spike is accepted.

Suppose the limit were "1,200 per minute", counted in fixed windows. Send all 1,200 in the first second, sit out the remaining 59, then send 1,200 more the instant the window turns over. You never break the stated limit, and CLC sees 2,400 requests in about two seconds. Every client doing this lines up on the same clock edge, which is the worst possible arrival pattern.

A bucket cannot be gamed that way. You never get more than the burst in a row, and the refill applies continuously instead of resetting on a clock edge.

The per-minute column in the table above is just the sustained rate multiplied out. Read it as intuition, not as a budget you can spend however you like inside the minute.

Portfolio updates are shed, not rejected

The portfolio endpoint behaves differently when you push past its limit, because it can afford to.

It is a full replace ordered by as_of, so when several snapshots for the same borrower arrive close together, only the newest one matters. Rather than reject the extras, CLC keeps the newest as_of per borrower and drops the ones it supersedes. The outcome is identical to having processed all of them, and the superseded ones come back 200 with applied: false.

So a borrower doing something frantic never costs you a rejected update, and never leaves CLC holding a stale collateral value. Only a flood far beyond the class limit is answered with 429.

No other endpoint can do this. Dropping a repayment loses money, so those are rejected rather than shed.

Reading your budget

Every response carries your current position, not only the ones that fail. Slow down before you hit the wall rather than after.

RateLimit-Limit: 200
RateLimit-Remaining: 60
RateLimit-Reset: 7

RateLimit-Limit is the burst ceiling for the class you just called and RateLimit-Remaining is what is left in that bucket. RateLimit-Reset is the seconds until the bucket is full again: 140 tokens are missing above, and Reads refill at 20 per second, so 7.

It is not how long until you may send the next request. Tokens arrive continuously, so at 20 per second the next one is 50 ms away. Reset answers a different question: when your full burst is back, which is what tells a batch job whether pausing is worth more than trickling along at the refill rate. For how long to wait after a rejection, read Retry-After on the 429.

When you run out

{
  "type": "https://developers.clc.solutions/errors/rate_limited",
  "title": "Too Many Requests",
  "status": 429,
  "code": "rate_limited",
  "detail": "Rate limit exceeded for class 'money'."
}

The response carries Retry-After in seconds. Wait at least that long. Retrying sooner spends nothing and gets nothing, and a tight retry loop keeps the bucket empty for your own legitimate traffic.

If the call was a mutation, reuse the same Idempotency-Key on the retry. A 429 means the request never ran, so the key is still unused.

Worth knowing

Sandbox and live carry the same numbers but separate budgets. They are separate stacks, so hammering sandbox cannot consume anything in live. The numbers match on purpose: a load test is only worth running if it predicts what live will do, and it cannot if the two are sized differently.

Idempotent replays count. A retry with a key CLC has already seen is answered from the record rather than executed, but it still spends a token. Otherwise a retry storm would be free, and the bucket would stop protecting anything.

Only requests that authenticate as you count against you. Your key_id is a public identifier, so anyone who has seen it can put it in a header. Those requests never reach your bucket: nothing is charged to you until the signature verifies. Requests that fail to verify are limited separately, by source, and never touch your budget.


Did this page help you?