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.
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. |
items[].files | array | no | { "upload_id": "…", "role": "…" }, see 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:
{
"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 →200with the existing order and"warnings": []. Nothing is created twice. - Same
external_order_id, different content →409order_already_exists_with_different_content. Nothing is changed. Orders are never overwritten through the API. - Test and live are separate number spaces.
ORDER-2026-1044sent with amk_test_key does not block the same number sent later with amk_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_ids, 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_ids. 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.
// 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:
{
"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:
"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_idinitems[].files— see 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[].priceis a net unit price — the price of one piece, excluding tax, in the order currency. Do not send a line total.total_amountis 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.currencyis mandatory as soon astotal_amountis set. ISO-4217, three letters, case-insensitive (euris stored asEUR). Amounts without an explicit currency are rejected withvalidation_failedonfieldcurrency— the currency is never guessed.- Timestamps are ISO-8601 with a timezone, either an offset (
2026-08-07T09:12:00+02:00) orZ. This applies toordered_atin requests and toordered_at,created_at,updated_atin responses. A timestamp without timezone is rejected. - If
ordered_atis 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.
curl https://api.meykt.com/v1/orders/8c4d2f9a-5b71-4e6c-9a03-1f7d8e2b6c45 \
-H "Authorization: Bearer mk_test_YOUR_KEY"{
"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. |
curl "https://api.meykt.com/v1/orders?limit=50&updated_since=2026-08-07T06:00:00Z" \
-H "Authorization: Bearer mk_test_YOUR_KEY"{
"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:
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 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
statusandproduction_statusvalues 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 onerror.message— messages are written for humans and may change at any time. Each error also carries adoc_urlpointing at its reference page, for example /developers/errors/order_already_exists_with_different_content.
Machine-readable version of this page: https://www.meykt.com/developers/orders.md