Skip to content
Sandbox

Enrich a transaction

Send a raw transaction to POST /transactions/v1/enrichments and read what the response asserts, ignores or refuses.

View as Markdown

A raw transaction goes in; a structured enrichment comes out. POST /transactions/v1/enrichments takes a TransactionInput object, authenticated with your nry_txn_sbx_* key (see Authentication), and returns an Enrichment: counterparty, transaction type, relationships, series. This page covers the request body, the Idempotency-Key header, the difference between 201 and 200, and the two blocks of the response to read first: signals and resolution.policy.

The operation

POST/transactions/v1/enrichments

Enrich a transaction.

Reference Transaction Intelligence

Parameters

  • Idempotency-Keystringheader

    Optional. With the same key and the same request: replay (200), the original response is returned as it was, engine version included. With the same key and a different request: 409. "DIFFERENT REQUEST" is judged on the economic fingerprint (fingerprint), and on nothing else: two bodies that differ only by external_transaction_id, account_hint or counterparty_hint have the same fingerprint and are the same request for idempotency; the first submission is replayed as it was. Do not reuse a key for two transactions that you distinguish by those fields alone. No engine version enters this identity: a deployment between two attempts does not turn a replay into a new operation. The raw value is never stored or logged. The key is retained for at least 24 hours: it expires 24 hours after the first submission and is only erased by the daily purge that follows that expiry. Between 24 and 48 hours after the first submission, the same key therefore opens a new operation (201), never before 24 hours. From 1 to 255 bytes of visible ASCII (0x21 to 0x7E); an empty value or one outside that alphabet is rejected with 400.

Request bodyrequired

application/json · TransactionInput

  • descriptorstring · length 1–512required
  • directionstringrequired
    incomingoutgoing
  • occurred_atstring · date-time

    RFC 3339 with an offset. Never reduced to a date: midnight is not the same day everywhere.

  • amountMoney
  • valuestringrequired

    Decimal as a string. A float would lose pence in silence. The number of decimal places allowed depends on the currency: 5.905 EUR is rejected, and so is 100.50 ISK (ISK and JPY have none, KWD has three).

    ^-?(0|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$
  • currencystringrequired

    A currency outside this list is rejected with 422 unknown_currency.

    CADCHFDKKEURGBPISKJPYKWDNOKPLNSEKUSD
  • paymentobject
  • railstring · length ≤ 128
  • mccstring
    ^[0-9]{4}$
  • card_presentboolean
  • network_merchant_idstring · length ≤ 128
  • raw_typestring · length ≤ 128
  • location_hintobject
  • countrystring
    ^[A-Z]{2}$
  • citystring · length ≤ 128
  • postal_codestring · length ≤ 128
  • counterparty_hintobject

    The IBAN is reduced on entry to its country and bank code. The account number does not enter the system, so it cannot leave it.

  • ibanstring
    ^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$
  • bicstring
    ^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$
  • account_hintobject

    The account on which the transaction went through, as distinct from counterparty_hint, which designates the other end. Without it, a transfer pair cannot be identified: the only signal that designates it is "my account is your counterparty, and your counterparty is my account". The IBAN is reduced on entry, like the counterparty's: the account number does not enter the system. external_account_id: your own identifier for the account, for cards, wallets and rails without an IBAN. It serves one purpose: linking the transaction to a coverage declared on the same account. It does not enter the fingerprint. Measured: it used to be accepted, then discarded; the series had no source, and lifecycle.state stayed unknown.

  • ibanstring
    ^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$
  • external_account_idstring · length 1–128
  • external_transaction_idstring · length 1–128

    Your own transaction identifier. It does not enter the fingerprint: it says how you name the transaction, not what it is. Its purpose is to stop two submissions of the same transaction from being matched to each other.

Responses

200Already enriched: request replayed, nothing was created. Location is set here too: the client learns where the resource it did not create lives.Enrichment
201Enrichment created.Enrichment
400Unreadable body, or Idempotency-Key outside the alphabet.Problem
401Credential missing, malformed, from another family, unknown, revoked, or whose declared prefix does not match the recorded prefix. A single code for all these cases: telling them apart would be an oracle.Problem
403Workspace or organisation inactive.Problem
409This Idempotency-Key has already been used for a different request, that is, one with a different economic fingerprint (fingerprint). Same key + same fingerprint = replay (200), never 409.Problem
413Body too large.Problem
415The body must be application/json.Problem
422Closed schema: unknown field, or violated constraint. errors[].code names the rule, and the list is the runtime's own, by construction: body_not_object, unknown_field, required_non_empty_string, too_long, contains_control_character, must_be_incoming_or_outgoing, must_be_rfc3339_with_offset, must_be_object, must_be_string, must_be_boolean, must_be_four_digits, must_be_iso_3166_1_alpha_2_uppercase, malformed_iban, malformed_bic, must_be_non_empty_string, amount_not_object, amount_value_not_string, amount_value_malformed, amount_currency_not_string, amount_currency_malformed, amount_currency_unknown, amount_precision_exceeds_currency. must_be_rfc3339_with_offset covers form and calendar: 2026-02-30T00:00:00Z, +16:00 or 23:59:60 are rejected. must_be_object also rejects an array, for all four families. contains_control_character: a control character (C0, DEL, C1) in a text, except tab, line feed and carriage return. amount_currency_unknown is returned under the unknown_currency type, the others under invalid_input.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 body must be application/json, otherwise 415. Every error carries request_id in its body, which is what you quote to support; once the credential is accepted, every response also carries the noryo-request-id, noryo-ratelimit-limit and noryo-ratelimit-remaining headers (see Rate limits).

The body: TransactionInput

Schema TransactionInput

Closed schema: an unknown field is rejected (422), never ignored.

  • descriptorstring · length 1–512required
  • directionstringrequired
    incomingoutgoing
  • occurred_atstring · date-time

    RFC 3339 with an offset. Never reduced to a date: midnight is not the same day everywhere.

  • amountMoney
  • valuestringrequired

    Decimal as a string. A float would lose pence in silence. The number of decimal places allowed depends on the currency: 5.905 EUR is rejected, and so is 100.50 ISK (ISK and JPY have none, KWD has three).

    ^-?(0|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$
  • currencystringrequired

    A currency outside this list is rejected with 422 unknown_currency.

    CADCHFDKKEURGBPISKJPYKWDNOKPLNSEKUSD
  • paymentobject
  • railstring · length ≤ 128
  • mccstring
    ^[0-9]{4}$
  • card_presentboolean
  • network_merchant_idstring · length ≤ 128
  • raw_typestring · length ≤ 128
  • location_hintobject
  • countrystring
    ^[A-Z]{2}$
  • citystring · length ≤ 128
  • postal_codestring · length ≤ 128
  • counterparty_hintobject

    The IBAN is reduced on entry to its country and bank code. The account number does not enter the system, so it cannot leave it.

  • ibanstring
    ^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$
  • bicstring
    ^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$
  • account_hintobject

    The account on which the transaction went through, as distinct from counterparty_hint, which designates the other end. Without it, a transfer pair cannot be identified: the only signal that designates it is "my account is your counterparty, and your counterparty is my account". The IBAN is reduced on entry, like the counterparty's: the account number does not enter the system. external_account_id: your own identifier for the account, for cards, wallets and rails without an IBAN. It serves one purpose: linking the transaction to a coverage declared on the same account. It does not enter the fingerprint. Measured: it used to be accepted, then discarded; the series had no source, and lifecycle.state stayed unknown.

  • ibanstring
    ^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$
  • external_account_idstring · length 1–128
  • external_transaction_idstring · length 1–128

    Your own transaction identifier. It does not enter the fingerprint: it says how you name the transaction, not what it is. Its purpose is to stop two submissions of the same transaction from being matched to each other.

Only descriptor and direction are required; an optional field is only worth sending if it is actually read (see signals.used below).

FieldRequiredWhat it carries
descriptoryesThe label exactly as it appears on the statement, 1 to 512 characters.
directionyesincoming or outgoing.
occurred_atnoWhen the transaction happened, RFC 3339 with an offset.
amountnoA Money object: value as a string, currency from the accepted list.
paymentnorail, mcc (four digits), card_present, network_merchant_id, raw_type.
location_hintnocountry (ISO 3166-1 alpha-2, upper case), city, postal_code.
counterparty_hintnoiban, bic of the other party.
account_hintnoiban or external_account_id of the account the transaction went through.
external_transaction_idnoYour own transaction identifier, 1 to 128 characters.

A closed schema

An unknown field is never silently dropped: it is rejected with a 422 invalid_input whose errors[] entries carry field and code, here unknown_field. The same rule applies inside payment, location_hint, counterparty_hint and account_hint, and an array in place of any of those objects is rejected with must_be_object.

occurred_at: an instant, not a date

The value is an RFC 3339 timestamp with an offset, for example 2026-09-09T07:31:00+02:00. A bare date is refused: midnight is not the same day everywhere, and series cadence is read against the calendar. The must_be_rfc3339_with_offset code covers both form and calendar: 2026-02-30T00:00:00Z, an offset of +16:00 or a 23:59:60 second are all refused.

amount: a decimal as a string

Schema Money

  • valuestringrequired

    Decimal as a string. A float would lose pence in silence. The number of decimal places allowed depends on the currency: 5.905 EUR is rejected, and so is 100.50 ISK (ISK and JPY have none, KWD has three).

    ^-?(0|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$
  • currencystringrequired

    A currency outside this list is rejected with 422 unknown_currency.

    CADCHFDKKEURGBPISKJPYKWDNOKPLNSEKUSD

amount.value is a string ("49.90"), never a JSON number: a float would lose pence in silence. The number of decimal places allowed depends on the currency: 5.905 in EUR is refused (amount_precision_exceeds_currency), and so is 100.50 in ISK, because ISK and JPY have none and KWD has three. currency must belong to the Money.currency enum; any other currency returns a 422 of type unknown_currency.

account_hint and counterparty_hint

The IBAN, whether the account's or the counterparty's, is reduced on entry to its country and bank code: the account number never enters the system. Without account_hint, a transfer pair cannot be identified. For a card or wallet with no IBAN, external_account_id carries your own identifier; its single purpose is to link the transaction to an observation coverage declaration on the same account, and it does not enter the fingerprint.

external_transaction_id does not enter the fingerprint either: it says how you name the transaction, not what it is, and it stops two submissions of the same transaction from being related to each other.

Idempotency-Key

The header is optional. Its value is 1 to 255 bytes of visible ASCII (0x21 to 0x7E); an empty value, or one outside that alphabet, returns a 400 invalid_idempotency_key.

SituationResponse
Key never seen201, the enrichment is created.
Same key, same fingerprint (fingerprint)200: a replay, the original response returned as it was, engine version included.
Same key, different fingerprint409 idempotency_conflict.

"Different request" is judged on the economic fingerprint and on nothing else. Two bodies that differ only by external_transaction_id, account_hint or counterparty_hint share a fingerprint: the first submission is replayed. So do not reuse a key for two transactions you distinguish by those fields alone. A key is retained for at least 24 hours; beyond that it may open a new operation.

Note

A 409, or a 400 on the key, is returned after a valid credential: it consumes a rate-limit token, just as a 422 does.

201 or 200

A 201 means an enrichment was created; a 200, that an already processed request was replayed, creating nothing. Both carry a Location header pointing at the resource, which stays readable for 30 days through GET /transactions/v1/enrichments/{enrichment_id} (see Read an enrichment). The body is an Enrichment: enrichment_id, created_at, contract_version, canonical_version, fingerprint, signals, resolution, then relationships and series (empty lists when no relationship or series was concluded) and relationships_policy, whose unresolved says why a detector did not conclude.

signals.used and signals.ignored

signals.used lists the received signals that were passed to the engine: to the identity resolver, or to the store for series, relationships and observation coverage. signals.ignored lists those nobody read. Only fields present in your request appear in either list. Today, payment.mcc, payment.card_present, payment.network_merchant_id, location_hint.city, location_hint.postal_code and counterparty_hint.bic are accepted and then ignored.

This block is not a convenience: without it you could send an MCC for months, receive 201, and believe it weighs on the response. Check in your integration tests that no field you rely on shows up in ignored. signals.conflicting mirrors resolution.signal_conflicts, so that the block is complete.

What policy.outcome means

resolution.policy states what the response asserts, and it is the field to base decisions on. It carries outcome, archetype, missing and policy_version.

outcomeMeaning
resolvedEvery dimension the archetype requires is present.
partialThe nature is known, but a required dimension is missing, or signals contradict each other and the engine declines to conclude.
unresolvedEven the nature of the transaction is unknown.

What "resolved" means depends on the nature of the transaction. A transfer to a person is resolved without any identity being created; a cash withdrawal is resolved without a merchant; a card purchase, on the other hand, requires its counterparty, and its absence yields partial with missing: ["counterparty_entity"]. A counterparty of null is a result, not a failure, and counterparty_note says why. The numbers under resolution.confidence are declared constants, neither probabilities nor thresholds: see Score semantics.

Examples

With curl:

curl -X POST https://api.noryo.app/transactions/v1/enrichments \
  -H "Authorization: Bearer $NORYO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tx-2026-09-09-0042" \
  -d '{
    "descriptor": "CARTE 09/09 MONOPRIX PARIS 15",
    "direction": "outgoing",
    "occurred_at": "2026-09-09T07:31:00+02:00",
    "amount": { "value": "49.90", "currency": "EUR" },
    "payment": { "rail": "card", "mcc": "5411", "card_present": true },
    "location_hint": { "country": "FR" },
    "account_hint": { "iban": "FR7630006000011234567890189" },
    "external_transaction_id": "tx-2026-09-09-0042"
  }'
Shell

With fetch:

const response = await fetch("https://api.noryo.app/transactions/v1/enrichments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NORYO_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "tx-2026-09-09-0042",
  },
  body: JSON.stringify({
    descriptor: "CARTE 09/09 MONOPRIX PARIS 15",
    direction: "outgoing",
    occurred_at: "2026-09-09T07:31:00+02:00",
    amount: { value: "49.90", currency: "EUR" },
    payment: { rail: "card", mcc: "5411", card_present: true },
    location_hint: { country: "FR" },
    account_hint: { iban: "FR7630006000011234567890189" },
    external_transaction_id: "tx-2026-09-09-0042",
  }),
});

const enrichment = await response.json();
console.log(response.status, enrichment.signals, enrichment.resolution.policy);
TypeScript

For this request, the response lists payment.mcc and payment.card_present under signals.ignored.

Error responses

Every error returned by the API is a Problem object with type, title, status and request_id, detail when the API provides one and, on a 422, errors[]. A 401 means the credential is missing, malformed, from another family, unknown or revoked; a 403 an inactive workspace; a 413 a body that is too large; a 415 a body that is not application/json; a 429 a rate limit reached, to be retried after Retry-After. The 400, 409 and 422 cases are described above. The full catalogue lives on Errors and on /transaction-intelligence/errors/<code>.

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

Was this page helpful?