# Mercura API

## What this API is

**Mercura** is an AI-powered platform for processing inquiries — bills
of materials, tenders, RFQs — in wholesale distribution, manufacturing,
and technical sales. The **Mercura API** is the integration surface
your organisation uses to plug Mercura into the rest of your software
landscape.

The API is bidirectional by design:

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

The source system on your side does not have to be a specific ERP. Any
system that owns master data — an ERP, a PIM, a custom catalogue, a
spreadsheet export pipeline — can be the integration partner.

## Main use case

The end-to-end flow is three steps:

1. **Push master data.** `POST /articles`, `POST /customers`, and
   `POST /suppliers` accept bulk payloads of up to 100,000 rows each
   and return a `JobAck` immediately. Sending one large request per
   resource is preferred over many small ones.
2. **Wait for the jobs to finish.** Each `POST` returns a `job_id`.
   Poll `GET /jobs/{job_id}` until `status` is `COMPLETED` or
   `FAILED` (see **How to use Jobs** below).
3. **Read offers back.** This is the payoff. `GET /offers` returns a
   cursor-paginated list of offers — each offer is a structured LV
   with its positions, matched articles, quantities, prices, customer,
   project, and totals. `GET /offers/{offer_id}` returns one offer
   in full.

A typical integration runs step 1 nightly (delta-sync new and changed
master-data rows) and step 3 on a short polling interval (or on
webhook delivery once outbound webhooks ship), processing any new
offers into your own downstream system.

## Data depth and match quality

The precision of Mercura's article matching depends directly on the
**depth and structure** of the article data you push. The more you
send, the more accurately Mercura can resolve incoming LV positions
against your catalogue.

Articles that match well tend to carry:

- **Identifying attributes** — manufacturer name and article number,
  supplier article number, EAN / GTIN.
- **Classifications** — ETIM class, eCl@ss code, applicable norms
  (DIN / EN / ISO).
- **Technical parameters** — power, voltage, dimensions, IP rating,
  and any other physical or electrical characteristics that distinguish
  one variant from another.

Any field your source system carries but the standard schema does not
cover can be passed through the `custom_fields` attribute available
on every master-data resource — no schema change required.

## Authentication

The API uses **bearer tokens** issued in the Mercura admin UI under
**Settings → Organisation → API Keys**.

```
Authorization: Bearer mrc_live_<token>
```

Missing, malformed, expired, or revoked tokens return
`401 UNAUTHORIZED`.

## Endpoint catalog

| Resource | Endpoints | Read | Write |
|---|---|:---:|:---:|
| **Articles**  | `POST /articles`, `GET /articles`, `GET /articles/{article_number}` | ✓ | ✓ |
| **Customers** | `POST /customers`, `GET /customers`, `GET /customers/{customer_id}` | ✓ | ✓ |
| **Suppliers** | `POST /suppliers`, `GET /suppliers`, `GET /suppliers/{external_id}` | ✓ | ✓ |
| **Offers**    | `GET /offers`, `GET /offers/{offer_id}` | ✓ | — |
| **Jobs**      | `GET /jobs/{job_id}` | ✓ | — |
| **Webhooks**  | `POST /webhook_subscriptions`, `GET /webhook_subscriptions`, `GET /webhook_subscriptions/{id}`, `PATCH /webhook_subscriptions/{id}`, `DELETE /webhook_subscriptions/{id}`, `POST /webhook_subscriptions/{id}/test` | ✓ | ✓ |

Each resource has its own chapter further down with a detailed
description, the schema, and per-endpoint behaviour. Offers are
**read-only**: they are produced by Mercura's matching pipeline, not
pushed by partners.

## Data conventions

A few conventions hold across every resource:

- **Embedded addresses and contacts.** Customers and suppliers carry
  their `addresses[]` and `contacts[]` inline. There is no separate
  address or contact resource — every push of a master-data row is a
  consistent snapshot of that row.
- **Stable partner-supplied identifiers.** `article_number`,
  `customer_id`, and `supplier` `external_id` are the keys you assign
  in your source system. They must remain stable across sync cycles —
  Mercura uses them to upsert: same id → existing row updated; new id
  → new row created.
- **Timestamps in ISO-8601 UTC.** All timestamps on the wire are
  ISO-8601 with an explicit `Z` (or `+00:00`) suffix.
- **`custom_fields` on every master-data resource.** Use it for any
  source-system field that doesn't fit the standard schema. Mercura
  preserves the value alongside the row; matching uses it where
  appropriate.

## How to use Jobs

Every write endpoint in this API is **asynchronous**. When you `POST`
articles, customers, or suppliers, Mercura validates the payload, opens
an ingestion job, and returns immediately with a `JobAck`:

```json
{ "job_id": "4242", "status_url": "/api/public/v1/jobs/4242" }
```

- `202 Accepted` — a new job was created.
- `200 OK` — your request matched an existing job by `Idempotency-Key`
  (see **Idempotency** below) and the original job was returned. Safe
  to retry the same payload as often as you like.

You then **poll** `GET /jobs/{job_id}` until you reach a terminal
status:

| `status` | Meaning |
|---|---|
| `PENDING`   | Queued, not yet picked up by a worker. |
| `RUNNING`   | A worker is processing the rows. |
| `COMPLETED` | Finished. If `error_count > 0`, some rows failed — see `errors[]`. The rest were applied. |
| `FAILED`    | The whole job failed before any rows were applied. |

Recommended polling cadence: every **1 second for the first 30 seconds**,
then back off to **5–10 seconds**. Most jobs finish in seconds; a
100k-row catalogue sync may take a few minutes. The Jobs chapter has a
full response example.

### Idempotency

Pass an `Idempotency-Key` header (any string up to 255 characters, e.g.
`erp-2026-05-18-batch-1`) on bulk-write requests. Mercura keys jobs on
`(organisation, Idempotency-Key)`:

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

This lets you safely retry on network or proxy failures without
double-writing.

## Webhooks

Webhooks let your downstream system react to events in Mercura
without polling. You self-serve subscription management — register
an HTTPS endpoint, get a signing secret back exactly once, optionally
filter to specific event types, and ship a test event to verify your
endpoint before relying on real ones.

Two events are emitted today:

- **`offer.new_export_run`** — fires the moment a Mercura user clicks
  **Export → ERP** on an offer in the UI. Payload carries the
  `offer_id`; fetch the full content with `GET /offers/{offer_id}`.
- **`job.finished`** — fires when an async bulk-write job reaches
  `COMPLETED` or `FAILED`. Payload mirrors `GET /jobs/{job_id}` —
  counters and per-row `errors[]`. Use this instead of polling the
  jobs endpoint.

Every webhook carries an HMAC-SHA256 signature over
`<timestamp>.<raw_body>` in the `Mercura-Signature-256` header.
Reject deliveries whose `Mercura-Timestamp` drifts more than five
minutes from your clock to close the replay window.

See the **Webhooks** chapter for the full wire format, the
signature-verification recipe, the retry schedule (30s / 5m / 30m /
2h / 12h, six attempts total), and the CRUD endpoints.

## Errors

Every non-2xx response uses one envelope:

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

Branch on `code` — it is stable. `message` is human-readable and may
change between versions. The full list of codes (`VALIDATION_FAILED`,
`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `IDEMPOTENCY_KEY_MISMATCH`,
`RATE_LIMITED`, `INTERNAL_ERROR`, …) is documented per endpoint.

## Request IDs

Every response carries an `X-Request-Id` header (echoed if you supply
one, generated otherwise) and the same id is embedded in every error
envelope. Quote it in support tickets — it is the fastest way for
Mercura's team to look up the exact request in our logs.

## Versioning

The API follows **Semantic Versioning** (`MAJOR.MINOR.PATCH`). Only the
`MAJOR` number lives in the URL path (`/v1`, future `/v2`, …). The full
SemVer string is published in the OpenAPI `info.version` field and on
every response as the `X-API-Version` header.

| Bump | What it means |
|---|---|
| **PATCH** | Bugfix / doc fix. OpenAPI shape unchanged. |
| **MINOR** | Additive only: new optional field, new endpoint, relaxed constraint. Existing clients keep working unchanged. |
| **MAJOR** | Breaking: removed field, renamed field, narrower constraint. A new URL prefix is published; the previous `MAJOR` stays live for at least 12 months. |

## Rate limits

Each API key has its own budget. **Every request counts as one** against
the matching limiter — reads draw from the read bucket, writes from the
write bucket, and each in-flight bulk-write job holds one concurrency
slot. Defaults today:

| Class | Limit |
|---|---|
| Writes (POST)     | 60 / minute, burst 10 |
| Reads (GET)       | 600 / minute, burst 60 |
| Concurrent in-flight bulk-write jobs | 5 per key |

Every response carries `RateLimit-Policy` / `RateLimit` headers (and
legacy `X-RateLimit-*` headers) describing your current budget. On a
`429 RATE_LIMITED` response, `Retry-After` tells you how long to wait,
and `X-RateLimit-Scope` tells you whether you hit the per-minute bucket
(`rate`) or the concurrent-jobs cap (`concurrency`).

## Read-endpoint pagination

`GET /articles`, `/customers`, `/suppliers`, and `/offers` are cursor
paginated. Pass `?modified_since=<ISO-8601>` on the first page to bound
the lower edge of the scan (useful for delta-sync). Mercura returns a
page plus a `next_cursor` — pass it back as `?cursor=…` to get the
next page. Once you have a cursor, `modified_since` is ignored.

The cursor is keyed on `(updated_at, id)`, so it is stable under
concurrent writes: a row updated mid-scan may resurface on a later
page, but you will never silently skip or duplicate rows.

## Alternative: SFTP-based integration

The Mercura API documented here is the preferred channel because it is
synchronous, low-latency, and operationally light — status comes back
immediately, no file-watching is required on either side, and
master-data updates can flow event-driven. Where a direct API
integration is not feasible (network constraints, security policy,
ERP capability), Mercura also supports SFTP-based exchange in both
directions for the same payloads. Talk to your Mercura contact if you
need that path; the data shapes are identical to the ones documented
here.


Version: 1.2.0

## Servers

```
/api/public/v1
```

## Download OpenAPI description

[Mercura API](https://docs.mercura.ai/_bundle/index.yaml)

## Articles

An **Article** is one row in your sales catalogue: a thing you sell,
keyed by the `article_number` you assign in your source system. Articles
are the most important resource in this API — Mercura matches every
position on every incoming customer LV against your articles, so the
quality of the offers you read back depends directly on the quality of
the article catalogue you push.

You can:

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

### About `article_number`

Article numbers are partner-supplied and must match the character class
`[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path
segments. `/`, `?`, `#`, and whitespace are not allowed inside an
article number.


### Public Articles Bulk Upsert

 - [POST /articles](https://docs.mercura.ai/articles/public_articles_bulk_upsert.md): Bulk-upsert articles (async).

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

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

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

Idempotency. Pass an Idempotency-Key header (≤ 255 chars).
Same key with a different body returns
422 IDEMPOTENCY_KEY_MISMATCH`.

### Public Articles List

 - [GET /articles](https://docs.mercura.ai/articles/public_articles_list.md): Cursor-paginated list of articles.

### Public Articles Get

 - [GET /articles/{article_number}](https://docs.mercura.ai/articles/public_articles_get.md): Fetch one article by its partner-supplied `article_number`.

## Customers

A **Customer** is one of your B2B accounts — the party that sends you
the LVs Mercura processes. Each customer is keyed by the `customer_id`
you assign in your source system, and carries one or more nested
addresses and contact persons. Mercura uses this data both to resolve
the customer on incoming LVs and to populate the `customer` block of
every offer you read back.

You can:

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

### About `customer_id`

Customer ids are partner-supplied and must match
`[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path
segments.


### Public Customers Bulk Upsert

 - [POST /customers](https://docs.mercura.ai/customers/public_customers_bulk_upsert.md): Bulk-upsert customers (async).

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

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

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

Idempotency. Pass an Idempotency-Key header (≤ 255 chars).
Same key with a different body returns
422 IDEMPOTENCY_KEY_MISMATCH`.

### Public Customers List

 - [GET /customers](https://docs.mercura.ai/customers/public_customers_list.md): Cursor-paginated list of customers.

### Public Customers Get

 - [GET /customers/{customer_id}](https://docs.mercura.ai/customers/public_customers_get.md): Fetch one customer by its partner-supplied `customer_id`.

## Suppliers

A **Supplier** is one of the vendors behind your article catalogue —
who you buy from. Each supplier is keyed by the `external_id` you
assign in your source system. Mercura uses supplier data when sourcing
articles for an offer and when generating supplier requests (RFQs).

You can:

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

### About `external_id`

Supplier external ids are partner-supplied and must match
`[A-Za-z0-9._\-]{1,255}` so they round-trip safely through URL path
segments.


### Public Suppliers Bulk Upsert

 - [POST /suppliers](https://docs.mercura.ai/suppliers/public_suppliers_bulk_upsert.md): Bulk-upsert suppliers (async).

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

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

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

Idempotency. Pass an Idempotency-Key header (≤ 255 chars).
Same key with a different body returns
422 IDEMPOTENCY_KEY_MISMATCH`.

### Public Suppliers List

 - [GET /suppliers](https://docs.mercura.ai/suppliers/public_suppliers_list.md): Cursor-paginated list of suppliers.

### Public Suppliers Get

 - [GET /suppliers/{external_id}](https://docs.mercura.ai/suppliers/public_suppliers_get.md): Fetch one supplier by its partner-supplied `external_id`.

## Offers

An **Offer** is the payoff of this API: the structured output that
Mercura produces by matching an incoming LV against your article
catalogue and pricing every position. Reading offers back is the reason
most partners integrate.

Each offer carries everything that goes on the corresponding PDF the
customer sees:

- **`customer`** — the resolved business partner, including the address
  used on the document and the contact person.
- **`project`** — the customer's object / project the LV belongs to
  (object number, name).
- **`positions[]`** — one entry per non-deleted position. Each carries
  `position_number`, `article_number` (the match into your catalogue),
  `description`, `quantity`, `unit`, `list_price`, `net_price`,
  `line_net`, and `line_gross`. Alternative positions are flagged with
  `is_alternative: true` and excluded from the totals — same semantics
  as the document.
- **`totals`** — `positions_subtotal`, `net_total`, `gross_total`. All
  amounts are EUR.
- **`erp_offer_id`** — the partner-side ERP offer id when known.

You can:

- **`GET /offers`** — cursor-paginated list of offers. Use
  `modified_since` for delta-sync. This is what a partner typically
  polls (or what an outbound webhook will trigger, in a future release).
- **`GET /offers/{offer_id}`** — fetch one offer in full.

### Read-only

Offers are produced by Mercura's matching pipeline. There is no
`POST /offers` — partners do not push offers in.


### Public Offers List

 - [GET /offers](https://docs.mercura.ai/offers/public_offers_list.md): Cursor-paginated list of offers for the authed organisation.

### Public Offers Get

 - [GET /offers/{offer_id}](https://docs.mercura.ai/offers/public_offers_get.md): Fetch one offer by its public id.

## Jobs

A **Job** is the asynchronous unit of work behind every bulk write in
this API. When you `POST /articles`, `POST /customers`, or
`POST /suppliers`, Mercura validates the payload, persists an
`ImportRun` row, enqueues a worker task, and returns a `JobAck`
immediately. The actual ingestion (validation per row, upsert into the
catalogue, error aggregation) happens out-of-band.

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

## The async write loop

```
POST /articles
Authorization: Bearer mrc_live_...
Idempotency-Key: nightly-sync-2026-05-19
Content-Type: application/json
{ "articles": [ ... up to 100,000 rows ... ] }

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

Then poll:

```
GET /api/public/v1/jobs/4242

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

…and again a few seconds later:

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

## Status enum

| `status` | Meaning |
|---|---|
| `PENDING`   | Queued, not yet picked up by a worker. |
| `RUNNING`   | A worker is processing rows. |
| `COMPLETED` | Finished. If `error_count > 0`, those specific rows failed and `errors[]` lists them — the rest were applied. |
| `FAILED`    | The whole job failed before any rows were applied (e.g. a validation error that affected the entire payload). |

`COMPLETED` is always a terminal *partial-success* status: a non-zero
`error_count` does not mean the job failed; it means those rows were
skipped while the others were applied. Treat `errors[]` as the
authoritative per-row failure list.

## Polling cadence

Most jobs finish in seconds; a 100,000-row catalogue sync may take a
few minutes. A reasonable client polls every **1 second for the first
30 seconds**, then backs off to **5–10 seconds**. Poll until you see
`COMPLETED` or `FAILED` — do not assume a wall-clock timeout.

## Errors

- `404 NOT_FOUND` — the `job_id` is unknown, malformed, or belongs to
  another organisation. We deliberately do not distinguish these cases:
  the existence of another tenant's jobs is never leaked.


### Public Jobs Get

 - [GET /jobs/{job_id}](https://docs.mercura.ai/jobs/public_jobs_get.md): Fetch the status of an async ingestion job.

## Webhooks

A **Webhook subscription** is an HTTPS endpoint your organisation
registers to receive event notifications from Mercura — so your
downstream system reacts in real time instead of polling.

You can manage subscriptions yourself through this API; the same
endpoints back the Settings → Organisation → Webhooks page in the
admin UI.

## Lifecycle

1. **Create.** `POST /webhook_subscriptions` with a `name`, an HTTPS
   `url`, and an optional `event_types` allow-list. The response
   includes the plaintext `secret` exactly once — store it now.
2. **Verify the endpoint.** `POST /webhook_subscriptions/{id}/test`
   fires a `webhook.test` event to this subscription only. Confirm
   you receive it and that your signature verification works before
   relying on real events.
3. **Receive events.** Mercura POSTs JSON to your `url` with the
   headers and body documented below.
4. **Update or revoke.** `PATCH /webhook_subscriptions/{id}` updates
   `url`, `event_types`, `is_active`, or `name`.
   `DELETE /webhook_subscriptions/{id}` revokes.

## Available event types

| Event | When it fires | Where to look |
|---|---|---|
| `offer.new_export_run` | A Mercura user clicks **Export → ERP** on an offer in the UI. | The Offers chapter. The payload carries the `offer_id`; fetch the full content with `GET /offers/{offer_id}`. |
| `job.finished` | An async bulk-write job reaches `COMPLETED` or `FAILED`. | The Jobs chapter. The payload mirrors `GET /jobs/{job_id}` — counters and the per-row `errors[]`. |

A subscription with an empty `event_types` list receives every event
type. Pass an explicit list to filter — e.g. `["job.finished"]` for an
endpoint that only cares about ingestion outcomes.

## Wire format

Every webhook is an HTTPS `POST` with the following shape:

```
POST <your subscription.url>
Content-Type: application/json
User-Agent: Mercura-Webhooks/1.0
Mercura-Event: offer.new_export_run
Mercura-Event-Id: 4a2b...e2
Mercura-Delivery-Id: 9c1f...77
Mercura-Delivery-Attempt: 1
Mercura-Timestamp: 1747742400
Mercura-Signature-256: sha256=<hex>

{
  "event_id": "4a2b...e2",
  "event_type": "offer.new_export_run",
  "delivered_at": "2026-05-20T12:00:00Z",
  "data": { ... }
}
```

| Header | Meaning |
|---|---|
| `Mercura-Event` | The event type (matches your subscription's `event_types`). |
| `Mercura-Event-Id` | UUID stable across delivery attempts. Use it to dedup if you process the same event from multiple webhooks. |
| `Mercura-Delivery-Id` | UUID unique to this delivery attempt. |
| `Mercura-Delivery-Attempt` | `1` for the first attempt, increments on retries. |
| `Mercura-Timestamp` | Unix epoch second when Mercura built the signature. |
| `Mercura-Signature-256` | `sha256=<hex>` HMAC of `<timestamp>.<raw_body>`. |

## Verifying the signature

Reconstruct the signed string and recompute the HMAC with your
subscription secret. Reject deliveries whose timestamp drifts more
than five minutes from your clock — that closes the replay window
on a captured webhook.

```python
import hashlib
import hmac
import time

MAX_SKEW_SECONDS = 5 * 60

def verify(secret: str, raw_body: bytes, headers: dict[str, str]) -> bool:
    timestamp = headers["Mercura-Timestamp"]
    signature = headers["Mercura-Signature-256"]
    if abs(int(time.time()) - int(timestamp)) > MAX_SKEW_SECONDS:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

The signing input is **the exact raw bytes** of the body Mercura sent
— do not re-serialise the parsed JSON before signing. Some frameworks
mutate whitespace or key order when round-tripping; signing against
the re-serialised form will fail.

## Retry policy

Mercura retries on transient failures: HTTP `5xx`, `408`, `429`, and
network errors. Other `4xx` responses are treated as permanent
(usually a partner config issue — surface it in your monitoring).

| Attempt | Wait before next attempt |
|---|---|
| 1 → 2 | 30 s |
| 2 → 3 | 5 min |
| 3 → 4 | 30 min |
| 4 → 5 | 2 h |
| 5 → 6 | 12 h |

After six total attempts Mercura stops retrying and marks the
delivery `failed`. The subscription stays active — Mercura does not
auto-disable on a streak of failures in this release, so check the
admin UI's recent-deliveries view if events appear to be missing.

## A note on secrets

The plaintext `secret` is returned exactly once, on `POST`. Mercura
stores only the secret itself (used to sign outbound bodies) — there
is no recovery flow. If you lose it, `DELETE` the subscription and
create a new one. The two-step rotation also lets you cut over a
running integration without downtime: create the new subscription,
deploy your endpoint with the new secret, then revoke the old.

## Errors

| Code | Status | When |
|---|---|---|
| `VALIDATION_FAILED` | 400 | URL must be HTTPS; userinfo not allowed; resolved host must be publicly routable; event types must be known values. |
| `UNAUTHORIZED` | 401 | Same envelope as the rest of the API — missing / malformed / expired / revoked bearer token. |
| `NOT_FOUND` | 404 | Unknown subscription id (or one belonging to another organisation). |


### Public Webhook Subscriptions Create

 - [POST /webhook_subscriptions](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_create.md): Create a webhook subscription for the authed organisation.

The response carries the plaintext signing secret in the
`secret` field — Mercura never echoes it again. Lost secrets
require a delete + recreate cycle.

### Public Webhook Subscriptions List

 - [GET /webhook_subscriptions](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_list.md): List all webhook subscriptions for the authed organisation.

Subscription counts per org are expected to stay small (handfuls
at most — prod ERP / sandbox / monitoring), so no pagination yet.
The shape will gain cursor pagination if any partner ever crosses
~100 subscriptions.

### Public Webhook Subscriptions Get

 - [GET /webhook_subscriptions/{subscription_id}](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_get.md): Fetch one subscription by id. The `secret` is never returned.

### Public Webhook Subscriptions Update

 - [PATCH /webhook_subscriptions/{subscription_id}](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_update.md): Update mutable fields. `secret` is not modifiable here.

### Public Webhook Subscriptions Delete

 - [DELETE /webhook_subscriptions/{subscription_id}](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_delete.md): Hard-delete a subscription.

In-flight delivery tasks that already hold a reference to this
subscription complete normally; new fan-out stops immediately.

### Public Webhook Subscriptions Test

 - [POST /webhook_subscriptions/{subscription_id}/test](https://docs.mercura.ai/webhooks/public_webhook_subscriptions_test.md): Fire a `webhook.test event scoped to this subscription only.

The event lands in the outbox and the dispatcher delivers it on
its next tick. The returned event_id is the same one your
endpoint will see on the Mercura-Event-Id` header, so you can
correlate.

