{"templateId":"openapi_docs","sharedDataIds":{"openAPIDocsStore":"oas-openapi.json","sidebar":"sidebar-sidebars.yaml"},"props":{"definitionId":"openapi.json","dynamicMarkdocComponents":[],"baseSlug":"/openapi","seo":{"title":"Mercura API","llmstxt":{"hide":true}},"itemId":"","disableAutoScroll":true,"metadata":{"type":"openapi","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## 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## 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## Endpoint catalog\n\n| Resource | Endpoints | Read | Write |\n|---|---|:---:|:---:|\n| **Tenders**   | `POST /tenders`, `GET /tenders`, `GET /tenders/{tender_id}`, `PATCH /tenders/{tender_id}`, `POST /tenders/{tender_id}/acknowledgements` | ✓ | ✓ |\n| **Orders**    | `POST /orders`, `GET /orders`, `GET /orders/{order_id}`, `POST /orders/{order_id}/acknowledgements` | ✓ | ✓ |\n| **Supplier Requests** | `GET /supplier-requests`, `GET /supplier-requests/{supplier_request_id}` | ✓ | — |\n| **Articles**  | `POST /articles`, `GET /articles`, `GET /articles/{article_number}` | ✓ | ✓ |\n| **Customers** | `POST /customers`, `GET /customers`, `GET /customers/{customer_id}` | ✓ | ✓ |\n| **Suppliers** | `POST /suppliers`, `GET /suppliers`, `GET /suppliers/{supplier_id}` | ✓ | ✓ |\n| **Projects**  | `GET /projects`, `GET /projects/{project_id}` | ✓ | — |\n| **Contacts**  | `POST /contacts`, `GET /contacts`, `GET /contacts/{id}`, `PATCH /contacts/{id}`, `DELETE /contacts/{id}` | ✓ | ✓ |\n| **Jobs**      | `GET /jobs/{job_id}` | ✓ | — |\n\nEach resource has its own chapter further down with a detailed\ndescription, the schema, and per-endpoint behaviour. **Tenders** (incoming\nLV / RFQ → priced offer) and **Orders** (incoming purchase order) are the\ntype-specific entry points. The earlier untyped **Requests** / **Offers**\nresources they replace are no longer documented; new integrations use\nTenders / Orders.\n\nA separate **Webhooks** chapter (no endpoints — see the section\nfurther down) documents the outbound HTTPS callbacks Mercura sends\nwhen tenders, orders, or jobs change state, so your system can react\nwithout polling.\n\n# Conventions\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** are managed via the dedicated `/contacts` resource\n  (`POST` for bulk-upsert, `GET` / `PATCH` / `DELETE` per row); they\n  are *not* embedded in customer or supplier payloads. Each contact\n  carries its own `parent_type` (`customer` or `supplier`) and\n  `parent_id` so adding or updating a contact does not require\n  re-sending the entire parent. Within a parent Mercura upserts by\n  `external_id` first, then `email`, then `(name, phone)` — see the\n  Contacts chapter for the full 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## How to use Jobs\n\nEvery write endpoint in this API is **asynchronous**. When you `POST`\narticles, customers, or suppliers, Mercura validates the payload, opens\nan ingestion job, and returns immediately with a `JobAck`:\n\n```json\n{ \"job_id\": \"4242\", \"status_url\": \"/api/public/v1/jobs/4242\" }\n```\n\n- `202 Accepted` — a new job was created.\n- `200 OK` — your request matched an existing job by `Idempotency-Key`\n  (see **Idempotency** below) and the original job was returned. Safe\n  to retry the same payload as often as you like.\n\nYou then **poll** `GET /jobs/{job_id}` until you reach a terminal\nstatus:\n\n| `status` | Meaning |\n|---|---|\n| `PENDING`   | Queued, not yet picked up by a worker. |\n| `RUNNING`   | A worker is processing the rows. |\n| `COMPLETED` | Finished. If `error_count > 0`, some rows failed — see `errors[]`. The rest were applied. |\n| `FAILED`    | The whole job failed before any rows were applied. |\n\nRecommended polling cadence: every **1 second for the first 30 seconds**,\nthen back off to **5–10 seconds**. Most jobs finish in seconds; a\n100k-row catalogue sync may take a few minutes. The Jobs chapter has a\nfull response example.\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## 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# Operations\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"},"compilationErrors":[],"markdown":{"partials":{},"variables":{"rbac":{"teams":["anonymous"]},"user":{},"remoteAddr":{"hostname":"docs.mercura.ai","port":4000,"ipAddress":"216.73.217.87"},"lang":"default_locale","env":{"PUBLIC_REDOCLY_BRANCH_NAME":"main"}}},"pagePropGetterError":{"message":"","name":""}},"slug":"/openapi","userData":{"isAuthenticated":false,"teams":["anonymous"]},"isPublic":true}