API quickstart
Get an X3D API key, submit a print order with one multipart POST, poll it until it ships, and read the tracking number back out.
This page goes from nothing to a paid, queued print order. It takes five minutes and one HTTP call. You need an X3D account, a card on it, and a model file — an STL is the easiest thing to test with.
Five steps to a printed part#
- 1Put a card on the account
Sign in and open /profile. In the Developer API key panel, press Add a card on file — Stripe collects the details, we never see them. This is a prerequisite, not a formality: the key cannot be issued without it, because API orders bill automatically with no checkout page in between.
- 2Generate the key
Same panel, press Generate key. The secret is shown once and never again — we store only a SHA-256 hash of it and a display prefix like x3d_live_ab12cd…. Copy it into an environment variable on your server before you close the tab.
bashexport X3D_API_KEY="x3d_live_paste_your_key_here" - 3Submit the order
One multipart/form-data POST carries the file, the address and the print settings. Required: a file (or a modelUrl of a model we already hold for you), recipientName, street1, city, state and zip. Everything else has a default. Order emails go to your account email unless you send email, which takes up to five addresses: repeat the field for each person who should hear about the order.
bashcurl -X POST https://x3dstudios.com/api/print/orders \ -H "Authorization: Bearer $X3D_API_KEY" \ -F "[email protected]" \ -F "recipientName=Ada Lovelace" \ -F "street1=1100 Congress Ave" \ -F "street2=Suite 200" \ -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." - 4Read the 201
A successful submit returns 201 with paid: true, the amount charged, and the order code. That code is the only handle you get — store it. It is X3D- followed by six uppercase characters.
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": "[email protected]", "notifyEmails": [], "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." } - 5Poll the status
Read the order back with the same key. Poll on a human timescale — a print takes hours, so once every few minutes is plenty, and hourly is fine once it is on a plate.
bashcurl https://x3dstudios.com/api/print/orders/X3D-K7M2QP \ -H "Authorization: Bearer $X3D_API_KEY" - 6Collect the tracking number
tracking stays null until the parcel is handed over. When status becomes SHIPPED it holds the number and the carrier — that is the point at which you can tell your own customer it is on the way.
json{ "code": "X3D-K7M2QP", "status": "SHIPPED", "statusLabel": "Shipped", "paid": true, "fileName": "bracket.stl", "material": "pla", "color": "Black", "quality": "standard", "quantity": 1, "estimatedCost": 9.52, "currency": "USD", "tracking": { "number": "1Z999AA10123456784", "carrier": "ups" }, "shipTo": { "name": "Ada Lovelace", "city": "Austin", "state": "TX", "country": "US" }, "email": "[email protected]", "notifyEmails": [], "createdAt": "2026-09-09T15:02:11.417Z", "updatedAt": "2026-09-11T18:40:02.930Z" }
The same submit in three languages#
All three do exactly what the curl above does. In JavaScript and Python, do not set Content-Type yourself — the HTTP client has to write the multipart boundary, and overriding the header is the most common reason a first call comes back as 400 Expected multipart/form-data with a 'file' field.
curl -X POST https://x3dstudios.com/api/print/orders \
-H "Authorization: Bearer $X3D_API_KEY" \
-F "[email protected]" \
-F "recipientName=Ada Lovelace" \
-F "street1=1100 Congress Ave" \
-F "city=Austin" \
-F "state=TX" \
-F "zip=78701" \
-F "material=pla" \
-F "color=Black" \
-F "quantity=1"The fields this call used#
/api/print/ordersAPI keySubmit a print order. multipart/form-data. Charges the card on file.
filefilerequired- The model. STL, GLB, .gcode or .gcode.3mf, up to 100MB. Optional only if you send modelUrl instead — a gs:// URI of a model X3D already stores for your account, which saves you downloading and re-uploading something we generated.
recipientNamestringrequired- Who the parcel is addressed to. The alias name is accepted for the same field.
street1stringrequired- First address line. The alias address1 is accepted.
citystringrequired- City.
statestringrequired- State or region. Not validated against a list.
zipstringrequired- Postal code. The alias postalCode is accepted.
countrystringoptionaldefaultUS- Uppercased before checking. Anything outside this list is a 400 that names the country back to you.
USAEATAUBECACHDEDKESFIFRGBIEINITJPMXNLNONZPTSESG materialstringoptionaldefaultpla- Lowercased. An unrecognised value is a 400, not a silent fallback.
plapetgabsasatpu qualitystringoptionaldefaultstandard- The price factor: standard is 1x the rate, premium is 1.75x. It does not set the layer height on this endpoint; layerHeight does, and it defaults to 0.2mm whichever quality you send.
standardpremium quantityintegeroptionaldefault1- Clamped to 1–50 rather than rejected. 5 or more takes 5% off, 10 or more takes 10%.
colorstringoptionaldefaultDefault- A stock spool name — Black, White, Red, Cyan, Sakura Pink, Sunny Orange, Green, Yellow, Mint Green, Lavender, Silver or Brown. Anything else prices at the $0.20/g custom-colour floor. Left out, it is stored as Default: our choice of a stock spool, at the material rate.
infillintegeroptionaldefault20- Percent, clamped to 10–100. Ignored for an already-sliced file, which is stored as 100 because the slicer already decided.
notestringoptional- A message to the operator. Silently truncated at 1000 characters.
Nozzle diameter, flow mode and layer height are accepted too. They change how the part is printed and the printHours in the breakdown, but not what it costs — the price is weight × rate × quality factor. Those, and the exact response shape for every branch, are on /docs/api/submit-order.
When the charge fails#
A declined or missing card does not lose the order. You still get 201, the order is stored at PENDING_PAYMENT, and the body carries a way to pay it: paymentUrl is an X3D page for the order, checkoutUrl is the hosted Stripe page. Nothing reaches the farm until one of them is paid. Read chargeOutcome first: "not-charged" means nothing was taken, while "unknown" means the charge may have gone through with its confirmation lost, so read the order back before anyone pays again.
{
"code": "X3D-P4T8WN",
"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-P4T8WN",
"apiStatusUrl": "https://x3dstudios.com/api/print/orders/X3D-P4T8WN",
"email": "[email protected]",
"notifyEmails": [],
"status": "PENDING_PAYMENT",
"paid": false,
"chargeOutcome": "not-charged",
"paymentUrl": "https://x3dstudios.com/print/order/X3D-P4T8WN/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."
}Reading the order back#
/api/print/orders/{code}API keyStatus, price and tracking for one order. Only the key that owns it can read it.
The code is matched exactly, so pass it back as it was given, in uppercase. A code that does not exist and a code belonging to someone else both return the same 404 Order not found, on purpose. shipTo comes back deliberately trimmed to name, city, state and country: the street, postcode and phone are not echoed. email and notifyEmails list who gets the order emails.
First calls that fail#
| Status | Message | Usually means |
|---|---|---|
| 401 | Invalid or missing API key. Generate one on your X3D profile. | No Authorization header, or the key was rotated since you copied it. |
| 400 | Expected multipart/form-data with a 'file' field. | You sent JSON, or overrode Content-Type and broke the boundary. |
| 400 | Missing required shipping fields: recipientName, city | Exactly what it says, in the order recipientName, street1, city, state, zip. |
| 415 | Unsupported file. Send an STL or GLB model, or an already-sliced .gcode / .gcode.3mf. | An OBJ, a STEP, or a file whose name lost its extension. |
| 422 | We couldn't read a printable mesh from that file. Make sure it's a valid, watertight STL/GLB. | The mesh is broken, empty, or not really the format its name claims. |
| 413 | File too large (max 100MB). | Decimate the mesh, or slice it yourself and send the G-code. |
Questions
How much does the smallest possible test order cost?
About $8 in the US. Printing has a $1.00 minimum per part, and US postage is $7 flat below a $49.50 subtotal. A one-gram part and an eight-gram part therefore cost the same: 8g of PLA at $0.12/g is $0.96, still under the minimum, and only at about 9g does the per-gram rate start to bite.
Do I have to slice the file first?
No. Send an STL or GLB and we slice it. If you would rather control the print exactly, send a .gcode or .gcode.3mf you exported from Bambu Studio or OrcaSlicer — we price it from the filament grams and print time in the file and print it as exported.
Why did my order come back with status PENDING_PAYMENT?
The order was created but the card on file could not be charged. The response message says why, and carries checkoutUrl and paymentUrl so the order can still be paid. Nothing goes to the farm until it is.