Rate limits
How the API bounds bursts per key and per workspace, what the rate-limit headers say, what a 429 means and how to retry properly.
The Transaction Intelligence API protects its engine from bursts with an operational rate limit, published in the contract under the x-noryo-rate-limit extension of openapi.json, with a policy version (policy_version: debit_ti_v1).
What it is, and what it is not
The contract describes it as operational burst protection (kind: operational_burst_protection) and states explicitly what it is not:
| Not this, according to the contract | What that means for you |
|---|---|
sandbox_lifetime_quota | It does not count your requests over the lifetime of the sandbox: the bucket refills continuously. |
production_usage_quota | It says nothing about a permitted volume in production, which is not open. |
billing_entitlement | Nothing is billed. |
commercial_promise | The published numbers are versioned settings, not a commercial commitment. |
Two token buckets
The algorithm is a token bucket (token_bucket), applied to two scopes at once:
- the key: every API key has its own bucket;
- the workspace: a second bucket bounds the sum of all the workspace's keys: a hundred keys do not make a hundred times the limit.
The values in force are the contract's:
debit_ti_v1operational_burst_protection · token_bucket« 120 requetes / minute » est traduit en seau a jetons : capacite 120 (la rafale toleree) rechargee a 2 jetons / seconde (le regime durable). Une clef au repos peut envoyer 120 requetes d'un coup, puis 2 par seconde ; sur la premiere minute apres un repos, jusqu'a 240 passent. Le workspace, lui, borne la somme de toutes ses clefs a 600 / minute (capacite 600, 10 / s) : cent clefs ne font pas cent fois la limite.
This is not: sandbox_lifetime_quota · production_usage_quota · billing_entitlement · commercial_promise
Each bucket has a capacity (the tolerated burst) and a refill rate (the sustainable pace, in tokens per second). "N requests per minute" translates that pair: a key at rest can empty its bucket in one go, then carries on at the refill rate.
The count is central and atomic, one request at a time, with no per-server local counter. The environment is part of the bucket's identity (environment_scoped): the published values are the sandbox's. Production is not open, and its settings are not a commitment. See Sandbox limitations.
Reading the headers
As soon as a request is authenticated, the response carries the state of the limit, refusals included.
| Header | Present on | Meaning |
|---|---|---|
noryo-ratelimit-limit | success, 422, 404, 409, 429 | The burst capacity of your key. |
noryo-ratelimit-remaining | success, 422, 404, 409, 429 | What your key can still send right now: the smaller of the two buckets, the key's and the workspace's. |
Retry-After | 429 only | Whole seconds before the bucket that refused will cover a new request. |
noryo-request-id | every response served after authentication (every error carries request_id in its body) | The request identifier, to quote to support. A 404 (unknown path) or a 405 (undeclared verb) comes from the host, before the API, without a request_id. |
noryo-ratelimit-remaining can read zero while your key still has tokens: the workspace bucket is then the empty one.
When the limit is reached: 429
Beyond the limit, the API answers 429 with an application/problem+json body of type rate_limited and a Retry-After header. Nothing was computed, nothing was stored, no event was emitted. The request can be replayed as is once the delay has passed.
{
"type": "https://noryo.app/problems/transactions/rate_limited",
"title": "Trop de requetes.",
"status": 429,
"detail": "La limite de debit de cette clef est atteinte. Rejouez apres le delai de Retry-After.",
"request_id": "req_01J9…"
}JSONWhen the workspace is the scope that refused, the detail says so. title and detail are texts meant for a person, written in French by the API: base your logic on type and status, never on their wording. See the error catalogue and the Errors page.
The limiter never fabricates a 429 when it is itself failing: it answers 500 (internal_error) rather than letting traffic through unlimited.
What consumes a token, and what does not
The contract fixes the processing order: host, content type, body size, credential, then authentication, prefix, usage and rate limit in a single transaction, then JSON, Idempotency-Key, schema and business logic. The token is consumed as soon as authentication succeeds, before the body is read at all: every authenticated request consumes one, whatever its response.
| Response | Token consumed | Why |
|---|---|---|
201 created; 200 read or coverage recorded | yes | Request authenticated and processed. |
200 idempotent replay | yes | Authenticated and processed, even though nothing was created. |
422 schema; 404 not found; 409 idempotency conflict | yes | The credential was valid; the refusal comes after the rate limit. |
400 unreadable JSON or Idempotency-Key outside the alphabet | yes | Same reason. |
401 key missing, malformed, from another family, unknown, revoked, or under a divergent prefix | no | No bucket touched, no usage recorded; an unknown credential does not learn that a bucket exists. |
403 workspace or organisation inactive | no | Same rule as a 401. |
400 undeclared host; 413; 415 | no | Refused before the credential is read at all. |
GET /openapi.json | no | Public, without a credential. |
The three counted operations are POST /transactions/v1/enrichments, GET /transactions/v1/enrichments/{enrichment_id} and POST /transactions/v1/coverage-windows, one token each: a read costs as much as an enrichment.
Retrying properly
- Honour
Retry-After. Wait at least the delay given: it is the number of seconds before the bucket that refused will cover a new request. - Replay the same request, with the same
Idempotency-Key. A 429 is decided before the body is read: the replayed request is processed as if for the first time. If an earlier attempt did succeed but the response never reached you, the same key gets you a200replay rather than a duplicate. See Enrich a transaction. - Cap your attempts and smooth your sending. When 429s repeat, send at the refill rate rather than adding attempts: the capacity absorbs an occasional peak, it is not a sustainable rate.
- Watch
noryo-ratelimit-remainingand slow down as it approaches zero. - Coordinate your keys. Spreading load across several keys adds no capacity beyond the workspace bucket.
A fetch example that honours Retry-After and keeps the Idempotency-Key from one attempt to the next:
async function enrich(transaction: unknown, idempotencyKey: string, maxAttempts = 3) {
for (let attempt = 1; ; attempt++) {
const response = await fetch("https://api.noryo.app/transactions/v1/enrichments", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NORYO_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(transaction),
});
if (response.status !== 429 || attempt >= maxAttempts) return response;
// Retry-After is in whole seconds.
const seconds = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}
const response = await enrich(
{
descriptor: "PRLV SEPA NETFLIX INTERNATIONAL",
direction: "outgoing",
occurred_at: "2026-09-05T03:10:00+02:00",
amount: { value: "13.49", currency: "EUR" },
payment: { rail: "sepa_direct_debit" },
},
crypto.randomUUID(),
);
console.log(response.status, response.headers.get("noryo-ratelimit-remaining"));TypeScriptWhere to see your 429s
429s appear on Platform, on the Usage page, Transaction Intelligence section, in the category of limited requests, per key and per workspace. 401s never appear there: a revoked or unknown key is not counted. See Usage and API keys.
Published on 2026-09-17 11:19 UTC (version 1).