openapi: 3.1.0
info:
  title: Customable Services API
  version: 1.3.2
  description: >
    Internal service hub. The Geolocation module provides German PLZ → city →
    street lookups plus radius/distance queries, backed by OpenPLZ + OpenStreetMap
    data (ODbL — attribution "© OpenStreetMap-Mitwirkende"). The Finance module
    serves German tax-office (Finanzamt) reference data plus Steuernummer
    validation/conversion (state form ↔ unified 13-digit). The Bank module validates
    IBANs (ISO 13616), enriches German IBANs with bank name + BIC, and derives a
    German IBAN from Bankleitzahl + Kontonummer (Bundesbank standard rule). The
    PDF module renders PDFs in-process (pdf-lib): fill/merge/split plus named
    template rendering. The Suitability module assesses a parcel's open-field PV
    suitability, built incrementally per data layer; a standalone PV-yield
    endpoint returns PVGIS annual + monthly estimates for a coordinate and
    system. The SEPA module generates ISO 20022 pain.001 credit-transfer files
    from a debtor plus a batch of payouts, and pain.008 direct-debit collection
    files from a creditor plus a batch of mandated debits. The Extraction
    module turns a document (PDF/image) plus a requested field set into structured
    fields with per-field confidence, provider-agnostic and processed in memory.
    The Parcel-locker module returns nearby parcel lockers (DHL Packstation,
    Amazon Locker, …) for a coordinate or PLZ, provider-agnostic and OSM-derived
    (ODbL — same attribution as the geo data). The Holidays module computes
    German public holidays plus working-day / deadline arithmetic per
    Bundesland, and serves community-maintained (non-authoritative) school
    holidays (Schulferien) per state + year.
    GET /api/v1/meta lists the modules/scopes and what the presented key may call.
    The admin surface (/api/admin/**) is session-protected and intentionally not
    part of this public contract.
servers:
  - url: https://services.customable.host
components:
  securitySchemes:
    sourceKey:
      type: apiKey
      in: header
      name: x-source-key
  parameters:
    postalCode:
      name: postalCode
      in: path
      required: true
      schema: { type: string, pattern: '^\d{5}$' }
      example: '10115'
  schemas:
    Meta:
      type: object
      additionalProperties:
        oneOf: [{ type: number }, { type: string }, { type: boolean }, { type: 'null' }]
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
    CitiesResult:
      type: object
      required: [cities, state]
      properties:
        cities: { type: array, items: { type: string } }
        state: { type: string }
    RangeEntry:
      type: object
      required: [name, code, distance]
      properties:
        name: { type: string }
        code: { type: string }
        distance: { type: number, description: distance in kilometres }
    RangeResult:
      type: object
      required: [count, citiesInRange]
      properties:
        count: { type: integer }
        citiesInRange: { type: array, items: { $ref: '#/components/schemas/RangeEntry' } }
    TaxOffice:
      type: object
      required: [code, name, stateCode, stateName]
      properties:
        code: { type: string, example: '9101' }
        name: { type: string, example: München }
        stateCode: { type: string, example: '9' }
        stateName: { type: string, example: Bayern }
    BankCode:
      type: object
      required: [code, name, bic]
      properties:
        code: { type: string, example: '37040044' }
        name: { type: string, example: Commerzbank }
        bic: { type: ['string', 'null'], example: COBADEFFXXX }
    IbanValidationResult:
      type: object
      required: [iban, valid, countryCode, bankCode, bankName, bic, accountNumber]
      properties:
        iban:
          {
            type: string,
            description: formatted (4-char blocks),
            example: 'DE89 3704 0044 0532 0130 00',
          }
        valid: { type: boolean }
        countryCode: { type: string, example: DE }
        bankCode:
          { type: ['string', 'null'], description: German Bankleitzahl, example: '37040044' }
        bankName: { type: ['string', 'null'], example: Commerzbank Köln }
        bic: { type: ['string', 'null'], example: COBADEFFXXX }
        accountNumber: { type: ['string', 'null'], example: '0532013000' }
    IbanDerivationResult:
      type: object
      required: [blz, kontonummer, iban, bankName, bic, standardRuleApplied]
      properties:
        blz: { type: string, example: '37040044' }
        kontonummer: { type: string, example: '532013000' }
        iban: { type: string, example: DE89370400440532013000 }
        bankName: { type: ['string', 'null'], example: Commerzbank Köln }
        bic: { type: ['string', 'null'], example: COBADEFFXXX }
        standardRuleApplied:
          type: boolean
          description: >
            Always true on a 200 — the Bundesbank standard rule was applied.
            ~60 institutes use non-standard "Sonderregeln" (gated dataset, not
            available to this service) not covered here; verify for those.
    SepaParty:
      type: object
      required: [name, iban]
      properties:
        name: { type: string, maxLength: 70, example: Conduvia Tenant GmbH }
        iban: { type: string, example: DE89370400440532013000 }
        bic:
          type: ['string', 'null']
          pattern: '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$'
          example: COBADEFFXXX
    SepaPayment:
      type: object
      required: [endToEndId, creditorName, creditorIban, amountCents, remittanceInfo]
      properties:
        endToEndId: { type: string, maxLength: 35, example: payout-1 }
        creditorName: { type: string, maxLength: 70, example: Partner GmbH }
        creditorIban: { type: string, example: DE02120300000000202051 }
        creditorBic:
          type: ['string', 'null']
          pattern: '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$'
        amountCents:
          { type: integer, minimum: 1, description: amount in integer cents, example: 12345 }
        remittanceInfo: { type: string, maxLength: 140, example: Provision Lead 4711 }
    SepaDirectDebitCreditor:
      type: object
      required: [name, iban, creditorIdentifier]
      properties:
        name: { type: string, maxLength: 70, example: Conduvia Tenant GmbH }
        iban: { type: string, example: DE89370400440532013000 }
        bic:
          type: ['string', 'null']
          pattern: '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$'
        creditorIdentifier:
          type: string
          description: SEPA Creditor Identifier (Gläubiger-Identifikationsnummer).
          example: DE98ZZZ09999999999
    SepaMandate:
      type: object
      required: [mandateId, signatureDate, sequenceType]
      properties:
        mandateId: { type: string, maxLength: 35, example: mandate-42 }
        signatureDate: { type: string, format: date, example: '2026-01-15' }
        sequenceType: { type: string, enum: [FRST, RCUR, OOFF, FNAL] }
    SepaDirectDebitTransaction:
      type: object
      required: [endToEndId, debtorName, debtorIban, amountCents, mandate, remittanceInfo]
      properties:
        endToEndId: { type: string, maxLength: 35, example: collect-1 }
        debtorName: { type: string, maxLength: 70, example: Max Mustermann }
        debtorIban: { type: string, example: DE02120300000000202051 }
        debtorBic:
          type: ['string', 'null']
          pattern: '^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$'
        amountCents:
          { type: integer, minimum: 1, description: amount in integer cents, example: 4990 }
        mandate: { $ref: '#/components/schemas/SepaMandate' }
        remittanceInfo: { type: string, maxLength: 140, example: Abo Juli 2026 }
    ExtractionFieldSpec:
      type: object
      required: [key]
      properties:
        key: { type: string, maxLength: 64, example: vorversorger }
        description:
          type: string
          maxLength: 280
          description: Optional hint guiding a vision/Document-AI provider.
          example: Name of the current energy supplier
    ExtractedField:
      type: object
      required: [value, confidence, needsReview]
      properties:
        value: { type: ['string', 'null'] }
        confidence: { type: number, minimum: 0, maximum: 1 }
        needsReview: { type: boolean }
    DocumentExtractionResult:
      type: object
      required: [fields, providerReference]
      properties:
        fields:
          type: object
          additionalProperties: { $ref: '#/components/schemas/ExtractedField' }
        providerReference: { type: string, example: stub }
    DetectedFormField:
      type: object
      required: [key, label, type, page, rect, confidence, source]
      properties:
        key: { type: string, example: jahresverbrauch }
        label: { type: string, example: Jahresverbrauch }
        type: { type: string, enum: [text, checkbox, signature] }
        source:
          type: string
          enum: [text-layer, ocr]
          description: text-layer = exact; ocr = approximate (editor-refined).
        page: { type: integer, description: zero-based page index, example: 0 }
        rect:
          type: object
          description: PDF user-space points, bottom-left origin (pdf-lib convention).
          required: [x, y, width, height]
          properties:
            x: { type: number }
            y: { type: number }
            width: { type: number }
            height: { type: number }
        confidence: { type: number, minimum: 0, maximum: 1 }
    FormDetectionResult:
      type: object
      required: [pages, fields]
      properties:
        pages:
          type: array
          items:
            type: object
            required: [index, width, height]
            properties:
              index: { type: integer }
              width: { type: number }
              height: { type: number }
        fields:
          type: array
          items: { $ref: '#/components/schemas/DetectedFormField' }
    ParcelLockerStation:
      type: object
      required: [id, provider, latitude, longitude, address, source]
      properties:
        id: { type: string, example: node/123456789 }
        provider:
          type: string
          enum: [dhl, amazon, inpost, ups, hermes, dpd, gls, other]
        stationNumber: { type: ['string', 'null'], example: '123' }
        name: { type: ['string', 'null'] }
        latitude: { type: number, example: 52.52 }
        longitude: { type: number, example: 13.405 }
        address:
          type: object
          properties:
            street: { type: ['string', 'null'] }
            houseNumber: { type: ['string', 'null'] }
            postalCode: { type: ['string', 'null'] }
            city: { type: ['string', 'null'] }
        distanceMeters: { type: ['number', 'null'], example: 820 }
        openingHours: { type: ['string', 'null'], example: 24/7 }
        source: { type: string, enum: [osm, manual] }
    SteuernummerValidationResult:
      type: object
      required:
        [input, valid, format, state, bundeseinheitlich, laenderspezifisch, finanzamt, reason]
      properties:
        input: { type: string, example: '101/815/08153' }
        valid: { type: boolean }
        format:
          type: ['string', 'null']
          enum: [laenderspezifisch, bundeseinheitlich, null]
        state:
          type: ['object', 'null']
          properties:
            code: { type: string, example: '9' }
            name: { type: string, example: Bayern }
        bundeseinheitlich:
          { type: ['string', 'null'], description: 13-digit unified form, example: '9101081508153' }
        laenderspezifisch: { type: ['string', 'null'], example: '101/815/08153' }
        finanzamt:
          type: ['object', 'null']
          properties:
            code: { type: string, example: '9101' }
            name: { type: string, example: Augsburg-Stadt }
        reason:
          type: ['string', 'null']
          description: machine-readable failure reason; null when valid
          example: null
    PvYieldResult:
      type: object
      required: [location, system, available, annual, monthly]
      properties:
        location:
          type: object
          properties:
            lat: { type: number, example: 52.52 }
            lng: { type: number, example: 13.4 }
        system:
          type: object
          properties:
            kwp: { type: number, example: 5 }
            lossPercent: { type: number, example: 14 }
            tiltDeg: { type: ['number', 'null'], example: 35 }
            azimuthDeg:
              {
                type: ['number', 'null'],
                description: 'compass 0=N/90=E/180=S/270=W',
                example: 180,
              }
            optimalAngles: { type: boolean }
        available: { type: boolean, description: false when PVGIS could not be reached }
        annual:
          type: object
          properties:
            energyKwh: { type: ['number', 'null'], example: 5290 }
            specificYieldKwhPerKwp: { type: ['number', 'null'], example: 1058 }
            inPlaneIrradiationKwhPerM2Year: { type: ['number', 'null'], example: 1322 }
        monthly:
          type: array
          items:
            type: object
            required: [month, energyKwh]
            properties:
              month: { type: integer, minimum: 1, maximum: 12 }
              energyKwh: { type: number }
    BusinessDayAddResult:
      type: object
      required: [date, businessDays, state, result]
      properties:
        date: { type: string, example: '2026-01-02' }
        businessDays: { type: integer, example: 5 }
        state: { type: ['string', 'null'], example: BY }
        result: { type: string, example: '2026-01-09' }
    BusinessDayCountResult:
      type: object
      required: [from, to, state, businessDays]
      properties:
        from: { type: string, example: '2026-01-05' }
        to: { type: string, example: '2026-01-11' }
        state: { type: ['string', 'null'], example: BY }
        businessDays: { type: integer, example: 5 }
    SchulferienPeriod:
      type: object
      required: [name, startDate, endDate]
      properties:
        name: { type: string, example: Sommerferien Bayern 2026 }
        startDate: { type: string, format: date, example: '2026-08-03' }
        endDate: { type: string, format: date, example: '2026-09-14' }
    SchulferienResult:
      type: object
      required: [state, year, periods]
      properties:
        state: { type: string, example: BY }
        year: { type: integer, example: 2026 }
        periods: { type: array, items: { $ref: '#/components/schemas/SchulferienPeriod' } }
    SanctionsScreenRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 2, maxLength: 200, example: John Smith }
        dateOfBirth: { type: string, format: date, example: '1980-05-01' }
        country: { type: string, minLength: 2, maxLength: 2, example: DE }
    SanctionsHit:
      type: object
      required: [matchedName, score, subjectType, programme, euReferenceNumber, regulationSummary]
      properties:
        matchedName: { type: string, example: Saddam Hussein Al-Tikriti }
        score: { type: number, format: float, minimum: 0, maximum: 1, example: 0.95 }
        subjectType: { type: string, example: person }
        programme: { type: ['string', 'null'], example: IRQ }
        euReferenceNumber: { type: ['string', 'null'], example: EU.27.28 }
        regulationSummary: { type: ['string', 'null'], example: '1210/2003 (OJ L169)' }
    SanctionsScreenResult:
      type: object
      required: [hits, listVersion]
      properties:
        hits: { type: array, items: { $ref: '#/components/schemas/SanctionsHit' } }
        listVersion: { type: ['string', 'null'], example: '2026-06-05T15:51:25.849+02:00' }
    MastrSolarInstallation:
      type: object
      required:
        [
          mastrNummer,
          postalCode,
          city,
          bundesland,
          latitude,
          longitude,
          grossCapacityKw,
          netCapacityKw,
          commissioningDate,
          operatingStatus,
          distanceMeters,
        ]
      properties:
        mastrNummer: { type: string, example: SEE912345678 }
        postalCode: { type: ['string', 'null'], example: '39179' }
        city: { type: ['string', 'null'], example: Sülztal }
        bundesland: { type: ['string', 'null'], example: Sachsen-Anhalt }
        latitude: { type: number, format: float, example: 52.012 }
        longitude: { type: number, format: float, example: 11.654 }
        grossCapacityKw: { type: ['number', 'null'], format: float, example: 150.112 }
        netCapacityKw: { type: ['number', 'null'], format: float, example: 148.987 }
        commissioningDate: { type: ['string', 'null'], format: date, example: '1998-01-02' }
        operatingStatus: { type: ['integer', 'null'], example: 35 }
        distanceMeters: { type: integer, example: 842 }
    MastrInstallationsResult:
      type: object
      required: [installations]
      properties:
        installations:
          { type: array, items: { $ref: '#/components/schemas/MastrSolarInstallation' } }
    EinvoiceInvoice:
      type: object
      description: >
        An EN16931-conformant invoice in the e-invoice-eu library's own
        internal UBL-tag-keyed format (ADR 0007) — this hub does not
        reshape it into a friendlier DTO, to avoid a custom-mapping
        correctness risk. Top-level key is `ubl:Invoice`; nested keys use
        the `cbc:`/`cac:` UBL prefixes, with `@`-suffixed sibling keys for
        XML attributes (e.g. `cbc:TaxAmount` + `cbc:TaxAmount@currencyID`).
        See the library's published `Invoice` TypeScript interface and
        `invoiceSchema` (Ajv 2019-09) for the full, authoritative shape:
        https://www.npmjs.com/package/@e-invoice-eu/core
      additionalProperties: true
    ZugferdRequest:
      type: object
      required: [invoice, pdf]
      properties:
        invoice: { $ref: '#/components/schemas/EinvoiceInvoice' }
        pdf:
          type: string
          format: byte
          description: >
            Base64-encoded, already-rendered human-readable PDF
            representation of the invoice (the library embeds the CII XML
            into this PDF and adds PDF/A-3 conformance — it does not render
            an invoice layout from JSON alone). Render it however you
            like, including via this hub's own POST /api/v1/pdf/render.
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: >
        Per-key rate limit exceeded — either the per-minute burst ceiling
        (X-RateLimit-Limit/-Remaining response headers, Retry-After on this
        response) or, when the key has a daily quota configured, the
        requests/day cap (X-Daily-Quota-Limit/-Remaining headers). Each key's
        limits default to the deployment-wide settings unless overridden per
        key in the admin area.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
security:
  - sourceKey: []
paths:
  /api/health:
    get:
      operationId: getHealth
      summary: Liveness probe
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string, example: ok } }
  /api/v1/geolocation/{postalCode}/cities:
    get:
      operationId: getCities
      summary: Cities for a PLZ
      parameters:
        - $ref: '#/components/parameters/postalCode'
        - { name: state, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Cities for the PLZ
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CitiesResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/{postalCode}/cities/streets:
    get:
      operationId: getStreets
      summary: Streets for a PLZ + city
      parameters:
        - $ref: '#/components/parameters/postalCode'
        - { name: city, in: query, required: false, schema: { type: string } }
        - { name: state, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Street names
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: string } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pdf/fill:
    post:
      operationId: pdfFill
      summary: Fill a PDF form's fields in-process (pdf-lib) and return the PDF
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file, fields]
              properties:
                file: { type: string, description: base64-encoded source PDF }
                flatten: { type: boolean, description: flatten the form after filling }
                fields:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [type, key, value]
                    properties:
                      type:
                        type: string
                        enum: [text, radio, checkbox, signature, image, optionList, dropdown]
                      key: { type: string }
                      value: { description: 'string/boolean; base64 image for image/signature' }
      responses:
        '200':
          description: The filled PDF
          content:
            application/pdf: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Invalid PDF or unknown field (PDF_FIELD_ERROR / INVALID_PDF) }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pdf/merge:
    post:
      operationId: pdfMerge
      summary: Merge several base64 PDFs into one (in-process)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  minItems: 2
                  items: { type: string, description: base64-encoded PDF }
      responses:
        '200':
          description: The merged PDF
          content:
            application/pdf: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: One of the inputs is not a valid PDF }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pdf/split:
    post:
      operationId: pdfSplit
      summary: Split a base64 PDF per page or per page-range (in-process)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, description: base64-encoded source PDF }
                ranges:
                  type: array
                  description: '[from, to] 1-based inclusive page ranges; omitted = one per page'
                  items:
                    type: array
                    items: { type: integer, minimum: 1 }
                    minItems: 2
                    maxItems: 2
      responses:
        '200':
          description: The resulting PDFs, base64-encoded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: string, description: base64 PDF } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Invalid PDF or out-of-bounds range (PDF_RANGE_ERROR / INVALID_PDF) }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pdf/render:
    post:
      operationId: pdfRender
      summary: Render a named server-side template + data payload to a PDF (in-process)
      description: >-
        Renders a registered document template to PDF bytes. The `data` payload is
        validated against the named template's own schema. Currently registered:
        `partner-contract` (Energiant partner framework contract — company master
        data + embedded signature PNG + contract version stamp).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template, data]
              properties:
                template:
                  type: string
                  description: Registered template name (e.g. partner-contract)
                data:
                  type: object
                  description: Template-specific payload, validated server-side
                  additionalProperties: true
            examples:
              partnerContract:
                summary: partner-contract payload
                value:
                  template: partner-contract
                  data:
                    companyName: Muster Solar GmbH
                    companyStreet: Musterstraße 1
                    companyPostalCode: '10115'
                    companyCity: Berlin
                    contactName: Erika Mustermann
                    vatId: DE123456789
                    serviceArea: Berlin + 50 km
                    signaturePng: data:image/png;base64,iVBORw0KGgo...
                    signedAtIso: '2026-06-13'
      responses:
        '200':
          description: The rendered PDF
          content:
            application/pdf: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          {
            description: Unknown template (PDF_TEMPLATE_UNKNOWN) or invalid render input (INVALID_PDF),
          }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/vat/validate:
    post:
      operationId: validateVat
      summary: Validate an EU VAT-ID (local format + authoritative VIES lookup)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vatId]
              properties:
                vatId: { type: string, example: DE123456789 }
      responses:
        '200':
          description: >
            Validation result. When VIES is unreachable, viesAvailable is false
            and only formatValid is authoritative.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [vatId, countryCode, formatValid, viesAvailable, registered]
                    properties:
                      vatId: { type: string, example: DE123456789 }
                      countryCode: { type: string, example: DE }
                      formatValid: { type: boolean }
                      viesAvailable: { type: boolean }
                      registered: { type: boolean }
                      name: { type: ['string', 'null'] }
                      address: { type: ['string', 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Malformed VAT-ID (error code INVALID_VAT_FORMAT) }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/email/validate:
    post:
      operationId: validateEmail
      summary: Email lead-quality check (syntax + role + disposable + MX + SPF/DMARC)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, example: info@example.com }
                probeCatchAll:
                  type: boolean
                  default: false
                  description: >
                    Opt-in: probe the domain for catch-all behaviour via live SMTP
                    (slower, best-effort; unsolicited RCPT probing can be treated
                    as abuse by receiving mail providers).
      responses:
        '200':
          description: >
            Validation result. Malformed syntax returns 200 with syntaxValid
            false (the verdict is the payload). DNS/SMTP outages degrade the
            affected signal to null rather than failing the request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [email, syntaxValid, role, disposable, deliverability]
                    properties:
                      email: { type: string, example: info@example.com }
                      syntaxValid: { type: boolean }
                      localPart: { type: ['string', 'null'], example: info }
                      domain: { type: ['string', 'null'], example: example.com }
                      role: { type: boolean, description: 'Generic mailbox (info@, kontakt@, …)' }
                      disposable: { type: boolean }
                      mxFound: { type: ['boolean', 'null'] }
                      deliverability:
                        type: object
                        required: [spfFound, dmarcFound, dmarcPolicy, catchAll]
                        properties:
                          spfFound: { type: ['boolean', 'null'] }
                          dmarcFound: { type: ['boolean', 'null'] }
                          dmarcPolicy:
                            type: ['string', 'null']
                            enum: [none, quarantine, reject, null]
                          catchAll:
                            type: ['boolean', 'null']
                            description: Only populated when probeCatchAll was true.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/phone/validate:
    post:
      operationId: validatePhone
      summary: Validate a phone number (libphonenumber) — type, E.164, area code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, example: '0171 1234567' }
                region:
                  type: string
                  description: ISO 3166-1 alpha-2 default region; defaults to DE
                  example: DE
      responses:
        '200':
          description: >
            Validation result. An unparseable number returns 200 with
            valid=false. areaCode is the Ortsnetzkennzahl (landline only),
            with the trunk 0 stripped.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [input, valid, type]
                    properties:
                      input: { type: string, example: '0171 1234567' }
                      valid: { type: boolean }
                      formatted: { type: ['string', 'null'], example: '+491711234567' }
                      type: { type: string, enum: [mobile, landline, voip, unknown] }
                      regionCode: { type: ['string', 'null'], example: DE }
                      areaCode: { type: ['string', 'null'], example: '30' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/address/verify:
    post:
      operationId: verifyAddress
      summary: Verify and normalize an address against the geo reference DB
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [postalCode, city]
              properties:
                street: { type: string, example: Hauptstraße }
                postalCode: { type: string, example: '74921' }
                city: { type: string, example: Helmstadt }
      responses:
        '200':
          description: >
            Verification result. Casing is corrected to the canonical DB value
            where it matches; unresolved fields stay null. confidence is 0..1.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [plzValid, cityMatchesPlz, normalized, confidence]
                    properties:
                      plzValid: { type: boolean }
                      cityMatchesPlz: { type: boolean }
                      streetExistsForCity: { type: ['boolean', 'null'] }
                      normalized:
                        type: object
                        properties:
                          street: { type: ['string', 'null'] }
                          postalCode: { type: string, example: '74921' }
                          city: { type: ['string', 'null'], example: Helmstadt }
                          state: { type: ['string', 'null'], example: Baden-Württemberg }
                      confidence: { type: number, format: float, example: 1.0 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geocode:
    get:
      operationId: geocode
      summary: Forward-geocode an address to coordinates (geo DB / Nominatim)
      parameters:
        - {
            name: street,
            in: query,
            required: false,
            schema: { type: string },
            example: Hauptstraße,
          }
        - {
            name: postalCode,
            in: query,
            required: true,
            schema: { type: string },
            example: '74921',
          }
        - { name: city, in: query, required: true, schema: { type: string }, example: Helmstadt }
      responses:
        '200':
          description: >
            Coordinates with a source + confidence. A street yields street-level
            Nominatim precision; otherwise the PLZ centroid from the geo DB.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [found, confidence]
                    properties:
                      found: { type: boolean }
                      latitude: { type: ['number', 'null'], example: 49.3 }
                      longitude: { type: ['number', 'null'], example: 8.9 }
                      confidence: { type: number, format: float, example: 0.9 }
                      source: { type: ['string', 'null'], enum: [geo-db, nominatim, null] }
                      state: { type: ['string', 'null'], example: Baden-Württemberg }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geocode/building:
    get:
      operationId: geocodeBuilding
      summary: OSM building footprint (polygon + area) at a coordinate
      parameters:
        - { name: lat, in: query, required: true, schema: { type: number }, example: 49.3 }
        - { name: lng, in: query, required: true, schema: { type: number }, example: 8.9 }
      responses:
        '200':
          description: >
            The building outline at the point (else the nearest within 30 m) plus
            its ground area. Degrades to found=false when no building is tagged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [found]
                    properties:
                      found: { type: boolean }
                      polygon: { type: ['object', 'null'], description: GeoJSON Polygon }
                      areaSqm: { type: ['number', 'null'], example: 142 }
                      osmWayId: { type: ['integer', 'null'], example: 42 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/lead/plausibility:
    post:
      operationId: leadPlausibility
      summary: Anti-fraud / dedupe signals composing the contact validators
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, example: info@example.com }
                phone: { type: string, example: '0171 2345678' }
                region: { type: string, example: DE }
                address:
                  type: object
                  required: [postalCode, city]
                  properties:
                    street: { type: string }
                    postalCode: { type: string, example: '74921' }
                    city: { type: string, example: Helmstadt }
                coordinates:
                  type: object
                  required: [lat, lng]
                  properties:
                    lat: { type: number, example: 49.3 }
                    lng: { type: number, example: 8.9 }
      responses:
        '200':
          description: >
            Plausibility flags + a 0..100 score, plus SHA-256 fingerprints of the
            normalized contact fields the caller stores/compares to dedupe.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [score, flags, fingerprints]
                    properties:
                      score: { type: integer, minimum: 0, maximum: 100, example: 80 }
                      flags:
                        type: array
                        items:
                          type: object
                          required: [code, severity, message]
                          properties:
                            code: { type: string, example: email_disposable }
                            severity: { type: string, enum: [info, warning, critical] }
                            message: { type: string }
                      fingerprints:
                        type: object
                        properties:
                          email: { type: ['string', 'null'] }
                          phone: { type: ['string', 'null'] }
                          address: { type: ['string', 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/lead/score:
    post:
      operationId: leadScore
      summary: Composite lead score (blends contact validation, fraud, suitability)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: >
                Same contact fields as /lead/plausibility, plus an optional
                precomputed suitability hint (0..1) and per-channel weight
                overrides.
              properties:
                email: { type: string }
                phone: { type: string }
                region: { type: string, example: DE }
                address:
                  type: object
                  required: [postalCode, city]
                  properties:
                    street: { type: string }
                    postalCode: { type: string, example: '74921' }
                    city: { type: string, example: Helmstadt }
                coordinates:
                  type: object
                  properties: { lat: { type: number }, lng: { type: number } }
                suitability: { type: number, minimum: 0, maximum: 1, example: 0.8 }
                weights:
                  type: object
                  properties:
                    email: { type: number }
                    phone: { type: number }
                    address: { type: number }
                    suitability: { type: number }
      responses:
        '200':
          description: Weighted 0..100 score, an A–D tier, reasons, and dedupe fingerprints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [score, tier, reasons, fingerprints]
                    properties:
                      score: { type: integer, minimum: 0, maximum: 100, example: 82 }
                      tier: { type: string, enum: [A, B, C, D] }
                      reasons: { type: array, items: { type: string } }
                      fingerprints:
                        type: object
                        properties:
                          email: { type: ['string', 'null'] }
                          phone: { type: ['string', 'null'] }
                          address: { type: ['string', 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/suitability/rooftop:
    post:
      operationId: suitabilityRooftop
      summary: Rooftop-PV suitability (OSM roof + PVGIS irradiation) at a coordinate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lat, lng]
              properties:
                lat: { type: number, example: 49.3005 }
                lng: { type: number, example: 8.9006 }
      responses:
        '200':
          description: >
            Installable-kWp / annual-yield estimate, a weighted score + verdict,
            and flagged data-availability gaps. Missing roof tags or a PVGIS
            outage degrade gracefully (verdict insufficient-data when no building).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [location, building, irradiation, verdict, facts, flags, sources]
                    properties:
                      location:
                        type: object
                        properties: { lat: { type: number }, lng: { type: number } }
                      building:
                        type: object
                        properties:
                          found: { type: boolean }
                          footprintSqm: { type: ['number', 'null'] }
                          estimatedRoofSqm: { type: ['number', 'null'] }
                          levels: { type: ['integer', 'null'] }
                          roofShape: { type: ['string', 'null'] }
                          azimuthDeg: { type: ['number', 'null'] }
                          tiltDeg: { type: ['number', 'null'] }
                          tiltAssumed: { type: boolean }
                          osmWayId: { type: ['integer', 'null'] }
                      irradiation:
                        type: object
                        properties:
                          available: { type: boolean }
                          inPlaneKwhPerM2Year: { type: ['number', 'null'] }
                          specificYieldKwhPerKwpYear: { type: ['number', 'null'] }
                          optimalSlopeDeg: { type: ['number', 'null'] }
                      estimatedKwp: { type: ['number', 'null'], example: 13.1 }
                      estimatedAnnualKwh: { type: ['number', 'null'], example: 14600 }
                      score: { type: ['integer', 'null'], example: 78 }
                      verdict:
                        {
                          type: string,
                          enum: [suitable, conditional, unsuitable, insufficient-data],
                        }
                      facts: { type: array, items: { type: string } }
                      flags: { type: array, items: { type: string } }
                      sources: { type: array, items: { type: string } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/parcel:
    post:
      operationId: resolveParcel
      summary: Resolve the ALKIS Flurstück (parcel geometry + area) at a coordinate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lat, lng]
              properties:
                lat: { type: number, example: 48.778 }
                lng: { type: number, example: 9.18 }
      responses:
        '200':
          description: >
            The containing parcel's geometry + official area from the detected
            state's open ALKIS WFS (BW/BE/SN/NRW). States without an open WFS
            (e.g. Bayern, token-gated) and lookup outages return found=false with
            a reason.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [found]
                    properties:
                      found: { type: boolean }
                      areaSqm: { type: ['number', 'null'], example: 3419 }
                      geometry:
                        {
                          type: ['object', 'null'],
                          description: GeoJSON Polygon/MultiPolygon (WGS84),
                        }
                      source: { type: ['string', 'null'], example: alkis-bw }
                      state: { type: ['string', 'null'], example: Baden-Württemberg }
                      reason: { type: ['string', 'null'] }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/holidays/{year}:
    get:
      operationId: getHolidays
      summary: German public holidays for a year, optionally per federal state
      parameters:
        - name: year
          in: path
          required: true
          schema: { type: integer, minimum: 1970, maximum: 2200 }
          example: 2024
        - name: state
          in: query
          required: false
          description: ISO 3166-2:DE code (e.g. BY). Omitted = nationwide holidays only.
          schema:
            type: string
            enum: [BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH]
      responses:
        '200':
          description: Holidays for the year, sorted by date
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [date, name, nationwide]
                      properties:
                        date: { type: string, format: date, example: '2024-01-01' }
                        name: { type: string, example: Neujahr }
                        nationwide: { type: boolean }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/suitability/assess:
    post:
      operationId: assessSuitability
      summary: Assess a parcel's ground-mount PV suitability (open-field PV)
      description: >-
        Returns a suitability assessment for a parcel — identified by a drawn
        GeoJSON polygon (preferred: real area + zone intersection), by lat+lng, or
        by PLZ — combining grid proximity, protected-area exclusions, the EEG
        support zone (benachteiligtes Gebiet + 200 m corridor), and parcel size
        into a 0–100 `score` and a `verdict`. A hard exclusion disqualifies
        outright (`unsuitable`); otherwise the layers are blended (soft exclusions
        penalized) into suitable/conditional/unsuitable, or `insufficient-data`
        when no location layer is available. Per-state EEG/ALKIS coverage is added
        in tranches. See docs/adr/0001-pv-suitability-data-sources.md.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                lat: { type: number, minimum: -90, maximum: 90 }
                lng: { type: number, minimum: -180, maximum: 180 }
                postalCode: { type: string, pattern: '^\d{5}$' }
                minSizeSqm: { type: number, exclusiveMinimum: 0, description: parcel size in m² }
                geometry:
                  type: object
                  description: >-
                    Drawn parcel as a GeoJSON Polygon/MultiPolygon ([lng, lat]
                    positions). When given, area is computed from it and the zone
                    layers use polygon intersection; lat/lng/PLZ/size are ignored.
                  required: [type, coordinates]
                  properties:
                    type: { type: string, enum: [Polygon, MultiPolygon] }
                    coordinates: { type: array, items: {} }
              description: Provide a drawn geometry, lat+lng together, or a postalCode.
            examples:
              byCoordinates:
                summary: by coordinates
                value: { lat: 51.34, lng: 12.37, minSizeSqm: 40000 }
              byPostalCode:
                summary: by PLZ
                value: { postalCode: '04109' }
      responses:
        '200':
          description: The suitability assessment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [location, verdict, facts, sources]
                    properties:
                      location:
                        type: object
                        properties:
                          lat: { type: ['number', 'null'] }
                          lng: { type: ['number', 'null'] }
                          postalCode: { type: ['string', 'null'] }
                      parcel: { type: ['object', 'null'] }
                      grid: { type: ['object', 'null'] }
                      eeg: { type: ['object', 'null'] }
                      exclusions: { type: ['array', 'null'], items: { type: object } }
                      score: { type: ['number', 'null'] }
                      verdict:
                        type: string
                        enum: [suitable, conditional, unsuitable, insufficient-data]
                      facts: { type: array, items: { type: string } }
                      sources: { type: array, items: { type: string } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/meta:
    get:
      operationId: getMeta
      summary: Capabilities — modules, scopes, endpoints, and what this key may call
      responses:
        '200':
          description: The module/scope catalogue with per-scope grant flags
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [modules, grantedScopes, rateLimit]
                    properties:
                      grantedScopes: { type: array, items: { type: string } }
                      rateLimit:
                        type: object
                        required: [max, dailyQuota]
                        description: >
                          The presented key's own rate-limit tier. `max` is the
                          per-minute burst ceiling (the key's override, or the
                          deployment default when null); `dailyQuota` is the
                          requests/day cap, or null when uncapped.
                        properties:
                          max: { type: ['integer', 'null'], example: 120 }
                          dailyQuota: { type: ['integer', 'null'], example: null }
                      modules:
                        type: array
                        items:
                          type: object
                          properties:
                            key: { type: string }
                            label: { type: string }
                            scopes:
                              type: array
                              items:
                                type: object
                                properties:
                                  scope: { type: string }
                                  label: { type: string }
                                  granted: { type: boolean }
                                  endpoints:
                                    type: array
                                    items:
                                      type: object
                                      properties:
                                        method: { type: string }
                                        path: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /api/v1/bank/codes/{blz}:
    get:
      operationId: getBankByCode
      summary: Resolve a German Bankleitzahl to its bank name and BIC
      parameters:
        - name: blz
          in: path
          required: true
          schema: { type: string, pattern: '^\d{8}$' }
          example: '37040044'
      responses:
        '200':
          description: The bank for the Bankleitzahl
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/BankCode' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: No bank with this Bankleitzahl }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/search:
    get:
      operationId: searchCities
      summary: Autocomplete over PLZ + city (prefix on PLZ, substring on city)
      parameters:
        - { name: q, in: query, required: true, schema: { type: string }, example: '101' }
      responses:
        '200':
          description: Matching PLZ-cities (capped for typeahead)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [postalCode, name, state]
                      properties:
                        postalCode: { type: string, example: '10115' }
                        name: { type: string, example: Berlin }
                        state: { type: string, example: Berlin }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/{postalCode}/coordinates:
    get:
      operationId: getPostalCodeCoordinates
      summary: Centroid coordinate of a PLZ (optionally a specific city)
      parameters:
        - $ref: '#/components/parameters/postalCode'
        - { name: city, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Coordinate for the PLZ
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [postalCode, city, latitude, longitude]
                    properties:
                      postalCode: { type: string, example: '10115' }
                      city: { type: string, example: Berlin }
                      latitude: { type: number, example: 52.5321 }
                      longitude: { type: number, example: 13.3849 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: No coordinates for this PLZ }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/distance/batch:
    post:
      operationId: batchDistances
      summary: Distance from one base PLZ to many target PLZ in a single request
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [basePostalCode, targets]
              properties:
                basePostalCode: { type: string, pattern: '^\d{5}$', example: '10115' }
                baseCity: { type: string }
                targets:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string, pattern: '^\d{5}$' }
      responses:
        '200':
          description: Distance (km) to each target PLZ; null when a target has no coordinate
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [basePostalCode, count, results]
                    properties:
                      basePostalCode: { type: string }
                      count: { type: integer }
                      results:
                        type: array
                        items:
                          type: object
                          required: [postalCode, city, distance]
                          properties:
                            postalCode: { type: string }
                            city: { type: ['string', 'null'] }
                            distance: { type: ['number', 'null'], description: kilometres }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/radius:
    get:
      operationId: getCitiesNearCoordinate
      summary: Reverse radius — PLZ-cities near a latitude/longitude, nearest first
      parameters:
        - {
            name: lat,
            in: query,
            required: true,
            schema: { type: number, minimum: -90, maximum: 90 },
            example: 52.5321,
          }
        - {
            name: lng,
            in: query,
            required: true,
            schema: { type: number, minimum: -180, maximum: 180 },
            example: 13.3849,
          }
        - name: radius
          in: query
          required: true
          description: radius in metres
          schema: { type: integer, minimum: 1, maximum: 500000 }
      responses:
        '200':
          description: Cities within range of the coordinate
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RangeResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/{postalCode}/cities/radius/{radius}:
    get:
      operationId: getCitiesInRadius
      summary: Cities within a radius (metres), sorted by distance
      parameters:
        - $ref: '#/components/parameters/postalCode'
        - name: radius
          in: path
          required: true
          schema: { type: integer, minimum: 1, maximum: 500000 }
        - { name: baseCity, in: query, required: false, schema: { type: string } }
        - { name: city, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Cities within range
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RangeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/finance/tax-offices:
    get:
      operationId: getTaxOffices
      summary: All German tax offices (Finanzämter)
      responses:
        '200':
          description: Tax offices ordered by state then name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/TaxOffice' } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/finance/tax-offices/{stateCode}:
    get:
      operationId: getTaxOfficesByState
      summary: Tax offices of one federal state
      parameters:
        - name: stateCode
          in: path
          required: true
          description: Federal-state code 1–16 (official Bundesland ordering)
          schema: { type: string, pattern: '^\d{1,2}$' }
          example: '9'
      responses:
        '200':
          description: Tax offices of the state, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/TaxOffice' } }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/bank/iban/validate:
    post:
      operationId: validateIban
      summary: Validate an IBAN (ISO 13616) and enrich German ones with bank + BIC
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [iban]
              properties:
                iban: { type: string, example: 'DE89 3704 0044 0532 0130 00' }
      responses:
        '200':
          description: The IBAN is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/IbanValidationResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: The IBAN failed validation (error code INVALID_IBAN)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/bank/iban/derive:
    post:
      operationId: deriveIban
      summary: Derive a German IBAN from a Bankleitzahl + Kontonummer (Bundesbank standard rule)
      description: >
        Derives the IBAN using the Bundesbank standard rule (Kontonummer
        zero-padded to 10 digits + BLZ, ISO 13616 check digits). The BLZ must
        be a known institute in the reference data. Coverage caveat: ~60
        institutes use non-standard Bundesbank "Sonderregeln" not implemented
        here (their rule dataset is gated, not openly available) — verify the
        result against the bank for payouts of consequence.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blz, kontonummer]
              properties:
                blz: { type: string, pattern: '^\d{8}$', example: '37040044' }
                kontonummer: { type: string, pattern: '^\d{1,10}$', example: '532013000' }
      responses:
        '200':
          description: The derived IBAN.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/IbanDerivationResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: The BLZ is not a known institute (error code UNKNOWN_BLZ)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/geolocation/{postalCode}/distance/{basePostalCode}/cities:
    get:
      operationId: getDistances
      summary: Distance from a base PLZ-city to each city of the PLZ
      parameters:
        - $ref: '#/components/parameters/postalCode'
        - name: basePostalCode
          in: path
          required: true
          schema: { type: string, pattern: '^\d{5}$' }
        - { name: baseCity, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Distances to the target PLZ cities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RangeResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/sepa/credit-transfer:
    post:
      operationId: generateSepaCreditTransfer
      summary: Generate a SEPA credit-transfer file (ISO 20022 pain.001.001.03)
      description: >
        Builds a pain.001 credit-transfer XML from a debtor plus a batch of
        payouts. Pure generation — nothing is persisted. messageId and createdAt
        default server-side when omitted. Amounts are integer cents. BIC is
        optional (IBAN-only / "BIC-less" transfers are supported).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestedExecutionDate, debtor, payments]
              properties:
                messageId:
                  type: string
                  maxLength: 35
                  description: Unique file id; generated server-side when omitted.
                createdAt:
                  type: string
                  format: date-time
                  description: Creation timestamp (ISO 8601); defaults to now.
                requestedExecutionDate:
                  type: string
                  format: date
                  example: '2026-07-01'
                debtor: { $ref: '#/components/schemas/SepaParty' }
                payments:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { $ref: '#/components/schemas/SepaPayment' }
      responses:
        '200':
          description: The generated credit-transfer file plus reconciliation totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [messageId, transactionCount, controlSum, xml]
                    properties:
                      messageId: { type: string }
                      transactionCount: { type: integer, example: 3 }
                      controlSum: { type: string, example: '370.45' }
                      xml: { type: string, description: pain.001.001.03 XML }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/sepa/direct-debit:
    post:
      operationId: generateSepaDirectDebit
      summary: Generate a SEPA direct-debit collection file (ISO 20022 pain.008.001.02)
      description: >
        Builds a pain.008 direct-debit-collection XML from a creditor plus a
        batch of mandated debits. Pure generation — nothing is persisted.
        messageId and createdAt default server-side when omitted. Transactions
        are grouped into one payment-information block per mandate sequence
        type (FRST/RCUR/OOFF/FNAL), per the schema. Amounts are integer cents.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestedCollectionDate, creditor, transactions]
              properties:
                messageId:
                  type: string
                  maxLength: 35
                  description: Unique file id; generated server-side when omitted.
                createdAt:
                  type: string
                  format: date-time
                  description: Creation timestamp (ISO 8601); defaults to now.
                requestedCollectionDate:
                  type: string
                  format: date
                  example: '2026-07-15'
                creditor: { $ref: '#/components/schemas/SepaDirectDebitCreditor' }
                transactions:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { $ref: '#/components/schemas/SepaDirectDebitTransaction' }
      responses:
        '200':
          description: The generated direct-debit file plus reconciliation totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [messageId, transactionCount, controlSum, xml]
                    properties:
                      messageId: { type: string }
                      transactionCount: { type: integer, example: 3 }
                      controlSum: { type: string, example: '149.70' }
                      xml: { type: string, description: pain.008.001.02 XML }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/extraction/document:
    post:
      operationId: extractDocument
      summary: Extract structured fields from a document (provider-agnostic)
      description: >
        Given a base64 document (PDF or image) plus a requested field set,
        returns each field with a value, confidence and an explicit needsReview
        flag (human-in-the-loop). Provider-agnostic; when no AI provider is
        configured the stub adapter returns every field as needsReview.
        GDPR: the document is processed in memory and never persisted — only the
        extracted fields are returned, and no document bytes or values are logged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mimeType, document, fields]
              properties:
                mimeType:
                  type: string
                  enum: [application/pdf, image/png, image/jpeg, image/webp]
                document:
                  type: string
                  description: Base64-encoded document bytes (max 10 MB decoded).
                fields:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { $ref: '#/components/schemas/ExtractionFieldSpec' }
      responses:
        '200':
          description: The extracted fields keyed by the requested field keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DocumentExtractionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "extraction" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pdf/detect-fields:
    post:
      operationId: detectPdfFields
      summary: Detect fillable field positions in a flat PDF form
      description: >
        Locates the fillable fields in a flat PDF — underscore input lines, ☐
        checkboxes, and label-anchored comb fields (IBAN/Zählernummer/…) — and
        returns their exact positions in PDF points (bottom-left origin) so an
        editor or pdf-lib can place AcroForm widgets there. Positions come from
        the PDF text layer; vector-only fields (signature areas, vector
        checkboxes) are a follow-up vision pass. The document is processed in
        memory and never persisted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document]
              properties:
                document:
                  type: string
                  description: Base64-encoded PDF (max 20 MB decoded).
                mode:
                  type: string
                  enum: [text, ai]
                  default: text
                  description: >
                    text = exact text-layer detection only (free); ai = also run
                    the Mistral OCR generalizer for arbitrary/box forms
                    (signature + table fields, approximate; paid OCR call).
      responses:
        '200':
          description: The detected fields with positions (exact + optionally approximate).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/FormDetectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "form-detect" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/parcel-locker/nearby:
    get:
      operationId: parcelLockerNearby
      summary: Nearest parcel lockers for a coordinate or PLZ (provider-agnostic, OSM/ODbL)
      description: >
        Returns parcel lockers (DHL Packstation, Amazon Locker, …) near a
        coordinate (lat+lng) or a PLZ, sorted by distance. Provider is explicit
        (carrier coupling): pass `provider` to restrict to the carrier you ship
        with. Data is OSM-derived (ODbL); `meta.attribution` carries the
        required "© OpenStreetMap-Mitwirkende" credit. The data layer is built
        incrementally — until it lands the endpoint returns an empty result set.
      parameters:
        - { name: lat, in: query, required: false, schema: { type: number }, example: 52.52 }
        - { name: lng, in: query, required: false, schema: { type: number }, example: 13.405 }
        - {
            name: plz,
            in: query,
            required: false,
            schema: { type: string, pattern: '^\d{5}$' },
            example: '10115',
          }
        - {
            name: radius,
            in: query,
            required: false,
            description: 'Search radius in meters (default 10000, max 50000).',
            schema: { type: integer, maximum: 50000 },
          }
        - {
            name: provider,
            in: query,
            required: false,
            description: 'CSV carrier filter, e.g. dhl,amazon.',
            schema: { type: string },
            example: dhl,
          }
        - {
            name: limit,
            in: query,
            required: false,
            description: 'Max results (default 20, max 100).',
            schema: { type: integer, maximum: 100 },
          }
      responses:
        '200':
          description: Stations sorted by distance, with ODbL attribution + count in meta.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [stations]
                    properties:
                      stations:
                        type: array
                        items: { $ref: '#/components/schemas/ParcelLockerStation' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "parcel-locker" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/steuernummer/validate:
    post:
      operationId: validateSteuernummer
      summary: Validate + convert a German Steuernummer (state form ↔ unified 13-digit)
      description: >
        Validates a German Steuernummer and converts between the state-specific
        form (10–11 digits) and the unified federal ELSTER form (13 digits). A
        13-digit input is self-describing; a 10/11-digit input needs `state`
        (federal-state code 1–16 or German state name). The embedded
        Bundesfinanzamtsnummer is cross-checked against the Finanzamt reference
        data. Fully deterministic, no external service. The Prüfziffer is not
        algorithmically verified (no public uniform algorithm).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [steuernummer]
              properties:
                steuernummer:
                  type: string
                  description: With or without separators (/, spaces).
                  example: '101/815/08153'
                state:
                  type: string
                  description: Federal-state code (1–16) or German state name; required for the state form.
                  example: Bayern
      responses:
        '200':
          description: The validation verdict plus both representations and the resolved Finanzamt.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SteuernummerValidationResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "steuernummer" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/pv/yield:
    get:
      operationId: pvYield
      summary: Expected PV yield (annual + monthly) for a coordinate and system (PVGIS)
      description: >
        Returns the expected photovoltaic yield for a location and system from
        PVGIS. Provide `tilt` + `azimuth` for a fixed mounting, or omit them (or
        pass `optimal=true`) to let PVGIS optimize the angles. Azimuth uses the
        compass convention (0=N, 90=E, 180=S, 270=W). A PVGIS outage degrades to
        `available: false` rather than failing. PVGIS © European Union.
      parameters:
        - { name: lat, in: query, required: true, schema: { type: number }, example: 52.52 }
        - { name: lng, in: query, required: true, schema: { type: number }, example: 13.4 }
        - {
            name: kwp,
            in: query,
            required: false,
            description: Installed peak power (default 1).,
            schema: { type: number, maximum: 10000 },
          }
        - {
            name: loss,
            in: query,
            required: false,
            description: System loss percentage (default 14).,
            schema: { type: number, minimum: 0, maximum: 100 },
          }
        - {
            name: tilt,
            in: query,
            required: false,
            description: Module tilt in degrees; omit for optimized angles.,
            schema: { type: number, minimum: 0, maximum: 90 },
          }
        - {
            name: azimuth,
            in: query,
            required: false,
            description: 'Compass azimuth 0–360 (0=N, 180=S).',
            schema: { type: number, minimum: 0, maximum: 360 },
          }
        - {
            name: optimal,
            in: query,
            required: false,
            description: 'true/1 to force PVGIS-optimized tilt + azimuth.',
            schema: { type: string },
          }
      responses:
        '200':
          description: The yield estimate, with PVGIS attribution in meta.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/PvYieldResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "pv-yield" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/business-days/add:
    get:
      operationId: businessDaysAdd
      summary: Add/subtract working days to a date (deadline calculation)
      description: >
        Adds `days` working days to `date` (negative counts backwards), skipping
        weekends and the public holidays of the given Bundesland (nationwide-only
        without `state`). Deterministic.
      parameters:
        - {
            name: date,
            in: query,
            required: true,
            schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' },
            example: '2026-01-02',
          }
        - {
            name: days,
            in: query,
            required: true,
            description: Working-day offset (negative counts backwards).,
            schema: { type: integer, minimum: -3650, maximum: 3650 },
            example: 5,
          }
        - {
            name: state,
            in: query,
            required: false,
            description: ISO 3166-2:DE state code; omit for nationwide holidays only.,
            schema: { type: string },
            example: BY,
          }
      responses:
        '200':
          description: The resulting working day.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/BusinessDayAddResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "business-days" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/business-days/count:
    get:
      operationId: businessDaysCount
      summary: Count working days in an inclusive date range
      description: >
        Counts the working days in [from, to] (both ends inclusive), skipping
        weekends and the public holidays of the given Bundesland (nationwide-only
        without `state`). `from` must be on or before `to`. Deterministic.
      parameters:
        - {
            name: from,
            in: query,
            required: true,
            schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' },
            example: '2026-01-05',
          }
        - {
            name: to,
            in: query,
            required: true,
            schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' },
            example: '2026-01-11',
          }
        - { name: state, in: query, required: false, schema: { type: string }, example: BY }
      responses:
        '200':
          description: The number of working days in the range.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/BusinessDayCountResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "business-days" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/schulferien:
    get:
      operationId: getSchulferien
      summary: School holidays (Schulferien) for a Bundesland + year
      description: >
        Returns the Schulferien periods for the given state + year. Coverage
        varies per state/year; an uncovered combination returns an empty
        `periods` array, not an error. Data is community-maintained
        (paulbrejla/ferien-api-data, MIT) — "ohne Gewähr", no official
        government source — distinct from the deterministic, authoritative
        public-holiday computation in the `holidays` scope. See
        meta.attribution on the response.
      parameters:
        - { name: state, in: query, required: true, schema: { type: string }, example: BY }
        - { name: year, in: query, required: true, schema: { type: integer }, example: 2026 }
      responses:
        '200':
          description: The Schulferien periods for the state + year.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SchulferienResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "schulferien" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/sanctions/screen:
    post:
      operationId: sanctionsScreen
      summary: Fuzzy-screen a name against the EU consolidated financial sanctions list
      description: >
        Automated fuzzy-name screening against the EU Financial Sanctions
        Files (FSF) consolidated list (ADR 0005) — not a legal compliance
        determination; review hits manually before acting on them. An empty
        `hits` array is a valid no-match result, not an error. Optional
        `dateOfBirth`/`country` only boost the score of an agreeing name
        match — they never filter out a name match on their own.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SanctionsScreenRequest' }
      responses:
        '200':
          description: Fuzzy-matched sanctions list hits, ordered by score descending.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SanctionsScreenResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "sanctions" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/mastr/installations:
    get:
      operationId: mastrInstallations
      summary: Nearby MaStR PV installations for a coordinate or PLZ
      description: >
        Returns PV installations from the Bundesnetzagentur
        Marktstammdatenregister (MaStR, ADR 0006) near the given coordinate or
        PLZ, sorted by distance. Either lat+lng or plz is required. Installations
        without published coordinates are not included (coordinate-radius search
        only). DL-DE-BY-2.0 licensed — see meta.attribution.
      parameters:
        - {
            name: lat,
            in: query,
            schema: { type: number, minimum: -90, maximum: 90 },
            example: 52.012,
          }
        - {
            name: lng,
            in: query,
            schema: { type: number, minimum: -180, maximum: 180 },
            example: 11.654,
          }
        - { name: plz, in: query, schema: { type: string, pattern: '^\d{5}$' }, example: '39179' }
        - {
            name: radius,
            in: query,
            schema: { type: integer, default: 5000, maximum: 50000 },
            description: meters,
          }
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Nearby PV installations, ordered by distance ascending.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/MastrInstallationsResult' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "mastr" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/einvoice/xrechnung:
    post:
      operationId: einvoiceXrechnung
      summary: Generate a standalone XRechnung (UBL) XML document
      description: >
        Generates an EN16931-conformant XRechnung UBL XML document from an
        invoice (ADR 0007). Returns the raw XML document, not the
        `{ data, meta }` envelope (matching this hub's binary-file-response
        convention from the pdf module).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EinvoiceInvoice' }
      responses:
        '200':
          description: The generated XRechnung UBL XML document.
          content:
            application/xml: { schema: { type: string } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "einvoice" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/einvoice/zugferd:
    post:
      operationId: einvoiceZugferd
      summary: Generate a Factur-X/ZUGFeRD PDF/A-3 with embedded CII XML
      description: >
        Generates a Factur-X/ZUGFeRD PDF/A-3 (EN16931 "Comfort" profile,
        ADR 0007) by embedding the invoice's CII XML into a caller-supplied,
        already-rendered human-readable PDF. Returns the raw PDF, not the
        `{ data, meta }` envelope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ZugferdRequest' }
      responses:
        '200':
          description: The generated Factur-X PDF/A-3.
          content:
            application/pdf: { schema: { type: string, format: binary } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: The API key lacks the "einvoice" scope }
        '429': { $ref: '#/components/responses/RateLimited' }
