# 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](/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](/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](/en/developers/errors). Order semantics, limits and warnings: [/developers/orders](/en/developers/orders).
