Quota & rate limits
Kaleidr limits usage in two different ways, and both answer with HTTP 429:
- Quota is how much your organization may use in a billing period. Running out means "wait for the period to reset, or add more".
- Rate limits are how fast you may call. Hitting one means "slow down for a few seconds".
The response body tells you which one you hit. This page shows every body, what each call is charged, and how to handle each case.
The two pools
Usage is counted per organization, in two pools.
| Pool | What spends it | Free | Pro | Enterprise |
|---|---|---|---|---|
| AI credits | Chat answers, place lookups, address search, design analysis | 500,000 per period | 5,000,000 per period | Custom, set in your contract |
| Map loads | Each Tile session, and each view of a published or embedded map | 50,000 per period | 50,000 per period | The same base allowance; higher volumes are set in your contract |
Three things about the pools are easy to get wrong:
- AI credits are one pool for the whole organization. Your API keys draw from the same AI credits your team uses inside Kaleidr's own apps. There is no separate API budget, and every key shares it.
- The period is your billing period. For a paid subscription that is the subscription's monthly cycle, starting on the day you subscribed. Without one it is the calendar month. Periods roll over at midnight US Eastern time.
- Packs raise the limit. Purchased AI-credit packs and map-load packs are added to the plan's allowance, and
the
limityou see in responses includes them.
Plan changes reach your existing keys within a few minutes. You do not need to create new keys.
What each call is charged
| Call | Pool | Charge |
|---|---|---|
POST /chat/control/stream, /chat/summary/stream, /chat/button/stream, /spot/overview/stream | AI credits | The tokens the answer used, input plus output. See How a stream is charged. |
POST /chat/control/route | none | Not charged. A hold is placed while the route is planned and always released. |
POST /spot | AI credits | A flat fee per call, charged whether or not anything was found: the fee of /retrieval/poi/enrich when want includes content or photos and the content is fetched for this call, otherwise the fee of /retrieval/geocode/forward when it includes address. Content already fetched for an earlier request costs nothing. A call for places, regions and outlines only is free. |
GET /retrieval/poi/enrich | AI credits | A flat fee per call, charged whether or not content was found. |
GET /retrieval/geocode/forward | AI credits | A smaller flat fee per call, charged even when there are no results. Not charged if the lookup itself fails. |
POST /design/analyze | AI credits | A flat fee per successful call. Not charged when the call fails. |
POST /design/apply-direct, GET /design/styles, /design/samples, /design/themes | none | Not charged. |
POST /sdk/sessions for chat, editor, viewer | none | Not charged. |
POST /sdk/sessions for tile | Map loads | One map load per exchange. |
POST /sdk/share-sessions | none | Not charged. The chat calls made with the session are charged to the map owner. |
POST /inference-api/vendor/mapping/propose, the mapping proposal of the business-data upload API | AI credits | The tokens the proposal used. Called with an account sign-in, not an API key. When the credits are used up it answers 429 quota_exceeded; it also has its own limit of 30 proposals per account per hour, and the two 429s differ in their body. Uploading, publishing, unpublishing and deleting are not charged. |
Your current usage and limits are on kaleidr.com/billing.
How a stream is charged
A streaming call does not know its cost until it finishes, so it is charged in two steps:
- Hold. When the request is admitted, a fixed number of credits is held from the pool. If the pool cannot
cover the hold, the request is refused with
quota_exceededbefore any answer is produced. This means a call needs some headroom in the pool to start, even when it would have cost less. - Settle. When the stream ends, the hold is replaced by the real charge: the tokens the answer used. The real charge can be more or less than the hold.
If you abort a stream, or it fails before the answer completes, the token count is not known and the charge is an estimate:
| Stream | Charge when it ends without completing |
|---|---|
/chat/control/stream | Whichever is larger: an estimate of the conversation you sent (the user and assistant messages in the request), or an estimate of the text already streamed, which is counted up to the hold. This can be more than the hold. |
/chat/summary/stream, /chat/button/stream, /spot/overview/stream | An estimate of the text already streamed, never more than the hold. |
So closing a chat stream early does not make a long conversation free: the request is charged for at least its own size, however little of the answer you read. Keep requests small by sending only the turns the next answer needs. See Reading the stream.
A limit reached while a stream is running does not interrupt that stream. The check happens at admission; the next request is the one that is refused.
A refused request is not charged. A request that is refused before it is processed (a rate or concurrency limit, a validation error, a body over the size limit) holds no AI credits and no concurrency slot afterward. You can retry it without having paid for the refusal.
A cap on one key
A key can carry a monthly cap (monthly_cap_tokens, set through the
key management API). It limits what that one key may spend from the
organization's AI credits in a period. The organization's pool is still the outer limit.
A request that would take a capped key over its cap is refused with the same quota_exceeded body, where
limit and used are the key's numbers instead of the pool's.
Quota exceeded
HTTP/1.1 429 Too Many Requests
Retry-After: 1296000
Content-Type: application/json
{
"error": "quota_exceeded",
"detail": "Usage limit reached. Please try again later.",
"product": "b2b",
"meter": "b2b.tokens",
"tier": "pro",
"limit": 5000000,
"used": 4999100,
"remaining": 900,
"reset_at": "2026-11-01T04:00:00+00:00",
"period": "month",
"reset_in_seconds": 1296000
}
| Field | Meaning |
|---|---|
error | Always quota_exceeded for this case. Branch on this field. |
detail | A sentence for a person. Do not parse it. |
product, meter | Identify the API's AI-credit counter. Always b2b and b2b.tokens for platform keys. |
tier | Your organization's plan. |
limit, used, remaining | The pool's numbers in credits, or the key's numbers when a per-key cap refused the call. remaining can be more than zero: the call was refused because the hold did not fit. |
reset_at | When the period ends, as an ISO 8601 timestamp with offset. Use this to tell a user when service resumes. |
period | month. |
reset_in_seconds | Seconds until reset_at, the end of the period your pool resets on (your subscription cycle, or the calendar month without one). The Retry-After header carries the same number. |
The body is flat: the fields sit at the top level, not inside a detail object.
With a session token
The body above is what a server key or legacy key receives. A request made with a session token runs in a visitor's browser, so its refusal does not disclose the organization's plan, usage or limit. This applies to a session exchanged from a publishable key and to a published map's visitor session.
{
"error": "quota_exceeded",
"detail": "Usage limit reached. Please try again later.",
"meter": "b2b.tokens",
"reset_at": "2026-11-01T04:00:00+00:00",
"reset_in_seconds": 1296000
}
| Field | Server key or legacy key | Session token |
|---|---|---|
error, detail | Yes | Yes |
meter | Yes | Yes |
reset_at, reset_in_seconds | Yes | Yes |
product, period | Yes | No |
tier | Yes | No |
limit, used, remaining | Yes | No |
The status, the Retry-After header and the meaning are the same. In browser code, branch on error and show
reset_at; do not read limit, used or remaining. To show usage numbers, read them on your backend with a
server key, or on kaleidr.com/billing.
If the body shows used: 0 while you know the organization has been active, the API could not read your usage
and refused the call to be safe. Retry after a few seconds instead of waiting for reset_at.
Rate limits and concurrency
Three limits sit in front of every runtime call. Each refuses the excess at once; nothing is queued.
| Limit | Counted per | What it covers |
|---|---|---|
| Requests per minute | Key | A rolling 60-second window over every runtime call made with the key and with every session exchanged from it. All visitors of a site that uses one publishable key share this budget. |
| Session exchanges per minute | Key | Calls to POST /sdk/sessions. A separate budget, so mounting embeds does not use up runtime calls. |
| Concurrent requests | Organization | Calls in progress at the same moment across all of the organization's keys. A stream counts for as long as it is open. |
A key's requests-per-minute value is not editable from the API Keys page or the key management API. If your traffic needs a higher rate, contact us.
Every one of these refusals has the same shape and the same header:
HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/json
{ "error": "rate_limited", "detail": "API key rate limit exceeded. Slow down." }
error | Meaning | What to do |
|---|---|---|
rate_limited | The key's requests-per-minute or exchange budget is used up. | Wait Retry-After seconds, then retry. Add jitter. |
org_overloaded | Your organization has too many calls in progress. | Wait for one of your open calls to finish, then retry. Close streams you no longer need. |
b2b_overloaded | The API is at capacity for all callers. | Wait Retry-After seconds, then retry with backoff. |
limiter_unavailable | The rate limiter could not answer. | Retry shortly. Slowing down does not help; this is not about your rate. |
visitor_limit_reached | One visitor of a published map's chat has asked too much, per minute or per day. | Show the visitor a "try again later" message. See Chat on a published map. |
Requests are also rate limited at the network edge, per IP address and per credential. A request blocked there does not carry one of the JSON bodies on this page. Treat a 4xx response without a Kaleidr body as "back off and retry later".
Map loads
A map load is counted each time a Kaleidr map starts up for a viewer:
- each session exchange for
product: "tile", including the SDK's refresh and each remount of the element; - each view of a published or embedded map.
The embedded Editor's session exchange is not a map load. Its basemap requests that carry your publishable key (the style, the sprites and the fonts) are counted in bulk instead: every 20 of them your organization makes within an hour add one map load, after the fact. They are never refused for the allowance. Map tile data itself is not counted.
Every plan stops at its allowance. When the pool is used up, the next load is refused and not counted. There is no grace band on paid plans; a purchased map-load pack raises the limit immediately.
From the session exchange the refusal is:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{ "detail": "map_load_quota_exceeded" }
This body is wrapped in detail, unlike the flat quota body above, and has no Retry-After header. Retrying
does not help until the period resets or you add a pack. In the page, the Tile embed fails to start and the SDK
logs kaleidr session exchange failed (429).
Direct tile requests
The basemap tile host is not a public tile API. The supported way to show a Kaleidr basemap is the
Tile product. A request to tile.kaleidr.com that does not come from a Kaleidr embed
with a valid publishable key on an allowed origin is refused:
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
Cache-Control: no-store
Content-Type: application/json
{
"error": "tile_access_restricted",
"message": "Direct access to tile.kaleidr.com requires a Kaleidr publishable key. Mint a free one at kaleidr.com/api-keys and pass it as ?key= from an allowed origin. Kaleidr-hosted maps and embeds are unaffected.",
"docs": "https://docs.kaleidr.com/get-an-api-key"
}
This response comes from the tile host, not from the platform API, so it uses message where the API uses
detail. A new or edited key can take a few minutes to be accepted for tiles.
The quota event on a stream
A chat stream reports the pool after it has been charged. The quota event is the last event of the
stream, sent after end or error:
event: quota
data: {"type": "quota", "product": "b2b", "meter": "b2b.tokens", "tier": "pro", "used": 184223, "limit": 5000000, "reset_at": "2026-11-01T04:00:00+00:00", "period": "month"}
That is the event on a stream read with a server key or legacy key. On a stream read with a session token (exchanged from a publishable key, or a published map's visitor session) the event carries no plan and no numbers:
event: quota
data: {"type": "quota", "meter": "b2b.tokens", "reset_at": "2026-11-01T04:00:00+00:00"}
| Field | Server key or legacy key | Session token |
|---|---|---|
type, meter, reset_at | Yes | Yes |
product, period | Yes | No |
tier | Yes | No |
used, limit | Yes | No |
With a server key, use it to update a usage meter without a second request. Three limits apply:
- It has no
remainingfield. Computelimit - used. - It is sent on a best-effort basis. A stream can end without it, so do not wait for it.
- It reports; it does not warn. It never arrives in the middle of an answer, and it cannot stop a stream.
To receive it, keep reading until the response body closes. A client that stops at end misses it.
Handling 429 in code
Read the body before deciding. The status alone does not tell you whether to retry.
| You receive | Retry? | When |
|---|---|---|
error: "quota_exceeded" | No | After reset_at, or after adding credits or raising the key's cap. Show the user a "limit reached" message. |
error: "rate_limited", "org_overloaded", "b2b_overloaded", "limiter_unavailable" | Yes | After Retry-After seconds, with jitter, a few times at most. |
error: "visitor_limit_reached" | No | Later; tell the visitor. |
detail: "map_load_quota_exceeded" | No | After the period resets or a map-load pack is added. |
error: "tile_access_restricted" | No | Fix the embed: use the Tile product with a publishable key on an allowed origin. |
const RETRYABLE = new Set(['rate_limited', 'org_overloaded', 'b2b_overloaded', 'limiter_unavailable']);
async function callKaleidr(url, init, attempt = 0) {
const res = await fetch(url, init);
if (res.status !== 429) return res;
const body = await res.json().catch(() => ({}));
if (RETRYABLE.has(body.error) && attempt < 3) {
const seconds = Number(res.headers.get('retry-after')) || 5;
await new Promise((resolve) => setTimeout(resolve, (seconds + Math.random()) * 1000));
return callKaleidr(url, init, attempt + 1);
}
const error = new Error(body.error || body.detail || 'rate limited');
error.status = 429;
error.code = body.error || body.detail;
error.resetAt = body.reset_at; // present for quota_exceeded
throw error;
}
In a browser, the Retry-After header is readable from JavaScript on platform API responses.
Related
- Errors: every status and code.
- SSE wire contract: where the
quotaevent sits in a stream. - Reading the stream: complete clients with retry and timeout handling.
- Get an API key: what each plan includes.