# Meykt API — complete developer documentation This single file contains the entire Meykt API documentation. It is generated from the same Markdown sources as the HTML pages at https://www.meykt.com/en/developers, so both versions are always identical. If you are an AI agent connecting a customer's system to Meykt: this file plus the machine-readable specification at https://api.meykt.com/v1/openapi.json is everything you need. Nothing important is documented anywhere else. Base URL: https://api.meykt.com/v1 Authentication: Authorization: Bearer mk_live_... (or mk_test_... for testing) Individual pages are also available as Markdown, for example https://www.meykt.com/developers/orders.md --- # Meykt API The Meykt API connects your own system — ERP, shop, or in-house software — to your Meykt organization. Orders go in, including personalization fields and production files; order and production status comes back out, either by polling or by webhook. It is a versioned REST API over JSON, authenticated with API keys that carry explicit permission scopes. ## Base URL All endpoints live under a single versioned base URL: ``` https://api.meykt.com/v1 ``` The version is part of the path. Fields may be added to `/v1` responses at any time; fields are never removed or redefined without a new version prefix. ## Authentication Send your API key in the `Authorization` header on every request — never as a URL parameter, because URLs end up in logs and browser history. ```bash curl https://api.meykt.com/v1/ping \ -H "Authorization: Bearer mk_test_YOUR_KEY" ``` Keys are created in the Meykt dashboard and exist in two environments: `mk_test_` keys create orders that are visible in the order list but never enter production, and `mk_live_` keys create real orders. Every rejection of a key — missing, unknown, revoked, or expired — returns the same error, [`invalid_api_key`](https://www.meykt.com/en/developers/errors/invalid_api_key) with HTTP 401. Scopes are `orders:read`, `orders:write`, `files:write` and `webhooks:manage`; there is no hierarchy, so `orders:write` does not imply `orders:read`. Details: [Authentication](https://www.meykt.com/en/developers/authentication). ## Rate limits Each API key is limited to **120 requests per minute**. Every authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. When the limit is exceeded the API returns HTTP 429 with the error code [`rate_limited`](https://www.meykt.com/en/developers/errors/rate_limited) and a `Retry-After` header. Prefer one list call with `updated_since` over many single-order lookups. ## Compatibility promise Write your integration so that it survives additive changes. Three rules apply: - **Ignore unknown response fields.** New fields can appear in any `/v1` response. A client that rejects unknown keys will break on an ordinary, non-breaking update. - **Treat unknown status values as "unknown".** New status values can be introduced. Map values you know, pass through the rest, and never crash on an unrecognized status. - **Nothing is removed without a new version.** Existing fields will not be deleted or given a different meaning inside `/v1`. A breaking change ships as a new version prefix. Error codes are permanent and are never renamed. ## For AI agents This documentation is designed to be read by machines, not only by people. An agent can build a complete Meykt integration from these three resources: | Resource | URL | Contents | |---|---|---| | Full documentation | `https://www.meykt.com/llms-full.txt` | Every developer page concatenated into one plain-text file | | Machine specification | `https://api.meykt.com/v1/openapi.json` | OpenAPI description generated from the live endpoint registry | | Markdown twin of any page | `https://www.meykt.com/developers/orders.md` | Append `.md` to any documentation URL to get raw Markdown instead of HTML | **A single fetch of `llms-full.txt` is enough to write the whole integration.** It contains the quickstart, the request and response shapes, all limits, the complete error catalog with remedies, the duplicate-protection semantics, the file upload flow and the webhook signature scheme. No HTML parsing and no crawling of individual pages is required. ```bash curl -s https://www.meykt.com/llms-full.txt curl -s https://api.meykt.com/v1/openapi.json curl -s https://www.meykt.com/developers/orders.md ``` `openapi.json` is the canonical machine-readable source for paths, methods, required scopes and schemas — generate clients from it rather than transcribing endpoint tables by hand. Error codes returned by the API carry a `doc_url` that points at the matching page under `/developers/errors/`. ## Pages - [Quickstart](https://www.meykt.com/en/developers/quickstart) — from creating a key to a first order visible in the dashboard, including the safe-retry behavior. - [Authentication](https://www.meykt.com/en/developers/authentication) — keys, scopes, test versus live environment, rate limits. - [Orders](https://www.meykt.com/en/developers/orders) — creating orders with personalization, duplicate protection via `external_order_id`, warnings, limits, listing and polling with `updated_since`. - [Files](https://www.meykt.com/en/developers/files) — the two-step upload flow, accepted file types, size limits, expiry of unreferenced uploads. - [Webhooks](https://www.meykt.com/en/developers/webhooks) — event types, envelope, `X-Meykt-Signature` verification, retry schedule, automatic disabling. - [Errors](https://www.meykt.com/en/developers/errors) — every stable error code with cause and remedy. ## What this API does not do Stated explicitly so that no integration is built on an assumption: - **No modification of orders already sent.** Once an order has been accepted, its content cannot be changed through the API. Sending the same `external_order_id` with different content returns HTTP 409 [`order_already_exists_with_different_content`](https://www.meykt.com/en/developers/errors/order_already_exists_with_different_content); existing data is never overwritten. Corrections are made in the Meykt dashboard. - **No deletion of orders.** There is no endpoint to delete or purge an order. - **No `shipped` event.** Meykt has no shipping flow, so no shipping webhook and no shipping status exist. The available events cover order receipt, production start, production completion, production failure, cancellation and item completion. - **No tax calculation and no invoicing.** Unit prices you send are net values; `total_amount` is the gross amount your customer paid and is carried for display only. Meykt neither computes tax nor produces invoices. - **No partial order acceptance.** Order creation is all-or-nothing: one invalid item rejects the entire order with HTTP 422 [`validation_failed`](https://www.meykt.com/en/developers/errors/validation_failed), listing every field error at once. --- # Quickstart: your first order in 10 minutes This page takes you from zero to a personalized order inside Meykt using nothing but `curl`. The base URL of the API is `https://api.meykt.com/v1`; every request is authenticated with an API key in the `Authorization` header. Start in the **test** environment — test orders are visible in the order list but never reach production. ## Step 1: Create a test key In the Meykt dashboard go to **Integrations → API access → "Create key"**. 1. Choose the environment **Test**. Test keys start with `mk_test_`, live keys with `mk_live_`. 2. Select the scopes you need. For this quickstart: `orders:write` (send orders), `orders:read` (read them back), `files:write` (upload files). There is **no scope hierarchy** — `orders:write` does not include `orders:read`. 3. Copy the key immediately. The plaintext key is shown **exactly once**. If you lose it, create a new key and revoke the old one. Keep the key out of your source code and out of URLs — pass it only as a header. ```bash export MEYKT_API_KEY="mk_test_a1b2c3d4e5f6g7h8i9j0k1l2" ``` ## Step 2: Verify the connection `GET /v1/ping` confirms which key and which environment you are actually using. It requires no scope. ```bash curl https://api.meykt.com/v1/ping \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` Response `200`: ```json { "data": { "organization": { "name": "Musterwerkstatt" }, "environment": "test", "scopes": ["orders:read", "orders:write", "files:write"], "key_prefix": "mk_test_a1b2c3", "api_version": "v1" } } ``` Every authenticated response carries the rate limit headers `X-RateLimit-Limit: 120`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until the window resets). The limit is 120 requests per minute per key. If you get `401` with the code `invalid_api_key`, the header is malformed or the key is unknown, revoked or expired — all four cases return the same code on purpose. See [/developers/errors/invalid_api_key](https://www.meykt.com/en/developers/errors/invalid_api_key). ## Step 3: Send your first order `POST /v1/orders` creates an order. `external_order_id` is your own order number and is the key for duplicate protection. ```bash curl -X POST https://api.meykt.com/v1/orders \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_order_id": "ORDER-2026-1044", "customer": { "name": "Anna Beispiel" }, "items": [{ "sku": "BECHER-GROSS", "name": "Becher gross", "quantity": 2, "personalization": { "gravur": "Alles Gute", "name": "Anna" } }] }' ``` Response `201`: ```json { "data": { "order": { "id": "8f2c1d0a-6b44-4c11-9a3e-1f0c9d7a5b21", "external_order_id": "ORDER-2026-1044", "order_number": "ORDER-2026-1044", "status": "pending", "is_test": true, "currency": null, "total_amount": null, "ordered_at": "2026-08-07T09:12:04.311Z", "created_at": "2026-08-07T09:12:44.512Z", "updated_at": "2026-08-07T09:12:44.512Z", "customer": { "name": "Anna Beispiel", "email": null, "phone": null }, "items": [ { "id": "b7413f52-0e9a-4d77-8c62-2a5f6c31d904", "sku": "BECHER-GROSS", "name": "Becher gross", "quantity": 2, "price": null, "product_linked": false, "production_status": null, "personalization": { "gravur": "Alles Gute", "name": "Anna" }, "files": [] } ] }, "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." } ] } } ``` Two things to note: - **Warnings are not errors.** `product_linked: false` plus `item_product_unmatched` means Meykt does not know that SKU yet. The order was still accepted. Link the SKU once in the dashboard under **Products → assignment queue**; from then on it matches automatically. - **Orders are all-or-nothing.** If any item is invalid, the whole order is rejected with `422` `validation_failed` and nothing is created. The response lists *all* field errors at once, with dot-notation paths such as `items.0.quantity`. See [/developers/errors/validation_failed](https://www.meykt.com/en/developers/errors/validation_failed). ## Step 4: Check the order in the dashboard Open **Orders** in the dashboard. The new row carries the **Test** badge. Open it: customer, quantities, the personalization fields and any attached files are all there. Test orders behave differently from live orders on purpose: - They appear **only** in the order list — not on the dashboard home, not in statistics, not in billing counters. - They never start production and never trigger workflows or webhooks. - Test and live are separate numbering spaces: `ORDER-2026-1044` used in test does **not** block the same number in live. ## Step 5: Retry the same request Send the exact same request from step 3 again. You get `200` — not a duplicate, not a `409`. ```bash curl -X POST https://api.meykt.com/v1/orders \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d @order.json ``` The body has the same shape as in step 3; `data.order.id` is identical and `data.warnings` is empty (the warning was reported when the order was first created). Fields are omitted below for brevity: ```json { "data": { "order": { "id": "8f2c1d0a-6b44-4c11-9a3e-1f0c9d7a5b21", "external_order_id": "ORDER-2026-1044", "status": "pending", "is_test": true }, "warnings": [] } } ``` This is what makes retries safe: after a timeout or a dropped connection, resend the identical request. Same `external_order_id` **and** same content → the existing order is returned with `200`. Same `external_order_id` but **different** content → `409` `order_already_exists_with_different_content`. Orders are never overwritten through the API. Do not invent a new order number to work around this — use a new `external_order_id` only for a genuinely new order, and change existing orders in the dashboard. There is no endpoint for updating an order that was already sent. See [/developers/errors/order_already_exists_with_different_content](https://www.meykt.com/en/developers/errors/order_already_exists_with_different_content). ## Step 6: Go live 1. Create a second key in **Integrations → API access**, this time with the environment **Live** (prefix `mk_live_`), with the same scopes. 2. Replace the key in your system. Nothing else changes: same base URL, same paths, same request bodies. 3. Verify with `GET /v1/ping` that the response now says `"environment": "live"`. From that point on, orders you send are real: they start production, appear in statistics, trigger automation workflows and, if you have registered endpoints, emit the `order.received` webhook. ```bash curl https://api.meykt.com/v1/ping \ -H "Authorization: Bearer mk_live_YOUR_KEY" ``` > Keep both keys. The test key stays useful for every future change to your integration — you can rehearse the full flow without touching the shop floor. ## Next steps - [Authentication](https://www.meykt.com/en/developers/authentication) — key format, scopes, rate limits, test vs. live. - [Orders](https://www.meykt.com/en/developers/orders) — full field reference, limits (max. 200 items, quantity 1–10,000, max. 50 personalization fields per item), listing with `updated_since` and cursor paging. - [Files](https://www.meykt.com/en/developers/files) — the two-step upload: `POST /v1/uploads`, then `PUT` the file content to the returned `upload_url`, then reference the `upload_id` in `items[].files`. - [Webhooks](https://www.meykt.com/en/developers/webhooks) — event types, signature verification, retries. There is deliberately **no** "shipped" event, because Meykt has no shipping flow. If you cannot expose a public endpoint, poll `GET /v1/orders?updated_since=…` instead — it is a fully supported path. - [Error reference](https://www.meykt.com/en/developers/errors) — every stable error code with cause and fix. Each API error response also carries a `doc_url` pointing directly at the matching page. - [OpenAPI specification](https://api.meykt.com/v1/openapi.json) — the machine-readable source; generate a client from it instead of transcribing endpoints. --- # Authentication Every request to `https://api.meykt.com/v1` is authenticated with an API key sent in the `Authorization` header. A key belongs to one Meykt organization, carries a fixed set of scopes, and is bound to one environment — `live` or `test`. There is no session, cookie, or OAuth flow in v1. ## API key format A key looks like this: ``` mk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 mk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 ``` - `mk_` is a fixed marker so leaked keys are machine-detectable by secret-scanning services. - `live` or `test` is part of the key itself, so a key can never be used against the wrong environment by accident. - The remainder is 32 random characters. The first 14 characters (`mk_live_a1b2c3`) are the **key prefix**. The prefix is not secret: it is shown in the key list in the dashboard and returned by `GET /v1/ping` as `key_prefix`, so you can tell which key a running system is actually using. Keys are created in the Meykt dashboard under Integrations → API access. ## Authorization header Send the key as a Bearer token on every request: ```bash curl https://api.meykt.com/v1/ping \ -H "Authorization: Bearer mk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" ``` `GET /v1/ping` requires no scope and is the correct first call of any integration. It answers "did I pick the right key and the right environment?" in one line: ```json { "data": { "organization": { "name": "Musterwerkstatt" }, "environment": "test", "scopes": ["orders:read", "orders:write", "files:write"], "key_prefix": "mk_test_a1b2c3", "api_version": "v1" } } ``` One exception: the `PUT` request that uploads file content to an `upload_url` from `POST /v1/uploads` is authenticated by the signed URL itself. Do **not** send your API key to that URL. ## Never send the key in a URL The API only reads the `Authorization` header. There is no `?api_key=` query parameter, and adding one does not work. This is deliberate. Query strings end up in places you do not control: - server and proxy access logs, on both sides of the connection - browser history and `Referer` headers - error trackers, screenshots, and pasted support tickets - CDN and cache keys A header is not stored in any of those by default. Keep keys in environment variables or a secret store, never in source control, front-end code, or a URL. ## One-time display The plaintext key exists exactly once: in the response of the "create key" action in the dashboard. Meykt stores only a hash of it and can never show or recover the plaintext again. If a key is lost or exposed: 1. Create a new key with the same scopes and environment. 2. Deploy the new key to your system. 3. Revoke the old key in the dashboard. From that point on it is rejected. Revoking is immediate and applies to every request made with that key. ## Scopes Each key carries an explicit list of scopes. **A scope always describes what your system is allowed to do at Meykt — never what Meykt is allowed to do in your system.** Meykt does not call into your systems at all, with one exception you configure yourself: webhook deliveries to URLs you register. | Scope | What a key with this scope may do | |---|---| | `orders:read` | Your system may read and list the orders it created at Meykt through this connection. | | `orders:write` | Your system may send orders to Meykt. | | `files:write` | Your system may request upload URLs and reference the resulting files on order items. | | `webhooks:manage` | Your system may create, list, test and delete webhook endpoints and read the delivery log. | Which endpoint needs which scope: | Endpoint | Required scope | |---|---| | `GET /v1/ping` | none | | `POST /v1/orders` | `orders:write` | | `GET /v1/orders` | `orders:read` | | `GET /v1/orders/{id}` | `orders:read` | | `POST /v1/uploads` | `files:write` | | `POST /v1/webhook-endpoints` | `webhooks:manage` | | `GET /v1/webhook-endpoints` | `webhooks:manage` | | `DELETE /v1/webhook-endpoints/{id}` | `webhooks:manage` | | `POST /v1/webhook-endpoints/{id}/test` | `webhooks:manage` | | `GET /v1/webhook-deliveries` | `webhooks:manage` | Read access is additionally bound to the connection the key belongs to: a key only ever sees the orders that were created through it. An order id that belongs to a different channel or a different organization answers `404 not_found`, never `403` — a `403` would confirm that the id exists. ### There is no scope hierarchy `orders:write` does **not** include `orders:read`. A system that sends orders and later polls their status needs both scopes checked on the same key. Implicit rights are a common source of surprises in access systems, so they were left out on purpose. A missing scope is rejected with `403 insufficient_scope`, and the message names exactly which scopes are missing: ```json { "error": { "code": "insufficient_scope", "message": "This API key is missing the required scope(s): orders:read.", "doc_url": "https://www.meykt.com/en/developers/errors/insufficient_scope" } } ``` Scopes cannot be changed on an existing key. Create a new key with the right scopes and retire the old one. ## Every rejected key is one error Missing header, unknown key, revoked key, expired key, malformed key — all of them return the same response: ```json { "error": { "code": "invalid_api_key", "message": "Missing or invalid API key. Send it as \"Authorization: Bearer mk_live_…\".", "doc_url": "https://www.meykt.com/en/developers/errors/invalid_api_key" } } ``` HTTP status is always `401`. This is intentional: distinguishing "unknown key" from "revoked key" would confirm to anyone probing the API that a given key exists or once existed. The distinction is recorded on the Meykt side, not returned. Practical consequence for your error handling: on `401`, do not retry with a backoff — nothing about the request will change. Check the header spelling, check the environment prefix, and check in the dashboard whether the key is still active. See [invalid_api_key](https://www.meykt.com/en/developers/errors/invalid_api_key). ## Test vs. live The environment is decided by the key, not by a flag in the request body. A `mk_test_` key writes into the **same organization** and matches against your **real products and mappings**, so the test exercises the identical code path as production. What a test order does: - appears in the order list in the dashboard, marked with a "Test" badge - carries `is_test: true` in the API response - resolves personalization, files, and product matching exactly like a live order What a test order never does: - start production of any kind - appear in the dashboard home screen or in any statistics - trigger workflows - count towards billing Test and live are **separate number spaces**. `ORDER-2026-1044` sent with a test key does not block `ORDER-2026-1044` sent later with a live key — the duplicate protection on `external_order_id` is scoped per environment. This means you can rehearse a real order number and then send it for real. Going live is a key swap: create a second key with environment `live` and the same scopes, replace the value in your system, and verify with `GET /v1/ping` that `environment` now reads `"live"`. > Test keys are not a sandbox with fake data. They write real order rows into your real organization; they are only excluded from production, statistics, workflows, and billing. ## Rate limits The limit is **120 requests per minute per key**, counted across all endpoints. Two keys of the same organization have independent budgets. Every response that got past key verification carries the current state (a `401` for an unknown key and a `503` cannot carry them — there is no key to count against yet): | Header | Meaning | |---|---| | `X-RateLimit-Limit` | Requests allowed per window, currently `120` | | `X-RateLimit-Remaining` | Requests left in the current window | | `X-RateLimit-Reset` | Seconds until the window resets, currently `60` | Exceeding the limit returns `429` with code `rate_limited` and a `Retry-After` header in seconds: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 X-RateLimit-Limit: 120 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 60 ``` Handling rule: on `429`, wait for `Retry-After` seconds and repeat the identical request. Repeating a `POST /v1/orders` is safe — the same `external_order_id` with the same content returns the existing order with `200` instead of creating a duplicate. If you are hitting the limit, the usual cause is polling one order at a time. Replace it with one list call per interval: ```bash curl "https://api.meykt.com/v1/orders?updated_since=2026-08-07T09:00:00Z&limit=100" \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` Or register a webhook endpoint so Meykt pushes status changes to you instead. ## Next steps - [Send your first order](https://www.meykt.com/en/developers/orders) - [Attach files to order items](https://www.meykt.com/en/developers/files) - [Receive status events](https://www.meykt.com/en/developers/webhooks) - [Error reference](https://www.meykt.com/en/developers/errors) --- # 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](https://www.meykt.com/en/developers/orders#personalization). | | `items[].files` | array | no | `{ "upload_id": "…", "role": "…" }`, see [Files](https://www.meykt.com/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](https://www.meykt.com/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](https://www.meykt.com/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](https://www.meykt.com/en/developers/errors/order_already_exists_with_different_content). --- # Files Artwork, engraving files and print data are attached to order items through a two-step upload. You first request an upload slot (`POST /v1/uploads`), then send the file content directly to the returned address with a single HTTP `PUT`, and finally reference the returned `upload_id` inside `items[].files` when you create the order. File content is validated when the order is accepted, not when the file is uploaded. ## Two-step upload The file never passes through the Meykt application server. `POST /v1/uploads` only hands out a short-lived, signed storage address; the bytes go straight to storage. This has three practical consequences: - **No request size or timeout problems.** You are not limited by JSON body limits, and you never base64-encode a file into a request. - **Retrying is cheap.** If the `PUT` fails, repeat only the `PUT`. If the order call fails, repeat only the order call with the same `upload_id` values. - **Validation happens later.** The upload slot accepts whatever you send. Type checking, SVG sanitizing and the move into permanent storage happen when `POST /v1/orders` accepts the order that references the upload. `POST /v1/uploads` requires the scope `files:write`. Creating the order requires `orders:write`. Scopes are not hierarchical — a key that only has `orders:write` cannot request upload slots. ## Step 1 request an upload slot Send the file name you want the file to be stored under. The name is sanitized (directory parts removed, unusual characters replaced with `_`, truncated to 120 characters) and the sanitized value is echoed back as `filename`. ```bash curl -X POST https://api.meykt.com/v1/uploads \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "engraving-logo.svg"}' ``` Response `201`: ```json { "data": { "upload_id": "3f0a9c1e-2b77-4c1a-9f1c-0d2a4e6b8c10", "filename": "engraving-logo.svg", "upload_url": "https://…signed…", "method": "PUT", "expires_in_seconds": 7200, "max_bytes": 52428800 } } ``` One `upload_id` belongs to exactly one file. Request a separate slot for every file you want to attach. ## Step 2 send the file Send the raw file content as the request body of a single `PUT` to `upload_url`. No authorization header is needed — the address is already signed. ```bash response=$(curl -s -X POST https://api.meykt.com/v1/uploads \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"filename": "engraving-logo.svg"}') upload_url=$(echo "$response" | jq -r '.data.upload_url') upload_id=$(echo "$response" | jq -r '.data.upload_id') curl -X PUT "$upload_url" --data-binary @engraving-logo.svg ``` Check the HTTP status of the `PUT`. Only a 2xx means the file arrived; on any other status the file is not stored and the `upload_id` will later be rejected with `file_not_found`. ## Step 3 reference the upload in an order Add the `upload_id` to the `files` array of the item it belongs to. `role` is optional free text (max 40 characters) and is display information only. ```bash curl -X POST https://api.meykt.com/v1/orders \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_order_id": "ORDER-2026-1044", "customer": { "name": "Anna Beispiel" }, "items": [{ "sku": "BECHER-GROSS", "name": "Mug large", "quantity": 2, "personalization": { "engraving": "All the best" }, "files": [{ "upload_id": "3f0a9c1e-2b77-4c1a-9f1c-0d2a4e6b8c10", "role": "engraving" }] }] }' ``` Files are accepted before anything is written. A single unusable file rejects the **whole** order with `422` and nothing is created — this is the same all-or-nothing rule that applies to field validation. In responses, each item reports its files as name and role only: ```json { "files": [{ "name": "engraving-logo.svg", "role": "engraving" }] } ``` The API does not return download URLs for order files. The files are visible on the order in the dashboard. ## Allowed file types The **actual content** is checked, not the file extension and not the `Content-Type` you sent. Detection reads the first bytes of the stored file. Renaming a `.mp4` to `.svg` does not help — it is rejected with `file_type_not_allowed`, and the error message names the type that was detected. | Format | Detected type | |---|---| | PNG | `image/png` | | JPEG | `image/jpeg` | | WebP | `image/webp` | | GIF | `image/gif` | | SVG | `image/svg+xml` | | TIFF | `image/tiff` | | BMP | `image/bmp` | | HEIC / HEIF / AVIF | `image/heic`, `image/heif`, `image/avif` | | PDF | `application/pdf` | | DXF | `application/dxf` | | EPS / PS | `application/postscript` | | ZIP (including ZIP-based Office formats) | `application/zip` | | Plain text / CSV | `text/plain`, `text/csv` | Notes: - Adobe Illustrator files are accepted through the format they are actually written in: older PostScript-based `.ai` files are detected as `application/postscript`, current PDF-based `.ai` files as `application/pdf`. - A file must start with a recognizable header. A truncated or empty upload is reported as `file_not_found`, not as a type error. - **SVG is sanitized automatically.** Scripts, inline event handlers, external references and active URL schemes are stripped, and the file is stored with `Content-Disposition: attachment`. The stored SVG can therefore differ from the bytes you uploaded. ## Limits - **50 MB per file** (`max_bytes: 52428800`). - **Up to 10 files per order item.** More than 10 entries in `items[].files` is a schema violation and fails with `validation_failed`. - **One file per `upload_id`.** - `upload_id` must be a UUID — the exact value returned by `POST /v1/uploads`. ## Lifetime - The `upload_url` is valid for **2 hours** (`expires_in_seconds: 7200`). After that, request a new upload slot; the old `upload_id` is unusable. - An upload that is never referenced by an order expires after **14 days**. Nothing to clean up on your side, and there is no endpoint to delete an upload. - Once an order referencing the upload is accepted, the file is stored permanently with that order and is no longer subject to the 14-day expiry. ### Reusing uploads on retry Duplicate detection for orders is computed over the `upload_id` values, not over storage locations. That makes retries safe: - Resending the identical order body, including the same `upload_id` values, returns `200` with the existing order — never a duplicate. - Using **new** `upload_id` values with the same `external_order_id` counts as different content and returns `409` `order_already_exists_with_different_content`. - If an order is rejected (file problem or validation error), the uploads stay usable. Fix the request and resend it with the same `upload_id` values. ## Errors Both file errors are reported with HTTP `422` and the top-level code `validation_failed`; the specific code sits in `errors[]` together with the exact position in `field`, using bracket notation. ```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_type_not_allowed", "message": "The file for upload_id 3f0a9c1e-2b77-4c1a-9f1c-0d2a4e6b8c10 has an unsupported type (detected: video/mp4).", "field": "items[0].files[0].upload_id" } ] } } ``` **`file_not_found`** — the `upload_id` is unknown, the `PUT` in step 2 never succeeded, or the upload already expired after 14 days. Fix: run `POST /v1/uploads`, `PUT` the content to `upload_url`, verify the `PUT` returned 2xx, then reference the `upload_id`. Details: [/developers/errors/file_not_found](https://www.meykt.com/en/developers/errors/file_not_found). **`file_type_not_allowed`** — the detected content is not in the allowed list. The message names the detected type. Fix: export the file in one of the formats listed under [Allowed file types](#allowed-file-types). Renaming the extension changes nothing, because the content is what is checked. Details: [/developers/errors/file_type_not_allowed](https://www.meykt.com/en/developers/errors/file_type_not_allowed). > Both errors mean that **no part of the order was created**. Correct the file, then resend the complete order. Full list of codes: [/developers/errors](https://www.meykt.com/en/developers/errors). Order semantics, limits and warnings: [/developers/orders](https://www.meykt.com/en/developers/orders). --- # Webhooks Webhooks push order and production events from Meykt to an HTTPS endpoint you control, so you do not have to poll. Endpoints are managed through the API with an API key that carries the `webhooks:manage` scope, and every delivery is signed with an HMAC-SHA256 signature you must verify. Events cover only API orders in the live environment — never test orders, never orders that came from a connected shop. ## Register an endpoint `POST https://api.meykt.com/v1/webhook-endpoints` creates an endpoint. Requires the `webhooks:manage` scope. ```bash curl -X POST https://api.meykt.com/v1/webhook-endpoints \ -H "Authorization: Bearer $MEYKT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://erp.musterwerkstatt.example/meykt/webhook", "description": "Musterwerkstatt ERP" }' ``` ```json { "data": { "endpoint": { "id": "b7f1c2a4-9d3e-4c1b-8a52-0f6d9e4a7c31", "url": "https://erp.musterwerkstatt.example/meykt/webhook", "description": "Musterwerkstatt ERP", "is_active": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "last_failure_at": null, "created_at": "2026-08-07T09:12:44.118Z" }, "secret": "whsec_3kQpZ8vN2yLdR7fWbXs1TgCmHjA4uE0i" } } ``` Rules that apply at registration time: - **The `secret` is returned exactly once**, in this response. It cannot be retrieved later — not through the API, not through the dashboard. Store it in your configuration before you do anything else. If you lose it, delete the endpoint and register a new one. - **`https` only.** An `http://` URL is rejected with `422` [`webhook_url_invalid`](https://www.meykt.com/en/developers/errors/webhook_url_invalid). URLs with embedded credentials (`https://user:pass@…`) are rejected the same way. - **Private and reserved addresses are rejected** with `422` [`webhook_target_blocked`](https://www.meykt.com/en/developers/errors/webhook_target_blocked). The hostname is resolved when the endpoint is created and the resolved address is pinned; deliveries connect to that pinned address. - **Redirects are not followed.** A `301`/`302` response counts as a failed delivery. Point the endpoint at its final URL. - **At most 5 active endpoints per connection.** A sixth registration returns `422` `webhook_limit_reached`. Disable one first. ## Listing and disabling endpoints `GET https://api.meykt.com/v1/webhook-endpoints` returns your endpoints (up to 50, newest first). Secrets are never included. ```bash curl https://api.meykt.com/v1/webhook-endpoints \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` Each entry carries `id`, `url`, `description`, `is_active`, `disabled_reason`, `consecutive_failures`, `last_success_at`, `last_failure_at` and `created_at`. Use `consecutive_failures` and `disabled_reason` for health monitoring. `DELETE https://api.meykt.com/v1/webhook-endpoints/{id}` disables an endpoint permanently. It is a deactivation, not a row deletion: the delivery log stays readable. The response is `{"data": {"id": "...", "is_active": false}}`. An unknown or foreign id returns `404` [`not_found`](https://www.meykt.com/en/developers/errors/not_found) — never `403`. ## Event types | Event type | Sent when | Additional `data` fields | |---|---|---| | `order.received` | An API order was accepted and newly created (HTTP `201`) | — | | `order.production_started` | The production order moved to `in_progress` | `production_order_id`, `production_status` | | `order.production_completed` | The production order moved to `completed` | `production_order_id`, `production_status` | | `order.production_failed` | The production order moved to `failed` | `production_order_id`, `production_status` | | `order.cancelled` | The production order was cancelled | `production_order_id`; plus `production_status` when it came from a status transition | | `order_item.completed` | A production job for one order item was completed | `order_item_id`, `production_job_id`, `sku`, `name` | | `ping` | You called the test endpoint | `message` only — no order fields | Scope and limits of the event stream: - **There is no `shipped` event.** Meykt has no shipping workflow, so there is nothing to report. Do not build a flow that waits for one. Use `order.production_completed` (whole order) or `order_item.completed` (single item) as your "work is done" signal. - **Live API orders only.** Test orders (keys with the `mk_test_` prefix) and orders that arrived through a shop connection never produce events. - **`order.received` fires only for newly created orders.** A repeated `POST /v1/orders` with identical content answers `200` with the existing order and produces **no** event. That is intentional: the retry is a no-op, so there is nothing new to report. - **Status transitions only.** If a production order is set to a status it already has, no event is produced. - Event type names are a permanent contract. New types may be added; existing ones are never renamed or repurposed. Treat an unknown `type` as "ignore" rather than as an error. ## Payload Every delivery is a `POST` with `Content-Type: application/json` and this envelope: ```json { "id": "1f4d2a67-5b83-4e0c-9a11-8c7de2b45f90", "type": "order.production_completed", "created_at": "2026-08-07T11:04:02.517Z", "api_version": "v1", "data": { "order_id": "9c2b7d18-3e54-42a7-b6f0-5a1d8e93c204", "external_order_id": "ORDER-2026-1044", "production_order_id": "44a0f6c9-71b2-4d38-9e5c-2f8b0a7e1d36", "production_status": "completed" } } ``` - `id` — unique per event. Use it for deduplication (see [Delivery guarantees](https://www.meykt.com/en/developers/webhooks#delivery-guarantees)). It is also sent as the `X-Meykt-Event-Id` header. - `type` — one of the values in [Event types](https://www.meykt.com/en/developers/webhooks#event-types). Also sent as `X-Meykt-Event-Type`. - `created_at` — ISO 8601 UTC timestamp of when the event was recorded, not of the delivery attempt. - `api_version` — always `"v1"` for this API version. - `data` — contains `order_id` and `external_order_id` for every order event, plus the event-specific fields from the table. `ping` is the only type without order fields. The `data` object may gain new fields at any time. Ignore fields you do not know; never reject a payload because it contains more than you expected. Additional request headers on every delivery: `X-Meykt-Signature`, `X-Meykt-Event-Id`, `X-Meykt-Event-Type`, `User-Agent: Meykt-Webhooks/1.0`. ## Verifying the signature Every delivery carries a signature header: ```http X-Meykt-Signature: t=1786633442,v1=6b2f9c1e84a70d5f3e2c9b81a4d70f6e5c3b2a19d8e7f6c5b4a39281706f5e4d ``` - `t` — Unix timestamp in **seconds** at the moment of signing. - `v1` — lowercase hex HMAC-SHA256 over the string `"{t}.{raw request body}"`, keyed with your `whsec_…` secret. Three rules, all mandatory: 1. **Sign the raw body bytes.** Do not parse the JSON and re-serialize it — key order and whitespace would change and the signature would never match. Read the request body as a raw string or byte buffer before any JSON middleware touches it. 2. **Enforce a tolerance window.** Reject the request if `t` deviates from your own clock by more than ±5 minutes (300 seconds). This blocks replay of an old, valid payload. 3. **Compare in constant time.** Use `timingSafeEqual` / `hash_equals` / `hmac.compare_digest`, never `==` on the hex strings. Reject with `401` and process nothing if any of the three checks fails. ### Node.js ```js import { createHmac, timingSafeEqual } from 'node:crypto'; function verifyMeyktSignature(header, rawBody, secret, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const given = Buffer.from(parts.v1 ?? '', 'hex'); const want = Buffer.from(expected, 'hex'); return given.length === want.length && timingSafeEqual(given, want); } ``` In Express, mount `express.raw({ type: 'application/json' })` on the webhook route so `req.body` is the untouched buffer. ### PHP ```php function meykt_verify_signature( string $header, string $rawBody, string $secret, int $toleranceSeconds = 300 ): bool { $parts = []; foreach (explode(',', $header) as $piece) { $kv = explode('=', $piece, 2); if (count($kv) === 2) { $parts[trim($kv[0])] = trim($kv[1]); } } if (!isset($parts['t'], $parts['v1'])) return false; $t = (int) $parts['t']; if (abs(time() - $t) > $toleranceSeconds) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } // Usage $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_MEYKT_SIGNATURE'] ?? ''; if (!meykt_verify_signature($header, $rawBody, getenv('MEYKT_WEBHOOK_SECRET'))) { http_response_code(401); exit; } $event = json_decode($rawBody, true); ``` ### Python ```python import hashlib import hmac import time def verify_meykt_signature(header: str, raw_body: bytes, secret: str, tolerance_seconds: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) given = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance_seconds: return False expected = hmac.new( secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, given) ``` In Flask use `request.get_data()`; in Django use `request.body`. Both give you the raw bytes. ## Orders produced by a partner Some Meykt organizations do not produce every order themselves — they pass items to a partner workshop. When that happens, you still receive the same order-level events (`order.production_started`, `order.production_completed`, `order.production_failed`, `order.cancelled`) against your own order, and `updated_at` on your order changes so polling picks it up too. The event carries `"produced_by_partner": true` in its `data` object. One difference: `order_item.completed` is not emitted for partner-produced orders. Your item IDs do not exist in the partner's system, so an item-level event could not name an item you can look up. Use the order-level events instead. ## Delivery guarantees Delivery is **at least once**. An event can arrive more than once — after a network timeout on our side, after a retry, or after a manual redelivery. - **Deduplication is your responsibility.** Store the event `id` (or the `X-Meykt-Event-Id` header) and discard anything you have already processed. Do not rely on event order either: retries can make a later event arrive before an earlier one. - **Respond `2xx` within 10 seconds.** Anything else — `3xx`, `4xx`, `5xx`, a connection error, or a timeout — counts as a failed attempt. Acknowledge first, do the heavy work afterwards in your own queue. - **The response body is ignored.** Only the status code matters. An empty `200` is a perfectly good answer. - **Retry schedule after a failed attempt:** 1 min, 5 min, 15 min, then 1 h, 3 h, 6 h, 12 h, 24 h. After the last attempt the delivery is marked permanently failed and is not retried again. The full window spans roughly 48 hours. - **Automatic shutdown after 25 consecutive failures.** The endpoint is set to `is_active: false` with `disabled_reason: "too_many_failures"`. A successful delivery resets the counter to zero. Once disabled, the endpoint receives nothing until you register a new one — monitor `consecutive_failures` through `GET /v1/webhook-endpoints` or the dashboard card. ## Testing `POST https://api.meykt.com/v1/webhook-endpoints/{id}/test` queues a `ping` event to that one endpoint and wakes the delivery worker immediately. Use it to prove that your signature check and your server work — no real order required. ```bash curl -X POST https://api.meykt.com/v1/webhook-endpoints/b7f1c2a4-9d3e-4c1b-8a52-0f6d9e4a7c31/test \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` The API answers `202` with `{"data": {"queued": true}}`; the delivery itself arrives within seconds. The payload uses the normal envelope with `"type": "ping"` and a `data` object containing only a `message`. A disabled endpoint returns `422` [`validation_failed`](https://www.meykt.com/en/developers/errors/validation_failed); an unknown or foreign id returns `404` [`not_found`](https://www.meykt.com/en/developers/errors/not_found). ## Delivery log `GET https://api.meykt.com/v1/webhook-deliveries` returns the recent delivery attempts for your endpoints, newest first. | Parameter | Meaning | |---|---| | `endpoint_id` | Restrict to one endpoint. An id that is not yours returns `404` `not_found`. | | `limit` | 1–100, default 25. | ```bash curl "https://api.meykt.com/v1/webhook-deliveries?limit=10" \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` Each entry contains `id`, `endpoint_id`, `event_id`, `event_type`, `status`, `attempts`, `next_attempt_at`, `last_attempt_at`, `response_status`, `response_snippet`, `error_code` and `created_at`. The request body is not returned — you already know the contract; the log exists to explain *what happened to the delivery*. `response_snippet` holds the beginning of your server's response and is usually the fastest way to find a misconfigured receiver. The same information is available in the dashboard under Integrations → API access → Webhooks, together with a "Redeliver" action for individual deliveries. ## No public server? Webhooks require a publicly reachable HTTPS endpoint. If you do not have one — an on-premise ERP behind a firewall, a workshop PC, a scheduled script — polling is a fully supported path, not a workaround: ```bash curl "https://api.meykt.com/v1/orders?updated_since=2026-08-07T10:00:00Z&limit=100" \ -H "Authorization: Bearer $MEYKT_API_KEY" ``` Poll `GET /v1/orders` with `updated_since` on your own schedule and compare the returned status against what you last stored. Overlap the window slightly (for example, ask for the last 10 minutes every 5 minutes) so a clock skew cannot skip a change. Keep the rate limit of 120 requests per minute per key in mind: one list call with `updated_since` is always cheaper than one call per order. Details on paging and the `updated_since` parameter are on the [orders page](https://www.meykt.com/en/developers/orders). --- # 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/`, 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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`](https://www.meykt.com/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`.