# Files

> Upload STL, OBJ and 3MF in two calls and one PUT; what complete verifies; rejection codes; what we keep, for how long and how to delete.

Updated 2026-10-24 · HTML: https://layercrew.com/developers/files · Markdown: https://layercrew.com/developers/files.md · OpenAPI: https://layercrew.com/openapi.json

A file is the 3D model an order item points at. You upload it once — two calls and one PUT — and reuse it across quotes and orders. Files belong to one environment and are visible to you alone.

## Formats

| Format | Priced | Notes |
| --- | --- | --- |
| STL | automatically | Binary or ASCII. Units are millimetres. |
| OBJ | automatically | Geometry only; materials and textures are ignored. |
| 3MF | automatically | Printable objects are read with the settings the file was saved with — material, nozzle, layer height, infill, supports. Embedded slicer scripts are stripped and `facts.sanitised` says so. |
| STEP, STP | by an engineer | Send them through [contact us](https://layercrew.com/contact-us) and we come back with a quote. `POST /v1/files` answers `422` for a `.step` or `.stp` filename and says the same. |

## Limits

- **Size** — your plan's `max_file_bytes`: 64 MiB on the default plan; it can differ per plan, and `GET /v1/account` names yours.
- **Geometry** — up to four million triangles for STL and OBJ; a 3MF is read within the same caps the public [calculator](https://layercrew.com/calculator) uses. Beyond them `complete` answers `rejected` with `too_complex`: simplify the mesh, or send it through [contact us](https://layercrew.com/contact-us).
- **Count** — your plan's `max_files`, when set.

## Upload in three steps

1. **Declare.** `POST /v1/files` with `filename` (with its extension), `size_bytes` and the lowercase hex `sha256` of the exact bytes. The answer is `201` with the file in `pending` and a one-time `upload` block: a `url`, the `headers` to send and an `expires_at`.
2. **PUT the bytes** to `upload.url` with exactly the headers in `upload.headers`. **Both are mandatory** — `Content-Type` and `x-goog-content-length-range` — because the signed URL covers them; a PUT missing either is refused by the storage service. The body is the raw file, never multipart.
3. **Complete.** `POST /v1/files/{id}/complete`. We check the object's size against `size_bytes` and its hash against `sha256`, read the model, sanitise a 3MF and write the canonical copy. `status` becomes `ready` and `file.ready` is emitted.

_Declare, PUT, complete — with the hash and size computed from the file_

```bash
FILE=bracket-v3.stl
SIZE=$(stat -c %s "$FILE")                 # macOS: stat -f %z "$FILE"
HASH=$(sha256sum "$FILE" | cut -d' ' -f1)   # macOS: shasum -a 256 "$FILE" | cut -d' ' -f1

# 1. declare
DECLARED=$(curl -s -X POST "https://engine.layercrew.com/v1/files" \
  -H "Authorization: Bearer $LAYERCREW_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\":\"$FILE\",\"size_bytes\":$SIZE,\"sha256\":\"$HASH\"}")
FILE_ID=$(echo "$DECLARED" | jq -r .data.id)
UPLOAD_URL=$(echo "$DECLARED" | jq -r .data.upload.url)
RANGE=$(echo "$DECLARED" | jq -r '.data.upload.headers["x-goog-content-length-range"]')

# 2. PUT the raw bytes with both signed headers
curl -s -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/octet-stream" \
  -H "x-goog-content-length-range: $RANGE" \
  --data-binary @"$FILE"

# 3. complete
curl -s -X POST "https://engine.layercrew.com/v1/files/$FILE_ID/complete" \
  -H "Authorization: Bearer $LAYERCREW_API_KEY" | jq .data.status
```

```node
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const base = "https://engine.layercrew.com/v1";
const headers = { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}` };
const bytes = await readFile("bracket-v3.stl");

// 1. declare
const declared = await fetch(`${base}/files`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    filename: "bracket-v3.stl",
    size_bytes: bytes.byteLength,
    sha256: createHash("sha256").update(bytes).digest("hex"),
  }),
}).then((r) => r.json());
const { id, upload } = declared.data;

// 2. PUT the raw bytes with exactly the signed headers
const put = await fetch(upload.url, { method: "PUT", headers: upload.headers, body: bytes });
if (!put.ok) throw new Error(`upload failed: ${put.status}`);

// 3. complete
const file = await fetch(`${base}/files/${id}/complete`, { method: "POST", headers }).then((r) => r.json());
console.log(file.data.status); // "ready", or "rejected" with rejection_code
```

```python
import hashlib, os, requests

BASE = "https://engine.layercrew.com/v1"
AUTH = {"Authorization": f"Bearer {os.environ['LAYERCREW_API_KEY']}"}
data = open("bracket-v3.stl", "rb").read()

# 1. declare
declared = requests.post(f"{BASE}/files", headers=AUTH, timeout=30, json={
    "filename": "bracket-v3.stl",
    "size_bytes": len(data),
    "sha256": hashlib.sha256(data).hexdigest(),
}).json()["data"]

# 2. PUT the raw bytes with exactly the signed headers
put = requests.put(declared["upload"]["url"], headers=declared["upload"]["headers"], data=data, timeout=300)
put.raise_for_status()

# 3. complete
file = requests.post(f"{BASE}/files/{declared['id']}/complete", headers=AUTH, timeout=60).json()["data"]
print(file["status"])  # "ready", or "rejected" with rejection_code
```

`complete` is idempotent: calling it on a `ready` file returns the file. While another completion of the same file is running it answers `409`; wait a moment and call again. The upload slot expires after `expires_at`; declare the file again for a fresh one.

## Rejections

When verification fails the file is `rejected`, `complete` answers `422 FILE_REJECTED`, and `rejection_code` on the file says why:

| Code | What happened | Next step |
| --- | --- | --- |
| `size_mismatch` | The object's size differs from `size_bytes`. | Declare the exact size of the bytes you send, then upload again. |
| `hash_mismatch` | The object's SHA-256 differs from `sha256`. | Hash the exact bytes you upload (lowercase hex), then upload again. |
| `upload_missing` | No object arrived at the signed URL, or it expired. | Declare the file again and PUT within `expires_at`. |
| `not_a_model` | The bytes hold no readable geometry for the declared extension. | Export the part again from your CAD or slicer as STL, OBJ or 3MF. |
| `too_complex` | More geometry than we price automatically. | Simplify the mesh, or send the file through [contact us](https://layercrew.com/contact-us). |
| `too_large` | Over your plan's `max_file_bytes`. | Reduce the mesh, split the part, or ask us about a larger limit. |

A `.step` or `.stp` filename is refused at `POST /v1/files` (`422`, field `filename`): STEP files are priced by an engineer — send them through [contact us](https://layercrew.com/contact-us); STL, OBJ and 3MF are priced automatically here.

## What we read from the file

`facts` carries what the parser saw: `triangles`, `objects`, the bounding box in millimetres (`bbox_mm`), the `producer` of a 3MF and whether it was `sanitised`. The geometry itself stays in our systems and is read by the slicer, by the engineer who reviews your order and by the printer that makes it.

## What we keep, for how long, and how to delete

Your files are stored in the EU, encrypted at rest, under an object name no signed URL can target once the canonical copy is written. The retention rules are the plan's; the default plan's values are below, and `GET /v1/account` names yours under `limits`.

| What | How long |
| --- | --- |
| A test file no order uses | Deleted after `test_file_retention_days`; `purge_after` on the file says when. Set to null the first time an order uses it. |
| A live file no order uses | Kept until you delete it, within your plan's `max_files` when set. |
| A file used by an order | Kept while any order needs it. Once its last order reaches a final state (`delivered`, `cancelled`, `rejected`), deleted after `file_retention_days_after_last_order`. |
| The production copy on an accepted order | Deleted together with the file. The order keeps the filename, the `facts` and the price, so your history stays complete. |
| Your customer's personal data on an order | Redacted after `recipient_retention_days` — 90 days on the default plan — or at once with `DELETE /v1/orders/{id}/recipient` on a finished order. See [Orders](https://layercrew.com/developers/orders#delivery). |

To delete a file yourself, call `DELETE /v1/files/{id}`:

- No order uses it — deleted now, `204`.
- Used only by finished orders — scheduled, deleted within the hour, `202`; `file.purged` follows.
- An open order, a pending quote or a running price job still needs it — `409 FILE_IN_USE`, and `error.details` names it. Delete once that has finished.

Deleting a file removes its bytes and every production copy; `purged_at` is set and `file.purged` is emitted. The runbook that erases a partner account runs the same for every file. Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-files).
