# Validation

> The request schema is closed: every validation refusal, its code, its exact message, and the rules that most often surprise (upper case, null, UTC, unknown fields).

A request body is validated against a **closed** schema: an unknown field is never ignored, it makes the request fail. This strictness makes the comparison of two requests total: a field silently tolerated would make two requests differ that are identical in the engine's eyes, and would produce an unjustified `409`.

Every body validation error returns `400` with the code `invalid_request`. The `detail` field says which rule failed; the first failing rule stops validation.

```json
{
  "type": "https://developer.noryo.app/errors/invalid_request",
  "title": "Invalid request",
  "status": 400,
  "code": "invalid_request",
  "detail": "currency must be an ISO 4217 code",
  "request_id": "req_…"
}
```

> **Warning** Build your logic on `code`, not on `detail`. The `detail` text is written for a human, in English, and may be reworded without a version change.

## The `detail` messages

| `detail` | Cause |
|---|---|
| `body must be a JSON object` | The body is an array, a string, a number or `null` |
| `unknown field: <name>` | A field outside the schema is present |
| `descriptor is required` | `descriptor` missing, empty, whitespace only, or not a string |
| `descriptor is too long` | More than 512 characters |
| `amount must be a finite number` | `amount` is not a JSON number (a string `"12.40"`, `null`) |
| `currency must be an ISO 4217 code` | Not exactly three upper-case letters (`eur`, `EURO`, `null`) |
| `occurred_at must be ISO 8601 UTC` | Not a string shaped `YYYY-MM-DDTHH:MM:SS` followed by an optional fraction and `Z`, or a date that does not exist |
| `country must be ISO 3166-1 alpha-2` | Not exactly two upper-case letters |
| `mcc must be four digits` | Not a string of exactly four digits (a number such as `5814` included) |
| `external_transaction_id must be 1..255 characters` | Empty, too long, or not a string |

## The rules that surprise

**Unknown fields are refused.** A typo (`occured_at`) or a field from your internal model sent by mistake returns `unknown field: occured_at`. Some names are reserved for the server and refused the same way if they appear in the body: `status`, `confidence`, `billable`, `units`, `environment`, `source`, `customized`, `enrichment_id`, `request_id`, among others. The client can never declare what the server computes.

**`null` is not an absence.** For an optional field you do not have, omit the key. `"amount": null` is refused.

**Upper case is required.** `currency` and `country` are compared verbatim; the validator refuses `eur` rather than correcting it. See [Formats](https://developer.noryo.app/en-GB/merchant/requests/formats).

**`occurred_at` must be in UTC, with `Z`.** `2026-09-06T13:42:00+02:00` is refused: convert it to `2026-09-06T11:42:00Z`. A well-shaped date that does not exist (`2026-02-30T10:00:00Z`, an hour of `24`, a second of `60`) is refused the same way, with `400`.

**`mcc` is a string.** Send `"5814"`, not `5814`: a numeric `5814` is refused with `400` and `mcc must be four digits`.

## The body of a refresh

The body of `POST …/refresh` accepts the six economic fields and nothing else. An `external_transaction_id` is refused there (`unknown field: external_transaction_id`): the refresh targets the enrichment named in the URL, and accepting a second identifier would let one request designate two transactions. See [Refresh](https://developer.noryo.app/en-GB/merchant/refresh).

## Refusals that come before body validation

| Code | Status | Cause |
|---|---|---|
| [`unsupported_media_type`](https://developer.noryo.app/en-GB/errors/unsupported_media_type) | 415 | `Content-Type` missing or other than `application/json` |
| [`request_entity_too_large`](https://developer.noryo.app/en-GB/errors/request_entity_too_large) | 413 | Body over 16 KiB |
| [`invalid_json`](https://developer.noryo.app/en-GB/errors/invalid_json) | 400 | Body that is not valid JSON, or an empty body |
| [`invalid_idempotency_key`](https://developer.noryo.app/en-GB/errors/invalid_idempotency_key) | 400 | Malformed `Idempotency-Key` header |

## Other route-specific refusals

| Code | Status | Cause |
|---|---|---|
| [`insufficient_scope`](https://developer.noryo.app/en-GB/errors/insufficient_scope) | 403 | The key lacks `enrich:write` |
| [`identifier_required`](https://developer.noryo.app/en-GB/errors/identifier_required) | 400 | Create call with neither `Idempotency-Key` nor `external_transaction_id`, or refresh without `Idempotency-Key` |

On create, the order is: body, scope, identifier. On refresh: path identifier, `Idempotency-Key`, scope, then body. The complete order of checks is described in [Requests](https://developer.noryo.app/en-GB/merchant/requests).

---

Canonical page: https://developer.noryo.app/en-GB/merchant/requests/validation
