{
  "openapi": "3.1.0",
  "info": {
    "title": "Mercura API",
    "description": "# Getting Started\n\n## What this API is\n\n**Mercura** is an AI-powered platform for processing inquiries — bills\nof materials, tenders, RFQs — in wholesale distribution, manufacturing,\nand technical sales. The **Mercura API** is the integration surface\nyour organisation uses to plug Mercura into the rest of your software\nlandscape.\n\nThe API is bidirectional by design:\n\n- **Master data flows in** — your articles, customers, and suppliers\n  are pushed into Mercura so that incoming inquiries can be processed\n  against them.\n- **Structured offers flow out** — each inquiry, once Mercura has read\n  the LV, captured the positions, and matched each one to an article\n  from your catalogue, is returned as a fully structured offer ready\n  for your downstream system to pick up.\n\nThe source system on your side does not have to be a specific ERP. Any\nsystem that owns master data — an ERP, a PIM, a custom catalogue, a\nspreadsheet export pipeline — can be the integration partner.\n\n## Main use case\n\nThe end-to-end flow is four steps:\n\n1. **Push master data.** `POST /articles`, `POST /customers`, and\n   `POST /suppliers` accept bulk payloads of up to 100,000 rows each\n   and return a `JobAck` immediately. Sending one large request per\n   resource is preferred over many small ones.\n2. **Get notified when the jobs finish.** Each `POST` returns a\n   `job_id`. The recommended path is a **webhook**: register a\n   subscription once (admin UI, see **Webhooks** below) and Mercura\n   delivers a signed `job.finished` event to your endpoint as soon\n   as the job reaches a terminal state. If a webhook receiver is not\n   an option, fall back to polling `GET /jobs/{job_id}`.\n3. **Read completed tenders and orders back.** This is the payoff. A\n   `tender.completed` (or `order.completed`) webhook fires the moment a\n   Mercura user releases a record to your system (the **Finalize** /\n   Export → API action) — your endpoint then calls `GET /tenders/{tender_id}`\n   (or `GET /orders/{order_id}`) to pull the full structured record\n   (positions, matched articles, quantities, prices, totals).\n   `GET /tenders` / `GET /orders` are the cursor-paginated list views\n   for catch-up scans; pass `?completed_since=…` for the completion feed.\n4. **Acknowledge the import.** Once the record is committed to your\n   ERP/CRM, `POST /tenders/{tender_id}/acknowledgements` (or\n   `/orders/{order_id}/acknowledgements`) with `status: \"SUCCESS\"` and\n   your ERP document id in `external_id` — Mercura stores it on the\n   record's `erp_offer_id` (latest-wins) and surfaces the outcome in the\n   app. Report `status: \"FAILED\"` with a `message` when the import is\n   rejected.\n\nA typical integration runs step 1 nightly (delta-sync new and changed\nmaster-data rows) and reacts to `tender.completed` / `order.completed`\nwebhooks in step 3 to drive new offers into the downstream system in\nreal-time, acknowledging each import in step 4.\n\n## Integration patterns: webhook vs polling\n\nSteps 3–4 above can be driven two ways. **Webhook (push)** is the\nrecommended path — Mercura notifies your endpoint the instant a tender\nor order completes, so you react in real time. **Polling (pull)** is the\nfallback when your side cannot expose an inbound HTTPS endpoint — you\nscan the completion feed on a schedule. Both end the same way: fetch the\nfull record, write it to your ERP/CRM, and acknowledge the import.\n\nThe sequences below show the **tender** flow. **Orders are identical** —\nswap `tender.completed` → `order.completed`, `GET /tenders/{id}` →\n`GET /orders/{id}`, and the acknowledgements path.\n\n### Webhook (push)\n\n![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)\n\nThe webhook payload is intentionally small — it identifies *which*\ntender changed and *when*; you always fetch the current state with\n`GET /tenders/{tender_id}`. See the **Webhooks** chapter for the wire\nformat, the HMAC-SHA256 signature recipe, and the retry schedule.\n\n### Polling (pull)\n\n![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)\n\nPoll `?completed_since=…` on a schedule (every few minutes is typical).\n`completed_at` refreshes on every completion, so a re-exported tender\nreappears in the feed — key off `tender_id` and keep your ERP writes\nidempotent. Keep `completed_since` on every request; once you hold a\n`next_cursor`, send only the cursor.\n\n## Webhooks\n\nIf you would rather **not poll**, Mercura can push events to an HTTPS\nendpoint on your side as soon as something happens — a `job.finished`\nevent replaces the polling loop above, and a `tender.completed` /\n`order.completed` event tells you the moment a record has been released\nto your system (see **Integration patterns: webhook vs polling** above\nfor the full push-vs-pull sequence).\n\nSubscriptions are set up by an org admin in the Mercura web app under\n**Settings → Organisation → Webhooks** — there are no\n`/webhook_subscriptions` endpoints in this API. Once a subscription\nexists, the wire format, HMAC-SHA256 signature recipe, retry schedule,\nand example payloads are documented in the **Webhooks** chapter\nfurther down.\n\n# Authentication\n\nThe API uses **bearer tokens** issued in the Mercura admin UI under\n**Settings → Organisation → API Keys**.\n\n```\nAuthorization: Bearer mrc_live_<token>\n```\n\nMissing, malformed, expired, or revoked tokens return\n`401 UNAUTHORIZED`.\n\n# Operations\n\n## Data conventions\n\nA few conventions hold across every resource:\n\n- **Embedded addresses, standalone contacts.** Customer and\n  supplier payloads carry their `addresses[]` inline — there is no\n  separate `/addresses` endpoint, and the wire format hides the\n  internal address table so each parent push is a single snapshot.\n  **Contacts**, by contrast, are managed via the dedicated `/contacts`\n  resource and carry their own `parent_type` (`customer` or `supplier`)\n  and `parent_id`, so adding or updating a contact does not require\n  re-sending the entire parent — see the **Contacts** chapter for the\n  full model and upsert rules.\n- **Stable partner-supplied identifiers.** `article_number`,\n  `customer_id`, and `supplier_id` are the keys you assign in your\n  source system. They must remain stable across sync cycles — Mercura\n  uses them to upsert: same id → existing row updated; new id → new\n  row created.\n- **Timestamps in ISO-8601 UTC.** All timestamps on the wire are\n  ISO-8601 with an explicit `Z` (or `+00:00`) suffix.\n- **`custom_fields` on every master-data resource.** Use it for any\n  source-system field that doesn't fit the standard schema. Mercura\n  preserves the value alongside the row; matching uses it where\n  appropriate.\n\n## Errors\n\nEvery non-2xx response uses one envelope:\n\n```json\n{\n  \"error\": {\n    \"code\": \"VALIDATION_FAILED\",\n    \"message\": \"Request validation failed\",\n    \"details\": [ { \"loc\": [\"query\", \"cursor\"], \"msg\": \"Invalid cursor\", \"type\": \"value_error\" } ],\n    \"request_id\": \"9a3b...e2\"\n  }\n}\n```\n\nBranch on `code` — it is stable. `message` is human-readable and may\nchange between versions. The full list of codes (`VALIDATION_FAILED`,\n`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `IDEMPOTENCY_KEY_MISMATCH`,\n`RATE_LIMITED`, `INTERNAL_ERROR`, …) is documented per endpoint.\n\n## Request IDs\n\nEvery response carries an `X-Request-Id` header (echoed if you supply\none, generated otherwise) and the same id is embedded in every error\nenvelope. Quote it in support tickets — it is the fastest way for\nMercura's team to look up the exact request in our logs.\n\n## Versioning\n\nThe API follows **Semantic Versioning** (`MAJOR.MINOR.PATCH`). Only the\n`MAJOR` number lives in the URL path (`/v1`, future `/v2`, …). The full\nSemVer string is published in the OpenAPI `info.version` field and on\nevery response as the `X-API-Version` header.\n\n| Bump | What it means |\n|---|---|\n| **PATCH** | Bugfix / doc fix. OpenAPI shape unchanged. |\n| **MINOR** | Additive only: new optional field, new endpoint, relaxed constraint. Existing clients keep working unchanged. |\n| **MAJOR** | Breaking: removed field, renamed field, narrower constraint. A new URL prefix is published; the previous `MAJOR` remains available during a migration period. |\n\n## Rate limits\n\nEach API key has its own budget. **Every request counts as one** against\nthe matching limiter — reads draw from the read bucket, writes from the\nwrite bucket, and each in-flight bulk-write job holds one concurrency\nslot. Defaults today:\n\n| Class | Limit |\n|---|---|\n| Writes (POST)     | 60 / minute, burst 10 |\n| Reads (GET)       | 600 / minute, burst 60 |\n| Concurrent in-flight bulk-write jobs | 5 per key |\n\nEvery response carries `RateLimit-Policy` / `RateLimit` headers (and\nlegacy `X-RateLimit-*` headers) describing your current budget. On a\n`429 RATE_LIMITED` response, `Retry-After` tells you how long to wait,\nand `X-RateLimit-Scope` tells you whether you hit the per-minute bucket\n(`rate`) or the concurrent-jobs cap (`concurrency`).\n\n## Read-endpoint pagination\n\nEvery `GET` list endpoint — `/tenders`, `/orders`, `/supplier-requests`,\n`/articles`, `/customers`, `/suppliers`, `/projects`, and `/contacts` — is cursor\npaginated. Pass `?modified_since=<ISO-8601>` on the\nfirst page to bound the lower edge of the scan by `updated_at` (useful for\ndelta-sync). Mercura returns a page plus a `next_cursor` — pass it back as\n`?cursor=…` to get the next page. Once you have a cursor, `modified_since`\nis ignored.\n\nOn `/tenders` and `/orders` you can instead pass\n`?completed_since=<ISO-8601>` to pull the **completion feed** — only records\ncompleted at/after that instant, ordered by `completed_at` — the\nreconciliation twin of the `tender.completed` / `order.completed` webhooks.\nBoth accept a `status` filter; `/contacts` additionally filters by\n`parent_type` / `parent_id` / `is_active`.\n\nThe cursor is keyed on `(updated_at, id)`, so it is stable under\nconcurrent writes: a row updated mid-scan may resurface on a later\npage, but you will never silently skip or duplicate rows.\n\n# Alternative integration\n\n## SFTP-based integration\n\nThe Mercura API documented here is the preferred channel because it is\nsynchronous, low-latency, and operationally light — status comes back\nimmediately, no file-watching is required on either side, and\nmaster-data updates can flow event-driven. Where a direct API\nintegration is not feasible (network constraints, security policy,\nERP capability), Mercura also supports SFTP-based exchange in both\ndirections for the same payloads. Talk to your Mercura contact if you\nneed that path; the data shapes are identical to the ones documented\nhere.\n",
    "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).\n\nThe programmatic twin of forwarding an LV / RFQ email to\n``anfragen@lv.mercura.ai``: Mercura stores the files, picks the file\nthat drives parsing (GAEB before spreadsheet/CSV/DOCX/TXT before PDF\nbefore image), extracts chapters and positions, matches them against\nyour catalogue, and lands the tender ready for review.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the first\n  call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay (same\n  ``Idempotency-Key`` + same form fields and file contents).\n\n**What to do next.** Poll ``GET /jobs/{job_id}`` until ``status`` is\n``COMPLETED`` or ``FAILED`` (the terminal state is reached when\n*processing* finishes, not when the upload is accepted), or subscribe\nto the ``request.processing_completed`` webhook and correlate by\n``job_id``. The resulting offer is then readable via ``GET /tenders``.\n\n**Correlating with your system.** Use ``custom_fields`` to carry your\nown identifier (a CRM case number, an ERP reference) onto the tender at\ncreation time, and ``branch`` to route it. Both are validated against\nyour organisation's configuration *before* the job is created, so a\ntypo fails fast instead of landing an unidentifiable tender. Store the\n``request_id`` from ``GET /jobs/{job_id}`` on your side — it is the\nstable handle for every later call (``GET``/``PATCH /tenders``).\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars). Same\nkey 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.\n\nThe response carries a strong ``ETag`` over the tender content; pass it back\nas ``If-Match`` on ``PATCH /tenders/{tender_id}`` for optimistic concurrency.\nThe ETag covers the *live* content only, so it is unaffected by\n``include_deleted`` and a document fetched either way round-trips through\n``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.\n\nTwo addressing modes: a sparse top-level ``positions[]`` (recommended —\nedit or delete existing lines by ``id``) and the nested ``chapters`` tree\n(required for adds; also accepts edits). Edit a line by ``id``; delete one\nwith ``{\"id\": \"…\", \"op\": \"delete\"}`` (soft-delete); add a line by sending it\nwithout an ``id`` (``article_number`` for an article line — with optional\nprice/discount — else a free-text line under a chapter). Omitted lines are\nleft untouched. ``totals.net_total`` / ``gross_total`` persist as the\npartner's authoritative amounts. Pass the GET's ETag as ``If-Match`` to be\nrejected with ``412`` if the tender changed since you read it.\n\n**Closing a tender.** Send ``{\"status\": \"CANCELLED\"}`` for a tender that was\nresolved outside Mercura — its CRM case was closed, or it was quoted by hand\n— so it leaves the open inbox instead of accumulating there. It is the only\nwritable transition; echoing the tender's current status is a no-op, so a\nfull 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.\n\nReturns metadata for each **original uploaded** file (GAEB / PDF / Excel /\nimage / e-mail) together with a short-lived, pre-signed ``download_url`` that\nfetches the bytes directly from object storage — no ``Authorization`` header\nneeded on that URL, and it never proxies the file through the API.\n\nOnly the customer's own uploads are listed: Mercura-generated artefacts (the\noffer / display PDF, structured-doc previews, OCR / metadata side-files) and\nhidden files are excluded. The download URLs expire (see\n``download_url_expires_at``) — re-list to obtain fresh ones. An unknown /\nnon-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.\n\nThe partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an\n``external_id`` (its own offer/record id) and ``metadata``, and a\n``message`` (required on ``FAILED`` as the reason, optional on ``SUCCESS``\nas a free-text note). Idempotent and latest-wins: a later acknowledgement\noverwrites the earlier one. When supplied, ``external_id`` is also written\nback to the tender's ``erp_offer_id``. Returns the recorded acknowledgement\n(status, sticky-aware external_id, message, metadata, acknowledged_at) —\nthe same shape as the ``acknowledgement`` field on ``PublicTenderOut``, not\nthe whole tender. Recording-only: it does not alter the tender's lifecycle\nstatus 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.\n\nAppend-only: each POST records one event (e.g. the tender was marked *Won*\nor *Lost* in your CRM), so the full partner-side timeline is preserved.\nSupply ``external_event_id`` to make retries idempotent — an identical\nreplay returns the already-stored event with ``200``, a conflicting reuse of\nthe id is ``409``. **Recording-only**: this never changes the tender's\nlifecycle status or re-triggers any export. These are events *you* report to\nMercura — 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).\n\nThe programmatic twin of forwarding a purchase-order email: Mercura\nstores the files, extracts the order metadata (customer, references,\ndelivery), matches the line items against your catalogue, and lands the\norder ready for review / ERP export.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the first\n  call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay (same\n  ``Idempotency-Key`` + same form fields and file contents).\n\n**What to do next.** Poll ``GET /jobs/{job_id}`` until ``status`` is\n``COMPLETED`` or ``FAILED``, or subscribe to the\n``request.processing_completed`` webhook and correlate by ``job_id``.\nThe order is then readable via ``GET /orders``.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars). Same\nkey 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.\n\nThe partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an\n``external_id`` (its own order/record id) and ``metadata``, and a\n``message`` (required on ``FAILED`` as the reason, optional on ``SUCCESS``\nas a free-text note). Idempotent and latest-wins: a later acknowledgement\noverwrites the earlier one. When supplied, ``external_id`` is also written\nback to the order's ``erp_offer_id``. Returns the recorded acknowledgement\n(status, sticky-aware external_id, message, metadata, acknowledged_at) —\nthe same shape as the ``acknowledgement`` field on ``PublicOrderOut``, not\nthe whole order. Recording-only: it does not alter the order's workflow\nstatus 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.\n\nAppend-only: each POST records one event (e.g. the order was *Cancelled* in\nyour ERP), so the full partner-side timeline is preserved. Supply\n``external_event_id`` to make retries idempotent — an identical replay\nreturns the already-stored event with ``200``, a conflicting reuse of the id\nis ``409``. **Recording-only**: this never changes the order's workflow\nstatus or re-triggers any export. These are events *you* report to Mercura —\nnot 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.\n\nThe id matches the ``project_id`` field carried on the\n``offer.new_export_run`` webhook — pass the value verbatim\n(stringified) to round-trip.\n\nThe response carries a strong ``ETag`` over the editable content; pass it\nback as ``If-Match`` on ``PATCH /projects/{project_id}`` for optimistic\nconcurrency.",
        "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.\n\nSend only the fields you want to change; omitted fields are left untouched,\nand an explicit ``null`` clears a field (except ``status``, which cannot be\nnull). ``object_number`` writes the ERP-facing \"Objektnummer\"; ``status``\ntakes the UPPERCASE ``PublicProjectStatus`` values; ``custom_fields`` is\nlabel-keyed and shallow-merged (a key set to ``null`` clears that field).\nPass the GET's ETag as ``If-Match`` to be rejected with ``412`` if the\nproject 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.\n\nThe partner POSTs ``status`` (``SUCCESS`` / ``FAILED``), optionally an\n``external_id`` (its own project/record id, e.g. its ERP document number)\nand ``metadata``, and a ``message`` (required on ``FAILED`` as the reason,\noptional on ``SUCCESS`` as a free-text note). Idempotent and latest-wins:\na later acknowledgement overwrites the earlier one; ``external_id`` is\nkept once provided. Returns the recorded acknowledgement (status,\nsticky-aware external_id, message, metadata, acknowledged_at) — the same\nshape as the ``acknowledgement`` field on ``PublicProjectOut``, not the\nwhole project. A supplied ``external_id`` is also written to the project's\n``object_number`` (the Objektnummer) — the key Mercura echoes back as\n``erp_object_id`` on later tender/order exports — so one POST both records\nthe outcome and reconciles the identifier without a PATCH. It never alters\nthe project's lifecycle ``status`` and re-triggers no export. The\nmethod-minimal alternative to PATCH for middlewares that cannot issue the\nPATCH 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).\n\nAccepts up to 100,000 rows per call. Each row upserts the\n``(source_article_number, accessory_article_number)`` pair; rows whose\narticles don't exist are skipped (see the job's ``skipped_count``).\n\n**Response.** ``202 Accepted`` with ``JobAck`` on the first call;\n``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll\n``GET /jobs/{job_id}`` for terminal status.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the\nsame 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).\n\nAccepts up to 100,000 rows per call. Each row upserts the\n``(source_article_number, alternative_article_number)`` pair; rows\nwhose articles don't exist are skipped (see the job's ``skipped_count``).\n\n**Response.** ``202 Accepted`` with ``JobAck`` on the first call;\n``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll\n``GET /jobs/{job_id}`` for terminal status.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the\nsame 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).\n\nAccepts up to 100,000 rows per call. Successors are 1:1 per source\narticle: re-sending a source with a new successor replaces the previous\none. Rows whose articles don't exist are skipped (see the job's\n``skipped_count``).\n\n**Response.** ``202 Accepted`` with ``JobAck`` on the first call;\n``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll\n``GET /jobs/{job_id}`` for terminal status.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the\nsame 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).\n\nAccepts up to 100,000 rows per call. Each row upserts the\n``(article_number, alternative_unit)`` conversion; rows whose article\ndoesn't exist are skipped (see the job's ``skipped_count``).\n\n**Response.** ``202 Accepted`` with ``JobAck`` on the first call;\n``200 OK`` with the same ``JobAck`` on an idempotent replay. Poll\n``GET /jobs/{job_id}`` for terminal status.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars); the\nsame 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).\n\nAccepts up to 100,000 articles per call. The body is validated\nsynchronously; the actual upsert runs out-of-band on a worker.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the\n  first call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay\n  (same ``Idempotency-Key`` + same body). No second job runs.\n\n**What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``\nis the canonical path) until ``status`` is ``COMPLETED`` or\n``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some\nrows failed — see ``errors[]`` for the per-row detail. The Jobs\nchapter has the full polling guide.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).\nSame key with a *different* body returns\n``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).\n\nAccepts up to 100,000 customers per call. The body is validated\nsynchronously; the actual upsert runs out-of-band on a worker.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the\n  first call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay\n  (same ``Idempotency-Key`` + same body). No second job runs.\n\n**What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``\nis the canonical path) until ``status`` is ``COMPLETED`` or\n``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some\nrows failed — see ``errors[]`` for the per-row detail. The Jobs\nchapter has the full polling guide.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).\nSame key with a *different* body returns\n``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).\n\nAccepts up to 100,000 suppliers per call. The body is validated\nsynchronously; the actual upsert runs out-of-band on a worker.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the\n  first call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay\n  (same ``Idempotency-Key`` + same body). No second job runs.\n\n**What to do next.** Poll ``GET /jobs/{job_id}`` (the ``status_url``\nis the canonical path) until ``status`` is ``COMPLETED`` or\n``FAILED``. A ``COMPLETED`` job with ``error_count > 0`` means some\nrows failed — see ``errors[]`` for the per-row detail. The Jobs\nchapter has the full polling guide.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).\nSame key with a *different* body returns\n``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).\n\nEach row carries its own ``parent_type`` (``customer`` or\n``supplier``) and ``parent_id`` (the parent's partner-supplied\nidentifier — ``customer_id`` for customers, ``supplier_id`` for\nsuppliers). A single batch may mix both kinds.\n\n**Upsert key.** When ``external_id`` is set on a contact it is the\nprimary upsert key against the parent. Without it, the worker\nfalls back to dedup by email, then ``(name, phone)`` — same\npriority as the internal ERP sync path. Rows whose parent cannot\nbe resolved fail at the per-row level and surface in the job's\n``errors[]``; the rest of the batch is unaffected.\n\n**Response.**\n- ``202 Accepted`` with ``JobAck { job_id, status_url }`` on the\n  first call.\n- ``200 OK`` with the *same* ``JobAck`` on an idempotent replay.\n\n**Idempotency.** Pass an ``Idempotency-Key`` header (≤ 255 chars).\nSame key with a *different* body returns\n``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.\n\nOnly fields present in the payload are applied. Reassigning the\nparent 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.\n\nReturns a ``CursorPage`` with the matching user(s), or an empty\n``data`` array when the email is unknown in your organisation\n(never a 404 — the status code doesn't reveal which emails exist).\nAlmost always a single match, but email is not guaranteed unique:\nif more than one user shares an email, all are returned so you can\ndisambiguate by ``id``. ``next_cursor`` is always ``null`` — every\nmatch fits in one page. Use this to resolve a tender's\n``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.\n\nReturns the same raw data as the in-app \"Exported requests overview\"\ndashboard: for each request in the range, how its positions'\nselections were resolved — ``auto_selected`` (resolved without prediction\nranking), ``prediction_correct`` (chosen article was among Mercura's\npredictions), or ``manual`` (chosen article not predicted). The three counts\nsum to ``selection_count``. Each row also carries the request ``status``, the true\nhandling time ``active_seconds`` (engaged working time from usage telemetry;\ntenders only), and the ``first_opened_at`` / ``exported_at`` /\n``completed_at`` lifecycle timestamps. Divide ``active_seconds`` by\n``position_count`` / ``selection_count`` for a per-position / per-selection\nfigure. Rows are newest-first by request creation time. Compute hit-rate /\naccuracy percentages client-side from the raw counts. By default only\nexported requests are returned; pass ``include_unexported=true`` to also see\nrequests 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.\n\nReturned with ``202 Accepted`` on first-time creation and\n``200 OK`` on idempotent replay (same ``(org, Idempotency-Key)``,\nsame body hash). Clients poll ``GET /jobs/{job_id}`` for\nterminal 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.\n\nCollapsed from the internal ``ImportRunStatus`` (which carries\nmore granular states like ``PARTIALLY_COMPLETED`` and\n``VALIDATION_FAILED``) — see ``jobs/service.py:_STATUS_MAP`` for\nthe translation table. The public surface gives partners the four\nstates 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.\n\nBoth article numbers must already exist for your organisation; a row\nwhose source or accessory article is unknown is skipped (counted in\nthe 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.\n\nBoth article numbers must already exist for your organisation; a row\nwhose source or alternative article is unknown is skipped (counted in\nthe 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.\n\n``article_number`` is the partner's stable identifier — used both\nas the upsert key in the article DB and as the public-API id\nreturned in lists / ``GET /articles/{article_number}``. Required\nso 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.\n\nSubset of the internal ``Article`` ORM row; intentionally omits\ninternal frequency counters and sync bookkeeping. ``tags`` are\nreturned as names (resolved from the stored ``tag_ids``); a tag id\nwith 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.\n\n``parent_type`` + ``parent_id`` identify the owning business\npartner. ``external_id`` is the contact's own stable identifier\nfrom the source ERP (optional) — when present it is the primary\nupsert key. Without it the worker falls back to dedup by email,\nthen 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}``.\n\nEvery field is optional; only fields present in the payload are\napplied. Reassigning the parent is not supported via PATCH —\ndelete 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.\n\n``customer_id`` is the partner's stable identifier — used both as\nthe upsert key and as the public-API identifier returned in\n``GET /customers/{customer_id}``. Required so retries converge on\nthe 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.\n\n``address`` is singular (not plural) — reflects today's persistence\nreality. When Wave 2 lands multi-address support the field name\nbecomes ``addresses`` and ``address`` is kept as a deprecated\nalias for one minor cycle (additive change per the SemVer policy\nin 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.\n\nMirrors the canonical shape Mercura stores and matches against: the\nfeature ``etim_code``, a human-readable ``human_label``, a ``type``\ndiscriminator, and the one ``value_*`` field that matches that type\n(the others stay null). For ``type: \"number\"`` send ``value_number``,\nfor ``\"string\"`` send ``value_string``, and so on.\n\nRicher ETIM data sharpens Mercura's matching of incoming LV positions\nagainst 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.\n\nDistinct from ``schemas.common.Address`` — no ``kind`` / ``is_default``\nfields, because an offer's customer address is the single resolved\naddress that went on the document. All fields nullable to match the\nunderlying ``address_table`` (street can be NULL on minimally\npopulated rows). No ``region`` field — the internal address model\ndoesn'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``.\n\nOn the wire the customer's partner-id is named ``customer_id`` — the\nsame field name the customers resource uses, so a partner ingesting\nan offer can resolve the reference via ``GET /customers/{customer_id}``.\n\nThe Python attribute is still ``external_id`` to keep the shared\nprojection (``features/customer_offers/offer_projection.py``)\nindifferent to the rename. ``serialize_by_alias=True`` plus the\nfield's ``serialization_alias`` make every JSON path (FastAPI\nresponse, ``model_dump(mode='json')`` in the webhook emitter) emit\n``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.\n\nBuilt per non-deleted ``Selection`` (not per ``Position``) — a\nposition with multiple selections produces multiple rows, exactly\nmatching the PDF exporter. Alternative selections (carrying the\n``ALTERNATIVE`` label) are still emitted as rows but flagged with\n``is_alternative = true`` and are excluded from the totals.\n\n``article_number`` is the partner-facing identifier (the same value\na partner would use against ``GET /articles/{article_number}``). It\ncomes from the article record when the selection is article-backed,\nor from ``selection.article_number`` for free-text positions.\n\n``description`` is the line description as it appeared on the PDF.\nFor article-backed selections this is the article's own description;\nfor free-text it's the selection's text. Partners with their own\narticle 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.\n\nMirrors ``pdf_offer_service._build_real_data``: ``positions_subtotal``\nis the sum of ``line_net`` across non-alternative positions;\n``net_total`` prefers ``Request.net_total`` if set (header-level\ndiscount applied), otherwise equals ``positions_subtotal``;\n``gross_total`` prefers ``Request.gross_total`` if set, otherwise\nis 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.\n\nIts own type (parallel to ``PublicTenderAckStatus`` / the deprecated\n``PublicOfferAckStatus``) so the orders surface stays self-contained.\nTranslated from the internal ``RequestAckStatus`` via\n``orders.service._ORDER_ACK_STATUS_MAP`` — a test asserts the map covers\nevery internal value, so a new internal state is a loud failure rather than\na silent passthrough. A *separate axis* from ``PublicOrderStatus``: that is\nMercura-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.\n\nUsed in two places with the same shape:\n- as the ``acknowledgement`` field on ``PublicOrderOut`` — ``null`` until\n  the partner has POSTed an acknowledgement;\n- as the standalone response body of\n  ``POST /orders/{order_id}/acknowledgements`` — the recorded receipt, so\n  partners can confirm what Mercura persisted (resolved status, sticky-aware\n  ``external_id``, server-side ``acknowledged_at``) without refetching the\n  whole order.\n\n``status`` / ``message`` / ``metadata`` reflect the most recent\nacknowledgement; ``external_id`` is kept once provided (last-provided-wins)\nand 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``.\n\nThe partner reports the outcome of importing this order into their ERP\nsystem. Idempotent: a later acknowledgement overwrites\n``status`` / ``message`` / ``metadata`` (a retry that first ``FAILED`` then\n``SUCCESS`` ends up ``SUCCESS``, previous message cleared unless a new one\nis provided). ``external_id`` is the exception — it is *last-provided-wins*:\nonly updated when an acknowledgement carries one and never cleared, so a\nlater id-less acknowledgement does not erase a previously reported id (and\nit stays in step with the order's ``erp_offer_id``).\n\n``message`` is required when ``status`` is ``FAILED`` (the failure reason)\nand 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.\n\nResponse body of ``POST /orders/{order_id}/events``. ``event_type`` is the\nnormalised token (e.g. ``CANCELLED``); ``status_label`` the partner's\nverbatim 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``.\n\nAppend-only and recording-only: recording an event never changes the\norder's lifecycle status. Supply ``external_event_id`` to make retries\nidempotent — an identical replay returns the stored event, a conflicting\nreuse 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.\n\nSourced from the internal ``OrderMetadata`` row (one-to-one with the\norder). Internal-only fields (extraction confidence, the redundant\n``extracted_*`` / ``customer_address_*`` blocks that duplicate the\nresolved ``customer``) are deliberately dropped. All fields nullable —\nan 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.\n\nKeyed by the request id: for orders ``id == request_id`` (an order has a\nsingle result, unlike a tender which can have several offer exports).\n``totals`` are present for shape-symmetry with tenders but are typically\nzero — 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).\n\nA translated subset of the internal ``OrderWorkflowStatus`` (see README\n→ Design decisions → Public offer status as a translated subset). The\ninternal ``INCOMPLETE_METADATA`` is folded into ``NEEDS_REVIEW`` — both\nmean \"a human still has to act\" — so the public surface exposes a single\naction-required state. The mapping lives in\n``services.orders_service._ORDER_STATUS_MAP`` and a test asserts it\ncovers every internal value, so a new internal state is a loud failure\nrather 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.\n\nTranslated from the internal ``RequestAckStatus`` (the identical axis the\ntender/order acknowledgements use) via\n``projects.service._PROJECT_ACK_STATUS_MAP``. A *separate axis* from\n``PublicProjectStatus``: that is Mercura-side lifecycle, this is the\npartner'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.\n\nUsed as the ``acknowledgement`` field on ``PublicProjectOut`` and as the\nresponse body of ``POST /projects/{project_id}/acknowledgements``.\n``status`` / ``message`` / ``metadata`` reflect the most recent\nacknowledgement; ``external_id`` is kept once provided\n(last-provided-wins). Like the tender acknowledgement it is mirrored onto a\nfirst-class field: a supplied ``external_id`` is also written to the\nproject's ``object_number`` (the Objektnummer), which is what Mercura sends\nback 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``.\n\nIdempotent and latest-wins for status/message/metadata; ``external_id`` is\nlast-provided-wins (kept once set). ``message`` is required when ``status``\nis ``FAILED``. A supplied ``external_id`` is **also written to the project's\n``object_number``** (the Objektnummer) — the ERP-facing key Mercura echoes\nback as ``erp_object_id`` on later exports — so one POST both records the\noutcome and lands the partner's number. The project's lifecycle ``status``\nis 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.\n\n``id`` is the stringified ``Project.id`` — the same integer the\n``offer.new_export_run`` webhook carries as ``project_id``, so\npartners can round-trip the webhook payload straight into\n``GET /projects/{id}``.\n\n``object_number`` is the ERP-facing grouping key. It is NOT unique:\nMercura groups multiple project rows that share the same\n``object_number`` into one \"master project\" internally. Partners\nfiltering by ``object_number`` should be prepared to receive\nmultiple rows (page endpoint).\n\n``custom_fields`` carries the organisation-defined custom field\nvalues keyed by the field name defined in the organisation's\n``custom_column_definitions``. Multi-select values are returned as\ntheir human-readable option **labels** (e.g. ``[\"3030\"]``), not the\ninternal option ids — a value carrying multiple labels comes back as\na list. No JSON schema is enforced on the contents — the shape is\nper-org and can evolve without an API version bump. When the field\nis empty or unset, it is omitted from the response entirely.\n\n``request_ids`` lists the Mercura request ids linked to this\nproject (via ``Request.project_id``). Drill down to any request\nwith ``GET /offers/{request_id}`` — the ``offer_id`` used by that\nendpoint 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.\n\nTranslated from the internal ``ProjectStatus`` enum — the internal\nvalues are lower-cased for historical reasons; the public API uses\nUPPERCASE (matches ``PublicOfferStatus`` and the platform-wide\nenum convention in ``CLAUDE.md``). New values are SemVer MINOR\n(additive); removed values are MAJOR. Translation table lives in\n``projects.service._STATUS_MAP`` so a missing case is a test\nfailure, 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``.\n\nThe canonical use-case is writing an ERP-assigned identifier back onto the\nMercura project: the ERP creates a commission / project number and PATCHes\nit into ``object_number`` (the \"Objektnummer\"), so subsequent\n``offer.new_export_run`` webhooks and ``GET /projects`` reconcile against\nthe partner's own key.\n\n**PATCH semantics.** Only fields **present in the body** are written; an\nomitted field is left untouched. A field explicitly set to ``null`` clears\nit (``object_number: null`` blanks the Objektnummer). This\npresent-vs-absent distinction is resolved via Pydantic's\n``model_fields_set`` in the write service — do not read a missing field as\n``null``.\n\n**``object_number`` is a grouping key.** Mercura groups every project row\nthat shares an ``object_number`` into one logical \"master project\" — the\ngrouping is *derived at read time*, not stored, so writing the field on one\nproject row is sufficient and moves only that row into the group. The PATCH\nnever rewrites sibling rows.\n\n**``status``** takes the same UPPERCASE ``PublicProjectStatus`` values the\nread side emits; an unknown value is a ``400``. It cannot be cleared —\nsending ``status: null`` is a ``400`` (a project always has a lifecycle\nstate).\n\n**``custom_fields``** is label-keyed, symmetric with the read side: keys are\nthe org's custom-column **labels** (not internal UUIDs) and ``multi_select``\nvalues are the option **labels** (e.g. ``[\"3030\"]``). A key mapped to\n``null`` clears that field; keys omitted from the map are left untouched\n(shallow-merge, not replace). An unknown label — or an unknown option label\non a ``multi_select`` — is a ``400`` listing the offending keys, so a typo\nfails 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.\n\nThe three outcome counts (``auto_selected_count``,\n``prediction_correct_count``, ``manual_selection_count``) are mutually\nexclusive and sum to ``selection_count``. Percentages and hit-rate are\nintentionally **not** computed server-side — derive them from these raw\ncounts (e.g. ``hit_rate = (auto_selected_count + prediction_correct_count)\n/ 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.\n\nA request is uploaded as ``NEW``, moves through ``IN_PROGRESS`` while it is\nbeing worked, and becomes ``DONE`` once completed/exported. ``PARSING`` /\n``PARSING_FAILED`` are transient intake states; ``CANCELLED`` is a request\nthat was abandoned. Pair this with ``exported_at`` to tell an exported\nrequest 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.\n\nSmall denormalised bundle rather than a bare id — partners rendering\nan offer inbox rarely need to hit a second endpoint just to display\n\"Assigned to Anna Schmidt\". Email is included so downstream systems\ncan 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.\n\nBoth article numbers must already exist for your organisation; a row\nwhose source or successor article is unknown is skipped (counted in\nthe job's ``skipped_count``), not an error. Re-sending a source with a\nnew 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.\n\n``supplier_id`` is the partner's stable identifier — used both as\nthe upsert key and as the public-API identifier returned in lists\nand ``GET /suppliers/{supplier_id}``. Required so that retries\nconverge 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.\n\nSubset of the internal ``Supplier`` ORM row; intentionally omits\nrelational data (branch_ids, manufacturer_filter) which belong to\nWave 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.\n\nA translated subset of the internal ``SupplierRequestStatus``; the mapping\nlives in ``supplier_requests.service._SUPPLIER_REQUEST_STATUS_MAP`` and a\ntest asserts it covers every internal value. New values may be added in a\nMINOR release — the OpenAPI enum is closed, so consumers should treat an\nunknown 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.\n\nTranslated from the internal ``RequestAckStatus`` via\n``tenders.service._TENDER_ACK_STATUS_MAP``. A *separate axis* from\n``PublicTenderStatus``: that is Mercura-side lifecycle, this is the partner's\nimport 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.\n\nUsed as the ``acknowledgement`` field on ``PublicTenderOut`` and as the\nresponse body of ``POST /tenders/{tender_id}/acknowledgements``.\n``status`` / ``message`` / ``metadata`` reflect the most recent\nacknowledgement; ``external_id`` is kept once provided (last-provided-wins)\nand 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``.\n\nIdempotent and latest-wins for status/message/metadata; ``external_id`` is\nlast-provided-wins (kept once set) and is written back to the tender's\n``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``.\n\nLines to edit/add go in ``positions``; nested ``chapters`` are processed\nrecursively. Chapter metadata is declared for round-trip but ignored —\ncreating 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.\n\nA file the customer uploaded into the request — the same attachments a\nforwarded LV / RFQ email carried. Mercura-generated artefacts (the offer /\ndisplay PDF, structured-doc previews, OCR / metadata side-files) and hidden\nfiles are **not** listed here.\n\n``download_url`` is a short-lived, pre-signed link that serves the bytes\ndirectly from object storage — it needs no ``Authorization`` header and\nexpires at ``download_url_expires_at``. Treat it as opaque and transient:\nfetch promptly, and re-list the tender's documents to obtain a fresh URL\nonce 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``.\n\nA wrapper object (rather than a bare array) so the shape can grow without a\nbreaking 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.\n\nResponse body of ``POST /tenders/{tender_id}/events``. ``event_type`` is the\nnormalised token (e.g. ``WON``); ``status_label`` the partner's verbatim\nlabel; ``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``.\n\nAppend-only and recording-only: recording an event never changes the\ntender's lifecycle status. Supply ``external_event_id`` to make retries\nidempotent — an identical replay returns the stored event, a conflicting\nreuse 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).\n\n``BASE`` — Grundposition; ``OPTIONAL_WITH_TOTAL`` — Bedarfsposition mit\nGesamtbetrag; ``OPTIONAL_WITHOUT_TOTAL`` — Bedarfsposition ohne\nGesamtbetrag; ``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.\n\nA line can carry several. ``text`` is the label's display text (for CUSTOM\nlabels 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``.\n\n``CUSTOM`` labels carry a meaningful org-defined ``text`` (product groups,\n\"Bestellware\", …); the others are structural roles."
      },
      "PublicTenderLineType": {
        "type": "string",
        "enum": [
          "ARTICLE",
          "TEXT",
          "DIVERSE"
        ],
        "title": "PublicTenderLineType",
        "description": "What a line *is*, from ``Selection.item_type``.\n\n``ARTICLE`` — matched to a catalogue article; ``TEXT`` — a free-text line;\n``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``).\n\n``id`` is the opaque, re-match-stable handle you pass back in\n``PATCH /tenders/{tender_id}`` to edit this line. A GAEB position that\ncarries several selections (a main article plus alternatives / accessories /\nnotes) produces several lines that share a ``position_number`` but have\ndistinct ``id``s.\n\nLines **removed** from the tender are omitted by default and returned\nflagged (``is_deleted`` / ``deleted_at``) when the request asks for them via\n``include_deleted=true`` — one flagged line per removed article, so an ERP\nkeyed per article learns about a single article taken off an otherwise\nunchanged position. The ordinal number itself is gone exactly when *all*\nlines 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.\n\n* **Edit** — send ``id`` (from GET) plus the fields to change.\n* **Add article line** — omit ``id`` and send ``article_number`` (resolved\n  against your catalogue; deterministic, no matching).\n* **Add free-text line** — omit ``id`` and ``article_number`` and send\n  ``description``.\n* **Add / replace an article note** — omit ``id`` and send ``parent_line_id``\n  + ``description``. The note attaches to the parent line's existing position\n  (no new position, no own position number) and is an *upsert*: re-sending\n  for the same parent replaces its note instead of adding a second one.\n\nPrice resolution per line: ``list_price`` (Brutto) is the anchor; then **at\nmost one** of ``discount_percentage`` / ``net_price`` resolves the other\n(both ⇒ ``400``). Read-only fields are declared so a round-tripped GET body\nvalidates, 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).\n\nA translated subset of the internal ``RequestStatus``. The mapping lives in\n``tenders.service._TENDER_STATUS_MAP`` and a test asserts it covers every\ninternal 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.\n\n``positions_subtotal`` is a **derived helper**: the sum of ``line_net`` across\nlines with ``is_counted_in_total = true`` (i.e. excluding alternatives and\nBedarfspositionen ohne Gesamtbetrag / ``OPTIONAL_WITHOUT_TOTAL``).\n``net_total`` / ``gross_total`` are the **authoritative** request totals — the\nsame figures Mercura prints on the PDF — preferring the request-level persisted\nvalues (which a partner can override via PATCH — see\n``PublicTenderTotalsUpdate``) and otherwise falling back to the line sums.\n\nThe two can legitimately differ: ``net_total`` is *after* the header\n``additional_discount_percentage`` (``positions_subtotal`` is before it), and\nMercura's persisted total may include optional positions or other adjustments\nthat the counted-line subtotal omits. Treat ``net_total`` as the source of\ntruth 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\nauthoritative amounts and win over the server recalculation — they may\nintentionally diverge from the line sums (freight, rounding, surcharges).\n``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.\n\nTwo ways to address lines, freely combined:\n\n* **Top-level ``positions[]``** — the ergonomic *sparse* path and the\n  recommended contract for ERP sync: each entry edits or deletes an existing\n  line **by ``id``** (line ids are unique within a tender, so no chapter path\n  is needed). Send only the lines you changed.\n* **``chapters[]``** — the nested tree. Required to **add** a line (the target\n  chapter matters); edits/deletes are also accepted here so a GET body\n  round-trips. Omitted lines/chapters are left untouched. An **article note**\n  (``parent_line_id``) is the one add that needs no chapter path — the parent\n  line names its position — so it may go in either list.\n\nOptionally send ``totals`` overrides (incl. the header discount), label-keyed\n``request_custom_fields``, or ``status: \"CANCELLED\"`` to close a tender that\nwas resolved outside Mercura. The full read body (``PublicTenderOut``) is\naccepted verbatim — extra read-only fields are tolerated, unknown keys are\n``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.\n\nThe article must already exist for your organisation; a row whose\narticle is unknown is skipped (counted in the job's ``skipped_count``),\nnot 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)\nor RFQ: the customer demand Mercura matches against your article catalogue\nand prices into an offer. Tenders are one of the two entry points of the\nprocurement workflow (the other is `orders`).\n\n## Creating a tender\n\n`POST /tenders` is the programmatic twin of forwarding an LV / RFQ email to\n`anfragen@lv.mercura.ai`: send the same files you would attach to the mail\nand Mercura runs the identical intake pipeline — same parsers, same\nmatching, same landing states. Accepted files: GAEB\n(`.d81/.d83/.d94/.p81/.p83/.p93/.p94/.x81/.x83/.x93/.x94/.onlv`), `.xlsx`,\n`.csv`, `.docx`, `.txt`, `.pdf`, images, and `.eml`. Max 50 MB per file,\n100 MB total, 20 files. Mercura picks the file that drives parsing (GAEB\nbefore spreadsheet/CSV/DOCX/TXT before PDF before image); at least one file\nmust be parsable.\n\nUse the optional `context` field to pass anything that helps Mercura\nprocess the tender — the original email body, delivery notes, or guidance\nfor article matching. An unresolvable `customer_id` / `customer_email` is\n**not** an error: the tender is created without a customer, exactly like an\nemail from an unknown sender.\n\n### Carrying your own reference and routing\n\nTwo optional form fields let a CRM / ERP hand over the context Mercura\ncannot infer from the files:\n\n| Field | What it does |\n|---|---|\n| `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}`. |\n| `branch` | Name of the Mercura branch (Niederlassung), matched case-insensitively. Routes the tender to the team that works that branch. |\n\nBoth are validated against your organisation's configuration **before** the\njob is created, so a typo is a plain `400` with no job and no half-created\ntender: an unknown branch name, an unknown custom-field label, or a\nduplicate branch name all fail fast. This is deliberately stricter than\n`customer_id` — a tender that lands unrouted or without its case number is\ninvisibly wrong, which is worse than a rejected upload.\n\n```bash\ncurl -X POST https://prod-euapi.mercura.ai/api/public/v1/tenders \\\n  -H \"Authorization: Bearer $MERCURA_API_KEY\" \\\n  -H \"Idempotency-Key: case-00012345\" \\\n  -F \"files=@Leistungsverzeichnis.x83\" \\\n  -F \"name=Neubau Bürogebäude Nord\" \\\n  -F \"branch=Niederlassung München\" \\\n  -F 'custom_fields={\"Case Number\": \"00012345\"}'\n```\n\nStore the `request_id` that `GET /jobs/{job_id}` returns: it is the\n`tender_id` for every later call, and the stable handle to correlate the\ntender with the record on your side.\n\nThe upload is accepted in seconds; the job stays `RUNNING` until the\n**processing pipeline finishes** (typically a few minutes). Learn the\noutcome either by polling `GET /jobs/{job_id}` until `status` is\n`COMPLETED` / `FAILED`, or by subscribing to the\n`request.processing_completed` webhook and correlating by `job_id`.\n\n## Reading tenders\n\nA tender is **keyed by the request id** (`tender_id == request_id`): reading\none returns the tender's *current* projected state — the structured, priced\nresult Mercura produces — carrying everything on the corresponding PDF the\ncustomer sees:\n\n- **`status`** — lifecycle state (`NEW`, `IN_PROGRESS`, `DONE`, `CANCELLED`,\n  `PARSING`, `PARSING_FAILED`). `DONE` means the tender has been completed\n  (released to the partner API via the seller's **Finalize** / Export → API\n  action — which is what stamps `completed_at` and fires `tender.completed`).\n- **`completed_at`** — when the tender was last completed, or `null` if it\n  never was. Refreshed on **every** completion, so a re-export moves it\n  forward (see the completion feed below).\n- **`customer_id`** — the **direct** customer (Sold-To) **reference only** (your\n  `BusinessPartner.external_id`); resolve name / address / contact / custom\n  fields via `GET /customers/{customer_id}`. Null when the tender has no customer.\n- **`indirect_customer_id`** — the **indirect** customer (Mercura's *indirekter\n  Kunde*) **reference only**; resolve the full record via\n  `GET /customers/{customer_id}`. Distinct from `customer_id`. Null when the\n  tender has no indirect customer.\n- **`project_id`** — the project **reference only**; resolve the full record via\n  `GET /projects/{project_id}`. Null when the tender has no project.\n- **`project_name`** — the project's display name, surfaced inline (same value as\n  `name` on `GET /projects/{project_id}`) so you need no second call. Null when\n  the tender has no project.\n- **`submission_date`** — the bid-submission date (Submissions-/Abgabetermin) as\n  originally supplied on the project: an ISO date **or** free text, `null` when\n  unknown. This is the tender's **submission deadline**, not an offer-validity\n  (`gültig bis`) date. Mirrors the project's `submission_date`.\n- **`deadline`** — Mercura's **internal** handling deadline for the request\n  (typically the submission date minus a buffer): an ISO date or free text,\n  `null` when unset. Informational only — not the submission deadline and not an\n  offer-validity date.\n- **`user_email`** — the responsible Mercura user for the tender (mirrors the\n  integration-export `user_email`).\n- **`branch`** — the primary Mercura branch (Niederlassung) assigned to the\n  tender as `{ id, name }`, or `null` when none is assigned.\n- **`request_custom_fields`** — custom fields on the underlying request, keyed by\n  display label (`multi_select` values resolved to their option labels). **Writable**\n  on `PATCH` (see *Editing a tender*).\n- **`user_custom_fields`** — custom fields on the responsible user (`user_email`),\n  keyed by display label — e.g. a user's `SGI`. `null` when the tender has no\n  responsible user or the user has no custom fields. (You can still resolve the\n  same fields via `GET /users?email={user_email}`.)\n- **`chapters[]`** — the title tree. Each chapter carries an `id`,\n  `chapter_number`, `name`, `is_pretext` (a Vorbemerkung), nested `chapters[]`,\n  and `positions[]`.\n- **`positions[]`** (per chapter) — one entry per line, each with:\n  - **`id`** — an opaque, re-match-stable handle (the position's request-scoped\n    number); the key you pass back to edit the line.\n  - **`line_type`** — `ARTICLE` / `TEXT` / `DIVERSE`.\n  - **`labels[]`** — role/label overlay, each `{ text, type }` (`type` is\n    `CUSTOM` / `ALTERNATIVE` / `ACCESSORY` / `PARTS_LIST`); a `CUSTOM` label\n    carries your org-defined text (product groups, \"Bestellware\", …).\n  - **`gaeb_position_type`** — the GAEB Positionsart (`BASE`,\n    `OPTIONAL_WITH_TOTAL`, `OPTIONAL_WITHOUT_TOTAL`, `ALTERNATIVE`) or null.\n  - `article_number`, `manufacturer_article_number`, `name`, `description`,\n    `additional_text`, `quantity`, `unit`, `list_price`, `discount_percentage`,\n    `net_price`, `line_net`, `line_gross`.\n  - **`parent_line_id`** — set only on an **article-note** line: the `id` of the\n    line it annotates (both sit on the same position). Null everywhere else.\n  - **`name`** — the matched article's name: the article text maintained in Mercura\n    (the title shown in the UI). Null for a free-text line.\n  - **`description`** — the matched article's own `description` field (distinct from\n    `name`), or the free-text for a text line.\n  - **`manufacturer`** — the matched article's manufacturer / supplier name\n    (Hersteller/Lieferant), or null for a free-text line.\n  - **`custom_attributes`** — the matched article's catalogue custom attributes\n    (same shape as `GET /articles/{id}`'s `custom_attributes`, e.g. `sales_unit`,\n    the article type `article_type`, and other source-system unit/extra fields),\n    or null for a free-text line.\n  - For a `DIVERSE` line matched to a generic \"Divers\" placeholder article,\n    `description` is the operator-typed text (what you see in the matching UI),\n    not the placeholder article's catalogue text.\n  - **`is_counted_in_total`** — false for lines excluded from the totals\n    (alternatives, Bedarfsposition ohne Gesamtbetrag).\n  - **`partner_pricing_override_at`** — when a partner last overrode the line's\n    price via PATCH, else null.\n  - **`is_deleted`** / **`deleted_at`** — whether **this line** was removed from\n    the tender, and when. Only ever set when you ask for removed lines\n    (`include_deleted=true`, see *Removed lines and positions*).\n- **`totals`** — `positions_subtotal`, `additional_discount_percentage`,\n  `net_total`, `gross_total` (EUR), and `partner_totals_override_at`.\n  `positions_subtotal` is a derived helper: the sum across lines with\n  `is_counted_in_total = true` (excludes alternatives and Bedarfspositionen ohne\n  Gesamtbetrag). `net_total` / `gross_total` are the **authoritative** amounts that\n  match the PDF — they are *after* the header discount and may include optional\n  positions or other adjustments, so they can legitimately differ from\n  `positions_subtotal`. Treat `net_total` as the amount owed.\n- **`erp_offer_id`** — the partner-side ERP offer id when known (populated\n  from your acknowledgement's `external_id`, or from a Mercura-managed ERP\n  export).\n- **`acknowledgement`** — your last reported import outcome for this tender\n  (`status`, `external_id`, `message`, `metadata`, `acknowledged_at`), or\n  `null` until you acknowledge it (see below).\n\nYou can:\n\n- **`GET /tenders`** — cursor-paginated list. Use `modified_since` for\n  delta-sync catch-up scans, or `status` to filter by lifecycle state.\n- **`GET /tenders/{tender_id}`** — one tender in full. This is what the\n  `tender.completed` webhook points you at. The response carries a strong\n  **`ETag`** over the tender content; pass it back as `If-Match` on PATCH.\n\n### Removed lines and positions\n\nBy default a tender contains only what is currently on it: a line removed after\nyou imported the tender simply stops appearing, which leaves you unable to tell\nit apart from one that never existed. Pass **`include_deleted=true`** on\n`GET /tenders` or `GET /tenders/{tender_id}` and removed lines come back\n**flagged** instead of omitted, so you can delete the corresponding record in\nyour own system:\n\n- **`is_deleted`** is `true` on the removed line and **`deleted_at`** carries the\n  UTC time *that line* was removed (`null` on a legacy row removed before those\n  times were recorded).\n- Removals are reported per **line**. Take one article off a position and that\n  article comes back flagged next to the position's live lines — which is what\n  you need if you keep one record per matched article. The `id` tells you which\n  record to drop.\n- **A whole position (Ordnungszahl) is gone exactly when *all* lines sharing its\n  `position_number` come back flagged.** If you key your records on the ordinal\n  number rather than per article, that is your signal; a position with at least\n  one unflagged line still exists.\n- Removed lines never count toward `totals`, and the `ETag` covers the live\n  content only. A document fetched with `include_deleted=true` therefore\n  round-trips through `PATCH /tenders/{tender_id}` unchanged: echoed removed\n  lines are ignored.\n- A position that Mercura merely flags as *not relevant* is not a removal and\n  stays hidden either way.\n- Line `id`s are stable across a removal — the live primary line keeps its\n  handle. They do change when a source document is **reprocessed**, which\n  recreates the tender's positions and lines (and changes the `ETag`).\n\n## Tender documents\n\n`GET /tenders/{tender_id}/documents` lists the **source documents** the customer\nuploaded into the tender — the same attachments a forwarded LV / RFQ email\ncarried (GAEB, PDF, Excel, images, `.eml`), or the files a `POST /tenders`\nupload sent. Use it to archive the customer's original request alongside the\npriced offer you write back into your ERP.\n\n```\nGET /tenders/63365/documents\n```\n```json\n{\n  \"data\": [\n    {\n      \"id\": \"b3f1c2a4-9d6e-4f21-8a0c-2e5d7f9a1b34\",\n      \"filename\": \"LV_Tiefgarage_Beleuchtung.gaeb\",\n      \"content_type\": \"application/xml\",\n      \"size_bytes\": 284913,\n      \"created_at\": \"2026-07-08T09:14:00Z\",\n      \"download_url\": \"https://s3.fra1.example.com/mercura/files/…?X-Amz-Expires=3600&X-Amz-Signature=…\",\n      \"download_url_expires_at\": \"2026-07-08T10:14:00Z\"\n    }\n  ]\n}\n```\n\n- **Only the customer's own uploads are listed.** Mercura-generated artefacts —\n  the offer / display PDF, the structured-doc preview, and internal OCR /\n  metadata side-files — and any hidden files are excluded. If a tender has no\n  uploaded documents, `data` is an empty array.\n- **`download_url` is a short-lived, pre-signed link** to the bytes in object\n  storage. Fetch it with a plain `GET` — **no `Authorization` header** — and the\n  file downloads with its original name and content type. The API never proxies\n  the file contents.\n- **The link expires** at `download_url_expires_at` (one hour). Treat both the\n  `download_url` and the whole document set as transient: re-list to obtain fresh\n  links rather than persisting a URL. In non-production environments where the\n  storage backend cannot sign URLs, `download_url` and its expiry are `null`.\n- **`id`** is the file's stable id: it is stable across re-lists, so you can skip\n  a document you have already imported.\n- An unknown, non-tender, or foreign id returns `404` — indistinguishable from a\n  missing tender.\n\n## Editing a tender\n\n`PATCH /tenders/{tender_id}` is a **partial, in-place** edit that returns the\nre-projected tender.\n\n**Recommended: send only what changed (sparse).** Put the lines you touched in a\ntop-level `positions[]`, each addressed by its `id` — no chapter path needed\n(line ids are unique within a tender). This is the primary contract for keeping\nan ERP in sync and the one that scales: the body is proportional to your edit,\nnot to the tender.\n\n```\nPATCH /tenders/63365\n{ \"positions\": [ { \"id\": \"10\", \"net_price\": \"1.00\" }, { \"id\": \"11\", \"op\": \"delete\" } ] }\n```\n\n**Also supported: full-document round-trip.** The body is field-aligned with the\nGET response, so you *can* GET a tender, change fields in the nested `chapters[]`\ntree, and send it all back — read-only fields are tolerated and ignored, unknown\nkeys are a `400`. Prefer sparse for large tenders: a full round-trip re-sends and\nre-resolves every line. (A discounted line's GET carries `list_price` +\n`discount_percentage` + `net_price` together; that's accepted as long as they're\nconsistent — but if you *change* a price, send only the field you changed, or a\ncontradictory pair is a `400`.)\n\n- **Edit a line** — top-level `positions[]` (by `id`), or under its chapter.\n  Price resolution: `list_price` (Brutto) is the anchor, then **at most one** of\n  `discount_percentage` / `net_price` resolves the other:\n  - `discount_percentage` is a **manual** discount applied *on top of* any\n    catalogue/pricing-rule discount already on the line — i.e. on the\n    net-of-catalogue base, exactly as the Mercura UI applies it — not a raw\n    percentage off `list_price`. Editing it recomputes `net_price` off that base\n    (a catalogue discount is preserved).\n  - `net_price` sets the exact per-unit net; the resulting `discount_percentage`\n    is back-computed from it. **To pin an exact net, send `net_price`.**\n\n  Also editable: `quantity`, `unit`, `additional_text`. `description` is editable\n  only on a free-text line — on an ARTICLE line it comes from the catalogue and\n  is read-only (sent values are ignored, so a GET body still round-trips).\n\n  Note on `additional_text`: it is stored and returned, and some ERP\n  integrations consume it as their long-text element, but **no Mercura-side\n  output reads it** — neither the offer PDF nor the GAEB export. To put text on a\n  line that reaches those, use an **article note** (below).\n- **Annotate a line (article note)** — send `parent_line_id` (the `id` of the\n  line to annotate) plus `description`, and **no** `id`. The note becomes a text\n  line on the parent's own position — no new position, no own position number —\n  and its text follows the path an operator-typed note takes: into Mercura's\n  offer PDF and GAEB export, and folded into the parent line's remark\n  (`Bemerkung`) in the ERP payload.\n\n  This is the one add that needs no chapter path (the parent line already names\n  its position), so it is accepted in the top-level `positions[]` too. It is an\n  **upsert**: sending a note for a parent that already has one *replaces* that\n  note, so a retried PATCH cannot pile up duplicates. A note is text only —\n  `article_number`, prices, `quantity`, `unit` and `additional_text` on a note\n  are rejected with a `400` rather than silently dropped, and a note never\n  enters the totals. To remove one, address the note line itself with\n  `{ \"id\": \"…\", \"op\": \"delete\" }`.\n- **Delete a line** — `{ \"id\": \"…\", \"op\": \"delete\" }` (soft-delete). Works in the\n  top-level `positions[]` or under a chapter.\n- **Add a line** — include a position **without** an `id` under an existing\n  chapter (adds need the chapter target, so they can't go in top-level\n  `positions[]` — an article note is the exception). With an `article_number` it becomes an article line (resolved\n  against your catalogue — an unknown number is a `400`); without one, send\n  `description` for a free-text line. Either way you can set the line's price and\n  discount on the add (`list_price` + one of `discount_percentage` / `net_price`).\n- **Summary totals** — `totals.net_total` / `totals.gross_total` persist as your\n  authoritative amounts and **win over** Mercura's recalculation (they may\n  legitimately differ from the line sums — freight, rounding, surcharges).\n  `totals.additional_discount_percentage` sets the header discount.\n- **Request custom fields** — send a label-keyed `request_custom_fields` object to\n  shallow-merge values onto the tender's underlying request (symmetric with the\n  read side — send back exactly what you GET). A `null` **value** clears that field;\n  a whole-object `null` (or omitting it) is a no-op, so a full GET body round-trips.\n  An unknown label or an unknown `multi_select` option label is a `400`. Use this to\n  report partner-maintained values back with the prices — e.g. an \"Offer Validity\"\n  (Angebotsgültigkeit) custom field maintained in your ERP.\n- **Close a tender** — see *Closing a tender* below.\n- **Concurrency** — pass the GET's `ETag` as `If-Match`; if the tender changed\n  since you read it you get a `412` (re-fetch and retry). Omitting `If-Match`\n  skips the check (last-write-wins).\n\nApart from an explicit `status` write, editing does not change the tender's\nlifecycle `status` and never re-triggers an export.\n\n### Closing a tender\n\nNot every tender that reaches Mercura is worked in Mercura. A small one may be\nquoted by hand, or it may belong to a product line that is not onboarded — and\nits case is then closed in your CRM while the tender stays open in Mercura\nforever. Left alone, those accumulate until the open list is unusable.\n\nSend `status: \"CANCELLED\"` to close such a tender so it leaves the open inbox:\n\n```\nPATCH /tenders/63365\n{ \"status\": \"CANCELLED\" }\n```\n\n- **`CANCELLED` is the only writable transition.** Everything else is\n  Mercura-side workflow — a tender becomes `DONE` by being exported, and\n  `NEW → IN_PROGRESS` when someone starts working it. Any other target is a\n  `400`.\n- **Echoing the current status is a no-op**, so a full GET body still\n  round-trips through PATCH whatever the tender's status is.\n- **`409` for `PARSING` and `DONE`.** A `PARSING` tender is mid-flight (retry\n  once it lands); a `DONE` one has already been completed in Mercura, and\n  silently downgrading that to `CANCELLED` would lose the result. Everything\n  else — `NEW`, `IN_PROGRESS`, `PARSING_FAILED` — is cancellable.\n- Cancelling is **not** an acknowledgement. Use\n  `POST /tenders/{tender_id}/acknowledgements` to report an *import outcome*;\n  use this to close a tender nobody will work.\n\n## Acknowledging a tender\n\n- **`POST /tenders/{tender_id}/acknowledgements`** — after you import a\n  tender's offer into your ERP/CRM, report the result back so the Mercura\n  operator can see it. Send `status: \"SUCCESS\"` (optionally with your own\n  `external_id`, a free-text `message`, and any `metadata`), or\n  `status: \"FAILED\"` with a required `message` describing why. The call is\n  idempotent and **latest-wins** for the status and message (a retry that\n  first failed then succeeded ends up `SUCCESS`); a once-reported\n  `external_id` is kept even if a later acknowledgement omits it. Your\n  `external_id` is also written back to the tender's `erp_offer_id`, so it\n  shows up everywhere Mercura reads that field. The response body is the\n  recorded acknowledgement only — `{ status, external_id, message, metadata,\n  acknowledged_at }`, the same shape as the tender's `acknowledgement` field —\n  a small deterministic confirmation of what Mercura persisted (including the\n  sticky `external_id`) without refetching the whole tender. Recording-only:\n  it does not change the tender's lifecycle `status` or re-trigger an export.\n\n## Reporting lifecycle events\n\n- **`POST /tenders/{tender_id}/events`** — report a lifecycle event your own\n  system recorded for a tender *after* the initial acknowledgement: the tender\n  was marked **Won**, **Lost**, or **Cancelled** in your CRM, and so on. Unlike\n  an acknowledgement (one latest-wins outcome), events are **append-only** — one\n  row per report — so the full timeline is preserved.\n- Send an `event_type` (a normalised token: uppercase letters, digits, and\n  underscores, e.g. `WON` / `LOST` / `CANCELLED`), and optionally a\n  `status_label` (your verbatim label, e.g. \"Closed Won\"), a `message`, an\n  `occurred_at` (when it happened on your side), and any `metadata`.\n- Supply an **`external_event_id`** to make retries safe: reposting the same id\n  with the same payload returns the already-stored event with `200` (instead of\n  recording a duplicate), while reusing the id with a *different* payload is a\n  `409`. Without it, every call records a new event.\n- The response is the recorded event — `{ id, event_type, status_label,\n  message, metadata, external_event_id, occurred_at, recorded_at }` — with `201`\n  for a newly recorded event.\n- **Recording-only**: reporting an event never changes the tender's lifecycle\n  `status` or re-triggers an export. These are events *you* report to Mercura —\n  not the webhook deliveries Mercura sends you (see the *Webhooks* chapter).\n\n### Completion feed\n\nTo pull every tender that was completed recently — the typical ERP\nwrite-back trigger — pass **`completed_since`** (an ISO-8601 instant):\n\n```\nGET /tenders?completed_since=2026-07-09T11:55:00Z\n```\n\nThis returns only tenders with `completed_at >= completed_since`, ordered by\n`completed_at` ascending, and paginates with the same opaque `cursor`. Keep\n`completed_since` present on every page of the feed (its value is ignored\nonce a `cursor` is supplied). Because `completed_at` is refreshed on every\ncompletion, a re-exported tender reappears in the feed — combine this with\nthe `tender.completed` webhook for a robust push-plus-reconcile integration.\n\n> **Note.** `/tenders` reflects the request's *current* state: it returns one\n> row per tender, not one row per historical export event. A re-exported\n> tender reappears in the feed (via a refreshed `completed_at`) rather than\n> accumulating separate rows.\n"
    },
    {
      "name": "Orders",
      "description": "An **order** is an incoming purchase order (Bestellung): a customer places\na firm order and Mercura extracts the order metadata, matches the line\nitems against your article catalogue, and prepares it for ERP export.\nOrders are one of the two entry points of the workflow (the other is\n`tenders`).\n\n## Creating an order\n\n`POST /orders` is the programmatic twin of forwarding a purchase-order\nemail: send the order document(s) and Mercura runs the same intake\npipeline. Accepted files, size caps, primary-file selection, `context`,\ncustomer resolution, idempotency, and the async `JobAck` → poll /\nwebhook flow are all identical to `POST /tenders` — see the Tenders\nchapter. The only difference is the request type: an order skips LV\nparsing and runs the order-processing agent (metadata extraction + line\nmatching).\n\n## Reading orders\n\n`GET /orders` and `GET /orders/{order_id}` return an order's matched line\nitems plus order-specific metadata. Keyed by the **request id** — an order\nhas a single result, so `order_id == request_id`. (Note: unlike a tender —\nwhich is now a nested `chapters[]` tree with id-only `customer_id` /\n`project_id` — an order returns a flat `positions[]` list and full\n`customer` / `project` objects.)\n\n- **`status`** — the order's workflow state: `PROCESSING`, `NEEDS_REVIEW`\n  (metadata or line matches need a human), `READY_TO_EXPORT`, `EXPORTED`,\n  `ARCHIVED`.\n- **`completed_at`** — when the order was last completed (finalised /\n  exported), or `null` if it never was. Refreshed on every completion; drives\n  the completion feed below.\n- **`customer`** — the resolved business partner (`customer_id`, name,\n  address, contact person). **`project`** — `object_number` and name.\n  **`request_custom_fields`** — organisation-defined custom fields on the\n  request.\n- **`positions[]`** — one entry **per line item**. Each carries\n  `position_number`, `article_number` (the catalogue match, when the line\n  was matched), `description`, `quantity`, and `unit`. A line the agent\n  could not match still appears, with its verbatim number / description and\n  a null `article_number`.\n- **`order_metadata`** — the extracted order header: `customer_reference`\n  (the customer's PO number), `order_date`, `quote_number`,\n  `project_number`, `requested_delivery_date`, `shipping_terms`,\n  `contact_person`, `special_instructions`, and the `delivery_recipient` /\n  `delivery_address` (Warenempfänger) when different from the customer.\n  Null while the order is still parsing.\n- **`erp_offer_id`** — the partner-side ERP order id when known (populated\n  from your acknowledgement's `external_id`, or from a Mercura-managed ERP\n  export).\n- **`acknowledgement`** — your last reported import outcome for this order\n  (`status`, `external_id`, `message`, `metadata`, `acknowledged_at`), or\n  `null` until you acknowledge it (see below).\n\n> **Prices.** Orders are a **matching**, not a pricing, workflow. Line\n> items carry the matched article, quantity, and unit but **no prices**, so\n> `list_price` / `net_price` / `line_net` / `line_gross` are `null` and\n> `totals` are `0`. The `positions` and `order_metadata` are the payload\n> that matters. (The `totals` block is kept for shape-parity with tenders.)\n\nUse `modified_since` on `GET /orders` for delta-sync catch-up scans.\n\n### Completion feed\n\nPass **`completed_since`** (an ISO-8601 instant) to pull every order\ncompleted recently — the typical ERP write-back trigger:\n\n```\nGET /orders?completed_since=2026-07-09T11:55:00Z\n```\n\nThis returns only orders with `completed_at >= completed_since`, ordered by\n`completed_at` ascending, and paginates with the same opaque `cursor` (keep\n`completed_since` present on every page). Because `completed_at` is refreshed\non every completion, a re-exported order reappears — pair it with the\n`order.completed` webhook for a robust push-plus-reconcile integration.\n\n## Acknowledging an order\n\n- **`POST /orders/{order_id}/acknowledgements`** — after you import an order\n  into your ERP, report the result back so the Mercura operator can see it.\n  Send `status: \"SUCCESS\"` (optionally with your own `external_id`, a\n  free-text `message`, and any `metadata`), or `status: \"FAILED\"` with a\n  required `message` describing why. The call is idempotent and\n  **latest-wins** for the status and message (a retry that first failed then\n  succeeded ends up `SUCCESS`); a once-reported `external_id` is kept even if\n  a later acknowledgement omits it. Your `external_id` is also written back to\n  the order's `erp_offer_id`, so it shows up everywhere Mercura reads that\n  field. The response body is the recorded acknowledgement only —\n  `{ status, external_id, message, metadata, acknowledged_at }`, the same\n  shape as the order's `acknowledgement` field — a small deterministic\n  confirmation of what Mercura persisted (including the sticky `external_id`)\n  without refetching the whole order. Recording-only: it does not change the\n  order's workflow `status` or re-trigger an export.\n\n## Reporting lifecycle events\n\n- **`POST /orders/{order_id}/events`** — report a lifecycle event your own\n  system recorded for an order *after* the initial acknowledgement: the order\n  was **Cancelled**, **Reopened**, and so on. Unlike an acknowledgement (one\n  latest-wins outcome), events are **append-only** — one row per report — so the\n  full timeline is preserved.\n- Send an `event_type` (a normalised token: uppercase letters, digits, and\n  underscores, e.g. `CANCELLED` / `REOPENED`), and optionally a `status_label`\n  (your verbatim label), a `message`, an `occurred_at`, and any `metadata`.\n- Supply an **`external_event_id`** to make retries safe: reposting the same id\n  with the same payload returns the already-stored event with `200`, while\n  reusing the id with a *different* payload is a `409`. Without it, every call\n  records a new event.\n- The response is the recorded event — `{ id, event_type, status_label,\n  message, metadata, external_event_id, occurred_at, recorded_at }` — with `201`\n  for a newly recorded event.\n- **Recording-only**: reporting an event never changes the order's workflow\n  `status` or re-triggers an export. These are events *you* report to Mercura —\n  not the webhook deliveries Mercura sends you (see the *Webhooks* chapter).\n\n`PATCH /orders` is planned for a later release.\n"
    },
    {
      "name": "Supplier Requests",
      "description": "A **supplier request** (*Werksanfrage*) is an outbound RFQ you send to one of\nyour suppliers to price a set of positions from a tender (or a project). Where a\n`tender` is the demand coming *in* from your customer, a supplier request is the\nenquiry going *out* to a supplier — and the supplier's reply is parsed into the\n**offered lines** this resource exposes.\n\nThis resource is **read-only**.\n\n## Reading supplier requests\n\nA supplier request is keyed by its own **UUID** (`id`) and carries:\n\n- **`status`** — lifecycle state: `REQUEST_SENT` (sent, awaiting an offer),\n  `OFFER_RECEIVED` (the supplier replied), or `DECLINED`.\n- **`tender_id`** — the tender this request was raised for, when tender-scoped —\n  resolve the full document via `GET /tenders/{tender_id}`. Null when the\n  request is project-scoped.\n- **`project_id`** — the project this request was raised for, when\n  project-scoped — resolve via `GET /projects/{project_id}`. Null when\n  tender-scoped. A supplier request is always scoped to exactly one of the two.\n- **`supplier_id`** — the supplier **reference only** (your\n  `BusinessPartner.external_id`); resolve name / address / contacts via\n  `GET /suppliers/{supplier_id}`. `supplier_name` is a convenience mirror.\n- **`supplier_emails`** — the addresses the request was sent to.\n- **`sender_email`** — the Mercura user who sent the request.\n- **`is_awarded`** / **`awarded_line_count`** — whether (and how many) offered\n  lines were accepted into the final tender.\n- **`offered_lines[]`** — the lines the supplier quoted back, one per parsed\n  candidate. Each carries:\n  - **`id`** — an opaque handle for the offered line.\n  - **`position_id`** — the position this line answers. For a tender-scoped\n    supplier request it is the line handle in `GET /tenders/{tender_id}`, so you\n    can line the supplier's quote up against the position it responds to; for a\n    project-scoped one it is an internal reference with no public resolution\n    endpoint today. Null when the line is not mapped to a position.\n  - **`item_type`** — `ARTICLE` / `TEXT_FIELD` / `DIVERSE`.\n  - **`candidate_status`** — `PENDING` / `ACCEPTED` / `DECLINED`.\n  - **`selected`** — `true` when this line was accepted into the final tender\n    (i.e. `candidate_status` is `ACCEPTED`). This is the line you are actually\n    buying at the quoted price.\n  - `article_number`, `manufacturer_article_number`, `description`, `quantity`,\n    `unit`, `unit_price`, `line_total`.\n\nYou can:\n\n- **`GET /supplier-requests`** — cursor-paginated list for your organisation.\n- **`GET /supplier-requests/{supplier_request_id}`** — one supplier request in\n  full, by its UUID.\n\nThe list is ordered by `updated_at` ascending (oldest-touched first) — the\nnatural order for `modified_since` delta-sync — and paginated with the opaque\n`cursor`. Page through until `next_cursor` is `null`.\n\n## Filtering\n\nAll filters are optional and composable:\n\n- **`tender_id`** — only supplier requests raised for one tender. This is the\n  authoritative \"all supplier requests for this tender\" query:\n\n  ```\n  GET /supplier-requests?tender_id=48213\n  ```\n\n- **`project_id`** — only supplier requests raised for one project (a distinct\n  axis from `tender_id`).\n- **`supplier_id`** — only requests sent to one supplier (its `external_id`).\n- **`status`** — filter by lifecycle state, e.g. `status=OFFER_RECEIVED`.\n- **`awarded`** — `awarded=true` returns only requests with at least one accepted\n  (selected) line — i.e. the suppliers you are actually buying from; `false`\n  returns those without one.\n- **`modified_since`** — ISO-8601 lower bound on `updated_at` for delta-sync\n  catch-up scans (first page only; the `cursor` supersedes it thereafter).\n\n```\n# only the awarded requests for one tender\nGET /supplier-requests?tender_id=48213&awarded=true\n\n# one supplier's request on a tender that came back with an offer\nGET /supplier-requests?tender_id=48213&supplier_id=SUP-1007&status=OFFER_RECEIVED\n```\n\n## Joining to a tender\n\nGiven a tender, `GET /supplier-requests?tender_id={id}` lists every Werksanfrage\nraised for it. Within each, every `offered_lines[].position_id` points at the\nposition that line answers in `GET /tenders/{id}`, so you can align the\nsupplier's quote against the customer's demand line by line — and the `selected`\nflag tells you which quotes made it into the finalised tender.\n"
    },
    {
      "name": "Projects",
      "description": "A **Project** is the real-world construction project (or business\nopportunity) an incoming request belongs to. Multiple requests — a\ntender, its follow-up amendments, later related orders — usually roll\nup under one project. Every incoming LV becomes a tender that Mercura\ngroups into a `Project` on your behalf, and the `tender.completed` /\n`order.completed` webhooks carry the `project_id` so partners can\nreconcile the exported result against the same project record on\ntheir side.\n\nEach project carries:\n\n- **`id`** — the numeric identifier equal to the `project_id` field on\n  the `tender.completed` / `order.completed` webhooks (stringified).\n  Round-trip the value verbatim to `GET /projects/{id}`.\n- **`object_number`** — the ERP-facing grouping key. Not unique:\n  Mercura groups multiple project rows that share the same\n  `object_number` into one master project internally, so filtering by\n  `object_number` may return several rows.\n- **`name`** and **`status`** — display name and lifecycle state\n  (`ACTIVE`, `PROCESSED`, `BID_SUBMITTED`, `CUSTOMER_LOST`,\n  `CUSTOMER_WON_PENDING`, `CUSTOMER_WON_AWARDED_ELSEWHERE`,\n  `CUSTOMER_WON_AWARDED_TO_US`).\n- **`estimated_value`** / **`currency`** / **`submission_date`** —\n  bid metadata as captured on the LV.\n- **`responsible_user`** — the Mercura user (name + email) assigned to\n  the project. Convenient for surfacing \"assigned to Anna Schmidt\" in\n  a downstream inbox without a second lookup.\n- **`construction_site_address`** / **`planner_address`** /\n  **`developer_address`** — up to three postal addresses associated\n  with the project. Fields not set on the internal row surface as\n  `null`.\n- **`custom_fields`** — organisation-defined key-value pairs. Keys and\n  value shapes are governed by your organisation's custom-column\n  configuration (Settings → Organisation → Custom fields), not by\n  this API. Multi-select fields return the option **labels** (e.g.\n  `[\"3030\"]`), not the internal option ids. Empty custom-field sets are\n  omitted from the response entirely.\n- **`request_ids`** — the Mercura request ids linked to the project\n  via the `Request.project_id` foreign key. Each id is request-keyed,\n  so drill down to a tender with `GET /tenders/{id}` or to an order\n  with `GET /orders/{id}`.\n- **`acknowledgement`** — your side's last reported ERP/CRM import\n  outcome for this project, when one was recorded via\n  `POST /projects/{project_id}/acknowledgements`. `null` when the\n  project was never acknowledged. See *Acknowledging a project* below.\n\nYou can:\n\n- **`GET /projects`** — cursor-paginated list of projects for the\n  authed organisation. Use `modified_since` for delta-sync catch-up.\n- **`GET /projects/{project_id}`** — fetch a single project. This is\n  what the `tender.completed` / `order.completed` webhooks (see the\n  Webhooks chapter) point you at when partners need project-level\n  context (site address, custom fields) beyond what the tender or\n  order itself carries. The response carries a strong `ETag` for\n  optimistic concurrency on PATCH.\n- **`PATCH /projects/{project_id}`** — edit a project in place. The\n  inbound counterpart of the otherwise Mercura-produced project: write\n  your ERP's own commission / project number (and other metadata) back\n  onto the Mercura object. See *Updating a project* below.\n- **`POST /projects/{project_id}/acknowledgements`** — report whether\n  your ERP/CRM managed to create/import the project (`SUCCESS` with\n  your own record id, or `FAILED` with a reason). A `SUCCESS` id is\n  written onto the project's `object_number` too, so this is also the\n  method-minimal way to reconcile identifiers when your middleware\n  cannot issue the PATCH verb. See *Acknowledging a project* below.\n\nAnd Mercura can tell you when to act:\n\n- **`project.exported`** — the webhook a Mercura user triggers with the\n  **Send to ERP** button on the project page. It is the signal to create\n  the project as an object in your system; you then fetch it with\n  `GET /projects/{project_id}` and report the result with an\n  acknowledgement. See *Creating a project in your ERP* below.\n\n## Updating a project\n\n`PATCH /projects/{project_id}` writes back the fields your ERP owns.\nThe canonical use is reconciling identifiers: your ERP creates a\nproject / commission number and PATCHes it into `object_number` so\nevery later `tender.completed` / `order.completed` webhook and\n`GET /projects` lines up with your own key.\n\nWritable fields (all optional): `object_number`, `name`, `status`,\n`estimated_value`, `currency`, `submission_date`, `custom_fields`.\n\nIf `object_number` is the only field you need to write, you can skip\nPATCH entirely and send it as the `external_id` of an acknowledgement\ninstead — see *Acknowledging a project*.\n\nSemantics:\n\n- **Partial.** Send only what you change. An omitted field is left\n  untouched; an explicit `null` clears that field. `status` is the one\n  exception — it cannot be `null` (a project always has a lifecycle\n  state), and an unknown status value is a `400`.\n- **`object_number` is a grouping key.** Mercura derives the\n  master-project grouping from this value at read time, so writing it\n  simply moves the one project row you addressed into (or out of) a\n  group — sibling rows that shared the old value are not rewritten.\n- **`custom_fields` is label-keyed and shallow-merged.** Use the same\n  labels you receive on `GET` (and, for a multi-select, the same option\n  labels). Keys you include are set; a key mapped to `null` clears that\n  one field; keys you omit are left as they are. An unknown label — or\n  an unknown option label on a multi-select — returns\n  `400 VALIDATION_FAILED` naming the offending keys, so a typo fails\n  loudly instead of silently writing nothing.\n\nOptimistic concurrency: read the project first, then echo the `ETag`\nyou received as an `If-Match` header on the PATCH. If someone else\nchanged the project in the meantime the ETag no longer matches and the\nrequest is rejected with `412 Precondition Failed` — re-fetch and\nretry. Omit `If-Match` (or send `If-Match: *`) to skip the check. A\nno-op edit (an empty body, or values equal to the current ones) does\nnot advance the project's `updated_at`.\n\n```http\nPATCH /projects/90 HTTP/1.1\nAuthorization: Bearer <your-api-key>\nIf-Match: \"3f8b6d21...\"\nContent-Type: application/json\n\n{\n  \"object_number\": \"P-2026-00417\",\n  \"status\": \"CUSTOMER_WON_AWARDED_TO_US\",\n  \"custom_fields\": { \"ERP-Auftragsnummer\": \"SO-88231\" }\n}\n```\n\n## Creating a project in your ERP\n\nProjects are created inside Mercura (from a GAEB file, from an email\nintake, or by a user), so — unlike tenders — there is no partner path\nthat creates one. What you get instead is a **handoff you can react\nto**: a Mercura user opens the project and clicks **Send to ERP**, and\nMercura emits a `project.exported` webhook.\n\nThe round trip:\n\n1. **You subscribe** to `project.exported` (Settings → Organisation →\n   Webhooks). The button only appears in Mercura for organisations that\n   have an active subscription for this event — no subscription, no\n   button, so nothing can be handed to an endpoint that is not listening.\n2. **A user clicks the button.** Mercura emits `project.exported`\n   carrying `project_id`, `name`, the current `object_number`, the\n   lifecycle `status` and `request_count`. Nothing on the project is\n   changed by the click: its lifecycle `status` stays as it was, and\n   `object_number` remains yours to assign.\n3. **You fetch** `GET /projects/{project_id}` for the full record —\n   addresses, custom fields, `request_ids`.\n4. **You create** the object in your ERP and **acknowledge** it with\n   `POST /projects/{project_id}/acknowledgements`, sending your document\n   number as `external_id`. That number lands on the project's\n   `object_number` and comes back to you as `erp_object_id` on every\n   later tender/order export for the project.\n\nRe-clicking the button is safe and always emits again — the payload then\ncarries the `object_number` you acknowledged earlier, which is how you\ntell an update from a create without keeping your own mapping. The\nacknowledgement of the previous handoff stays visible to the Mercura user\nnext to the button, so a `FAILED` acknowledgement with a `message` is the\nway to tell them what went wrong on your side.\n\n## Acknowledging a project\n\n`POST /projects/{project_id}/acknowledgements` records your ERP/CRM\nimport outcome on the project — the project-keyed sibling of the\ntender/order acknowledgements. Use it when your integration creates\nthe Mercura project as an object/document in your own system and you\nwant to report the result (and your document number) back without a\nPATCH round-trip — for example when your middleware cannot issue the\nPATCH verb, or when all you hold at creation time is your own\ndocument number.\n\nBody fields:\n\n- **`status`** (required) — `SUCCESS` or `FAILED`.\n- **`external_id`** — your own id for the created record (e.g. the\n  ERP document number). Typically sent on `SUCCESS`.\n- **`message`** — free-text note; **required on `FAILED`** as the\n  failure reason, optional on `SUCCESS`.\n- **`metadata`** — optional extra references echoed back verbatim for\n  traceability.\n\nSemantics:\n\n- **Latest-wins.** A later acknowledgement overwrites `status`,\n  `message` and `metadata`; `external_id` is kept once provided (an\n  id-less follow-up ack never clears it).\n- **`external_id` also sets `object_number`.** When you send one it is\n  written to the project's `object_number` (the Objektnummer) as well —\n  the same field `PATCH /projects/{project_id}` writes, and the one\n  Mercura sends back to you as `erp_object_id` on every later\n  tender/order export. One POST therefore both reports the outcome and\n  establishes the identifier both sides reconcile on; you do not need\n  the PATCH for it. Your value wins over an Objektnummer that came from\n  a GAEB file or from a Mercura user, and an acknowledgement without an\n  `external_id` leaves the existing one untouched. Because\n  `object_number` also groups projects (see *Reading projects*),\n  rewriting it moves this project into that number's group.\n- **Otherwise recording-only.** The project's lifecycle `status` is\n  never changed and no export is re-triggered. The acknowledged outcome\n  (including `external_id`) is returned on every `GET /projects` read as\n  the `acknowledgement` field.\n- The write advances the project's `updated_at`, so a `modified_since`\n  delta-sync surfaces acknowledged projects.\n\n```http\nPOST /projects/90/acknowledgements HTTP/1.1\nAuthorization: Bearer <your-api-key>\nContent-Type: application/json\n\n{\n  \"status\": \"SUCCESS\",\n  \"external_id\": \"4500012345\",\n  \"metadata\": { \"erp_client\": \"100\" }\n}\n```\n\nThe response is the recorded acknowledgement (not the whole project):\nresolved `status`, the sticky-aware `external_id`, `message`,\n`metadata`, and the server-side `acknowledged_at` timestamp.\n\n## Filtering the list\n\nThree filters are AND-combined with `modified_since` on the list\nendpoint:\n\n| Query parameter | Behaviour |\n|---|---|\n| `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. |\n| `request_id=<int>` | Returns the single project linked to the request via `Request.project_id`. Empty page for requests with no project FK. |\n| `status=<UPPERCASE>` | Restrict to one lifecycle status. Unknown values return `400 VALIDATION_FAILED`. |\n\nCombine `object_number` with `request_id` when you need to verify\nthat a specific request rolls up under a specific ERP grouping key.\n\n## Custom fields\n\n`custom_fields` is a free-form map shaped by your organisation's own\ncustom-column configuration — Mercura does not enforce a JSON schema on\nthe contents. Keys and value formats can evolve on your side without an\nAPI version bump. Partners rendering custom fields should treat unknown\nkeys gracefully (skip, or show verbatim); Mercura will not remove keys\nbehind your back.\n\nValues follow the column's type: text / number / date fields are\nreturned as-is, while a **multi-select** field is returned as a list of\nits selected option **labels** (the same text you see in the Mercura\nUI), e.g. `\"Gewerk\": [\"DD - DACHDECKUNG DACHABDICHTUNG\", \"EL - ELEMENTE TÜREN\"]`.\nThe internal option ids are never exposed on the read path.\n\nEmpty custom-field sets are omitted from the response body entirely\n(the field is not returned as `null` or `{}`). Compact wire, easier\ndiffs against the Mercura admin UI.\n\n## Relationship to tenders and orders\n\n- One project → many requests (`Request.project_id`).\n- Each request is reachable by its id as a tender (`GET /tenders/{id}`)\n  or an order (`GET /orders/{id}`); these never collide with\n  `GET /projects/{id}` because `project_id` and `request_id` are\n  separate integer sequences.\n- The `tender.completed` / `order.completed` webhooks fire per request\n  id and carry the parent `project_id` on the wire, so partners can\n  decide whether to fetch request-level data (`GET /tenders/{id}` /\n  `GET /orders/{id}`) or project-level data (`GET /projects/{id}`) —\n  or both.\n"
    },
    {
      "name": "Articles",
      "description": "An **Article** is one row in your sales catalogue: a thing you sell,\nkeyed by the `article_number` you assign in your source system. Articles\nare the most important resource in this API — Mercura matches every\nposition on every incoming customer LV against your articles, so the\nquality of the offers you read back depends directly on the quality of\nthe article catalogue you push.\n\nYou can:\n\n- **`POST /articles`** — bulk-upsert up to 100,000 articles in a single\n  call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for\n  status. Rows with the same `article_number` as an existing row are\n  updated in place; new rows are inserted. Recommend running this once\n  per night with the full delta since the last successful job.\n- **`GET /articles`** — cursor-paginated list of your articles. Use\n  `modified_since` on the first page to fetch only the rows that\n  changed since your last sync.\n- **`GET /articles/{article_number}`** — fetch a single article by the\n  partner-supplied `article_number`.\n\n### Data depth and match quality\n\nThe precision of Mercura's article matching depends directly on the\n**depth and structure** of the article data you push. The more you\nsend, the more accurately Mercura can resolve incoming LV positions\nagainst your catalogue.\n\nArticles that match well tend to carry:\n\n- **Identifying attributes** — manufacturer name and article number,\n  supplier article number, EAN / GTIN.\n- **Classifications** — ETIM class, applicable norms (DIN / EN / ISO).\n- **Technical parameters** — power, voltage, dimensions, IP rating,\n  and any other physical or electrical characteristics that distinguish\n  one variant from another.\n\nAny field your source system carries but the standard schema does not\ncover can be passed through the `custom_fields` attribute available\non every master-data resource — no schema change required.\n\n### About `article_number`\n\nArticle numbers are partner-supplied and must match the character class\n`[A-Za-z0-9._\\-]{1,255}` so they round-trip safely through URL path\nsegments. `/`, `?`, `#`, and whitespace are not allowed inside an\narticle number.\n\n### Tags\n\nArticles carry **tags** — short labels (e.g. `\"LED\"`, `\"Eigenmarke\"`)\nyour team manages in the Mercura admin UI under **Settings → Article\ntags**. Send them by **name**: `POST /articles` accepts a `tags` array\nof names, and the read endpoints return the same names — you never deal\nwith Mercura's internal numeric tag ids.\n\n```json\n{ \"article_number\": \"LEU-1500-50-840\", \"name\": \"LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65\", \"tags\": [\"LED\", \"Eigenmarke\"] }\n```\n\n- Every name you send must **already exist** for your organisation. An\n  unknown name fails the whole batch (the job ends `FAILED` with a\n  message listing the offending names) — create the tag in the admin UI\n  first, then retry.\n- Sending `tags` **replaces** the article's current tags. **Omit** the\n  field to leave the tags unchanged; send `[]` to clear them.\n\n### ETIM features\n\nPush ETIM classification features to sharpen how Mercura matches\nincoming LV positions against your catalogue. Each feature is one\nobject:\n\n```json\n{\n  \"article_number\": \"LEU-1500-50-840\",\n  \"name\": \"LED-Feuchtraumleuchte 1500 mm 50 W 4000 K IP65\",\n  \"etim_features\": [\n    { \"etim_code\": \"EF000008\", \"human_label\": \"Nennspannung\",         \"type\": \"number\",  \"value_number\": 230 },\n    { \"etim_code\": \"EF000007\", \"human_label\": \"Farbe\",                \"type\": \"string\",  \"value_string\": \"weiß\" },\n    { \"etim_code\": \"EF000131\", \"human_label\": \"Mit Anschlussleitung\", \"type\": \"boolean\", \"value_boolean\": true },\n    { \"etim_code\": \"EF000056\", \"human_label\": \"Leistungsbereich\",     \"type\": \"range\",   \"value_range\": { \"gte\": 18.0, \"lte\": 50.0 } }\n  ]\n}\n```\n\nSet the one `value_*` field that matches `type` (`value_string`,\n`value_number`, `value_boolean`, or `value_range`); leave the others\nout. Sending `etim_features` **replaces** the article's full feature\nlist; omit the field to leave it unchanged.\n\n### Soft-deleting articles\n\nSet `is_deleted: true` on an article in a `POST /articles` payload to\n**hide it everywhere in Mercura** — it stops appearing in catalogue\nsearch, AI matching suggestions, and selection candidates. The row is\nkept (not destroyed), so any offer position that already selected the\narticle in the past still shows it. Set `is_deleted: false` to restore\nthe article. Omitting the field leaves the current value unchanged.\n\nSoft-deleted articles are excluded from `GET /articles` by default; pass\n`include_deleted=true` to list them (for example, to confirm a delete or\nfind one to restore). `GET /articles/{article_number}` always returns the\narticle, including its `is_deleted` state.\n\n### Lifecycle flags\n\nTwo more boolean flags are accepted on `POST /articles` and returned on the\nread endpoints (both default `false`):\n\n- **`is_diverse`** — marks a catch-all / placeholder (\"diverse\") article.\n- **`is_legacy_article`** — marks a discontinued article that should stay\n  resolvable by id and visible on past selections but be kept out of new\n  matches. (`is_deleted` hides it everywhere; `is_legacy_article` only\n  demotes it from new matching.)\n"
    },
    {
      "name": "Accessories",
      "description": "**Accessories** link a source article to accessory articles that are\nfitted or ordered alongside it — an optional add-on, or a mandatory part.\nPushing them lets Mercura suggest the right accessories when it matches a\ncustomer position against your catalogue.\n\nYou can:\n\n- **`POST /articles/accessories`** — bulk-upsert up to 100,000 accessory links in a\n  single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for\n  status. Each row upserts the `(source_article_number,\n  accessory_article_number)` pair.\n- **`GET /articles/accessories`** — cursor-paginated list of your accessory links.\n  Pass `source_article_number` to list one article's accessories, or\n  `modified_since` on the first page to fetch only what changed.\n\n### Fields\n\n- **`multiplier`** — how many of the accessory are needed per unit of the\n  source article (optional).\n- **`order`** — display order among a source article's accessories.\n- **`is_mandatory`** — `true` marks a required part rather than an optional\n  add-on.\n\n### Upsert semantics (v1)\n\n`POST` **inserts or updates** each `(source, accessory)` pair; it never\nremoves links. Re-sending the same pair is idempotent. There is **no\ndelete endpoint yet** — omitting a previously sent accessory leaves it in\nplace. A row whose source or accessory `article_number` does not exist in\nyour catalogue is **skipped** (counted in the job's `skipped_count`), not\nan error. If any row is structurally invalid (missing a required field, a\nnon-positive `multiplier`, a negative `order`) the whole batch is rejected\nand the per-row detail appears in `GET /jobs/{job_id}`.\n"
    },
    {
      "name": "Alternatives",
      "description": "**Alternatives** link a source article to substitute articles a customer\ncould accept in its place. Pushing them lets Mercura offer a valid\nalternative when the requested article is unavailable or less suitable.\n\nYou can:\n\n- **`POST /articles/alternatives`** — bulk-upsert up to 100,000 alternative links in\n  a single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for\n  status. Each row upserts the `(source_article_number,\n  alternative_article_number)` pair.\n- **`GET /articles/alternatives`** — cursor-paginated list of your alternative\n  links. Pass `source_article_number` to list one article's alternatives,\n  or `modified_since` on the first page to fetch only what changed.\n\n### Fields\n\n- **`order`** — display order among a source article's alternatives\n  (optional).\n\n### Upsert semantics (v1)\n\n`POST` **inserts or updates** each `(source, alternative)` pair; it never\nremoves links. Re-sending the same pair is idempotent. There is **no\ndelete endpoint yet** — omitting a previously sent alternative leaves it in\nplace. A row whose source or alternative `article_number` does not exist in\nyour catalogue is **skipped** (counted in the job's `skipped_count`), not\nan error. If any row is structurally invalid (missing a required field, a\nnegative `order`) the whole batch is rejected and the per-row detail\nappears in `GET /jobs/{job_id}`.\n"
    },
    {
      "name": "Successors",
      "description": "**Successors** mark the article that replaces a discontinued one\n(`source_article_number` → `successor_article_number`). Pushing them lets\nMercura retarget demand for a phased-out article onto its current\nreplacement.\n\nYou can:\n\n- **`POST /articles/successors`** — bulk-upsert up to 100,000 successor links in a\n  single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for\n  status.\n- **`GET /articles/successors`** — cursor-paginated list of your successor links.\n  Pass `source_article_number` to look up one article's successor, or\n  `modified_since` on the first page to fetch only what changed.\n\n### One successor per article\n\nA source article has **exactly one** active successor. Re-sending a source\narticle with a different `successor_article_number` **replaces** the\nprevious successor — this is the intended way to update a chain. `order` is\naccepted for parity with the other relations but has no effect on a 1:1\nlink.\n\n### Upsert semantics (v1)\n\nThere is **no delete endpoint yet**. A row whose source or successor\n`article_number` does not exist in your catalogue is **skipped** (counted\nin the job's `skipped_count`), not an error. If any row is structurally\ninvalid (missing a required field, a negative `order`) the whole batch is\nrejected and the per-row detail appears in `GET /jobs/{job_id}`.\n"
    },
    {
      "name": "Unit Conversions",
      "description": "**Unit conversions** map an alternative unit to an article's base unit via\na `numerator` / `denominator` ratio — for example a carton of 6 pieces is\n`alternative_unit = \"CTN\", numerator = 6, denominator = 1`. Mercura uses\nthem to normalise ordered quantities across units.\n\nYou can:\n\n- **`POST /articles/unit-conversions`** — bulk-upsert up to 100,000 conversions in a\n  single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}` for\n  status. Each row upserts the `(article_number, alternative_unit)`\n  conversion.\n- **`GET /articles/unit-conversions`** — cursor-paginated list of your conversions.\n  Pass `article_number` to list one article's conversions, or\n  `modified_since` on the first page to fetch only what changed.\n\n### The ratio\n\nThe conversion is `1 alternative_unit = (numerator / denominator)\nbase units`. Both must be positive. The ratio is stored and returned\nexactly as sent — it is not collapsed to a single factor — so values like\n3 pieces per pack (`numerator = 1, denominator = 3`) round-trip without\nloss.\n\n### Upsert semantics (v1)\n\n`POST` **inserts or updates** each `(article, alternative_unit)`\nconversion; it never removes conversions you stop sending, and there is\n**no delete endpoint yet**. A row whose `article_number` does not exist in\nyour catalogue is **skipped** (counted in the job's `skipped_count`), and\nrows with a missing unit or a non-positive `numerator` / `denominator` are\nskipped as well — unit-conversion batches do not fail as a whole.\n"
    },
    {
      "name": "Customers",
      "description": "A **Customer** is one of your B2B accounts — the party that sends you\nthe LVs Mercura processes. Each customer is keyed by the `customer_id`\nyou assign in your source system. Address is sent as an `addresses[]`\narray on write, but Mercura persists and returns exactly one (the\n`is_default` entry, else the first); extra entries are dropped. Contact\npersons live on the separate `/contacts` resource (each contact references\nits parent by `parent_type` + `parent_id`). Mercura uses this data both to resolve\nthe customer on incoming LVs and to populate the `customer` block of\nevery offer you read back.\n\nYou can:\n\n- **`POST /customers`** — bulk-upsert up to 100,000 customers in a\n  single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}`\n  for status. Existing customers are updated in place by `customer_id`;\n  new ones are inserted.\n- **`GET /customers`** — cursor-paginated list of your customers. Use\n  `modified_since` for delta-sync.\n- **`GET /customers/{customer_id}`** — fetch a single customer by the\n  partner-supplied id.\n\n### About `customer_id`\n\nCustomer ids are partner-supplied and must match\n`[A-Za-z0-9._\\-]{1,255}` so they round-trip safely through URL path\nsegments.\n\n### About `custom_fields`\n\nRead responses (`GET /customers`, `GET /customers/{customer_id}`, and the\n`customer` block of every offer) return `custom_fields` keyed by the\nfield's display **label** — the same label you see in the Mercura admin UI\n— rather than the internal column id. Columns with no active definition\nare omitted. If two custom columns share a label, the one defined first\n(by display order, then creation time) wins and the other's value is\ndropped from the response; deduplicate colliding columns in the admin UI.\nThe same label-keying applies to suppliers.\n\nThe **write** path is deliberately asymmetric: on `POST /customers` send\n`custom_fields` keyed by the internal column **id** (not the label). Only\nreads are label-keyed.\n"
    },
    {
      "name": "Suppliers",
      "description": "A **Supplier** is one of the vendors behind your article catalogue —\nwho you buy from. Each supplier is keyed by the `supplier_id` you\nassign in your source system. Mercura uses supplier data when sourcing\narticles for an offer and when generating supplier requests (RFQs).\n\nYou can:\n\n- **`POST /suppliers`** — bulk-upsert up to 100,000 suppliers in a\n  single call. Async — returns a `JobAck`; poll `GET /jobs/{job_id}`\n  for status. Existing suppliers are updated in place by `supplier_id`;\n  new ones are inserted.\n- **`GET /suppliers`** — cursor-paginated list of your suppliers. Use\n  `modified_since` for delta-sync.\n- **`GET /suppliers/{supplier_id}`** — fetch a single supplier by the\n  partner-supplied id.\n\n### About `supplier_id`\n\nSupplier ids are partner-supplied and must match\n`[A-Za-z0-9._\\-]{1,255}` so they round-trip safely through URL path\nsegments. Up to v1.6.x this field was called `external_id` (see the\nv1.7.0 entry in the changelog).\n\n### About `custom_fields`\n\nRead responses (`GET /suppliers`, `GET /suppliers/{supplier_id}`) return\n`custom_fields` keyed by the field's display **label** — the same label you\nsee in the Mercura admin UI — rather than the internal column id. Columns\nwith no active definition are omitted, and on a label collision the column\ndefined first (by display order, then creation time) wins. On the **write**\npath (`POST /suppliers`) send `custom_fields` keyed by the internal column\n**id**; only reads are label-keyed.\n"
    },
    {
      "name": "Contacts",
      "description": "A **Contact** (Ansprechpartner) is a person attached to exactly one\nbusiness partner — a customer or a supplier. Contacts are managed via\na single top-level resource so that adding or updating a contact does\nnot require re-sending the entire parent payload.\n\nThe contact's parent is identified by `parent_type` (`customer` or\n`supplier`) plus `parent_id` — the parent's partner-supplied\nidentifier (`customer_id` for customers, `supplier_id` for suppliers).\n\nYou can:\n\n- **`POST /contacts`** — bulk-upsert contacts. Async — returns a\n  `JobAck`; poll `GET /jobs/{job_id}` for status. A single batch may\n  mix customer and supplier contacts.\n- **`GET /contacts`** — cursor-paginated list, filterable by\n  `parent_type`, `parent_id`, and `is_active`. Use `modified_since`\n  for delta-sync.\n- **`GET /contacts/{id}`** — fetch one contact by its Mercura id\n  (discoverable from `GET /contacts` list responses).\n- **`PATCH /contacts/{id}`** — partial update. Reassigning a contact\n  to a different parent is not supported — delete and recreate.\n- **`DELETE /contacts/{id}`** — soft delete. The row stays in the\n  database with `is_active=false`, so re-POSTing the same identity\n  will reactivate it.\n\n### Upsert key\n\nEach contact may carry its own optional `external_id` — the stable\nidentifier from your source ERP (for example, Clage's `ASP_ID`). The\nupsert priority within a parent is:\n\n1. `external_id` when present on both sides — preferred because\n   email and name can drift on the source system.\n2. `email` for contacts without an `external_id`.\n3. `(name, phone)` as a last resort for email-less contacts.\n\nA row whose `parent_type` / `parent_id` cannot be resolved fails at\nthe per-row level — the rest of the batch is unaffected.\n"
    },
    {
      "name": "Users",
      "description": "A **User** is a member of your Mercura organisation (a\nSachbearbeiter) — the person who works a request and is named as its\nresponsible user. This is distinct from the business-partner resources\n(`customers` / `suppliers` / `contacts`); a User logs in to Mercura,\na Contact does not.\n\nThe resource is **read-only**. Users are managed in the Mercura admin\nUI; there is no public write path.\n\nYou can:\n\n- **`GET /users?email=<addr>`** — look a user up by email\n  (case-insensitive, exact match). `email` is required. Returns a\n  paginated envelope with the matching user, or an empty `data` array\n  when the email is unknown in your organisation (never a `404` — the\n  status code does not reveal which emails exist). `next_cursor` is\n  always `null` — every match fits in one page. Almost always a single\n  match; email is not guaranteed unique, so if more than one user in\n  your organisation shares an email, all are returned and you\n  disambiguate by `id`.\n- **`GET /users/{user_id}`** — fetch one user by their Mercura user id\n  (the `id` field from a `GET /users` response).\n\n### Typical use: resolve a tender's responsible user\n\n`GET /tenders` returns `user_email` — the email of the Mercura user who\nreleased the tender to the API. Feed that value into\n`GET /users?email=<user_email>` to read that user's `custom_fields`,\nfor example an ERP/SAP identifier your system needs when creating the\noffer.\n\n### `custom_fields`\n\nOrganisation-defined custom fields are returned keyed by their display\n**label** (for example `\"SAP ID\"`), not by the internal column id.\nFields with no active column definition are omitted; the property is\n`null` when the user carries none. Define user custom columns in the\nMercura admin UI.\n\n### Service accounts\n\nThe API service account backing your integration is never returned by\neither endpoint.\n"
    },
    {
      "name": "Jobs",
      "description": "A **Job** is the asynchronous unit of work behind every bulk write in\nthis API. When you `POST /articles`, `POST /customers`, or\n`POST /suppliers`, Mercura validates the payload, persists an\n`ImportRun` row, enqueues a worker task, and returns a `JobAck`\nimmediately. The actual ingestion (validation per row, upsert into the\ncatalogue, error aggregation) happens out-of-band.\n\nPolling `GET /jobs/{job_id}` is how you learn what happened.\n\n## The async write loop\n\n```\nPOST /articles\nAuthorization: Bearer mrc_live_...\nIdempotency-Key: nightly-sync-2026-05-19\nContent-Type: application/json\n{ \"articles\": [ ... up to 100,000 rows ... ] }\n\n→  202 Accepted\n   { \"job_id\": \"4242\", \"status_url\": \"/api/public/v1/jobs/4242\" }\n```\n\nThen poll:\n\n```\nGET /api/public/v1/jobs/4242\n\n→  200 OK\n   {\n     \"job_id\": \"4242\",\n     \"entity\": \"ARTICLES\",\n     \"status\": \"RUNNING\",\n     \"created_at\": \"2026-05-19T10:00:00Z\",\n     \"updated_at\": \"2026-05-19T10:00:05Z\",\n     \"total_rows\": 12000,\n     \"created_count\": 0,\n     \"updated_count\": 0,\n     \"skipped_count\": 0,\n     \"deleted_count\": 0,\n     \"error_count\": 0,\n     \"errors\": [],\n     \"result\": null\n   }\n```\n\n…and again a few seconds later:\n\n```\n{\n   \"job_id\": \"4242\",\n   \"entity\": \"ARTICLES\",\n   \"status\": \"COMPLETED\",\n   \"created_at\": \"2026-05-19T10:00:00Z\",\n   \"updated_at\": \"2026-05-19T10:00:42Z\",\n   \"total_rows\": 12000,\n   \"created_count\": 9000,\n   \"updated_count\": 2950,\n   \"skipped_count\": 0,\n   \"deleted_count\": 0,\n   \"error_count\": 50,\n   \"errors\": [\n     { \"row_number\": 137, \"identifier\": \"LEU-0900-18-830\", \"error_message\": \"missing list_price\" }\n   ],\n   \"result\": null\n}\n```\n\n`result` is `null` for `ARTICLES` / `CUSTOMERS` / `SUPPLIERS` jobs; for\n`REQUESTS` jobs (created via `POST /tenders` / `POST /orders`) it carries\n`{ request_id, request_status, position_count }`.\n\n## Status enum\n\n| `status` | Meaning |\n|---|---|\n| `PENDING`   | Queued, not yet picked up by a worker. |\n| `RUNNING`   | A worker is processing rows. |\n| `COMPLETED` | Finished. If `error_count > 0`, those specific rows failed and `errors[]` lists them — the rest were applied. |\n| `FAILED`    | The whole job failed before any rows were applied (e.g. a validation error that affected the entire payload). |\n\n`COMPLETED` is always a terminal *partial-success* status: a non-zero\n`error_count` does not mean the job failed; it means those rows were\nskipped while the others were applied. Treat `errors[]` as the\nauthoritative per-row failure list.\n\n## Polling cadence\n\nMost jobs finish in seconds; a 100,000-row catalogue sync may take a\nfew minutes. A reasonable client polls every **1 second for the first\n30 seconds**, then backs off to **5–10 seconds**. Poll until you see\n`COMPLETED` or `FAILED` — do not assume a wall-clock timeout.\n\n## Idempotency\n\nPass an `Idempotency-Key` header (any string up to 255 characters, e.g.\n`erp-2026-05-18-batch-1`) on bulk-write requests. Mercura keys jobs on\n`(organisation, Idempotency-Key)`:\n\n- **Same key + same body** → the original `job_id` is returned with\n  `200 OK`. No second job runs.\n- **Same key + different body** → `422 IDEMPOTENCY_KEY_MISMATCH`. This\n  is almost always a bug on the caller side; we refuse to silently\n  replay a different payload under a key you said you were reusing.\n- **No header** → every call is a new job.\n\nThis lets you safely retry on network or proxy failures without\ndouble-writing.\n\n## Errors\n\n- `404 NOT_FOUND` — the `job_id` is unknown, malformed, or belongs to\n  another organisation. We deliberately do not distinguish these cases:\n  the existence of another tenant's jobs is never leaked.\n"
    },
    {
      "name": "Statistics",
      "description": "Read-only reporting over Mercura's processing outcomes. The statistics\nresource exposes the **raw data** behind the in-app *Exported requests\noverview* dashboard so you can build your own reporting — it returns counts,\nnever pre-computed percentages, so you stay in control of how accuracy is\nframed.\n\n## Selection accuracy\n\n`GET /statistics/selections` returns, for every request in a date range, how\nthat request's positions were resolved into selections. Rows cover both request\ntypes shown on the dashboard — **tenders and orders** — and each carries a\n`request_type` (`TENDER` / `ORDER`) so you know whether its `request_id`\nresolves via `GET /tenders/{id}` or `GET /orders/{id}`. Each row carries these\ncounts:\n\n- `position_count` — the number of **relevant** positions on the request (lines\n  Mercura kept for matching; irrelevant lines such as headings or notes are\n  excluded).\n- `positions_with_selection` — how many of those relevant positions actually got\n  **resolved**, i.e. carry at least one selection. Always `<= position_count`\n  (some relevant positions end up with no selection at all). This is the\n  denominator behind the in-app time-per-position metric — see *Status &\n  handling time* below.\n- `selection_count` — the total number of surviving selections across those\n  relevant positions. A single position can carry more than one selection (for\n  example two articles chosen for one line), so `selection_count` may exceed\n  `position_count`. It counts *selections*, not positions, so it is **not**\n  interchangeable with `positions_with_selection`.\n- `auto_selected_count` — selections made by a mechanism that resolves the line\n  without going through prediction ranking. These fire when Mercura can resolve a\n  line on its own: an exact **article-number match**; propagation from a\n  **master**, **historic**, **cluster**, **duplicate**, or\n  **equivalent-position** match; a **parts-list** lookup; a configured\n  **default article**; or an **auto-selection the order-entry agent makes on its\n  own**. In practice this is high whenever the customer supplied article numbers\n  up front.\n- `prediction_correct_count` — prediction-driven selections where the chosen\n  article was among Mercura's predictions for that position (a \"correct\"\n  prediction — the model surfaced the article the user picked). This is a\n  straight *predicted / not-predicted* split: the earlier **top-10** rank cut has\n  been removed, since the order-entry agent surfaces only a handful of candidates\n  per position rather than a long ranked list.\n- `manual_selection_count` — prediction-driven selections where the chosen\n  article was **not** among Mercura's predictions for that position (shown as\n  *Manual selections* in the dashboard — the user picked something the model did\n  not surface).\n\nThe three outcome counts (`auto_selected_count`, `prediction_correct_count`,\n`manual_selection_count`) are mutually exclusive and sum to `selection_count`.\n\nDerive whatever rollups you need client-side, for example:\n\n```\nhit_rate = (auto_selected_count + prediction_correct_count) / selection_count\n```\n\n## Status & handling time\n\nEach row also reports where the request sits in Mercura's processing lifecycle,\nthe true handling time spent on it, and the timestamps around it:\n\n- `status` — the lifecycle state: `NEW`, `IN_PROGRESS`, `DONE`, `CANCELLED`,\n  `PARSING`, or `PARSING_FAILED`.\n- `active_seconds` — the **true handling time**: the total *active* (engaged)\n  seconds a user spent working the request before export. This is the exact\n  per-request figure behind the in-app *time per position* metric — the same\n  usage telemetry, read straight from that computation — and it counts only time\n  the request was actively being worked. Defined for **exported tenders** only:\n  `null` for orders, for requests not yet exported, and for exported requests\n  with no recorded active time.\n- `first_opened_at` — when a user first opened the request, or `null` if it was\n  never opened.\n- `exported_at` — when the request was first exported to your system, or `null`\n  if it has not been exported yet.\n- `completed_at` — the most recent completion / re-export. Unlike `exported_at`\n  (set once, on the first export) this advances on every re-export.\n\nUse `active_seconds` for handling time. To reproduce the **time per position**\nfigure Mercura shows in-app, divide by `positions_with_selection` and aggregate\nas a **pooled ratio** across the rows:\n\n```\n-- matches the in-app dashboard\nseconds_per_position = SUM(active_seconds) / SUM(positions_with_selection)\n```\n\nTwo details decide whether your number matches ours:\n\n- **Use `positions_with_selection`, not `position_count`.** The metric measures\n  effort per *resolved* line. Relevant positions that never got a selection are\n  in `position_count` but were not worked, so including them understates the\n  figure.\n- **Pool the totals; don't average the per-request ratios.**\n  `AVG(active_seconds / positions_with_selection)` weights a 3-position request\n  exactly as heavily as a 300-position one. Request sizes are long-tailed —\n  small requests are the most common — so the mean of ratios lands well above\n  the pooled value. Both are legitimate statistics; only the pooled one is what\n  the dashboard reports.\n\nPer-selection effort is a different question, and `selection_count` is the right\ndenominator for it:\n\n```\nseconds_per_selection = SUM(active_seconds) / SUM(selection_count)\n```\n\nRows where `active_seconds` is `null` (orders, unexported requests, or requests\nwith no recorded telemetry) must be excluded from both — they are unmeasured,\nnot zero.\n\n> **Do not** use `exported_at - first_opened_at` as handling time. That span is\n> wall-clock: a request opened on Monday and exported on Wednesday reads as two\n> days even if only 20 minutes of work went in. It includes overnight and idle\n> gaps, so it cannot tell you how long the user actually spent. `active_seconds`\n> is the field that answers that. The timestamps are still useful for lifecycle\n> reporting (when a request entered/left each state).\n\n## Exported vs. in-progress requests\n\nBy default only **exported**, non-deleted, non-evaluation requests are included\n— the same universe as the dashboard. Pass **`include_unexported=true`** to also\nreceive requests that are still in progress (never exported). This lets you\ncompare how many requests were **uploaded** against how many were\n**successfully exported**: with the flag on, an exported request has a non-null\n`exported_at` (and typically `status = DONE`), while an uploaded-but-open one\nhas `exported_at = null` and a `status` such as `NEW` or `IN_PROGRESS`.\nNote that `active_seconds` stays `null` for these still-open requests — handling\ntime is measured against the export.\n\n## Date range\n\n`start_date` and `end_date` (both `YYYY-MM-DD`, required) bound the range on the\nrequest's **creation date**, inclusive on both ends. The window is capped at\n**400 days**; an inverted range or one wider than the cap returns\n`400 VALIDATION_FAILED`. Keep both parameters present on every page of a paged\nread — they define the query window, and the cursor resumes within it.\n\n## Pagination\n\nStandard cursor pagination (see *Getting Started → Pagination*). Rows are\nordered newest-first by request creation time. Pass `limit` (1–500, default\n100); when a further page exists the response carries a `next_cursor`, which\nyou pass back as `cursor` on the next call along with the unchanged\n`start_date` / `end_date`. A `null` `next_cursor` means the range is exhausted.\n"
    },
    {
      "name": "Webhooks",
      "description": "**Webhooks** are how Mercura tells your system that something happened\nwithout you having to poll. Instead of asking `GET /jobs/{job_id}`\nevery few seconds, you register an HTTPS endpoint once and Mercura\ndelivers a signed `POST` whenever a relevant event fires.\n\nFor the end-to-end sequence — how a completed tender flows from Mercura\ninto your ERP via a webhook, and the polling alternative — see\n**Getting Started → Integration patterns: webhook vs polling**.\n\n# Setup\n\n## Setting up a subscription\n\n1. An org admin opens **Settings → Organisation → Webhooks** in the\n   Mercura admin UI.\n2. They click **New webhook**, enter the HTTPS endpoint your system\n   exposes, pick which event types should fan out to that endpoint,\n   and save. Mercura returns the **signing secret exactly once** at\n   this moment — copy it into your secret store immediately, it cannot\n   be retrieved later.\n3. They click **Send test event** to fire a `webhook.test` event at\n   the endpoint. Use this to validate signature verification end-to-end\n   before going live.\n4. Your endpoint then receives the event types you selected\n   automatically, as they fire on the Mercura side (e.g.\n   `tender.completed` / `order.completed` on completion, `job.finished`\n   on a bulk-write job).\n\nIf the secret is lost, the admin can delete the subscription and\ncreate a new one — there is no rotate-in-place flow.\n\n## Event types\n\n| `event_type` | Fires when |\n|---|---|\n| `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.) |\n| `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}`. |\n| `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. |\n| `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}`. |\n| `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. |\n| `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. |\n\nEvery event type uses the same wire format, headers, and signature\nscheme (below), so a `webhook.test` delivery validates your verification\ncode against the exact production contract before you rely on any real\nevent.\n\n# Delivery\n\n## Wire format\n\nEvery delivery is a `POST` from Mercura to your endpoint with this\nshape:\n\n```\nPOST https://your-erp.example.com/mercura/webhooks\nContent-Type: application/json\nUser-Agent: Mercura-Webhooks/1.0\nMercura-Event: tender.completed\nMercura-Event-Id: 4a2b9d1c-7e8f-4a3b-9c1d-2e3f4a5b6c7d\nMercura-Delivery-Id: 9c1f77a2-8b3e-4d5f-a6c7-1b2c3d4e5f60\nMercura-Delivery-Attempt: 1\nMercura-Timestamp: 1747742400\nMercura-Signature-256: sha256=4f3a9e...\n\n{\n  \"event_id\": \"4a2b9d1c-7e8f-4a3b-9c1d-2e3f4a5b6c7d\",\n  \"event_type\": \"tender.completed\",\n  \"delivered_at\": \"2026-05-20T12:00:00Z\",\n  \"data\": { ... }\n}\n```\n\n| Header | Purpose |\n|---|---|\n| `Mercura-Event`            | The event type. Same as `event_type` in the body. |\n| `Mercura-Event-Id`         | Stable across delivery attempts. Use this for **dedup**: if you see the same `Mercura-Event-Id` twice, process it once. |\n| `Mercura-Delivery-Id`      | Unique per delivery attempt. Useful when quoting an attempt in a support ticket. |\n| `Mercura-Delivery-Attempt` | `1` for the first attempt, incremented on each retry. |\n| `Mercura-Timestamp`        | Unix seconds at the moment Mercura signed the payload. Used in the signature and for replay protection. |\n| `Mercura-Signature-256`    | `sha256=` followed by the hex HMAC-SHA256 of `<timestamp>.<raw_body>` keyed by your subscription secret. |\n\nRespond with any `2xx` to acknowledge — Mercura ignores the response\nbody. A `4xx` (other than `408` / `429`) marks the delivery as\npermanently failed; a `5xx`, `408`, `429`, or network error triggers\nthe retry schedule below.\n\n## Example payloads\n\n### `tender.completed`\n\nFired when a tender offer is released to the partner API (the seller's **Finalize** / Export → API action). The\npayload is intentionally small — it tells you *which* tender changed and\n*when*, and you fetch the current state from `GET /tenders/{tender_id}`\n(`tender_id == request_id`). `completed_at` is refreshed on every\ncompletion, so pairing this event with `GET /tenders?completed_since=…` lets\nyou reconcile even if a delivery is missed.\n\n```json\n{\n  \"event_id\": \"7e8f4a3b-9c1d-4a2b-9d1c-2e3f4a5b6c7d\",\n  \"event_type\": \"tender.completed\",\n  \"delivered_at\": \"2026-07-09T12:00:00Z\",\n  \"data\": {\n    \"tender_id\": 12345,\n    \"name\": \"RFQ-2026-0042 — Office Tower B\",\n    \"customer_bp_id\": 678,\n    \"project_id\": 90,\n    \"position_count\": 47,\n    \"erp_offer_id\": null,\n    \"completed_at\": \"2026-07-09T11:59:58Z\"\n  }\n}\n```\n\n### `order.completed`\n\nFired when an order is finalised (marked exported). Same shape\nas `tender.completed` but keyed by `order_id` (= the request id); fetch the\nfull record from `GET /orders/{order_id}`.\n\n```json\n{\n  \"event_id\": \"9c1d2e3f-4a5b-4c7d-8e9f-0a1b2c3d4e5f\",\n  \"event_type\": \"order.completed\",\n  \"delivered_at\": \"2026-07-09T12:05:00Z\",\n  \"data\": {\n    \"order_id\": 20456,\n    \"name\": \"PO-88213\",\n    \"customer_bp_id\": 678,\n    \"project_id\": null,\n    \"position_count\": 12,\n    \"erp_offer_id\": \"SO-2026-5567\",\n    \"completed_at\": \"2026-07-09T12:04:57Z\"\n  }\n}\n```\n\n### `project.exported`\n\nFired when a Mercura user clicks **Send to ERP** on a project. Like the\n`*.completed` events the payload only says *which* project and *when* —\nfetch the record from `GET /projects/{project_id}`. `object_number` is\nMercura's current value for your object id: `null` on a first handoff,\nand on a re-export the number you acknowledged earlier, so you can tell\na create from an update without your own mapping table.\n\n```json\n{\n  \"event_id\": \"1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9\",\n  \"event_type\": \"project.exported\",\n  \"delivered_at\": \"2026-08-31T09:15:00Z\",\n  \"data\": {\n    \"project_id\": 90,\n    \"name\": \"Maplewood Heights\",\n    \"object_number\": null,\n    \"status\": \"ACTIVE\",\n    \"request_count\": 3,\n    \"exported_at\": \"2026-08-31T09:14:58Z\"\n  }\n}\n```\n\n### `job.finished`\n\nFired when a bulk-write job reaches a terminal status. The payload\nmirrors the `GET /jobs/{job_id}` envelope — receive this event and\nyou no longer need to poll. The semantics of `status`, `error_count`,\nand `errors[]` are documented under the **Jobs** chapter.\n\n```json\n{\n  \"event_id\": \"8b3e4d5f-a6c7-1b2c-3d4e-5f6071829304\",\n  \"event_type\": \"job.finished\",\n  \"delivered_at\": \"2026-05-20T10:00:42Z\",\n  \"data\": {\n    \"job_id\": \"4242\",\n    \"entity\": \"ARTICLES\",\n    \"status\": \"COMPLETED\",\n    \"created_at\": \"2026-05-20T10:00:00Z\",\n    \"updated_at\": \"2026-05-20T10:00:42Z\",\n    \"total_rows\": 12000,\n    \"created_count\": 9000,\n    \"updated_count\": 2950,\n    \"skipped_count\": 0,\n    \"deleted_count\": 0,\n    \"error_count\": 50,\n    \"errors\": [\n      {\n        \"row_number\": 137,\n        \"identifier\": \"LEU-0900-18-830\",\n        \"error_message\": \"missing list_price\"\n      }\n    ]\n  }\n}\n```\n\n### `request.processing_completed`\n\nFired when a request finishes intake processing, regardless of how it\nentered the platform — a forwarded email, a web-app upload, or\n`POST /tenders` / `POST /orders`. `status` is `COMPLETED` when the\nrequest landed ready for review and `FAILED` when parsing failed.\n\n`job_id` is populated only for requests created via the API\n(`POST /tenders` / `POST /orders`) and matches the `job_id` from the\noriginal `JobAck` — use it to correlate the event with your upload.\nFor email- and UI-created requests it is `null`, so this event also\nlets you track LVs your customers forward by email without any polling.\n\n```json\n{\n  \"event_id\": \"5f607182-9304-4d5f-a6c7-1b2c3d4e8b3e\",\n  \"event_type\": \"request.processing_completed\",\n  \"delivered_at\": \"2026-06-10T10:04:12Z\",\n  \"data\": {\n    \"request_id\": 12345,\n    \"request_type\": \"TENDER\",\n    \"status\": \"COMPLETED\",\n    \"name\": \"RFQ-2026-0042 — Office Tower B\",\n    \"customer_bp_id\": 678,\n    \"position_count\": 47,\n    \"job_id\": \"4242\",\n    \"error_message\": null\n  }\n}\n```\n\n### `webhook.test`\n\nFired when a human clicks **Send test event** in the admin UI.\nCarries a fixed synthetic payload — your verification code should\ntreat it exactly like a real event.\n\n```json\n{\n  \"event_id\": \"00000000-0000-4000-8000-000000000000\",\n  \"event_type\": \"webhook.test\",\n  \"delivered_at\": \"2026-05-20T09:30:00Z\",\n  \"data\": {\n    \"subscription_id\": 42,\n    \"message\": \"This is a test webhook from Mercura.\",\n    \"sent_at\": \"2026-05-20T09:30:00Z\"\n  }\n}\n```\n\n# Receiver implementation\n\n## Verifying the signature\n\nMercura signs every delivery with HMAC-SHA256 over\n`<Mercura-Timestamp>.<raw_body>`, keyed by your subscription secret.\n**Always verify before trusting the payload** — and verify against\nthe raw bytes you received, not against a re-serialised JSON object.\n\n```python\nimport hashlib\nimport hmac\nimport time\n\nMAX_TIMESTAMP_SKEW_SECONDS = 5 * 60  # 5 minutes\n\n\ndef verify_mercura_webhook(\n    *,\n    secret: str,\n    raw_body: bytes,\n    timestamp_header: str,\n    signature_header: str,\n) -> bool:\n    # 1. Reject stale or future-dated deliveries — protects against replays.\n    try:\n        ts = int(timestamp_header)\n    except (TypeError, ValueError):\n        return False\n    if abs(time.time() - ts) > MAX_TIMESTAMP_SKEW_SECONDS:\n        return False\n\n    # 2. Recompute the signature.\n    signed_payload = f\"{ts}.\".encode() + raw_body\n    expected = hmac.new(\n        secret.encode(\"utf-8\"),\n        signed_payload,\n        hashlib.sha256,\n    ).hexdigest()\n\n    # 3. Constant-time compare against the value Mercura sent.\n    prefix = \"sha256=\"\n    if not signature_header.startswith(prefix):\n        return False\n    return hmac.compare_digest(expected, signature_header[len(prefix):])\n```\n\nIf verification fails, return `400` and log the\n`Mercura-Delivery-Id` — Mercura will not retry on a `4xx`, which is\nthe right behaviour for a malformed or unauthenticated request.\n\n## Custom auth header (optional)\n\nIf your receiver sits behind a gateway or reverse proxy that requires\na fixed authentication header — API key, bearer token, service\ncredential — you can configure Mercura to send one on every delivery.\n\nWhen creating or editing a subscription, set:\n\n- **Auth header name** — e.g. `X-API-Key`, `Authorization`\n- **Auth header value** — e.g. `sk_live_abc123`, `Bearer eyJhbGci…`\n\nMercura then attaches that header to every `POST` **on top of** the\nHMAC signature — the signature is still your authoritative source of\ntruth. Treat the custom header as a coarse gateway filter, not as a\nreplacement for verifying the signature.\n\nThe header value is never returned by any read endpoint — GET\nresponses expose only the header **name** and a `has_auth_header_value`\nboolean so the admin UI can show \"configured\" without leaking the\nsecret. To rotate the value, `PATCH` the subscription with a fresh\n`auth_header_value` (both fields must be sent together); to remove\nthe header entirely, `PATCH` both fields to `null`.\n\nThe following prefixes are reserved and rejected — they would collide\nwith Mercura's own delivery metadata:\n\n- `Mercura-*`\n- `Content-*`\n- `User-Agent`\n- `Host`\n\n`Authorization` is intentionally NOT reserved — using\n`Authorization: Bearer …` is a primary use case.\n\n## Retry policy\n\nIf your endpoint returns a retryable status — `5xx`, `408`, `429`, or\na network/timeout error — Mercura retries on this schedule:\n\n| Attempt | Wait before this attempt |\n|---|---|\n| 1 | immediate |\n| 2 | 30 seconds |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 | 12 hours |\n\nAfter attempt 6 the delivery is recorded as permanently `failed` and\nshows up in the admin UI's recent-deliveries view; Mercura does not\nretry further. The `Mercura-Event-Id` stays stable across all\nattempts, so a delivery that eventually succeeds after some retries\nis the same logical event as the earlier attempts.\n\nA `2xx` response stops the retry chain immediately. Any other `4xx`\n(except `408` and `429`) is treated as a permanent partner-side\nconfiguration error and is not retried.\n"
    }
  ],
  "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"
      ]
    }
  ]
}