openapi: 3.1.0

# The contract, as a file a developer can feed to a generator before writing
# any code. Kept by hand rather than generated from the controllers: it is a
# published promise, and it should change only when we mean it to.

info:
  title: NormAPI
  version: '1'
  summary: Generate and validate German e-invoices (XRechnung, ZUGFeRD).
  description: |
    Two endpoints. `POST /v1/validate` checks an invoice against the official
    KoSIT rule set and returns every finding with the rule set's own text;
    `POST /v1/invoices` generates one from JSON in UBL, CII or ZUGFeRD-PDF and
    validates it before returning it.

    Every business-rule finding also carries a plain-language explanation —
    what the rule requires, why it typically fires, how to fix it — in German
    or English, chosen with `Accept-Language` (English by default). Schema
    findings carry the XML schema's own message only.

    Validation is free and unlimited on every plan. Generation counts against
    a monthly allowance; when it is used up the API answers 402 with the
    remedy in the body.

    Every validation result and every generated invoice carries the rule set
    version in force as `X-Normapi-Ruleset`.
  contact:
    name: NormAPI
    email: kontakt@normapi.de
    url: https://normapi.de
  termsOfService: https://normapi.de/agb

servers:
  - url: https://api.normapi.de
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Validation
    description: Check an existing invoice. Free, unlimited, no account required.
  - name: Generation
    description: Produce a compliant invoice from JSON. Counts against the allowance.

paths:
  /v1/validate:
    post:
      tags: [Validation]
      summary: Validate an invoice
      description: |
        Accepts XRechnung XML (UBL or CII) or a ZUGFeRD/Factur-X PDF; for
        hybrid PDFs the embedded invoice is what gets checked. Invoice content
        is processed in memory and never stored.
      security:
        - bearerAuth: []
        - {}
      parameters:
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/xml:
            schema: { type: string, format: binary }
          application/pdf:
            schema: { type: string, format: binary }
      responses:
        '200':
          description: The invoice was examined. `acceptable` says whether it passes.
          headers:
            X-Normapi-Ruleset:
              schema: { type: string }
              description: Rule set version that produced this verdict.
            Content-Language:
              $ref: '#/components/headers/ContentLanguage'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ValidationResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/OverCapacity' }

  /v1/invoices:
    post:
      tags: [Generation]
      summary: Generate an invoice
      description: |
        Produces a compliant invoice from structured data. Every result is
        validated before it is returned, so a 200 means the document passes
        the rule set.
      parameters:
        - name: syntax
          in: query
          description: Output format.
          schema:
            type: string
            enum: [ubl, cii, zugferd]
            default: ubl
        - $ref: '#/components/parameters/AcceptLanguage'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceRequest' }
            examples:
              creditNote:
                summary: A credit note (381) for an earlier invoice
                description: |
                  In UBL this becomes a CreditNote document; in CII and ZUGFeRD
                  an invoice with TypeCode 381, and the PDF page reads
                  GUTSCHRIFT. The due date is when the credited amount is paid
                  out; for a credit note, BT-84 is the account it is paid to.
                value:
                  invoiceNumber: GS-2026-0007
                  issueDate: '2026-09-01'
                  typeCode: '381'
                  precedingInvoices:
                    - number: RE-2026-0815
                      issueDate: '2026-08-13'
                  dueDate: '2026-09-15'
                  currency: EUR
                  buyerReference: '04011000-12345-03'
                  seller:
                    name: Muster Software GmbH
                    vatId: DE123456789
                    electronicAddress: rechnung@muster-software.de
                    address:
                      street: Beispielstraße 12
                      city: Berlin
                      postcode: '10115'
                      country: DE
                    contact:
                      name: Maria Muster
                      phone: '+49 30 1234567'
                      email: maria@muster-software.de
                  buyer:
                    name: Beispiel Handel AG
                    electronicAddress: einkauf@beispiel-handel.de
                    address:
                      street: Handelsweg 3
                      city: Hamburg
                      postcode: '20095'
                      country: DE
                  payment:
                    meansCode: '58'
                    iban: DE02120300000000202051
                    reference: GS-2026-0007
                  lines:
                    - name: Softwarelizenz Jahresabo, anteilig zurückerstattet
                      quantity: 1
                      unit: C62
                      unitPrice: 199.00
                      vatCategory: S
                      vatRate: 19
      responses:
        '200':
          description: The generated invoice — XML, or PDF for `syntax=zugferd`.
          headers:
            X-Normapi-Ruleset:
              schema: { type: string }
          content:
            application/xml:
              schema: { type: string }
            application/pdf:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExceeded' }
        '422':
          description: |
            The data describes an invoice the rule set does not permit. The
            findings say which rules failed — nothing is charged for this.
          headers:
            Content-Language:
              $ref: '#/components/headers/ContentLanguage'
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/InvoiceNotPermitted' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/OverCapacity' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Your API key from the dashboard, as `Authorization: Bearer nk_live_…`.
        Validation also works without one, under anonymous rate limits.

  parameters:
    AcceptLanguage:
      name: Accept-Language
      in: header
      required: false
      description: |
        The language of the findings' explanations: `de` for German, anything
        else — or no header — for English. Standard weighting applies, so
        `de-DE,de;q=0.9,en;q=0.8` gets German. The rule set's own texts are
        not translated.
      schema: { type: string, examples: [de] }

  headers:
    ContentLanguage:
      description: The language the explanations are in, `de` or `en`.
      schema: { type: string, enum: [de, en] }

  responses:
    BadRequest:
      description: |
        The request cannot become a document, and `detail` names the field: a
        required field is missing or malformed, or the syntax parameter is
        unknown; an allowance or charge gives both or neither of `amount` and
        `percent`; a document-level `percent` has nothing to be taken of (no
        `baseAmount`, and no line in its VAT category and rate); a card
        `number` carries more than 10 digits — at most the first 6 and the
        last 4 may appear on an invoice.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: The key is unknown or revoked.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    QuotaExceeded:
      description: |
        This month's generation allowance is used up. Validation is
        unaffected. Not retryable — the month has to turn, or the plan has to
        change.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    TooLarge:
      description: The upload exceeds 5 MB.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Too many requests. `Retry-After` says when to come back.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    OverCapacity:
      description: |
        All validation slots, or for `syntax=zugferd` all PDF rendering slots,
        are busy. Load on our side; retry after `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Problem:
      type: object
      description: RFC 9457 problem detail.
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
      required: [type, title, status]

    ValidationResult:
      type: object
      properties:
        acceptable:
          type: boolean
          description: True when no ERROR-severity finding remains. The verdict.
        rulesetVersion: { type: string }
        scenario:
          type: [string, 'null']
          description: |
            Which profile was recognised. Null means no rules ran at all — an
            empty findings list then does not mean "correct".
        wellFormed:
          type: boolean
          description: Whether the document parsed as XML at all.
        schemaValid:
          type: boolean
          description: Whether it satisfied the XSD for its syntax.
        schematronValid:
          type: boolean
          description: |
            Whether the business rules reported nothing whatsoever. Not a
            verdict: the rule set emits advisories on perfectly valid invoices,
            and any advisory sets this false while acceptable stays true.
        businessRulesEvaluated:
          type: boolean
          description: False when the document failed the schema first.
        findings:
          type: array
          items: { $ref: '#/components/schemas/Finding' }
      required:
        [
          acceptable,
          rulesetVersion,
          wellFormed,
          schemaValid,
          schematronValid,
          businessRulesEvaluated,
          findings,
        ]

    Finding:
      type: object
      properties:
        code:
          type: string
          description: The rule identifier, e.g. BR-DE-15.
          examples: [BR-DE-15]
        severity:
          type: string
          enum: [ERROR, WARNING, INFORMATION]
          description: |
            As the official XRechnung configuration sets it for the matched
            scenario, which can differ from the rule's own flag — BR-CL-23 is
            fatal as written and a warning in an XRechnung. That is how it
            matches the verdict in `acceptable`.
        origin:
          type: string
          enum: [SCHEMA, SCHEMATRON]
        text:
          type: string
          description: |
            The official rule text, as the rule set words it. Named `text`, not
            `message` — a client generated from an earlier revision of this file
            looked for `message` and never found it.
        location:
          type: [string, 'null']
          description: |
            Where in the document the rule fired, in Clark notation: each step
            carries its namespace as a `Q{…}` prefix rather than a short form
            like `cac:`, because a prefix means nothing without the document
            that declared it.
        line:
          type: [integer, 'null']
          description: Set for schema findings; null for Schematron ones.
        column:
          type: [integer, 'null']
          description: Set for schema findings; null for Schematron ones.
        test:
          type: [string, 'null']
          description: The expression the validator evaluated, where it reports one.
        explanation:
          description: |
            The rule in plain language, in the language `Accept-Language`
            chose. Always present; null for schema findings (origin SCHEMA).
          anyOf:
            - $ref: '#/components/schemas/Explanation'
            - type: 'null'
      required: [code, severity, origin, text]

    Explanation:
      type: object
      properties:
        meaning:
          type: string
          description: What the rule actually requires, in ordinary language.
        cause:
          type: string
          description: Why an invoice typically trips it.
        fix:
          type: string
          description: What to change to make it pass.
        url:
          type: string
          format: uri
          description: |
            Where the rule is explained on the site: its own page, with an XML
            example for the common rules, or its entry on a rule-group page.
          examples: ['https://normapi.com/en/errors/br-de-15']
      required: [meaning, cause, fix, url]

    InvoiceNotPermitted:
      description: A Problem whose findings name each rule the data breaks.
      allOf:
        - $ref: '#/components/schemas/Problem'
        - type: object
          properties:
            rulesetVersion: { type: string }
            findings:
              type: array
              items: { $ref: '#/components/schemas/Finding' }
          required: [rulesetVersion, findings]

    InvoiceRequest:
      type: object
      required: [invoiceNumber, issueDate, currency, seller, buyer, lines]
      properties:
        invoiceNumber: { type: string, examples: ['2027-0042'] }
        issueDate: { type: string, format: date }
        typeCode:
          type: string
          pattern: '^[0-9]{3}$'
          default: '380'
          description: |
            BT-3, the document type (UNTDID 1001). XRechnung lists 326 partial
            invoice, 380 commercial invoice, 381 credit note, 384 corrected
            invoice, 389 self-billed invoice, 875 partial construction invoice,
            876 partial final construction invoice and 877 final construction
            invoice; any other code draws the BR-DE-17 warning. A 381 is
            generated as a UBL CreditNote, where the due date moves into the
            payment means.
          examples: ['381']
        precedingInvoices:
          type: array
          description: |
            BG-3, the invoices this one corrects, credits or settles. BR-DE-26
            expects one on a 384. UBL carries any number; CII — and so
            ZUGFeRD — has room for one, and a second fails the CII schema
            (422).
          items: { $ref: '#/components/schemas/PrecedingInvoice' }
        dueDate: { type: string, format: date }
        currency: { type: string, examples: [EUR] }
        buyerReference:
          type: string
          description: |
            BT-10. For public-sector invoices this is the Leitweg-ID, and
            BR-DE-15 makes it mandatory.
        seller: { $ref: '#/components/schemas/Party' }
        buyer: { $ref: '#/components/schemas/Party' }
        payment: { $ref: '#/components/schemas/Payment' }
        paymentTerms: { type: string }
        allowances:
          type: array
          description: BG-20, allowances on the whole document.
          items: { $ref: '#/components/schemas/AllowanceCharge' }
        charges:
          type: array
          description: BG-21, charges on the whole document — freight, packaging.
          items: { $ref: '#/components/schemas/AllowanceCharge' }
        prepaidAmount:
          type: number
          description: |
            BT-113, already paid — the advances a final invoice (877) settles.
            The amount due (BT-115) is the gross total less this. Two decimals.
          examples: [500.00]
        lines:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Line' }

    PrecedingInvoice:
      type: object
      required: [number]
      properties:
        number: { type: string, description: 'BT-25.', examples: [RE-2026-0815] }
        issueDate:
          type: string
          format: date
          description: BT-26, needed when the number alone is not unique.

    Payment:
      type: object
      description: |
        BG-16, and the group its code calls for: the payee account (BG-17)
        for a transfer, `card` (BG-18) for a card payment, `directDebit`
        (BG-19) for a SEPA direct debit. Which group may stand beside which
        code is the rule set's verdict (BR-DE-23 to BR-DE-25), returned as
        findings — a payee IBAN beside a direct debit is a 422, not a 400.
      required: [meansCode]
      properties:
        meansCode:
          type: string
          pattern: '^([0-9]{1,3}|ZZZ)$'
          description: |
            BT-81, UNTDID 4461: 58 SEPA credit transfer, 30 credit transfer,
            59 SEPA direct debit, 48/54/55 card, 10 cash, ZZZ mutually defined.
          examples: ['58']
        meansText: { type: string, description: 'BT-82, the payment means in words.' }
        iban: { type: string, description: 'BT-84, the payee account.' }
        accountName: { type: string, description: 'BT-85, the payee account holder.' }
        bic:
          type: string
          pattern: '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$'
          description: BT-86, the payee's bank.
          examples: [BYLADEM1001]
        reference: { type: string, description: 'BT-83, what the payer should quote.' }
        card: { $ref: '#/components/schemas/Card' }
        directDebit: { $ref: '#/components/schemas/DirectDebit' }

    Card:
      type: object
      description: BG-18, with payment means 48, 54 or 55.
      required: [number]
      properties:
        number:
          type: string
          description: |
            BT-87, the masked card number. More than 10 digits is a 400: at
            most the first 6 and the last 4 may appear on an invoice. BR-51
            counts characters, not digits — over 10 is rejected in CII and
            warned about in UBL — so send the digits without mask characters.
          examples: ['1234']
        holder: { type: string, description: 'BT-88, the cardholder.' }
        network:
          type: string
          description: The card network; UBL requires one and gets NA when absent.
          examples: [VISA]

    DirectDebit:
      type: object
      description: |
        BG-19, with payment means 59. Optional in the request, all three
        required by the rule set: the creditor identifier and the debited
        account by BR-DE-30 and BR-DE-31, the mandate reference by
        PEPPOL-EN16931-R061. Leave out the payee IBAN — the money is
        collected, not transferred (BR-DE-25-b).
      properties:
        mandateReference: { type: string, description: 'BT-89.', examples: [MANDAT-2026-001] }
        creditorId:
          type: string
          description: BT-90, the SEPA creditor identifier (Gläubiger-ID).
          examples: [DE98ZZZ09999999999]
        debitedAccount:
          type: string
          description: BT-91, the buyer's IBAN the amount is collected from.

    AllowanceCharge:
      type: object
      description: |
        A document-level allowance (BG-20, BT-92 to BT-98) or charge (BG-21,
        BT-99 to BT-105) — which one is decided by the list it stands in.
        Give `amount` or `percent`, exactly one. With `percent` the amount is
        computed: base × percent / 100, rounded half-up to the cent. It moves
        the taxable amount of its own VAT category and rate.
      required: [vatCategory]
      properties:
        amount: { type: number, description: 'BT-92 / BT-99, two decimals.' }
        percent: { type: number, description: 'BT-94 / BT-101.', examples: [3] }
        baseAmount:
          type: number
          description: |
            BT-93 / BT-100, what the percentage is taken of. Without it: the
            line nets of the lines in the same VAT category and rate — and if
            there are none, the request is a 400. Only with `percent`: beside
            a plain `amount` the rule set refuses it (PEPPOL-EN16931-R042).
        reason:
          type: string
          description: BT-97 / BT-104. This or `reasonCode` is required (BR-33, BR-38).
          examples: [Treuerabatt]
        reasonCode:
          type: string
          description: BT-98 (UNTDID 5189, e.g. 95 discount) / BT-105 (UNTDID 7161, e.g. FC freight).
        vatCategory: { type: string, description: 'BT-95 / BT-102.', examples: [S] }
        vatRate: { type: number, description: 'BT-96 / BT-103.', examples: [19] }
        vatExemptionReason:
          type: string
          description: BT-120 for the VAT group this falls into, as on a line.

    LineAllowanceCharge:
      type: object
      description: |
        A line allowance (BG-27, BT-136 to BT-140) or charge (BG-28, BT-141
        to BT-145). Give `amount` or `percent`, exactly one. It carries no VAT
        of its own: it changes the line net (BT-131), which stays in the
        line's VAT category.
      properties:
        amount: { type: number, description: 'BT-136 / BT-141, two decimals.' }
        percent: { type: number, description: 'BT-138 / BT-143.', examples: [10] }
        baseAmount:
          type: number
          description: |
            BT-137 / BT-142. Without it: the line's quantity × unit price,
            rounded to the cent. Only with `percent` (PEPPOL-EN16931-R042).
        reason:
          type: string
          description: BT-139 / BT-144. This or `reasonCode` is required (BR-42, BR-44).
          examples: [Mengenrabatt]
        reasonCode: { type: string, description: 'BT-140 (UNTDID 5189) / BT-145 (UNTDID 7161).' }

    Party:
      type: object
      required: [name]
      properties:
        name: { type: string }
        vatId: { type: string }
        electronicAddress: { type: string }
        electronicAddressScheme: { type: string, examples: [EM] }
        address:
          type: object
          properties:
            street: { type: string }
            city: { type: string }
            postcode: { type: string }
            country: { type: string, examples: [DE] }
        contact:
          type: object
          description: BG-6. Missing entirely is the most common mapping error (BR-DE-2).
          properties:
            name: { type: string }
            phone: { type: string }
            email: { type: string }

    Line:
      type: object
      required: [name, quantity, unitPrice, vatCategory, vatRate]
      properties:
        name: { type: string }
        quantity: { type: number }
        unit: { type: string, examples: [HUR, C62] }
        unitPrice: { type: number }
        vatCategory: { type: string, examples: [S] }
        vatRate: { type: number, examples: [19] }
        allowances:
          type: array
          description: BG-27; the line net (BT-131) is quantity × unit price, less these.
          items: { $ref: '#/components/schemas/LineAllowanceCharge' }
        charges:
          type: array
          description: BG-28; the line net (BT-131) is quantity × unit price, plus these.
          items: { $ref: '#/components/schemas/LineAllowanceCharge' }
