# 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](https://api.noryo.app/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_v1` — operational_burst_protection · token_bucket

- per key: 120 requests / minute (capacity 120, refill 2/s)
- per workspace: 600 requests / minute (capacity 600, refill 10/s)

« 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.

It 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](https://developer.noryo.app/en-GB/transaction-intelligence/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.

```json
{
  "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…"
}
```

When 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](https://developer.noryo.app/en-GB/transaction-intelligence/errors/rate_limited) and the [Errors](https://developer.noryo.app/en-GB/transaction-intelligence/errors) page.

> **Note** 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

1. **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.
2. **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 a `200` replay rather than a duplicate. See [Enrich a transaction](https://developer.noryo.app/en-GB/transaction-intelligence/enrich-a-transaction).
3. **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.
4. **Watch `noryo-ratelimit-remaining`** and slow down as it approaches zero.
5. **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:

```ts
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"));
```

## Where to see your 429s

429s appear on Platform, on the [Usage](https://platform.noryo.app/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](https://developer.noryo.app/en-GB/transaction-intelligence/usage-and-api-keys).

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/rate-limits
