# 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](/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](/en/developers/orders)
- [Attach files to order items](/en/developers/files)
- [Receive status events](/en/developers/webhooks)
- [Error reference](/en/developers/errors)
