# Enrich a transaction

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

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](https://developer.noryo.app/en-GB/transaction-intelligence/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.

#### Parameters

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | — | string | 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. Pattern: `^[\x21-\x7E]{1,255}$`. |

#### Request body

`application/json` · required · Schema `TransactionInput`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `descriptor` | string · length 1–512 | yes | — |
| `direction` | string | yes | Values: `incoming`, `outgoing`. |
| `occurred_at` | string · date-time | — | RFC 3339 with an offset. Never reduced to a date: midnight is not the same day everywhere. |
| `amount` | Money | — | — |
| `amount.value` | string | yes | 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). Pattern: `^-?(0\|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$`. |
| `amount.currency` | string | yes | A currency outside this list is rejected with 422 `unknown_currency`. Values: `CAD`, `CHF`, `DKK`, `EUR`, `GBP`, `ISK`, `JPY`, `KWD`, `NOK`, `PLN`, `SEK`, `USD`. |
| `payment` | object | — | — |
| `payment.rail` | string · length ≤ 128 | — | — |
| `payment.mcc` | string | — | Pattern: `^[0-9]{4}$`. |
| `payment.card_present` | boolean | — | — |
| `payment.network_merchant_id` | string · length ≤ 128 | — | — |
| `payment.raw_type` | string · length ≤ 128 | — | — |
| `location_hint` | object | — | — |
| `location_hint.country` | string | — | Pattern: `^[A-Z]{2}$`. |
| `location_hint.city` | string · length ≤ 128 | — | — |
| `location_hint.postal_code` | string · length ≤ 128 | — | — |
| `counterparty_hint` | object | — | 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. |
| `counterparty_hint.iban` | string | — | Pattern: `^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$`. |
| `counterparty_hint.bic` | string | — | Pattern: `^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$`. |
| `account_hint` | object | — | 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`. |
| `account_hint.iban` | string | — | Pattern: `^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$`. |
| `account_hint.external_account_id` | string · length 1–128 | — | — |
| `external_transaction_id` | string · 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. |

Example — A card purchase, with the account to link a coverage window

```json
{
  "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"
}
```

Example — A recurring SEPA direct debit

```json
{
  "descriptor": "PRLV SEPA NETFLIX INTERNATIONAL",
  "direction": "outgoing",
  "occurred_at": "2026-09-05T03:10:00+02:00",
  "amount": {
    "value": "13.49",
    "currency": "EUR"
  },
  "payment": {
    "rail": "sepa_direct_debit"
  },
  "account_hint": {
    "iban": "FR7630006000011234567890189"
  }
}
```

Example — A prepaid card: the account is named by your identifier

```json
{
  "descriptor": "APPLE.COM/BILL",
  "direction": "outgoing",
  "amount": {
    "value": "2.99",
    "currency": "EUR"
  },
  "account_hint": {
    "external_account_id": "carte-4970"
  }
}
```

#### Responses

| Status | Description |
| --- | --- |
| `200` | Already enriched: request replayed, nothing was created. `Location` is set here too: the client learns where the resource it did not create lives. |
| `201` | Enrichment created. |
| `400` | Unreadable body, or Idempotency-Key outside the alphabet. |
| `401` | Credential 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. |
| `403` | Workspace or organisation inactive. |
| `409` | This 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. |
| `413` | Body too large. |
| `415` | The body must be application/json. |
| `422` | Closed 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`. |
| `429` | `rate_limited`: the operational rate limit has been reached, for this key or for the workspace (the `detail` says which). Nothing was computed, nothing was stored, no event was emitted. Replay after `Retry-After`. See `x-noryo-rate-limit`. |
| `500` | Internal error. |

#### Errors named by the contract

- [`invalid_api_key`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/invalid_api_key)
- [`account_inactive`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/account_inactive)
- [`idempotency_conflict`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/idempotency_conflict)
- [`request_entity_too_large`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/request_entity_too_large)
- [`unsupported_media_type`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/unsupported_media_type)
- [`invalid_input`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/invalid_input)
- [`unknown_currency`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/unknown_currency)
- [`rate_limited`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/rate_limited)
- [`internal_error`](https://developer.noryo.app/en-GB/transaction-intelligence/errors/internal_error)

#### Rate limits

This operation consumes the rate limit `debit_ti_v1`.

`debit_ti_v1` — operational_burst_protection · token_bucket

- per key: 120 requests / minute (capacity 120, refill 2/s)
- per workspace: 600 requests / minute (capacity 600, refill 10/s)

« 120 requetes / minute » est traduit en seau a jetons : capacite 120 (la rafale toleree) rechargee a 2 jetons / seconde (le regime durable). Une clef au repos peut envoyer 120 requetes d'un coup, puis 2 par seconde ; sur la premiere minute apres un repos, jusqu'a 240 passent. Le workspace, lui, borne la somme de toutes ses clefs a 600 / minute (capacite 600, 10 / s) : cent clefs ne font pas cent fois la limite.

It is not: `sandbox_lifetime_quota`, `production_usage_quota`, `billing_entitlement`, `commercial_promise`.

→ [POST /transactions/v1/enrichments](https://developer.noryo.app/en-GB/transaction-intelligence/reference/create-enrichment.md)

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](https://developer.noryo.app/en-GB/transaction-intelligence/rate-limits)).

## The body: `TransactionInput`

**Schema `TransactionInput`**

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `descriptor` | string · length 1–512 | yes | — |
| `direction` | string | yes | Values: `incoming`, `outgoing`. |
| `occurred_at` | string · date-time | — | RFC 3339 with an offset. Never reduced to a date: midnight is not the same day everywhere. |
| `amount` | Money | — | — |
| `amount.value` | string | yes | 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). Pattern: `^-?(0\|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$`. |
| `amount.currency` | string | yes | A currency outside this list is rejected with 422 `unknown_currency`. Values: `CAD`, `CHF`, `DKK`, `EUR`, `GBP`, `ISK`, `JPY`, `KWD`, `NOK`, `PLN`, `SEK`, `USD`. |
| `payment` | object | — | — |
| `payment.rail` | string · length ≤ 128 | — | — |
| `payment.mcc` | string | — | Pattern: `^[0-9]{4}$`. |
| `payment.card_present` | boolean | — | — |
| `payment.network_merchant_id` | string · length ≤ 128 | — | — |
| `payment.raw_type` | string · length ≤ 128 | — | — |
| `location_hint` | object | — | — |
| `location_hint.country` | string | — | Pattern: `^[A-Z]{2}$`. |
| `location_hint.city` | string · length ≤ 128 | — | — |
| `location_hint.postal_code` | string · length ≤ 128 | — | — |
| `counterparty_hint` | object | — | 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. |
| `counterparty_hint.iban` | string | — | Pattern: `^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$`. |
| `counterparty_hint.bic` | string | — | Pattern: `^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$`. |
| `account_hint` | object | — | 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`. |
| `account_hint.iban` | string | — | Pattern: `^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$`. |
| `account_hint.external_account_id` | string · length 1–128 | — | — |
| `external_transaction_id` | string · 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).

| Field | Required | What it carries |
|---|---|---|
| `descriptor` | yes | The label exactly as it appears on the statement, 1 to 512 characters. |
| `direction` | yes | `incoming` or `outgoing`. |
| `occurred_at` | no | When the transaction happened, RFC 3339 with an offset. |
| `amount` | no | A `Money` object: `value` as a string, `currency` from the accepted list. |
| `payment` | no | `rail`, `mcc` (four digits), `card_present`, `network_merchant_id`, `raw_type`. |
| `location_hint` | no | `country` (ISO 3166-1 alpha-2, upper case), `city`, `postal_code`. |
| `counterparty_hint` | no | `iban`, `bic` of the other party. |
| `account_hint` | no | `iban` or `external_account_id` of the account the transaction went through. |
| `external_transaction_id` | no | Your 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`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | string | yes | 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). Pattern: `^-?(0\|[1-9][0-9]{0,15})(\.[0-9]{1,4})?$`. |
| `currency` | string | yes | A currency outside this list is rejected with 422 `unknown_currency`. Values: `CAD`, `CHF`, `DKK`, `EUR`, `GBP`, `ISK`, `JPY`, `KWD`, `NOK`, `PLN`, `SEK`, `USD`. |

`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](https://developer.noryo.app/en-GB/transaction-intelligence/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`.

| Situation | Response |
|---|---|
| Key never seen | 201, 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 fingerprint | 409 `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](https://developer.noryo.app/en-GB/transaction-intelligence/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`.

| `outcome` | Meaning |
|---|---|
| `resolved` | Every dimension the archetype requires is present. |
| `partial` | The nature is known, but a required dimension is missing, or signals contradict each other and the engine declines to conclude. |
| `unresolved` | Even 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](https://developer.noryo.app/en-GB/transaction-intelligence/score-semantics).

## Examples

With `curl`:

```bash
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"
  }'
```

With `fetch`:

```ts
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);
```

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](https://developer.noryo.app/en-GB/transaction-intelligence/errors) and on `/transaction-intelligence/errors/<code>`.

---

Canonical page: https://developer.noryo.app/en-GB/transaction-intelligence/enrich-a-transaction
