API
Machine-readable versions: /agent.md (this page as Markdown) · openapi.json
Upload a CAD file, get G-code. Two HTTP calls, no UI, no configuration required.
NullCAM is a GPU-native CAM system for N×2.5D milling — pockets and holes approached from multiple discrete directions, cut with helix plunges and trochoidal paths. You send a solid (STEP, IGES) or a mesh (STL, OBJ, PLY, 3MF, GLB) and get back a zip containing the .nc program and a machining plan.
Base URL: https://api.nullcam.com
# 1. Create a job. Always 202, always this shape, plus `Location: /v1/jobs/{id}`.
JOB=$(curl -sS https://api.nullcam.com/v1/jobs \
-H "Authorization: Bearer $NULLCAM_TOKEN" \
-F "[email protected]" | jq -r .job_id)
# 2. Wait for the artifact. 303 -> a short-lived signed URL; -L follows it.
curl -L -o output.zip "https://api.nullcam.com/v1/jobs/$JOB/result?wait=90" \
-H "Authorization: Bearer $NULLCAM_TOKEN"That is the whole flow. If step 2 returns 202 the job is not finished — call it again. wait is clamped to 90 seconds; a longer wait is not more efficient, it just holds a connection.
Creating a job and fetching its artifact are two different operations on two different resources, which is why they are two calls. A single endpoint that did both had to answer six different response shapes across eleven status codes, because it was answering two questions at once.
The multipart body above is buffered in memory and capped at 20 MB. For anything large, presign instead — same job, the bytes just never transit the API:
# 1. Ask for a URL (valid 15 minutes).
read FILE_ID UPLOAD_URL < <(curl -sS https://api.nullcam.com/v1/files \
-H "Authorization: Bearer $NULLCAM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename":"part.step"}' | jq -r '.file_id + " " + .upload_url')
# 2. PUT the bytes straight to storage.
curl -sS -X PUT --upload-file part.step "$UPLOAD_URL"
# 3. Create the job from the file_id (JSON body this time).
curl -sS https://api.nullcam.com/v1/jobs \
-H "Authorization: Bearer $NULLCAM_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"file_id\":\"$FILE_ID\"}"Every call takes Authorization: Bearer nc_.... Create a token in the web console under Settings → API Token; it is shown exactly once. Tokens expire after 90 days — GET /v1/profile/api-token reports expires_at so you can rotate before that happens rather than discovering it mid-run.
A 401 with code INVALID_API_TOKEN is permanent: stop and re-authenticate. A 503 is transient: retry. These are deliberately different answers, because the API cannot always tell "this token is bad" from "I could not check right now", and reporting the second as the first would tell a client with a perfectly good token to give up.
settings is optional. Omit it and the server resolves your saved machine profile, or the canonical default if you have never saved one. The web client itself never sends settings — machine setup is server-authoritative.
So the minimum viable request really is a file and nothing else. Everything below is for when you want to override that.
These describe the part rather than the machine, and travel with a single request. Send them as JSON: in the options part of a multipart body, or as top-level fields of a JSON body.
| Field | Type | Meaning |
|---|---|---|
material_id | string | Material glob. aluminum_*, steel_*, stainless_*, delrin*, or * for automatic. Selects feeds and speeds from your machine's per-material table. |
units | string | Units of the uploaded file (mm, in, …). Meshes carry no units, so the same STL can load 25.4× or 1000× off. Absent = the worker guesses. Ignored for STEP/IGES, which are self-describing. |
stock_override | [x,y,z] | Explicit billet size in meters. Centred on the part and clamped so the part is always contained. |
stock_padding | [p] or [x,y,z] | Extra material per side, in meters. Default [0.005]. |
notify_email | bool | Email the result when it finishes. |
workflow | string | mill (default), or analyze for geometry analysis with no toolpaths. |
stock_override and stock_padding are mutually exclusive — setting one clears the other. Send neither and the stock is computed from the part's bounding box.
curl -sS https://api.nullcam.com/v1/jobs \
-H "Authorization: Bearer $NULLCAM_TOKEN" \
-F "[email protected]" \
-F 'options={"material_id":"steel_*","units":"mm","stock_padding":[0.01]}'To change the machine itself — tools, work volume, spindle limits, cutting strategy — send a full MillSettings as settings. Two ways:
PUT /v1/profile with the MillSettings body. Every later job uses it. This is what you want if all your parts run on one machine.settings field on POST /v1/jobs. Overrides the profile for that job only.Start from GET /v1/mill/default and edit it. Do not build a MillSettings from the proto definition. Submission validates that machine.max_velocity_cut is three positive finite numbers, and the proto default for that field is [0, 0, 0] — so a hand-built settings blob is rejected while one derived from /v1/mill/default is not. The same is true of machine.extents and machine.max_rpm.
curl -sS https://api.nullcam.com/v1/mill/default \
-H "Authorization: Bearer $NULLCAM_TOKEN" > mill.json
jq '.machine.max_rpm = 12000' mill.json > tuned.json
curl -sS -X PUT https://api.nullcam.com/v1/profile \
-H "Authorization: Bearer $NULLCAM_TOKEN" \
-H "Content-Type: application/json" --data-binary @tuned.jsonValidation refuses: zero tools, more than 100 tools, any tool radius that is not positive and finite, machine.extents that is not three positive finite numbers, and a non-positive machine.max_rpm.
A Haas-VF2-class 3-axis mill, and five carbide tools:
| Shape | Tool | Radius |
|---|---|---|
| flat | 1/8" 2-flute flat end mill | 0.0015875 m |
| flat | 1/4" 4-flute flat end mill | 0.003175 m |
| flat | 1/2" 4-flute flat end mill | 0.00635 m |
| cone | 3/8" 2-flute 90° chamfer mill | 0.0047625 m |
| cone | 1/4" 2-flute 142° spot drill | 0.003175 m |
extents [0.762, 0.406, 0.508] m, max_rpm 8100, max_velocity_cut [0.2822, 0.2822, 0.2822] m/s, max_acceleration [9.81, 9.81, 9.81] m/s² (1 g), default material aluminum_6061_t6.
Drills are not tools in the magazine. They are synthesized per hole from the bores found in your part, sized to the hole, up to drill_settings.max_drill.
POST /v1/tools/parse?format=fusion or ?format=mastercam with the raw library file as the body returns {"tools": [...]}. It does not persist anything — merge the result into a MillSettings.tools and PUT /v1/profile yourself. POST /v1/tools/format goes the other way.
These have no control anywhere in the console. They are reachable only through the API, which makes them the most useful part of this document.
| Field | Default | Meaning |
|---|---|---|
detail | unset → standard | Simulation resolution: 1 draft, 2 standard, 3 high. Higher costs compute and buys fidelity on small features. |
prune_aggression | 3 (standard) | How hard the planner drops unproductive motion: 1 off, 2 conservative, 3 standard, 4 aggressive. |
max_directions | 0 (uncapped) | Cap on the number of discrete tool approach directions. Lower is faster and less complete. |
plane_normals | empty | Explicit (n,3) list of approach directions, instead of letting the planner choose. |
default_radial_depth_of_cut | {relative: 0.12} | Stepover as a fraction of tool diameter. Floored at 0.01. |
default_radial_finish | {relative: 0.04, limits.min: 5.08e-05} | Wall material left for the finish pass, as a fraction of tool radius, floor 0.002". |
default_axial_finish | same | Floor material left for the finish pass. |
default_terrace | {relative: 1.0, limits.min: 5.08e-05} | Roughing Z step, as a fraction of tool radius. |
max_axial_depth_of_cut | 0 (no cap) | Absolute metres plus a fraction of flute length. |
finish_feed_fraction | 0.5 | Feed multiplier on finish passes. RPM is unchanged. |
feed_ramp | 0 (auto) | Ramp feed, mm/min. |
angle_helix | 0.05235988 | Helix plunge angle. Radians, despite what the proto comment says — this is 3°. |
skip_fixture_collisions | false | Skip collision checking against the vise and table. |
default_skip_ball | false | Skip ball-end finishing. |
enable_compensation | false | Emit G41 D# / G40 cutter compensation on finish contours. |
edge_break | 5.08e-05 | Chamfer width on sharp corners, metres. 0 skips the pass entirely. |
inwards_cut | 0 (all) | 0 all, 1 outside only, 2 top only, 3 disabled. |
drill_settings.max_drill | 0.00635 | Largest hole that gets drilled rather than milled. A radius — the UI label says diameter. |
drill_settings.peck_min_aspect | 0 (never peck) | Depth-to-diameter ratio above which pecking starts. 2–3 is typical. |
drill_settings.peck_depth_ratio | 0.3 when pecking | Peck depth as a fraction of drill diameter. |
drill_settings.default_spot_angle | 142.0 | Spot drill included angle, degrees. |
drill_settings.skip_spot_drill | false | Skip spotting. |
drill_settings.skip_countersink | false | Skip countersinking. |
drill_settings.skip_drill | false | Mill every hole instead of drilling. |
machine.tool_pad | 0.001 | Collision padding on tool radius and bottom, metres. |
machine.spindle_radius | 0.1 | Spindle body radius for collision checks, metres. |
machine.spindle_length | 1.0 | Spindle body length, metres. |
machine.spindle_pad | 0.01 | Collision padding on the spindle body, metres. |
machine.max_velocity_rapid | [1.0, 1.0, 0.5] | Rapid speeds, m/s. |
machine.max_travel | [1e9, 1e9, 10.0] | Per-axis travel limits, metres. Huge = unlimited. |
machine.junction_deviation | 2.54e-05 | Cornering tolerance, metres. |
machine.max_biarc_acceleration | 2.4525 | Acceleration limit in curved moves, m/s². |
machine.use_fast_inplane | true | G0 rather than G1 for in-plane repositioning. |
machine.use_variables | true | Emit speed variables in the G-code. |
Per-tool overrides also exist and are not fully exposed: speed_feed (mm/min), speed_rpm, stepover, stepover_finish (metres), flutes, and preferred_index.
Everything is MKS — metres, kilograms, seconds. Not millimetres, not inches. radius: 0.003175 is a 1/4" end mill.
angle_helix is in radians, even though the proto comment says degrees. The default 0.05235988 is 3°.drill_settings.max_drill is a radius. Every UI label and FAQ entry calls it a diameter. The default 0.00635 means "drill holes up to 1/2 inch across".RelativeQuantity's reference differs per field. {absolute, relative, limits} resolves to absolute + relative × reference, then clamps. The reference is tool radius for the finish allowances and default_terrace, tool diameter for radial_depth_of_cut, and flute length for max_axial_depth_of_cut.limits.min or limits.max of 0 means unbounded, not "a floor of zero".edge_break: 0 is the explicit off switch for the chamfer pass. There is no separate boolean.Every field is #[serde(default)], so anything you omit takes its proto default — 0, false, "", or []. That means omitting a field and sending it as 0 are the same request, which matters for the fields where 0 is a real setting (edge_break, max_directions, max_axial_depth_of_cut).
POST /v1/jobs:
| Code | Meaning |
|---|---|
| 202 | Created. Body has job_id; Location header has the poll URL. |
| 400 | Malformed body, or settings failed validation. The message names the field. |
| 401 | Bad or missing token. |
| 402 | Out of credits. |
| 413 | File over 20 MB — use the presign flow. |
| 429 | Too many jobs already running for this account. Wait for one to finish. |
| 503 | Queue unreachable. Retry in a few minutes. |
GET /v1/jobs/{id}/result:
| Code | Meaning |
|---|---|
| 303 | Ready. Follow the redirect to the artifact. |
| 202 | Not finished. Call again. |
| 404 | No such job, not yours, or it produced no artifact. Another account's job is a 404, never a 403 — the API will not confirm a job exists to someone who cannot read it. |
| 410 | Cancelled. |
| 422 | The job ran and failed. The body says why; retrying unchanged will not help. |
Errors are always {"error": "<human message>", "code": "<SCREAMING_CASE>"}. Branch on code; show error.
There is also GET /v1/jobs/{id}?wait=N for status without fetching the artifact (wait clamped to 20s), GET /v1/jobs to list, GET /v1/jobs/{id}/quality for the simulation report, and POST /v1/jobs/{id}/rerun to re-cut the same uploaded file with different settings — no re-upload.
You can run a part with no account at all: POST /v1/anon/session mints a session, then POST /v1/anon/upload takes the file. Polling uses the same GET /v1/jobs/{id} and GET /v1/jobs/{id}/result as everything else.
The session arrives as an httpOnly cookie, not a bearer token, so a script has to keep a cookie jar (curl -c jar -b jar). It lasts an hour.
It is deliberately limited, and knowing the limits is cheaper than discovering them:
material_id, units, stock_override, stock_padding, notify_email, workflow. It has no settings field at all, so sending one is a 400 naming the field. Anonymous jobs always run on the canonical default machine. Consistently, GET /v1/mill/default requires a login, so an anonymous caller cannot read the defaults it is not allowed to override.422 JOB_FAILED, with a message that says so rather than a generic error.ANON_ONE_AT_A_TIME), and a small per-session quota (ANON_QUOTA_EXHAUSTED).503 ANON_LOW_PRIORITY rather than queued behind everything.If you are writing an integration, get a token. The anonymous tier exists so a person can try the product without signing up, not as an API.
Accepted: STEP, IGES, STL, OBJ, PLY, 3MF, GLTF/GLB.
Solids (STEP, IGES) carry units and exact surfaces, and give better results. Meshes are approximations and carry no units — set units when you send one.
The 20 MB cap applies to the inline multipart path and to presigned uploads alike.