# Read an enrichment

> Read an enrichment back by its enrichment_id for 30 days, understand the single 404, and tell what is recomputed on read from what stays immutable.

Every enrichment created with [`POST /transactions/v1/enrichments`](https://developer.noryo.app/en-GB/transaction-intelligence/enrich-a-transaction) is given an `enrichment_id` of the form `enr_<uuid>`. That identifier lets you read the enrichment back for 30 days without resending the transaction. A read is not a frozen copy of the creation response: the resolution does not move, but relationships and series are recomputed at the moment you read, so that the response reflects what your `workspace` has learnt since.

## The call

**`GET /transactions/v1/enrichments/{enrichment_id}`** — Read back a returned enrichment.

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `enrichment_id` | path | yes | string | Pattern: `^enr_[0-9a-f-]{36}$`. |

#### Responses

| Status | Description |
| --- | --- |
| `200` | Found. |
| `400` | Undeclared host. Never returned for the shape of the identifier. |
| `401` | Unusable credential. |
| `403` | Workspace or organisation inactive. |
| `404` | Malformed, unknown, expired, or belonging to another workspace: all four cases return the same response. Returning 400 on a malformed identifier would tell a caller that a well-formed `enr_<uuid>` "looks right": that is an oracle on shape, and it is closed. |
| `429` | `rate_limited`: the operational rate limit has been reached, for this key or for the workspace (the `detail` says which). Nothing was computed, nothing was stored, no event was emitted. Replay after `Retry-After`. See `x-noryo-rate-limit`. |
| `500` | Internal error. |

#### Errors named by the contract

- [`invalid_api_key`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/invalid_api_key)
- [`account_inactive`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/account_inactive)
- [`not_found`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/not_found)
- [`rate_limited`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/rate_limited)
- [`internal_error`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/internal_error)

#### Rate limits

This operation consumes the rate limit `debit_ti_v1`.

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

→ [GET /transactions/v1/enrichments/{enrichment_id}](https://developer.noryo.app/en-GB/transaction-intelligence/reference/get-enrichment.md)

The call authenticates with a `txn` family key in the `Authorization` header (see [Authentication](https://developer.noryo.app/en-GB/transaction-intelligence/authentication)). It has no body and no query parameters.

```bash
curl -s "https://api.noryo.app/transactions/v1/enrichments/enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301" \
  -H "Authorization: Bearer $NORYO_API_KEY"
```

```ts
const enrichmentId = "enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301";

const res = await fetch(
  `https://api.noryo.app/transactions/v1/enrichments/${enrichmentId}`,
  { headers: { Authorization: `Bearer ${process.env.NORYO_API_KEY}` } },
);

const requestId = res.headers.get("noryo-request-id");

if (res.status === 404) {
  // Malformed, unknown, expired or owned by another workspace: same response.
  const problem = await res.json();
  console.warn("enrichment not found", problem.request_id ?? requestId);
} else if (res.ok) {
  const stored = await res.json();
  console.log(stored.expires_at, stored.series?.length ?? 0);
}
```

> **Note** The creation response carries a `Location` header (on the 201, and on the 200 of an idempotent replay) giving the path of the resource. Keep it rather than rebuilding the URL yourself.

## The 200 response

The body is a `StoredEnrichment` (see the [reference](https://developer.noryo.app/en-GB/transaction-intelligence/reference)). Its shape differs from the creation response (`Enrichment`): it adds `expires_at`, and it carries neither `contract_version`, `canonical_version`, `fingerprint`, `signals` nor `relationships_policy`. Those five fields are only available at creation.

**Schema `StoredEnrichment`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `enrichment_id` | string | yes | — |
| `created_at` | string · date-time | yes | — |
| `expires_at` | string · date-time | yes | The response is operational and bounded: it is not a corpus. |
| `resolution` | Resolution | yes | The shape returned by the `ti_v4` engine. |
| `resolution.policy` | object | — | What "RESOLVED" means depends on the nature of the transaction. A transfer to a person is `resolved` without any identity being created: that is the right answer, not a failure. A cash withdrawal is `resolved` without a merchant. A card purchase, however, requires its counterparty. |
| `resolution.policy.outcome` | string | yes | `resolved`: every dimension the archetype requires is present. `partial`: the nature is known, but a required dimension is missing, or signals contradict each other, in which case the engine declines to conclude. `unresolved`: even the nature is unknown. Technical errors (401, 409, 503) are never a semantic outcome: they do not carry this field. Values: `resolved`, `partial`, `unresolved`. |
| `resolution.policy.archetype` | string | yes | The vocabulary is french and it is the database's (the vocabulary of resolution archetypes), kept as is for `ti_v1`. `inconnu`: the very nature of the transaction is unknown. `non_couvert`: the nature is known but no policy covers it (`missing = [resolution_policy]`). Values: `achat_carte`, `frais_bancaires`, `impot`, `indetermine`, `prelevement`, `remboursement`, `retrait_especes`, `revenu`, `transfert`, `transfert_personne`, `inconnu`, `non_couvert`. |
| `resolution.policy.missing` | array<string> | yes | The dimensions required by the archetype that are absent. |
| `resolution.policy.policy_version` | string | yes | Values: `ti_resolution_v1`. |
| `resolution.normalized` | object | — | What the normaliser read. Strings, not scores. |
| `resolution.normalized.descriptor` | string | — | — |
| `resolution.normalized.signal` | string | — | — |
| `resolution.normalized.useful` | string | — | — |
| `resolution.normalized.residual` | string | — | — |
| `resolution.normalized.normalizer_version` | string | — | — |
| `resolution.counterparty` | object \| null | — | `null` is a result, not a failure: an honest unknown. |
| `resolution.counterparty.entity_id` | string · uuid | — | The stable identifier of the entity in the graph, as a bare UUID, with no prefix. It is opaque: do not assume any structure in it. The same identifier designates the same entity from one response to the next and from one workspace to the next (the graph is shared; the identifier is not data belonging to your workspace). It is quoted as is in `series[].evidence` in the form `entite:<uuid>`. |
| `resolution.counterparty.name` | string | — | — |
| `resolution.counterparty.kind` | string | — | Values: `brand`, `organisation`. |
| `resolution.counterparty.evidence` | object | — | How the pattern matched the descriptor. Two length ratios, which are properties of the text and not proof of identity. `match` says by which route (`jetons`: a whole alias in the descriptor; `tronque`: a descriptor fragment cut short by the bank). `pattern` is the matched alias, in plain text. |
| `resolution.counterparty.evidence.match` | string | — | Values: `jetons`, `tronque`. |
| `resolution.counterparty.evidence.pattern` | string | — | — |
| `resolution.counterparty.evidence.coverage` | number ≥ 0 | — | — |
| `resolution.counterparty.evidence.covered` | number · 0.6–1 | — | — |
| `resolution.counterparty.semantics` | CounterpartySemantics \| null | — | Four different things that look alike, and that this block refuses to confuse. The entity type (`counterparty.kind`: brand, legal entity, payment intermediary) says what it is. The activity (`activity`) says what it does, in industry names. The category (`category`, outside this object) says where the money went, in budget lines. The transaction type (`transaction_type`) says what happened. None is derived from another. And there is no `category = subscription`: a subscription is not a kind of spending, it is a model of commercial relationship. `null` when the graph knows nothing about this entity: an abstention, not a failure. |
| `resolution.counterparty.semantics.activity` | object \| null | — | The trade, on two levels (`parent` then `code`). It stops at the parent when the source stops there: a `utility` role says "network" without saying which one, and `utilities` is then returned, never `utilities.electricity`. What is not known is not filled in. |
| `resolution.counterparty.semantics.activity.code` | string | — | — |
| `resolution.counterparty.semantics.activity.label` | string | — | — |
| `resolution.counterparty.semantics.activity.parent` | string \| null | — | — |
| `resolution.counterparty.semantics.recurring_model` | string \| null | — | What is usual in this trade, not a verdict on this transaction. A streaming service sells by subscription; an electricity supplier bills repeatedly without anyone subscribing; a supermarket sells per purchase. It is a plausibility that reinforces a rhythm already observed in the workspace. On its own, it never concludes: `commonly_subscription_based` on an isolated transaction does not make a subscription, and it is the series (`series[].type = subscription`) that decides. Values: `commonly_subscription_based`, `commonly_recurring_but_not_subscription`, `usually_one_off`, `both_recurring_and_one_off`, `unknown`, `null`. |
| `resolution.counterparty.semantics.roles` | array<string> | — | The roles the entity holds in the graph (`merchant`, `utility`, `government`, `financial_institution`, `payment_intermediary`…). A role is not a trade, but it says enough to derive one when no catalogue covers the entity. |
| `resolution.counterparty.semantics.legal_entity` | object \| null | — | A brand is not a legal entity. "Netflix" is what you read on the statement; "Netflix Inc" is what contracts. The two exist separately in the graph, linked by `brand_of`, because merging them would erase the day a brand changes owner. `null` when no parent company is known, not when there is none. |
| `resolution.counterparty.semantics.legal_entity.entity_id` | string · uuid | — | The same identifier space as `counterparty.entity_id`: a bare, opaque, stable UUID. |
| `resolution.counterparty.semantics.legal_entity.name` | string | — | — |
| `resolution.counterparty.semantics.provenance` | object \| null | — | Where what we assert comes from. `nature` ranks knowledge by declared authority, in this order: `curated` (a person verified it), `deterministic` (derived mechanically from a fact already held), `provider` (a third party states it), `inferred` (we deduced it), `participant` (a single client said so). This order is declared, not deduced from a number: without it, a deduced 0.95 would beat a verified 0.90. `strength` only breaks ties at equal nature, and is not calibrated. And no global knowledge comes from a single client: what a workspace observes stays in that workspace. A `participant` nature can never become a truth of the shared graph. |
| `resolution.counterparty.semantics.provenance.nature` | string | — | Values: `curated`, `deterministic`, `provider`, `inferred`, `participant`. |
| `resolution.counterparty.semantics.provenance.source` | string | — | — |
| `resolution.counterparty.semantics.provenance.strength` | number · 0–1 | — | — |
| `resolution.counterparty.semantics.provenance.calibration` | string | — | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `resolution.counterparty.semantics.provenance.evidence` | object | — | How the provenance was established. The keys vary: `derivation` (`categorie_de_depense_vers_activite`, `role_vers_activite`…), `categorie_source` or `role`, and `note`, a constant sentence in French. None of these values is a number. |
| `resolution.counterparty_type` | string | — | — |
| `resolution.counterparty_note` | string \| null | — | Says why no identity was established. |
| `resolution.category` | object \| null | — | A category is not an identity. When the descriptor names a trade ("BOULANGERIE DUPONT", "PHARMACIE DE LA GARE") rather than a known merchant, the response returns that trade here and leaves `counterparty` at `null`. Returning "Boulangerie" as the counterparty would amount to naming a shop aisle instead of a business. And it is a budget line, not a trade. This category answers "where did this money go?", from the person's point of view. What the entity does is another field, `counterparty.semantics.activity`, and the two do not substitute for each other: EDF sells electricity (`activity = utilities.electricity`) and the spend files under `logement.electricite`. `source` says where the line comes from: `entity_graph` when it is carried by the recognised merchant, `generic_trade_entry` when it is deduced from a trade word read in the descriptor. The first is bounded by `confidence.entity`: a poorly recognised merchant does not yield a reliable category. When a rule of your organisation set the category (see `customization`), `source` is absent: `slug` and `label` then come from the Noryo catalogue, and neither Noryo origin would be true. |
| `resolution.category.slug` | string | — | — |
| `resolution.category.label` | string \| null | — | — |
| `resolution.category.source` | string | — | Values: `entity_graph`, `generic_trade_entry`. |
| `resolution.identity_abstention` | string \| null | — | Why the engine declined to name, when it could have. `trade_not_identity`: a trade was recognised, not a merchant. `atm_operator_not_counterparty`: on a cash movement, the recognised bank operates the cash machine; it is not on the other side. `account_bank_not_counterparty`: on a bank fee, it is the account's own bank, and naming it teaches nothing. `person_named_not_brand`: the descriptor names a person, and the recognised merchant belongs to the transfer reference. An abstention is not a failure: `policy.outcome` can remain `resolved` if the archetype did not require an entity. |
| `resolution.intermediaries` | array<object> | — | The payment facilitators read in the descriptor (`PAYPAL *NETFLIX` yields one). They are never the counterparty. `evidence` is a code and a source, and no longer the matching pattern itself, which is an internal rule. |
| `resolution.intermediaries[].name` | string | yes | — |
| `resolution.intermediaries[].role` | string | yes | — |
| `resolution.intermediaries[].entity_id` | string · uuid \| null | — | The facilitator's entity in the graph, when it is there. |
| `resolution.intermediaries[].carries_sub_merchant` | boolean | — | — |
| `resolution.intermediaries[].evidence` | object | yes | — |
| `resolution.intermediaries[].evidence.code` | string | yes | The facilitator's code in the reference catalogue. |
| `resolution.intermediaries[].evidence.source` | string | yes | Values: `referentiel_facilitateurs`. |
| `resolution.transaction_type` | object | — | What the transaction means, as opposed to `payment`, which says how the money moved. `evidence` is a rule code, not an explanation: the rule's internal note came out here in an earlier version; the responses created at that time carry `legacy_note_redacted`. |
| `resolution.transaction_type.canonical` | string | yes | — |
| `resolution.transaction_type.evidence` | string | yes | `rail_rule`: the rail alone decided. `descriptor_rule`: the descriptor alone. `descriptor_sense_rail_mechanism`: the descriptor gives the meaning, the rail the mechanism, with no conflict. `rail_and_descriptor_agree`: both say the same thing. `descriptor_refines_rail` / `rail_refines_descriptor`: one narrows down the other. `rail_descriptor_conflict`: contradiction, no certainty manufactured (see `signal_conflicts`). `no_rule`: nothing applies. The `+counterparty_nature` suffix says that the nature of the counterparty refined the type. Pattern: `^(rail_rule\|descriptor_rule\|descriptor_sense_rail_mechanism\|rail_and_descriptor_agree\|descriptor_refines_rail\|rail_refines_descriptor\|rail_descriptor_conflict\|no_rule)(\+counterparty_nature)?$\|^legacy_note_redacted$`. |
| `resolution.transaction_type.contradiction` | string \| null | yes | — |
| `resolution.payment` | object \| null | — | What the engine understood of the rail and the payment mechanism. |
| `resolution.payment.rail` | string \| null | — | — |
| `resolution.payment.mechanism` | string \| null | — | — |
| `resolution.payment.note` | string \| null | — | A constant explanatory sentence, in French, restating the difference between `rail` and `transaction_type`. Not data. |
| `resolution.signal_conflicts` | array<SignalConflict> | — | The signals that contradict each other. When there are any, `policy.outcome` is `partial`: the engine declines to conclude. |
| `resolution.signal_conflicts[].dimension` | string | yes | — |
| `resolution.signal_conflicts[].signals` | array<string> | yes | — |
| `resolution.signal_conflicts[].resolution` | string | yes | A sentence, in French: how the conflict was arbitrated. |
| `resolution.signal_conflicts[].resolved_to` | string | yes | — |
| `resolution.submerchant_lane` | string | — | The outcome of the "sub-merchant" lane (a facilitator carries the real merchant). `none`: lane not opened. `no_candidate`: opened, no entity. `resolved`: a single entity. `ambiguous`: several, abstention. Absent on the oldest responses, created before this lane existed. Values: `none`, `no_candidate`, `resolved`, `ambiguous`. |
| `resolution.truncated_lane` | string | — | The outcome of the "truncated descriptor" lane (the bank cut the name short). Same values as `submerchant_lane`. Absent on the oldest responses, created before this lane existed. Values: `none`, `no_candidate`, `resolved`, `ambiguous`. |
| `resolution.confidence` | object | — | The name is kept for `ti_v1`; this is not a confidence. Each of these numbers is a declared constant, set by the person who wrote the pattern or the rule, and read as is. Every transaction that hits the same pattern receives the same number. Measured on 644 descriptors: precision as a function of the threshold is not monotone: 0.85 gives 0.86 precision, 0.88 gives 0.33, 0.92 gives 0.25, 0.97 gives 0.79. This number does not rank right above wrong. Use it neither as a probability nor as a threshold: use `policy.outcome`. `0` has a precise meaning per field: abstention (`entity`), unknown role (`counterparty`), conflict with no common ancestor (`transaction_type`). `location` is always `0`: nothing is resolved on this axis in this version. |
| `resolution.confidence.entity` | number · 0–1 | yes | — |
| `resolution.confidence.counterparty` | number · 0–1 | yes | — |
| `resolution.confidence.transaction_type` | number · 0–1 | yes | — |
| `resolution.confidence.category` | number · 0–1 | yes | — |
| `resolution.confidence.location` | number | yes | Values: `0`. |
| `resolution.confidence.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `resolution.timings_ms` | object | — | Durations, in milliseconds. Nothing to do with a confidence. |
| `resolution.timings_ms.normalization` | number | — | — |
| `resolution.timings_ms.entity_resolution` | number | — | — |
| `resolution.timings_ms.submerchant` | number | — | — |
| `resolution.timings_ms.total` | number | — | — |
| `resolution.versions` | object | — | The versions that produced this response. A client that keeps a number must keep the version alongside it: the same value, under another version, may no longer measure the same thing. |
| `resolution.versions.graph` | string | — | The ontology of the entity graph (`onto_v1`). |
| `resolution.versions.rules` | string | — | The transaction type rules (`txtype_v1`). |
| `resolution.versions.engine` | string | yes | The resolution engine (`ti_v4`). |
| `resolution.versions.resolution_policy` | string | yes | The same value as `policy.policy_version`. |
| `resolution.versions.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `relationships` | array<Relationship> | — | — |
| `relationships[].type` | string | yes | `refund_of`: the purchase took place, then the money came back; both entries remain. `reversal_of`: the original entry is erased; nothing happened. `transfer_pair`: two legs of a single movement. Values: `refund_of`, `reversal_of`, `transfer_pair`. |
| `relationships[].role` | string | yes | The role of this transaction. `peer` for a symmetric relationship, where neither source nor target means anything. Values: `source`, `target`, `peer`. |
| `relationships[].symmetric` | boolean | yes | — |
| `relationships[].counterpart` | string | yes | The other transaction, by its enrichment identifier. Pattern: `^enr_[0-9a-f-]{36}$`. |
| `relationships[].match_strength` | object | yes | This is not a probability. It is the share of the corroborating signals available for this type that are actually present. `calibration` is `non_calibree` as long as no ground truth has certified a quantified reading of this number. |
| `relationships[].match_strength.value` | number · 0.001–1 | yes | The denominator depends on the type: 5 for `refund_of`, 4 for `reversal_of`, 2 for `transfer_pair`. A `0.5` on a transfer pair (1/2) and a `0.5` on a reversal (2/4) are not the same strength. Read `corroborations` alongside. `present` only counts signals from the set specific to the type: a refund marker on a transfer pair stays visible in `evidence` but does not corroborate. |
| `relationships[].match_strength.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `relationships[].match_strength.corroborations` | object | yes | — |
| `relationships[].match_strength.corroborations.present` | integer ≥ 0 | yes | — |
| `relationships[].match_strength.corroborations.possible` | integer ≥ 1 | yes | — |
| `relationships[].evidence` | array<object> | yes | The signals the relationship rests on, preconditions included. A relationship that cannot say why it exists is not created. |
| `relationships[].evidence[].code` | string | yes | — |
| `relationships[].evidence[].value` | string | — | — |
| `relationships[].evidence[].value_seconds` | integer | — | For `within_window`: the measured gap, in seconds. A duration, not a confidence. |
| `relationships[].window_seconds` | integer \| null | — | The window of the relationship type, in seconds. A policy constant, not a measurement. |
| `relationships[].detector_version` | string | yes | — |
| `relationships[].status` | string | yes | Values: `active`, `supersedee`, `invalidee`. |
| `relationships[].created_at` | string · date-time | — | — |
| `series` | array<Series> | — | — |
| `series[].series_id` | string | yes | Stable. It derives from what does not change (workspace, family, counterparty, direction, currency), never from the latest occurrence or the current descriptor. One more occurrence does not create another series. Pattern: `^ser_[0-9a-f-]{36}$`. |
| `series[].type` | string | yes | `recurring_payment` says "this payment comes back", and nothing more: rent and a loan instalment are this too. `subscription` is a stronger claim and requires its own signal; without it, the answer stays `recurring_payment`. Likewise, `recurring_income` covers a pension, a benefit and rent received; `salary_series` is a specialisation of it, not a synonym. Values: `recurring_payment`, `subscription`, `recurring_income`, `salary_series`. |
| `series[].implies` | array<string> | — | The more general type that this one specialises, if there is one. |
| `series[].status` | string | yes | Two occurrences make a series emerge, three can establish it, a single one never. `broken` is computed at read time: a series becomes late through the mere passage of time, with no write taking place. Values: `emerging`, `established`, `uncertain`, `broken`. |
| `series[].cadence` | string | yes | Read on the calendar, not in bare days: 31 January and 28 February are one month apart, and a salary on the last business day shifts. Values: `weekly`, `biweekly`, `monthly`, `quarterly`, `yearly`, `irregular_recurring`, `unknown`. |
| `series[].occurrence_count` | integer | yes | — |
| `series[].amount_behavior` | object | yes | Exact equality is not required: an electricity bill varies, and so does a salary. Amounts in minor units, as a string, never a JSON number. |
| `series[].amount_behavior.kind` | string | — | Values: `fixed`, `narrow_variable`, `broadly_variable`. |
| `series[].amount_behavior.min_minor_units` | string | — | — |
| `series[].amount_behavior.max_minor_units` | string | — | — |
| `series[].amount_behavior.currency` | string | — | — |
| `series[].first_seen` | string · date-time | — | — |
| `series[].last_seen` | string · date-time | — | — |
| `series[].expected_next_at` | string · date-time \| null | — | Optional, and that is the point. Absent as long as the series is not established or the cadence is irregular: returning a date on the basis of two weak observations would pass it off as a forecast. This is not a forecasting API. |
| `series[].match_strength` | object | yes | This is not a probability: it is the share of corroborating signals present out of those this type can produce. And it is not the same number as `relationships[].match_strength`: same name, different denominator, different signals. Five heterogeneous criteria out of five: calendar cadence, fixed or narrow amount, at least three occurrences, known entity, specialised type. They only add up here. |
| `series[].match_strength.value` | number · 0.001–1 | yes | — |
| `series[].match_strength.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `series[].match_strength.corroborations` | object | yes | — |
| `series[].match_strength.corroborations.present` | integer | yes | — |
| `series[].match_strength.corroborations.possible` | integer | yes | Values: `5`. |
| `series[].evidence` | array<object> | — | The signals the series rests on. `value` is a string or an integer depending on the code (`occurrences` carries an integer). |
| `series[].evidence[].code` | string | yes | — |
| `series[].evidence[].value` | string \| integer \| null | — | — |
| `series[].lifecycle` | SeriesLifecycle | — | An axis different from `status`. `status` says how strongly we believe this is a series; `lifecycle` says what is happening to it now. A very well-established series may have stopped collecting, and a fresh series may be perfectly alive. And no state is concluded from an absence without coverage: if the source was not observed completely over the expected window, the state is `unknown`, not `late`. "I saw nothing" never becomes "nothing happened": that is the difference between a client that stopped sending and a subscription that stopped collecting. |
| `series[].lifecycle.state` | string | yes | `active`: the expected occurrence has arrived, or is not yet due. `late`: an occurrence is missing and the account was under observation. `inactive`: several consecutive occurrences are missing under complete observation; the series is no longer collecting, and we do not know why. `ended`: reserved for explicit terminal evidence. No accumulation of absences produces this state: a payment that stops may be a cancellation, a suspension, an expired card or a failed collection, and we do not guess which. `unknown`: we cannot conclude, and `reason` says why. Values: `active`, `late`, `inactive`, `ended`, `unknown`. |
| `series[].lifecycle.reason` | string | — | — |
| `series[].lifecycle.last_observed_on` | string · date \| null | — | — |
| `series[].lifecycle.expected_next_window` | object \| null | — | A window, not a date. A salary lands around the last business days, a SEPA direct debit around the 5th. Returning an exact instant would be a precision we do not have, and you would believe it. |
| `series[].lifecycle.expected_next_window.from` | string · date | — | — |
| `series[].lifecycle.expected_next_window.to` | string · date | — | — |
| `series[].lifecycle.expected_next_window.tolerance_days` | integer | — | The half-width of the window, in days. Larger = less precise. Comes from the anchor type, raised to the floor of the series family. |
| `series[].lifecycle.expected_next_window.anchor` | string | — | The anchor type that produced the window. |
| `series[].lifecycle.expected_next_window.widened_to_family_floor` | boolean | — | `true` if the tolerance was raised to the family floor (5 days for a subscription, 6 for a salary, 7 otherwise). |
| `series[].lifecycle.consecutive_misses` | integer \| null | — | The counter only advances under complete coverage. Absent when nothing is expected, which is not the same ignorance as "we were not looking". |
| `series[].lifecycle.observation_coverage` | string | — | `not_required`: no due date has closed, nothing had to be observed. `complete`: every source of the series was covered over the expected window; a miss is a miss. `incomplete`: at least one source was not; the absence proves nothing, `state` is `unknown`. `unknown`: the sources are unknown or too numerous to be checked. Values: `not_required`, `complete`, `incomplete`, `unknown`. |
| `series[].lifecycle.cadence_strength` | number \| null | — | The same number as `cadence_detail.strength`, returned here so that the lifecycle can be read on its own. It is not a second score. |
| `series[].lifecycle.calendar_anchor` | any | — | The same object as `calendar_pattern` (class `anchor_stability`), returned here for the same reason; `{ type: unknown }` when no anchor was inferred. Absent when no occurrence is expected. duplicate_of: `series[].calendar_pattern`; it is not a second score. |
| `series[].lifecycle.evidence` | array<object> | — | Present for `ended`: the terminal evidence that produced it. |
| `series[].lifecycle.evidence[].code` | string | — | — |
| `series[].lifecycle.identity_status` | string \| null | — | Present only when the identity of the series has been corrected, to say why `state` is `unknown`: this is not economic ignorance, it is that the verdict now belongs to the successors. The full identity state is read in `identity`, which is an axis in its own right; this field is only a pointer, and it never takes the value `current`. Values: `superseded`, `null`. |
| `series[].lifecycle.policy_version` | string | yes | — |
| `series[].lifecycle.calibration` | string | yes | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `series[].identity` | SeriesIdentity | — | A third axis, and it mixes with neither `status` nor `lifecycle`. `status` says how strongly we believe this is a series; `lifecycle` says what is happening to it economically; `identity` says whether its name is still the right one. A series can debit every month and still have changed identity because we worked out that it was in fact two flows, or two halves of one. And a `series_id` is never rewritten or silently reused. When we correct, the old name remains, marked `superseded`, and it carries its successors. |
| `series[].identity.status` | string | yes | `current`: this name is the current name of this flow. `superseded`: this name has been replaced by an identity correction. It stays valid as a historical reference (it will not disappear), but future occurrences will carry the name of its successors. Values: `current`, `superseded`. |
| `series[].identity.superseded_by` | object \| null | — | After a split there is no single redirect, and that is why this field returns a list. If we discover that a series was mixing two flows, neither successor is "the continuation" of the old one: half of the old one's occurrences are missing from each of them. Electing a canonical one would be comfortable and wrong. |
| `series[].identity.superseded_by.reason` | string | — | `split`: this identity was covering several, and each now has its own. `merge`: this identity and another were the same flow; both are replaced by a new identity. Values: `split`, `merge`. |
| `series[].identity.superseded_by.at` | string · date-time | — | — |
| `series[].identity.superseded_by.evidence` | array<string> | — | The evidence codes that authorised the correction. Never an internal score, never a row identifier. |
| `series[].identity.superseded_by.successor_series_ids` | array<string> | — | One after a merge, several after a split. Never empty when `status` is `superseded`. |
| `series[].identity.supersedes` | array<string> | yes | The way back, and it matters as much as the other one. The identities this one replaces. Without it, the old `series_id` you had stored would no longer correspond to anything you receive, and you would have no way to make the link. Empty for a series that was not born of any correction. |
| `series[].membership` | SeriesMembership | — | Where this occurrence came from, and where it is now. When an identity is corrected, the occurrence changes series: without this block, you would have no way to relate the `series_id` you had stored to the one you receive. You never have to rebuild the graph yourself. |
| `series[].membership.original_series_id` | string \| null | — | The series this occurrence was attached to the first time. Equal to `current_series_id` for as long as no correction has taken place. Pattern: `^ser_[0-9a-f-]{36}$`. |
| `series[].membership.current_series_id` | string \| null | yes | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `series[].membership.corrections` | array<object> | yes | The successive moves, from oldest to most recent. Empty for an occurrence that has never moved. |
| `series[].membership.corrections[].reason` | string | — | Values: `split`, `merge`. |
| `series[].membership.corrections[].from_series_id` | string \| null | — | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `series[].membership.corrections[].to_series_id` | string \| null | — | Pattern: `^ser_[0-9a-f-]{36}$`. |
| `series[].membership.corrections[].at` | string · date-time | — | — |
| `series[].first_observed_on` | string · date | — | The first day **WE** Observed this series, not the day it started. A series may have been running for five years while we have only been observing it for three months. There is deliberately NO `started_at` field: we do not know it. |
| `series[].last_observed_on` | string · date | — | — |
| `series[].may_predate_observation` | boolean | — | `true`: our observation begins with the first known occurrence, so the series may have started before us and nothing will show it. `false`: the first known occurrence predates the start of our observation, so its beginning is observed, not assumed. |
| `series[].cadence_detail` | object | — | How often the event repeats, and nothing else. The cadence is deduced from the progression of calendar periods, not from gaps in days: three consecutive months are three consecutive months, whether the gaps are 28, 30 or 31 days. And a 28-day recurrence is not monthly: it falls once a month for eleven months, then twice, and its day moves back with every cycle. We do not call it `monthly`: this version has no cadence for it, and `irregular_recurring` is better than a false monthly. `strength` is the share of expected cycles that carry an occurrence. It is not a probability. |
| `series[].cadence_detail.type` | string | — | — |
| `series[].cadence_detail.strength` | number · 0–1 \| null | — | `0` = no calendar hypothesis retained, not "weak". |
| `series[].cadence_detail.calibration` | string | — | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `series[].calendar_pattern` | CalendarPattern | — | Where, within each period, the series tends to fall: a different question from `cadence`. A series can be squarely monthly while floating between the 1st and the 6th: without this field, "monthly" would read as "the same day every month", which is false for most real series. It is also what gives `expected_next_window` its width: a direct debit always on the 5th justifies three days, a rent that floats justifies eight. Applying the same window to both would declare a perfectly normal series "late". |
| `series[].calendar_pattern.type` | string | — | `fixed_day_of_month`: always around the same day. `month_end`: at month end, whatever the number of days; a direct debit on 31 January, 28 February and 31 March is a single pattern. `last_business_day`: the last business day, a common case for salaries. `business_day_adjusted`: a nominal day moved off the weekend (Saturday → Friday, Sunday → Monday). We do not model public holidays: neither French, nor target, nor banking holidays. A holiday shift is absorbed by the window's tolerance, not recognised; claiming to know a calendar we do not have would be worse than ignoring it. `fixed_weekday`: always the same day of the week. `flexible_calendar_window`: one occurrence per period, with no stable day. `unknown`: nothing identifiable. Values: `fixed_day_of_month`, `month_end`, `last_business_day`, `business_day_adjusted`, `fixed_weekday`, `flexible_calendar_window`, `unknown`. |
| `series[].calendar_pattern.nominal_day` | integer \| null | — | — |
| `series[].calendar_pattern.weekday` | integer \| null | — | — |
| `series[].calendar_pattern.day_range` | array<integer> \| null | — | — |
| `series[].calendar_pattern.weekend_shifts` | integer \| null | — | — |
| `series[].calendar_pattern.strength` | number · 0–1 | — | A constant per anchor type, or a linear formula on the spread of days for `fixed_day_of_month` and `flexible_calendar_window`. `0` for `unknown`. A 0.33 `fixed_day_of_month` and a 0.3 `flexible_calendar_window` cannot be compared, and `flexible_calendar_window` changes its rule with the cadence (a constant 0.3 when weekly, `1 - etendue/10` when monthly). |
| `series[].calendar_pattern.calibration` | string | — | Always `non_calibree` in this version, and that is a constraint, not a convention: the value is a vocabulary in the database, the columns that carry it reference it, and a guard rejects any literal outside the vocabulary at every producer. When a real calibration exists, it will enter through a declared migration, together with the corpus that supports it. Values: `non_calibree`. |
| `series[].history_coverage` | object | yes | What we can see, and what we cannot. `insufficient` means "our memory is too short to judge", never "there is no older occurrence". And there are two memories, which must not be confused. `raw_days` (30) says how long the transaction remains readable; `derived_days` (400) says how long what we have learnt about a series outlives it. A purged transaction stays purged (this API never brings it back), but the series remembers that it happened. That is what makes a monthly cadence confirmable when January's transaction no longer exists. `observed_days` is what we actually saw: a client reading "monthly / sufficient" can check that the conclusion does not come from thirty days. |
| `series[].history_coverage.observed_from` | string · date | — | — |
| `series[].history_coverage.observed_to` | string · date | — | — |
| `series[].history_coverage.raw_days` | integer | — | Lifetime of the raw transaction. Past this delay it is physically deleted and nothing brings it back. |
| `series[].history_coverage.derived_days` | integer | — | Lifetime of the series memory, counted from the latest occurrence. Without a new occurrence, the derived state is physically deleted when it expires, and a later occurrence opens a new series. |
| `series[].history_coverage.observed_days` | integer | — | The span actually observed, from the first to the last known occurrence. |
| `series[].history_coverage.retention_limit_days` | integer | yes | Kept for compatibility: identical to `raw_days`. |
| `series[].history_coverage.occurrence_count` | integer | yes | — |
| `series[].history_coverage.cadence` | string | — | — |
| `series[].history_coverage.sufficiency` | string | yes | Answers one question only: can the cadence be believed? Not "do we know when it started", which is the next field. Values: `sufficient`, `limited`, `insufficient`. |
| `series[].history_coverage.reason` | string | — | — |
| `series[].history_coverage.first_occurrence_reason` | string | — | Says why `first_occurrence_bounded` has the value it has. |
| `series[].history_coverage.first_occurrence_bounded` | boolean | — | A different question from `sufficiency`, and one that used to be confused with it. `true`: our observation of this series begins with the first occurrence we know of, so it may have started before us, and nothing will ever show it. `false`: the first known occurrence predates the start of our observation, so the beginning is observed, not assumed. A history import returns `false`; a series discovered as transactions arrive returns `true`. |
| `series[].detector_version` | string | — | — |
| `customization` | Customization | — | Present only when a rule of your organisation changed this response (Platform, Rules). The Noryo result is kept intact; a rule applies only to your organisation and to the key's environment. A POST is rendered with the rules published at that moment (an idempotent replay returns the same body); a GET applies the rules published today. A field two rules disagree on keeps the Noryo value. |
| `customization.rules` | array<object> | yes | — |
| `customization.rules[].id` | string · uuid | yes | — |
| `customization.rules[].version` | integer ≥ 1 | yes | — |
| `customization.rules[].fields` | array<string> | yes | The fields the rule changed: `contrepartie.nom` (`resolution.counterparty.name`), `categorie` (`resolution.category`), `recurrence` (`resolution.counterparty.semantics.recurring_model`). |

| Field | What it holds | Changes between two reads? |
|---|---|---|
| `enrichment_id` | The identifier returned at creation | No |
| `created_at` | When the enrichment was created | No |
| `expires_at` | The instant from which a read returns 404 | No |
| `resolution` | The resolution rendered at creation | No |
| `relationships` | Links to other transactions in the workspace | Yes, computed on read |
| `series` | The series this occurrence belongs to | Yes, computed on read |

The response also carries the `noryo-request-id` header (quote it to support), `noryo-ratelimit-limit` and `noryo-ratelimit-remaining`. An excerpt cut down to the identity fields, after a series has been split:

```json
{
  "enrichment_id": "enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "created_at": "2026-09-05T01:10:00Z",
  "expires_at": "2026-10-05T01:10:00Z",
  "series": [
    {
      "series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
      "identity": {
        "status": "superseded",
        "superseded_by": {
          "reason": "split",
          "at": "2026-09-10T22:14:00Z",
          "successor_series_ids": [
            "ser_6ba7b812-9dad-11d1-80b4-00c04fd430c8",
            "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8"
          ]
        },
        "supersedes": []
      },
      "membership": {
        "original_series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
        "current_series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
        "corrections": [
          {
            "reason": "split",
            "from_series_id": "ser_6ba7b811-9dad-11d1-80b4-00c04fd430c8",
            "to_series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
            "at": "2026-09-10T22:14:00Z"
          }
        ]
      }
    }
  ]
}
```

## The single 404

Four different situations return exactly the same `404` response, of type `not_found`:

| Situation | Response |
|---|---|
| The identifier is malformed | 404 |
| The identifier is well formed but unknown | 404 |
| The enrichment is past `expires_at` | 404 |
| The enrichment belongs to another workspace | 404 |

This is deliberate: returning 400 on a malformed identifier would tell a caller that a well-formed `enr_<uuid>` "looks right", and the API refuses to act as an oracle on the shape of its identifiers. The only 400 this call can return concerns the host (undeclared host), never the identifier. If a recent enrichment ought to exist, check that the key belongs to the workspace that created it.

The remaining codes are those of the whole API: `401` for an unusable credential, `403` for an inactive workspace or organisation, `429` (`rate_limited`, with `Retry-After`) when the rate limit is reached, `500` on an internal error. Each returns a `Problem` with a `request_id`. See [Errors](https://developer.noryo.app/en-GB/transaction-intelligence/errors) and the [generated catalogue](https://developer.noryo.app/en-GB/transaction-intelligence/errors).

## What can change between creation and a read

`relationships` and `series` are computed on read, not frozen on write. Monday's purchase knows nothing of Friday's refund at the moment it answers; the read is what shows it.

**Relationships.** A transaction enriched later can create a relationship with this one. `relationships` then carries the link (`refund_of`, `reversal_of` or `transfer_pair`), with `role`, `counterpart` and `status`. See [Relationships](https://developer.noryo.app/en-GB/transaction-intelligence/relationships).

**Series status and lifecycle.** Yesterday's third occurrence may have moved a series from `emerging` to `established`, and the first transaction must reflect it. `broken` is also computed on read: a series falls late through the passage of time alone, with no write taking place. `lifecycle.state` follows the same logic, driven by due dates and the declared observation coverage. See [Recurring series](https://developer.noryo.app/en-GB/transaction-intelligence/recurring-series) and [Series lifecycle](https://developer.noryo.app/en-GB/transaction-intelligence/series-lifecycle).

**Series identity.** This is the change to plan for if you store `series_id` values. An identity correction never rewrites an identifier: the old series stays readable, but its `identity` block moves to `status = superseded`, and `superseded_by` gives `reason` (`split` or `merge`), `at`, `evidence` and `successor_series_ids`. After a split, `successor_series_ids` holds several entries: no successor is "the continuation" of the old one, and the contract refuses to elect a canonical one. After a merge, it holds exactly one. Each successor carries `supersedes` in return, the list of identities it replaces.

**Occurrence membership.** The `membership` block says where this occurrence came from and where it is now: `original_series_id` is the series it was first attached to, `current_series_id` the one it sits in today, and `corrections` lists its moves from oldest to newest, each with `reason`, `from_series_id`, `to_series_id` and `at`. Without a correction, both identifiers are equal and `corrections` is empty. You never have to rebuild the graph yourself.

When a series has been superseded, `lifecycle.identity_status` is `superseded` to explain why `lifecycle.state` is `unknown`: the verdict now belongs to the successors. See [Split, merge, supersession](https://developer.noryo.app/en-GB/transaction-intelligence/split-merge-supersession).

> **Warning** If you store a `series_id`, read `membership.current_series_id` and `identity.supersedes` back rather than assuming the identifier from the first POST is still the current one.

## What does not change

`enrichment_id`, `created_at`, `expires_at` and `resolution` are immutable: a read returns the resolution established at creation. If you keep a number from the resolution, keep `resolution.versions` alongside it: under another version, the same value may no longer measure the same thing. One nuance of form: on the oldest responses, `transaction_type.evidence` returns `legacy_note_redacted` in place of an internal note from an earlier version.

A `series_id` is never rewritten or reused silently. A superseded identity remains valid as a historical reference.

A read and a replay are different things. A replay through `Idempotency-Key` on the POST returns the original response as it was, engine version included (200). A read through GET returns the original resolution, but relationships and series computed today.

## Retention and rate limits

An enrichment can be read for 30 days: that is the `retention_limit_days` value (identical to `raw_days`) in `series[].history_coverage`, and `expires_at` gives each enrichment's exact deadline. Past it, the raw transaction is deleted and the API never brings it back: the response is operational and bounded, not a corpus. A series' derived memory outlives it (`derived_days`): a monthly cadence stays confirmable even though January's transaction no longer exists. See [Sandbox limitations](https://developer.noryo.app/en-GB/transaction-intelligence/sandbox-limitations).

This read counts against the operational rate limits, just like a POST. A 404 returned after a valid credential consumes a token; a 401 consumes none. The values are in [Rate limits](https://developer.noryo.app/en-GB/transaction-intelligence/rate-limits). On Platform, the [Usage](https://platform.noryo.app/usage) page counts these reads in their own family.

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/read-an-enrichment
