openapi: 3.1.0
info:
  title: Mercura API
  description: >
    # Getting Started


    ## What this API is


    **Mercura** is an AI-powered platform for processing inquiries — bills

    of materials, tenders, RFQs — in wholesale distribution, manufacturing,

    and technical sales. The **Mercura API** is the integration surface

    your organisation uses to plug Mercura into the rest of your software

    landscape.


    The API is bidirectional by design:


    - **Master data flows in** — your articles, customers, and suppliers
      are pushed into Mercura so that incoming inquiries can be processed
      against them.
    - **Structured offers flow out** — each inquiry, once Mercura has read
      the LV, captured the positions, and matched each one to an article
      from your catalogue, is returned as a fully structured offer ready
      for your downstream system to pick up.

    The source system on your side does not have to be a specific ERP. Any

    system that owns master data — an ERP, a PIM, a custom catalogue, a

    spreadsheet export pipeline — can be the integration partner.


    ## Main use case


    The end-to-end flow is four steps:


    1. **Push master data.** `POST /articles`, `POST /customers`, and
       `POST /suppliers` accept bulk payloads of up to 100,000 rows each
       and return a `JobAck` immediately. Sending one large request per
       resource is preferred over many small ones.
    2. **Get notified when the jobs finish.** Each `POST` returns a
       `job_id`. The recommended path is a **webhook**: register a
       subscription once (admin UI, see **Webhooks** below) and Mercura
       delivers a signed `job.finished` event to your endpoint as soon
       as the job reaches a terminal state. If a webhook receiver is not
       an option, fall back to polling `GET /jobs/{job_id}`.
    3. **Read completed tenders and orders back.** This is the payoff. A
       `tender.completed` (or `order.completed`) webhook fires the moment a
       Mercura user releases a record to your system (the **Finalize** /
       Export → API action) — your endpoint then calls `GET /tenders/{tender_id}`
       (or `GET /orders/{order_id}`) to pull the full structured record
       (positions, matched articles, quantities, prices, totals).
       `GET /tenders` / `GET /orders` are the cursor-paginated list views
       for catch-up scans; pass `?completed_since=…` for the completion feed.
    4. **Acknowledge the import.** Once the record is committed to your
       ERP/CRM, `POST /tenders/{tender_id}/acknowledgements` (or
       `/orders/{order_id}/acknowledgements`) with `status: "SUCCESS"` and
       your ERP document id in `external_id` — Mercura stores it on the
       record's `erp_offer_id` (latest-wins) and surfaces the outcome in the
       app. Report `status: "FAILED"` with a `message` when the import is
       rejected.

    A typical integration runs step 1 nightly (delta-sync new and changed

    master-data rows) and reacts to `tender.completed` / `order.completed`

    webhooks in step 3 to drive new offers into the downstream system in

    real-time, acknowledging each import in step 4.


    ## Integration patterns: webhook vs polling


    Steps 3–4 above can be driven two ways. **Webhook (push)** is the

    recommended path — Mercura notifies your endpoint the instant a tender

    or order completes, so you react in real time. **Polling (pull)** is the

    fallback when your side cannot expose an inbound HTTPS endpoint — you

    scan the completion feed on a schedule. Both end the same way: fetch the

    full record, write it to your ERP/CRM, and acknowledge the import.


    The sequences below show the **tender** flow. **Orders are identical** —

    swap `tender.completed` → `order.completed`, `GET /tenders/{id}` →

    `GET /orders/{id}`, and the acknowledgements path.


    ### Webhook (push)


    ![Webhook (push) sequence — Mercura user finalizes a record; Mercura POSTs
    an HMAC-signed tender.completed to your endpoint; you GET
    /tenders/{tender_id}, write the offer to your ERP, then POST the
    acknowledgement.](https://prod-euapi.mercura.ai/api/public/v1/docs-assets/webhook.svg)


    The webhook payload is intentionally small — it identifies *which*

    tender changed and *when*; you always fetch the current state with

    `GET /tenders/{tender_id}`. See the **Webhooks** chapter for the wire

    format, the HMAC-SHA256 signature recipe, and the retry schedule.


    ### Polling (pull)


    ![Polling (pull) sequence — your poller GETs /tenders?completed_since=… and
    pages via cursor; each item is a full tender; you write the offer to your
    ERP, then POST the
    acknowledgement.](https://prod-euapi.mercura.ai/api/public/v1/docs-assets/polling.svg)


    Poll `?completed_since=…` on a schedule (every few minutes is typical).

    `completed_at` refreshes on every completion, so a re-exported tender

    reappears in the feed — key off `tender_id` and keep your ERP writes

    idempotent. Keep `completed_since` on every request; once you hold a

    `next_cursor`, send only the cursor.


    ## Webhooks


    If you would rather **not poll**, Mercura can push events to an HTTPS

    endpoint on your side as soon as something happens — a `job.finished`

    event replaces the polling loop above, and a `tender.completed` /

    `order.completed` event tells you the moment a record has been released

    to your system (see **Integration patterns: webhook vs polling** above

    for the full push-vs-pull sequence).


    Subscriptions are set up by an org admin in the Mercura web app under

    **Settings → Organisation → Webhooks** — there are no

    `/webhook_subscriptions` endpoints in this API. Once a subscription

    exists, the wire format, HMAC-SHA256 signature recipe, retry schedule,

    and example payloads are documented in the **Webhooks** chapter

    further down.


    # Authentication


    The API uses **bearer tokens** issued in the Mercura admin UI under

    **Settings → Organisation → API Keys**.


    ```

    Authorization: Bearer mrc_live_<token>

    ```


    Missing, malformed, expired, or revoked tokens return

    `401 UNAUTHORIZED`.


    # Operations


    ## Data conventions


    A few conventions hold across every resource:


    - **Embedded addresses, standalone contacts.** Customer and
      supplier payloads carry their `addresses[]` inline — there is no
      separate `/addresses` endpoint, and the wire format hides the
      internal address table so each parent push is a single snapshot.
      **Contacts**, by contrast, are managed via the dedicated `/contacts`
      resource and carry their own `parent_type` (`customer` or `supplier`)
      and `parent_id`, so adding or updating a contact does not require
      re-sending the entire parent — see the **Contacts** chapter for the
      full model and upsert rules.
    - **Stable partner-supplied identifiers.** `article_number`,
      `customer_id`, and `supplier_id` are the keys you assign in your
      source system. They must remain stable across sync cycles — Mercura
      uses them to upsert: same id → existing row updated; new id → new
      row created.
    - **Timestamps in ISO-8601 UTC.** All timestamps on the wire are
      ISO-8601 with an explicit `Z` (or `+00:00`) suffix.
    - **`custom_fields` on every master-data resource.** Use it for any
      source-system field that doesn't fit the standard schema. Mercura
      preserves the value alongside the row; matching uses it where
      appropriate.

    ## Errors


    Every non-2xx response uses one envelope:


    ```json

    {
      "error": {
        "code": "VALIDATION_FAILED",
        "message": "Request validation failed",
        "details": [ { "loc": ["query", "cursor"], "msg": "Invalid cursor", "type": "value_error" } ],
        "request_id": "9a3b...e2"
      }
    }

    ```


    Branch on `code` — it is stable. `message` is human-readable and may

    change between versions. The full list of codes (`VALIDATION_FAILED`,

    `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `IDEMPOTENCY_KEY_MISMATCH`,

    `RATE_LIMITED`, `INTERNAL_ERROR`, …) is documented per endpoint.


    ## Request IDs


    Every response carries an `X-Request-Id` header (echoed if you supply

    one, generated otherwise) and the same id is embedded in every error

    envelope. Quote it in support tickets — it is the fastest way for

    Mercura's team to look up the exact request in our logs.


    ## Versioning


    The API follows **Semantic Versioning** (`MAJOR.MINOR.PATCH`). Only the

    `MAJOR` number lives in the URL path (`/v1`, future `/v2`, …). The full

    SemVer string is published in the OpenAPI `info.version` field and on

    every response as the `X-API-Version` header.


    | Bump | What it means |

    |---|---|

    | **PATCH** | Bugfix / doc fix. OpenAPI shape unchanged. |

    | **MINOR** | Additive only: new optional field, new endpoint, relaxed
    constraint. Existing clients keep working unchanged. |

    | **MAJOR** | Breaking: removed field, renamed field, narrower constraint. A
    new URL prefix is published; the previous `MAJOR` remains available during a
    migration period. |


    ## Rate limits


    Each API key has its own budget. **Every request counts as one** against

    the matching limiter — reads draw from the read bucket, writes from the

    write bucket, and each in-flight bulk-write job holds one concurrency

    slot. Defaults today:


    | Class | Limit |

    |---|---|

    | Writes (POST)     | 60 / minute, burst 10 |

    | Reads (GET)       | 600 / minute, burst 60 |

    | Concurrent in-flight bulk-write jobs | 5 per key |


    Every response carries `RateLimit-Policy` / `RateLimit` headers (and

    legacy `X-RateLimit-*` headers) describing your current budget. On a

    `429 RATE_LIMITED` response, `Retry-After` tells you how long to wait,

    and `X-RateLimit-Scope` tells you whether you hit the per-minute bucket

    (`rate`) or the concurrent-jobs cap (`concurrency`).


    ## Read-endpoint pagination


    Every `GET` list endpoint — `/tenders`, `/orders`, `/supplier-requests`,

    `/articles`, `/customers`, `/suppliers`, `/projects`, and `/contacts` — is
    cursor

    paginated. Pass `?modified_since=<ISO-8601>` on the

    first page to bound the lower edge of the scan by `updated_at` (useful for

    delta-sync). Mercura returns a page plus a `next_cursor` — pass it back as

    `?cursor=…` to get the next page. Once you have a cursor, `modified_since`

    is ignored.


    On `/tenders` and `/orders` you can instead pass

    `?completed_since=<ISO-8601>` to pull the **completion feed** — only records

    completed at/after that instant, ordered by `completed_at` — the

    reconciliation twin of the `tender.completed` / `order.completed` webhooks.

    Both accept a `status` filter; `/contacts` additionally filters by

    `parent_type` / `parent_id` / `is_active`.


    The cursor is keyed on `(updated_at, id)`, so it is stable under

    concurrent writes: a row updated mid-scan may resurface on a later

    page, but you will never silently skip or duplicate rows.


    # Alternative integration


    ## SFTP-based integration


    The Mercura API documented here is the preferred channel because it is

    synchronous, low-latency, and operationally light — status comes back

    immediately, no file-watching is required on either side, and

    master-data updates can flow event-driven. Where a direct API

    integration is not feasible (network constraints, security policy,

    ERP capability), Mercura also supports SFTP-based exchange in both

    directions for the same payloads. Talk to your Mercura contact if you

    need that path; the data shapes are identical to the ones documented

    here.
  version: 1.33.0
servers:
  - url: /api/public/v1
  - url: https://prod-euapi.mercura.ai/api/public/v1
    description: Production
paths:
  /tenders:
    post:
      tags:
        - Tenders
      summary: Public Tenders Create
      description: |-
        Create a tender from uploaded files (async processing).

        The programmatic twin of forwarding an LV / RFQ email to
        ``anfragen@lv.mercura.ai``: Mercura stores the files, picks the file
        that drives parsing (GAEB before spreadsheet/CSV/DOCX/TXT before PDF
        before image), extracts chapters and positions, matches them against
        your catalogue, and lands the tender ready for review.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the first
          call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay (same
          ``Idempotency-Key`` + same form fields and file contents).

        **What to do next.** Poll ``GET /jobs/{job_id}`` until ``status`` is
        ``COMPLETED`` or ``FAILED`` (the terminal state is reached when
        *processing* finishes, not when the upload is accepted), or subscribe
        to the ``request.processing_completed`` webhook and correlate by
        ``job_id``. The resulting offer is then readable via ``GET /tenders``.

        **Correlating with your system.** Use ``custom_fields`` to carry your
        own identifier (a CRM case number, an ERP reference) onto the tender at
        creation time, and ``branch`` to route it. Both are validated against
        your organisation's configuration *before* the job is created, so a
        typo fails fast instead of landing an unidentifiable tender. Store the
        ``request_id`` from ``GET /jobs/{job_id}`` on your side — it is the
        stable handle for every later call (``GET``/``PATCH /tenders``).

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars). Same
        key with a *different* body returns ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_tenders_create
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/Body_public_tenders_create'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: >-
            Accepted; poll status_url, or subscribe to
            request.processing_completed
        '400':
          description: >-
            Validation failed (no parsable file, empty file, too many files,
            unknown branch, unknown custom-field label)
        '413':
          description: A file exceeds 50 MB or the upload exceeds 100 MB in total
        '415':
          description: A file has an unsupported extension
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Tenders
      summary: Public Tenders List
      description: Cursor-paginated list of tenders for the authed organisation.
      operationId: public_tenders_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: completed_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``completed_at``. When set, returns the
              **completion feed**: only tenders completed at/after this instant,
              ordered by ``completed_at`` — e.g. everything completed in the
              last 5 minutes. ``completed_at`` is refreshed on every completion,
              so a re-exported tender reappears. Keep this parameter present on
              every page of the feed (its value is ignored once a cursor is
              supplied).
            title: Completed Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``completed_at``. When set, returns the
            **completion feed**: only tenders completed at/after this instant,
            ordered by ``completed_at`` — e.g. everything completed in the last
            5 minutes. ``completed_at`` is refreshed on every completion, so a
            re-exported tender reappears. Keep this parameter present on every
            page of the feed (its value is ignored once a cursor is supplied).
        - name: status
          in: query
          required: false
          schema:
            description: Filter by lifecycle state (e.g. ``DONE``).
            title: Status
            $ref: '#/components/schemas/PublicTenderStatus'
          description: Filter by lifecycle state (e.g. ``DONE``).
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: include_deleted
          in: query
          required: false
          schema:
            type: boolean
            description: >-
              Also report lines that were **removed** from the tender: they come
              back flagged (``is_deleted`` / ``deleted_at``) instead of being
              omitted, so an ERP that already imported the offer can drop what
              is no longer on it. Removals are reported per **line**: taking one
              article off a position yields that article flagged next to the
              position's live lines, and a position that vanished completely
              comes back with *all* of its lines flagged — so a consumer keyed
              on the ordinal number can still tell the two apart. Deleted lines
              never count toward ``totals``, and a position merely flagged 'not
              relevant' is not a deletion and stays hidden.
            default: false
            title: Include Deleted
          description: >-
            Also report lines that were **removed** from the tender: they come
            back flagged (``is_deleted`` / ``deleted_at``) instead of being
            omitted, so an ERP that already imported the offer can drop what is
            no longer on it. Removals are reported per **line**: taking one
            article off a position yields that article flagged next to the
            position's live lines, and a position that vanished completely comes
            back with *all* of its lines flagged — so a consumer keyed on the
            ordinal number can still tell the two apart. Deleted lines never
            count toward ``totals``, and a position merely flagged 'not
            relevant' is not a deletion and stays hidden.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicTenderOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /tenders/{tender_id}:
    get:
      tags:
        - Tenders
      summary: Public Tenders Get
      description: >-
        Fetch one tender by its public id.


        The response carries a strong ``ETag`` over the tender content; pass it
        back

        as ``If-Match`` on ``PATCH /tenders/{tender_id}`` for optimistic
        concurrency.

        The ETag covers the *live* content only, so it is unaffected by

        ``include_deleted`` and a document fetched either way round-trips
        through

        ``PATCH`` (echoed deleted lines are ignored).
      operationId: public_tenders_get
      parameters:
        - name: tender_id
          in: path
          required: true
          schema:
            type: string
            title: Tender Id
        - name: include_deleted
          in: query
          required: false
          schema:
            type: boolean
            description: >-
              Also report lines that were **removed** from the tender: they come
              back flagged (``is_deleted`` / ``deleted_at``) instead of being
              omitted, so an ERP that already imported the offer can drop what
              is no longer on it. Removals are reported per **line**: taking one
              article off a position yields that article flagged next to the
              position's live lines, and a position that vanished completely
              comes back with *all* of its lines flagged — so a consumer keyed
              on the ordinal number can still tell the two apart. Deleted lines
              never count toward ``totals``, and a position merely flagged 'not
              relevant' is not a deletion and stays hidden.
            default: false
            title: Include Deleted
          description: >-
            Also report lines that were **removed** from the tender: they come
            back flagged (``is_deleted`` / ``deleted_at``) instead of being
            omitted, so an ERP that already imported the offer can drop what is
            no longer on it. Removals are reported per **line**: taking one
            article off a position yields that article flagged next to the
            position's live lines, and a position that vanished completely comes
            back with *all* of its lines flagged — so a consumer keyed on the
            ordinal number can still tell the two apart. Deleted lines never
            count toward ``totals``, and a position merely flagged 'not
            relevant' is not a deletion and stays hidden.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicTenderOut'
        '404':
          description: Tender not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
    patch:
      tags:
        - Tenders
      summary: Public Tenders Update
      description: >-
        Edit one tender in place and read back the re-projected result.


        Two addressing modes: a sparse top-level ``positions[]`` (recommended —

        edit or delete existing lines by ``id``) and the nested ``chapters``
        tree

        (required for adds; also accepts edits). Edit a line by ``id``; delete
        one

        with ``{"id": "…", "op": "delete"}`` (soft-delete); add a line by
        sending it

        without an ``id`` (``article_number`` for an article line — with
        optional

        price/discount — else a free-text line under a chapter). Omitted lines
        are

        left untouched. ``totals.net_total`` / ``gross_total`` persist as the

        partner's authoritative amounts. Pass the GET's ETag as ``If-Match`` to
        be

        rejected with ``412`` if the tender changed since you read it.


        **Closing a tender.** Send ``{"status": "CANCELLED"}`` for a tender that
        was

        resolved outside Mercura — its CRM case was closed, or it was quoted by
        hand

        — so it leaves the open inbox instead of accumulating there. It is the
        only

        writable transition; echoing the tender's current status is a no-op, so
        a

        full GET body still round-trips.
      operationId: public_tenders_update
      parameters:
        - name: tender_id
          in: path
          required: true
          schema:
            type: string
            title: Tender Id
        - name: If-Match
          in: header
          required: false
          schema:
            description: >-
              Optional strong ETag from a prior GET; a mismatch is a 412
              (someone else edited the tender).
            title: If-Match
            type:
              - string
              - 'null'
          description: >-
            Optional strong ETag from a prior GET; a mismatch is a 412 (someone
            else edited the tender).
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicTenderUpdate'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicTenderOut'
        '400':
          description: >-
            Validation failed (bad line id, conflicting price fields, unknown
            article, …)
        '404':
          description: Tender not found
        '409':
          description: >-
            Status transition rejected (cancelling a PARSING or already DONE
            tender)
        '412':
          description: >-
            If-Match ETag does not match the current tender (it changed since
            your GET)
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /tenders/{tender_id}/documents:
    get:
      tags:
        - Tenders
      summary: Public Tenders List Documents
      description: >-
        List the customer's source documents attached to a tender.


        Returns metadata for each **original uploaded** file (GAEB / PDF / Excel
        /

        image / e-mail) together with a short-lived, pre-signed ``download_url``
        that

        fetches the bytes directly from object storage — no ``Authorization``
        header

        needed on that URL, and it never proxies the file through the API.


        Only the customer's own uploads are listed: Mercura-generated artefacts
        (the

        offer / display PDF, structured-doc previews, OCR / metadata side-files)
        and

        hidden files are excluded. The download URLs expire (see

        ``download_url_expires_at``) — re-list to obtain fresh ones. An unknown
        /

        non-tender / foreign id is a ``404``, indistinguishable from a missing
        tender.
      operationId: public_tenders_list_documents
      parameters:
        - name: tender_id
          in: path
          required: true
          schema:
            type: string
            title: Tender Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicTenderDocuments'
        '404':
          description: Tender not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /tenders/{tender_id}/acknowledgements:
    post:
      tags:
        - Tenders
      summary: Public Tenders Acknowledge
      description: >-
        Report the ERP/CRM import outcome for one tender.


        The partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an

        ``external_id`` (its own offer/record id) and ``metadata``, and a

        ``message`` (required on ``FAILED`` as the reason, optional on
        ``SUCCESS``

        as a free-text note). Idempotent and latest-wins: a later
        acknowledgement

        overwrites the earlier one. When supplied, ``external_id`` is also
        written

        back to the tender's ``erp_offer_id``. Returns the recorded
        acknowledgement

        (status, sticky-aware external_id, message, metadata, acknowledged_at) —

        the same shape as the ``acknowledgement`` field on ``PublicTenderOut``,
        not

        the whole tender. Recording-only: it does not alter the tender's
        lifecycle

        status or re-trigger any export.
      operationId: public_tenders_acknowledge
      parameters:
        - name: tender_id
          in: path
          required: true
          schema:
            type: string
            title: Tender Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicTenderAcknowledgementCreate'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicTenderAcknowledgement'
        '400':
          description: Validation failed (e.g. FAILED without message)
        '404':
          description: Tender not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /tenders/{tender_id}/events:
    post:
      tags:
        - Tenders
      summary: Public Tenders Record Event
      description: >-
        Report a lifecycle event your system recorded for one tender.


        Append-only: each POST records one event (e.g. the tender was marked
        *Won*

        or *Lost* in your CRM), so the full partner-side timeline is preserved.

        Supply ``external_event_id`` to make retries idempotent — an identical

        replay returns the already-stored event with ``200``, a conflicting
        reuse of

        the id is ``409``. **Recording-only**: this never changes the tender's

        lifecycle status or re-triggers any export. These are events *you*
        report to

        Mercura — not the webhook deliveries Mercura sends you.
      operationId: public_tenders_record_event
      parameters:
        - name: tender_id
          in: path
          required: true
          schema:
            type: string
            title: Tender Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicTenderEventCreate'
      responses:
        '200':
          description: 'Idempotent replay: this external_event_id was already recorded'
        '201':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicTenderEvent'
        '400':
          description: >-
            Validation failed (e.g. bad event_type, oversized metadata, future
            occurred_at)
        '404':
          description: Tender not found
        '409':
          description: >-
            external_event_id reused with a different payload, or per-request
            event cap reached
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /orders:
    post:
      tags:
        - Orders
      summary: Public Orders Create
      description: |-
        Create an order from uploaded files (async processing).

        The programmatic twin of forwarding a purchase-order email: Mercura
        stores the files, extracts the order metadata (customer, references,
        delivery), matches the line items against your catalogue, and lands the
        order ready for review / ERP export.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the first
          call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay (same
          ``Idempotency-Key`` + same form fields and file contents).

        **What to do next.** Poll ``GET /jobs/{job_id}`` until ``status`` is
        ``COMPLETED`` or ``FAILED``, or subscribe to the
        ``request.processing_completed`` webhook and correlate by ``job_id``.
        The order is then readable via ``GET /orders``.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars). Same
        key with a *different* body returns ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_orders_create
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/Body_public_orders_create'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: >-
            Accepted; poll status_url, or subscribe to
            request.processing_completed
        '400':
          description: Validation failed (no parsable file, empty file, too many files)
        '413':
          description: A file exceeds 50 MB or the upload exceeds 100 MB in total
        '415':
          description: A file has an unsupported extension
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Orders
      summary: Public Orders List
      description: Cursor-paginated list of orders for the authed organisation.
      operationId: public_orders_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: completed_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``completed_at``. When set, returns the
              **completion feed**: only orders completed at/after this instant,
              ordered by ``completed_at`` — e.g. everything completed in the
              last 5 minutes. ``completed_at`` is refreshed on every completion,
              so a re-exported order reappears. Keep this parameter present on
              every page of the feed (its value is ignored once a cursor is
              supplied).
            title: Completed Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``completed_at``. When set, returns the
            **completion feed**: only orders completed at/after this instant,
            ordered by ``completed_at`` — e.g. everything completed in the last
            5 minutes. ``completed_at`` is refreshed on every completion, so a
            re-exported order reappears. Keep this parameter present on every
            page of the feed (its value is ignored once a cursor is supplied).
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicOrderOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /orders/{order_id}:
    get:
      tags:
        - Orders
      summary: Public Orders Get
      description: Fetch one order by its public id.
      operationId: public_orders_get
      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
            title: Order Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicOrderOut'
        '404':
          description: Order not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /orders/{order_id}/acknowledgements:
    post:
      tags:
        - Orders
      summary: Public Orders Acknowledge
      description: >-
        Report the ERP import outcome for one order.


        The partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an

        ``external_id`` (its own order/record id) and ``metadata``, and a

        ``message`` (required on ``FAILED`` as the reason, optional on
        ``SUCCESS``

        as a free-text note). Idempotent and latest-wins: a later
        acknowledgement

        overwrites the earlier one. When supplied, ``external_id`` is also
        written

        back to the order's ``erp_offer_id``. Returns the recorded
        acknowledgement

        (status, sticky-aware external_id, message, metadata, acknowledged_at) —

        the same shape as the ``acknowledgement`` field on ``PublicOrderOut``,
        not

        the whole order. Recording-only: it does not alter the order's workflow

        status or re-trigger any export.
      operationId: public_orders_acknowledge
      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
            title: Order Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicOrderAcknowledgementCreate'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicOrderAcknowledgement'
        '400':
          description: Validation failed (e.g. FAILED without message)
        '404':
          description: Order not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /orders/{order_id}/events:
    post:
      tags:
        - Orders
      summary: Public Orders Record Event
      description: >-
        Report a lifecycle event your system recorded for one order.


        Append-only: each POST records one event (e.g. the order was *Cancelled*
        in

        your ERP), so the full partner-side timeline is preserved. Supply

        ``external_event_id`` to make retries idempotent — an identical replay

        returns the already-stored event with ``200``, a conflicting reuse of
        the id

        is ``409``. **Recording-only**: this never changes the order's workflow

        status or re-triggers any export. These are events *you* report to
        Mercura —

        not the webhook deliveries Mercura sends you.
      operationId: public_orders_record_event
      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
            title: Order Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicOrderEventCreate'
      responses:
        '200':
          description: 'Idempotent replay: this external_event_id was already recorded'
        '201':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicOrderEvent'
        '400':
          description: >-
            Validation failed (e.g. bad event_type, oversized metadata, future
            occurred_at)
        '404':
          description: Order not found
        '409':
          description: >-
            external_event_id reused with a different payload, or per-request
            event cap reached
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /supplier-requests:
    get:
      tags:
        - Supplier Requests
      summary: Public Supplier Requests List
      description: Cursor-paginated list of supplier requests for the authed organisation.
      operationId: public_supplier_requests_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from a prior page.
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from a prior page.
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: tender_id
          in: query
          required: false
          schema:
            description: >-
              Return only supplier requests raised for this tender (the tender's
              id from ``GET /tenders``). Distinct from ``project_id``.
            title: Tender Id
            type:
              - string
              - 'null'
          description: >-
            Return only supplier requests raised for this tender (the tender's
            id from ``GET /tenders``). Distinct from ``project_id``.
        - name: project_id
          in: query
          required: false
          schema:
            description: >-
              Return only supplier requests raised for this project
              (project-scoped SRs).
            title: Project Id
            type:
              - string
              - 'null'
          description: >-
            Return only supplier requests raised for this project
            (project-scoped SRs).
        - name: supplier_id
          in: query
          required: false
          schema:
            description: >-
              Return only supplier requests sent to this supplier (its
              ``external_id``).
            title: Supplier Id
            type:
              - string
              - 'null'
          description: >-
            Return only supplier requests sent to this supplier (its
            ``external_id``).
        - name: status
          in: query
          required: false
          schema:
            description: >-
              Filter by lifecycle state (``REQUEST_SENT`` | ``OFFER_RECEIVED`` |
              ``DECLINED``).
            title: Status
            $ref: '#/components/schemas/PublicSupplierRequestStatus'
          description: >-
            Filter by lifecycle state (``REQUEST_SENT`` | ``OFFER_RECEIVED`` |
            ``DECLINED``).
        - name: awarded
          in: query
          required: false
          schema:
            description: >-
              ``true`` returns only supplier requests with at least one accepted
              (selected) line; ``false`` returns only those without one.
            title: Awarded
            type:
              - boolean
              - 'null'
          description: >-
            ``true`` returns only supplier requests with at least one accepted
            (selected) line; ``false`` returns only those without one.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicSupplierRequestOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /supplier-requests/{supplier_request_id}:
    get:
      tags:
        - Supplier Requests
      summary: Public Supplier Requests Get
      description: Fetch one supplier request by its public id (its UUID).
      operationId: public_supplier_requests_get
      parameters:
        - name: supplier_request_id
          in: path
          required: true
          schema:
            type: string
            title: Supplier Request Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicSupplierRequestOut'
        '404':
          description: Supplier request not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /projects:
    get:
      tags:
        - Projects
      summary: Public Projects List
      description: Cursor-paginated list of projects for the authed organisation.
      operationId: public_projects_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: object_number
          in: query
          required: false
          schema:
            description: >-
              Filter to projects with this ERP-facing grouping key. Not unique —
              multiple projects may share an ``object_number`` and all matches
              are returned across the page.
            title: Object Number
            type:
              - string
              - 'null'
          description: >-
            Filter to projects with this ERP-facing grouping key. Not unique —
            multiple projects may share an ``object_number`` and all matches are
            returned across the page.
        - name: request_id
          in: query
          required: false
          schema:
            description: >-
              Filter to the project linked to this Mercura request id (via the
              ``Request.project_id`` FK). Returns an empty page for requests
              with no project FK.
            title: Request Id
            type:
              - integer
              - 'null'
          description: >-
            Filter to the project linked to this Mercura request id (via the
            ``Request.project_id`` FK). Returns an empty page for requests with
            no project FK.
        - name: status
          in: query
          required: false
          schema:
            description: >-
              Filter to projects in this lifecycle status. One of ``ACTIVE``,
              ``PROCESSED``, ``BID_SUBMITTED``, ``CUSTOMER_LOST``,
              ``CUSTOMER_WON_PENDING``, ``CUSTOMER_WON_AWARDED_ELSEWHERE``,
              ``CUSTOMER_WON_AWARDED_TO_US``. Unknown values return 400.
            title: Status
            type:
              - string
              - 'null'
          description: >-
            Filter to projects in this lifecycle status. One of ``ACTIVE``,
            ``PROCESSED``, ``BID_SUBMITTED``, ``CUSTOMER_LOST``,
            ``CUSTOMER_WON_PENDING``, ``CUSTOMER_WON_AWARDED_ELSEWHERE``,
            ``CUSTOMER_WON_AWARDED_TO_US``. Unknown values return 400.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicProjectOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /projects/{project_id}:
    get:
      tags:
        - Projects
      summary: Public Projects Get
      description: >-
        Fetch one project by its internal numeric id.


        The id matches the ``project_id`` field carried on the

        ``offer.new_export_run`` webhook — pass the value verbatim

        (stringified) to round-trip.


        The response carries a strong ``ETag`` over the editable content; pass
        it

        back as ``If-Match`` on ``PATCH /projects/{project_id}`` for optimistic

        concurrency.
      operationId: public_projects_get
      parameters:
        - name: project_id
          in: path
          required: true
          schema:
            type: string
            title: Project Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicProjectOut'
        '404':
          description: Project not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
    patch:
      tags:
        - Projects
      summary: Public Projects Update
      description: >-
        Edit one project in place and read back the re-projected result.


        Send only the fields you want to change; omitted fields are left
        untouched,

        and an explicit ``null`` clears a field (except ``status``, which cannot
        be

        null). ``object_number`` writes the ERP-facing "Objektnummer";
        ``status``

        takes the UPPERCASE ``PublicProjectStatus`` values; ``custom_fields`` is

        label-keyed and shallow-merged (a key set to ``null`` clears that
        field).

        Pass the GET's ETag as ``If-Match`` to be rejected with ``412`` if the

        project changed since you read it.
      operationId: public_projects_update
      parameters:
        - name: project_id
          in: path
          required: true
          schema:
            type: string
            title: Project Id
        - name: If-Match
          in: header
          required: false
          schema:
            description: >-
              Optional strong ETag from a prior GET; a mismatch is a 412
              (someone else edited the project).
            title: If-Match
            type:
              - string
              - 'null'
          description: >-
            Optional strong ETag from a prior GET; a mismatch is a 412 (someone
            else edited the project).
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicProjectUpdate'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicProjectOut'
        '400':
          description: >-
            Validation failed (unknown custom-field label / option, null status,
            …)
        '404':
          description: Project not found
        '412':
          description: >-
            If-Match ETag does not match the current project (it changed since
            your GET)
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /projects/{project_id}/acknowledgements:
    post:
      tags:
        - Projects
      summary: Public Projects Acknowledge
      description: >-
        Report the ERP/CRM import outcome for one project.


        The partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an

        ``external_id`` (its own project/record id, e.g. its ERP document
        number)

        and ``metadata``, and a ``message`` (required on ``FAILED`` as the
        reason,

        optional on ``SUCCESS`` as a free-text note). Idempotent and
        latest-wins:

        a later acknowledgement overwrites the earlier one; ``external_id`` is

        kept once provided. Returns the recorded acknowledgement (status,

        sticky-aware external_id, message, metadata, acknowledged_at) — the same

        shape as the ``acknowledgement`` field on ``PublicProjectOut``, not the

        whole project. A supplied ``external_id`` is also written to the
        project's

        ``object_number`` (the Objektnummer) — the key Mercura echoes back as

        ``erp_object_id`` on later tender/order exports — so one POST both
        records

        the outcome and reconciles the identifier without a PATCH. It never
        alters

        the project's lifecycle ``status`` and re-triggers no export. The

        method-minimal alternative to PATCH for middlewares that cannot issue
        the

        PATCH verb.
      operationId: public_projects_acknowledge
      parameters:
        - name: project_id
          in: path
          required: true
          schema:
            type: string
            title: Project Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicProjectAcknowledgementCreate'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicProjectAcknowledgement'
        '400':
          description: Validation failed (e.g. FAILED without message)
        '404':
          description: Project not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles/accessories:
    post:
      tags:
        - Accessories
      summary: Public Accessories Bulk Upsert
      description: |-
        Bulk-upsert accessory relationships (async).

        Accepts up to 100,000 rows per call. Each row upserts the
        ``(source_article_number, accessory_article_number)`` pair; rows whose
        articles don't exist are skipped (see the job's ``skipped_count``).

        **Response.** ``202 Accepted`` with ``JobAck`` on the first call;
        ``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll
        ``GET /jobs/{job_id}`` for terminal status.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the
        same key with a *different* body returns ``422``.
      operationId: public_accessories_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkAccessoriesIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Accessories
      summary: Public Accessories List
      description: Cursor-paginated list of accessory relationships.
      operationId: public_accessories_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: source_article_number
          in: query
          required: false
          schema:
            description: Return only accessories of this source article.
            title: Source Article Number
            type:
              - string
              - 'null'
          description: Return only accessories of this source article.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicAccessoryOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles/alternatives:
    post:
      tags:
        - Alternatives
      summary: Public Alternatives Bulk Upsert
      description: >-
        Bulk-upsert alternative relationships (async).


        Accepts up to 100,000 rows per call. Each row upserts the

        ``(source_article_number, alternative_article_number)`` pair; rows

        whose articles don't exist are skipped (see the job's
        ``skipped_count``).


        **Response.** ``202 Accepted`` with ``JobAck`` on the first call;

        ``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll

        ``GET /jobs/{job_id}`` for terminal status.


        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the

        same key with a *different* body returns ``422``.
      operationId: public_alternatives_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkAlternativesIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Alternatives
      summary: Public Alternatives List
      description: Cursor-paginated list of alternative relationships.
      operationId: public_alternatives_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: source_article_number
          in: query
          required: false
          schema:
            description: Return only alternatives of this source article.
            title: Source Article Number
            type:
              - string
              - 'null'
          description: Return only alternatives of this source article.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicAlternativeOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles/successors:
    post:
      tags:
        - Successors
      summary: Public Successors Bulk Upsert
      description: |-
        Bulk-upsert successor relationships (async).

        Accepts up to 100,000 rows per call. Successors are 1:1 per source
        article: re-sending a source with a new successor replaces the previous
        one. Rows whose articles don't exist are skipped (see the job's
        ``skipped_count``).

        **Response.** ``202 Accepted`` with ``JobAck`` on the first call;
        ``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll
        ``GET /jobs/{job_id}`` for terminal status.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the
        same key with a *different* body returns ``422``.
      operationId: public_successors_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkSuccessorsIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Successors
      summary: Public Successors List
      description: Cursor-paginated list of successor relationships.
      operationId: public_successors_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: source_article_number
          in: query
          required: false
          schema:
            description: Return only the successor of this source article.
            title: Source Article Number
            type:
              - string
              - 'null'
          description: Return only the successor of this source article.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicSuccessorOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles/unit-conversions:
    post:
      tags:
        - Unit Conversions
      summary: Public Unit Conversions Bulk Upsert
      description: |-
        Bulk-upsert unit conversions (async).

        Accepts up to 100,000 rows per call. Each row upserts the
        ``(article_number, alternative_unit)`` conversion; rows whose article
        doesn't exist are skipped (see the job's ``skipped_count``).

        **Response.** ``202 Accepted`` with ``JobAck`` on the first call;
        ``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll
        ``GET /jobs/{job_id}`` for terminal status.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the
        same key with a *different* body returns ``422``.
      operationId: public_unit_conversions_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkUnitConversionsIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Unit Conversions
      summary: Public Unit Conversions List
      description: Cursor-paginated list of unit conversions.
      operationId: public_unit_conversions_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: article_number
          in: query
          required: false
          schema:
            description: Return only conversions for this article.
            title: Article Number
            type:
              - string
              - 'null'
          description: Return only conversions for this article.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicUnitConversionOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles:
    post:
      tags:
        - Articles
      summary: Public Articles Bulk Upsert
      description: |-
        Bulk-upsert articles (async).

        Accepts up to 100,000 articles per call. The body is validated
        synchronously; the actual upsert runs out-of-band on a worker.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the
          first call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay
          (same ``Idempotency-Key`` + same body). No second job runs.

        **What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``
        is the canonical path) until ``status`` is ``COMPLETED`` or
        ``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some
        rows failed — see ``errors[]`` for the per-row detail. The Jobs
        chapter has the full polling guide.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).
        Same key with a *different* body returns
        ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_articles_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkArticlesIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Articles
      summary: Public Articles List
      description: Cursor-paginated list of articles.
      operationId: public_articles_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: include_deleted
          in: query
          required: false
          schema:
            type: boolean
            description: When true, include soft-deleted articles (hidden by default).
            default: false
            title: Include Deleted
          description: When true, include soft-deleted articles (hidden by default).
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicArticleOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /articles/{article_number}:
    get:
      tags:
        - Articles
      summary: Public Articles Get
      description: Fetch one article by its partner-supplied ``article_number``.
      operationId: public_articles_get
      parameters:
        - name: article_number
          in: path
          required: true
          schema:
            type: string
            pattern: ^[A-Za-z0-9._\-]{1,255}$
            title: Article Number
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicArticleOut'
        '404':
          description: Article not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /customers:
    post:
      tags:
        - Customers
      summary: Public Customers Bulk Upsert
      description: |-
        Bulk-upsert customers (async).

        Accepts up to 100,000 customers per call. The body is validated
        synchronously; the actual upsert runs out-of-band on a worker.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the
          first call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay
          (same ``Idempotency-Key`` + same body). No second job runs.

        **What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``
        is the canonical path) until ``status`` is ``COMPLETED`` or
        ``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some
        rows failed — see ``errors[]`` for the per-row detail. The Jobs
        chapter has the full polling guide.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).
        Same key with a *different* body returns
        ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_customers_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkCustomersIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Customers
      summary: Public Customers List
      description: Cursor-paginated list of customers.
      operationId: public_customers_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicCustomerOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /customers/{customer_id}:
    get:
      tags:
        - Customers
      summary: Public Customers Get
      description: Fetch one customer by its partner-supplied ``customer_id``.
      operationId: public_customers_get
      parameters:
        - name: customer_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^[A-Za-z0-9._\-]{1,255}$
            title: Customer Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicCustomerOut'
        '404':
          description: Customer not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /suppliers:
    post:
      tags:
        - Suppliers
      summary: Public Suppliers Bulk Upsert
      description: |-
        Bulk-upsert suppliers (async).

        Accepts up to 100,000 suppliers per call. The body is validated
        synchronously; the actual upsert runs out-of-band on a worker.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the
          first call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay
          (same ``Idempotency-Key`` + same body). No second job runs.

        **What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``
        is the canonical path) until ``status`` is ``COMPLETED`` or
        ``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some
        rows failed — see ``errors[]`` for the per-row detail. The Jobs
        chapter has the full polling guide.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).
        Same key with a *different* body returns
        ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_suppliers_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkSuppliersIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Suppliers
      summary: Public Suppliers List
      description: Cursor-paginated list of suppliers.
      operationId: public_suppliers_list
      parameters:
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicSupplierOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /suppliers/{supplier_id}:
    get:
      tags:
        - Suppliers
      summary: Public Suppliers Get
      description: Fetch one supplier by its partner-supplied ``supplier_id``.
      operationId: public_suppliers_get
      parameters:
        - name: supplier_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^[A-Za-z0-9._\-]{1,255}$
            title: Supplier Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicSupplierOut'
        '404':
          description: Supplier not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /contacts:
    post:
      tags:
        - Contacts
      summary: Public Contacts Bulk Upsert
      description: |-
        Bulk-upsert contacts (async).

        Each row carries its own ``parent_type`` (``customer`` or
        ``supplier``) and ``parent_id`` (the parent's partner-supplied
        identifier — ``customer_id`` for customers, ``supplier_id`` for
        suppliers). A single batch may mix both kinds.

        **Upsert key.** When ``external_id`` is set on a contact it is the
        primary upsert key against the parent. Without it, the worker
        falls back to dedup by email, then ``(name, phone)`` — same
        priority as the internal ERP sync path. Rows whose parent cannot
        be resolved fail at the per-row level and surface in the job's
        ``errors[]``; the rest of the batch is unaffected.

        **Response.**
        - ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the
          first call.
        - ``200 OK`` with the *same* ``JobAck`` on an idempotent replay.

        **Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).
        Same key with a *different* body returns
        ``422 IDEMPOTENCY_KEY_MISMATCH``.
      operationId: public_contacts_bulk_upsert
      parameters:
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            description: Optional client-supplied key for safe retries; <= 255 chars
            title: Idempotency-Key
            type:
              - string
              - 'null'
          description: Optional client-supplied key for safe retries; <= 255 chars
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkContactsIn'
      responses:
        '200':
          description: Idempotent replay — same job returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAck'
        '202':
          description: Accepted; poll status_url for completion
        '400':
          description: Validation failed
        '422':
          description: Idempotency-Key reused with a different body
    get:
      tags:
        - Contacts
      summary: Public Contacts List
      description: Cursor-paginated list of contacts.
      operationId: public_contacts_list
      parameters:
        - name: parent_type
          in: query
          required: false
          schema:
            description: >-
              Filter to contacts on a specific parent kind (customer or
              supplier).
            title: Parent Type
            $ref: '#/components/schemas/ContactParentType'
          description: Filter to contacts on a specific parent kind (customer or supplier).
        - name: parent_id
          in: query
          required: false
          schema:
            description: >-
              Filter to contacts under one specific parent's partner-supplied
              id. Requires ``parent_type`` so the same id under both kinds
              cannot collide.
            title: Parent Id
            maxLength: 255
            pattern: ^[A-Za-z0-9._\-]{1,255}$
            type:
              - string
              - 'null'
          description: >-
            Filter to contacts under one specific parent's partner-supplied id.
            Requires ``parent_type`` so the same id under both kinds cannot
            collide.
        - name: is_active
          in: query
          required: false
          schema:
            description: >-
              Filter by active flag. Omit to return both active and soft-deleted
              contacts.
            title: Is Active
            type:
              - boolean
              - 'null'
          description: >-
            Filter by active flag. Omit to return both active and soft-deleted
            contacts.
        - name: cursor
          in: query
          required: false
          schema:
            description: Opaque cursor from prior page
            title: Cursor
            type:
              - string
              - 'null'
          description: Opaque cursor from prior page
        - name: modified_since
          in: query
          required: false
          schema:
            description: >-
              ISO-8601 lower bound on ``updated_at``. Applied only on the first
              page; the cursor is a strict super-set thereafter.
            title: Modified Since
            format: date-time
            type:
              - string
              - 'null'
          description: >-
            ISO-8601 lower bound on ``updated_at``. Applied only on the first
            page; the cursor is a strict super-set thereafter.
        - name: limit
          in: query
          required: false
          schema:
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicContactOut_'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /contacts/{contact_id}:
    get:
      tags:
        - Contacts
      summary: Public Contacts Get
      description: Fetch one contact by its Mercura id.
      operationId: public_contacts_get
      parameters:
        - name: contact_id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
            description: Mercura contact id
            title: Contact Id
          description: Mercura contact id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicContactOut'
        '404':
          description: Contact not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
    patch:
      tags:
        - Contacts
      summary: Public Contacts Patch
      description: |-
        Partial update of a contact.

        Only fields present in the payload are applied. Reassigning the
        parent is not supported — delete the contact and POST a new one.
      operationId: public_contacts_patch
      parameters:
        - name: contact_id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
            description: Mercura contact id
            title: Contact Id
          description: Mercura contact id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicContactPatch'
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicContactOut'
        '404':
          description: Contact not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
    delete:
      tags:
        - Contacts
      summary: Public Contacts Delete
      description: Soft-delete a contact (``is_active=false``).
      operationId: public_contacts_delete
      parameters:
        - name: contact_id
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
            description: Mercura contact id
            title: Contact Id
          description: Mercura contact id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '204':
          description: Successful Response
        '404':
          description: Contact not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /users:
    get:
      tags:
        - Users
      summary: Public Users List By Email
      description: |-
        Look a user up by email.

        Returns a ``CursorPage`` with the matching user(s), or an empty
        ``data`` array when the email is unknown in your organisation
        (never a 404 — the status code doesn't reveal which emails exist).
        Almost always a single match, but email is not guaranteed unique:
        if more than one user shares an email, all are returned so you can
        disambiguate by ``id``. ``next_cursor`` is always ``null`` — every
        match fits in one page. Use this to resolve a tender's
        ``user_email`` to the user's ``custom_fields`` (e.g. an ERP/SAP id).
      operationId: public_users_list_by_email
      parameters:
        - name: email
          in: query
          required: true
          schema:
            type: string
            description: >-
              Email to look up (case-insensitive, exact match). Typically the
              ``user_email`` returned by ``GET /tenders``. Required.
            format: email
            title: Email
          description: >-
            Email to look up (case-insensitive, exact match). Typically the
            ``user_email`` returned by ``GET /tenders``. Required.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicUserOut_'
        '400':
          description: Missing or malformed email
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /users/{user_id}:
    get:
      tags:
        - Users
      summary: Public Users Get
      description: Fetch one user by their Mercura user id.
      operationId: public_users_get
      parameters:
        - name: user_id
          in: path
          required: true
          schema:
            type: string
            pattern: ^[A-Za-z0-9._\-]{1,255}$
            description: >-
              The Mercura user id — the ``id`` field from a ``GET /users``
              response.
            title: User Id
          description: >-
            The Mercura user id — the ``id`` field from a ``GET /users``
            response.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicUserOut'
        '404':
          description: User not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /jobs/{job_id}:
    get:
      tags:
        - Jobs
      summary: Public Jobs Get
      description: Fetch the status of an async ingestion job.
      operationId: public_jobs_get
      parameters:
        - name: job_id
          in: path
          required: true
          schema:
            type: string
            title: Job Id
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobStatus'
        '404':
          description: Job not found
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
  /statistics/selections:
    get:
      tags:
        - Statistics
      summary: Public Statistics Selections
      description: >-
        Per-request selection-accuracy and processing-time statistics.


        Returns the same raw data as the in-app "Exported requests overview"

        dashboard: for each request in the range, how its positions'

        selections were resolved — ``auto_selected`` (resolved without
        prediction

        ranking), ``prediction_correct`` (chosen article was among Mercura's

        predictions), or ``manual`` (chosen article not predicted). The three
        counts

        sum to ``selection_count``. Each row also carries the request
        ``status``, the true

        handling time ``active_seconds`` (engaged working time from usage
        telemetry;

        tenders only), and the ``first_opened_at`` / ``exported_at`` /

        ``completed_at`` lifecycle timestamps. Divide ``active_seconds`` by

        ``position_count`` / ``selection_count`` for a per-position /
        per-selection

        figure. Rows are newest-first by request creation time. Compute hit-rate
        /

        accuracy percentages client-side from the raw counts. By default only

        exported requests are returned; pass ``include_unexported=true`` to also
        see

        requests still in progress.
      operationId: public_statistics_selections
      parameters:
        - name: start_date
          in: query
          required: true
          schema:
            type: string
            format: date
            description: >-
              Inclusive start of the range (YYYY-MM-DD), anchored to the request
              creation date.
            title: Start Date
          description: >-
            Inclusive start of the range (YYYY-MM-DD), anchored to the request
            creation date.
        - name: end_date
          in: query
          required: true
          schema:
            type: string
            format: date
            description: >-
              Inclusive end of the range (YYYY-MM-DD). The window is capped at
              400 days.
            title: End Date
          description: >-
            Inclusive end of the range (YYYY-MM-DD). The window is capped at 400
            days.
        - name: cursor
          in: query
          required: false
          schema:
            description: >-
              Opaque cursor from a prior page's ``next_cursor``. Keep
              ``start_date`` / ``end_date`` present on every page; the cursor
              resumes strictly after the last row already returned.
            title: Cursor
            type:
              - string
              - 'null'
          description: >-
            Opaque cursor from a prior page's ``next_cursor``. Keep
            ``start_date`` / ``end_date`` present on every page; the cursor
            resumes strictly after the last row already returned.
        - name: limit
          in: query
          required: false
          schema:
            description: Maximum rows per page (default 100).
            title: Limit
            maximum: 500
            minimum: 1
            type:
              - integer
              - 'null'
          description: Maximum rows per page (default 100).
        - name: include_unexported
          in: query
          required: false
          schema:
            type: boolean
            description: >-
              By default only exported requests are returned (the in-app
              dashboard universe). Set to ``true`` to also include requests
              still in progress (never exported), so you can compare how many
              were uploaded vs. successfully exported. Each row's ``status`` and
              ``exported_at`` distinguish the two.
            default: false
            title: Include Unexported
          description: >-
            By default only exported requests are returned (the in-app dashboard
            universe). Set to ``true`` to also include requests still in
            progress (never exported), so you can compare how many were uploaded
            vs. successfully exported. Each row's ``status`` and ``exported_at``
            distinguish the two.
        - name: authorization
          in: header
          required: false
          schema:
            description: Bearer <mrc_live_…> API key
            title: Authorization
            type:
              - string
              - 'null'
          description: Bearer <mrc_live_…> API key
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CursorPage_PublicRequestSelectionStats_'
        '400':
          description: >-
            Validation failed (inverted or too-wide date range, malformed
            cursor)
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
components:
  schemas:
    Address:
      properties:
        kind:
          title: Kind
          description: Address type, e.g. billing | shipping | hq
          type:
            - string
            - 'null'
        street:
          title: Street
          type:
            - string
            - 'null'
        street_number:
          title: Street Number
          description: >-
            House / building number kept separate from street for ERPs that
            store them apart
          type:
            - string
            - 'null'
        postal_code:
          title: Postal Code
          type:
            - string
            - 'null'
        city:
          title: City
          type:
            - string
            - 'null'
        region:
          title: Region
          type:
            - string
            - 'null'
        country:
          title: Country
          description: ISO 3166-1 alpha-2 country code
          type:
            - string
            - 'null'
        is_default:
          title: Is Default
          type:
            - boolean
            - 'null'
      additionalProperties: false
      type: object
      title: Address
      description: Postal address shared across customers and suppliers.
      example:
        city: Düsseldorf
        country: DE
        is_default: true
        kind: billing
        postal_code: '40210'
        region: NRW
        street: Industriestraße
        street_number: '12'
    Body_public_orders_create:
      properties:
        files:
          items:
            type: string
            contentMediaType: application/octet-stream
          type: array
          title: Files
          description: >-
            One or more files, exactly as you would attach them to a forwarded
            email: GAEB, .xlsx, .csv, .docx, .txt, .pdf, images
            (.png/.jpg/.jpeg/.gif/.bmp/.tiff/.webp), and .eml. Max 50 MB per
            file, 100 MB total, 20 files.
        name:
          title: Name
          description: >-
            Display name for the order in Mercura (the email channel uses the
            subject line). Defaults to the primary file's name.
          maxLength: 500
          type:
            - string
            - 'null'
        customer_id:
          title: Customer Id
          description: >-
            Your customer id — the same identifier the customers resource
            exposes as ``customer_id``. Takes precedence over customer_email. An
            unknown id is not an error — the order is created without a
            customer, exactly like an email from an unknown sender.
          maxLength: 255
          type:
            - string
            - 'null'
        customer_email:
          title: Customer Email
          description: >-
            Customer contact email, matched with the same rules the email
            channel applies to the sender address (exact contact match, domain
            pattern, unique inferred domain).
          maxLength: 320
          type:
            - string
            - 'null'
        context:
          title: Context
          description: >-
            Free-text context that helps Mercura process the order — e.g. the
            original email body, delivery notes, or guidance for article
            matching.
          maxLength: 5000
          type:
            - string
            - 'null'
      type: object
      required:
        - files
      title: Body_public_orders_create
    Body_public_tenders_create:
      properties:
        files:
          items:
            type: string
            contentMediaType: application/octet-stream
          type: array
          title: Files
          description: >-
            One or more files, exactly as you would attach them to a forwarded
            email: GAEB
            (.d81/.d83/.d94/.p81/.p83/.p93/.p94/.x81/.x83/.x93/.x94/.onlv),
            .xlsx, .csv, .docx, .txt, .pdf, images
            (.png/.jpg/.jpeg/.gif/.bmp/.tiff/.webp), and .eml. Max 50 MB per
            file, 100 MB total, 20 files.
        name:
          title: Name
          description: >-
            Display name for the tender in Mercura (the email channel uses the
            subject line). Defaults to the primary file's name.
          maxLength: 500
          type:
            - string
            - 'null'
        customer_id:
          title: Customer Id
          description: >-
            Your customer id — the same identifier the customers resource
            exposes as ``customer_id``. Takes precedence over customer_email. An
            unknown id is not an error — the tender is created without a
            customer, exactly like an email from an unknown sender.
          maxLength: 255
          type:
            - string
            - 'null'
        customer_email:
          title: Customer Email
          description: >-
            Customer contact email, matched with the same rules the email
            channel applies to the sender address (exact contact match, domain
            pattern, unique inferred domain).
          maxLength: 320
          type:
            - string
            - 'null'
        context:
          title: Context
          description: >-
            Free-text context that helps Mercura process the tender — e.g. the
            original email body, the kind of request, delivery notes, or
            guidance for article matching.
          maxLength: 5000
          type:
            - string
            - 'null'
        branch:
          title: Branch
          description: >-
            Name of the Mercura branch (Niederlassung) this tender belongs to —
            exactly as it is configured in Mercura, matched case-insensitively.
            Routes the tender to the team that works that branch. An unknown
            name is a ``400``: an unrouted tender is worse than a rejected
            upload.
          maxLength: 255
          type:
            - string
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            JSON object of organisation-defined custom fields to stamp on the
            tender, keyed by the field's display **label** — e.g. ``{"Case
            Number": "00012345"}``. The same labels the read side emits in
            ``request_custom_fields``, so a value set here comes back unchanged
            on ``GET /tenders/{tender_id}``. Unknown labels are a ``400``.
          type:
            - string
            - 'null'
      type: object
      required:
        - files
      title: Body_public_tenders_create
    BulkAccessoriesIn:
      properties:
        accessories:
          items:
            $ref: '#/components/schemas/PublicAccessoryIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Accessories
      additionalProperties: false
      type: object
      required:
        - accessories
      title: BulkAccessoriesIn
      description: Body of ``POST /accessories``.
    BulkAlternativesIn:
      properties:
        alternatives:
          items:
            $ref: '#/components/schemas/PublicAlternativeIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Alternatives
      additionalProperties: false
      type: object
      required:
        - alternatives
      title: BulkAlternativesIn
      description: Body of ``POST /alternatives``.
    BulkArticlesIn:
      properties:
        articles:
          items:
            $ref: '#/components/schemas/PublicArticleIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Articles
      additionalProperties: false
      type: object
      required:
        - articles
      title: BulkArticlesIn
      description: Body of ``POST /articles``.
      example:
        articles:
          - article_number: LEU-1500-50-840
            category: Feuchtraumleuchte
            custom_attributes:
              lichtfarbe: 4000K
              lichtstrom_lm: '6500'
              schutzart: IP65
            delivery_quantity: '1'
            description: >-
              LED-Feuchtraumwannenleuchte, 1500 mm, 50 W, 6500 lm, 4000 K
              neutralweiß, Schutzart IP65, Polycarbonat-Gehäuse, inkl.
              Durchgangsverdrahtung.
            ean_number: '4260001234562'
            etim_features:
              - etim_code: EF000008
                human_label: Nennspannung
                type: number
                value_number: 230
              - etim_code: EF000131
                human_label: Mit Anschlussleitung
                type: boolean
                value_boolean: true
            list_price: '89.90'
            manufacturer: Lumaris
            manufacturer_article_number: LM-DP1500-50
            name: LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65
            series: AquaLine PRO
            tags:
              - LED
              - Eigenmarke
            unit: Stk
          - article_number: LEU-1200-30-840
            category: Feuchtraumleuchte
            ean_number: '4260001234579'
            etim_features:
              - etim_code: EF000056
                human_label: Leistungsbereich
                type: range
                value_range:
                  gte: 18
                  lte: 30
            list_price: '69.90'
            manufacturer: Lumaris
            manufacturer_article_number: LM-DP1200-30
            name: LED-Feuchtraumleuchte 1200 mm 30 W 4000 K IP65
            series: AquaLine PRO
            tags:
              - LED
            unit: Stk
    BulkContactsIn:
      properties:
        contacts:
          items:
            $ref: '#/components/schemas/PublicContactIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Contacts
      additionalProperties: false
      type: object
      required:
        - contacts
      title: BulkContactsIn
      description: Body of ``POST /contacts``.
      example:
        contacts:
          - email: t.weber@mustermann-elektro.example
            external_id: ASP-00815
            is_default: true
            name: Thomas Weber
            parent_id: K-10042
            parent_type: customer
            phone: +49 211 5551020
    BulkCustomersIn:
      properties:
        customers:
          items:
            $ref: '#/components/schemas/PublicCustomerIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Customers
      additionalProperties: false
      type: object
      required:
        - customers
      title: BulkCustomersIn
      description: Body of ``POST /customers``.
      example:
        customers:
          - addresses:
              - city: Düsseldorf
                country: DE
                is_default: true
                kind: billing
                postal_code: '40210'
                region: NRW
                street: Industriestraße
                street_number: '12'
            custom_fields:
              9b1f3c7a-2d84-4e11-9f0a-6c2e5b7d1a34: Elektrogroßhandel
            customer_id: K-10042
            emails:
              - einkauf@mustermann-elektro.example
            name: Elektro Mustermann GmbH
            vat_id: DE123456789
    BulkSuccessorsIn:
      properties:
        successors:
          items:
            $ref: '#/components/schemas/PublicSuccessorIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Successors
      additionalProperties: false
      type: object
      required:
        - successors
      title: BulkSuccessorsIn
      description: Body of ``POST /successors``.
    BulkSuppliersIn:
      properties:
        suppliers:
          items:
            $ref: '#/components/schemas/PublicSupplierIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Suppliers
      additionalProperties: false
      type: object
      required:
        - suppliers
      title: BulkSuppliersIn
      description: Body of ``POST /suppliers``.
      example:
        suppliers:
          - addresses:
              - city: Lüdenscheid
                country: DE
                is_default: true
                kind: hq
                postal_code: '58507'
                region: NRW
                street: Lichtstraße
                street_number: '8'
            custom_fields:
              7c4e0a91-5b23-4d6f-8e10-3a9c1f2b6d05: Leuchten
            emails:
              - vertrieb@lumaris.example
            name: Lumaris Leuchten GmbH
            supplier_id: L-2001
    BulkUnitConversionsIn:
      properties:
        unit_conversions:
          items:
            $ref: '#/components/schemas/PublicUnitConversionIn'
          type: array
          maxItems: 100000
          minItems: 1
          title: Unit Conversions
      additionalProperties: false
      type: object
      required:
        - unit_conversions
      title: BulkUnitConversionsIn
      description: Body of ``POST /unit-conversions``.
    ContactParentType:
      type: string
      enum:
        - customer
        - supplier
      title: ContactParentType
      description: Which kind of business partner a contact is attached to.
    CursorPage_PublicAccessoryOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicAccessoryOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicAccessoryOut]
    CursorPage_PublicAlternativeOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicAlternativeOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicAlternativeOut]
    CursorPage_PublicArticleOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicArticleOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicArticleOut]
    CursorPage_PublicContactOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicContactOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicContactOut]
    CursorPage_PublicCustomerOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicCustomerOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicCustomerOut]
    CursorPage_PublicOrderOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicOrderOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicOrderOut]
    CursorPage_PublicProjectOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicProjectOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicProjectOut]
    CursorPage_PublicRequestSelectionStats_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicRequestSelectionStats'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicRequestSelectionStats]
    CursorPage_PublicSuccessorOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicSuccessorOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicSuccessorOut]
    CursorPage_PublicSupplierOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicSupplierOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicSupplierOut]
    CursorPage_PublicSupplierRequestOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicSupplierRequestOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicSupplierRequestOut]
    CursorPage_PublicTenderOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicTenderOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicTenderOut]
    CursorPage_PublicUnitConversionOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicUnitConversionOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicUnitConversionOut]
    CursorPage_PublicUserOut_:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicUserOut'
          type: array
          title: Data
        next_cursor:
          title: Next Cursor
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - data
      title: CursorPage[PublicUserOut]
    HTTPValidationError:
      properties:
        detail:
          items:
            $ref: '#/components/schemas/ValidationError'
          type: array
          title: Detail
      type: object
      title: HTTPValidationError
    JobAck:
      properties:
        job_id:
          type: string
          title: Job Id
        status_url:
          type: string
          title: Status Url
          description: Relative URL to poll for this job's status
      additionalProperties: false
      type: object
      required:
        - job_id
        - status_url
      title: JobAck
      description: |-
        Response body for bulk POSTs.

        Returned with ``202 Accepted`` on first-time creation and
        ``200 OK`` on idempotent replay (same ``(org, Idempotency-Key)``,
        same body hash). Clients poll ``GET /jobs/{job_id}`` for
        terminal status.
      example:
        job_id: '4242'
        status_url: /api/public/v1/jobs/4242
    JobRowError:
      properties:
        row_number:
          type: integer
          title: Row Number
        identifier:
          title: Identifier
          type:
            - string
            - 'null'
        error_message:
          type: string
          title: Error Message
      additionalProperties: false
      type: object
      required:
        - row_number
        - error_message
      title: JobRowError
      description: One row-level error emitted by an entity processor.
    JobStatus:
      properties:
        job_id:
          type: string
          title: Job Id
        entity:
          type: string
          title: Entity
        status:
          $ref: '#/components/schemas/JobStatusValue'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
        total_rows:
          type: integer
          title: Total Rows
          default: 0
        created_count:
          type: integer
          title: Created Count
          default: 0
        updated_count:
          type: integer
          title: Updated Count
          default: 0
        skipped_count:
          type: integer
          title: Skipped Count
          default: 0
        deleted_count:
          type: integer
          title: Deleted Count
          default: 0
        error_count:
          type: integer
          title: Error Count
          default: 0
        errors:
          items:
            $ref: '#/components/schemas/JobRowError'
          type: array
          title: Errors
        result:
          title: Result
          description: >-
            Job-type-specific success payload; branch on ``entity`` for the
            shape. For ``REQUESTS`` jobs it carries ``{request_id,
            request_status, position_count}`` once the job completes. Other job
            types return ``null``.
          additionalProperties: true
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - job_id
        - entity
        - status
        - created_at
        - updated_at
      title: JobStatus
      description: Status envelope returned by ``GET /jobs/{job_id}``.
      example:
        created_at: '2026-07-14T22:00:00Z'
        created_count: 320
        deleted_count: 0
        entity: ARTICLES
        error_count: 5
        errors:
          - error_message: missing list_price
            identifier: LEU-0900-18-830
            row_number: 137
        job_id: '4242'
        skipped_count: 0
        status: COMPLETED
        total_rows: 12000
        updated_at: '2026-07-14T22:00:42Z'
        updated_count: 11675
    JobStatusValue:
      type: string
      enum:
        - PENDING
        - RUNNING
        - COMPLETED
        - FAILED
      title: JobStatusValue
      description: |-
        Lifecycle states for an async ingestion job.

        Collapsed from the internal ``ImportRunStatus`` (which carries
        more granular states like ``PARTIALLY_COMPLETED`` and
        ``VALIDATION_FAILED``) — see ``jobs/service.py:_STATUS_MAP`` for
        the translation table. The public surface gives partners the four
        states they actually need to branch on.
    PublicAccessoryIn:
      properties:
        source_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Source Article Number
          description: Article the accessory belongs to.
        accessory_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Accessory Article Number
          description: The accessory article.
        multiplier:
          title: Multiplier
          description: Quantity of the accessory required per unit of the source article.
          exclusiveMinimum: 0
          type:
            - number
            - 'null'
        order:
          title: Order
          description: Display order among a source article's accessories.
          minimum: 0
          type:
            - integer
            - 'null'
        is_mandatory:
          type: boolean
          title: Is Mandatory
          description: >-
            Mark a mandatory accessory (a required part) rather than an optional
            add-on.
          default: false
      additionalProperties: false
      type: object
      required:
        - source_article_number
        - accessory_article_number
      title: PublicAccessoryIn
      description: |-
        One accessory relationship in a bulk-write payload.

        Both article numbers must already exist for your organisation; a row
        whose source or accessory article is unknown is skipped (counted in
        the job's ``skipped_count``), not an error.
    PublicAccessoryOut:
      properties:
        id:
          type: integer
          title: Id
          description: Internal relationship id.
        source_article_number:
          type: string
          title: Source Article Number
        accessory_article_number:
          type: string
          title: Accessory Article Number
        multiplier:
          title: Multiplier
          type:
            - number
            - 'null'
        order:
          title: Order
          type:
            - integer
            - 'null'
        is_mandatory:
          type: boolean
          title: Is Mandatory
          default: false
        source_type:
          type: string
          title: Source Type
          description: 'How the relation was created: MANUAL or MATCHING_EXTRACTED.'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - source_article_number
        - accessory_article_number
        - source_type
        - created_at
        - updated_at
      title: PublicAccessoryOut
      description: One accessory relationship in a list response.
    PublicAlternativeIn:
      properties:
        source_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Source Article Number
          description: Article the alternative substitutes for.
        alternative_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Alternative Article Number
          description: The substitute article.
        order:
          title: Order
          description: Display order among a source article's alternatives.
          minimum: 0
          type:
            - integer
            - 'null'
      additionalProperties: false
      type: object
      required:
        - source_article_number
        - alternative_article_number
      title: PublicAlternativeIn
      description: |-
        One alternative relationship in a bulk-write payload.

        Both article numbers must already exist for your organisation; a row
        whose source or alternative article is unknown is skipped (counted in
        the job's ``skipped_count``), not an error.
    PublicAlternativeOut:
      properties:
        id:
          type: integer
          title: Id
          description: Internal relationship id.
        source_article_number:
          type: string
          title: Source Article Number
        alternative_article_number:
          type: string
          title: Alternative Article Number
        order:
          title: Order
          type:
            - integer
            - 'null'
        source_type:
          type: string
          title: Source Type
          description: 'How the relation was created: MANUAL or MATCHING_EXTRACTED.'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - source_article_number
        - alternative_article_number
        - source_type
        - created_at
        - updated_at
      title: PublicAlternativeOut
      description: One alternative relationship in a list response.
    PublicArticleIn:
      properties:
        article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Article Number
        name:
          type: string
          maxLength: 2000
          minLength: 1
          title: Name
        description:
          title: Description
          type:
            - string
            - 'null'
        manufacturer:
          title: Manufacturer
          maxLength: 255
          type:
            - string
            - 'null'
        manufacturer_article_number:
          title: Manufacturer Article Number
          maxLength: 255
          type:
            - string
            - 'null'
        ean_number:
          title: Ean Number
          maxLength: 50
          type:
            - string
            - 'null'
        series:
          title: Series
          maxLength: 255
          type:
            - string
            - 'null'
        category:
          title: Category
          maxLength: 255
          type:
            - string
            - 'null'
        unit:
          title: Unit
          maxLength: 50
          type:
            - string
            - 'null'
        list_price:
          anyOf:
            - type: number
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: List Price
        delivery_quantity:
          anyOf:
            - type: number
              exclusiveMinimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Delivery Quantity
          description: Delivery multiple in the article's unit
        custom_attributes:
          title: Custom Attributes
          additionalProperties: true
          type:
            - object
            - 'null'
        tags:
          title: Tags
          description: >-
            Tag names to assign, e.g. ["Pumpen", "Eigenmarke"]. Names must
            already exist for your organisation (create them in the Mercura
            admin UI under Settings → Article tags); an unknown name fails the
            whole batch with a clear error. Replaces the article's current tags
            when provided. Omit to leave them unchanged; send [] to clear them.
          items:
            type: string
          maxItems: 200
          type:
            - array
            - 'null'
        etim_features:
          title: Etim Features
          description: >-
            ETIM classification features. Replaces the article's full feature
            list when provided; omit to leave it unchanged.
          items:
            $ref: '#/components/schemas/PublicEtimFeature'
          maxItems: 2000
          type:
            - array
            - 'null'
        is_diverse:
          title: Is Diverse
          type:
            - boolean
            - 'null'
        is_legacy_article:
          title: Is Legacy Article
          type:
            - boolean
            - 'null'
        is_deleted:
          title: Is Deleted
          description: >-
            Soft-delete flag. When true the article is hidden from all discovery
            surfaces; clear to restore.
          type:
            - boolean
            - 'null'
      additionalProperties: false
      type: object
      required:
        - article_number
        - name
      title: PublicArticleIn
      description: |-
        One article in a bulk-write payload.

        ``article_number`` is the partner's stable identifier — used both
        as the upsert key in the article DB and as the public-API id
        returned in lists / ``GET /articles/{article_number}``. Required
        so retries converge on the same row instead of duplicating.
    PublicArticleOut:
      properties:
        id:
          type: string
          title: Id
          description: Public id; equals the article's article_number
        article_number:
          type: string
          title: Article Number
        name:
          type: string
          title: Name
        description:
          title: Description
          type:
            - string
            - 'null'
        manufacturer:
          title: Manufacturer
          type:
            - string
            - 'null'
        manufacturer_article_number:
          title: Manufacturer Article Number
          type:
            - string
            - 'null'
        ean_number:
          title: Ean Number
          type:
            - string
            - 'null'
        series:
          title: Series
          type:
            - string
            - 'null'
        category:
          title: Category
          type:
            - string
            - 'null'
        unit:
          title: Unit
          type:
            - string
            - 'null'
        list_price:
          title: List Price
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        delivery_quantity:
          title: Delivery Quantity
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        custom_attributes:
          title: Custom Attributes
          additionalProperties: true
          type:
            - object
            - 'null'
        tags:
          items:
            type: string
          type: array
          title: Tags
          description: Tag names assigned to the article
        etim_features:
          items:
            $ref: '#/components/schemas/PublicEtimFeature'
          type: array
          title: Etim Features
          description: ETIM classification features stored for the article
        is_diverse:
          type: boolean
          title: Is Diverse
          default: false
        is_legacy_article:
          type: boolean
          title: Is Legacy Article
          default: false
        is_deleted:
          type: boolean
          title: Is Deleted
          default: false
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - article_number
        - name
        - created_at
        - updated_at
      title: PublicArticleOut
      description: |-
        One article in a list / get-by-id response.

        Subset of the internal ``Article`` ORM row; intentionally omits
        internal frequency counters and sync bookkeeping. ``tags`` are
        returned as names (resolved from the stored ``tag_ids``); a tag id
        with no matching tag is dropped.
      example:
        article_number: LEU-1500-50-840
        category: Feuchtraumleuchte
        created_at: '2026-01-15T08:30:00Z'
        custom_attributes:
          lichtfarbe: 4000K
          lichtstrom_lm: '6500'
          schutzart: IP65
        delivery_quantity: '1'
        description: >-
          LED-Feuchtraumwannenleuchte, 1500 mm, 50 W, 6500 lm, 4000 K
          neutralweiß, Schutzart IP65, Polycarbonat-Gehäuse, inkl.
          Durchgangsverdrahtung.
        ean_number: '4260001234562'
        etim_features:
          - etim_code: EF000008
            human_label: Nennspannung
            type: number
            value_number: 230
          - etim_code: EF000131
            human_label: Mit Anschlussleitung
            type: boolean
            value_boolean: true
        id: LEU-1500-50-840
        is_deleted: false
        is_diverse: false
        is_legacy_article: false
        list_price: '89.90'
        manufacturer: Lumaris
        manufacturer_article_number: LM-DP1500-50
        name: LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65
        series: AquaLine PRO
        tags:
          - LED
          - Eigenmarke
        unit: Stk
        updated_at: '2026-07-14T09:12:44Z'
    PublicCandidateStatus:
      type: string
      enum:
        - PENDING
        - ACCEPTED
        - DECLINED
      title: PublicCandidateStatus
      description: >-
        Acceptance state of one offered line, from
        ``CandidateSelection.status``.
    PublicContactIn:
      properties:
        parent_type:
          $ref: '#/components/schemas/ContactParentType'
        parent_id:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Parent Id
        external_id:
          title: External Id
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-:]{1,255}$
          type:
            - string
            - 'null'
        name:
          title: Name
          maxLength: 500
          type:
            - string
            - 'null'
        email:
          title: Email
          format: email
          type:
            - string
            - 'null'
        phone:
          title: Phone
          maxLength: 100
          type:
            - string
            - 'null'
        is_default:
          title: Is Default
          type:
            - boolean
            - 'null'
      additionalProperties: false
      type: object
      required:
        - parent_type
        - parent_id
      title: PublicContactIn
      description: |-
        One contact in a bulk-write payload.

        ``parent_type`` + ``parent_id`` identify the owning business
        partner. ``external_id`` is the contact's own stable identifier
        from the source ERP (optional) — when present it is the primary
        upsert key. Without it the worker falls back to dedup by email,
        then by ``(name, phone)``.
    PublicContactOut:
      properties:
        id:
          type: integer
          title: Id
        parent_type:
          $ref: '#/components/schemas/ContactParentType'
        parent_id:
          title: Parent Id
          description: >-
            Parent's partner-supplied id (customer_id for customers, supplier_id
            for suppliers)
          type:
            - string
            - 'null'
        external_id:
          title: External Id
          type:
            - string
            - 'null'
        name:
          title: Name
          type:
            - string
            - 'null'
        email:
          title: Email
          type:
            - string
            - 'null'
        phone:
          title: Phone
          type:
            - string
            - 'null'
        is_active:
          type: boolean
          title: Is Active
        is_default:
          type: boolean
          title: Is Default
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - parent_type
        - is_active
        - is_default
        - created_at
        - updated_at
      title: PublicContactOut
      description: One contact in a list / get-by-id response.
      example:
        created_at: '2026-02-03T10:16:00Z'
        email: t.weber@mustermann-elektro.example
        external_id: ASP-00815
        id: 5567
        is_active: true
        is_default: true
        name: Thomas Weber
        parent_id: K-10042
        parent_type: customer
        phone: +49 211 5551020
        updated_at: '2026-07-05T11:03:00Z'
    PublicContactPatch:
      properties:
        name:
          title: Name
          maxLength: 500
          type:
            - string
            - 'null'
        email:
          title: Email
          format: email
          type:
            - string
            - 'null'
        phone:
          title: Phone
          maxLength: 100
          type:
            - string
            - 'null'
        external_id:
          title: External Id
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-:]{1,255}$
          type:
            - string
            - 'null'
        is_active:
          title: Is Active
          type:
            - boolean
            - 'null'
        is_default:
          title: Is Default
          type:
            - boolean
            - 'null'
      additionalProperties: false
      type: object
      title: PublicContactPatch
      description: |-
        Body of ``PATCH /contacts/{id}``.

        Every field is optional; only fields present in the payload are
        applied. Reassigning the parent is not supported via PATCH —
        delete the contact and POST a new one instead.
      example:
        is_default: true
        phone: +49 211 5551099
    PublicCustomerIn:
      properties:
        customer_id:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Customer Id
        name:
          type: string
          maxLength: 500
          minLength: 1
          title: Name
        vat_id:
          title: Vat Id
          maxLength: 50
          type:
            - string
            - 'null'
        emails:
          title: Emails
          items:
            type: string
            format: email
          type:
            - array
            - 'null'
        addresses:
          title: Addresses
          items:
            $ref: '#/components/schemas/Address'
          type:
            - array
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            Custom fields for the customer. On the **write** path these are
            stored as supplied (internal column-id keyed) and are NOT
            label-remapped — deliberately asymmetric with the label-keyed read
            responses in v1.9.0 (Wave 1 partners only read).
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - customer_id
        - name
      title: PublicCustomerIn
      description: |-
        One customer in a bulk-write payload.

        ``customer_id`` is the partner's stable identifier — used both as
        the upsert key and as the public-API identifier returned in
        ``GET /customers/{customer_id}``. Required so retries converge on
        the same row instead of creating duplicates.
    PublicCustomerOut:
      properties:
        id:
          title: Id
          description: Public id; equals the customer's customer_id when present
          type:
            - string
            - 'null'
        customer_id:
          title: Customer Id
          type:
            - string
            - 'null'
        name:
          type: string
          title: Name
        vat_id:
          title: Vat Id
          type:
            - string
            - 'null'
        emails:
          items:
            type: string
          type: array
          title: Emails
        address:
          $ref: '#/components/schemas/Address'
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom fields, keyed by the field's display
            **label** (v1.9.0; previously keyed by the internal column UUID).
            Columns with no active definition are omitted; null when the
            customer carries none. On duplicate labels the field defined first
            (by display order, then creation time) wins.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - name
        - created_at
        - updated_at
      title: PublicCustomerOut
      description: |-
        One customer in a list / get-by-id response.

        ``address`` is singular (not plural) — reflects today's persistence
        reality. When Wave 2 lands multi-address support the field name
        becomes ``addresses`` and ``address`` is kept as a deprecated
        alias for one minor cycle (additive change per the SemVer policy
        in README → Design decisions → SemVer versioning policy).
      example:
        address:
          city: Düsseldorf
          country: DE
          is_default: true
          kind: billing
          postal_code: '40210'
          region: NRW
          street: Industriestraße
          street_number: '12'
        created_at: '2026-02-03T10:15:00Z'
        custom_fields:
          Kundengruppe: Elektrogroßhandel
          Zahlungsziel: 30 Tage netto
        customer_id: K-10042
        emails:
          - einkauf@mustermann-elektro.example
        id: K-10042
        name: Elektro Mustermann GmbH
        updated_at: '2026-07-12T14:22:10Z'
        vat_id: DE123456789
    PublicEtimFeature:
      properties:
        etim_code:
          type: string
          maxLength: 64
          minLength: 1
          title: Etim Code
          description: ETIM feature code, e.g. 'EF000007'
        human_label:
          type: string
          maxLength: 255
          minLength: 1
          title: Human Label
          description: Human-readable feature label, e.g. 'Nominal voltage'
        type:
          type: string
          enum:
            - string
            - number
            - boolean
            - range
          title: Type
          description: Value-type discriminator
        value_string:
          title: Value String
          description: Value when type is 'string'
          type:
            - string
            - 'null'
        value_number:
          title: Value Number
          description: Value when type is 'number'
          type:
            - number
            - 'null'
        value_boolean:
          title: Value Boolean
          description: Value when type is 'boolean'
          type:
            - boolean
            - 'null'
        value_range:
          description: Value when type is 'range'
          $ref: '#/components/schemas/PublicEtimFeatureRange'
        translations:
          title: Translations
          description: Localized labels and textual values keyed by lowercase language code
          additionalProperties:
            $ref: '#/components/schemas/PublicEtimFeatureTranslation'
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - etim_code
        - human_label
        - type
      title: PublicEtimFeature
      description: |-
        One ETIM classification feature on an article.

        Mirrors the canonical shape Mercura stores and matches against: the
        feature ``etim_code``, a human-readable ``human_label``, a ``type``
        discriminator, and the one ``value_*`` field that matches that type
        (the others stay null). For ``type: "number"`` send ``value_number``,
        for ``"string"`` send ``value_string``, and so on.

        Richer ETIM data sharpens Mercura's matching of incoming LV positions
        against your catalogue.
    PublicEtimFeatureRange:
      properties:
        gte:
          type: number
          title: Gte
          description: Lower bound (inclusive)
        lte:
          type: number
          title: Lte
          description: Upper bound (inclusive)
      additionalProperties: false
      type: object
      required:
        - gte
        - lte
      title: PublicEtimFeatureRange
      description: Numeric range value for an ETIM feature whose ``type`` is ``"range"``.
    PublicEtimFeatureTranslation:
      properties:
        human_label:
          title: Human Label
          maxLength: 255
          type:
            - string
            - 'null'
        value_string:
          title: Value String
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      title: PublicEtimFeatureTranslation
      description: Localized display text for one ETIM feature.
    PublicOfferAddress:
      properties:
        street:
          title: Street
          type:
            - string
            - 'null'
        street_number:
          title: Street Number
          type:
            - string
            - 'null'
        postal_code:
          title: Postal Code
          type:
            - string
            - 'null'
        city:
          title: City
          type:
            - string
            - 'null'
        country:
          title: Country
          description: ISO 3166-1 alpha-2 country code
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      title: PublicOfferAddress
      description: |-
        Postal address attached to a customer business partner.

        Distinct from ``schemas.common.Address`` — no ``kind`` / ``is_default``
        fields, because an offer's customer address is the single resolved
        address that went on the document. All fields nullable to match the
        underlying ``address_table`` (street can be NULL on minimally
        populated rows). No ``region`` field — the internal address model
        doesn't carry one today; adding it later is MINOR additive.
    PublicOfferCustomer:
      properties:
        customer_id:
          title: Customer Id
          description: >-
            Partner-side customer id — the same value the customers resource
            exposes as ``customer_id`` (stored internally as
            ``BusinessPartner.external_id``).
          type:
            - string
            - 'null'
        name:
          type: string
          title: Name
        address:
          $ref: '#/components/schemas/PublicOfferAddress'
        contact_person:
          title: Contact Person
          description: Free-text contact person on the request, e.g. 'Hans Müller'.
          type:
            - string
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom fields for this customer, keyed by the
            field's display **label** (not the internal column id). Null when
            the customer carries no custom fields.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - name
      title: PublicOfferCustomer
      description: |-
        Customer the offer was made to. Sourced from ``Request.customer_bp``.

        On the wire the customer's partner-id is named ``customer_id`` — the
        same field name the customers resource uses, so a partner ingesting
        an offer can resolve the reference via ``GET /customers/{customer_id}``.

        The Python attribute is still ``external_id`` to keep the shared
        projection (``features/customer_offers/offer_projection.py``)
        indifferent to the rename. ``serialize_by_alias=True`` plus the
        field's ``serialization_alias`` make every JSON path (FastAPI
        response, ``model_dump(mode='json')`` in the webhook emitter) emit
        ``customer_id``.
    PublicOfferPosition:
      properties:
        position_number:
          type: string
          title: Position Number
          description: Display number (request-scoped, e.g. '01.10').
        article_number:
          title: Article Number
          type:
            - string
            - 'null'
        description:
          type: string
          title: Description
          default: ''
        quantity:
          title: Quantity
          type:
            - number
            - 'null'
        unit:
          title: Unit
          type:
            - string
            - 'null'
        list_price:
          title: List Price
          description: Per-unit list price (gross of discounts), EUR.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        net_price:
          title: Net Price
          description: >-
            Per-unit net price after position-level discounts, EUR. Equivalent
            to ``Selection.net_price_ea`` in the internal model.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        line_net:
          title: Line Net
          description: '``net_price * quantity``, rounded to 2 dp. Same math as the PDF.'
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        line_gross:
          title: Line Gross
          description: '``list_price * quantity``, rounded to 2 dp. Same math as the PDF.'
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        is_alternative:
          type: boolean
          title: Is Alternative
          description: >-
            If true, this row is an alternative selection — excluded from
            totals.
          default: false
      additionalProperties: false
      type: object
      required:
        - position_number
      title: PublicOfferPosition
      description: |-
        One offer line item.

        Built per non-deleted ``Selection`` (not per ``Position``) — a
        position with multiple selections produces multiple rows, exactly
        matching the PDF exporter. Alternative selections (carrying the
        ``ALTERNATIVE`` label) are still emitted as rows but flagged with
        ``is_alternative = true`` and are excluded from the totals.

        ``article_number`` is the partner-facing identifier (the same value
        a partner would use against ``GET /articles/{article_number}``). It
        comes from the article record when the selection is article-backed,
        or from ``selection.article_number`` for free-text positions.

        ``description`` is the line description as it appeared on the PDF.
        For article-backed selections this is the article's own description;
        for free-text it's the selection's text. Partners with their own
        article catalogue can ignore this and dereference ``article_number``.
    PublicOfferProject:
      properties:
        object_number:
          title: Object Number
          type:
            - string
            - 'null'
        name:
          title: Name
          type:
            - string
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom fields for the project, keyed by the
            field's display **label**. Null when the project carries none.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      title: PublicOfferProject
      description: Project / object the offer relates to. Sourced from ``Request.project``.
    PublicOfferTotals:
      properties:
        positions_subtotal:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Positions Subtotal
          description: Sum of ``line_net`` across non-alternative positions, EUR.
        additional_discount_percentage:
          title: Additional Discount Percentage
          description: Header-level discount applied on top of position prices, 0–100.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        net_total:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Net Total
          description: Final net (after additional discount), EUR.
        gross_total:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Gross Total
          description: Sum of ``line_gross`` across non-alternative positions, EUR.
      additionalProperties: false
      type: object
      required:
        - positions_subtotal
        - net_total
        - gross_total
      title: PublicOfferTotals
      description: |-
        Aggregate amounts for the offer, all in EUR.

        Mirrors ``pdf_offer_service._build_real_data``: ``positions_subtotal``
        is the sum of ``line_net`` across non-alternative positions;
        ``net_total`` prefers ``Request.net_total`` if set (header-level
        discount applied), otherwise equals ``positions_subtotal``;
        ``gross_total`` prefers ``Request.gross_total`` if set, otherwise
        is the sum of ``line_gross`` across non-alternative positions.
    PublicOfferedLine:
      properties:
        id:
          type: string
          title: Id
          description: >-
            Opaque handle for this offered line (internal candidate-selection
            id).
        position_id:
          title: Position Id
          description: >-
            The position this line answers. For a tender-scoped supplier request
            it matches the position id in ``GET /tenders/{tender_id}``; for a
            project-scoped one it is an internal reference with no public
            resolution endpoint today. Null when the line is not mapped to a
            position.
          type:
            - string
            - 'null'
        item_type:
          $ref: '#/components/schemas/PublicOfferedLineType'
        candidate_status:
          $ref: '#/components/schemas/PublicCandidateStatus'
          description: Acceptance state of this offered line.
        selected:
          type: boolean
          title: Selected
          description: >-
            True when this line was accepted into the final tender
            (candidate_status ACCEPTED).
        article_number:
          title: Article Number
          description: Article number the supplier quoted, when any.
          type:
            - string
            - 'null'
        manufacturer_article_number:
          title: Manufacturer Article Number
          description: Manufacturer article number, when any.
          type:
            - string
            - 'null'
        description:
          title: Description
          description: Supplier's line text, when provided.
          type:
            - string
            - 'null'
        quantity:
          title: Quantity
          type:
            - number
            - 'null'
        unit:
          title: Unit
          type:
            - string
            - 'null'
        unit_price:
          title: Unit Price
          description: Supplier's offered price per unit, EUR.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        line_total:
          title: Line Total
          description: Supplier's offered line total, EUR.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - id
        - item_type
        - candidate_status
        - selected
      title: PublicOfferedLine
      description: One line the supplier quoted back — one per candidate selection.
    PublicOfferedLineType:
      type: string
      enum:
        - ARTICLE
        - TEXT_FIELD
        - DIVERSE
      title: PublicOfferedLineType
      description: What an offered line is, from ``CandidateSelection.item_type``.
    PublicOrderAckStatus:
      type: string
      enum:
        - SUCCESS
        - FAILED
      title: PublicOrderAckStatus
      description: >-
        Outcome a partner reports after importing an order into their ERP.


        Its own type (parallel to ``PublicTenderAckStatus`` / the deprecated

        ``PublicOfferAckStatus``) so the orders surface stays self-contained.

        Translated from the internal ``RequestAckStatus`` via

        ``orders.service._ORDER_ACK_STATUS_MAP`` — a test asserts the map covers

        every internal value, so a new internal state is a loud failure rather
        than

        a silent passthrough. A *separate axis* from ``PublicOrderStatus``: that
        is

        Mercura-side workflow, this is the partner's import result.
    PublicOrderAcknowledgement:
      properties:
        status:
          $ref: '#/components/schemas/PublicOrderAckStatus'
        external_id:
          title: External Id
          description: Partner-side ERP id of the created order / record, when provided.
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. On ``FAILED`` this is the failure
            reason (required); on ``SUCCESS`` an optional note (e.g. queue id,
            warehouse, operator comment).
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload the partner echoed back for
            traceability.
          additionalProperties: true
          type:
            - object
            - 'null'
        acknowledged_at:
          type: string
          format: date-time
          title: Acknowledged At
          description: When the partner reported this outcome (server receipt time).
      additionalProperties: false
      type: object
      required:
        - status
        - acknowledged_at
      title: PublicOrderAcknowledgement
      description: >-
        The partner's last reported ERP import outcome for an order.


        Used in two places with the same shape:

        - as the ``acknowledgement`` field on ``PublicOrderOut`` — ``null``
        until
          the partner has POSTed an acknowledgement;
        - as the standalone response body of
          ``POST /orders/{order_id}/acknowledgements`` — the recorded receipt, so
          partners can confirm what Mercura persisted (resolved status, sticky-aware
          ``external_id``, server-side ``acknowledged_at``) without refetching the
          whole order.

        ``status`` / ``message`` / ``metadata`` reflect the most recent

        acknowledgement; ``external_id`` is kept once provided
        (last-provided-wins)

        and mirrors the order's ``erp_offer_id``.
      example:
        acknowledged_at: '2026-07-08T09:06:00Z'
        external_id: SO-2026-5567
        message: Bestellung als Kundenauftrag angelegt.
        metadata:
          sales_order: SO-2026-5567
        status: SUCCESS
    PublicOrderAcknowledgementCreate:
      properties:
        status:
          $ref: '#/components/schemas/PublicOrderAckStatus'
        external_id:
          title: External Id
          description: >-
            Partner-side ERP id of the created order / record. Typically set on
            SUCCESS.
          maxLength: 255
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. Required when ``status`` is
            ``FAILED`` (the failure reason); optional on ``SUCCESS``.
          maxLength: 10000
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload echoed back for traceability
            (e.g. other ERP ids).
          additionalProperties: true
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - status
      title: PublicOrderAcknowledgementCreate
      description: >-
        Body of ``POST /orders/{order_id}/acknowledgements``.


        The partner reports the outcome of importing this order into their ERP

        system. Idempotent: a later acknowledgement overwrites

        ``status`` / ``message`` / ``metadata`` (a retry that first ``FAILED``
        then

        ``SUCCESS`` ends up ``SUCCESS``, previous message cleared unless a new
        one

        is provided). ``external_id`` is the exception — it is
        *last-provided-wins*:

        only updated when an acknowledgement carries one and never cleared, so a

        later id-less acknowledgement does not erase a previously reported id
        (and

        it stays in step with the order's ``erp_offer_id``).


        ``message`` is required when ``status`` is ``FAILED`` (the failure
        reason)

        and optional when ``status`` is ``SUCCESS`` (a free-text note).
      example:
        external_id: SO-2026-5567
        message: Bestellung als Kundenauftrag angelegt.
        metadata:
          sales_order: SO-2026-5567
        status: SUCCESS
    PublicOrderEvent:
      properties:
        id:
          type: string
          format: uuid
          title: Id
          description: Mercura's id for the recorded event.
        event_type:
          type: string
          title: Event Type
          description: Normalised partner event token (e.g. WON / LOST / CANCELLED).
        status_label:
          title: Status Label
          description: The partner's verbatim status label.
          type:
            - string
            - 'null'
        message:
          title: Message
          description: Free-text note about the event.
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: Optional extra references the partner echoed back.
          additionalProperties: true
          type:
            - object
            - 'null'
        external_event_id:
          title: External Event Id
          description: The partner's own id for this event, when provided.
          type:
            - string
            - 'null'
        occurred_at:
          title: Occurred At
          description: When the event happened in the partner system.
          format: date-time
          type:
            - string
            - 'null'
        recorded_at:
          type: string
          format: date-time
          title: Recorded At
          description: When Mercura recorded the event (server receipt time).
      additionalProperties: false
      type: object
      required:
        - id
        - event_type
        - recorded_at
      title: PublicOrderEvent
      description: >-
        One lifecycle event a partner reported for an order.


        Response body of ``POST /orders/{order_id}/events``. ``event_type`` is
        the

        normalised token (e.g. ``CANCELLED``); ``status_label`` the partner's

        verbatim label; ``recorded_at`` the server receipt time.
    PublicOrderEventCreate:
      properties:
        event_type:
          type: string
          maxLength: 64
          pattern: ^[A-Z][A-Z0-9_]{0,63}$
          title: Event Type
          description: >-
            Normalised event token: uppercase letters, digits, underscores (e.g.
            WON, LOST, CANCELLED).
        status_label:
          title: Status Label
          description: The partner's verbatim status label, e.g. 'Cancelled'.
          maxLength: 255
          type:
            - string
            - 'null'
        message:
          title: Message
          description: Optional free-text note about the event.
          maxLength: 10000
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload the partner echoed back for
            traceability.
          additionalProperties: true
          type:
            - object
            - 'null'
        external_event_id:
          title: External Event Id
          description: >-
            The partner's own id for this event; supplying it makes retries
            idempotent.
          maxLength: 255
          type:
            - string
            - 'null'
        occurred_at:
          title: Occurred At
          description: >-
            When the event happened in the partner system (defaults to
            unreported).
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - event_type
      title: PublicOrderEventCreate
      description: |-
        Body of ``POST /orders/{order_id}/events``.

        Append-only and recording-only: recording an event never changes the
        order's lifecycle status. Supply ``external_event_id`` to make retries
        idempotent — an identical replay returns the stored event, a conflicting
        reuse of the id is rejected.
    PublicOrderMetadata:
      properties:
        order_date:
          title: Order Date
          format: date
          type:
            - string
            - 'null'
        customer_reference:
          title: Customer Reference
          description: The customer's own order reference / PO number.
          type:
            - string
            - 'null'
        quote_number:
          title: Quote Number
          type:
            - string
            - 'null'
        project_number:
          title: Project Number
          type:
            - string
            - 'null'
        requested_delivery_date:
          title: Requested Delivery Date
          format: date
          type:
            - string
            - 'null'
        shipping_terms:
          title: Shipping Terms
          type:
            - string
            - 'null'
        contact_person:
          title: Contact Person
          description: Contact at the ordering customer, e.g. 'Hans Müller'.
          type:
            - string
            - 'null'
        special_instructions:
          title: Special Instructions
          type:
            - string
            - 'null'
        delivery_recipient:
          title: Delivery Recipient
          description: >-
            Ship-to party name (Warenempfänger), when different from the
            customer.
          type:
            - string
            - 'null'
        delivery_address:
          description: >-
            Delivery address (Warenempfänger), when it differs from the
            customer's default.
          $ref: '#/components/schemas/PublicOfferAddress'
      additionalProperties: false
      type: object
      title: PublicOrderMetadata
      description: |-
        Order-level metadata extracted from the order document.

        Sourced from the internal ``OrderMetadata`` row (one-to-one with the
        order). Internal-only fields (extraction confidence, the redundant
        ``extracted_*`` / ``customer_address_*`` blocks that duplicate the
        resolved ``customer``) are deliberately dropped. All fields nullable —
        an order that is still parsing may carry none.
    PublicOrderOut:
      properties:
        id:
          type: string
          title: Id
          description: Public id; equals ``request_id`` for an order.
        request_id:
          type: string
          title: Request Id
          description: Mercura request id backing this order.
        status:
          $ref: '#/components/schemas/PublicOrderStatus'
        completed_at:
          title: Completed At
          description: >-
            When the order was last completed (finalised / exported). Refreshed
            on every completion; null if never completed. Use it with the ``GET
            /orders?completed_since=…`` completion feed.
          format: date-time
          type:
            - string
            - 'null'
        customer:
          $ref: '#/components/schemas/PublicOfferCustomer'
        project:
          $ref: '#/components/schemas/PublicOfferProject'
        request_custom_fields:
          title: Request Custom Fields
          description: >-
            Organisation-defined custom fields on the underlying request, keyed
            by the field's display **label**. Null when the request carries
            none.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        positions:
          items:
            $ref: '#/components/schemas/PublicOfferPosition'
          type: array
          title: Positions
        totals:
          $ref: '#/components/schemas/PublicOfferTotals'
        order_metadata:
          description: >-
            Extracted order-level metadata. Null while the order is still
            parsing.
          $ref: '#/components/schemas/PublicOrderMetadata'
        erp_offer_id:
          title: Erp Offer Id
          description: >-
            The partner-side ERP order id, when known — from your
            acknowledgement's ``external_id`` or a previous Mercura-managed
            export; otherwise null.
          type:
            - string
            - 'null'
        acknowledgement:
          description: >-
            Your last reported ERP import outcome for this order, or null if
            none yet.
          $ref: '#/components/schemas/PublicOrderAcknowledgement'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - request_id
        - status
        - totals
        - created_at
        - updated_at
      title: PublicOrderOut
      description: |-
        One order in a list / get-by-id response.

        Keyed by the request id: for orders ``id == request_id`` (an order has a
        single result, unlike a tender which can have several offer exports).
        ``totals`` are present for shape-symmetry with tenders but are typically
        zero — orders match articles, they do not price them.
      example:
        created_at: '2026-07-08T08:30:00Z'
        customer:
          address:
            city: Düsseldorf
            country: DE
            postal_code: '40210'
            street: Industriestraße
            street_number: '12'
          contact_person: Thomas Weber
          custom_fields:
            Kundengruppe: Elektrogroßhandel
          customer_id: K-10042
          name: Elektro Mustermann GmbH
        erp_offer_id: SO-2026-5567
        id: '20456'
        order_metadata:
          contact_person: Thomas Weber
          customer_reference: PO-88213
          delivery_address:
            city: Düsseldorf
            country: DE
            postal_code: '40213'
            street: Rheinuferstraße
            street_number: '45'
          delivery_recipient: Baustelle Rheinpark
          order_date: '2026-07-08'
          project_number: OBJ-2026-0042
          quote_number: AN-2026-0042
          requested_delivery_date: '2026-07-22'
          shipping_terms: frei Baustelle
          special_instructions: Anlieferung nur vormittags 7–12 Uhr.
        positions:
          - article_number: LEU-1500-50-840
            description: LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65
            is_alternative: false
            position_number: '10'
            quantity: 48
            unit: Stk
          - article_number: LEU-1200-30-840
            description: LED-Feuchtraumleuchte 1200 mm 30 W 4000 K IP65
            is_alternative: false
            position_number: '20'
            quantity: 24
            unit: Stk
        project:
          name: Neubau Bürogebäude Rheinpark
          object_number: OBJ-2026-0042
        request_custom_fields:
          Angebots-Nr: AN-2026-0042
        request_id: '20456'
        status: READY_TO_EXPORT
        totals:
          gross_total: '0.00'
          net_total: '0.00'
          positions_subtotal: '0.00'
        updated_at: '2026-07-08T09:05:00Z'
    PublicOrderStatus:
      type: string
      enum:
        - PROCESSING
        - NEEDS_REVIEW
        - READY_TO_EXPORT
        - EXPORTED
        - ARCHIVED
      title: PublicOrderStatus
      description: |-
        Lifecycle state of an order (open enum — new values are SemVer MINOR).

        A translated subset of the internal ``OrderWorkflowStatus`` (see README
        → Design decisions → Public offer status as a translated subset). The
        internal ``INCOMPLETE_METADATA`` is folded into ``NEEDS_REVIEW`` — both
        mean "a human still has to act" — so the public surface exposes a single
        action-required state. The mapping lives in
        ``services.orders_service._ORDER_STATUS_MAP`` and a test asserts it
        covers every internal value, so a new internal state is a loud failure
        rather than a silent passthrough.
    PublicProjectAckStatus:
      type: string
      enum:
        - SUCCESS
        - FAILED
      title: PublicProjectAckStatus
      description: >-
        Outcome a partner reports after importing a project into their ERP/CRM.


        Translated from the internal ``RequestAckStatus`` (the identical axis
        the

        tender/order acknowledgements use) via

        ``projects.service._PROJECT_ACK_STATUS_MAP``. A *separate axis* from

        ``PublicProjectStatus``: that is Mercura-side lifecycle, this is the

        partner's import result.
    PublicProjectAcknowledgement:
      properties:
        status:
          $ref: '#/components/schemas/PublicProjectAckStatus'
        external_id:
          title: External Id
          description: >-
            Partner-side ERP/CRM id of the created project / record (e.g. its
            ERP document number).
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. On ``FAILED`` this is the failure
            reason (required); on ``SUCCESS`` an optional note.
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload the partner echoed back for
            traceability.
          additionalProperties: true
          type:
            - object
            - 'null'
        acknowledged_at:
          type: string
          format: date-time
          title: Acknowledged At
          description: When the partner reported this outcome (server receipt time).
      additionalProperties: false
      type: object
      required:
        - status
        - acknowledged_at
      title: PublicProjectAcknowledgement
      description: >-
        The partner's last reported ERP/CRM import outcome for a project.


        Used as the ``acknowledgement`` field on ``PublicProjectOut`` and as the

        response body of ``POST /projects/{project_id}/acknowledgements``.

        ``status`` / ``message`` / ``metadata`` reflect the most recent

        acknowledgement; ``external_id`` is kept once provided

        (last-provided-wins). Like the tender acknowledgement it is mirrored
        onto a

        first-class field: a supplied ``external_id`` is also written to the

        project's ``object_number`` (the Objektnummer), which is what Mercura
        sends

        back out as ``erp_object_id`` on later tender/order exports.
      example:
        acknowledged_at: '2026-08-20T09:15:00Z'
        external_id: '4500012345'
        metadata:
          erp_client: '100'
        status: SUCCESS
    PublicProjectAcknowledgementCreate:
      properties:
        status:
          $ref: '#/components/schemas/PublicProjectAckStatus'
        external_id:
          title: External Id
          description: >-
            Partner-side ERP/CRM id of the created project / record (e.g. the
            ERP document number). Typically set on SUCCESS.
          maxLength: 255
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. Required when ``status`` is
            ``FAILED`` (the failure reason); optional on ``SUCCESS``.
          maxLength: 10000
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload echoed back for traceability
            (e.g. other ERP/CRM ids).
          additionalProperties: true
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - status
      title: PublicProjectAcknowledgementCreate
      description: >-
        Body of ``POST /projects/{project_id}/acknowledgements``.


        Idempotent and latest-wins for status/message/metadata; ``external_id``
        is

        last-provided-wins (kept once set). ``message`` is required when
        ``status``

        is ``FAILED``. A supplied ``external_id`` is **also written to the
        project's

        ``object_number``** (the Objektnummer) — the ERP-facing key Mercura
        echoes

        back as ``erp_object_id`` on later exports — so one POST both records
        the

        outcome and lands the partner's number. The project's lifecycle
        ``status``

        is never touched and no export is re-triggered.
      example:
        external_id: '4500012345'
        metadata:
          erp_client: '100'
        status: SUCCESS
    PublicProjectOut:
      properties:
        id:
          type: string
          title: Id
          description: Public id; equals the internal Project.id as string.
        object_number:
          title: Object Number
          description: >-
            ERP-facing grouping key (Objektnummer). Multiple projects can share
            an ``object_number`` — filter on ``GET /projects?object_number=...``
            returns all matching rows. Writable via ``PATCH
            /projects/{project_id}`` or by supplying ``external_id`` on ``POST
            /projects/{project_id}/acknowledgements``.
          type:
            - string
            - 'null'
        name:
          title: Name
          type:
            - string
            - 'null'
        status:
          $ref: '#/components/schemas/PublicProjectStatus'
        estimated_value:
          title: Estimated Value
          type:
            - number
            - 'null'
        currency:
          title: Currency
          type:
            - string
            - 'null'
        submission_date:
          title: Submission Date
          description: >-
            Free-form submission deadline as originally supplied (ISO date or
            free text).
          type:
            - string
            - 'null'
        responsible_user:
          $ref: '#/components/schemas/PublicResponsibleUser'
        construction_site_address:
          $ref: '#/components/schemas/Address'
        planner_address:
          $ref: '#/components/schemas/Address'
        developer_address:
          $ref: '#/components/schemas/Address'
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom-field values. Keys and value shapes are
            governed by the organisation's ``custom_column_definitions``.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        request_ids:
          items:
            type: integer
          type: array
          title: Request Ids
          description: >-
            Request ids linked to this project via ``Request.project_id``.
            Round-trip against ``GET /offers/{id}`` to fetch per-request
            content.
        acknowledgement:
          description: >-
            The partner's last reported ERP/CRM import outcome, when one was
            recorded via ``POST /projects/{project_id}/acknowledgements``. Null
            when the project was never acknowledged.
          $ref: '#/components/schemas/PublicProjectAcknowledgement'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - status
        - created_at
        - updated_at
      title: PublicProjectOut
      description: |-
        One project in a list / get-by-id response.

        ``id`` is the stringified ``Project.id`` — the same integer the
        ``offer.new_export_run`` webhook carries as ``project_id``, so
        partners can round-trip the webhook payload straight into
        ``GET /projects/{id}``.

        ``object_number`` is the ERP-facing grouping key. It is NOT unique:
        Mercura groups multiple project rows that share the same
        ``object_number`` into one "master project" internally. Partners
        filtering by ``object_number`` should be prepared to receive
        multiple rows (page endpoint).

        ``custom_fields`` carries the organisation-defined custom field
        values keyed by the field name defined in the organisation's
        ``custom_column_definitions``. Multi-select values are returned as
        their human-readable option **labels** (e.g. ``["3030"]``), not the
        internal option ids — a value carrying multiple labels comes back as
        a list. No JSON schema is enforced on the contents — the shape is
        per-org and can evolve without an API version bump. When the field
        is empty or unset, it is omitted from the response entirely.

        ``request_ids`` lists the Mercura request ids linked to this
        project (via ``Request.project_id``). Drill down to any request
        with ``GET /offers/{request_id}`` — the ``offer_id`` used by that
        endpoint equals the request id.
      example:
        acknowledgement:
          acknowledged_at: '2026-08-20T09:15:00Z'
          external_id: '4500012345'
          metadata:
            erp_client: '100'
          status: SUCCESS
        construction_site_address:
          city: Düsseldorf
          country: DE
          is_default: true
          kind: site
          postal_code: '40213'
          region: NRW
          street: Rheinuferstraße
          street_number: '45'
        created_at: '2026-07-08T09:10:00Z'
        currency: EUR
        custom_fields:
          Gewerk:
            - EL - ELEMENTE TÜREN
          Vergabeart: Öffentlich
        estimated_value: 185000
        id: '90'
        name: Neubau Bürogebäude Rheinpark
        object_number: OBJ-2026-0042
        planner_address:
          city: Düsseldorf
          country: DE
          kind: planner
          postal_code: '40212'
          street: Königsallee
          street_number: '60'
        request_ids:
          - 12345
        responsible_user:
          email: anna.schmidt@example.com
          id: 3f8b6d21-9a4c-4e77-b0e2-1c5d8a9f4e10
          name: Anna Schmidt
        status: ACTIVE
        submission_date: '2026-08-15'
        updated_at: '2026-07-09T12:00:12Z'
    PublicProjectStatus:
      type: string
      enum:
        - ACTIVE
        - PROCESSED
        - BID_SUBMITTED
        - CUSTOMER_LOST
        - CUSTOMER_WON_PENDING
        - CUSTOMER_WON_AWARDED_ELSEWHERE
        - CUSTOMER_WON_AWARDED_TO_US
      title: PublicProjectStatus
      description: |-
        Lifecycle status of a Mercura project.

        Translated from the internal ``ProjectStatus`` enum — the internal
        values are lower-cased for historical reasons; the public API uses
        UPPERCASE (matches ``PublicOfferStatus`` and the platform-wide
        enum convention in ``CLAUDE.md``). New values are SemVer MINOR
        (additive); removed values are MAJOR. Translation table lives in
        ``projects.service._STATUS_MAP`` so a missing case is a test
        failure, not silent passthrough.
    PublicProjectUpdate:
      properties:
        object_number:
          title: Object Number
          description: ERP-facing grouping key (Objektnummer). ``null`` clears it.
          type:
            - string
            - 'null'
        name:
          title: Name
          description: Display name. ``null`` clears it.
          type:
            - string
            - 'null'
        status:
          description: >-
            Lifecycle status (UPPERCASE). Cannot be null; unknown values are a
            400.
          $ref: '#/components/schemas/PublicProjectStatus'
        estimated_value:
          title: Estimated Value
          type:
            - number
            - 'null'
        currency:
          title: Currency
          type:
            - string
            - 'null'
        submission_date:
          title: Submission Date
          description: >-
            Free-form submission deadline (ISO date or free text). ``null``
            clears it.
          type:
            - string
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            Label-keyed custom-field values to shallow-merge. Keys are the
            organisation's custom-column labels; ``multi_select`` values are
            option labels. A ``null`` value clears that field. Unknown labels or
            option labels are a 400.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
              - type: 'null'
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      title: PublicProjectUpdate
      description: >-
        Partial update of a project — the writable subset of
        ``PublicProjectOut``.


        The canonical use-case is writing an ERP-assigned identifier back onto
        the

        Mercura project: the ERP creates a commission / project number and
        PATCHes

        it into ``object_number`` (the "Objektnummer"), so subsequent

        ``offer.new_export_run`` webhooks and ``GET /projects`` reconcile
        against

        the partner's own key.


        **PATCH semantics.** Only fields **present in the body** are written; an

        omitted field is left untouched. A field explicitly set to ``null``
        clears

        it (``object_number: null`` blanks the Objektnummer). This

        present-vs-absent distinction is resolved via Pydantic's

        ``model_fields_set`` in the write service — do not read a missing field
        as

        ``null``.


        **``object_number`` is a grouping key.** Mercura groups every project
        row

        that shares an ``object_number`` into one logical "master project" — the

        grouping is *derived at read time*, not stored, so writing the field on
        one

        project row is sufficient and moves only that row into the group. The
        PATCH

        never rewrites sibling rows.


        **``status``** takes the same UPPERCASE ``PublicProjectStatus`` values
        the

        read side emits; an unknown value is a ``400``. It cannot be cleared —

        sending ``status: null`` is a ``400`` (a project always has a lifecycle

        state).


        **``custom_fields``** is label-keyed, symmetric with the read side: keys
        are

        the org's custom-column **labels** (not internal UUIDs) and
        ``multi_select``

        values are the option **labels** (e.g. ``["3030"]``). A key mapped to

        ``null`` clears that field; keys omitted from the map are left untouched

        (shallow-merge, not replace). An unknown label — or an unknown option
        label

        on a ``multi_select`` — is a ``400`` listing the offending keys, so a
        typo

        fails loudly instead of silently writing nothing.
      example:
        custom_fields:
          ERP-Auftragsnummer: SO-88231
        object_number: P-2026-00417
        status: CUSTOMER_WON_AWARDED_TO_US
    PublicRequestSelectionStats:
      properties:
        request_id:
          type: string
          title: Request Id
          description: >-
            Mercura request id. Resolve the full record via ``GET
            /tenders/{request_id}`` when ``request_type`` is ``TENDER``, or
            ``GET /orders/{request_id}`` when ``ORDER``.
        request_type:
          $ref: '#/components/schemas/PublicStatsRequestType'
          description: Which resource this row belongs to — ``TENDER`` or ``ORDER``.
        request_name:
          type: string
          title: Request Name
          description: Request name / title (e.g. the LV subject line).
        status:
          $ref: '#/components/schemas/PublicRequestStatus'
          description: >-
            Mercura-side processing lifecycle of the request (``NEW``,
            ``IN_PROGRESS``, ``DONE``, ``CANCELLED``, ``PARSING``,
            ``PARSING_FAILED``). Combine with ``exported_at`` to tell an
            exported request apart from one still in progress. By default only
            exported requests are returned; pass ``include_unexported=true`` to
            also receive open ones.
        created_on:
          type: string
          format: date
          title: Created On
          description: Date the request was created (UTC).
        first_opened_at:
          title: First Opened At
          description: >-
            UTC timestamp a user first opened the request — the start of active
            handling. ``null`` if it was never opened. The handling time of a
            request is ``exported_at - first_opened_at``.
          format: date-time
          type:
            - string
            - 'null'
        exported_at:
          title: Exported At
          description: >-
            UTC timestamp the request was first exported to the partner system,
            or ``null`` if it has not been exported yet (only ever ``null`` when
            ``include_unexported=true``).
          format: date-time
          type:
            - string
            - 'null'
        completed_at:
          title: Completed At
          description: >-
            UTC timestamp of the most recent completion / re-export. Unlike
            ``exported_at`` (set once, on the first export) this advances on
            every re-export. ``null`` until the request is completed.
          format: date-time
          type:
            - string
            - 'null'
        active_seconds:
          title: Active Seconds
          description: >-
            True handling time: the total **active** (engaged) seconds a user
            spent working on this request before it was exported — the exact
            per-request figure behind the in-app 'time per position' metric,
            summed from usage telemetry. Counts only time the request was
            actively being worked, so unlike ``exported_at - first_opened_at``
            (wall-clock, includes idle gaps) it reflects real effort. Divide by
            ``position_count`` / ``selection_count`` for a per-position /
            per-selection figure. Defined for **exported tenders** only:
            ``null`` for orders, for requests not yet exported, and for exported
            requests with no recorded active time (telemetry unavailable).
          type:
            - integer
            - 'null'
        position_count:
          type: integer
          title: Position Count
          description: Number of relevant positions on the request.
        positions_with_selection:
          type: integer
          title: Positions With Selection
          description: >-
            Number of relevant positions that carry at least one selection —
            i.e. the positions that were actually resolved. Always ``<=
            position_count`` (a relevant position may end up with no selection),
            and not interchangeable with ``selection_count``, which counts
            *selections* rather than positions (one position can carry several:
            alternatives, accessories). **This is the denominator behind the
            in-app time-per-position metric**: divide ``active_seconds`` by it,
            and aggregate as a pooled ``SUM(active_seconds) /
            SUM(positions_with_selection)`` to match what the dashboard reports.
        selection_count:
          type: integer
          title: Selection Count
          description: >-
            Total surviving selections on relevant positions — the sum of the
            three outcome counts below.
        auto_selected_count:
          type: integer
          title: Auto Selected Count
          description: >-
            Selections resolved by a mechanism that bypasses prediction ranking
            (article-number match, master / historic / cluster / duplicate /
            equivalent-position propagation, parts-list lookup, default article,
            or an auto-selection the order-entry agent makes on its own).
        prediction_correct_count:
          type: integer
          title: Prediction Correct Count
          description: >-
            Prediction-driven selections where the chosen article was among
            Mercura's predictions for that position (predicted vs.
            not-predicted; the earlier top-10 rank cut has been removed, as the
            order-entry agent surfaces only a handful of candidates per
            position).
        manual_selection_count:
          type: integer
          title: Manual Selection Count
          description: >-
            Prediction-driven selections where the chosen article was NOT among
            Mercura's predictions for that position (shown as "Manual
            selections" in the dashboard).
      additionalProperties: false
      type: object
      required:
        - request_id
        - request_type
        - request_name
        - status
        - created_on
        - position_count
        - positions_with_selection
        - selection_count
        - auto_selected_count
        - prediction_correct_count
        - manual_selection_count
      title: PublicRequestSelectionStats
      description: >-
        Per-request selection-outcome counts for one exported request.


        The three outcome counts (``auto_selected_count``,

        ``prediction_correct_count``, ``manual_selection_count``) are mutually

        exclusive and sum to ``selection_count``. Percentages and hit-rate are

        intentionally **not** computed server-side — derive them from these raw

        counts (e.g. ``hit_rate = (auto_selected_count +
        prediction_correct_count)

        / selection_count``).
      example:
        active_seconds: 1284
        auto_selected_count: 31
        completed_at: '2026-07-08T10:02:47Z'
        created_on: '2026-07-08'
        exported_at: '2026-07-08T10:02:47Z'
        first_opened_at: '2026-07-08T09:14:03Z'
        manual_selection_count: 5
        position_count: 52
        positions_with_selection: 48
        prediction_correct_count: 14
        request_id: '12345'
        request_name: Neubau Bürogebäude Rheinpark – Elektro
        request_type: TENDER
        selection_count: 50
        status: DONE
    PublicRequestStatus:
      type: string
      enum:
        - NEW
        - IN_PROGRESS
        - DONE
        - CANCELLED
        - PARSING
        - PARSING_FAILED
      title: PublicRequestStatus
      description: >-
        Mercura-side processing lifecycle of a request.


        A request is uploaded as ``NEW``, moves through ``IN_PROGRESS`` while it
        is

        being worked, and becomes ``DONE`` once completed/exported. ``PARSING``
        /

        ``PARSING_FAILED`` are transient intake states; ``CANCELLED`` is a
        request

        that was abandoned. Pair this with ``exported_at`` to tell an exported

        request apart from one that is still open (uploaded but not yet
        exported).
    PublicResponsibleUser:
      properties:
        id:
          type: string
          title: Id
          description: Mercura user id (opaque; keep or drop on the partner side).
        name:
          type: string
          title: Name
        email:
          title: Email
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - id
        - name
      title: PublicResponsibleUser
      description: |-
        The Mercura user assigned to a project.

        Small denormalised bundle rather than a bare id — partners rendering
        an offer inbox rarely need to hit a second endpoint just to display
        "Assigned to Anna Schmidt". Email is included so downstream systems
        can key on it (Mercura's ``supabase_id`` is not portable).
    PublicStatsRequestType:
      type: string
      enum:
        - TENDER
        - ORDER
      title: PublicStatsRequestType
      description: Which resource a statistics row's ``request_id`` resolves against.
    PublicSuccessorIn:
      properties:
        source_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Source Article Number
          description: The discontinued / predecessor article.
        successor_article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Successor Article Number
          description: The article that replaces the source.
        order:
          title: Order
          description: Optional display order (a source has one active successor).
          minimum: 0
          type:
            - integer
            - 'null'
      additionalProperties: false
      type: object
      required:
        - source_article_number
        - successor_article_number
      title: PublicSuccessorIn
      description: |-
        One successor relationship in a bulk-write payload.

        Both article numbers must already exist for your organisation; a row
        whose source or successor article is unknown is skipped (counted in
        the job's ``skipped_count``), not an error. Re-sending a source with a
        new successor replaces the previous one.
    PublicSuccessorOut:
      properties:
        id:
          type: integer
          title: Id
          description: Internal relationship id.
        source_article_number:
          type: string
          title: Source Article Number
        successor_article_number:
          type: string
          title: Successor Article Number
        order:
          title: Order
          type:
            - integer
            - 'null'
        source_type:
          type: string
          title: Source Type
          description: 'How the relation was created: MANUAL or MATCHING_EXTRACTED.'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - source_article_number
        - successor_article_number
        - source_type
        - created_at
        - updated_at
      title: PublicSuccessorOut
      description: One successor relationship in a list response.
    PublicSupplierIn:
      properties:
        supplier_id:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Supplier Id
        name:
          type: string
          maxLength: 500
          minLength: 1
          title: Name
        emails:
          title: Emails
          items:
            type: string
            format: email
          type:
            - array
            - 'null'
        addresses:
          title: Addresses
          items:
            $ref: '#/components/schemas/Address'
          type:
            - array
            - 'null'
        custom_fields:
          title: Custom Fields
          description: >-
            Custom fields for the supplier. On the **write** path these are
            stored as supplied (internal column-id keyed) and are NOT
            label-remapped — deliberately asymmetric with the label-keyed read
            responses in v1.9.0 (Wave 1 partners only read).
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - supplier_id
        - name
      title: PublicSupplierIn
      description: |-
        One supplier in a bulk-write payload.

        ``supplier_id`` is the partner's stable identifier — used both as
        the upsert key and as the public-API identifier returned in lists
        and ``GET /suppliers/{supplier_id}``. Required so that retries
        converge on the same row instead of creating duplicates.
    PublicSupplierOut:
      properties:
        id:
          title: Id
          description: Public id; equals the supplier's supplier_id when present
          type:
            - string
            - 'null'
        supplier_id:
          title: Supplier Id
          type:
            - string
            - 'null'
        name:
          type: string
          title: Name
        emails:
          items:
            type: string
          type: array
          title: Emails
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom fields, keyed by the field's display
            **label** (v1.9.0; previously keyed by the internal column UUID).
            Columns with no active definition are omitted; null when the
            supplier carries none. On duplicate labels the field defined first
            (by display order, then creation time) wins.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - name
        - created_at
        - updated_at
      title: PublicSupplierOut
      description: |-
        One supplier in a list / get-by-id response.

        Subset of the internal ``Supplier`` ORM row; intentionally omits
        relational data (branch_ids, manufacturer_filter) which belong to
        Wave 2 endpoints once the contract is stable.
      example:
        created_at: '2026-02-03T10:20:00Z'
        custom_fields:
          Kreditor-Nr: '70012'
          Lieferantengruppe: Leuchten
        emails:
          - vertrieb@lumaris.example
        id: L-2001
        name: Lumaris Leuchten GmbH
        supplier_id: L-2001
        updated_at: '2026-07-10T08:05:00Z'
    PublicSupplierRequestOut:
      properties:
        id:
          type: string
          title: Id
          description: Public id — the supplier request's UUID.
        status:
          $ref: '#/components/schemas/PublicSupplierRequestStatus'
        tender_id:
          title: Tender Id
          description: >-
            Backing tender id when the request is tender-scoped — resolve via
            ``GET /tenders/{tender_id}``. Null when project-scoped.
          type:
            - string
            - 'null'
        project_id:
          title: Project Id
          description: >-
            Backing project id when the request is project-scoped — resolve via
            ``GET /projects/{project_id}``. Null when tender-scoped.
          type:
            - string
            - 'null'
        supplier_id:
          title: Supplier Id
          description: >-
            Supplier reference only — resolve full details via ``GET
            /suppliers/{supplier_id}``. Null when no supplier is set.
          type:
            - string
            - 'null'
        supplier_name:
          title: Supplier Name
          description: Supplier display name (convenience mirror of the reference).
          type:
            - string
            - 'null'
        supplier_emails:
          items:
            type: string
          type: array
          title: Supplier Emails
          description: Recipient email addresses the request was sent to.
        sender_email:
          title: Sender Email
          description: Email of the Mercura user who sent the request. Null when unknown.
          type:
            - string
            - 'null'
        is_awarded:
          type: boolean
          title: Is Awarded
          description: >-
            True when at least one offered line was accepted into the final
            tender.
        awarded_line_count:
          type: integer
          title: Awarded Line Count
          description: Number of offered lines accepted into the final tender.
        offered_lines:
          items:
            $ref: '#/components/schemas/PublicOfferedLine'
          type: array
          title: Offered Lines
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - status
        - is_awarded
        - awarded_line_count
        - created_at
        - updated_at
      title: PublicSupplierRequestOut
      description: One supplier request (Werksanfrage) in a list / get-by-id response.
      example:
        awarded_line_count: 1
        created_at: '2026-07-14T09:12:00Z'
        id: b3f1c2a4-6d9e-4f21-a0b7-2c5e8d1f9a44
        is_awarded: true
        offered_lines:
          - article_number: LEU-48210
            candidate_status: ACCEPTED
            description: LED-Einbauleuchte 4000K, 24W
            id: '5521'
            item_type: ARTICLE
            line_total: '4620.00'
            manufacturer_article_number: NL-LED-4000K
            position_id: '90112'
            quantity: 120
            selected: true
            unit: Stk
            unit_price: '38.50'
          - article_number: LEU-48477
            candidate_status: PENDING
            description: LED-Panel 62x62, 36W
            id: '5522'
            item_type: ARTICLE
            line_total: '2880.00'
            position_id: '90113'
            quantity: 40
            selected: false
            unit: Stk
            unit_price: '72.00'
        sender_email: einkauf@grosshandel.de
        status: OFFER_RECEIVED
        supplier_emails:
          - angebot@nordlicht-leuchten.de
        supplier_id: SUP-3001
        supplier_name: Nordlicht Leuchten GmbH
        tender_id: '12345'
        updated_at: '2026-07-16T14:05:11Z'
    PublicSupplierRequestStatus:
      type: string
      enum:
        - REQUEST_SENT
        - OFFER_RECEIVED
        - DECLINED
      title: PublicSupplierRequestStatus
      description: >-
        Lifecycle state of a supplier request.


        A translated subset of the internal ``SupplierRequestStatus``; the
        mapping

        lives in ``supplier_requests.service._SUPPLIER_REQUEST_STATUS_MAP`` and
        a

        test asserts it covers every internal value. New values may be added in
        a

        MINOR release — the OpenAPI enum is closed, so consumers should treat an

        unknown value defensively rather than fail strict validation.
    PublicTenderAckStatus:
      type: string
      enum:
        - SUCCESS
        - FAILED
      title: PublicTenderAckStatus
      description: >-
        Outcome a partner reports after importing a tender's offer into their
        ERP/CRM.


        Translated from the internal ``RequestAckStatus`` via

        ``tenders.service._TENDER_ACK_STATUS_MAP``. A *separate axis* from

        ``PublicTenderStatus``: that is Mercura-side lifecycle, this is the
        partner's

        import result.
    PublicTenderAcknowledgement:
      properties:
        status:
          $ref: '#/components/schemas/PublicTenderAckStatus'
        external_id:
          title: External Id
          description: >-
            Partner-side ERP/CRM id of the created offer / record, when
            provided.
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. On ``FAILED`` this is the failure
            reason (required); on ``SUCCESS`` an optional note.
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload the partner echoed back for
            traceability.
          additionalProperties: true
          type:
            - object
            - 'null'
        acknowledged_at:
          type: string
          format: date-time
          title: Acknowledged At
          description: When the partner reported this outcome (server receipt time).
      additionalProperties: false
      type: object
      required:
        - status
        - acknowledged_at
      title: PublicTenderAcknowledgement
      description: >-
        The partner's last reported ERP/CRM import outcome for a tender.


        Used as the ``acknowledgement`` field on ``PublicTenderOut`` and as the

        response body of ``POST /tenders/{tender_id}/acknowledgements``.

        ``status`` / ``message`` / ``metadata`` reflect the most recent

        acknowledgement; ``external_id`` is kept once provided
        (last-provided-wins)

        and mirrors the tender's ``erp_offer_id``.
      example:
        acknowledged_at: '2026-07-09T12:00:12Z'
        external_id: AB-2026-5567
        message: Angebot in ERP angelegt.
        metadata:
          erp_document: AB-2026-5567
          warehouse: Z-100
        status: SUCCESS
    PublicTenderAcknowledgementCreate:
      properties:
        status:
          $ref: '#/components/schemas/PublicTenderAckStatus'
        external_id:
          title: External Id
          description: >-
            Partner-side ERP/CRM id of the created offer / record. Typically set
            on SUCCESS.
          maxLength: 255
          type:
            - string
            - 'null'
        message:
          title: Message
          description: >-
            Free-text note about the outcome. Required when ``status`` is
            ``FAILED`` (the failure reason); optional on ``SUCCESS``.
          maxLength: 10000
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload echoed back for traceability
            (e.g. other ERP/CRM ids).
          additionalProperties: true
          type:
            - object
            - 'null'
      additionalProperties: false
      type: object
      required:
        - status
      title: PublicTenderAcknowledgementCreate
      description: >-
        Body of ``POST /tenders/{tender_id}/acknowledgements``.


        Idempotent and latest-wins for status/message/metadata; ``external_id``
        is

        last-provided-wins (kept once set) and is written back to the tender's

        ``erp_offer_id``. ``message`` is required when ``status`` is ``FAILED``.
      example:
        external_id: AB-2026-5567
        message: Angebot in ERP angelegt.
        metadata:
          erp_document: AB-2026-5567
          warehouse: Z-100
        status: SUCCESS
    PublicTenderBranch:
      properties:
        id:
          type: string
          title: Id
          description: Mercura branch id.
        name:
          type: string
          title: Name
          description: Branch (Niederlassung) display name.
          default: ''
      additionalProperties: false
      type: object
      required:
        - id
      title: PublicTenderBranch
      description: The Mercura branch (Niederlassung) a tender is assigned to. Read-only.
    PublicTenderChapter:
      properties:
        id:
          type: string
          title: Id
          description: Opaque, stable chapter handle. Target for adding lines via PATCH.
        chapter_number:
          type: string
          title: Chapter Number
          description: GAEB chapter number (e.g. '01').
          default: ''
        name:
          type: string
          title: Name
          description: Chapter title.
          default: ''
        is_pretext:
          type: boolean
          title: Is Pretext
          description: A Vorbemerkung / pretext chapter (intro text, not priced).
          default: false
        chapters:
          items:
            $ref: '#/components/schemas/PublicTenderChapter'
          type: array
          title: Chapters
          description: Nested sub-chapters.
        positions:
          items:
            $ref: '#/components/schemas/PublicTenderPosition'
          type: array
          title: Positions
          description: Lines directly under this chapter.
      additionalProperties: false
      type: object
      required:
        - id
      title: PublicTenderChapter
      description: A chapter (title / Titel) of a tender. Chapters nest via ``chapters``.
    PublicTenderChapterUpdate:
      properties:
        id:
          type: string
          title: Id
          description: Existing chapter handle from GET (adds/edits attach here).
        positions:
          items:
            $ref: '#/components/schemas/PublicTenderPositionUpdate'
          type: array
          title: Positions
        chapters:
          items:
            $ref: '#/components/schemas/PublicTenderChapterUpdate'
          type: array
          title: Chapters
        chapter_number:
          title: Chapter Number
          description: Ignored on write.
          type:
            - string
            - 'null'
        name:
          title: Name
          description: Ignored on write.
          type:
            - string
            - 'null'
        is_pretext:
          title: Is Pretext
          description: Ignored on write.
          type:
            - boolean
            - 'null'
      additionalProperties: false
      type: object
      required:
        - id
      title: PublicTenderChapterUpdate
      description: >-
        A chapter in a PATCH body — targets an existing chapter by ``id``.


        Lines to edit/add go in ``positions``; nested ``chapters`` are processed

        recursively. Chapter metadata is declared for round-trip but ignored —

        creating or renaming chapters via the API is out of scope for this
        release.
    PublicTenderDocument:
      properties:
        id:
          type: string
          title: Id
          description: >-
            Opaque document id (the file's stable uploaded-file id). Stable
            across re-lists; use it to correlate a document you have already
            seen.
        filename:
          type: string
          title: Filename
          description: Original file name as uploaded by the customer.
        content_type:
          type: string
          title: Content Type
          description: >-
            MIME type of the file (e.g. ``application/pdf``,
            ``application/xml``).
        size_bytes:
          type: integer
          title: Size Bytes
          description: File size in bytes.
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When the file was uploaded to Mercura (UTC).
        download_url:
          title: Download Url
          description: >-
            Short-lived pre-signed URL that downloads the file's bytes directly
            from object storage (no API key required on this URL). Expires at
            ``download_url_expires_at``. Null when the storage backend cannot
            issue a signed URL (non-production environments).
          type:
            - string
            - 'null'
        download_url_expires_at:
          title: Download Url Expires At
          description: >-
            UTC instant at which ``download_url`` stops working; null when no
            URL was issued.
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - id
        - filename
        - content_type
        - size_bytes
        - created_at
      title: PublicTenderDocument
      description: >-
        One source document attached to a tender.


        A file the customer uploaded into the request — the same attachments a

        forwarded LV / RFQ email carried. Mercura-generated artefacts (the offer
        /

        display PDF, structured-doc previews, OCR / metadata side-files) and
        hidden

        files are **not** listed here.


        ``download_url`` is a short-lived, pre-signed link that serves the bytes

        directly from object storage — it needs no ``Authorization`` header and

        expires at ``download_url_expires_at``. Treat it as opaque and
        transient:

        fetch promptly, and re-list the tender's documents to obtain a fresh URL

        once it has expired.
      example:
        content_type: application/xml
        created_at: '2026-07-08T09:14:00Z'
        download_url: >-
          https://s3.fra1.example.com/mercura/files/b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34_LV_Tiefgarage_Beleuchtung.gaeb?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…
        download_url_expires_at: '2026-07-08T10:14:00Z'
        filename: LV_Tiefgarage_Beleuchtung.gaeb
        id: b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34
        size_bytes: 284913
    PublicTenderDocuments:
      properties:
        data:
          items:
            $ref: '#/components/schemas/PublicTenderDocument'
          type: array
          title: Data
          description: The tender's source documents (the customer's original uploads).
      additionalProperties: false
      type: object
      title: PublicTenderDocuments
      description: >-
        Response body of ``GET /tenders/{tender_id}/documents``.


        A wrapper object (rather than a bare array) so the shape can grow
        without a

        breaking change. The document set per tender is small and unpaginated.
      example:
        data:
          - content_type: application/xml
            created_at: '2026-07-08T09:14:00Z'
            download_url: >-
              https://s3.fra1.example.com/mercura/files/b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34_LV_Tiefgarage_Beleuchtung.gaeb?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=…
            download_url_expires_at: '2026-07-08T10:14:00Z'
            filename: LV_Tiefgarage_Beleuchtung.gaeb
            id: b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34
            size_bytes: 284913
    PublicTenderEvent:
      properties:
        id:
          type: string
          format: uuid
          title: Id
          description: Mercura's id for the recorded event.
        event_type:
          type: string
          title: Event Type
          description: Normalised partner event token (e.g. WON / LOST / CANCELLED).
        status_label:
          title: Status Label
          description: The partner's verbatim status label.
          type:
            - string
            - 'null'
        message:
          title: Message
          description: Free-text note about the event.
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: Optional extra references the partner echoed back.
          additionalProperties: true
          type:
            - object
            - 'null'
        external_event_id:
          title: External Event Id
          description: The partner's own id for this event, when provided.
          type:
            - string
            - 'null'
        occurred_at:
          title: Occurred At
          description: When the event happened in the partner system.
          format: date-time
          type:
            - string
            - 'null'
        recorded_at:
          type: string
          format: date-time
          title: Recorded At
          description: When Mercura recorded the event (server receipt time).
      additionalProperties: false
      type: object
      required:
        - id
        - event_type
        - recorded_at
      title: PublicTenderEvent
      description: >-
        One lifecycle event a partner reported for a tender.


        Response body of ``POST /tenders/{tender_id}/events``. ``event_type`` is
        the

        normalised token (e.g. ``WON``); ``status_label`` the partner's verbatim

        label; ``recorded_at`` the server receipt time.
    PublicTenderEventCreate:
      properties:
        event_type:
          type: string
          maxLength: 64
          pattern: ^[A-Z][A-Z0-9_]{0,63}$
          title: Event Type
          description: >-
            Normalised event token: uppercase letters, digits, underscores (e.g.
            WON, LOST, CANCELLED).
        status_label:
          title: Status Label
          description: The partner's verbatim status label, e.g. 'Closed Won'.
          maxLength: 255
          type:
            - string
            - 'null'
        message:
          title: Message
          description: Optional free-text note about the event.
          maxLength: 10000
          type:
            - string
            - 'null'
        metadata:
          title: Metadata
          description: >-
            Optional extra references / raw payload the partner echoed back for
            traceability.
          additionalProperties: true
          type:
            - object
            - 'null'
        external_event_id:
          title: External Event Id
          description: >-
            The partner's own id for this event; supplying it makes retries
            idempotent.
          maxLength: 255
          type:
            - string
            - 'null'
        occurred_at:
          title: Occurred At
          description: >-
            When the event happened in the partner system (defaults to
            unreported).
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - event_type
      title: PublicTenderEventCreate
      description: |-
        Body of ``POST /tenders/{tender_id}/events``.

        Append-only and recording-only: recording an event never changes the
        tender's lifecycle status. Supply ``external_event_id`` to make retries
        idempotent — an identical replay returns the stored event, a conflicting
        reuse of the id is rejected.
    PublicTenderGaebPositionType:
      type: string
      enum:
        - BASE
        - OPTIONAL_WITH_TOTAL
        - OPTIONAL_WITHOUT_TOTAL
        - ALTERNATIVE
      title: PublicTenderGaebPositionType
      description: |-
        GAEB Positionsart, from ``Position.gaeb_position_type`` (or null).

        ``BASE`` — Grundposition; ``OPTIONAL_WITH_TOTAL`` — Bedarfsposition mit
        Gesamtbetrag; ``OPTIONAL_WITHOUT_TOTAL`` — Bedarfsposition ohne
        Gesamtbetrag; ``ALTERNATIVE`` — Wahlposition. Read-only.
    PublicTenderLabel:
      properties:
        text:
          type: string
          title: Text
          description: Label display text (org-defined for CUSTOM labels).
        type:
          $ref: '#/components/schemas/PublicTenderLabelType'
      additionalProperties: false
      type: object
      required:
        - text
        - type
      title: PublicTenderLabel
      description: >-
        One label on a line — ``{text, type}``. Read-only in this release.


        A line can carry several. ``text`` is the label's display text (for
        CUSTOM

        labels this is the org-defined name).
    PublicTenderLabelType:
      type: string
      enum:
        - CUSTOM
        - ALTERNATIVE
        - ACCESSORY
        - PARTS_LIST
      title: PublicTenderLabelType
      description: >-
        Role overlay on a line, from ``ArticleLabel.type``.


        ``CUSTOM`` labels carry a meaningful org-defined ``text`` (product
        groups,

        "Bestellware", …); the others are structural roles.
    PublicTenderLineType:
      type: string
      enum:
        - ARTICLE
        - TEXT
        - DIVERSE
      title: PublicTenderLineType
      description: >-
        What a line *is*, from ``Selection.item_type``.


        ``ARTICLE`` — matched to a catalogue article; ``TEXT`` — a free-text
        line;

        ``DIVERSE`` — a priced free-text / lump-sum ("Pauschal") line.
    PublicTenderOut:
      properties:
        id:
          type: string
          title: Id
          description: Public id; equals ``request_id`` for a tender.
        request_id:
          type: string
          title: Request Id
          description: Mercura request id backing this tender.
        status:
          $ref: '#/components/schemas/PublicTenderStatus'
        completed_at:
          title: Completed At
          description: >-
            When the tender was last completed (offer exported / sent).
            Refreshed on every completion; null if never completed. Use it with
            the ``GET /tenders?completed_since=…`` completion feed.
          format: date-time
          type:
            - string
            - 'null'
        customer_id:
          title: Customer Id
          description: >-
            Customer reference only — resolve full details via ``GET
            /customers/{customer_id}``. Null when the tender has no customer.
          type:
            - string
            - 'null'
        indirect_customer_id:
          title: Indirect Customer Id
          description: >-
            Indirect-customer reference (Mercura's *indirekter Kunde*) — resolve
            full details via ``GET /customers/{customer_id}``. Distinct from
            ``customer_id`` (the direct customer / Sold-To). Null when the
            tender has no indirect customer.
          type:
            - string
            - 'null'
        project_id:
          title: Project Id
          description: >-
            Project reference only — resolve full details via ``GET
            /projects/{project_id}``. Null when the tender has no project.
          type:
            - string
            - 'null'
        project_name:
          title: Project Name
          description: >-
            The project's display name, surfaced inline for convenience (same
            value as ``name`` on ``GET /projects/{project_id}``). Null when the
            tender has no project. Read-only.
          type:
            - string
            - 'null'
        submission_date:
          title: Submission Date
          description: >-
            Bid-submission date (Submissions-/Abgabetermin) as originally
            supplied on the project — an ISO date or free text; null when
            unknown. This is the tender's submission deadline, **not** an
            offer-validity ("gültig bis") date.
          type:
            - string
            - 'null'
        deadline:
          title: Deadline
          description: >-
            Mercura's internal handling deadline for this request (typically the
            submission date minus a buffer) — an ISO date or free text; null
            when unset. Informational only; **not** the submission deadline and
            **not** an offer-validity date.
          type:
            - string
            - 'null'
        user_email:
          title: User Email
          description: >-
            Email of the Mercura user **responsible** for this tender (its
            Bearbeiter / Sachbearbeiter) — not necessarily the user who
            triggered the export. Null when no responsible user is assigned.
          type:
            - string
            - 'null'
        branch:
          description: >-
            The primary Mercura branch (Niederlassung) assigned to this tender
            ({id, name}), or null when none is assigned. Read-only.
          $ref: '#/components/schemas/PublicTenderBranch'
        request_custom_fields:
          title: Request Custom Fields
          description: >-
            Organisation-defined custom fields on the request, keyed by display
            label. Null when none.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        user_custom_fields:
          title: User Custom Fields
          description: >-
            Organisation-defined custom fields on the responsible Mercura user
            (the ``user_email`` above), keyed by display label — e.g. a user's
            ``SGI``. Null when the tender has no responsible user or the user
            has no custom fields.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        chapters:
          items:
            $ref: '#/components/schemas/PublicTenderChapter'
          type: array
          title: Chapters
        totals:
          $ref: '#/components/schemas/PublicTenderTotals'
        erp_offer_id:
          title: Erp Offer Id
          description: >-
            The partner-side ERP offer id, when known — from your
            acknowledgement's ``external_id`` or a previous Mercura-managed
            export; otherwise null.
          type:
            - string
            - 'null'
        acknowledgement:
          description: >-
            Your last reported ERP/CRM import outcome for this tender, or null
            if none yet.
          $ref: '#/components/schemas/PublicTenderAcknowledgement'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - request_id
        - status
        - totals
        - created_at
        - updated_at
      title: PublicTenderOut
      description: >-
        One tender in a list / get-by-id response (request-keyed: ``id ==
        request_id``).
      example:
        acknowledgement:
          acknowledged_at: '2026-07-09T12:00:12Z'
          external_id: AB-2026-5567
          message: Angebot in ERP angelegt.
          metadata:
            erp_document: AB-2026-5567
            warehouse: Z-100
          status: SUCCESS
        branch:
          id: '7'
          name: Niederlassung München
        chapters:
          - chapter_number: '01'
            chapters:
              - chapter_number: '01.02'
                chapters: []
                id: '5503'
                is_pretext: false
                name: Beleuchtung Tiefgarage
                positions:
                  - article_number: LEU-1500-50-840
                    chapter_id: '5503'
                    chapter_number: '01.02'
                    custom_attributes:
                      article_type: YHAW
                      sales_unit: Stk
                    description: LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65
                    discount_percentage: '15.00'
                    gaeb_position_type: BASE
                    id: '840210'
                    is_counted_in_total: true
                    is_deleted: false
                    labels: []
                    line_gross: '4315.20'
                    line_net: '3668.16'
                    line_type: ARTICLE
                    list_price: '89.90'
                    manufacturer: Lumenflex GmbH
                    manufacturer_article_number: LM-DP1500-50
                    name: LED-Feuchtraumleuchte 1500 mm 50 W
                    net_price: '76.42'
                    position_number: 01.02.10
                    quantity: 48
                    unit: Stk
                  - article_number: LEU-1200-30-840
                    chapter_id: '5503'
                    chapter_number: '01.02'
                    custom_attributes:
                      article_type: YHAW
                      sales_unit: Stk
                    description: LED-Feuchtraumleuchte 1200 mm 30 W 4000 K IP65
                    discount_percentage: '15.00'
                    gaeb_position_type: BASE
                    id: '840215'
                    is_counted_in_total: true
                    is_deleted: false
                    labels: []
                    line_gross: '1677.60'
                    line_net: '1426.08'
                    line_type: ARTICLE
                    list_price: '69.90'
                    manufacturer: Lumenflex GmbH
                    manufacturer_article_number: LM-DP1200-30
                    name: LED-Feuchtraumleuchte 1200 mm 30 W
                    net_price: '59.42'
                    position_number: 01.02.15
                    quantity: 24
                    unit: Stk
                  - article_number: LEU-1500-40-840
                    chapter_id: '5503'
                    chapter_number: '01.02'
                    custom_attributes:
                      article_type: YHAW
                      sales_unit: Stk
                    description: >-
                      LED-Feuchtraumleuchte 1500 mm 40 W 4000 K IP65
                      (Alternativ)
                    discount_percentage: '15.00'
                    gaeb_position_type: ALTERNATIVE
                    id: '840220'
                    is_counted_in_total: false
                    is_deleted: false
                    labels:
                      - text: Alternativposition
                        type: ALTERNATIVE
                    line_gross: '3835.20'
                    line_net: '3260.16'
                    line_type: ARTICLE
                    list_price: '79.90'
                    manufacturer: Lumenflex GmbH
                    manufacturer_article_number: LM-DP1500-40
                    name: LED-Feuchtraumleuchte 1500 mm 40 W
                    net_price: '67.92'
                    position_number: 01.02.20
                    quantity: 48
                    unit: Stk
                  - chapter_id: '5503'
                    chapter_number: '01.02'
                    description: >-
                      Alle Leuchten inkl. Befestigungsmaterial liefern und
                      montagefertig anschließen.
                    id: '840290'
                    is_counted_in_total: true
                    is_deleted: false
                    labels: []
                    line_type: TEXT
                    position_number: 01.02.90
            id: '5502'
            is_pretext: false
            name: Elektroinstallation
            positions: []
        completed_at: '2026-07-09T11:59:58Z'
        created_at: '2026-07-08T09:14:00Z'
        customer_id: K-10042
        deadline: '2026-08-13'
        erp_offer_id: AB-2026-5567
        id: '12345'
        indirect_customer_id: K-20099
        project_id: '90'
        project_name: Neubau Bürogebäude Nord
        request_custom_fields:
          Angebots-Nr: AN-2026-0042
        request_id: '12345'
        status: DONE
        submission_date: '2026-08-20'
        totals:
          additional_discount_percentage: '3.00'
          gross_total: '5992.80'
          net_total: '4941.41'
          positions_subtotal: '5094.24'
        updated_at: '2026-07-09T12:00:12Z'
        user_custom_fields:
          SGI: '12345'
        user_email: anna.schmidt@example.com
    PublicTenderPosition:
      properties:
        id:
          type: string
          title: Id
          description: >-
            Opaque, re-match-stable line handle. Pass it back in ``PATCH
            /tenders/{tender_id}`` to edit this line; re-fetch on a
            ``404``/``412`` (a document reprocess recreates lines).
        position_number:
          type: string
          title: Position Number
          description: GAEB display number (e.g. '01.10'). Read-only.
        chapter_id:
          type: string
          title: Chapter Id
          description: >-
            Handle of the chapter this line sits in — the same value as the
            enclosing chapter's ``id``, repeated here so an ERP that stores a
            flat line list can keep the chapter reference without walking the
            nesting. Read-only.
        chapter_number:
          type: string
          title: Chapter Number
          description: >-
            GAEB number of the chapter this line sits in (e.g. '01.02'); empty
            when the chapter carries none. Read-only.
          default: ''
        line_type:
          $ref: '#/components/schemas/PublicTenderLineType'
          description: ARTICLE | TEXT | DIVERSE. Read-only.
        labels:
          items:
            $ref: '#/components/schemas/PublicTenderLabel'
          type: array
          title: Labels
          description: Role/label overlay ({text, type}); several possible. Read-only.
        gaeb_position_type:
          description: >-
            GAEB Positionsart, or null when the source LV carried none.
            Read-only.
          $ref: '#/components/schemas/PublicTenderGaebPositionType'
        article_number:
          title: Article Number
          description: Catalogue article number for an ARTICLE line; required to *add* one.
          type:
            - string
            - 'null'
        parent_line_id:
          title: Parent Line Id
          description: >-
            Set on an **article-note** line: the ``id`` of the line this note
            annotates (both sit on the same position). Null on every other line.
            Pass it back on an add to attach a note — see
            ``PublicTenderPositionUpdate.parent_line_id``.
          type:
            - string
            - 'null'
        manufacturer_article_number:
          title: Manufacturer Article Number
          description: >-
            Supplier/manufacturer article number (Lieferantenartikelnummer) as
            stored on the line; null when the line carries none. Read-only.
          type:
            - string
            - 'null'
        manufacturer:
          title: Manufacturer
          description: >-
            Manufacturer / supplier name (Hersteller/Lieferant) from the matched
            article, or null for a free-text line. Read-only.
          type:
            - string
            - 'null'
        name:
          title: Name
          description: >-
            The matched article's name — the article text maintained in Mercura
            (the title shown in the UI). Null for a free-text line. Read-only.
          type:
            - string
            - 'null'
        description:
          type: string
          title: Description
          description: >-
            Line description: the matched article's own ``description`` field,
            or the free-text for a text/DIVERSE line. Distinct from ``name``
            (the article title).
          default: ''
        additional_text:
          title: Additional Text
          description: Optional long-form note (Zusatztext), or null.
          type:
            - string
            - 'null'
        quantity:
          title: Quantity
          type:
            - number
            - 'null'
        unit:
          title: Unit
          type:
            - string
            - 'null'
        custom_attributes:
          title: Custom Attributes
          description: >-
            The matched article's catalogue custom attributes — same shape as
            ``GET /articles/{id}``'s ``custom_attributes`` (e.g. ``sales_unit``
            and other source-system unit/extra fields). Null for a free-text
            line. Read-only.
          additionalProperties: true
          type:
            - object
            - 'null'
        list_price:
          title: List Price
          description: Per-unit list price (Brutto/gross), EUR.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        discount_percentage:
          title: Discount Percentage
          description: >-
            Per-line manual discount percentage (0–100) on top of
            ``list_price``.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        net_price:
          title: Net Price
          description: Per-unit net price, EUR (``Selection.net_price_ea``).
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        line_net:
          title: Line Net
          description: '``net_price * quantity``, 2 dp. Read-only (derived).'
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        line_gross:
          title: Line Gross
          description: '``list_price * quantity``, 2 dp. Read-only (derived).'
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        is_counted_in_total:
          type: boolean
          title: Is Counted In Total
          description: >-
            False for lines excluded from the totals (alternatives, Bedarf ohne
            GP). Read-only.
          default: true
        partner_pricing_override_at:
          title: Partner Pricing Override At
          description: >-
            UTC time a partner last overrode this line's price via PATCH, or
            null. Read-only.
          format: date-time
          type:
            - string
            - 'null'
        is_deleted:
          type: boolean
          title: Is Deleted
          description: >-
            True when **this line** was removed from the tender — either the
            article was taken off the position, or the whole position went away
            (then every line of it is flagged). Only ever true in a response
            fetched with ``include_deleted=true``. Deleted lines never count
            toward ``totals``. Read-only.
          default: false
        deleted_at:
          title: Deleted At
          description: >-
            UTC time this line was removed, or null while it is live (also null
            on a legacy row deleted before removal times were recorded).
            Read-only.
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - id
        - position_number
        - chapter_id
        - line_type
      title: PublicTenderPosition
      description: >-
        One priced line of a tender (one per ``Selection``).


        ``id`` is the opaque, re-match-stable handle you pass back in

        ``PATCH /tenders/{tender_id}`` to edit this line. A GAEB position that

        carries several selections (a main article plus alternatives /
        accessories /

        notes) produces several lines that share a ``position_number`` but have

        distinct ``id``s.


        Lines **removed** from the tender are omitted by default and returned

        flagged (``is_deleted`` / ``deleted_at``) when the request asks for them
        via

        ``include_deleted=true`` — one flagged line per removed article, so an
        ERP

        keyed per article learns about a single article taken off an otherwise

        unchanged position. The ordinal number itself is gone exactly when *all*

        lines sharing its ``position_number`` come back flagged.
    PublicTenderPositionUpdate:
      properties:
        id:
          title: Id
          description: Line handle from GET (edit/delete); omit to add a line.
          type:
            - string
            - 'null'
        op:
          title: Op
          description: >-
            Set to ``delete`` (with ``id``) to remove this line (soft-delete).
            Omit to edit/add.
          const: delete
          type:
            - string
            - 'null'
        position_number:
          title: Position Number
          description: >-
            Only used when adding a line; ignored on edit (a line's number is
            read-only).
          type:
            - string
            - 'null'
        article_number:
          title: Article Number
          description: >-
            Required to add an ARTICLE line. On an **edit** it moves the line to
            that catalogue article (e.g. an availability-driven swap): the
            article's own fields (``description`` / ``name`` / ``manufacturer``
            / ``manufacturer_article_number`` / ``custom_attributes``) follow
            automatically, so send only the number. A number equal to the line's
            current one is a no-op, so a full-document resend is safe. Because
            every stored price belongs to the *previous* article, a swap must
            carry a price (``list_price``, ``net_price`` or
            ``discount_percentage``) — otherwise the edit is rejected — and the
            line's pricing is **reset to exactly what you send**: any price
            field you omit is cleared rather than carried over, and the replaced
            article's catalogue discount is dropped, so a percentage you send
            applies to the list price you send. Mercura does not re-derive
            catalogue prices for the new article here. Only ARTICLE lines can be
            swapped.
          type:
            - string
            - 'null'
        parent_line_id:
          title: Parent Line Id
          description: >-
            Add (or replace) an **article note** on the line with this ``id``:
            the note becomes a text line on the parent's position, so its
            ``description`` reaches the offer PDF, the GAEB export and the ERP
            payload (as the parent line's remark / Bemerkung) — unlike
            ``additional_text``, which no Mercura-side output reads. *Upsert* —
            re-sending for the same parent overwrites that parent's existing
            note rather than adding a second one, so a retried PATCH is safe.
            Add-only: send it without ``id`` and without ``article_number``,
            together with ``description``; ignored on an edit (``id`` present)
            so a GET body round-trips.
          type:
            - string
            - 'null'
        description:
          title: Description
          description: Free-text line description.
          maxLength: 10000
          type:
            - string
            - 'null'
        additional_text:
          title: Additional Text
          description: Zusatztext; empty string clears it.
          type:
            - string
            - 'null'
        quantity:
          title: Quantity
          minimum: 0
          type:
            - number
            - 'null'
        unit:
          title: Unit
          maxLength: 50
          type:
            - string
            - 'null'
        list_price:
          anyOf:
            - type: number
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: List Price
          description: Per-unit list price (Brutto), EUR.
        discount_percentage:
          anyOf:
            - type: number
              maximum: 100
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Discount Percentage
          description: >-
            0–100; manual discount applied on top of any catalogue discount (on
            the net-of-catalogue base, as in the UI), not off raw list_price.
            Mutually exclusive with ``net_price``; send ``net_price`` to pin an
            exact net.
        net_price:
          anyOf:
            - type: number
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Net Price
          description: Exact per-unit net price, EUR; xor ``discount_percentage``.
        line_type:
          description: Ignored on write (inferred).
          $ref: '#/components/schemas/PublicTenderLineType'
        labels:
          title: Labels
          description: Ignored on write (read-only).
          items:
            $ref: '#/components/schemas/PublicTenderLabel'
          type:
            - array
            - 'null'
        gaeb_position_type:
          description: Ignored on write.
          $ref: '#/components/schemas/PublicTenderGaebPositionType'
        chapter_id:
          title: Chapter Id
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        chapter_number:
          title: Chapter Number
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        manufacturer_article_number:
          title: Manufacturer Article Number
          description: Ignored on write.
          type:
            - string
            - 'null'
        manufacturer:
          title: Manufacturer
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        name:
          title: Name
          description: Ignored on write (read-only, article-derived).
          type:
            - string
            - 'null'
        custom_attributes:
          title: Custom Attributes
          description: Ignored on write (read-only).
          additionalProperties: true
          type:
            - object
            - 'null'
        line_net:
          anyOf:
            - type: number
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Line Net
          description: Ignored on write (derived).
        line_gross:
          anyOf:
            - type: number
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Line Gross
          description: Ignored on write (derived).
        is_counted_in_total:
          title: Is Counted In Total
          description: Ignored on write (derived).
          type:
            - boolean
            - 'null'
        partner_pricing_override_at:
          title: Partner Pricing Override At
          description: Ignored on write (provenance).
          format: date-time
          type:
            - string
            - 'null'
        is_deleted:
          title: Is Deleted
          description: >-
            Ignored on write (read-only). A line echoed back with ``true`` is
            skipped rather than rejected, so a document fetched with
            ``include_deleted=true`` round-trips through PATCH; use ``op:
            "delete"`` to remove a live line.
          type:
            - boolean
            - 'null'
        deleted_at:
          title: Deleted At
          description: Ignored on write (read-only).
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      title: PublicTenderPositionUpdate
      description: >-
        One line in a ``PATCH /tenders/{tender_id}`` body.


        * **Edit** — send ``id`` (from GET) plus the fields to change.

        * **Add article line** — omit ``id`` and send ``article_number``
        (resolved
          against your catalogue; deterministic, no matching).
        * **Add free-text line** — omit ``id`` and ``article_number`` and send
          ``description``.
        * **Add / replace an article note** — omit ``id`` and send
        ``parent_line_id``
          + ``description``. The note attaches to the parent line's existing position
          (no new position, no own position number) and is an *upsert*: re-sending
          for the same parent replaces its note instead of adding a second one.

        Price resolution per line: ``list_price`` (Brutto) is the anchor; then
        **at

        most one** of ``discount_percentage`` / ``net_price`` resolves the other

        (both ⇒ ``400``). Read-only fields are declared so a round-tripped GET
        body

        validates, but are ignored.
    PublicTenderStatus:
      type: string
      enum:
        - NEW
        - IN_PROGRESS
        - DONE
        - CANCELLED
        - PARSING
        - PARSING_FAILED
      title: PublicTenderStatus
      description: >-
        Lifecycle state of a tender (open enum — new values are SemVer MINOR).


        A translated subset of the internal ``RequestStatus``. The mapping lives
        in

        ``tenders.service._TENDER_STATUS_MAP`` and a test asserts it covers
        every

        internal value.
    PublicTenderTotals:
      properties:
        positions_subtotal:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Positions Subtotal
          description: >-
            Sum of ``line_net`` across counted lines (excl. alternatives /
            ``OPTIONAL_WITHOUT_TOTAL``), EUR. Derived helper — may differ from
            ``net_total``.
        additional_discount_percentage:
          title: Additional Discount Percentage
          description: Header-level discount on top of position prices, 0–100.
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          type:
            - string
            - 'null'
        net_total:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Net Total
          description: >-
            Authoritative final net (after the header discount), EUR — matches
            the PDF.
        gross_total:
          type: string
          pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
          title: Gross Total
          description: Authoritative gross total, EUR — matches the PDF.
        partner_totals_override_at:
          title: Partner Totals Override At
          description: >-
            UTC time a partner last overrode ``net_total``/``gross_total`` via
            PATCH, or null. Read-only.
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      required:
        - positions_subtotal
        - net_total
        - gross_total
      title: PublicTenderTotals
      description: >-
        Aggregate amounts for the tender, all in EUR.


        ``positions_subtotal`` is a **derived helper**: the sum of ``line_net``
        across

        lines with ``is_counted_in_total = true`` (i.e. excluding alternatives
        and

        Bedarfspositionen ohne Gesamtbetrag / ``OPTIONAL_WITHOUT_TOTAL``).

        ``net_total`` / ``gross_total`` are the **authoritative** request totals
        — the

        same figures Mercura prints on the PDF — preferring the request-level
        persisted

        values (which a partner can override via PATCH — see

        ``PublicTenderTotalsUpdate``) and otherwise falling back to the line
        sums.


        The two can legitimately differ: ``net_total`` is *after* the header

        ``additional_discount_percentage`` (``positions_subtotal`` is before
        it), and

        Mercura's persisted total may include optional positions or other
        adjustments

        that the counted-line subtotal omits. Treat ``net_total`` as the source
        of

        truth for the amount owed and ``positions_subtotal`` as an informational
        sum.
    PublicTenderTotalsUpdate:
      properties:
        additional_discount_percentage:
          anyOf:
            - type: number
              maximum: 100
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Additional Discount Percentage
        net_total:
          anyOf:
            - type: number
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Net Total
          description: Partner's authoritative net total, EUR.
        gross_total:
          anyOf:
            - type: number
              minimum: 0
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Gross Total
          description: Partner's authoritative gross total, EUR.
        positions_subtotal:
          anyOf:
            - type: number
            - type: string
              pattern: ^(?!^[-+.]*$)[+-]?0*\d*\.?\d*$
            - type: 'null'
          title: Positions Subtotal
          description: Ignored on write (derived).
        partner_totals_override_at:
          title: Partner Totals Override At
          description: Ignored on write (provenance).
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      title: PublicTenderTotalsUpdate
      description: >-
        Writable summary. ``net_total`` / ``gross_total`` persist as the
        partner's

        authoritative amounts and win over the server recalculation — they may

        intentionally diverge from the line sums (freight, rounding,
        surcharges).

        ``positions_subtotal`` is declared for round-trip but ignored (derived).
    PublicTenderUpdate:
      properties:
        totals:
          description: >-
            Optional summary-total overrides (net/gross totals, header
            discount).
          $ref: '#/components/schemas/PublicTenderTotalsUpdate'
        positions:
          items:
            $ref: '#/components/schemas/PublicTenderPositionUpdate'
          type: array
          title: Positions
          description: >-
            Sparse edits/deletes addressed by line ``id`` (no chapter path).
            Adds must use ``chapters``, except an article note
            (``parent_line_id``), which is accepted here.
        chapters:
          items:
            $ref: '#/components/schemas/PublicTenderChapterUpdate'
          type: array
          title: Chapters
        erp_offer_id:
          title: Erp Offer Id
          description: >-
            Your ERP's id for the offer created from this tender. **Set-only**:
            a value is written and kept, ``null`` (or omitting the key) leaves
            the stored id untouched so a full GET body round-trips, and there is
            no way to clear it — send a new value to replace it. The same field
            is set by an acknowledgement's ``external_id``; use whichever fits
            your flow (the acknowledgement additionally records the import
            outcome).
          maxLength: 255
          minLength: 1
          type:
            - string
            - 'null'
        request_custom_fields:
          title: Request Custom Fields
          description: >-
            Label-keyed REQUEST custom-field values to shallow-merge onto the
            tender's underlying request. Keys are the organisation's
            custom-column labels; ``multi_select`` values are option labels. A
            ``null`` *value* clears that field; sending the whole object as
            ``null`` (or omitting it) is a no-op, so a full GET body
            round-trips. Unknown labels or option labels are a 400. Symmetric
            with the ``request_custom_fields`` you read on GET.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
              - type: 'null'
          type:
            - object
            - 'null'
        status:
          description: >-
            Lifecycle status. The only writable transition is to ``CANCELLED`` —
            use it to close a tender that was resolved outside Mercura (e.g. its
            case was closed in your CRM), so it leaves the open inbox. Sending
            the tender's *current* status is a no-op, so a full GET body still
            round-trips. Any other target is a ``400``; cancelling a tender that
            is ``PARSING`` or already ``DONE`` is a ``409``.
          $ref: '#/components/schemas/PublicTenderStatus'
        id:
          title: Id
          description: Ignored on write.
          type:
            - string
            - 'null'
        request_id:
          title: Request Id
          description: Ignored on write.
          type:
            - string
            - 'null'
        completed_at:
          title: Completed At
          description: Ignored on write.
          format: date-time
          type:
            - string
            - 'null'
        customer_id:
          title: Customer Id
          description: Ignored on write.
          type:
            - string
            - 'null'
        indirect_customer_id:
          title: Indirect Customer Id
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        project_id:
          title: Project Id
          description: Ignored on write.
          type:
            - string
            - 'null'
        project_name:
          title: Project Name
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        submission_date:
          title: Submission Date
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        deadline:
          title: Deadline
          description: Ignored on write (read-only).
          type:
            - string
            - 'null'
        user_email:
          title: User Email
          description: Ignored on write.
          type:
            - string
            - 'null'
        branch:
          title: Branch
          description: Ignored on write (read-only).
          additionalProperties: true
          type:
            - object
            - 'null'
        user_custom_fields:
          title: User Custom Fields
          description: Ignored on write (read-only).
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        acknowledgement:
          title: Acknowledgement
          description: Ignored on write.
          additionalProperties: true
          type:
            - object
            - 'null'
        created_at:
          title: Created At
          description: Ignored on write.
          format: date-time
          type:
            - string
            - 'null'
        updated_at:
          title: Updated At
          description: Ignored on write.
          format: date-time
          type:
            - string
            - 'null'
      additionalProperties: false
      type: object
      title: PublicTenderUpdate
      description: >-
        Body of ``PATCH /tenders/{tender_id}`` — a partial, in-place edit.


        Two ways to address lines, freely combined:


        * **Top-level ``positions[]``** — the ergonomic *sparse* path and the
          recommended contract for ERP sync: each entry edits or deletes an existing
          line **by ``id``** (line ids are unique within a tender, so no chapter path
          is needed). Send only the lines you changed.
        * **``chapters[]``** — the nested tree. Required to **add** a line (the
        target
          chapter matters); edits/deletes are also accepted here so a GET body
          round-trips. Omitted lines/chapters are left untouched. An **article note**
          (``parent_line_id``) is the one add that needs no chapter path — the parent
          line names its position — so it may go in either list.

        Optionally send ``totals`` overrides (incl. the header discount),
        label-keyed

        ``request_custom_fields``, or ``status: "CANCELLED"`` to close a tender
        that

        was resolved outside Mercura. The full read body (``PublicTenderOut``)
        is

        accepted verbatim — extra read-only fields are tolerated, unknown keys
        are

        ``400``.
      example:
        erp_offer_id: SAP-4500012345
        positions:
          - id: '840210'
            net_price: '74.90'
          - article_number: LEU-1200-30-830
            id: '840215'
            net_price: '71.10'
          - id: '840290'
            op: delete
        totals:
          additional_discount_percentage: '3.00'
    PublicUnitConversionIn:
      properties:
        article_number:
          type: string
          maxLength: 255
          minLength: 1
          pattern: ^[A-Za-z0-9._\-]{1,255}$
          title: Article Number
          description: Article the conversion applies to.
        alternative_unit:
          type: string
          maxLength: 50
          minLength: 1
          title: Alternative Unit
          description: The alternative unit code, e.g. 'CTN', 'KG'.
        numerator:
          type: number
          exclusiveMinimum: 0
          title: Numerator
          description: 'Ratio numerator: base-unit quantity per one alternative unit.'
        denominator:
          type: number
          exclusiveMinimum: 0
          title: Denominator
          description: Ratio denominator (usually 1).
      additionalProperties: false
      type: object
      required:
        - article_number
        - alternative_unit
        - numerator
        - denominator
      title: PublicUnitConversionIn
      description: |-
        One unit-conversion rule in a bulk-write payload.

        The article must already exist for your organisation; a row whose
        article is unknown is skipped (counted in the job's ``skipped_count``),
        not an error.
    PublicUnitConversionOut:
      properties:
        id:
          type: integer
          title: Id
          description: Internal unit-conversion id.
        article_number:
          type: string
          title: Article Number
        alternative_unit:
          type: string
          title: Alternative Unit
        numerator:
          type: number
          title: Numerator
        denominator:
          type: number
          title: Denominator
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - article_number
        - alternative_unit
        - numerator
        - denominator
        - created_at
        - updated_at
      title: PublicUnitConversionOut
      description: One unit-conversion rule in a list response.
    PublicUserOut:
      properties:
        id:
          type: string
          title: Id
          description: >-
            Mercura user id (stable UUID). This is the id used across the API to
            reference the user; pass it to ``GET /users/{user_id}``.
        email:
          type: string
          title: Email
          description: The user's login email — matches the tender's ``user_email``.
        name:
          type: string
          title: Name
        external_id:
          title: External Id
          description: >-
            External / employee id from your HR or ERP system, when set on the
            user.
          type:
            - string
            - 'null'
        role:
          title: Role
          description: >-
            The user's role within the organisation (e.g. ``ADMIN``,
            ``MANAGER``, ``MEMBER``).
          type:
            - string
            - 'null'
        language:
          type: string
          title: Language
          description: >-
            The user's UI language as an upper-case ISO 639-1 code (e.g. ``DE``,
            ``EN``).
        is_active:
          type: boolean
          title: Is Active
          description: Whether the user is active in Mercura.
        custom_fields:
          title: Custom Fields
          description: >-
            Organisation-defined custom fields, keyed by the field's display
            **label** (e.g. an ERP/SAP id used for offer creation). Columns with
            no active definition are omitted; null when the user carries none.
            On duplicate labels the field defined first (by display order, then
            creation time) wins.
          additionalProperties:
            anyOf:
              - type: string
              - type: integer
              - type: number
              - type: boolean
              - items:
                  type: string
                type: array
          type:
            - object
            - 'null'
        created_at:
          type: string
          format: date-time
          title: Created At
        updated_at:
          type: string
          format: date-time
          title: Updated At
      additionalProperties: false
      type: object
      required:
        - id
        - email
        - name
        - language
        - is_active
        - created_at
        - updated_at
      title: PublicUserOut
      description: One user in a lookup / get-by-id response.
      example:
        created_at: '2026-01-05T08:00:00Z'
        custom_fields:
          SAP-Benutzer: SAP0042
        email: anna.schmidt@example.com
        external_id: MA-1042
        id: 3f8b6d21-9a4c-4e77-b0e2-1c5d8a9f4e10
        is_active: true
        language: DE
        name: Anna Schmidt
        role: MEMBER
        updated_at: '2026-06-30T16:45:00Z'
    ValidationError:
      properties:
        loc:
          items:
            anyOf:
              - type: string
              - type: integer
          type: array
          title: Location
        msg:
          type: string
          title: Message
        type:
          type: string
          title: Error Type
        input:
          title: Input
        ctx:
          type: object
          title: Context
      type: object
      required:
        - loc
        - msg
        - type
      title: ValidationError
tags:
  - name: Tenders
    description: >
      A **tender** is an incoming LV (Leistungsverzeichnis / bill of quantities)

      or RFQ: the customer demand Mercura matches against your article catalogue

      and prices into an offer. Tenders are one of the two entry points of the

      procurement workflow (the other is `orders`).


      ## Creating a tender


      `POST /tenders` is the programmatic twin of forwarding an LV / RFQ email
      to

      `anfragen@lv.mercura.ai`: send the same files you would attach to the mail

      and Mercura runs the identical intake pipeline — same parsers, same

      matching, same landing states. Accepted files: GAEB

      (`.d81/.d83/.d94/.p81/.p83/.p93/.p94/.x81/.x83/.x93/.x94/.onlv`), `.xlsx`,

      `.csv`, `.docx`, `.txt`, `.pdf`, images, and `.eml`. Max 50 MB per file,

      100 MB total, 20 files. Mercura picks the file that drives parsing (GAEB

      before spreadsheet/CSV/DOCX/TXT before PDF before image); at least one
      file

      must be parsable.


      Use the optional `context` field to pass anything that helps Mercura

      process the tender — the original email body, delivery notes, or guidance

      for article matching. An unresolvable `customer_id` / `customer_email` is

      **not** an error: the tender is created without a customer, exactly like
      an

      email from an unknown sender.


      ### Carrying your own reference and routing


      Two optional form fields let a CRM / ERP hand over the context Mercura

      cannot infer from the files:


      | Field | What it does |

      |---|---|

      | `custom_fields` | JSON object of your organisation's custom fields,
      keyed by the field's **label** — e.g. `{"Case Number": "00012345"}`. Comes
      back unchanged as `request_custom_fields` on `GET /tenders/{tender_id}`. |

      | `branch` | Name of the Mercura branch (Niederlassung), matched
      case-insensitively. Routes the tender to the team that works that branch.
      |


      Both are validated against your organisation's configuration **before**
      the

      job is created, so a typo is a plain `400` with no job and no half-created

      tender: an unknown branch name, an unknown custom-field label, or a

      duplicate branch name all fail fast. This is deliberately stricter than

      `customer_id` — a tender that lands unrouted or without its case number is

      invisibly wrong, which is worse than a rejected upload.


      ```bash

      curl -X POST https://prod-euapi.mercura.ai/api/public/v1/tenders \
        -H "Authorization: Bearer $MERCURA_API_KEY" \
        -H "Idempotency-Key: case-00012345" \
        -F "files=@Leistungsverzeichnis.x83" \
        -F "name=Neubau Bürogebäude Nord" \
        -F "branch=Niederlassung München" \
        -F 'custom_fields={"Case Number": "00012345"}'
      ```


      Store the `request_id` that `GET /jobs/{job_id}` returns: it is the

      `tender_id` for every later call, and the stable handle to correlate the

      tender with the record on your side.


      The upload is accepted in seconds; the job stays `RUNNING` until the

      **processing pipeline finishes** (typically a few minutes). Learn the

      outcome either by polling `GET /jobs/{job_id}` until `status` is

      `COMPLETED` / `FAILED`, or by subscribing to the

      `request.processing_completed` webhook and correlating by `job_id`.


      ## Reading tenders


      A tender is **keyed by the request id** (`tender_id == request_id`):
      reading

      one returns the tender's *current* projected state — the structured,
      priced

      result Mercura produces — carrying everything on the corresponding PDF the

      customer sees:


      - **`status`** — lifecycle state (`NEW`, `IN_PROGRESS`, `DONE`,
      `CANCELLED`,
        `PARSING`, `PARSING_FAILED`). `DONE` means the tender has been completed
        (released to the partner API via the seller's **Finalize** / Export → API
        action — which is what stamps `completed_at` and fires `tender.completed`).
      - **`completed_at`** — when the tender was last completed, or `null` if it
        never was. Refreshed on **every** completion, so a re-export moves it
        forward (see the completion feed below).
      - **`customer_id`** — the **direct** customer (Sold-To) **reference only**
      (your
        `BusinessPartner.external_id`); resolve name / address / contact / custom
        fields via `GET /customers/{customer_id}`. Null when the tender has no customer.
      - **`indirect_customer_id`** — the **indirect** customer (Mercura's
      *indirekter
        Kunde*) **reference only**; resolve the full record via
        `GET /customers/{customer_id}`. Distinct from `customer_id`. Null when the
        tender has no indirect customer.
      - **`project_id`** — the project **reference only**; resolve the full
      record via
        `GET /projects/{project_id}`. Null when the tender has no project.
      - **`project_name`** — the project's display name, surfaced inline (same
      value as
        `name` on `GET /projects/{project_id}`) so you need no second call. Null when
        the tender has no project.
      - **`submission_date`** — the bid-submission date
      (Submissions-/Abgabetermin) as
        originally supplied on the project: an ISO date **or** free text, `null` when
        unknown. This is the tender's **submission deadline**, not an offer-validity
        (`gültig bis`) date. Mirrors the project's `submission_date`.
      - **`deadline`** — Mercura's **internal** handling deadline for the
      request
        (typically the submission date minus a buffer): an ISO date or free text,
        `null` when unset. Informational only — not the submission deadline and not an
        offer-validity date.
      - **`user_email`** — the responsible Mercura user for the tender (mirrors
      the
        integration-export `user_email`).
      - **`branch`** — the primary Mercura branch (Niederlassung) assigned to
      the
        tender as `{ id, name }`, or `null` when none is assigned.
      - **`request_custom_fields`** — custom fields on the underlying request,
      keyed by
        display label (`multi_select` values resolved to their option labels). **Writable**
        on `PATCH` (see *Editing a tender*).
      - **`user_custom_fields`** — custom fields on the responsible user
      (`user_email`),
        keyed by display label — e.g. a user's `SGI`. `null` when the tender has no
        responsible user or the user has no custom fields. (You can still resolve the
        same fields via `GET /users?email={user_email}`.)
      - **`chapters[]`** — the title tree. Each chapter carries an `id`,
        `chapter_number`, `name`, `is_pretext` (a Vorbemerkung), nested `chapters[]`,
        and `positions[]`.
      - **`positions[]`** (per chapter) — one entry per line, each with:
        - **`id`** — an opaque, re-match-stable handle (the position's request-scoped
          number); the key you pass back to edit the line.
        - **`line_type`** — `ARTICLE` / `TEXT` / `DIVERSE`.
        - **`labels[]`** — role/label overlay, each `{ text, type }` (`type` is
          `CUSTOM` / `ALTERNATIVE` / `ACCESSORY` / `PARTS_LIST`); a `CUSTOM` label
          carries your org-defined text (product groups, "Bestellware", …).
        - **`gaeb_position_type`** — the GAEB Positionsart (`BASE`,
          `OPTIONAL_WITH_TOTAL`, `OPTIONAL_WITHOUT_TOTAL`, `ALTERNATIVE`) or null.
        - `article_number`, `manufacturer_article_number`, `name`, `description`,
          `additional_text`, `quantity`, `unit`, `list_price`, `discount_percentage`,
          `net_price`, `line_net`, `line_gross`.
        - **`parent_line_id`** — set only on an **article-note** line: the `id` of the
          line it annotates (both sit on the same position). Null everywhere else.
        - **`name`** — the matched article's name: the article text maintained in Mercura
          (the title shown in the UI). Null for a free-text line.
        - **`description`** — the matched article's own `description` field (distinct from
          `name`), or the free-text for a text line.
        - **`manufacturer`** — the matched article's manufacturer / supplier name
          (Hersteller/Lieferant), or null for a free-text line.
        - **`custom_attributes`** — the matched article's catalogue custom attributes
          (same shape as `GET /articles/{id}`'s `custom_attributes`, e.g. `sales_unit`,
          the article type `article_type`, and other source-system unit/extra fields),
          or null for a free-text line.
        - For a `DIVERSE` line matched to a generic "Divers" placeholder article,
          `description` is the operator-typed text (what you see in the matching UI),
          not the placeholder article's catalogue text.
        - **`is_counted_in_total`** — false for lines excluded from the totals
          (alternatives, Bedarfsposition ohne Gesamtbetrag).
        - **`partner_pricing_override_at`** — when a partner last overrode the line's
          price via PATCH, else null.
        - **`is_deleted`** / **`deleted_at`** — whether **this line** was removed from
          the tender, and when. Only ever set when you ask for removed lines
          (`include_deleted=true`, see *Removed lines and positions*).
      - **`totals`** — `positions_subtotal`, `additional_discount_percentage`,
        `net_total`, `gross_total` (EUR), and `partner_totals_override_at`.
        `positions_subtotal` is a derived helper: the sum across lines with
        `is_counted_in_total = true` (excludes alternatives and Bedarfspositionen ohne
        Gesamtbetrag). `net_total` / `gross_total` are the **authoritative** amounts that
        match the PDF — they are *after* the header discount and may include optional
        positions or other adjustments, so they can legitimately differ from
        `positions_subtotal`. Treat `net_total` as the amount owed.
      - **`erp_offer_id`** — the partner-side ERP offer id when known (populated
        from your acknowledgement's `external_id`, or from a Mercura-managed ERP
        export).
      - **`acknowledgement`** — your last reported import outcome for this
      tender
        (`status`, `external_id`, `message`, `metadata`, `acknowledged_at`), or
        `null` until you acknowledge it (see below).

      You can:


      - **`GET /tenders`** — cursor-paginated list. Use `modified_since` for
        delta-sync catch-up scans, or `status` to filter by lifecycle state.
      - **`GET /tenders/{tender_id}`** — one tender in full. This is what the
        `tender.completed` webhook points you at. The response carries a strong
        **`ETag`** over the tender content; pass it back as `If-Match` on PATCH.

      ### Removed lines and positions


      By default a tender contains only what is currently on it: a line removed
      after

      you imported the tender simply stops appearing, which leaves you unable to
      tell

      it apart from one that never existed. Pass **`include_deleted=true`** on

      `GET /tenders` or `GET /tenders/{tender_id}` and removed lines come back

      **flagged** instead of omitted, so you can delete the corresponding record
      in

      your own system:


      - **`is_deleted`** is `true` on the removed line and **`deleted_at`**
      carries the
        UTC time *that line* was removed (`null` on a legacy row removed before those
        times were recorded).
      - Removals are reported per **line**. Take one article off a position and
      that
        article comes back flagged next to the position's live lines — which is what
        you need if you keep one record per matched article. The `id` tells you which
        record to drop.
      - **A whole position (Ordnungszahl) is gone exactly when *all* lines
      sharing its
        `position_number` come back flagged.** If you key your records on the ordinal
        number rather than per article, that is your signal; a position with at least
        one unflagged line still exists.
      - Removed lines never count toward `totals`, and the `ETag` covers the
      live
        content only. A document fetched with `include_deleted=true` therefore
        round-trips through `PATCH /tenders/{tender_id}` unchanged: echoed removed
        lines are ignored.
      - A position that Mercura merely flags as *not relevant* is not a removal
      and
        stays hidden either way.
      - Line `id`s are stable across a removal — the live primary line keeps its
        handle. They do change when a source document is **reprocessed**, which
        recreates the tender's positions and lines (and changes the `ETag`).

      ## Tender documents


      `GET /tenders/{tender_id}/documents` lists the **source documents** the
      customer

      uploaded into the tender — the same attachments a forwarded LV / RFQ email

      carried (GAEB, PDF, Excel, images, `.eml`), or the files a `POST /tenders`

      upload sent. Use it to archive the customer's original request alongside
      the

      priced offer you write back into your ERP.


      ```

      GET /tenders/63365/documents

      ```

      ```json

      {
        "data": [
          {
            "id": "b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34",
            "filename": "LV_Tiefgarage_Beleuchtung.gaeb",
            "content_type": "application/xml",
            "size_bytes": 284913,
            "created_at": "2026-07-08T09:14:00Z",
            "download_url": "https://s3.fra1.example.com/mercura/files/…?X-Amz-Expires=3600&X-Amz-Signature=…",
            "download_url_expires_at": "2026-07-08T10:14:00Z"
          }
        ]
      }

      ```


      - **Only the customer's own uploads are listed.** Mercura-generated
      artefacts —
        the offer / display PDF, the structured-doc preview, and internal OCR /
        metadata side-files — and any hidden files are excluded. If a tender has no
        uploaded documents, `data` is an empty array.
      - **`download_url` is a short-lived, pre-signed link** to the bytes in
      object
        storage. Fetch it with a plain `GET` — **no `Authorization` header** — and the
        file downloads with its original name and content type. The API never proxies
        the file contents.
      - **The link expires** at `download_url_expires_at` (one hour). Treat both
      the
        `download_url` and the whole document set as transient: re-list to obtain fresh
        links rather than persisting a URL. In non-production environments where the
        storage backend cannot sign URLs, `download_url` and its expiry are `null`.
      - **`id`** is the file's stable id: it is stable across re-lists, so you
      can skip
        a document you have already imported.
      - An unknown, non-tender, or foreign id returns `404` — indistinguishable
      from a
        missing tender.

      ## Editing a tender


      `PATCH /tenders/{tender_id}` is a **partial, in-place** edit that returns
      the

      re-projected tender.


      **Recommended: send only what changed (sparse).** Put the lines you
      touched in a

      top-level `positions[]`, each addressed by its `id` — no chapter path
      needed

      (line ids are unique within a tender). This is the primary contract for
      keeping

      an ERP in sync and the one that scales: the body is proportional to your
      edit,

      not to the tender.


      ```

      PATCH /tenders/63365

      { "positions": [ { "id": "10", "net_price": "1.00" }, { "id": "11", "op":
      "delete" } ] }

      ```


      **Also supported: full-document round-trip.** The body is field-aligned
      with the

      GET response, so you *can* GET a tender, change fields in the nested
      `chapters[]`

      tree, and send it all back — read-only fields are tolerated and ignored,
      unknown

      keys are a `400`. Prefer sparse for large tenders: a full round-trip
      re-sends and

      re-resolves every line. (A discounted line's GET carries `list_price` +

      `discount_percentage` + `net_price` together; that's accepted as long as
      they're

      consistent — but if you *change* a price, send only the field you changed,
      or a

      contradictory pair is a `400`.)


      - **Edit a line** — top-level `positions[]` (by `id`), or under its
      chapter.
        Price resolution: `list_price` (Brutto) is the anchor, then **at most one** of
        `discount_percentage` / `net_price` resolves the other:
        - `discount_percentage` is a **manual** discount applied *on top of* any
          catalogue/pricing-rule discount already on the line — i.e. on the
          net-of-catalogue base, exactly as the Mercura UI applies it — not a raw
          percentage off `list_price`. Editing it recomputes `net_price` off that base
          (a catalogue discount is preserved).
        - `net_price` sets the exact per-unit net; the resulting `discount_percentage`
          is back-computed from it. **To pin an exact net, send `net_price`.**

        Also editable: `quantity`, `unit`, `additional_text`. `description` is editable
        only on a free-text line — on an ARTICLE line it comes from the catalogue and
        is read-only (sent values are ignored, so a GET body still round-trips).

        Note on `additional_text`: it is stored and returned, and some ERP
        integrations consume it as their long-text element, but **no Mercura-side
        output reads it** — neither the offer PDF nor the GAEB export. To put text on a
        line that reaches those, use an **article note** (below).
      - **Annotate a line (article note)** — send `parent_line_id` (the `id` of
      the
        line to annotate) plus `description`, and **no** `id`. The note becomes a text
        line on the parent's own position — no new position, no own position number —
        and its text follows the path an operator-typed note takes: into Mercura's
        offer PDF and GAEB export, and folded into the parent line's remark
        (`Bemerkung`) in the ERP payload.

        This is the one add that needs no chapter path (the parent line already names
        its position), so it is accepted in the top-level `positions[]` too. It is an
        **upsert**: sending a note for a parent that already has one *replaces* that
        note, so a retried PATCH cannot pile up duplicates. A note is text only —
        `article_number`, prices, `quantity`, `unit` and `additional_text` on a note
        are rejected with a `400` rather than silently dropped, and a note never
        enters the totals. To remove one, address the note line itself with
        `{ "id": "…", "op": "delete" }`.
      - **Delete a line** — `{ "id": "…", "op": "delete" }` (soft-delete). Works
      in the
        top-level `positions[]` or under a chapter.
      - **Add a line** — include a position **without** an `id` under an
      existing
        chapter (adds need the chapter target, so they can't go in top-level
        `positions[]` — an article note is the exception). With an `article_number` it becomes an article line (resolved
        against your catalogue — an unknown number is a `400`); without one, send
        `description` for a free-text line. Either way you can set the line's price and
        discount on the add (`list_price` + one of `discount_percentage` / `net_price`).
      - **Summary totals** — `totals.net_total` / `totals.gross_total` persist
      as your
        authoritative amounts and **win over** Mercura's recalculation (they may
        legitimately differ from the line sums — freight, rounding, surcharges).
        `totals.additional_discount_percentage` sets the header discount.
      - **Request custom fields** — send a label-keyed `request_custom_fields`
      object to
        shallow-merge values onto the tender's underlying request (symmetric with the
        read side — send back exactly what you GET). A `null` **value** clears that field;
        a whole-object `null` (or omitting it) is a no-op, so a full GET body round-trips.
        An unknown label or an unknown `multi_select` option label is a `400`. Use this to
        report partner-maintained values back with the prices — e.g. an "Offer Validity"
        (Angebotsgültigkeit) custom field maintained in your ERP.
      - **Close a tender** — see *Closing a tender* below.

      - **Concurrency** — pass the GET's `ETag` as `If-Match`; if the tender
      changed
        since you read it you get a `412` (re-fetch and retry). Omitting `If-Match`
        skips the check (last-write-wins).

      Apart from an explicit `status` write, editing does not change the
      tender's

      lifecycle `status` and never re-triggers an export.


      ### Closing a tender


      Not every tender that reaches Mercura is worked in Mercura. A small one
      may be

      quoted by hand, or it may belong to a product line that is not onboarded —
      and

      its case is then closed in your CRM while the tender stays open in Mercura

      forever. Left alone, those accumulate until the open list is unusable.


      Send `status: "CANCELLED"` to close such a tender so it leaves the open
      inbox:


      ```

      PATCH /tenders/63365

      { "status": "CANCELLED" }

      ```


      - **`CANCELLED` is the only writable transition.** Everything else is
        Mercura-side workflow — a tender becomes `DONE` by being exported, and
        `NEW → IN_PROGRESS` when someone starts working it. Any other target is a
        `400`.
      - **Echoing the current status is a no-op**, so a full GET body still
        round-trips through PATCH whatever the tender's status is.
      - **`409` for `PARSING` and `DONE`.** A `PARSING` tender is mid-flight
      (retry
        once it lands); a `DONE` one has already been completed in Mercura, and
        silently downgrading that to `CANCELLED` would lose the result. Everything
        else — `NEW`, `IN_PROGRESS`, `PARSING_FAILED` — is cancellable.
      - Cancelling is **not** an acknowledgement. Use
        `POST /tenders/{tender_id}/acknowledgements` to report an *import outcome*;
        use this to close a tender nobody will work.

      ## Acknowledging a tender


      - **`POST /tenders/{tender_id}/acknowledgements`** — after you import a
        tender's offer into your ERP/CRM, report the result back so the Mercura
        operator can see it. Send `status: "SUCCESS"` (optionally with your own
        `external_id`, a free-text `message`, and any `metadata`), or
        `status: "FAILED"` with a required `message` describing why. The call is
        idempotent and **latest-wins** for the status and message (a retry that
        first failed then succeeded ends up `SUCCESS`); a once-reported
        `external_id` is kept even if a later acknowledgement omits it. Your
        `external_id` is also written back to the tender's `erp_offer_id`, so it
        shows up everywhere Mercura reads that field. The response body is the
        recorded acknowledgement only — `{ status, external_id, message, metadata,
        acknowledged_at }`, the same shape as the tender's `acknowledgement` field —
        a small deterministic confirmation of what Mercura persisted (including the
        sticky `external_id`) without refetching the whole tender. Recording-only:
        it does not change the tender's lifecycle `status` or re-trigger an export.

      ## Reporting lifecycle events


      - **`POST /tenders/{tender_id}/events`** — report a lifecycle event your
      own
        system recorded for a tender *after* the initial acknowledgement: the tender
        was marked **Won**, **Lost**, or **Cancelled** in your CRM, and so on. Unlike
        an acknowledgement (one latest-wins outcome), events are **append-only** — one
        row per report — so the full timeline is preserved.
      - Send an `event_type` (a normalised token: uppercase letters, digits, and
        underscores, e.g. `WON` / `LOST` / `CANCELLED`), and optionally a
        `status_label` (your verbatim label, e.g. "Closed Won"), a `message`, an
        `occurred_at` (when it happened on your side), and any `metadata`.
      - Supply an **`external_event_id`** to make retries safe: reposting the
      same id
        with the same payload returns the already-stored event with `200` (instead of
        recording a duplicate), while reusing the id with a *different* payload is a
        `409`. Without it, every call records a new event.
      - The response is the recorded event — `{ id, event_type, status_label,
        message, metadata, external_event_id, occurred_at, recorded_at }` — with `201`
        for a newly recorded event.
      - **Recording-only**: reporting an event never changes the tender's
      lifecycle
        `status` or re-triggers an export. These are events *you* report to Mercura —
        not the webhook deliveries Mercura sends you (see the *Webhooks* chapter).

      ### Completion feed


      To pull every tender that was completed recently — the typical ERP

      write-back trigger — pass **`completed_since`** (an ISO-8601 instant):


      ```

      GET /tenders?completed_since=2026-07-09T11:55:00Z

      ```


      This returns only tenders with `completed_at >= completed_since`, ordered
      by

      `completed_at` ascending, and paginates with the same opaque `cursor`.
      Keep

      `completed_since` present on every page of the feed (its value is ignored

      once a `cursor` is supplied). Because `completed_at` is refreshed on every

      completion, a re-exported tender reappears in the feed — combine this with

      the `tender.completed` webhook for a robust push-plus-reconcile
      integration.


      > **Note.** `/tenders` reflects the request's *current* state: it returns
      one

      > row per tender, not one row per historical export event. A re-exported

      > tender reappears in the feed (via a refreshed `completed_at`) rather
      than

      > accumulating separate rows.
  - name: Orders
    description: >
      An **order** is an incoming purchase order (Bestellung): a customer places

      a firm order and Mercura extracts the order metadata, matches the line

      items against your article catalogue, and prepares it for ERP export.

      Orders are one of the two entry points of the workflow (the other is

      `tenders`).


      ## Creating an order


      `POST /orders` is the programmatic twin of forwarding a purchase-order

      email: send the order document(s) and Mercura runs the same intake

      pipeline. Accepted files, size caps, primary-file selection, `context`,

      customer resolution, idempotency, and the async `JobAck` → poll /

      webhook flow are all identical to `POST /tenders` — see the Tenders

      chapter. The only difference is the request type: an order skips LV

      parsing and runs the order-processing agent (metadata extraction + line

      matching).


      ## Reading orders


      `GET /orders` and `GET /orders/{order_id}` return an order's matched line

      items plus order-specific metadata. Keyed by the **request id** — an order

      has a single result, so `order_id == request_id`. (Note: unlike a tender —

      which is now a nested `chapters[]` tree with id-only `customer_id` /

      `project_id` — an order returns a flat `positions[]` list and full

      `customer` / `project` objects.)


      - **`status`** — the order's workflow state: `PROCESSING`, `NEEDS_REVIEW`
        (metadata or line matches need a human), `READY_TO_EXPORT`, `EXPORTED`,
        `ARCHIVED`.
      - **`completed_at`** — when the order was last completed (finalised /
        exported), or `null` if it never was. Refreshed on every completion; drives
        the completion feed below.
      - **`customer`** — the resolved business partner (`customer_id`, name,
        address, contact person). **`project`** — `object_number` and name.
        **`request_custom_fields`** — organisation-defined custom fields on the
        request.
      - **`positions[]`** — one entry **per line item**. Each carries
        `position_number`, `article_number` (the catalogue match, when the line
        was matched), `description`, `quantity`, and `unit`. A line the agent
        could not match still appears, with its verbatim number / description and
        a null `article_number`.
      - **`order_metadata`** — the extracted order header: `customer_reference`
        (the customer's PO number), `order_date`, `quote_number`,
        `project_number`, `requested_delivery_date`, `shipping_terms`,
        `contact_person`, `special_instructions`, and the `delivery_recipient` /
        `delivery_address` (Warenempfänger) when different from the customer.
        Null while the order is still parsing.
      - **`erp_offer_id`** — the partner-side ERP order id when known (populated
        from your acknowledgement's `external_id`, or from a Mercura-managed ERP
        export).
      - **`acknowledgement`** — your last reported import outcome for this order
        (`status`, `external_id`, `message`, `metadata`, `acknowledged_at`), or
        `null` until you acknowledge it (see below).

      > **Prices.** Orders are a **matching**, not a pricing, workflow. Line

      > items carry the matched article, quantity, and unit but **no prices**,
      so

      > `list_price` / `net_price` / `line_net` / `line_gross` are `null` and

      > `totals` are `0`. The `positions` and `order_metadata` are the payload

      > that matters. (The `totals` block is kept for shape-parity with
      tenders.)


      Use `modified_since` on `GET /orders` for delta-sync catch-up scans.


      ### Completion feed


      Pass **`completed_since`** (an ISO-8601 instant) to pull every order

      completed recently — the typical ERP write-back trigger:


      ```

      GET /orders?completed_since=2026-07-09T11:55:00Z

      ```


      This returns only orders with `completed_at >= completed_since`, ordered
      by

      `completed_at` ascending, and paginates with the same opaque `cursor`
      (keep

      `completed_since` present on every page). Because `completed_at` is
      refreshed

      on every completion, a re-exported order reappears — pair it with the

      `order.completed` webhook for a robust push-plus-reconcile integration.


      ## Acknowledging an order


      - **`POST /orders/{order_id}/acknowledgements`** — after you import an
      order
        into your ERP, report the result back so the Mercura operator can see it.
        Send `status: "SUCCESS"` (optionally with your own `external_id`, a
        free-text `message`, and any `metadata`), or `status: "FAILED"` with a
        required `message` describing why. The call is idempotent and
        **latest-wins** for the status and message (a retry that first failed then
        succeeded ends up `SUCCESS`); a once-reported `external_id` is kept even if
        a later acknowledgement omits it. Your `external_id` is also written back to
        the order's `erp_offer_id`, so it shows up everywhere Mercura reads that
        field. The response body is the recorded acknowledgement only —
        `{ status, external_id, message, metadata, acknowledged_at }`, the same
        shape as the order's `acknowledgement` field — a small deterministic
        confirmation of what Mercura persisted (including the sticky `external_id`)
        without refetching the whole order. Recording-only: it does not change the
        order's workflow `status` or re-trigger an export.

      ## Reporting lifecycle events


      - **`POST /orders/{order_id}/events`** — report a lifecycle event your own
        system recorded for an order *after* the initial acknowledgement: the order
        was **Cancelled**, **Reopened**, and so on. Unlike an acknowledgement (one
        latest-wins outcome), events are **append-only** — one row per report — so the
        full timeline is preserved.
      - Send an `event_type` (a normalised token: uppercase letters, digits, and
        underscores, e.g. `CANCELLED` / `REOPENED`), and optionally a `status_label`
        (your verbatim label), a `message`, an `occurred_at`, and any `metadata`.
      - Supply an **`external_event_id`** to make retries safe: reposting the
      same id
        with the same payload returns the already-stored event with `200`, while
        reusing the id with a *different* payload is a `409`. Without it, every call
        records a new event.
      - The response is the recorded event — `{ id, event_type, status_label,
        message, metadata, external_event_id, occurred_at, recorded_at }` — with `201`
        for a newly recorded event.
      - **Recording-only**: reporting an event never changes the order's
      workflow
        `status` or re-triggers an export. These are events *you* report to Mercura —
        not the webhook deliveries Mercura sends you (see the *Webhooks* chapter).

      `PATCH /orders` is planned for a later release.
  - name: Supplier Requests
    description: >
      A **supplier request** (*Werksanfrage*) is an outbound RFQ you send to one
      of

      your suppliers to price a set of positions from a tender (or a project).
      Where a

      `tender` is the demand coming *in* from your customer, a supplier request
      is the

      enquiry going *out* to a supplier — and the supplier's reply is parsed
      into the

      **offered lines** this resource exposes.


      This resource is **read-only**.


      ## Reading supplier requests


      A supplier request is keyed by its own **UUID** (`id`) and carries:


      - **`status`** — lifecycle state: `REQUEST_SENT` (sent, awaiting an
      offer),
        `OFFER_RECEIVED` (the supplier replied), or `DECLINED`.
      - **`tender_id`** — the tender this request was raised for, when
      tender-scoped —
        resolve the full document via `GET /tenders/{tender_id}`. Null when the
        request is project-scoped.
      - **`project_id`** — the project this request was raised for, when
        project-scoped — resolve via `GET /projects/{project_id}`. Null when
        tender-scoped. A supplier request is always scoped to exactly one of the two.
      - **`supplier_id`** — the supplier **reference only** (your
        `BusinessPartner.external_id`); resolve name / address / contacts via
        `GET /suppliers/{supplier_id}`. `supplier_name` is a convenience mirror.
      - **`supplier_emails`** — the addresses the request was sent to.

      - **`sender_email`** — the Mercura user who sent the request.

      - **`is_awarded`** / **`awarded_line_count`** — whether (and how many)
      offered
        lines were accepted into the final tender.
      - **`offered_lines[]`** — the lines the supplier quoted back, one per
      parsed
        candidate. Each carries:
        - **`id`** — an opaque handle for the offered line.
        - **`position_id`** — the position this line answers. For a tender-scoped
          supplier request it is the line handle in `GET /tenders/{tender_id}`, so you
          can line the supplier's quote up against the position it responds to; for a
          project-scoped one it is an internal reference with no public resolution
          endpoint today. Null when the line is not mapped to a position.
        - **`item_type`** — `ARTICLE` / `TEXT_FIELD` / `DIVERSE`.
        - **`candidate_status`** — `PENDING` / `ACCEPTED` / `DECLINED`.
        - **`selected`** — `true` when this line was accepted into the final tender
          (i.e. `candidate_status` is `ACCEPTED`). This is the line you are actually
          buying at the quoted price.
        - `article_number`, `manufacturer_article_number`, `description`, `quantity`,
          `unit`, `unit_price`, `line_total`.

      You can:


      - **`GET /supplier-requests`** — cursor-paginated list for your
      organisation.

      - **`GET /supplier-requests/{supplier_request_id}`** — one supplier
      request in
        full, by its UUID.

      The list is ordered by `updated_at` ascending (oldest-touched first) — the

      natural order for `modified_since` delta-sync — and paginated with the
      opaque

      `cursor`. Page through until `next_cursor` is `null`.


      ## Filtering


      All filters are optional and composable:


      - **`tender_id`** — only supplier requests raised for one tender. This is
      the
        authoritative "all supplier requests for this tender" query:

        ```
        GET /supplier-requests?tender_id=48213
        ```

      - **`project_id`** — only supplier requests raised for one project (a
      distinct
        axis from `tender_id`).
      - **`supplier_id`** — only requests sent to one supplier (its
      `external_id`).

      - **`status`** — filter by lifecycle state, e.g. `status=OFFER_RECEIVED`.

      - **`awarded`** — `awarded=true` returns only requests with at least one
      accepted
        (selected) line — i.e. the suppliers you are actually buying from; `false`
        returns those without one.
      - **`modified_since`** — ISO-8601 lower bound on `updated_at` for
      delta-sync
        catch-up scans (first page only; the `cursor` supersedes it thereafter).

      ```

      # only the awarded requests for one tender

      GET /supplier-requests?tender_id=48213&awarded=true


      # one supplier's request on a tender that came back with an offer

      GET
      /supplier-requests?tender_id=48213&supplier_id=SUP-1007&status=OFFER_RECEIVED

      ```


      ## Joining to a tender


      Given a tender, `GET /supplier-requests?tender_id={id}` lists every
      Werksanfrage

      raised for it. Within each, every `offered_lines[].position_id` points at
      the

      position that line answers in `GET /tenders/{id}`, so you can align the

      supplier's quote against the customer's demand line by line — and the
      `selected`

      flag tells you which quotes made it into the finalised tender.
  - name: Projects
    description: >
      A **Project** is the real-world construction project (or business

      opportunity) an incoming request belongs to. Multiple requests — a

      tender, its follow-up amendments, later related orders — usually roll

      up under one project. Every incoming LV becomes a tender that Mercura

      groups into a `Project` on your behalf, and the `tender.completed` /

      `order.completed` webhooks carry the `project_id` so partners can

      reconcile the exported result against the same project record on

      their side.


      Each project carries:


      - **`id`** — the numeric identifier equal to the `project_id` field on
        the `tender.completed` / `order.completed` webhooks (stringified).
        Round-trip the value verbatim to `GET /projects/{id}`.
      - **`object_number`** — the ERP-facing grouping key. Not unique:
        Mercura groups multiple project rows that share the same
        `object_number` into one master project internally, so filtering by
        `object_number` may return several rows.
      - **`name`** and **`status`** — display name and lifecycle state
        (`ACTIVE`, `PROCESSED`, `BID_SUBMITTED`, `CUSTOMER_LOST`,
        `CUSTOMER_WON_PENDING`, `CUSTOMER_WON_AWARDED_ELSEWHERE`,
        `CUSTOMER_WON_AWARDED_TO_US`).
      - **`estimated_value`** / **`currency`** / **`submission_date`** —
        bid metadata as captured on the LV.
      - **`responsible_user`** — the Mercura user (name + email) assigned to
        the project. Convenient for surfacing "assigned to Anna Schmidt" in
        a downstream inbox without a second lookup.
      - **`construction_site_address`** / **`planner_address`** /
        **`developer_address`** — up to three postal addresses associated
        with the project. Fields not set on the internal row surface as
        `null`.
      - **`custom_fields`** — organisation-defined key-value pairs. Keys and
        value shapes are governed by your organisation's custom-column
        configuration (Settings → Organisation → Custom fields), not by
        this API. Multi-select fields return the option **labels** (e.g.
        `["3030"]`), not the internal option ids. Empty custom-field sets are
        omitted from the response entirely.
      - **`request_ids`** — the Mercura request ids linked to the project
        via the `Request.project_id` foreign key. Each id is request-keyed,
        so drill down to a tender with `GET /tenders/{id}` or to an order
        with `GET /orders/{id}`.
      - **`acknowledgement`** — your side's last reported ERP/CRM import
        outcome for this project, when one was recorded via
        `POST /projects/{project_id}/acknowledgements`. `null` when the
        project was never acknowledged. See *Acknowledging a project* below.

      You can:


      - **`GET /projects`** — cursor-paginated list of projects for the
        authed organisation. Use `modified_since` for delta-sync catch-up.
      - **`GET /projects/{project_id}`** — fetch a single project. This is
        what the `tender.completed` / `order.completed` webhooks (see the
        Webhooks chapter) point you at when partners need project-level
        context (site address, custom fields) beyond what the tender or
        order itself carries. The response carries a strong `ETag` for
        optimistic concurrency on PATCH.
      - **`PATCH /projects/{project_id}`** — edit a project in place. The
        inbound counterpart of the otherwise Mercura-produced project: write
        your ERP's own commission / project number (and other metadata) back
        onto the Mercura object. See *Updating a project* below.
      - **`POST /projects/{project_id}/acknowledgements`** — report whether
        your ERP/CRM managed to create/import the project (`SUCCESS` with
        your own record id, or `FAILED` with a reason). A `SUCCESS` id is
        written onto the project's `object_number` too, so this is also the
        method-minimal way to reconcile identifiers when your middleware
        cannot issue the PATCH verb. See *Acknowledging a project* below.

      And Mercura can tell you when to act:


      - **`project.exported`** — the webhook a Mercura user triggers with the
        **Send to ERP** button on the project page. It is the signal to create
        the project as an object in your system; you then fetch it with
        `GET /projects/{project_id}` and report the result with an
        acknowledgement. See *Creating a project in your ERP* below.

      ## Updating a project


      `PATCH /projects/{project_id}` writes back the fields your ERP owns.

      The canonical use is reconciling identifiers: your ERP creates a

      project / commission number and PATCHes it into `object_number` so

      every later `tender.completed` / `order.completed` webhook and

      `GET /projects` lines up with your own key.


      Writable fields (all optional): `object_number`, `name`, `status`,

      `estimated_value`, `currency`, `submission_date`, `custom_fields`.


      If `object_number` is the only field you need to write, you can skip

      PATCH entirely and send it as the `external_id` of an acknowledgement

      instead — see *Acknowledging a project*.


      Semantics:


      - **Partial.** Send only what you change. An omitted field is left
        untouched; an explicit `null` clears that field. `status` is the one
        exception — it cannot be `null` (a project always has a lifecycle
        state), and an unknown status value is a `400`.
      - **`object_number` is a grouping key.** Mercura derives the
        master-project grouping from this value at read time, so writing it
        simply moves the one project row you addressed into (or out of) a
        group — sibling rows that shared the old value are not rewritten.
      - **`custom_fields` is label-keyed and shallow-merged.** Use the same
        labels you receive on `GET` (and, for a multi-select, the same option
        labels). Keys you include are set; a key mapped to `null` clears that
        one field; keys you omit are left as they are. An unknown label — or
        an unknown option label on a multi-select — returns
        `400 VALIDATION_FAILED` naming the offending keys, so a typo fails
        loudly instead of silently writing nothing.

      Optimistic concurrency: read the project first, then echo the `ETag`

      you received as an `If-Match` header on the PATCH. If someone else

      changed the project in the meantime the ETag no longer matches and the

      request is rejected with `412 Precondition Failed` — re-fetch and

      retry. Omit `If-Match` (or send `If-Match: *`) to skip the check. A

      no-op edit (an empty body, or values equal to the current ones) does

      not advance the project's `updated_at`.


      ```http

      PATCH /projects/90 HTTP/1.1

      Authorization: Bearer <your-api-key>

      If-Match: "3f8b6d21..."

      Content-Type: application/json


      {
        "object_number": "P-2026-00417",
        "status": "CUSTOMER_WON_AWARDED_TO_US",
        "custom_fields": { "ERP-Auftragsnummer": "SO-88231" }
      }

      ```


      ## Creating a project in your ERP


      Projects are created inside Mercura (from a GAEB file, from an email

      intake, or by a user), so — unlike tenders — there is no partner path

      that creates one. What you get instead is a **handoff you can react

      to**: a Mercura user opens the project and clicks **Send to ERP**, and

      Mercura emits a `project.exported` webhook.


      The round trip:


      1. **You subscribe** to `project.exported` (Settings → Organisation →
         Webhooks). The button only appears in Mercura for organisations that
         have an active subscription for this event — no subscription, no
         button, so nothing can be handed to an endpoint that is not listening.
      2. **A user clicks the button.** Mercura emits `project.exported`
         carrying `project_id`, `name`, the current `object_number`, the
         lifecycle `status` and `request_count`. Nothing on the project is
         changed by the click: its lifecycle `status` stays as it was, and
         `object_number` remains yours to assign.
      3. **You fetch** `GET /projects/{project_id}` for the full record —
         addresses, custom fields, `request_ids`.
      4. **You create** the object in your ERP and **acknowledge** it with
         `POST /projects/{project_id}/acknowledgements`, sending your document
         number as `external_id`. That number lands on the project's
         `object_number` and comes back to you as `erp_object_id` on every
         later tender/order export for the project.

      Re-clicking the button is safe and always emits again — the payload then

      carries the `object_number` you acknowledged earlier, which is how you

      tell an update from a create without keeping your own mapping. The

      acknowledgement of the previous handoff stays visible to the Mercura user

      next to the button, so a `FAILED` acknowledgement with a `message` is the

      way to tell them what went wrong on your side.


      ## Acknowledging a project


      `POST /projects/{project_id}/acknowledgements` records your ERP/CRM

      import outcome on the project — the project-keyed sibling of the

      tender/order acknowledgements. Use it when your integration creates

      the Mercura project as an object/document in your own system and you

      want to report the result (and your document number) back without a

      PATCH round-trip — for example when your middleware cannot issue the

      PATCH verb, or when all you hold at creation time is your own

      document number.


      Body fields:


      - **`status`** (required) — `SUCCESS` or `FAILED`.

      - **`external_id`** — your own id for the created record (e.g. the
        ERP document number). Typically sent on `SUCCESS`.
      - **`message`** — free-text note; **required on `FAILED`** as the
        failure reason, optional on `SUCCESS`.
      - **`metadata`** — optional extra references echoed back verbatim for
        traceability.

      Semantics:


      - **Latest-wins.** A later acknowledgement overwrites `status`,
        `message` and `metadata`; `external_id` is kept once provided (an
        id-less follow-up ack never clears it).
      - **`external_id` also sets `object_number`.** When you send one it is
        written to the project's `object_number` (the Objektnummer) as well —
        the same field `PATCH /projects/{project_id}` writes, and the one
        Mercura sends back to you as `erp_object_id` on every later
        tender/order export. One POST therefore both reports the outcome and
        establishes the identifier both sides reconcile on; you do not need
        the PATCH for it. Your value wins over an Objektnummer that came from
        a GAEB file or from a Mercura user, and an acknowledgement without an
        `external_id` leaves the existing one untouched. Because
        `object_number` also groups projects (see *Reading projects*),
        rewriting it moves this project into that number's group.
      - **Otherwise recording-only.** The project's lifecycle `status` is
        never changed and no export is re-triggered. The acknowledged outcome
        (including `external_id`) is returned on every `GET /projects` read as
        the `acknowledgement` field.
      - The write advances the project's `updated_at`, so a `modified_since`
        delta-sync surfaces acknowledged projects.

      ```http

      POST /projects/90/acknowledgements HTTP/1.1

      Authorization: Bearer <your-api-key>

      Content-Type: application/json


      {
        "status": "SUCCESS",
        "external_id": "4500012345",
        "metadata": { "erp_client": "100" }
      }

      ```


      The response is the recorded acknowledgement (not the whole project):

      resolved `status`, the sticky-aware `external_id`, `message`,

      `metadata`, and the server-side `acknowledged_at` timestamp.


      ## Filtering the list


      Three filters are AND-combined with `modified_since` on the list

      endpoint:


      | Query parameter | Behaviour |

      |---|---|

      | `object_number=<string>` | Exact match on the ERP grouping key. Returns
      every row that shares the value — Mercura's master-project concept treats
      them as one logically but exposes them as distinct rows. |

      | `request_id=<int>` | Returns the single project linked to the request
      via `Request.project_id`. Empty page for requests with no project FK. |

      | `status=<UPPERCASE>` | Restrict to one lifecycle status. Unknown values
      return `400 VALIDATION_FAILED`. |


      Combine `object_number` with `request_id` when you need to verify

      that a specific request rolls up under a specific ERP grouping key.


      ## Custom fields


      `custom_fields` is a free-form map shaped by your organisation's own

      custom-column configuration — Mercura does not enforce a JSON schema on

      the contents. Keys and value formats can evolve on your side without an

      API version bump. Partners rendering custom fields should treat unknown

      keys gracefully (skip, or show verbatim); Mercura will not remove keys

      behind your back.


      Values follow the column's type: text / number / date fields are

      returned as-is, while a **multi-select** field is returned as a list of

      its selected option **labels** (the same text you see in the Mercura

      UI), e.g. `"Gewerk": ["DD - DACHDECKUNG DACHABDICHTUNG", "EL - ELEMENTE
      TÜREN"]`.

      The internal option ids are never exposed on the read path.


      Empty custom-field sets are omitted from the response body entirely

      (the field is not returned as `null` or `{}`). Compact wire, easier

      diffs against the Mercura admin UI.


      ## Relationship to tenders and orders


      - One project → many requests (`Request.project_id`).

      - Each request is reachable by its id as a tender (`GET /tenders/{id}`)
        or an order (`GET /orders/{id}`); these never collide with
        `GET /projects/{id}` because `project_id` and `request_id` are
        separate integer sequences.
      - The `tender.completed` / `order.completed` webhooks fire per request
        id and carry the parent `project_id` on the wire, so partners can
        decide whether to fetch request-level data (`GET /tenders/{id}` /
        `GET /orders/{id}`) or project-level data (`GET /projects/{id}`) —
        or both.
  - name: Articles
    description: >
      An **Article** is one row in your sales catalogue: a thing you sell,

      keyed by the `article_number` you assign in your source system. Articles

      are the most important resource in this API — Mercura matches every

      position on every incoming customer LV against your articles, so the

      quality of the offers you read back depends directly on the quality of

      the article catalogue you push.


      You can:


      - **`POST /articles`** — bulk-upsert up to 100,000 articles in a single
        call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for
        status. Rows with the same `article_number` as an existing row are
        updated in place; new rows are inserted. Recommend running this once
        per night with the full delta since the last successful job.
      - **`GET /articles`** — cursor-paginated list of your articles. Use
        `modified_since` on the first page to fetch only the rows that
        changed since your last sync.
      - **`GET /articles/{article_number}`** — fetch a single article by the
        partner-supplied `article_number`.

      ### Data depth and match quality


      The precision of Mercura's article matching depends directly on the

      **depth and structure** of the article data you push. The more you

      send, the more accurately Mercura can resolve incoming LV positions

      against your catalogue.


      Articles that match well tend to carry:


      - **Identifying attributes** — manufacturer name and article number,
        supplier article number, EAN / GTIN.
      - **Classifications** — ETIM class, applicable norms (DIN / EN / ISO).

      - **Technical parameters** — power, voltage, dimensions, IP rating,
        and any other physical or electrical characteristics that distinguish
        one variant from another.

      Any field your source system carries but the standard schema does not

      cover can be passed through the `custom_fields` attribute available

      on every master-data resource — no schema change required.


      ### About `article_number`


      Article numbers are partner-supplied and must match the character class

      `[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path

      segments. `/`, `?`, `#`, and whitespace are not allowed inside an

      article number.


      ### Tags


      Articles carry **tags** — short labels (e.g. `"LED"`, `"Eigenmarke"`)

      your team manages in the Mercura admin UI under **Settings → Article

      tags**. Send them by **name**: `POST /articles` accepts a `tags` array

      of names, and the read endpoints return the same names — you never deal

      with Mercura's internal numeric tag ids.


      ```json

      { "article_number": "LEU-1500-50-840", "name": "LED-Feuchtraumleuchte 1500
      mm 50 W 4000 K IP65", "tags": ["LED", "Eigenmarke"] }

      ```


      - Every name you send must **already exist** for your organisation. An
        unknown name fails the whole batch (the job ends `FAILED` with a
        message listing the offending names) — create the tag in the admin UI
        first, then retry.
      - Sending `tags` **replaces** the article's current tags. **Omit** the
        field to leave the tags unchanged; send `[]` to clear them.

      ### ETIM features


      Push ETIM classification features to sharpen how Mercura matches

      incoming LV positions against your catalogue. Each feature is one

      object:


      ```json

      {
        "article_number": "LEU-1500-50-840",
        "name": "LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65",
        "etim_features": [
          { "etim_code": "EF000008", "human_label": "Nennspannung",         "type": "number",  "value_number": 230 },
          { "etim_code": "EF000007", "human_label": "Farbe",                "type": "string",  "value_string": "weiß" },
          { "etim_code": "EF000131", "human_label": "Mit Anschlussleitung", "type": "boolean", "value_boolean": true },
          { "etim_code": "EF000056", "human_label": "Leistungsbereich",     "type": "range",   "value_range": { "gte": 18.0, "lte": 50.0 } }
        ]
      }

      ```


      Set the one `value_*` field that matches `type` (`value_string`,

      `value_number`, `value_boolean`, or `value_range`); leave the others

      out. Sending `etim_features` **replaces** the article's full feature

      list; omit the field to leave it unchanged.


      ### Soft-deleting articles


      Set `is_deleted: true` on an article in a `POST /articles` payload to

      **hide it everywhere in Mercura** — it stops appearing in catalogue

      search, AI matching suggestions, and selection candidates. The row is

      kept (not destroyed), so any offer position that already selected the

      article in the past still shows it. Set `is_deleted: false` to restore

      the article. Omitting the field leaves the current value unchanged.


      Soft-deleted articles are excluded from `GET /articles` by default; pass

      `include_deleted=true` to list them (for example, to confirm a delete or

      find one to restore). `GET /articles/{article_number}` always returns the

      article, including its `is_deleted` state.


      ### Lifecycle flags


      Two more boolean flags are accepted on `POST /articles` and returned on
      the

      read endpoints (both default `false`):


      - **`is_diverse`** — marks a catch-all / placeholder ("diverse") article.

      - **`is_legacy_article`** — marks a discontinued article that should stay
        resolvable by id and visible on past selections but be kept out of new
        matches. (`is_deleted` hides it everywhere; `is_legacy_article` only
        demotes it from new matching.)
  - name: Accessories
    description: >
      **Accessories** link a source article to accessory articles that are

      fitted or ordered alongside it — an optional add-on, or a mandatory part.

      Pushing them lets Mercura suggest the right accessories when it matches a

      customer position against your catalogue.


      You can:


      - **`POST /articles/accessories`** — bulk-upsert up to 100,000 accessory
      links in a
        single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for
        status. Each row upserts the `(source_article_number,
        accessory_article_number)` pair.
      - **`GET /articles/accessories`** — cursor-paginated list of your
      accessory links.
        Pass `source_article_number` to list one article's accessories, or
        `modified_since` on the first page to fetch only what changed.

      ### Fields


      - **`multiplier`** — how many of the accessory are needed per unit of the
        source article (optional).
      - **`order`** — display order among a source article's accessories.

      - **`is_mandatory`** — `true` marks a required part rather than an
      optional
        add-on.

      ### Upsert semantics (v1)


      `POST` **inserts or updates** each `(source, accessory)` pair; it never

      removes links. Re-sending the same pair is idempotent. There is **no

      delete endpoint yet** — omitting a previously sent accessory leaves it in

      place. A row whose source or accessory `article_number` does not exist in

      your catalogue is **skipped** (counted in the job's `skipped_count`), not

      an error. If any row is structurally invalid (missing a required field, a

      non-positive `multiplier`, a negative `order`) the whole batch is rejected

      and the per-row detail appears in `GET /jobs/{job_id}`.
  - name: Alternatives
    description: >
      **Alternatives** link a source article to substitute articles a customer

      could accept in its place. Pushing them lets Mercura offer a valid

      alternative when the requested article is unavailable or less suitable.


      You can:


      - **`POST /articles/alternatives`** — bulk-upsert up to 100,000
      alternative links in
        a single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for
        status. Each row upserts the `(source_article_number,
        alternative_article_number)` pair.
      - **`GET /articles/alternatives`** — cursor-paginated list of your
      alternative
        links. Pass `source_article_number` to list one article's alternatives,
        or `modified_since` on the first page to fetch only what changed.

      ### Fields


      - **`order`** — display order among a source article's alternatives
        (optional).

      ### Upsert semantics (v1)


      `POST` **inserts or updates** each `(source, alternative)` pair; it never

      removes links. Re-sending the same pair is idempotent. There is **no

      delete endpoint yet** — omitting a previously sent alternative leaves it
      in

      place. A row whose source or alternative `article_number` does not exist
      in

      your catalogue is **skipped** (counted in the job's `skipped_count`), not

      an error. If any row is structurally invalid (missing a required field, a

      negative `order`) the whole batch is rejected and the per-row detail

      appears in `GET /jobs/{job_id}`.
  - name: Successors
    description: >
      **Successors** mark the article that replaces a discontinued one

      (`source_article_number` → `successor_article_number`). Pushing them lets

      Mercura retarget demand for a phased-out article onto its current

      replacement.


      You can:


      - **`POST /articles/successors`** — bulk-upsert up to 100,000 successor
      links in a
        single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for
        status.
      - **`GET /articles/successors`** — cursor-paginated list of your successor
      links.
        Pass `source_article_number` to look up one article's successor, or
        `modified_since` on the first page to fetch only what changed.

      ### One successor per article


      A source article has **exactly one** active successor. Re-sending a source

      article with a different `successor_article_number` **replaces** the

      previous successor — this is the intended way to update a chain. `order`
      is

      accepted for parity with the other relations but has no effect on a 1:1

      link.


      ### Upsert semantics (v1)


      There is **no delete endpoint yet**. A row whose source or successor

      `article_number` does not exist in your catalogue is **skipped** (counted

      in the job's `skipped_count`), not an error. If any row is structurally

      invalid (missing a required field, a negative `order`) the whole batch is

      rejected and the per-row detail appears in `GET /jobs/{job_id}`.
  - name: Unit Conversions
    description: >
      **Unit conversions** map an alternative unit to an article's base unit via

      a `numerator` / `denominator` ratio — for example a carton of 6 pieces is

      `alternative_unit = "CTN", numerator = 6, denominator = 1`. Mercura uses

      them to normalise ordered quantities across units.


      You can:


      - **`POST /articles/unit-conversions`** — bulk-upsert up to 100,000
      conversions in a
        single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for
        status. Each row upserts the `(article_number, alternative_unit)`
        conversion.
      - **`GET /articles/unit-conversions`** — cursor-paginated list of your
      conversions.
        Pass `article_number` to list one article's conversions, or
        `modified_since` on the first page to fetch only what changed.

      ### The ratio


      The conversion is `1 alternative_unit = (numerator / denominator)

      base units`. Both must be positive. The ratio is stored and returned

      exactly as sent — it is not collapsed to a single factor — so values like

      3 pieces per pack (`numerator = 1, denominator = 3`) round-trip without

      loss.


      ### Upsert semantics (v1)


      `POST` **inserts or updates** each `(article, alternative_unit)`

      conversion; it never removes conversions you stop sending, and there is

      **no delete endpoint yet**. A row whose `article_number` does not exist in

      your catalogue is **skipped** (counted in the job's `skipped_count`), and

      rows with a missing unit or a non-positive `numerator` / `denominator` are

      skipped as well — unit-conversion batches do not fail as a whole.
  - name: Customers
    description: >
      A **Customer** is one of your B2B accounts — the party that sends you

      the LVs Mercura processes. Each customer is keyed by the `customer_id`

      you assign in your source system. Address is sent as an `addresses[]`

      array on write, but Mercura persists and returns exactly one (the

      `is_default` entry, else the first); extra entries are dropped. Contact

      persons live on the separate `/contacts` resource (each contact references

      its parent by `parent_type` + `parent_id`). Mercura uses this data both to
      resolve

      the customer on incoming LVs and to populate the `customer` block of

      every offer you read back.


      You can:


      - **`POST /customers`** — bulk-upsert up to 100,000 customers in a
        single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}`
        for status. Existing customers are updated in place by `customer_id`;
        new ones are inserted.
      - **`GET /customers`** — cursor-paginated list of your customers. Use
        `modified_since` for delta-sync.
      - **`GET /customers/{customer_id}`** — fetch a single customer by the
        partner-supplied id.

      ### About `customer_id`


      Customer ids are partner-supplied and must match

      `[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path

      segments.


      ### About `custom_fields`


      Read responses (`GET /customers`, `GET /customers/{customer_id}`, and the

      `customer` block of every offer) return `custom_fields` keyed by the

      field's display **label** — the same label you see in the Mercura admin UI

      — rather than the internal column id. Columns with no active definition

      are omitted. If two custom columns share a label, the one defined first

      (by display order, then creation time) wins and the other's value is

      dropped from the response; deduplicate colliding columns in the admin UI.

      The same label-keying applies to suppliers.


      The **write** path is deliberately asymmetric: on `POST /customers` send

      `custom_fields` keyed by the internal column **id** (not the label). Only

      reads are label-keyed.
  - name: Suppliers
    description: >
      A **Supplier** is one of the vendors behind your article catalogue —

      who you buy from. Each supplier is keyed by the `supplier_id` you

      assign in your source system. Mercura uses supplier data when sourcing

      articles for an offer and when generating supplier requests (RFQs).


      You can:


      - **`POST /suppliers`** — bulk-upsert up to 100,000 suppliers in a
        single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}`
        for status. Existing suppliers are updated in place by `supplier_id`;
        new ones are inserted.
      - **`GET /suppliers`** — cursor-paginated list of your suppliers. Use
        `modified_since` for delta-sync.
      - **`GET /suppliers/{supplier_id}`** — fetch a single supplier by the
        partner-supplied id.

      ### About `supplier_id`


      Supplier ids are partner-supplied and must match

      `[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path

      segments. Up to v1.6.x this field was called `external_id` (see the

      v1.7.0 entry in the changelog).


      ### About `custom_fields`


      Read responses (`GET /suppliers`, `GET /suppliers/{supplier_id}`) return

      `custom_fields` keyed by the field's display **label** — the same label
      you

      see in the Mercura admin UI — rather than the internal column id. Columns

      with no active definition are omitted, and on a label collision the column

      defined first (by display order, then creation time) wins. On the
      **write**

      path (`POST /suppliers`) send `custom_fields` keyed by the internal column

      **id**; only reads are label-keyed.
  - name: Contacts
    description: |
      A **Contact** (Ansprechpartner) is a person attached to exactly one
      business partner — a customer or a supplier. Contacts are managed via
      a single top-level resource so that adding or updating a contact does
      not require re-sending the entire parent payload.

      The contact's parent is identified by `parent_type` (`customer` or
      `supplier`) plus `parent_id` — the parent's partner-supplied
      identifier (`customer_id` for customers, `supplier_id` for suppliers).

      You can:

      - **`POST /contacts`** — bulk-upsert contacts. Async — returns a
        `JobAck`; poll `GET /jobs/{job_id}` for status. A single batch may
        mix customer and supplier contacts.
      - **`GET /contacts`** — cursor-paginated list, filterable by
        `parent_type`, `parent_id`, and `is_active`. Use `modified_since`
        for delta-sync.
      - **`GET /contacts/{id}`** — fetch one contact by its Mercura id
        (discoverable from `GET /contacts` list responses).
      - **`PATCH /contacts/{id}`** — partial update. Reassigning a contact
        to a different parent is not supported — delete and recreate.
      - **`DELETE /contacts/{id}`** — soft delete. The row stays in the
        database with `is_active=false`, so re-POSTing the same identity
        will reactivate it.

      ### Upsert key

      Each contact may carry its own optional `external_id` — the stable
      identifier from your source ERP (for example, Clage's `ASP_ID`). The
      upsert priority within a parent is:

      1. `external_id` when present on both sides — preferred because
         email and name can drift on the source system.
      2. `email` for contacts without an `external_id`.
      3. `(name, phone)` as a last resort for email-less contacts.

      A row whose `parent_type` / `parent_id` cannot be resolved fails at
      the per-row level — the rest of the batch is unaffected.
  - name: Users
    description: |
      A **User** is a member of your Mercura organisation (a
      Sachbearbeiter) — the person who works a request and is named as its
      responsible user. This is distinct from the business-partner resources
      (`customers` / `suppliers` / `contacts`); a User logs in to Mercura,
      a Contact does not.

      The resource is **read-only**. Users are managed in the Mercura admin
      UI; there is no public write path.

      You can:

      - **`GET /users?email=<addr>`** — look a user up by email
        (case-insensitive, exact match). `email` is required. Returns a
        paginated envelope with the matching user, or an empty `data` array
        when the email is unknown in your organisation (never a `404` — the
        status code does not reveal which emails exist). `next_cursor` is
        always `null` — every match fits in one page. Almost always a single
        match; email is not guaranteed unique, so if more than one user in
        your organisation shares an email, all are returned and you
        disambiguate by `id`.
      - **`GET /users/{user_id}`** — fetch one user by their Mercura user id
        (the `id` field from a `GET /users` response).

      ### Typical use: resolve a tender's responsible user

      `GET /tenders` returns `user_email` — the email of the Mercura user who
      released the tender to the API. Feed that value into
      `GET /users?email=<user_email>` to read that user's `custom_fields`,
      for example an ERP/SAP identifier your system needs when creating the
      offer.

      ### `custom_fields`

      Organisation-defined custom fields are returned keyed by their display
      **label** (for example `"SAP ID"`), not by the internal column id.
      Fields with no active column definition are omitted; the property is
      `null` when the user carries none. Define user custom columns in the
      Mercura admin UI.

      ### Service accounts

      The API service account backing your integration is never returned by
      either endpoint.
  - name: Jobs
    description: >
      A **Job** is the asynchronous unit of work behind every bulk write in

      this API. When you `POST /articles`, `POST /customers`, or

      `POST /suppliers`, Mercura validates the payload, persists an

      `ImportRun` row, enqueues a worker task, and returns a `JobAck`

      immediately. The actual ingestion (validation per row, upsert into the

      catalogue, error aggregation) happens out-of-band.


      Polling `GET /jobs/{job_id}` is how you learn what happened.


      ## The async write loop


      ```

      POST /articles

      Authorization: Bearer mrc_live_...

      Idempotency-Key: nightly-sync-2026-05-19

      Content-Type: application/json

      { "articles": [ ... up to 100,000 rows ... ] }


      →  202 Accepted
         { "job_id": "4242", "status_url": "/api/public/v1/jobs/4242" }
      ```


      Then poll:


      ```

      GET /api/public/v1/jobs/4242


      →  200 OK
         {
           "job_id": "4242",
           "entity": "ARTICLES",
           "status": "RUNNING",
           "created_at": "2026-05-19T10:00:00Z",
           "updated_at": "2026-05-19T10:00:05Z",
           "total_rows": 12000,
           "created_count": 0,
           "updated_count": 0,
           "skipped_count": 0,
           "deleted_count": 0,
           "error_count": 0,
           "errors": [],
           "result": null
         }
      ```


      …and again a few seconds later:


      ```

      {
         "job_id": "4242",
         "entity": "ARTICLES",
         "status": "COMPLETED",
         "created_at": "2026-05-19T10:00:00Z",
         "updated_at": "2026-05-19T10:00:42Z",
         "total_rows": 12000,
         "created_count": 9000,
         "updated_count": 2950,
         "skipped_count": 0,
         "deleted_count": 0,
         "error_count": 50,
         "errors": [
           { "row_number": 137, "identifier": "LEU-0900-18-830", "error_message": "missing list_price" }
         ],
         "result": null
      }

      ```


      `result` is `null` for `ARTICLES` / `CUSTOMERS` / `SUPPLIERS` jobs; for

      `REQUESTS` jobs (created via `POST /tenders` / `POST /orders`) it carries

      `{ request_id, request_status, position_count }`.


      ## Status enum


      | `status` | Meaning |

      |---|---|

      | `PENDING`   | Queued, not yet picked up by a worker. |

      | `RUNNING`   | A worker is processing rows. |

      | `COMPLETED` | Finished. If `error_count > 0`, those specific rows failed
      and `errors[]` lists them — the rest were applied. |

      | `FAILED`    | The whole job failed before any rows were applied (e.g. a
      validation error that affected the entire payload). |


      `COMPLETED` is always a terminal *partial-success* status: a non-zero

      `error_count` does not mean the job failed; it means those rows were

      skipped while the others were applied. Treat `errors[]` as the

      authoritative per-row failure list.


      ## Polling cadence


      Most jobs finish in seconds; a 100,000-row catalogue sync may take a

      few minutes. A reasonable client polls every **1 second for the first

      30 seconds**, then backs off to **5–10 seconds**. Poll until you see

      `COMPLETED` or `FAILED` — do not assume a wall-clock timeout.


      ## Idempotency


      Pass an `Idempotency-Key` header (any string up to 255 characters, e.g.

      `erp-2026-05-18-batch-1`) on bulk-write requests. Mercura keys jobs on

      `(organisation, Idempotency-Key)`:


      - **Same key + same body** → the original `job_id` is returned with
        `200 OK`. No second job runs.
      - **Same key + different body** → `422 IDEMPOTENCY_KEY_MISMATCH`. This
        is almost always a bug on the caller side; we refuse to silently
        replay a different payload under a key you said you were reusing.
      - **No header** → every call is a new job.


      This lets you safely retry on network or proxy failures without

      double-writing.


      ## Errors


      - `404 NOT_FOUND` — the `job_id` is unknown, malformed, or belongs to
        another organisation. We deliberately do not distinguish these cases:
        the existence of another tenant's jobs is never leaked.
  - name: Statistics
    description: >
      Read-only reporting over Mercura's processing outcomes. The statistics

      resource exposes the **raw data** behind the in-app *Exported requests

      overview* dashboard so you can build your own reporting — it returns
      counts,

      never pre-computed percentages, so you stay in control of how accuracy is

      framed.


      ## Selection accuracy


      `GET /statistics/selections` returns, for every request in a date range,
      how

      that request's positions were resolved into selections. Rows cover both
      request

      types shown on the dashboard — **tenders and orders** — and each carries a

      `request_type` (`TENDER` / `ORDER`) so you know whether its `request_id`

      resolves via `GET /tenders/{id}` or `GET /orders/{id}`. Each row carries
      these

      counts:


      - `position_count` — the number of **relevant** positions on the request
      (lines
        Mercura kept for matching; irrelevant lines such as headings or notes are
        excluded).
      - `positions_with_selection` — how many of those relevant positions
      actually got
        **resolved**, i.e. carry at least one selection. Always `<= position_count`
        (some relevant positions end up with no selection at all). This is the
        denominator behind the in-app time-per-position metric — see *Status &
        handling time* below.
      - `selection_count` — the total number of surviving selections across
      those
        relevant positions. A single position can carry more than one selection (for
        example two articles chosen for one line), so `selection_count` may exceed
        `position_count`. It counts *selections*, not positions, so it is **not**
        interchangeable with `positions_with_selection`.
      - `auto_selected_count` — selections made by a mechanism that resolves the
      line
        without going through prediction ranking. These fire when Mercura can resolve a
        line on its own: an exact **article-number match**; propagation from a
        **master**, **historic**, **cluster**, **duplicate**, or
        **equivalent-position** match; a **parts-list** lookup; a configured
        **default article**; or an **auto-selection the order-entry agent makes on its
        own**. In practice this is high whenever the customer supplied article numbers
        up front.
      - `prediction_correct_count` — prediction-driven selections where the
      chosen
        article was among Mercura's predictions for that position (a "correct"
        prediction — the model surfaced the article the user picked). This is a
        straight *predicted / not-predicted* split: the earlier **top-10** rank cut has
        been removed, since the order-entry agent surfaces only a handful of candidates
        per position rather than a long ranked list.
      - `manual_selection_count` — prediction-driven selections where the chosen
        article was **not** among Mercura's predictions for that position (shown as
        *Manual selections* in the dashboard — the user picked something the model did
        not surface).

      The three outcome counts (`auto_selected_count`,
      `prediction_correct_count`,

      `manual_selection_count`) are mutually exclusive and sum to
      `selection_count`.


      Derive whatever rollups you need client-side, for example:


      ```

      hit_rate = (auto_selected_count + prediction_correct_count) /
      selection_count

      ```


      ## Status & handling time


      Each row also reports where the request sits in Mercura's processing
      lifecycle,

      the true handling time spent on it, and the timestamps around it:


      - `status` — the lifecycle state: `NEW`, `IN_PROGRESS`, `DONE`,
      `CANCELLED`,
        `PARSING`, or `PARSING_FAILED`.
      - `active_seconds` — the **true handling time**: the total *active*
      (engaged)
        seconds a user spent working the request before export. This is the exact
        per-request figure behind the in-app *time per position* metric — the same
        usage telemetry, read straight from that computation — and it counts only time
        the request was actively being worked. Defined for **exported tenders** only:
        `null` for orders, for requests not yet exported, and for exported requests
        with no recorded active time.
      - `first_opened_at` — when a user first opened the request, or `null` if
      it was
        never opened.
      - `exported_at` — when the request was first exported to your system, or
      `null`
        if it has not been exported yet.
      - `completed_at` — the most recent completion / re-export. Unlike
      `exported_at`
        (set once, on the first export) this advances on every re-export.

      Use `active_seconds` for handling time. To reproduce the **time per
      position**

      figure Mercura shows in-app, divide by `positions_with_selection` and
      aggregate

      as a **pooled ratio** across the rows:


      ```

      -- matches the in-app dashboard

      seconds_per_position = SUM(active_seconds) / SUM(positions_with_selection)

      ```


      Two details decide whether your number matches ours:


      - **Use `positions_with_selection`, not `position_count`.** The metric
      measures
        effort per *resolved* line. Relevant positions that never got a selection are
        in `position_count` but were not worked, so including them understates the
        figure.
      - **Pool the totals; don't average the per-request ratios.**
        `AVG(active_seconds / positions_with_selection)` weights a 3-position request
        exactly as heavily as a 300-position one. Request sizes are long-tailed —
        small requests are the most common — so the mean of ratios lands well above
        the pooled value. Both are legitimate statistics; only the pooled one is what
        the dashboard reports.

      Per-selection effort is a different question, and `selection_count` is the
      right

      denominator for it:


      ```

      seconds_per_selection = SUM(active_seconds) / SUM(selection_count)

      ```


      Rows where `active_seconds` is `null` (orders, unexported requests, or
      requests

      with no recorded telemetry) must be excluded from both — they are
      unmeasured,

      not zero.


      > **Do not** use `exported_at - first_opened_at` as handling time. That
      span is

      > wall-clock: a request opened on Monday and exported on Wednesday reads
      as two

      > days even if only 20 minutes of work went in. It includes overnight and
      idle

      > gaps, so it cannot tell you how long the user actually spent.
      `active_seconds`

      > is the field that answers that. The timestamps are still useful for
      lifecycle

      > reporting (when a request entered/left each state).


      ## Exported vs. in-progress requests


      By default only **exported**, non-deleted, non-evaluation requests are
      included

      — the same universe as the dashboard. Pass **`include_unexported=true`**
      to also

      receive requests that are still in progress (never exported). This lets
      you

      compare how many requests were **uploaded** against how many were

      **successfully exported**: with the flag on, an exported request has a
      non-null

      `exported_at` (and typically `status = DONE`), while an uploaded-but-open
      one

      has `exported_at = null` and a `status` such as `NEW` or `IN_PROGRESS`.

      Note that `active_seconds` stays `null` for these still-open requests —
      handling

      time is measured against the export.


      ## Date range


      `start_date` and `end_date` (both `YYYY-MM-DD`, required) bound the range
      on the

      request's **creation date**, inclusive on both ends. The window is capped
      at

      **400 days**; an inverted range or one wider than the cap returns

      `400 VALIDATION_FAILED`. Keep both parameters present on every page of a
      paged

      read — they define the query window, and the cursor resumes within it.


      ## Pagination


      Standard cursor pagination (see *Getting Started → Pagination*). Rows are

      ordered newest-first by request creation time. Pass `limit` (1–500,
      default

      100); when a further page exists the response carries a `next_cursor`,
      which

      you pass back as `cursor` on the next call along with the unchanged

      `start_date` / `end_date`. A `null` `next_cursor` means the range is
      exhausted.
  - name: Webhooks
    description: >
      **Webhooks** are how Mercura tells your system that something happened

      without you having to poll. Instead of asking `GET /jobs/{job_id}`

      every few seconds, you register an HTTPS endpoint once and Mercura

      delivers a signed `POST` whenever a relevant event fires.


      For the end-to-end sequence — how a completed tender flows from Mercura

      into your ERP via a webhook, and the polling alternative — see

      **Getting Started → Integration patterns: webhook vs polling**.


      # Setup


      ## Setting up a subscription


      1. An org admin opens **Settings → Organisation → Webhooks** in the
         Mercura admin UI.
      2. They click **New webhook**, enter the HTTPS endpoint your system
         exposes, pick which event types should fan out to that endpoint,
         and save. Mercura returns the **signing secret exactly once** at
         this moment — copy it into your secret store immediately, it cannot
         be retrieved later.
      3. They click **Send test event** to fire a `webhook.test` event at
         the endpoint. Use this to validate signature verification end-to-end
         before going live.
      4. Your endpoint then receives the event types you selected
         automatically, as they fire on the Mercura side (e.g.
         `tender.completed` / `order.completed` on completion, `job.finished`
         on a bulk-write job).

      If the secret is lost, the admin can delete the subscription and

      create a new one — there is no rotate-in-place flow.


      ## Event types


      | `event_type` | Fires when |

      |---|---|

      | `tender.completed` | A tender offer is **released to the partner API** —
      the seller clicks **Finalize** (Export → API) in the matching view.
      Carries `tender_id` (= the request id) and `completed_at`; fetch the full
      record from `GET /tenders/{tender_id}`. Fires again (with a newer
      `completed_at`) on every re-release. (Other export paths — PDF, offer
      email/download, the ERP callback — do NOT fire this.) |

      | `order.completed` | An order is **finalised** (marked exported) for
      handoff. Carries `order_id` (= the request id) and `completed_at`; fetch
      the full record from `GET /orders/{order_id}`. |

      | `project.exported` | A Mercura user hands a **project** (*Objekt*) over
      to your system — the **Send to ERP** button on the project page. Carries
      `project_id` and the current `object_number`; fetch the full record from
      `GET /projects/{project_id}`, create it on your side, then report the
      outcome with `POST /projects/{project_id}/acknowledgements`. Unlike the
      `*.completed` events this is an explicit handoff, not a lifecycle
      transition: the project's `status` is untouched and re-clicking emits
      again. The button only appears for organisations subscribed to this event.
      |

      | `job.finished` | A bulk-write job from this API (`POST /articles` /
      `/customers` / `/suppliers`) reaches a terminal status (`COMPLETED` or
      `FAILED`). Replaces the need to poll `GET /jobs/{job_id}`. |

      | `request.processing_completed` | A request finishes its intake
      processing — file parsing, position extraction — whatever the intake
      channel (email forwarding, web app, or `POST /tenders` / `POST /orders`).
      Fires on success *and* on parsing failure; branch on `data.status`. This
      is the *intake* signal; `tender.completed` / `order.completed` are the
      *export/finish* signal. |

      | `webhook.test` | A human clicks **Send test event** in the admin UI.
      Carries a fixed synthetic payload — useful for end-to-end smoke tests of
      your verification code. |


      Every event type uses the same wire format, headers, and signature

      scheme (below), so a `webhook.test` delivery validates your verification

      code against the exact production contract before you rely on any real

      event.


      # Delivery


      ## Wire format


      Every delivery is a `POST` from Mercura to your endpoint with this

      shape:


      ```

      POST https://your-erp.example.com/mercura/webhooks

      Content-Type: application/json

      User-Agent: Mercura-Webhooks/1.0

      Mercura-Event: tender.completed

      Mercura-Event-Id: 4a2b9d1c-7e8f-4a3b-9c1d-2e3f4a5b6c7d

      Mercura-Delivery-Id: 9c1f77a2-8b3e-4d5f-a6c7-1b2c3d4e5f60

      Mercura-Delivery-Attempt: 1

      Mercura-Timestamp: 1747742400

      Mercura-Signature-256: sha256=4f3a9e...


      {
        "event_id": "4a2b9d1c-7e8f-4a3b-9c1d-2e3f4a5b6c7d",
        "event_type": "tender.completed",
        "delivered_at": "2026-05-20T12:00:00Z",
        "data": { ... }
      }

      ```


      | Header | Purpose |

      |---|---|

      | `Mercura-Event`            | The event type. Same as `event_type` in the
      body. |

      | `Mercura-Event-Id`         | Stable across delivery attempts. Use this
      for **dedup**: if you see the same `Mercura-Event-Id` twice, process it
      once. |

      | `Mercura-Delivery-Id`      | Unique per delivery attempt. Useful when
      quoting an attempt in a support ticket. |

      | `Mercura-Delivery-Attempt` | `1` for the first attempt, incremented on
      each retry. |

      | `Mercura-Timestamp`        | Unix seconds at the moment Mercura signed
      the payload. Used in the signature and for replay protection. |

      | `Mercura-Signature-256`    | `sha256=` followed by the hex HMAC-SHA256
      of `<timestamp>.<raw_body>` keyed by your subscription secret. |


      Respond with any `2xx` to acknowledge — Mercura ignores the response

      body. A `4xx` (other than `408` / `429`) marks the delivery as

      permanently failed; a `5xx`, `408`, `429`, or network error triggers

      the retry schedule below.


      ## Example payloads


      ### `tender.completed`


      Fired when a tender offer is released to the partner API (the seller's
      **Finalize** / Export → API action). The

      payload is intentionally small — it tells you *which* tender changed and

      *when*, and you fetch the current state from `GET /tenders/{tender_id}`

      (`tender_id == request_id`). `completed_at` is refreshed on every

      completion, so pairing this event with `GET /tenders?completed_since=…`
      lets

      you reconcile even if a delivery is missed.


      ```json

      {
        "event_id": "7e8f4a3b-9c1d-4a2b-9d1c-2e3f4a5b6c7d",
        "event_type": "tender.completed",
        "delivered_at": "2026-07-09T12:00:00Z",
        "data": {
          "tender_id": 12345,
          "name": "RFQ-2026-0042 — Office Tower B",
          "customer_bp_id": 678,
          "project_id": 90,
          "position_count": 47,
          "erp_offer_id": null,
          "completed_at": "2026-07-09T11:59:58Z"
        }
      }

      ```


      ### `order.completed`


      Fired when an order is finalised (marked exported). Same shape

      as `tender.completed` but keyed by `order_id` (= the request id); fetch
      the

      full record from `GET /orders/{order_id}`.


      ```json

      {
        "event_id": "9c1d2e3f-4a5b-4c7d-8e9f-0a1b2c3d4e5f",
        "event_type": "order.completed",
        "delivered_at": "2026-07-09T12:05:00Z",
        "data": {
          "order_id": 20456,
          "name": "PO-88213",
          "customer_bp_id": 678,
          "project_id": null,
          "position_count": 12,
          "erp_offer_id": "SO-2026-5567",
          "completed_at": "2026-07-09T12:04:57Z"
        }
      }

      ```


      ### `project.exported`


      Fired when a Mercura user clicks **Send to ERP** on a project. Like the

      `*.completed` events the payload only says *which* project and *when* —

      fetch the record from `GET /projects/{project_id}`. `object_number` is

      Mercura's current value for your object id: `null` on a first handoff,

      and on a re-export the number you acknowledged earlier, so you can tell

      a create from an update without your own mapping table.


      ```json

      {
        "event_id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
        "event_type": "project.exported",
        "delivered_at": "2026-08-31T09:15:00Z",
        "data": {
          "project_id": 90,
          "name": "Maplewood Heights",
          "object_number": null,
          "status": "ACTIVE",
          "request_count": 3,
          "exported_at": "2026-08-31T09:14:58Z"
        }
      }

      ```


      ### `job.finished`


      Fired when a bulk-write job reaches a terminal status. The payload

      mirrors the `GET /jobs/{job_id}` envelope — receive this event and

      you no longer need to poll. The semantics of `status`, `error_count`,

      and `errors[]` are documented under the **Jobs** chapter.


      ```json

      {
        "event_id": "8b3e4d5f-a6c7-1b2c-3d4e-5f6071829304",
        "event_type": "job.finished",
        "delivered_at": "2026-05-20T10:00:42Z",
        "data": {
          "job_id": "4242",
          "entity": "ARTICLES",
          "status": "COMPLETED",
          "created_at": "2026-05-20T10:00:00Z",
          "updated_at": "2026-05-20T10:00:42Z",
          "total_rows": 12000,
          "created_count": 9000,
          "updated_count": 2950,
          "skipped_count": 0,
          "deleted_count": 0,
          "error_count": 50,
          "errors": [
            {
              "row_number": 137,
              "identifier": "LEU-0900-18-830",
              "error_message": "missing list_price"
            }
          ]
        }
      }

      ```


      ### `request.processing_completed`


      Fired when a request finishes intake processing, regardless of how it

      entered the platform — a forwarded email, a web-app upload, or

      `POST /tenders` / `POST /orders`. `status` is `COMPLETED` when the

      request landed ready for review and `FAILED` when parsing failed.


      `job_id` is populated only for requests created via the API

      (`POST /tenders` / `POST /orders`) and matches the `job_id` from the

      original `JobAck` — use it to correlate the event with your upload.

      For email- and UI-created requests it is `null`, so this event also

      lets you track LVs your customers forward by email without any polling.


      ```json

      {
        "event_id": "5f607182-9304-4d5f-a6c7-1b2c3d4e8b3e",
        "event_type": "request.processing_completed",
        "delivered_at": "2026-06-10T10:04:12Z",
        "data": {
          "request_id": 12345,
          "request_type": "TENDER",
          "status": "COMPLETED",
          "name": "RFQ-2026-0042 — Office Tower B",
          "customer_bp_id": 678,
          "position_count": 47,
          "job_id": "4242",
          "error_message": null
        }
      }

      ```


      ### `webhook.test`


      Fired when a human clicks **Send test event** in the admin UI.

      Carries a fixed synthetic payload — your verification code should

      treat it exactly like a real event.


      ```json

      {
        "event_id": "00000000-0000-4000-8000-000000000000",
        "event_type": "webhook.test",
        "delivered_at": "2026-05-20T09:30:00Z",
        "data": {
          "subscription_id": 42,
          "message": "This is a test webhook from Mercura.",
          "sent_at": "2026-05-20T09:30:00Z"
        }
      }

      ```


      # Receiver implementation


      ## Verifying the signature


      Mercura signs every delivery with HMAC-SHA256 over

      `<Mercura-Timestamp>.<raw_body>`, keyed by your subscription secret.

      **Always verify before trusting the payload** — and verify against

      the raw bytes you received, not against a re-serialised JSON object.


      ```python

      import hashlib

      import hmac

      import time


      MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60  # 5 minutes



      def verify_mercura_webhook(
          *,
          secret: str,
          raw_body: bytes,
          timestamp_header: str,
          signature_header: str,
      ) -> bool:
          # 1. Reject stale or future-dated deliveries — protects against replays.
          try:
              ts = int(timestamp_header)
          except (TypeError, ValueError):
              return False
          if abs(time.time() - ts) > MAX_TIMESTAMP_SKEW_SECONDS:
              return False

          # 2. Recompute the signature.
          signed_payload = f"{ts}.".encode() + raw_body
          expected = hmac.new(
              secret.encode("utf-8"),
              signed_payload,
              hashlib.sha256,
          ).hexdigest()

          # 3. Constant-time compare against the value Mercura sent.
          prefix = "sha256="
          if not signature_header.startswith(prefix):
              return False
          return hmac.compare_digest(expected, signature_header[len(prefix):])
      ```


      If verification fails, return `400` and log the

      `Mercura-Delivery-Id` — Mercura will not retry on a `4xx`, which is

      the right behaviour for a malformed or unauthenticated request.


      ## Custom auth header (optional)


      If your receiver sits behind a gateway or reverse proxy that requires

      a fixed authentication header — API key, bearer token, service

      credential — you can configure Mercura to send one on every delivery.


      When creating or editing a subscription, set:


      - **Auth header name** — e.g. `X-API-Key`, `Authorization`

      - **Auth header value** — e.g. `sk_live_abc123`, `Bearer eyJhbGci…`


      Mercura then attaches that header to every `POST` **on top of** the

      HMAC signature — the signature is still your authoritative source of

      truth. Treat the custom header as a coarse gateway filter, not as a

      replacement for verifying the signature.


      The header value is never returned by any read endpoint — GET

      responses expose only the header **name** and a `has_auth_header_value`

      boolean so the admin UI can show "configured" without leaking the

      secret. To rotate the value, `PATCH` the subscription with a fresh

      `auth_header_value` (both fields must be sent together); to remove

      the header entirely, `PATCH` both fields to `null`.


      The following prefixes are reserved and rejected — they would collide

      with Mercura's own delivery metadata:


      - `Mercura-*`

      - `Content-*`

      - `User-Agent`

      - `Host`


      `Authorization` is intentionally NOT reserved — using

      `Authorization: Bearer …` is a primary use case.


      ## Retry policy


      If your endpoint returns a retryable status — `5xx`, `408`, `429`, or

      a network/timeout error — Mercura retries on this schedule:


      | Attempt | Wait before this attempt |

      |---|---|

      | 1 | immediate |

      | 2 | 30 seconds |

      | 3 | 5 minutes |

      | 4 | 30 minutes |

      | 5 | 2 hours |

      | 6 | 12 hours |


      After attempt 6 the delivery is recorded as permanently `failed` and

      shows up in the admin UI's recent-deliveries view; Mercura does not

      retry further. The `Mercura-Event-Id` stays stable across all

      attempts, so a delivery that eventually succeeds after some retries

      is the same logical event as the earlier attempts.


      A `2xx` response stops the retry chain immediately. Any other `4xx`

      (except `408` and `429`) is treated as a permanent partner-side

      configuration error and is not retried.
x-tagGroups:
  - name: Masterdata
    tags:
      - Articles
      - Accessories
      - Alternatives
      - Successors
      - Unit Conversions
      - Customers
      - Suppliers
      - Contacts
  - name: Orders
    tags:
      - Orders
  - name: Tenders
    tags:
      - Tenders
      - Supplier Requests
      - Projects
  - name: Platform
    tags:
      - Jobs
      - Webhooks
  - name: Account
    tags:
      - Users
      - Statistics
