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 explains every finding; `POST /v1/invoices` generates
    one from JSON in UBL, CII or ZUGFeRD-PDF and validates it before returning
    it.

    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.

    The rule set version in force is stamped on every response 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: []
        - {}
      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:
            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
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InvoiceRequest' }
      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.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }

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.

  responses:
    BadRequest:
      description: The request body or the syntax parameter is malformed.
      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 are busy; retry in a few seconds.
      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]
        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.
      required: [code, severity, origin, text]

    InvoiceRequest:
      type: object
      required: [invoiceNumber, issueDate, currency, seller, buyer, lines]
      properties:
        invoiceNumber: { type: string, examples: ['2027-0042'] }
        issueDate: { type: string, format: date }
        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:
          type: object
          properties:
            meansCode: { type: string, examples: ['58'] }
            iban: { type: string }
        paymentTerms: { type: string }
        lines:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/Line' }

    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] }
