---
title: "Validate one e-invoice XML document"
method: POST
path: "/v1/einvoices/validate"
tags: ["validation"]
---

# Validate one e-invoice XML document

`POST /v1/einvoices/validate`

## Request

- Base URL: `https://api.ironfang.com/finance`
- URL: `https://api.ironfang.com/finance/v1/einvoices/validate`
- Auth: one of:
  - none
  - HTTP bearer

Validates a single UBL Invoice or CreditNote document synchronously and
returns a `ValidationVerdict`.

**Body.** Send the raw XML as the request body with
`Content-Type: application/xml` or `text/xml`, choosing the ruleset
with the query parameters. Or send `multipart/form-data` with a
`document` part holding the XML and an optional `options` part
holding a JSON `ValidationOptions` object; query parameters are not
read for multipart requests. The multipart form is the one to use
from browsers and low-code tools.

**Strictness.** For raw XML bodies, a query parameter that is
repeated or not one of `ruleset`, `profile` and `document_type` is
`400 malformed_request`, so a misspelt selector cannot silently fall
back to a default. For multipart bodies, exactly one `document`
part (an XML media type) and at most one `options` part
(`application/json`) are accepted; a repeated or unknown part is
`400 malformed_request`. A `Content-Encoding` other than identity is
`415 unsupported_content_type`.

**Correlation.** Every response carries `X-Ironfang-Request-ID`,
Ironfang's id for the request, which every Problem repeats as
`request_id`. Send your own `X-Request-ID` and it is echoed back
unchanged.

**Limits.** The XML is at most 5 MiB (5,242,880 bytes), 64 levels
deep and 250,000 elements. Archives and compressed bodies
(`Content-Encoding` other than identity, ZIP, gzip) are refused with
`415 unsupported_content_type`; a larger body with
`413 payload_too_large`. The validation deadline is 10 seconds; a
validation that exceeds it is indeterminate (`504`).

**Ruleset selection.** `ruleset` is either the keyword `latest` or an
exact immutable ruleset id (`fwrs_...`).

- `latest` resolves to the currently `sendable` release for the
  document's profile family at the moment of the request. With a
  `profile`, the result reports `selection_method: latest`; without
  one, the family is detected from the document's `CustomizationID`
  and `ProfileID` and the result reports `selection_method:
  detected`. Detection is for validation only; it is never a
  statement about what the document should be. A document whose
  profile cannot be detected - including XML that does not parse -
  is `422 profile_undetected`; name a `profile` or an exact ruleset
  to obtain a verdict on it.
- An exact id validates against that release whatever its state
  among `scheduled` (migration testing), `sendable` (current) and
  `historical` (audit and reproduction). The result reports
  `selection_method: exact` and the release's `state`. An id that is
  unknown - or names an internal candidate, which is
  indistinguishable from unknown - is `422 unknown_ruleset`; a
  withdrawn release is `410 ruleset_disabled`.

The result always names the resolved immutable id in `ruleset.id`.

**Outcome versus status.** The result separates how far execution
got (`status`) from what the standard concluded (`outcome`). A
document that fails the standard at any layer - including malformed
XML at the `xml` layer - is a successfully processed validation:
HTTP `200` with `outcome: invalid`, and every deeper layer that could
not run is reported as `skipped`, never omitted. Only the request
itself being wrong, or Ironfang Finance being unable to decide, produces a
Problem.

**Idempotency.** Authenticated requests may send an
`Idempotency-Key` (8-128 characters of `A-Z a-z 0-9 _ -`). The key
is scoped to the organisation and the validation endpoint. Replaying
the same request with the same key returns the original operation
and its result - the same `operation_id`, no second execution, no
second charge - even if `latest` has since come to mean a newer
release. A request is identified by its content: reusing a key with
different document bytes (a different input SHA-256) or a different
ruleset, profile or document type is `409 idempotency_conflict`.
Requests without a key always create a new operation. Anonymous
requests do not use the header.

**Findings.** At most 1,000 findings are returned inline, in
deterministic order: layer order, then severity (`fatal` before
`warning`), then `rule_id`, then `location`, then `message`.
`findings_summary` always states how many were returned, how many
exist, the cap, and whether truncation happened.

## Query parameters

- `ruleset` string: `latest`, or an exact ruleset id in any validatable state.
- `profile` 'peppol-bis-billing-3': A supported profile family. V1 supports Peppol BIS Billing 3.
- `document_type` 'invoice' | 'credit_note' | 'auto'

## Headers

- `Idempotency-Key` string

## Response `200`

A verdict. `status` is `completed`, `outcome` is `valid` or `invalid`, and no layer is in `error`. A standards failure at any layer is this response, not a Problem.

- ValidationVerdict: The `200` response of the validate operation: a `ValidationResult` that reached a verdict. `status` is `completed`, `outcome` is `valid` or `invalid`, and no layer is in `error`. Indeterminate results are not representable here; they are Problems.
  - `counts` FindingCounts, required
    - `by_layer` object, required
      - `en16931` integer, required
      - `input` integer, required
      - `peppol` integer, required
      - `xml` integer, required
      - `xsd` integer, required
    - `by_severity` object, required
      - `fatal` integer, required
      - `warning` integer, required
  - `document_id` string: An opaque Ironfang identifier.
  - `engine` EngineInfo, required: What ran, exactly, so the result can be reproduced.
    - `hint_pack_version` string: The version of Ironfang's hint mappings applied, when any. Nonblank when present.
    - `image_digest` string, required: The pinned validator engine image, as a sha256 digest.
    - `name` string, required: The validator engine and version, e.g. `phive/12.1.0`.
  - `findings` Finding[], required: Normalised findings in deterministic order. Empty, never null.
    - `hint` string
    - `hint_code` string
    - `layer` 'input' | 'xml' | 'xsd' | 'en16931' | 'peppol', required: A validation layer, in execution order.
    - `location` string: A bounded, sanitised location. Absent when `location_kind` is `none`.
    - `location_kind` 'none' | 'xpath' | 'line-column', required: The vocabulary the validator and the findings store use: `none`, `xpath`, or `line-column`.
    - `message` 'A Peppol BIS Billing 3.0 rule failed at this location.' | 'An EN 16931 business rule failed at this location.' | 'The document could not be read as XML within the contract limits.' | 'The document does not conform to the UBL 2.1 schema at this location.' | 'The document encoding, declaration or structure is not accepted.' | 'The document has more elements than the contract limit of 250,000.' | 'The document nests elements deeper than the contract limit of 64.' | 'The input is empty or larger than the contract limit of 5,242,880 bytes.' | 'The input was refused by the contract limits before parsing.', required: One of Ironfang's fixed finding messages, selected by the finding's rule id (for the validator's own FW-* rules) or by its layer. No message text from the validation engine or a rule set is ever returned: the rule id and the sanitised location carry the detail. The union below is exactly the closed catalogue (services/finance-validator/corpus/finding-messages.json); a result carrying any other message is refused before it is stored or written.
    - `rule_id` string, required: The official rule identifier, e.g. `PEPPOL-EN16931-R020`, `BR-CO-10`, or `cvc-complex-type.2.4.a` for schema errors.
    - `ruleset_id` string, required: An immutable ruleset identifier. Never reused, never edited.
    - `severity` 'fatal' | 'warning', required: `fatal` findings make the document invalid; `warning` findings never do and are never promoted.
    - `source_url` string, uri: A provenance URL under one narrow grammar: https, a lowercase DNS host of at least two labels, no userinfo, no IP literal, an optional port 1..65535, an optional path, query and fragment of unreserved and sub-delimiter characters or valid percent-escapes, no whitespace. The same expression is held by the registry review, the database and this document.
  - `findings_summary` FindingsSummary, required: Truncation is explicit. `returned` is the length of `findings`; `total` is how many exist; `cap` is the inline maximum; `truncated` is `total > returned`, and a truncated result returns exactly `cap`.
    - `cap` 1000, required
    - `returned` integer, required
    - `total` integer, required
    - `truncated` boolean, required
  - `input` ValidationInput, required
    - `bytes` integer, required
    - `content_type` 'application/xml' | 'text/xml', required
    - `document_type` 'invoice' | 'credit_note' | 'unknown', required: The document type found at the root element; `unknown` when the root was not recognised or the XML did not parse.
    - `sha256` string, required: Lowercase hex SHA-256.
  - `layers` object[], required: Every layer, always in this order, always present.
    - `fatal` integer, required: Fatal findings this layer produced.
    - `layer` 'input' | 'xml' | 'xsd' | 'en16931' | 'peppol', required: A validation layer, in execution order.
    - `reason` 'skipped_after_failure' | 'not_applicable' | 'engine_error' | 'deadline_exceeded': Why a layer did not run to a verdict. `skipped_after_failure`: an earlier layer failed and this one cannot execute safely. `not_applicable`: the layer does not exist for the selected profile. `engine_error`: the validator failed inside this layer. `deadline_exceeded`: the deadline passed during this layer.
    - `status` 'passed' | 'failed' | 'skipped', required
    - `warning` integer, required
  - `links` ResultLinks: Authorised links, present only on authenticated results whose retention policy keeps them. Time-limited.
    - `evidence` string, uri: A provenance URL under one narrow grammar: https, a lowercase DNS host of at least two labels, no userinfo, no IP literal, an optional port 1..65535, an optional path, query and fragment of unreserved and sub-delimiter characters or valid percent-escapes, no whitespace. The same expression is held by the registry review, the database and this document.
    - `report` string, uri: A provenance URL under one narrow grammar: https, a lowercase DNS host of at least two labels, no userinfo, no IP literal, an optional port 1..65535, an optional path, query and fragment of unreserved and sub-delimiter characters or valid percent-escapes, no whitespace. The same expression is held by the registry review, the database and this document.
  - `operation_id` string: An opaque Ironfang identifier.
  - `outcome` 'valid' | 'invalid', required: The standard's verdict. `indeterminate` means Ironfang Finance could not decide; it is never converted to `invalid` and never billed.
  - `profile` ProfileDetection, required: The profile family the document was validated as, and how that was decided.
    - `customization_id` string: The document's `CustomizationID`, a profile identifier - not invoice content. Present when parsed.
    - `family` 'peppol-bis-billing-3', required: A supported profile family. V1 supports Peppol BIS Billing 3.
    - `profile_id` string: The document's `ProfileID`. Present when parsed.
    - `source` 'requested' | 'detected', required: `requested` (the request named the profile or an exact ruleset) or `detected` (from the document's identifiers).
  - `ruleset` RulesetSelection, required: What was asked for and what was used.
    - `id` string, required: An immutable ruleset identifier. Never reused, never edited.
    - `official_release` string
    - `requested` string, required: `latest`, or an exact ruleset id in any validatable state.
    - `selection_method` 'exact' | 'latest' | 'detected', required: How the ruleset was chosen: `exact` (the request named the id), `latest` (resolved from the requested profile), `detected` (profile detected from the document, then resolved as latest).
    - `state` 'scheduled' | 'sendable' | 'historical', required: Public lifecycle state of a release that may be validated against. `sendable` is current and is what `latest` resolves to; `scheduled` is the next release, not yet in force, validatable for migration testing; `historical` has been superseded but stays validatable for reproduction. Withdrawn (`disabled`) releases answer `410` and never appear in a body; candidate releases are internal and never appear.
    - `vesid` string: The engine's coordinate for the release, for cross-referencing official tooling.
  - `schema` 'financewolf/einvoice/validation-result/v1', required: The version of this result document.
  - `status` 'completed', required: How far execution got, independent of the verdict.
  - `timing` ValidationTiming, required
    - `completed_at` string, date-time, required: RFC 3339, UTC.
    - `duration_ms` integer, required: Wall time, bounded by the 10-second deadline.
    - `started_at` string, date-time, required: RFC 3339, UTC.
  - `usage` Usage: Present only on authenticated results. Never reported for anonymous validation, which has no entitlement to report against.
    - `charged` boolean, required: Whether this validation consumed entitlement. Always `false` for indeterminate outcomes.
    - `credits` integer, required: Units of the operation's meter this operation used - 1 when charged, otherwise 0.
    - `period_ends_at` string, date-time: RFC 3339, UTC.
    - `remaining` integer: Only on results recorded before usage billing, returned as they were recorded. The month's usage is read at GET /v1/einvoices/usage.

## Other responses

- `400`: The request itself is wrong - not the document being validated.
- `401`: A credential was presented and could not be verified. Anonymous access, where permitted, sends no credential at all.
- `402`: The free allowance is used up and paid usage cannot continue until the billing account is put right: paused, no working payment method, a payment awaiting confirmation, or restricted. No work or usage was accepted.
- `403`: The key lacks the scope this operation needs, or paid Ironfang Finance usage needs an approved business and the free allowance is used up. No work or usage was accepted.
- `409`: The `Idempotency-Key` was already used by this organisation, on the validation endpoint, for a request with different content. The original operation is left untouched; send a new key for a new request.
- `410`: The selected release is withdrawn, the original saved result expired or was deleted, or the billing account is closed. Result replay never executes again.
- `413`: The body exceeds 5 MiB.
- `415`: The body is not XML (an archive, a compressed body, or another media type).
- `422`: The requested ruleset, profile or document type cannot be used: `unknown_ruleset` (unknown, or an internal candidate), `unsupported_profile`, `profile_undetected` or `unsupported_document_type`. The document was not validated.
- `429`: Per-address or per-key rate limit (`rate_limited`), which asks for a retry, or the billing account can admit no more of this work this month: the free allowance used up without paid usage, or a monthly limit reached.
- `500`: Ironfang Finance failed while validating, in a way that is not the validator's own. Indeterminate: the body says `outcome: indeterminate` and `usage_charged: false`.
- `503`: The validator crashed, was unavailable, or an internal dependency failed. Indeterminate: `outcome: indeterminate`, `usage_charged: false`. Or billing was briefly unavailable (`billing_temporarily_unavailable`), and nothing was accepted. Retry after the indicated delay.
- `504`: The 10-second validation deadline passed. Indeterminate: `outcome: indeterminate`, `usage_charged: false`.

---

[API](https://skmtc.dev/ironfang/apis/ironfang-finance-e-invoicing-api.md) · [All operations](https://skmtc.dev/ironfang/apis/ironfang-finance-e-invoicing-api/llms.txt) · [OpenAPI document](https://skmtc.dev/ironfang/apis/ironfang-finance-e-invoicing-api/revisions/115e20013788?raw)
