# Customers

Exchange your organization's customer records with your own system. This is a separate resource from the customer name and addresses inside an order. Customer access covers your organization's customer directory, including customers created through inquiries; it is not limited to customers created through this API connection.

## Permission and sharing approval

All three endpoints require a valid API key and the organization's current approval to share customer data. The owner or an administrator grants or withdraws that approval under **Integrations → API access → Share customer data with your system**. It applies to both reading and writing, including validation-only requests. Without it the API returns `403 customer_bridge_not_approved`.

| Method and path | Required scope | Test key | Live key |
|---|---|---|---|
| `GET /v1/customers` | `customers:read` | Reads real customer records after approval | Reads customer records after approval |
| `GET /v1/customers/{id}` | `customers:read` | Reads real customer records after approval | Reads customer records after approval |
| `POST /v1/customers` | `customers:write` | Only `validate_only: true`; no saved changes | Validate or create/update |

Write does not imply read. Order permissions do not grant customer-directory access. Unlike orders, customers have no test flag or separate test dataset: a test key's reads are real, and its ordinary POST is rejected with `403 test_key_read_only`.

Revoking sharing blocks subsequent customer calls. It cannot recall data already fetched by your system. There is no customer webhook or automatic outbound customer delivery; your system reads changes when needed.

## Create or update a customer

`POST /v1/customers` — requires `customers:write` and sharing approval.

The same endpoint creates or updates a record. There is no separate customer PATCH endpoint. Matching proceeds in this order:

1. `external_reference`: exactly one matching reference anywhere in your organization's customer directory.
2. `code`: the Meykt customer number, including an inactive customer with that code.
3. `email`: case-insensitive matching against active customers only, after trimming and lowercasing.
4. No match: create a new customer.

Names alone are not matching keys: repeating only a display name can create another customer. Retain a stable external reference or known customer code rather than relying on a name for duplicate protection.

A higher-priority match determines the target. Multiple matches for the external reference or email return `409 customer_match_ambiguous`; Meykt does not choose a customer for you. Use a stable reference or the known customer code to disambiguate. Do not assume that supplying a different email forces a new record when the external reference already matches.

These references are organization-wide, not a separate namespace per key or connection. If several source systems share the same directory, coordinate their references; for example, use a stable system prefix in `external_reference`. A newly supplied reference is filled in when the matched customer has none. An existing reference is never silently replaced. It is not returned in customer responses, so retain your source-to-Meykt mapping yourself.

Every request, including an update, needs at least one nonempty `name`, `company`, `last_name` or `email`. A reference or code alone is insufficient. Ordinary string inputs are trimmed and must be nonempty when supplied; JSON `null` and empty strings are not a way to clear them.

### Validate before writing

Save this fictional example as `customer.json`. Start with a test key and `validate_only: true`:

```json
{
  "external_reference": "ERP-CUSTOMER-42",
  "code": "C-2042",
  "name": "Example GmbH",
  "customer_type": "b2b",
  "company": "Example GmbH",
  "first_name": "Erika",
  "last_name": "Muster",
  "email": "erika@example.invalid",
  "phone": "+49 30 5550100",
  "street": "Example Street",
  "house_number": "7",
  "postal_code": "10115",
  "city": "Berlin",
  "country": "DE",
  "preferred_content_language": "de",
  "custom_fields": { "segment": "Wholesale" },
  "delivery_address": {
    "company": "Example GmbH",
    "street": "Warehouse Road",
    "house_number": "2",
    "postal_code": "10115",
    "city": "Berlin",
    "country": "DE"
  },
  "contacts": [{
    "first_name": "Erika",
    "last_name": "Muster",
    "position": "Purchasing",
    "email": "erika@example.invalid",
    "is_primary": true
  }],
  "update_mode": "overwrite",
  "validate_only": true
}
```

```bash
curl -X POST https://api.meykt.com/v1/customers \
  -H "Authorization: Bearer $MEYKT_API_KEY" \
  -H "Content-Type: application/json" \
  -d @customer.json
```

For a previously unknown customer, the validation response is `200`:

```json
{"data":{"result":"would_create","matched_by":null,"customer_id":null}}
```

A matching record instead yields `would_update`, its `customer_id`, and `matched_by` equal to `external_reference`, `code` or `email`. Validation saves nothing and does not reserve a customer number or freeze the decision. The result can change if another writer changes the directory before your live request.

To save, use a live key and set `validate_only` to `false` or omit it. A creation returns `201` with `result: "created"`; an update returns `200` with `result: "updated"`. Both include `data.customer` and nullable `data.matched_by`. Retain the returned customer `id` and `code`.

### Request fields and limits

| Field | Accepted input and behavior |
|---|---|
| `external_reference` | String, 1–200 characters; preferred source identity, not a returned field |
| `code` | String, 1–60; matches an existing Meykt customer or selects the number for a new one; omitted on creation means Meykt assigns one |
| `name` | String, 1–300; creation display name and required identity input, not a matching key; does not rename a matched customer's display name |
| `salutation` | `mr`, `mrs`, `diverse`, `dr`, `prof` |
| `first_name`, `last_name` | String, 1–120 each |
| `email` | Email address, at most 254; trimmed and normalized for matching |
| `phone`, `mobile` | String, 1–60 each |
| `company` | String, 1–200 |
| `customer_type` | `b2c`, `b2b`, `wholesale`; defaults to `b2c` only for creation |
| `street`, `address_line2` | String, 1–200 each; billing address input at the top level |
| `house_number` | String, 1–40 |
| `postal_code` | String, 1–30; preserve leading zeroes |
| `city` | String, 1–120 |
| `country` | String, 1–80; unlike the order address contract, this is not restricted to a two-letter value |
| `website_url` | String, 1–300 |
| `company_size` | String, 1–60 |
| `preferred_content_language` | Two-character string; supply a valid two-letter language code such as `de` or `en` |
| `custom_fields` | Object of additional customer-field values, keys at most 80 characters; not arbitrary customer metadata |
| `delivery_address` | Optional nonempty object; complete replacement of the default delivery address |
| `contacts` | Optional list of 1–20 complete contacts; replaces the contact list |
| `update_mode` | `overwrite` (default) or `fill_empty` |
| `validate_only` | Boolean; `true` evaluates matching without saving |

The complete JSON request is limited to 256 KiB, 32 nesting levels and 25000 values. No customer ID, organization ID, `is_active`, source, order revision or order-command idempotency field is writable through this contract. Send only documented fields.

### Update and omission rules

`overwrite` applies supplied nonempty ordinary contact and billing-address fields. Omitted fields stay as they are. `fill_empty` only fills currently empty ordinary fields; it preserves existing values in those fields.

The following rules are separate from that ordinary-field policy:

- `name` and `code` are used for creation/identification and do not rename or renumber a matched customer. Use Meykt for those edits. Supplying only a name does not change an existing display name.
- An explicitly supplied `customer_type` is applied even in `fill_empty`; omission preserves the current type.
- `custom_fields` merges the supplied keys into existing custom fields. A supplied key can replace its previous value even with `fill_empty`; omitted keys remain. `{}` does not clear all custom fields.
- A supplied `delivery_address` replaces the whole default delivery address, including clearing omitted fields in that object, even with `fill_empty`. Omit the object to preserve the address. `{}` and `null` are invalid.
- A supplied `contacts` list replaces the entire contact list, even with `fill_empty`. Omit it to preserve existing contacts. `[]` and `null` are invalid.

The delivery-address object accepts `company`, `first_name`, `last_name`, `street`, `house_number`, `address_line2`, `postal_code`, `city`, and `country` with the same lengths as above. A contact requires both `first_name` and `last_name`; optional fields are `salutation`, `position` (1–120), `email` (up to 254), `phone`, `mobile` (up to 60 each), and `is_primary`. The first submitted contact is marked primary; other supplied primary flags are retained. Reads return primary contacts first and then by creation time. Contact and address IDs are not exposed for individual patching.

For example, with a live key and an existing matching reference, this updates the phone and leaves omitted address/contact sections untouched. The required identity name does not rename the customer:

```json
{
  "external_reference": "ERP-CUSTOMER-42",
  "name": "Example GmbH",
  "phone": "+49 30 5550101",
  "update_mode": "overwrite"
}
```

Matching stops at the first decisive level. If `external_reference` matches customer A, a supplied `code` belonging to B does not override A or create an identity-conflict response; the existing code is not renamed. If that external reference is itself ambiguous, supplying a code alongside it does not bypass the ambiguity. Resolve the reference, or deliberately omit it to match by code after checking the preview.

### Retry and concurrency

This is a matching/upsert API, not the order-command journal. It has no immutable idempotency UUID, revision precondition or separate test/live customer namespace. A sequential repeat with the same stable reference normally updates the same customer and returns `200`, rather than replaying an immutable `201` result. Changed content can update that customer instead of producing an order-style content conflict.

Serialize writes for a source customer. Do not assume exactly-once creation under concurrent requests or reuse one reference for different people. If a timeout or `500` occurs, a customer may already have been saved, or some updates may have applied before a later failure. Read/reconcile the known customer and repeat with the same reference/code; do not manufacture a new identity to escape an uncertain outcome.

## Read one customer

`GET /v1/customers/{id}` — requires `customers:read` and sharing approval. `{id}` is the Meykt customer UUID, not the code or your external reference. An inactive customer can be read directly when its ID is known.

```bash
curl https://api.meykt.com/v1/customers/0f150bf1-cbe2-43a9-9f19-60394450c701 \
  -H "Authorization: Bearer $MEYKT_API_KEY"
```

The response is `200` with `data.customer`. The same complete customer shape is returned after a successful write:

```json
{
  "data": {
    "customer": {
      "id": "0f150bf1-cbe2-43a9-9f19-60394450c701",
      "code": "C-2042",
      "name": "Example GmbH",
      "salutation": null,
      "first_name": "Erika",
      "last_name": "Muster",
      "email": "erika@example.invalid",
      "phone": "+49 30 5550100",
      "mobile": null,
      "company": "Example GmbH",
      "customer_type": "b2b",
      "billing_address": { "street": "Example Street", "house_number": "7", "address_line2": null, "postal_code": "10115", "city": "Berlin", "country": "DE" },
      "website_url": null,
      "company_size": null,
      "preferred_content_language": "de",
      "is_active": true,
      "custom_fields": { "segment": "Wholesale" },
      "delivery_address": { "company": "Example GmbH", "first_name": null, "last_name": null, "street": "Warehouse Road", "house_number": "2", "address_line2": null, "postal_code": "10115", "city": "Berlin", "country": "DE" },
      "contacts": [{ "salutation": null, "first_name": "Erika", "last_name": "Muster", "position": "Purchasing", "email": "erika@example.invalid", "phone": null, "mobile": null, "is_primary": true }],
      "created_at": "2026-10-19T09:00:00Z",
      "updated_at": "2026-10-19T09:00:00Z"
    }
  }
}
```

Optional record values can be `null`; a missing default delivery address is `delivery_address: null`, and no contacts is `contacts: []`. Responses omit the source reference and private link/organization metadata. Foreign, unknown and malformed customer IDs answer the same `404 not_found`, once key/scope/consent checks pass.

## List and reconcile customers

`GET /v1/customers` — requires `customers:read` and sharing approval.

```bash
curl 'https://api.meykt.com/v1/customers?include_inactive=true&limit=100' \
  -H "Authorization: Bearer $MEYKT_API_KEY"
```

| Query | Meaning |
|---|---|
| `limit` | Page size, default 25; numeric values are truncated/clamped to 1–100 |
| `starting_after` | Last returned customer's `id`, supplied from `next_cursor` |
| `updated_since` | Timestamp; returns customers changed at or after it |
| `include_inactive` | Exactly `true` includes inactive customers; default excludes them |

The result is `{data:{customers:[...],has_more,next_cursor}}`. Every member has the complete customer shape above. `next_cursor` is a customer ID when more pages exist, otherwise `null`; pass it as `starting_after` while keeping the other filters fixed. A malformed, unknown or foreign cursor and an invalid timestamp return a neutral `422 validation_failed`. A failed cursor lookup is a `500 internal_error`; keep the previous cursor and retry instead of treating it as an empty page.

Include inactive customers when reconciling status. Otherwise an archived record disappears from the active list without a deletion notice. This endpoint has no tombstone stream or customer webhook; it is not the inventory cursor protocol. Do not treat an absent result or failed page as a deletion. Use overlapping update windows and periodic reconciliation rather than advancing a watermark before all pages have been stored.

Customers are ordered by creation time descending, then ID descending. Continuation uses both values, so customers sharing the same creation timestamp are included across page boundaries. Keep the returned ID cursor intact; do not reconstruct it from rounded timestamps. This is keyset pagination, not a frozen snapshot: keep filters fixed across a read and reconcile subsequent updates separately.

## Errors and recovery

| HTTP / code | Meaning and remedy |
|---|---|
| `401 invalid_api_key` | Check the key, connection, expiry and revocation before retrying |
| `403 insufficient_scope` | Explicitly grant the required customer read/write scope on a new key |
| `403 customer_bridge_not_approved` | An owner/admin must approve current customer sharing in Meykt |
| `403 test_key_read_only` | Use `validate_only:true`, or explicitly switch to an authorized live key to save |
| `422 validation_failed` | Fix missing identity, invalid values, empty replacement address/contact list or invalid read filters; POST reports its first validation issue |
| `409 customer_match_ambiguous` | Supply a known code/reference or resolve ambiguous records; never guess a target |
| `409 customer_code_taken` | A creation could not claim its supplied code; reconcile the existing customer and intended identity |
| `404 not_found` | The single customer is unavailable to this organization, or the endpoint is wrong |
| `429 rate_limited` | Honor `Retry-After`; all endpoints share the key's 120-request/minute limit |
| `500 internal_error` | Read/reconcile the known customer before retrying an uncertain write; a failed response is not proof that nothing was saved |

For shared JSON/body errors and key handling see [Errors](/en/developers/errors) and [Authentication](/en/developers/authentication). Customer-directory updates do not modify the address snapshot of an existing order; use the order's own [change command](/en/developers/orders#change-an-order-before-work-starts) when that is the intended action. Customer operations are not offered as integration-profile operations in the current [capability catalog](/en/developers/integration-profiles#discover-first).
