Errors
How to read an application/problem+json error from Transaction Intelligence, what to do for each HTTP status, and which requests to retry.
Every error returned by the Transaction Intelligence API has the same shape: an application/problem+json body (RFC 9457) whose type field identifies the error in a stable way. The full catalogue, generated from the contract, is at the bottom of this page.
The shape of an error
An error is a Problem object.
| Field | Presence | Role |
|---|---|---|
type | always | A stable URI of the form https://noryo.app/problems/transactions/<code>. This is what a client branches on. |
title | always | A human-readable sentence. It may be reworded: do not compare it. |
status | always | The HTTP status, repeated in the body. |
detail | depends on the error | A context sentence, never the rejected data: the scope (key or workspace) on a 429, a reminder that the schema is closed on invalid_input. Absent on some errors. |
errors | on a 422 | A list of { field, code } objects: the offending field as a dotted path (amount.currency, account_hint.iban), and the code of the rule. |
request_id | always | The request identifier, to quote to support. |
Schema Problem
- typestring · urirequired
https://noryo.app/problems/transactions/account_inactivehttps://noryo.app/problems/transactions/engine_unavailablehttps://noryo.app/problems/transactions/idempotency_conflicthttps://noryo.app/problems/transactions/internal_errorhttps://noryo.app/problems/transactions/invalid_api_keyhttps://noryo.app/problems/transactions/invalid_idempotency_keyhttps://noryo.app/problems/transactions/invalid_inputhttps://noryo.app/problems/transactions/invalid_requesthttps://noryo.app/problems/transactions/malformed_bodyhttps://noryo.app/problems/transactions/not_foundhttps://noryo.app/problems/transactions/rate_limitedhttps://noryo.app/problems/transactions/request_entity_too_largehttps://noryo.app/problems/transactions/unknown_currencyhttps://noryo.app/problems/transactions/unsupported_media_type - titlestringrequired
- statusintegerrequired
- detailstring
- errorsarray<object>
- fieldstringrequired
- codestringrequired
- request_idstring
Present on every error returned by the API: it is what you give to support. It is also in the
noryo-request-idheader of success responses. Two responses come from the host, before the API, and are not Problem objects: an undeclared verb on a declared path returns405with an empty body and noAllowheader; an unknown path returns404. Neither carriesrequest_id. Measured at the public edge; documented rather than hidden.
An example, for a timestamp without a UTC offset:
{
"type": "https://noryo.app/problems/transactions/invalid_input",
"title": "Une contrainte du schema d'entree n'est pas respectee.",
"status": 422,
"detail": "Le schema est ferme : un champ inconnu est refuse plutot qu'ignore.",
"errors": [
{ "field": "occurred_at", "code": "must_be_rfc3339_with_offset" }
],
"request_id": "req_01J9…"
}JSONdetail is a context sentence, never the rejected data: a rejected descriptor may contain a person's name, and an error message travels through logs. errors[].field tells you where to look, errors[].code tells you which rule.
Reading a response
Check the Content-Type first, then the type. A status on its own is not enough: two responses described further down come from the host, without a Problem body.
const transaction = {
descriptor: "CARTE 09/09 MONOPRIX PARIS 15",
direction: "outgoing",
occurred_at: "2026-09-09T07:31:00+02:00",
amount: { value: "49.90", currency: "EUR" },
payment: { rail: "card" },
external_transaction_id: "tx-2026-09-09-0042",
};
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": "tx-2026-09-09-0042",
},
body: JSON.stringify(transaction),
});
const contentType = response.headers.get("content-type") ?? "";
if (contentType.startsWith("application/problem+json")) {
const problem = await response.json();
const code = problem.type.replace("https://noryo.app/problems/transactions/", "");
switch (code) {
case "rate_limited": {
const seconds = Number(response.headers.get("retry-after"));
// wait `seconds`, then replay the same request with the same Idempotency-Key
break;
}
case "invalid_input":
case "unknown_currency":
// problem.errors[].field and problem.errors[].code: fix before resending
break;
case "idempotency_conflict":
// same key, different request: do not replay as is
break;
default:
console.error(code, problem.request_id);
}
}TypeScriptBranch on type (or on its last segment) and on errors[].code, never on title or detail, which are sentences.
What to do, status by status
| Status | type | Cause | What to do |
|---|---|---|---|
400 | malformed_body | The body is not valid JSON. | Fix the serialisation. Do not replay as is. |
400 | invalid_idempotency_key | The Idempotency-Key header is outside the accepted alphabet. | Fix the header. |
400 | invalid_request | The request was sent to a host not declared in the contract. | Call https://api.noryo.app. |
401 | invalid_api_key | Credential missing, malformed, from another family, unknown or revoked. A single code for all these cases. | Check the key and the Authorization header. A revoked key does not come back: create a new one on Platform. See Authentication. |
403 | account_inactive | The key is recognised, but the workspace or organisation is not active. | Nothing to fix in the request. Quote the request_id to support. |
404 | not_found | On GET /transactions/v1/enrichments/{enrichment_id}: identifier malformed, unknown, expired, or from another workspace. All four cases return the same response. | Check the identifier you stored. An enrichment can be read back for 30 days; after that, enrich the transaction again. See Read an enrichment. |
409 | idempotency_conflict | This Idempotency-Key has already been used for a different request. | Do not replay as is: see below. |
413 | request_entity_too_large | The body exceeds the limit. | Reduce the body. One call handles one transaction. |
415 | unsupported_media_type | The body is not declared as application/json. | Send Content-Type: application/json. |
422 | invalid_input, unknown_currency | The schema is closed: unknown field or violated constraint. | Fix what errors[] names. Do not replay as is. |
429 | rate_limited | The operational rate limit has been reached, for this key or for the workspace. | Wait for Retry-After, then replay the same request. |
500 | internal_error | An internal error, not caused by your request. | Retry later, with the same Idempotency-Key: if the first attempt had gone through, its response comes back as a 200 instead of a duplicate. If the error persists, quote the request_id to support. |
422: fix before resending
The offending field is in errors[].field, the rule in errors[].code. Fix it, then resend; another rule may then be reported. Replaying the same request unchanged returns the same 422.
A currency outside the Money.currency enum is returned under the unknown_currency type; every other constraint under invalid_input. The list of possible codes is the runtime's own, by construction: read the 422 response of each POST in the reference, and the common cases in Enrich a transaction and Observation coverage.
409: same key, different request
"Different" is judged on the economic fingerprint (fingerprint) of the transaction: two bodies that differ only by external_transaction_id, account_hint or counterparty_hint share the same fingerprint and are the same request, replayed as 200. A 409 therefore cannot be replayed as is: either you meant to replay the original request, and it needs its original body back; or this is a new transaction, and it needs a new key. A key is retained for at least 24 hours.
429: wait for Retry-After
The detail says whether the limit reached is the key's or the workspace's. Nothing was computed, nothing was stored, no event was emitted. The Retry-After header gives, in whole seconds, the delay after which the request can go through; then replay the same request, with the same Idempotency-Key if there was one. See Rate limits.
What counts towards rate limits and usage
The gates run in a fixed order: host, Content-Type, body size, credential, authentication and rate limiting, JSON, Idempotency-Key, schema, processing. A refusal before the credential (400 on the host, 415, 413) or by it (401, 403) touches neither your rate limits nor your usage. Once the credential is accepted, a 400 on the JSON or on the Idempotency-Key, a 422, a 404 or a 409 has consumed a rate-limit token and counts towards your usage as a request. The 422, 404 and 409 responses carry the noryo-ratelimit-limit and noryo-ratelimit-remaining headers, as successes do. See Usage and API keys.
Two responses without a Problem
Two responses come from the host, before the API, and are not Problem objects: an undeclared verb on a declared path returns 405 with an empty body and no Allow header; an unknown path returns 404. Neither carries a request_id. Hence the Content-Type check before reading the body: a 404 without application/problem+json is a wrong path, not a missing enrichment.
Quoting the request_id to support
Every error returned by the API carries request_id in its body; successes carry it in the noryo-request-id header. Keep it in your logs, next to the key's public identifier (key_id), and give it to support. Never include the key's secret.
The catalogue
The catalogue below is generated from the contract served at https://api.noryo.app/openapi.json: the list of type values is the one the runtime can produce. Each code has its own page under /transaction-intelligence/errors/<code>, for example /transaction-intelligence/errors/rate_limited.
| Status | Code | Title |
|---|---|---|
400 | invalid_idempotency_key | The Idempotency-Key header must be 1 to 255 bytes of visible ASCII (0x21 to 0x7E). |
400 | invalid_request | The request is not served on this host. |
400 | malformed_body | The request body is not valid JSON. |
401 | invalid_api_key | The credential presented is not usable. |
403 | account_inactive | The workspace or organisation is not active. |
404 | not_found | No readable enrichment under this identifier. Unknown, expired or outside your scope: these cases are not distinguished. |
409 | idempotency_conflict | This Idempotency-Key has already been used for a different request. |
413 | request_entity_too_large | The request body exceeds the limit. |
415 | unsupported_media_type | The body must be application/json. |
422 | invalid_input | A constraint of the input schema is not met. The schema is closed: an unknown field is rejected rather than ignored. |
422 | unknown_currency | The currency is not in the reference list. |
429 | rate_limited | Too many requests. The rate limit for this key has been reached. Replay after the Retry-After delay. |
500 | internal_error | An internal error occurred. |
503 | engine_unavailable | The resolution engine cannot be reached. |
All errors
| Status | Code | Title |
|---|---|---|
400 | invalid_idempotency_key | The Idempotency-Key header must be 1 to 255 bytes of visible ASCII (0x21 to 0x7E). |
400 | invalid_request | The request is not served on this host. |
400 | malformed_body | The request body is not valid JSON. |
401 | invalid_api_key | The credential presented is not usable. |
403 | account_inactive | The workspace or organisation is not active. |
404 | not_found | No readable enrichment under this identifier. Unknown, expired or outside your scope: these cases are not distinguished. |
409 | idempotency_conflict | This Idempotency-Key has already been used for a different request. |
413 | request_entity_too_large | The request body exceeds the limit. |
415 | unsupported_media_type | The body must be application/json. |
422 | invalid_input | A constraint of the input schema is not met. The schema is closed: an unknown field is rejected rather than ignored. |
422 | unknown_currency | The currency is not in the reference list. |
429 | rate_limited | Too many requests. The rate limit for this key has been reached. Replay after the Retry-After delay. |
500 | internal_error | An internal error occurred. |
503 | engine_unavailable | The resolution engine cannot be reached. |