# Public Contacts Bulk Upsert

Bulk-upsert contacts (async).
Each row carries its own `parent_type` (`customer` or
`supplier`) and `parent_id` (the parent's partner-supplied
identifier — `customer_id` for customers, `supplier_id` for
suppliers). A single batch may mix both kinds.
**Upsert key.** When `external_id` is set on a contact it is the
primary upsert key against the parent. Without it, the worker
falls back to dedup by email, then `(name, phone)` — same
priority as the internal ERP sync path. Rows whose parent cannot
be resolved fail at the per-row level and surface in the job's
`errors[]`; the rest of the batch is unaffected.
**Custom fields.** `custom_fields` is keyed by the organisation's
custom-column labels — the same keys `GET /contacts` returns.
Only the labels you send are written and a `null` value clears
that column, so a partial feed never drops fields another system
maintains. Unknown labels are rejected up front with `400`
rather than queued, since they affect every row that uses them.
**Response.**
- `202 Accepted` with `JobAck { job_id, status_url }` on the
first call.
- `200 OK` with the *same* `JobAck` on an idempotent replay.

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

Endpoint: POST /contacts
Version: 1.79.2

## Header parameters:

  - `authorization` (string | null)
    Bearer <mrc_live_…> API key

  - `Idempotency-Key` (string | null)
    Optional client-supplied key for safe retries; <= 255 chars

## Request fields (application/json):

  - `contacts` (array, required)

  - `contacts.parent_type` (string, required)
    Which kind of business partner a contact is attached to.
    Enum: "customer", "supplier"

  - `contacts.parent_id` (string, required)

  - `contacts.external_id` (string | null)

  - `contacts.name` (string | null)

  - `contacts.email` (string | null)

  - `contacts.phone` (string | null)

  - `contacts.is_default` (boolean | null)

  - `contacts.custom_fields` (object | null)
    Contact custom fields, keyed by the organisation's custom-column **label** — the same keys `GET /contacts` returns, so a value round-trips verbatim. Omitted keys are left untouched; a `null` value clears that field. Labels are resolved before the batch is accepted: an unknown one returns 400 with the offending row and label in `details[]`, so the job is never queued half-valid.

## Response 200:

  - `200` (unknown)
    Idempotent replay — same job returned

## Response 200 fields (application/json):

  - `job_id` (string, required)

  - `status_url` (string, required)
    Relative URL to poll for this job's status

  - `warnings` (array)
    Non-fatal notices about the accepted payload — e.g. article translations for unsupported languages that were dropped. The job runs regardless; nothing listed here blocks processing. Deterministic for a given body, so idempotent replays return the same warnings.

## Response 202:

  - `202` (unknown)
    Accepted; poll status_url for completion

## Response 400:

  - `400` (unknown)
    Validation failed

## Response 422:

  - `422` (unknown)
    Idempotency-Key reused with a different body

