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 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
/transactions/v1/enrichments/{enrichment_id}Read back a returned enrichment.
Reference Transaction IntelligenceParameters
- 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"Shellconst 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);
}TypeScriptThe 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_v4engine. - policyobject
What "RESOLVED" means depends on the nature of the transaction. A transfer to a person is
resolvedwithout any identity being created: that is the right answer, not a failure. A cash withdrawal isresolvedwithout 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
nullis 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[].evidencein the formentite:<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.
matchsays by which route (jetons: a whole alias in the descriptor;tronque: a descriptor fragment cut short by the bank).patternis 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 nocategory = subscription: a subscription is not a kind of spending, it is a model of commercial relationship.nullwhen the graph knows nothing about this entity: an abstention, not a failure. - activityobject· nullable
The trade, on two levels (
parentthencode). It stops at the parent when the source stops there: autilityrole says "network" without saying which one, andutilitiesis then returned, neverutilities.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_basedon 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.nullwhen 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.
natureranks 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.strengthonly 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. Aparticipantnature can never become a truth of the shared graph. - naturestring
curateddeterministicproviderinferredparticipant - sourcestring
- strengthnumber · 0–1
- calibrationstring
Always
non_calibreein 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_sourceorrole, andnote, 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
counterpartyatnull. 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 underlogement.electricite.sourcesays where the line comes from:entity_graphwhen it is carried by the recognised merchant,generic_trade_entrywhen it is deduced from a trade word read in the descriptor. The first is bounded byconfidence.entity: a poorly recognised merchant does not yield a reliable category. When a rule of your organisation set the category (seecustomization),sourceis absent:slugandlabelthen 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.outcomecan remainresolvedif the archetype did not require an entity. - intermediariesarray<object>
The payment facilitators read in the descriptor (
PAYPAL *NETFLIXyields one). They are never the counterparty.evidenceis 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.evidenceis a rule code, not an explanation: the rule's internal note came out here in an earlier version; the responses created at that time carrylegacy_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 (seesignal_conflicts).no_rule: nothing applies. The+counterparty_naturesuffix 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
railandtransaction_type. Not data. - signal_conflictsarray<SignalConflict>
The signals that contradict each other. When there are any,
policy.outcomeispartial: 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: usepolicy.outcome.0has a precise meaning per field: abstention (entity), unknown role (counterparty), conflict with no common ancestor (transaction_type).locationis always0: 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_calibreein 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_calibreein 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.
peerfor 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.
calibrationisnon_calibreeas 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 forreversal_of, 2 fortransfer_pair. A0.5on a transfer pair (1/2) and a0.5on a reversal (2/4) are not the same strength. Readcorroborationsalongside.presentonly counts signals from the set specific to the type: a refund marker on a transfer pair stays visible inevidencebut does not corroborate. - calibrationstringrequired
Always
non_calibreein 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_paymentsays "this payment comes back", and nothing more: rent and a loan instalment are this too.subscriptionis a stronger claim and requires its own signal; without it, the answer staysrecurring_payment. Likewise,recurring_incomecovers a pension, a benefit and rent received;salary_seriesis 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.
brokenis 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_calibreein 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.
valueis a string or an integer depending on the code (occurrencescarries an integer). - codestringrequired
- valuestring | integer· nullable
- lifecycleSeriesLifecycle
An axis different from
status.statussays how strongly we believe this is a series;lifecyclesays 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 isunknown, notlate. "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, andreasonsays 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
trueif 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,stateisunknown.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(classanchor_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
stateisunknown: this is not economic ignorance, it is that the verdict now belongs to the successors. The full identity state is read inidentity, which is an axis in its own right; this field is only a pointer, and it never takes the valuecurrent.supersedednull - policy_versionstringrequired
- calibrationstringrequired
Always
non_calibreein 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
statusnorlifecycle.statussays how strongly we believe this is a series;lifecyclesays what is happening to it economically;identitysays 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 aseries_idis never rewritten or silently reused. When we correct, the old name remains, markedsuperseded, 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
statusissuperseded. - 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_idyou 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_idyou 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_idfor 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_atfield: 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, andirregular_recurringis better than a false monthly.strengthis 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_calibreein 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 givesexpected_next_windowits 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_monthandflexible_calendar_window.0forunknown. A 0.33fixed_day_of_monthand a 0.3flexible_calendar_windowcannot be compared, andflexible_calendar_windowchanges its rule with the cadence (a constant 0.3 when weekly,1 - etendue/10when monthly). - calibrationstring
Always
non_calibreein 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.
insufficientmeans "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_daysis 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_boundedhas 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 returnsfalse; a series discovered as transactions arrive returnstrue. - 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).
| 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:
{
"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"
}
]
}
}
]
}JSONThe 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 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.
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).