Transaction types
The transaction type says what happened; it is neither the payment rail, nor the category, nor the counterparty.
Every enrichment carries, under resolution.transaction_type, a reading of what happened: a card purchase, for instance. This page explains what that block asserts, how it is established from the signals you send, and what it does not say. It assumes you have already enriched a transaction and know how to read an enrichment.
What the type asserts
resolution.transaction_type is an object with three fields, all required.
| Field | Type | Content |
|---|---|---|
canonical | string | The type that was retained, for example card_purchase. |
evidence | string | The code of the rule that established it. A code, not an explanation. |
contradiction | string or null | null when there is no contradiction to report, as in every example in the contract. |
The contract puts it this way: transaction_type says what the transaction means, as opposed to payment, which says how the money moved. A salary received by SEPA transfer is both at once: the transfer is the rail, the salary is what that transfer stands for.
{
"transaction_type": {
"canonical": "card_purchase",
"evidence": "rail_rule",
"contradiction": null
},
"payment": {
"rail": "card",
"mechanism": "card_purchase"
}
}JSONHere payment.mechanism and transaction_type.canonical hold the same value: two answers to two questions, which coincide without being the same thing. payment.note is a constant sentence, in French, that restates this difference; it is not data, so do not parse it. The full Resolution schema is in the reference.
Four questions that look alike
The contract separates four semantic readings that look alike and must not be confused: the entity kind, the activity, the category and the transaction type. None of them is derived from another. The rail joins them as a fifth question of a different nature: it does not say what the transaction means, but how the money moved.
| Question | Field | Example |
|---|---|---|
| How did the money move? | payment.rail on input; resolution.payment.rail and resolution.payment.mechanism on output | card, sepa_direct_debit |
| What happened? | resolution.transaction_type.canonical | card_purchase |
| What is the counterparty? | resolution.counterparty.kind | brand |
| What does the counterparty do? | resolution.counterparty.semantics.activity.code | retail.grocery |
| Where did the money go? | resolution.category.slug | alimentation.supermarche |
A card purchase remains a card_purchase whatever its category; the category is a budget line, not a kind of event, and two transactions in the same category can have different types. The distinction between entity kind, activity and category is covered in Counterparties and entities. There is no category = subscription: a subscription is neither a category nor a transaction type, it is a commercial relationship model that only a series can establish.
How the type is established
Two input signals carry the type: the rail declared in payment.rail and the descriptor label. The nature of the counterparty, when it is known, can refine the result. The evidence field names the rule that was applied.
evidence code | Meaning |
|---|---|
rail_rule | The rail alone decided. |
descriptor_rule | The descriptor alone decided. |
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 | The descriptor narrows down the rail. |
rail_refines_descriptor | The rail narrows down the descriptor. |
rail_descriptor_conflict | Contradiction; no certainty is manufactured. |
no_rule | No rule applies. |
legacy_note_redacted | Response created by an earlier version, when this field carried the rule's internal note; the note has been removed and no rule code is returned. |
The +counterparty_nature suffix, as in rail_rule+counterparty_nature, indicates that the nature of the counterparty refined the type.
The descriptor label and the direction (incoming or outgoing) are required in every request; the rail is optional. The direction is passed to the engine in every case: each example in the contract lists it in signals.used. The schema also accepts payment.raw_type; signals.used and signals.ignored tell you, response by response, whether it was read.
The payment.mcc, payment.card_present and payment.network_merchant_id fields are accepted on input but are currently listed in signals.ignored: nobody reads them. An MCC sent for months has no bearing on the type. Read signals.used in each response rather than assuming a signal counts.
Type rules are versioned: resolution.versions.rules is txtype_v1 today. If you store a type, store that version alongside it; under another version the same value may no longer mean the same thing.
When the signals contradict each other
Sometimes the rail and the descriptor tell two different stories. The engine does not settle the matter silently:
evidencestarts withrail_descriptor_conflict; readcontradictionalongside it.resolution.signal_conflicts[]describes each conflict: thedimensionconcerned, thesignalsin disagreement, aresolutionsentence in French stating how the conflict was arbitrated, theresolved_tovalue that was retained, and one<signal>_suggestsentry per signal for what each proposed. The same array is copied intosignals.conflicting.policy.outcomebecomespartial: the engine declines to conclude.confidence.transaction_typeis capped by policy; the value0means "conflict with no common ancestor".
Schema SignalConflict
Two independent signals that contradict each other on one dimension, and how the conflict was arbitrated. No certainty is manufactured: the retained type is capped (`confidence.transaction_type` <= 0.25).
- dimensionstringrequired
- signalsarray<string>required
- resolutionstringrequired
A sentence, in French: how the conflict was arbitrated.
- resolved_tostringrequired
What a type does not assert
It is not an identity. A card_purchase can be returned with counterparty at null: the nature of the transaction is known, the brand is not. That is the contract's non_resolue example, where policy.outcome is partial and policy.missing contains counterparty_entity, while transaction_type.canonical is card_purchase.
It is not a category. The type says what happened, not where the money went.
It is not a series. A type says nothing about recurrence: an isolated card_purchase is not a subscription, and an entity's recurring_model value is a plausibility of the trade, not a verdict. Only series[].type concludes; see Recurring series.
It is not a relationship. The type does not say that a refund answers a given purchase: that belongs to relationships[] and its type (refund_of, reversal_of, transfer_pair). The resolved type feeds those detectors (the contract's remboursement example carries the resolved_type_refund evidence), but the relationship is a separate object; see Relationships.
It is not the resolution archetype. policy.archetype says which policy applies and what "resolved" requires for that nature of transaction: a transfer to a person is resolved with no identity, a cash withdrawal is resolved with no merchant, a card purchase requires its counterparty. Its vocabulary is French and kept as is for ti_v1: achat_carte, prelevement, remboursement, retrait_especes, revenu, transfert, transfert_personne, frais_bancaires, impot, indetermine, inconnu (the very nature is unknown) and non_couvert (the nature is known but no policy covers it). transaction_type.canonical is the retained type; policy.missing can contain transaction_type when the archetype requires one and none was established.
confidence.transaction_type is not a probability: it is a constant declared by the rule, the same for every transaction that hits the same rule. Do not use it as a threshold or a rank; read policy.outcome. See Score semantics.
Reading the type in your code
const enrichmentId = "enr_3f2504e0-4f89-41d3-9a0c-0305e82c3301";
const response = await fetch(
`https://api.noryo.app/transactions/v1/enrichments/${enrichmentId}`,
{ headers: { Authorization: `Bearer ${process.env.NORYO_API_KEY}` } },
);
const enrichment = await response.json();
const type = enrichment.resolution.transaction_type;
const missing = enrichment.resolution.policy?.missing ?? [];
if (type?.evidence?.startsWith("rail_descriptor_conflict") || enrichment.resolution.signal_conflicts?.length) {
// Contradictory signals: policy.outcome is "partial".
// Do not conclude on `canonical` alone; read resolution.signal_conflicts.
}
if (missing.includes("transaction_type")) {
// The archetype required a type and no rule supplied one.
}TypeScriptPublished on 2026-09-17 11:19 UTC (version 1).