Skip to content
Sandbox

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.

View as Markdown

Every enrichment created with POST /transactions/v1/enrichments 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.

Reference Transaction Intelligence

Parameters

  • enrichment_idstringpathrequired

Responses

200Found.StoredEnrichment
400Undeclared host. Never returned for the shape of the identifier.Problem
401Unusable credential.Problem
403Workspace or organisation inactive.Problem
404Malformed, 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.Problem
429rate_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.Problem
500Internal error.Problem

The call authenticates with a txn family key in the Authorization header (see Authentication). It has no body and no query parameters.

curl -s "https://api.noryo.app/transactions/v1/enrichments/enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301" \
  -H "Authorization: Bearer $NORYO_API_KEY"
Shell
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);
}
TypeScript
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). 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

  • enrichment_idstringrequired
  • created_atstring · date-timerequired
  • expires_atstring · date-timerequired

    The response is operational and bounded: it is not a corpus.

  • resolutionResolutionrequired

    The shape returned by the ti_v4 engine.

  • policyobject

    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.

  • outcomestringrequired

    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.

    resolvedpartialunresolved
  • archetypestringrequired

    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]).

    achat_cartefrais_bancairesimpotindetermineprelevementremboursementretrait_especesrevenutransferttransfert_personneinconnunon_couvert
  • missingarray<string>required

    The dimensions required by the archetype that are absent.

  • policy_versionstringrequired
    ti_resolution_v1
  • normalizedobject

    What the normaliser read. Strings, not scores.

  • descriptorstring
  • signalstring
  • usefulstring
  • residualstring
  • normalizer_versionstring
  • counterpartyobject· nullable

    null is a result, not a failure: an honest unknown.

  • entity_idstring · 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>.

  • namestring
  • kindstring
    brandorganisation
  • evidenceobject

    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.

  • matchstring
    jetonstronque
  • patternstring
  • coveragenumber ≥ 0
  • coverednumber · 0.6–1
  • semanticsCounterpartySemantics· nullable

    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.

  • activityobject· nullable

    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.

  • codestring
  • labelstring
  • parentstring· nullable
  • recurring_modelstring· nullable

    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.

    commonly_subscription_basedcommonly_recurring_but_not_subscriptionusually_one_offboth_recurring_and_one_offunknownnull
  • rolesarray<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.

  • legal_entityobject· nullable

    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.

  • entity_idstring · uuid

    The same identifier space as counterparty.entity_id: a bare, opaque, stable UUID.

  • namestring
  • provenanceobject· nullable

    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.

  • naturestring
    curateddeterministicproviderinferredparticipant
  • sourcestring
  • strengthnumber · 0–1
  • calibrationstring

    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.

    non_calibree
  • evidenceobject

    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.

  • counterparty_typestring
  • counterparty_notestring· nullable

    Says why no identity was established.

  • categoryobject· nullable

    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.

  • slugstring
  • labelstring· nullable
  • sourcestring
    entity_graphgeneric_trade_entry
  • identity_abstentionstring· nullable

    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.

  • intermediariesarray<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.

  • namestringrequired
  • rolestringrequired
  • entity_idstring · uuid· nullable

    The facilitator's entity in the graph, when it is there.

  • carries_sub_merchantboolean
  • evidenceobjectrequired
  • codestringrequired

    The facilitator's code in the reference catalogue.

  • sourcestringrequired
    referentiel_facilitateurs
  • transaction_typeobject

    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.

  • canonicalstringrequired
  • evidencestringrequired

    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.

    ^(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$
  • contradictionstring· nullablerequired
  • paymentobject· nullable

    What the engine understood of the rail and the payment mechanism.

  • railstring· nullable
  • mechanismstring· nullable
  • notestring· nullable

    A constant explanatory sentence, in French, restating the difference between rail and transaction_type. Not data.

  • signal_conflictsarray<SignalConflict>

    The signals that contradict each other. When there are any, policy.outcome is partial: the engine declines to conclude.

  • dimensionstringrequired
  • signalsarray<string>required
  • resolutionstringrequired

    A sentence, in French: how the conflict was arbitrated.

  • resolved_tostringrequired
  • submerchant_lanestring

    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.

    noneno_candidateresolvedambiguous
  • truncated_lanestring

    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.

    noneno_candidateresolvedambiguous
  • confidenceobject

    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.

  • entitynumber · 0–1required
  • counterpartynumber · 0–1required
  • transaction_typenumber · 0–1required
  • categorynumber · 0–1required
  • locationnumberrequired
    0
  • calibrationstringrequired

    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.

    non_calibree
  • timings_msobject

    Durations, in milliseconds. Nothing to do with a confidence.

  • normalizationnumber
  • entity_resolutionnumber
  • submerchantnumber
  • totalnumber
  • versionsobject

    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.

  • graphstring

    The ontology of the entity graph (onto_v1).

  • rulesstring

    The transaction type rules (txtype_v1).

  • enginestringrequired

    The resolution engine (ti_v4).

  • resolution_policystringrequired

    The same value as policy.policy_version.

  • calibrationstringrequired

    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.

    non_calibree
  • relationshipsarray<Relationship>
  • typestringrequired

    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.

    refund_ofreversal_oftransfer_pair
  • rolestringrequired

    The role of this transaction. peer for a symmetric relationship, where neither source nor target means anything.

    sourcetargetpeer
  • symmetricbooleanrequired
  • counterpartstringrequired

    The other transaction, by its enrichment identifier.

    ^enr_[0-9a-f-]{36}$
  • match_strengthobjectrequired

    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.

  • valuenumber · 0.001–1required

    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.

  • calibrationstringrequired

    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.

    non_calibree
  • corroborationsobjectrequired
  • presentinteger ≥ 0required
  • possibleinteger ≥ 1required
  • evidencearray<object>required

    The signals the relationship rests on, preconditions included. A relationship that cannot say why it exists is not created.

  • codestringrequired
  • valuestring
  • value_secondsinteger

    For within_window: the measured gap, in seconds. A duration, not a confidence.

  • window_secondsinteger· nullable

    The window of the relationship type, in seconds. A policy constant, not a measurement.

  • detector_versionstringrequired
  • statusstringrequired
    activesupersedeeinvalidee
  • created_atstring · date-time
  • seriesarray<Series>
  • series_idstringrequired

    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.

    ^ser_[0-9a-f-]{36}$
  • typestringrequired

    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.

    recurring_paymentsubscriptionrecurring_incomesalary_series
  • impliesarray<string>

    The more general type that this one specialises, if there is one.

  • statusstringrequired

    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.

    emergingestablisheduncertainbroken
  • cadencestringrequired

    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.

    weeklybiweeklymonthlyquarterlyyearlyirregular_recurringunknown
  • occurrence_countintegerrequired
  • amount_behaviorobjectrequired

    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.

  • kindstring
    fixednarrow_variablebroadly_variable
  • min_minor_unitsstring
  • max_minor_unitsstring
  • currencystring
  • first_seenstring · date-time
  • last_seenstring · date-time
  • expected_next_atstring · date-time· nullable

    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.

  • match_strengthobjectrequired

    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.

  • valuenumber · 0.001–1required
  • calibrationstringrequired

    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.

    non_calibree
  • corroborationsobjectrequired
  • presentintegerrequired
  • possibleintegerrequired
    5
  • evidencearray<object>

    The signals the series rests on. value is a string or an integer depending on the code (occurrences carries an integer).

  • codestringrequired
  • valuestring | integer· nullable
  • lifecycleSeriesLifecycle

    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.

  • statestringrequired

    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.

    activelateinactiveendedunknown
  • reasonstring
  • last_observed_onstring · date· nullable
  • expected_next_windowobject· nullable

    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.

  • fromstring · date
  • tostring · date
  • tolerance_daysinteger

    The half-width of the window, in days. Larger = less precise. Comes from the anchor type, raised to the floor of the series family.

  • anchorstring

    The anchor type that produced the window.

  • widened_to_family_floorboolean

    true if the tolerance was raised to the family floor (5 days for a subscription, 6 for a salary, 7 otherwise).

  • consecutive_missesinteger· nullable

    The counter only advances under complete coverage. Absent when nothing is expected, which is not the same ignorance as "we were not looking".

  • observation_coveragestring

    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.

    not_requiredcompleteincompleteunknown
  • cadence_strengthnumber· nullable

    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.

  • calendar_anchorany

    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.

  • evidencearray<object>

    Present for ended: the terminal evidence that produced it.

  • codestring
  • identity_statusstring· nullable

    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.

    supersedednull
  • policy_versionstringrequired
  • calibrationstringrequired

    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.

    non_calibree
  • identitySeriesIdentity

    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.

  • statusstringrequired

    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.

    currentsuperseded
  • superseded_byobject· nullable

    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.

  • reasonstring

    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.

    splitmerge
  • atstring · date-time
  • evidencearray<string>

    The evidence codes that authorised the correction. Never an internal score, never a row identifier.

  • successor_series_idsarray<string>

    One after a merge, several after a split. Never empty when status is superseded.

  • supersedesarray<string>required

    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.

  • membershipSeriesMembership

    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.

  • original_series_idstring· nullable

    The series this occurrence was attached to the first time. Equal to current_series_id for as long as no correction has taken place.

    ^ser_[0-9a-f-]{36}$
  • current_series_idstring· nullablerequired
    ^ser_[0-9a-f-]{36}$
  • correctionsarray<object>required

    The successive moves, from oldest to most recent. Empty for an occurrence that has never moved.

  • reasonstring
    splitmerge
  • from_series_idstring· nullable
    ^ser_[0-9a-f-]{36}$
  • to_series_idstring· nullable
    ^ser_[0-9a-f-]{36}$
  • atstring · date-time
  • first_observed_onstring · 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.

  • last_observed_onstring · date
  • may_predate_observationboolean

    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.

  • cadence_detailobject

    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.

  • typestring
  • strengthnumber · 0–1· nullable

    0 = no calendar hypothesis retained, not "weak".

  • calibrationstring

    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.

    non_calibree
  • calendar_patternCalendarPattern

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

  • typestring

    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.

    fixed_day_of_monthmonth_endlast_business_daybusiness_day_adjustedfixed_weekdayflexible_calendar_windowunknown
  • nominal_dayinteger· nullable
  • weekdayinteger· nullable
  • day_rangearray<integer>· nullable
  • weekend_shiftsinteger· nullable
  • strengthnumber · 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).

  • calibrationstring

    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.

    non_calibree
  • history_coverageobjectrequired

    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.

  • observed_fromstring · date
  • observed_tostring · date
  • raw_daysinteger

    Lifetime of the raw transaction. Past this delay it is physically deleted and nothing brings it back.

  • derived_daysinteger

    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.

  • observed_daysinteger

    The span actually observed, from the first to the last known occurrence.

  • retention_limit_daysintegerrequired

    Kept for compatibility: identical to raw_days.

  • occurrence_countintegerrequired
  • cadencestring
  • sufficiencystringrequired

    Answers one question only: can the cadence be believed? Not "do we know when it started", which is the next field.

    sufficientlimitedinsufficient
  • reasonstring
  • first_occurrence_reasonstring

    Says why first_occurrence_bounded has the value it has.

  • first_occurrence_boundedboolean

    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.

  • detector_versionstring
  • customizationCustomization

    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.

  • rulesarray<object>required
  • idstring · uuidrequired
  • versioninteger ≥ 1required
  • fieldsarray<string>required

    The fields the rule changed: contrepartie.nom (resolution.counterparty.name), categorie (resolution.category), recurrence (resolution.counterparty.semantics.recurring_model).

FieldWhat it holdsChanges between two reads?
enrichment_idThe identifier returned at creationNo
created_atWhen the enrichment was createdNo
expires_atThe instant from which a read returns 404No
resolutionThe resolution rendered at creationNo
relationshipsLinks to other transactions in the workspaceYes, computed on read
seriesThe series this occurrence belongs toYes, 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:

{
  "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"
          }
        ]
      }
    }
  ]
}
JSON

The single 404

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

SituationResponse
The identifier is malformed404
The identifier is well formed but unknown404
The enrichment is past expires_at404
The enrichment belongs to another workspace404

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 and the generated catalogue.

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.

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

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.

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. On Platform, the Usage page counts these reads in their own family.

Published on 2026-09-17 11:19 UTC (version 1).

Was this page helpful?