# Orders

Orders are the core resource of the Meykt API: your system sends an order with its items, personalization and files, and Meykt turns it into work in the workshop. Order creation is idempotent on your own order number, all-or-nothing per request, and never overwrites an order that already exists. This page covers creating, reading and listing orders.

## Create an order

`POST https://api.meykt.com/v1/orders` — requires the scope `orders:write`.

```bash
curl -X POST https://api.meykt.com/v1/orders \
  -H "Authorization: Bearer mk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_order_id": "ORDER-2026-1044",
    "currency": "EUR",
    "total_amount": 47.60,
    "ordered_at": "2026-08-07T09:12:00+02:00",
    "customer": {
      "name": "Anna Beispiel",
      "email": "anna@beispiel.test"
    },
    "shipping_address": {
      "name": "Anna Beispiel",
      "line1": "Musterweg 5",
      "postal_code": "12345",
      "city": "Musterstadt",
      "country": "DE"
    },
    "items": [{
      "sku": "BECHER-GROSS",
      "name": "Becher gross",
      "quantity": 2,
      "price": 20.00,
      "personalization": {
        "engraving": "Alles Gute",
        "name": "Anna"
      },
      "files": [{ "upload_id": "0f2a6b3c-9d41-4a77-b0f1-2b7c9a5e4d10", "role": "engraving" }]
    }]
  }'
```

Request fields:

| Field | Type | Required | Notes |
|---|---|---|---|
| `external_order_id` | string | yes | Your order number. Basis of idempotency. |
| `order_number` | string | no | Human-facing number. Defaults to `external_order_id`. |
| `currency` | string | conditional | ISO-4217, three letters. Required as soon as `total_amount` is set. |
| `total_amount` | number | no | Gross amount paid, display only. |
| `ordered_at` | string | no | ISO-8601 with timezone. Defaults to the time of receipt. |
| `customer` | object | no | `name`, `email`, `phone`. |
| `shipping_address` | object | no | `name`, `line1`, `line2`, `postal_code`, `city`, `state`, `country`. |
| `items` | array | yes | 1–200 entries. |
| `items[].sku` | string | no | Your article number. Without it, no product matching is possible. |
| `items[].name` | string | yes | Item name shown in the workshop. |
| `items[].quantity` | integer | yes | 1–10000. |
| `items[].price` | number | no | Net unit price in `currency`. |
| `items[].personalization` | object | no | Named string fields, see [Personalization](/en/developers/orders#personalization). |
| `items[].files` | array | no | `{ "upload_id": "…", "role": "…" }`, see [Files](/en/developers/files). |
| `metadata` | object | no | Free-form. Stored unchanged with the raw order, not interpreted. |

A newly created order responds with `201`. Every successful response wraps its payload in `data`:

```json
{
  "data": {
    "order": {
      "id": "8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45",
      "external_order_id": "ORDER-2026-1044",
      "order_number": "ORDER-2026-1044",
      "status": "pending",
      "is_test": true,
      "currency": "EUR",
      "total_amount": 47.6,
      "ordered_at": "2026-08-07T09:12:00+02:00",
      "created_at": "2026-08-07T07:12:04.512Z",
      "updated_at": "2026-08-07T07:12:04.512Z",
      "customer": { "name": "Anna Beispiel", "email": "anna@beispiel.test", "phone": null },
      "items": [{
        "id": "b1e77c30-2a48-4c19-8f55-9d0a3e6b1742",
        "sku": "BECHER-GROSS",
        "name": "Becher gross",
        "quantity": 2,
        "price": 20,
        "product_linked": false,
        "production_status": null,
        "personalization": { "engraving": "Alles Gute", "name": "Anna" },
        "files": [{ "name": "gravur-logo.svg", "role": "engraving" }]
      }]
    },
    "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."
    }]
  }
}
```

`is_test` is `true` for keys starting with `mk_test_`. Test orders appear in the order list with a test badge, but never start production, never count towards statistics and never trigger webhooks.

### Status values

Two independent status fields exist. Map the values you know and treat anything else as unknown — new values can be added without a new API version.

`status` describes the order as a whole:

| Value | Meaning |
|---|---|
| `pending` | Accepted, not yet picked up for production |
| `processing` | Being prepared |
| `in_production` | Work has started |
| `production_complete` | All production work finished |
| `shipped` | Marked as shipped inside Meykt |
| `completed` | Closed |
| `cancelled` | Cancelled |
| `failed` | Production failed |

`production_status` sits on each item and starts as `null`. It becomes `in_production` when an operator starts the work, then `completed` or `failed`. An item that never enters production keeps `null`.

Do not hardcode either list. Treat a value you do not know as unknown rather than as an error.

## Idempotency

`external_order_id` is your own order number and the only thing that makes a request idempotent. There is no separate idempotency key header.

- **Same `external_order_id`, same content → `200`** with the existing order and `"warnings": []`. Nothing is created twice.
- **Same `external_order_id`, different content → `409` `order_already_exists_with_different_content`.** Nothing is changed. Orders are never overwritten through the API.
- **Test and live are separate number spaces.** `ORDER-2026-1044` sent with a `mk_test_` key does not block the same number sent later with a `mk_live_` key.

**After a connection failure, network timeout or a `500`, resend the exact same request.** That is the intended recovery path: either the first attempt never arrived (you get `201`) or it did (you get `200` with the same order `id`). You never risk a duplicate.

**If files are involved, reuse the same `upload_id` values in the retry.** The content comparison is computed over the `upload_id`s, not over stored file paths — uploading the file again under a new `upload_id` makes the request look like different content and produces a `409`.

The comparison covers `external_order_id`, `currency`, `total_amount` and, per item, `sku`, `name`, `quantity`, `price`, `personalization` and the referenced `upload_id`s. Fields outside that set — `customer`, `shipping_address`, `ordered_at`, `metadata` — do not make a retry count as different content, and they are also **not** applied to the existing order.

There is no endpoint to change an order after it was sent. Corrections happen in the Meykt dashboard. Do not invent a new `external_order_id` to "update" an order: that creates a second, independent order in the workshop. A new number is only correct for a genuinely new order.

```js
// Safe retry pattern: identical body, identical upload_ids.
async function sendOrder(order) {
  const res = await fetch('https://api.meykt.com/v1/orders', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.MEYKT_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(order),
  });
  const body = await res.json();
  if (res.status === 201) return { order: body.data.order, created: true };
  if (res.status === 200) return { order: body.data.order, created: false }; // retry, already there
  throw new Error(body.error.code); // 409, 422, 429, 5xx — see /developers/errors
}
```

## All or nothing

An order is accepted completely or not at all. One invalid item rejects the whole request, and nothing is stored — no partial order, no partial items, no partially accepted files.

A rejected request answers `422` with `error.code` `validation_failed` and lists **all** problems it found, not just the first one, so you can fix everything in one pass:

```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" },
      { "code": "validation_failed", "message": "currency is required when total_amount is set", "field": "currency" }
    ]
  }
}
```

`field` uses **dot notation** with numeric array indexes as produced by schema validation: `items.0.quantity`, `items.1.name`. File-related rejections (`file_not_found`, `file_type_not_allowed`) point at the reference with bracket indexes instead: `items[0].files[0].upload_id`.

Fix the listed fields and resend the **complete** order under the same `external_order_id` — since nothing was stored, this is a first attempt, not a change.

## Unknown SKUs

An unknown `sku` never rejects an order and is never guessed at. The order is accepted, the item is stored with `"product_linked": false`, and the response reports a warning. Warnings live in `data.warnings` and always accompany a `2xx` response:

| Warning code | Meaning |
|---|---|
| `item_product_unmatched` | The `sku` does not match any product in your Meykt organization yet. `value` contains the SKU. |
| `item_missing_sku` | The item has no `sku` at all, so it can never be matched to a product automatically. |

You link the SKU **once** in the dashboard under **Products → assignment queue**. The assignment applies retroactively to orders that already arrived and automatically to every future order with that SKU. There is nothing to change in your integration afterwards — keep sending the same SKU.

Matching runs through the same path as the shop connections: an established link is stable, and Meykt never rewires or overwrites it based on later SKU changes.

## Personalization

Personalization is a flat object of named string fields per item. The key is the label the workshop sees, the value is the text to apply:

```json
"personalization": {
  "engraving": "Alles Gute",
  "name": "Anna",
  "font": "Serif"
}
```

Rules:

- Keys are free-form, 1–80 characters. Choose stable, human-readable names — they appear on the order in the dashboard exactly as sent.
- Values are **strings only**. Send numbers and dates as strings (`"12"`, `"2026-08-07"`).
- Empty values are dropped; a field with an empty string does not reach the workshop.
- At most 50 fields per item, each value at most 2000 characters.
- Personalization is part of the idempotency comparison. Changing a value makes a retry a different order and produces `409`.
- Artwork and print files are **not** personalization fields. Upload them first and reference the `upload_id` in `items[].files` — see [Files](/en/developers/files).

The response echoes personalization per item under `items[].personalization`.

## Limits

| Limit | Value |
|---|---|
| Items per order | 1–200 |
| Quantity per item | 1–10000 |
| Personalization fields per item | max. 50 |
| Personalization key length | 1–80 characters |
| Personalization value length | max. 2000 characters |
| Files per item | max. 10 |
| File size per upload | max. 50 MB |
| `external_order_id` length | 1–128 characters |
| `items[].name` length | 1–300 characters |
| `items[].sku` length | 1–120 characters |
| `items[].price` | 0–1000000 |
| `total_amount` | 0–100000000 |
| `customer.name` / `.email` / `.phone` | 200 / 320 / 50 characters |
| Validation errors reported per response | up to 50 |
| List page size (`limit`) | 1–100, default 25 |
| Requests per API key | 120 per minute |

Exceeding the request limit answers `429` `rate_limited` with a `Retry-After` header. The `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers are present on every response.

## Money and time

- **`items[].price` is a net unit price** — the price of one piece, excluding tax, in the order currency. Do not send a line total.
- **`total_amount` is the gross amount the customer paid.** It is display information only: Meykt does not calculate tax, does not convert currencies and does not derive item prices from it.
- **`currency` is mandatory as soon as `total_amount` is set.** ISO-4217, three letters, case-insensitive (`eur` is stored as `EUR`). Amounts without an explicit currency are rejected with `validation_failed` on `field` `currency` — the currency is never guessed.
- **Timestamps are ISO-8601 with a timezone**, either an offset (`2026-08-07T09:12:00+02:00`) or `Z`. This applies to `ordered_at` in requests and to `ordered_at`, `created_at`, `updated_at` in responses. A timestamp without timezone is rejected.
- If `ordered_at` is omitted, the time of receipt is used.

## Read an order

`GET https://api.meykt.com/v1/orders/{id}` — requires the scope `orders:read`. `{id}` is the `id` from a create or list response, not your `external_order_id`.

```bash
curl https://api.meykt.com/v1/orders/8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45 \
  -H "Authorization: Bearer mk_test_YOUR_KEY"
```

```json
{
  "data": {
    "order": {
      "id": "8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45",
      "external_order_id": "ORDER-2026-1044",
      "status": "pending",
      "items": [{
        "id": "b1e77c30-2a48-4c19-8f55-9d0a3e6b1742",
        "sku": "BECHER-GROSS",
        "quantity": 2,
        "product_linked": true,
        "production_status": "in_production"
      }]
    }
  }
}
```

A key only sees orders that came in through its own API connection — not the orders of other channels in the same organization. An id that does not exist, belongs to another channel, or is not a well-formed id all answer the same way: **`404` `not_found`, never `403`.** A `403` would confirm that the id exists, so the API deliberately does not distinguish these cases. Never derive "this order belongs to someone else" from a `404`; treat it as "unknown to this key".

## List orders

`GET https://api.meykt.com/v1/orders` — requires the scope `orders:read`. Orders are returned newest first.

| Query parameter | Meaning |
|---|---|
| `limit` | Page size, 1–100. Default 25. Values outside the range are clamped. |
| `starting_after` | Cursor: the `id` of the last order of the previous page. |
| `updated_since` | ISO-8601 timestamp. Returns only orders changed at or after that moment. |

```bash
curl "https://api.meykt.com/v1/orders?limit=50&updated_since=2026-08-07T06:00:00Z" \
  -H "Authorization: Bearer mk_test_YOUR_KEY"
```

```json
{
  "data": {
    "orders": [{ "id": "8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45", "external_order_id": "ORDER-2026-1044", "status": "pending", "items": [] }],
    "has_more": true,
    "next_cursor": "8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45"
  }
}
```

Paginate with the cursor, not with page numbers — it stays correct while new orders arrive:

```js
async function listAll(params = '') {
  let cursor = null;
  const all = [];
  do {
    const url = `https://api.meykt.com/v1/orders?limit=100${params}` +
      (cursor ? `&starting_after=${cursor}` : '');
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.MEYKT_API_KEY}` },
    });
    const body = await res.json();
    if (!res.ok) throw new Error(body.error.code);
    all.push(...body.data.orders);
    cursor = body.data.next_cursor;
  } while (cursor);
  return all;
}
```

`starting_after` must be the id of an order visible to this key; anything else answers `422` `validation_failed` with `field` `starting_after`. An unparsable `updated_since` answers `422` with `field` `updated_since`.

**`updated_since` is the polling path for integrations without a publicly reachable server. Meykt refreshes an order's `updated_at` whenever its production state changes, so a repeated call with `updated_since` set to the timestamp of your last poll returns exactly the orders that moved. Compare `status` and each item's `production_status` against what you last stored.

Poll no more often than you need — one list call with `updated_since` costs a single request, while one call per order burns the rate limit. If your system can accept inbound HTTPS, [webhooks](/en/developers/webhooks) deliver the same changes without polling at all.

## Forward compatibility

Write your integration so that it survives additive changes:

- **Ignore unknown response fields.** Fields are added over time without a new API version.
- **Treat unknown `status` and `production_status` values as unknown**, not as an error, and never match on them exhaustively.
- **Fields are never removed or re-interpreted without a new version** in the path (`/v2`).
- **Error codes are permanent.** Branch on `error.code`, never on `error.message` — messages are written for humans and may change at any time. Each error also carries a `doc_url` pointing at its reference page, for example [/developers/errors/order_already_exists_with_different_content](/en/developers/errors/order_already_exists_with_different_content).
