Working with the API
Uploads
Files go straight to object storage through presigned URLs; the API only sees their metadata. A simple upload takes one PUT; large files use a resumable multipart upload.
Two ways to upload#
| Simple upload | Resumable upload | |
|---|---|---|
| Endpoint | POST /v1/files | POST /v1/files/resumable |
| Size per file | Up to 10 MiB | Above 10 MiB, up to your plan's limit (at most 1 GiB) |
| Body | filename, size_bytes, sha256 | filename, size_bytes, sha256, input_format |
| Transfer | One presigned PUT | One presigned PUT per part (8 MiB parts), resumable |
| Completion | Synchronous: ready or rejected | Always asynchronous: verifying, then poll |
| Who | Every account | Approved accounts |
Every account can upload files of up to 10 MiB, 100 MiB in total, with the simple upload. Once the Printy3D team has approved your account, you may upload larger files with the resumable upload, up to your plan's per-file limit (200 MB on the free plan, 2 GB on the developer and team plans, and never above 1 GiB) and within your plan's storage. Jobs run on those files without any further grant. Before approval the resumable upload answers 501 feature_unavailable.
Simple upload#
/v1/filesfiles:write- Reserve the file with
filename, its exactsize_bytesand the hexsha256of its bytes, and anIdempotency-Key. The answer is the File (awaiting_upload) and anupload:{url, method: "PUT", headers, expires_at}. - PUT exactly those bytes to
upload.urlwithupload.headersunchanged. The URL signs the length and a create-onlyIf-None-Match: *, so it accepts the declared file once; it expires after 15 minutes. Do not send your API key to it. - Complete it with
POST /v1/files/{id}/complete. The API checks the size, the digest and the container and answers the File asreadyorrejected. SendPrefer: respond-asyncto get202 verifyingat once and poll instead.
The quickstart shows the whole flow in curl, Python and TypeScript. If the PUT failed or the URL expired, replay the reserve request with the same Idempotency-Key (within 24 hours): you get the same file with a fresh URL.
Resumable upload#
/v1/files/resumablefiles:writeThe body adds input_format, which must match the file name's extension (zip for scan.zip). The answer's upload is an S3 multipart upload:
protocol: "s3_multipart",method: "PUT",part_size_bytes,expires_at(1 hour) andparts: one{part_number, offset, size_bytes, url}for each part the store does not hold yet.- PUT exactly
size_bytesbytes, read fromoffsetof your file, to each part'surl, in any order or in parallel. Each URL signs its length and refuses any other. Part URLs are credentials: never log them. - To resume (after a failure, a restart or before the URLs expire), replay the same request with the same
Idempotency-Key. The answer lists fresh URLs for the parts still missing; parts already sent are kept. Starting the multipart upload is limited to three times per file, failed attempts included; resuming does not count. - When
partscomes back empty, complete the file. Completion assembles the parts (409while any is missing) and always queues verification:202with statusverifying. PollGET /v1/files/{id}untilreadyorrejected. After completion a replay of the reserve request answers409.
The samples use the api, storage and ok helpers from the quickstart.
import os, uuid, hashlib, time
import httpx
path = "scan.zip"
size = os.path.getsize(path)
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
digest.update(chunk)
request = {
"url": "/v1/files/resumable",
# Keep this key: replaying the same request with it resumes the upload.
"headers": {"Idempotency-Key": str(uuid.uuid4())},
"json": {"filename": "scan.zip", "input_format": "zip",
"size_bytes": size, "sha256": digest.hexdigest()},
}
reserved = ok(api.post(**request))
file_id = reserved["file"]["id"]
for _ in range(3):
parts = reserved["upload"]["parts"] # only the parts still missing
if not parts:
break
with open(path, "rb") as f:
for part in parts: # any order, or in parallel
f.seek(part["offset"])
body = f.read(part["size_bytes"])
try:
storage.put(part["url"], content=body) # never log part URLs
except httpx.TransportError:
pass # listed again by the next replay
reserved = ok(api.post(**request)) # resume: fresh URLs for missing parts
file = ok(api.post(f"/v1/files/{file_id}/complete")) # 202, status "verifying"
while file["status"] == "verifying":
time.sleep(2)
file = ok(api.get(f"/v1/files/{file_id}"))
print(file["status"], file.get("rejected_reason"))File states#
| status | Meaning |
|---|---|
awaiting_upload | Reserved; the bytes are not completed yet. |
verifying | Completion is queued; the API is checking the bytes. |
ready | Verified; jobs can use it. |
rejected | Failed verification; rejected_reason says why (for example obj_materials_unsupported). |
A File also reports its input_format and its kind: mesh, splat, pointcloud, medical, cad or archive. A PLY is classified by its header: zero faces with Gaussian-splat columns is a splat, zero faces without them a pointcloud. List your files with GET /v1/files.
File names#
filename is 5 to 200 characters with no /, \, : or control characters, and its extension names the format (Formats). An unknown extension, or .7z, is refused.
Retention#
Files and job outputs are kept for your plan's retention period (30 days on every plan today). After that the API answers 410 expired for the file, for new jobs on it and for downloads of its outputs, and leaves it out of listings.
Errors you may see#
| Status | code | When |
|---|---|---|
| 413 | file_too_large | Larger than the upload lane or your plan allows. |
| 422 | validation_failed | Bad file name or extension, size or digest format. |
| 422 | idempotency_key_reused | The same Idempotency-Key with a different body. |
| 409 | conflict | Completing a multipart upload with parts missing, or replaying one that is complete. |
| 429 | quota_exceeded | Your account's input storage is full (with Retry-After). |
| 501 | feature_unavailable | The resumable upload before your account is approved. |