# Read back a returned enrichment

`GET /transactions/v1/enrichments/{enrichment_id}` — Transaction Intelligence · [API reference](https://developer.noryo.app/en-GB/transaction-intelligence/reference)

Authentication: `txnApiKey` (http bearer).

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

### `200` — `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`). |

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

## Request examples

cURL

```bash
curl -sS -g -X GET 'https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}' \
  -H "Authorization: Bearer $NORYO_API_KEY"
```

JavaScript

```js
const response = await fetch("https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.NORYO_API_KEY}`,
  },
});
console.log(response.status, await response.text());
```

Python

```python
import os

import requests  # pip install requests

response = requests.get(
    "https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}",
    headers={
        "Authorization": "Bearer " + os.environ["NORYO_API_KEY"],
    },
)
print(response.status_code, response.text)
```

Go

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("NORYO_API_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.StatusCode, string(data))
}
```

Java

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Main {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}"))
            .method("GET", HttpRequest.BodyPublishers.noBody())
            .header("Authorization", "Bearer " + System.getenv("NORYO_API_KEY"))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

C#

```csharp
using System;
using System.Net.Http;

using var client = new HttpClient();
using var request = new HttpRequestMessage(new HttpMethod("GET"), "https://api.noryo.app/transactions/v1/enrichments/{enrichment_id}");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NORYO_API_KEY"));

using var response = await client.SendAsync(request);
Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());
```

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/reference/get-enrichment
