# Errors

Every failed request to `https://api.meykt.com/v1` returns the same JSON envelope with a machine-readable `code`. Codes are a permanent part of the API contract: they are added over time, but never renamed or repurposed within `v1`. Warnings are a separate concept — a warning means the request **succeeded**.

## Error format

A failed request returns a single top-level `error` object. There is no `data` key on an error response.

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The order was rejected. No part of it was accepted — fix the listed fields and resend the complete order.",
    "doc_url": "https://www.meykt.com/en/developers/errors/validation_failed",
    "errors": [
      {
        "code": "validation_failed",
        "message": "Number must be greater than or equal to 1",
        "field": "items.0.quantity"
      },
      {
        "code": "validation_failed",
        "message": "String must contain at least 1 character(s)",
        "field": "items.1.name"
      }
    ]
  }
}
```

| Field | Always present | Meaning |
|---|---|---|
| `code` | yes | Stable machine identifier. Branch on this. |
| `message` | yes | Human-readable English text. May change at any time. |
| `field` | no | Path to the offending field, when a single field caused the failure. |
| `doc_url` | yes | Link to the reference page for this code. |
| `errors` | no | Present on validation failures: **all** problems found, not just the first. |

### code

`code` is the only value you should program against. It is stable forever — renaming a code would break existing integrations and is only possible in a new API version. Never match on `message`, and never parse the HTTP status alone: several distinct codes share the status `422`.

New codes may be added at any time. Treat an unrecognised code as a generic failure of its HTTP status class rather than crashing.

### message

`message` targets a human reading a log or a terminal. The wording, punctuation and level of detail can change without notice. It often carries the specific detail that `code` cannot express — for example `insufficient_scope` names the missing scopes, and `file_type_not_allowed` names the file type that was actually detected. Log it; do not branch on it.

### field

`field` names the exact location in your request body. It is present when the failure can be attributed to one place, and it also appears on each entry inside `errors[]`.

Two notations occur, depending on where the check happened:

- **Schema validation** uses dot notation for array indexes: `items.0.quantity`, `items.1.name`, `currency`.
- **File reference and warning paths** use bracket notation: `items[0].files[0].upload_id`, `items[2].sku`.

Do not build a parser that assumes one notation. Treat `field` as an opaque label for display and logging.

### doc_url

`doc_url` points at the reference page for that code. Every code in the table below is addressable at `/developers/errors/<code>`, so an automated client can fetch an explanation for a failure it has not seen before without a lookup table.

### errors

On `validation_failed`, the API validates the whole request and returns every problem at once (up to 50 entries), so you do not have to fix one field per round trip. Each entry has the same `code` / `message` / `field` shape as the outer object.

Order creation is all-or-nothing: if `errors[]` is present, **nothing** was created. Correct all listed fields and resend the complete order — not just the corrected part.

## Error codes

| Code | HTTP | Cause | Fix |
|---|---|---|---|
| [`invalid_api_key`](/en/developers/errors/invalid_api_key) | 401 | Key missing, unknown, revoked or expired | Check the `Authorization: Bearer …` header; verify in the dashboard that the key is still active; create a new one if needed |
| [`insufficient_scope`](/en/developers/errors/insufficient_scope) | 403 | The key does not carry the required scope | The message names the missing scopes; create a key with the matching scopes (there is no scope hierarchy — `orders:write` does not include `orders:read`) |
| [`invalid_json`](/en/developers/errors/invalid_json) | 400 | The body is not valid JSON | Check the request body — usually a missing quote or a trailing comma |
| [`validation_failed`](/en/developers/errors/validation_failed) | 422 | One or more fields invalid — **nothing** was created | Walk `errors[]`; each entry names a `field` and a reason; resend the corrected complete order |
| [`order_already_exists_with_different_content`](/en/developers/errors/order_already_exists_with_different_content) | 409 | The `external_order_id` already exists with different content | Do not invent a new number if this is meant to be the same order — align the content or resolve it in the dashboard. Use a new `external_order_id` only for a genuinely new order |
| [`file_not_found`](/en/developers/errors/file_not_found) | 422 | `upload_id` unknown, never uploaded, or expired | Call `POST /v1/uploads`, `PUT` the bytes to the returned `upload_url`, then reference the `upload_id`. Unreferenced uploads expire after 14 days and must be re-uploaded |
| [`file_type_not_allowed`](/en/developers/errors/file_type_not_allowed) | 422 | The file content is not an allowed type (the message names the detected type) | See the file reference for allowed types. Renaming the extension does not help — the actual content is inspected |
| [`not_found`](/en/developers/errors/not_found) | 404 | Unknown path, or an order id that is unknown or belongs to another integration | Check the path against the reference. Order ids come from your own create and list responses |
| [`rate_limited`](/en/developers/errors/rate_limited) | 429 | More than 120 requests per minute on one key | Wait for `Retry-After`, then retry. Batch reads (`GET /v1/orders?updated_since=…`) instead of polling single orders |
| [`internal_error`](/en/developers/errors/internal_error) | 500 | A failure on our side | Resend the identical body — duplicate protection makes this safe. Contact support if it repeats |
| [`service_unavailable`](/en/developers/errors/service_unavailable) | 503 | The API is temporarily unavailable | Wait briefly and retry |

### Webhook endpoint errors

These codes only occur when managing webhook endpoints.

| Code | HTTP | Cause | Fix |
|---|---|---|---|
| [`webhook_url_invalid`](/en/developers/errors/webhook_url_invalid) | 422 | The target URL is not a valid `https` URL | Only `https` is accepted; `http://` is rejected |
| [`webhook_target_blocked`](/en/developers/errors/webhook_target_blocked) | 422 | The target resolves to a private or reserved network address | Use a publicly reachable host. If you cannot expose one, poll `GET /v1/orders?updated_since=…` instead |

### File errors are nested

`file_not_found` and `file_type_not_allowed` never appear as the top-level `error.code`. File problems reject the order as a validation failure, so the top-level code is `validation_failed` (HTTP 422) and the specific file code sits inside `errors[]`:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The order was rejected because of file problems. Nothing was created.",
    "doc_url": "https://www.meykt.com/en/developers/errors/validation_failed",
    "errors": [
      {
        "code": "file_not_found",
        "message": "No uploaded file found for upload_id 6f1c…",
        "field": "items.0.files.0.upload_id"
      }
    ]
  }
}
```

Match on `error.code` first, then walk `errors[]` for the detail.

## Warnings

A warning is not an error. Warnings appear on a **successful** `POST /v1/orders` response (`201`), alongside the created order:

```json
{
  "data": {
    "order": {
      "id": "…",
      "external_order_id": "ORDER-2026-1044",
      "is_test": true,
      "items": [{ "sku": "BECHER-GROSS", "product_linked": false }]
    },
    "warnings": [
      {
        "code": "item_product_unmatched",
        "field": "items[0].sku",
        "value": "BECHER-GROSS",
        "message": "SKU \"BECHER-GROSS\" is not linked to any product yet. The order was accepted; link it in the dashboard under Products → assignment queue."
      }
    ]
  }
}
```

The order **was accepted and stored**. Do not treat warnings as a reason to retry, to change the `external_order_id`, or to abort your import run.

| Warning code | Meaning | Action |
|---|---|---|
| `item_product_unmatched` | The `sku` does not match any Meykt product yet, so `product_linked` is `false` on that item | Link it once in the dashboard under Products → assignment queue. The link applies retroactively and to all future orders with that SKU |
| `item_missing_sku` | The item was sent without a `sku` and therefore can never be matched automatically | Send a `sku` for items that should map to a Meykt product |

Warnings are advisory metadata. A repeated request that hits duplicate protection returns `200` with `"warnings": []` — an empty array is not a signal that a previous warning was resolved.

## Retrying safely

Retries are safe by design because `POST /v1/orders` is deduplicated on `external_order_id`: sending the identical body again returns `200` with the same order, never a duplicate. Reuse the same `upload_id` values in the retry — duplicate detection is computed over `upload_id`s, not over storage paths.

**Safe to retry with the identical body:**

- `500` `internal_error` — a failure on our side; the order may or may not have been stored, and the retry resolves that either way.
- `503` `service_unavailable` — the API is temporarily out of service.
- `429` `rate_limited` — wait for the seconds given in the `Retry-After` header first. The limit is 120 requests per minute per key; `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` are returned on every response.
- Network timeouts and dropped connections where you never saw a response — this is the case duplicate protection exists for.

**Do not retry unchanged (fix first):**

- `400` `invalid_json`, `422` `validation_failed`, `422` `file_not_found`, `422` `file_type_not_allowed` — the same body will fail identically. Correct it, then resend the complete order.
- `401` `invalid_api_key`, `403` `insufficient_scope` — a key or scope problem; retrying burns rate limit without changing anything.
- `404` `not_found` — the path or id is wrong.
- `409` `order_already_exists_with_different_content` — the number is taken by an order with different content. Meykt never overwrites an order through the API, and there is no endpoint to change an already-sent order; resolve it in the dashboard, or use a new `external_order_id` only if this really is a different order.

A workable client policy: retry `5xx` and `429` with exponential backoff (for example 1 s, 5 s, 30 s, 2 min) and a bounded number of attempts, honouring `Retry-After` when present; route everything else to a dead-letter queue for a human to inspect.

> Treat `200` and `201` identically when creating an order. `201` means the order was newly created, `200` means an identical order already existed. Both return the same order object under `data.order`.
