# Submit a print order

> POST a model, an address and print settings to /api/print/orders with your API key: X3D slices it, charges your card on file, and prints it.

Source: https://x3dstudios.com/docs/api/submit-order  ·  Updated: 2026-10-20

This endpoint takes a model file, a shipping address and a few print settings, and returns a paid order that is queued at the farm in Austin. One multipart POST does the whole thing: upload, analysis, quote, charge. There is no separate checkout step and no sandbox — a successful call charges the card on your account.

`POST /api/print/orders`

Create a print order from an uploaded model, and charge the saved card.

Auth: api-key

Authenticate with Authorization: Bearer x3d_live_… or with x-api-key. The body must be multipart/form-data — JSON is rejected with a 400. See /docs/api/authentication for how the header is read, and /docs/api/keys for how to mint the key.

## The model

Send exactly one of these. A file part wins if you send both.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | file | yes | The model, as a multipart file part. Required unless you send modelUrl. Accepted extensions: .stl, .glb, .gcode, .3mf (a .gcode.3mf is treated as 3mf). Anything else is a 415. Maximum 100 MB; an empty part is a 400. |
| `modelUrl` | string | no | Alternative to file: the gs:// URI of a model X3D already holds for your account. It must be a managed X3D storage URI and it must belong to the calling account — a URI you do not own returns 403. |

> **Sliced files are priced from the slice** — A .gcode or .gcode.3mf carries the filament grams and print time the slicer measured, including purge and tool changes. We bill those numbers rather than estimating from geometry, and the build-volume check is skipped because your slicer already fitted the plate. A .gcode.3mf whose plate uses five or more filaments also pays the $25 second-AMS fee, once per order, because the farm has to chain a second unit on for it. The count comes from the plate's own list of filaments it uses. A plain .gcode lists every filament its project defined, used or not, so it is priced as one colour.

## Print configuration

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `material` | string (pla \\| petg \\| abs \\| asa \\| tpu) | no, default `pla` | Case-insensitive. Sets the per-gram rate and the density used to turn volume into weight: PLA $0.12/g, PETG $0.12/g, ABS $0.12/g, ASA $0.14/g, TPU $0.25/g. Anything else is a 400. |
| `quality` | string (standard \\| premium) | no, default `standard` | Sets the price factor only: standard is 1x the per-gram rate, premium is 1.75x. It does not change the layer height on this endpoint — that comes from layerHeight, which stays at its own default of 0.2 mm whichever quality you send. Anything else is a 400. |
| `quantity` | integer | no, default `1` | Clamped to 1–50, never rejected: a 0 or a non-number becomes 1, and 200 becomes 50. Five or more units take 5% off, ten or more take 10%. |
| `color` | string (Black \\| White \\| Red \\| Cyan \\| Sakura Pink \\| Sunny Orange \\| Green \\| Yellow \\| Mint Green \\| Lavender \\| Silver \\| Brown) | no, default `Default` | A stock spool name or its hex, matched case-insensitively. Free text is accepted, but anything off the stock list is billed at the $0.20/g custom-colour floor because we have to buy the spool in. Omitting the field stores the literal "Default", which means our choice of a stock spool and prices at the material rate. |
| `infill` | integer | no, default `20` | Percent. Clamped to 10–100, never rejected. Ignored for an already-sliced file, which is recorded as 100 because the slice already decided. |
| `nozzleDiameter` | number (0.2 \\| 0.4 \\| 0.6 \\| 0.8) | no, default `0.4` | Millimetres. Any other value falls back to 0.4 silently — there is no error, so check what came back on the order if it matters. |
| `nozzleFlow` | string (standard \\| high) | no, default `standard` | high raises the modelled throughput by 1.6x, which shortens the estimated print time. Any value other than "high" is read as standard, silently. |
| `layerHeight` | number | no, default `0.2` | Millimetres. Clamped, never rejected, to 20–75% of the nozzle diameter — 0.08–0.30 mm on a 0.4 mm nozzle. Out-of-range values are pulled to the nearest end. |
| `note` | string | no | Free text for the operator: orientation, support preferences, anything a human should read before printing. Truncated to 1000 characters without warning. |

## Recipient and shipping address

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `recipientName` | string | yes | Name on the parcel. The alias name is accepted for the same field. |
| `street1` | string | yes | Street address. The alias address1 is accepted. |
| `street2` | string | no | Apartment, suite, unit. The alias address2 is accepted. Stored as null when empty. |
| `city` | string | yes | City. |
| `state` | string | yes | State or province. Not validated against a list. |
| `zip` | string | yes | Postal code. The alias postalCode is accepted. Not format-checked. |
| `country` | string (US \\| AE \\| AT \\| AU \\| BE \\| CA \\| CH \\| DE \\| DK \\| ES \\| FI \\| FR \\| GB \\| IE \\| IN \\| IT \\| JP \\| MX \\| NL \\| NO \\| NZ \\| PT \\| SE \\| SG) | no, default `US` | Uppercased before the check. Anywhere else returns 400. US postage is $7 flat standard, free at a $49.50 subtotal; everywhere else is $30 flat, free at $500 and standard service only. |
| `shippingSpeed` | string (standard \\| express) | no, default `standard` | standard is $7 and 4–6 business days in transit; express is $15 and 2–3. Express is US only and asking for it on a country other than US returns 400 rather than quietly shipping standard — this is the one order path that knows the destination before it prices. Any value other than standard or express is a 400 too. The service is echoed back in breakdown.shippingSpeed. It changes the postage only: the print itself is queued as Normal print, because Priority print is not sold through the API. |
| `email` | string | no, default `your account email` | One to five addresses that get the order emails: the confirmation, printed, shipped with tracking, and delivered. Repeat the field for each address, or separate them with commas or semicolons in one field. The first address is the main contact; every address gets every email. Lowercased, and repeats are dropped. Send the end customer's address if you are printing on their behalf. |
| `phone` | string | no | Optional, stored as-is for the carrier. Not validated. |

### Order emails to more than one person

Send email once for each person who should hear about the order, up to five in total. The first address is the main contact. All of them get the same emails at the same moments: the confirmation when the card is charged, then printed, shipped with the tracking number, and delivered. A field like email=ada@example.com, sam@example.com works the same as two separate fields. An address that is not valid, or more than five addresses, is a 400 that names the problem, and nothing is charged.

**curl**

```bash
curl -X POST https://x3dstudios.com/api/print/orders \
  -H "Authorization: Bearer $X3D_API_KEY" \
  -F "file=@bracket.stl" \
  -F "recipientName=Ada Lovelace" \
  -F "email=ada@example.com" \
  -F "email=sam@example.com" \
  -F "street1=1100 Congress Ave" \
  -F "city=Austin" -F "state=TX" -F "zip=78701" -F "country=US" \
  -F "material=pla" -F "color=Black"
```

**curl, one field**

```bash
curl -X POST https://x3dstudios.com/api/print/orders \
  -H "Authorization: Bearer $X3D_API_KEY" \
  -F "file=@bracket.stl" \
  -F "recipientName=Ada Lovelace" \
  -F "email=ada@example.com, sam@example.com" \
  -F "street1=1100 Congress Ave" \
  -F "city=Austin" -F "state=TX" -F "zip=78701" -F "country=US" \
  -F "material=pla" -F "color=Black"
```

**JavaScript**

```javascript
// form is the FormData from the request below.
form.append("email", "ada@example.com");
form.append("email", "sam@example.com");
```

**Python**

```python
# A list sends the field once per address.
data = {
    "recipientName": "Ada Lovelace",
    "email": ["ada@example.com", "sam@example.com"],
    # ...the rest of the fields from the request below
}
```

> **Missing fields are reported once, in a fixed order** — The check names every empty required field in one message, always in the order recipientName, street1, city, state, zip — for example "Missing required shipping fields: city, zip".

## Request

**curl**

```bash
curl -X POST https://x3dstudios.com/api/print/orders \
  -H "Authorization: Bearer $X3D_API_KEY" \
  -F "file=@bracket.stl" \
  -F "recipientName=Ada Lovelace" \
  -F "street1=1100 Congress Ave" \
  -F "city=Austin" \
  -F "state=TX" \
  -F "zip=78701" \
  -F "country=US" \
  -F "material=pla" \
  -F "color=Black" \
  -F "quality=standard" \
  -F "quantity=1" \
  -F "infill=20" \
  -F "note=Flat face down, no supports on the top surface."
```

**JavaScript**

```javascript
import { readFileSync } from "node:fs";

const form = new FormData();
form.set("file", new Blob([readFileSync("bracket.stl")]), "bracket.stl");
form.set("recipientName", "Ada Lovelace");
form.set("street1", "1100 Congress Ave");
form.set("city", "Austin");
form.set("state", "TX");
form.set("zip", "78701");
form.set("country", "US");
form.set("material", "pla");
form.set("color", "Black");
form.set("quality", "standard");
form.set("quantity", "1");

const res = await fetch("https://x3dstudios.com/api/print/orders", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.X3D_API_KEY}` },
  body: form,
});
const order = await res.json();
if (!res.ok) throw new Error(order.error);
console.log(order.code, order.status, order.charged);
```

**Python**

```python
import os
import requests

with open("bracket.stl", "rb") as fh:
    res = requests.post(
        "https://x3dstudios.com/api/print/orders",
        headers={"Authorization": f"Bearer {os.environ['X3D_API_KEY']}"},
        files={"file": ("bracket.stl", fh, "application/octet-stream")},
        data={
            "recipientName": "Ada Lovelace",
            "street1": "1100 Congress Ave",
            "city": "Austin",
            "state": "TX",
            "zip": "78701",
            "country": "US",
            "material": "pla",
            "color": "Black",
            "quality": "standard",
            "quantity": "1",
        },
        timeout=180,
    )

res.raise_for_status()
order = res.json()
print(order["code"], order["status"], order.get("charged"))
```

## Response

Every successful submit returns 201, whether or not the card went through. Read status and paid rather than the HTTP code to know which happened.

**201 — card charged, order queued**

```json
{
  "code": "X3D-K7M2QP",
  "estimatedCost": 9.52,
  "currency": "USD",
  "breakdown": { "print": 2.52, "shipping": 7, "shippingSpeed": "standard", "grams": 21, "printHours": 1.04 },
  "statusUrl": "https://x3dstudios.com/print/order/X3D-K7M2QP",
  "apiStatusUrl": "https://x3dstudios.com/api/print/orders/X3D-K7M2QP",
  "email": "ada@example.com",
  "notifyEmails": ["sam@example.com"],
  "status": "RECEIVED",
  "paid": true,
  "charged": 9.52,
  "paymentUrl": null,
  "message": "Order received and your card was charged $9.52. It's queued at the farm — track it at statusUrl."
}
```

**201 — charge failed, order held for payment**

```json
{
  "code": "X3D-K7M2QP",
  "estimatedCost": 9.52,
  "currency": "USD",
  "breakdown": { "print": 2.52, "shipping": 7, "shippingSpeed": "standard", "grams": 21, "printHours": 1.04 },
  "statusUrl": "https://x3dstudios.com/print/order/X3D-K7M2QP",
  "apiStatusUrl": "https://x3dstudios.com/api/print/orders/X3D-K7M2QP",
  "email": "ada@example.com",
  "notifyEmails": [],
  "status": "PENDING_PAYMENT",
  "paid": false,
  "chargeOutcome": "not-charged",
  "paymentUrl": "https://x3dstudios.com/print/order/X3D-K7M2QP/pay",
  "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_…",
  "message": "Auto-charge failed (No card on file) and nothing was taken. Update your card on file, or pay via paymentUrl."
}
```

**Response fields**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | no | The public order code: X3D- followed by six uppercase characters from an alphabet with no 0, 1, I, L or O. This is the only handle you get — store it. |
| `estimatedCost` | number | no | Print plus postage, plus the second-AMS fee when it applies, in USD, rounded to the cent. This is the amount charged. |
| `currency` | string | no | Always "USD". |
| `breakdown.print` | number | no | The print subtotal before postage: weight x rate x quality factor x quantity, with the bulk discount and the $1.00-per-part minimum applied. |
| `breakdown.shipping` | number | no | Postage. 7 or 0 on domestic standard, always 15 on domestic express, 30 or 0 internationally. |
| `breakdown.shippingSpeed` | string | no | Which service that postage bought — "standard" or "express". Echoed back because the amount alone cannot say: $15 is express, but it is also two standard parcels. |
| `breakdown.grams` | number | no | Billable weight, rounded up to the whole gram. |
| `breakdown.printHours` | number | no | Estimated machine hours. Informational — the only thing that adds cost on top of the per-gram rate is an already-sliced file whose measured time far exceeds what its weight implies. |
| `breakdown.secondAmsFee` | number | no | Present only when it is charged: 25, for a sliced .gcode.3mf whose plate uses five or more filaments. Once per order, whatever the quantity, and included in estimatedCost. |
| `breakdown.colours` | number | no | Present only with secondAmsFee: how many filaments the plate uses. |
| `statusUrl` | string | no | Human-readable order page. It needs no login, so it is safe to hand to your own customer. |
| `apiStatusUrl` | string | no | The URL to poll with your key. See /docs/api/order-status. |
| `email` | string | no | The order's main contact: the first address you sent, or your account email when you sent none. |
| `notifyEmails` | string[] | no | The other addresses that get every order email, in the order you sent them. An empty array when there is only the main contact. |
| `status` | string (RECEIVED \\| PENDING_PAYMENT) | no | RECEIVED when the card was charged, PENDING_PAYMENT when it was not or the charge could not be confirmed. |
| `paid` | boolean | no | Whether money has actually been taken. |
| `charged` | number | no | Present only on the paid branch: the amount taken from the card. |
| `paymentUrl` | string \| null | no | X3D payment page for an unpaid order. null when the order is already paid, and null if Stripe returned no link. |
| `checkoutUrl` | string \| null | no | Raw Stripe Checkout URL. Present only on the unpaid branch; absent entirely when the card was charged. |
| `chargeOutcome` | string (not-charged \\| unknown) | no | Present only on the unpaid branch. not-charged means the card was provably not charged: a decline, or no card on file. unknown means the charge may have gone through and only its confirmation was lost; read the order back before anyone pays again. |
| `message` | string | no | One sentence you can log or show. On the unpaid branch it quotes the decline reason verbatim, and when chargeOutcome is unknown it says the charge may have gone through. |

## Errors

Every failure is a JSON object with a single error key. Validation stops at the first problem, in this order: file, email addresses, shipping fields, country, shipping speed, material, quality, file extension, storage, analysis, minimum total.

| Status | error | Cause |
| --- | --- | --- |
| 401 | Invalid or missing API key. Generate one on your X3D profile. | No credential, or one we do not recognise. |
| 400 | Expected multipart/form-data with a 'file' field. | The body did not parse as multipart. Usually a JSON body. |
| 400 | The file is empty. | A file part of zero bytes. |
| 413 | File too large (max 100MB). | The file part is over 100 MB. |
| 400 | No file provided. | Neither a file part nor modelUrl. |
| 400 | modelUrl must be a model stored by X3D. | modelUrl is not a managed X3D storage URI. |
| 403 | That model does not belong to this account. | The modelUrl belongs to another account. |
| 404 | Could not read that model. | The stored object could not be fetched. |
| 400 | "…" is not an email address. | One of the addresses in email is not a valid address. The message quotes it. |
| 400 | An order takes up to 5 email addresses; 6 were given. | More than five different addresses across all email fields. |
| 400 | Missing required shipping fields: … | One or more of recipientName, street1, city, state, zip is empty. |
| 400 | We don't currently ship to XX. | The country is not US and not on the international list. |
| 400 | Invalid shippingSpeed. One of: standard, express. | shippingSpeed is set to anything else. |
| 400 | Express shipping is US only — it is a domestic carrier commitment. Omit shippingSpeed to ship standard to XX. | shippingSpeed=express on an order whose country is not US. |
| 400 | Invalid material. One of: pla, petg, abs, asa, tpu | Unrecognised material. |
| 400 | Invalid quality. One of: standard, premium | Unrecognised quality. |
| 415 | Unsupported file. Send an STL or GLB model, or an already-sliced .gcode / .gcode.3mf. | The filename extension is not one we print. |
| 502 | Could not store the file. Try again. | The upload to storage failed. Retry. |
| 422 | We couldn't read a printable mesh from that file. … | The model parsed but is not printable. The message names the specific problem. |
| 422 | This model is 400×80×20mm — larger than the 340×320×340mm build volume. … | A mesh that does not fit the plate. Scale it down or split it. |
| 400 | Order total is below the $0.50 minimum. | Stripe cannot take a charge under 50 cents. In practice unreachable domestically, where postage alone is $7. |

*Exact status codes and error strings returned by POST /api/print/orders.*

> **The order exists before the charge** — The row is written at PENDING_PAYMENT, then the card is charged. If the charge fails you have a real order that can still be paid — do not retry the submit, or you will create a second one. Retry only on a 5xx, where no code came back.

- [Order status](https://x3dstudios.com/docs/api/order-status) — Poll one order by its code, and read the status values.
- [Quote a model](https://x3dstudios.com/docs/api/quote) — Price a part before you commit to printing it.
- [Manage API keys](https://x3dstudios.com/docs/api/keys) — Mint, rotate and revoke the key this endpoint uses.
- [Errors](https://x3dstudios.com/docs/api/errors) — How X3D reports failures across the API.

## Frequently asked

**What happens if the card on file is declined?**

You still get 201 and an order code, but with status PENDING_PAYMENT and paid: false. The response carries checkoutUrl (a Stripe Checkout link) and paymentUrl (the X3D payment page for that order), and chargeOutcome says whether the card was provably not charged ("not-charged") or the charge could not be confirmed ("unknown"). Nothing reaches the farm until the order is paid.

**Can I order a model X3D already stores for me?**

Yes. Send modelUrl instead of file, with the gs:// URI of a model generated on your own account. We check ownership server-side and refuse a URI that belongs to somebody else, so you never have to download and re-upload a file we already hold.

**Can more than one person get the order emails?**

Yes. The email field takes up to five addresses. Repeat the field, or put them in one field separated by commas. The first address is the main contact, and every address gets the confirmation, the printed and shipped emails with tracking, and the delivered email.

**Do I have to slice the file first?**

No. Send an STL or GLB and we slice it. If you send an already-sliced .gcode or .gcode.3mf, we price it from the filament grams and print time recorded in the file, and the infill field is ignored because the slice already fixed it.

**Can I pay for Priority print through the API?**

Not yet. Priority print is a website checkout option. There is no field for it on this endpoint, and every API order is queued as Normal print. shippingSpeed=express buys faster postage, not a faster print.
