API

Machine-readable versions: /agent.md (this page as Markdown) · openapi.json

NullCAM API

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

The two calls

# 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.

Large files

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\"}"

Authentication

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.

You do not have to configure anything

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.

Per-part options

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.

FieldTypeMeaning
material_idstringMaterial glob. aluminum_*, steel_*, stainless_*, delrin*, or * for automatic. Selects feeds and speeds from your machine's per-material table.
unitsstringUnits 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_emailboolEmail the result when it finishes.
workflowstringmill (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]}'

Overriding the machine

To change the machine itself — tools, work volume, spindle limits, cutting strategy — send a full MillSettings as settings. Two ways:

  • Once, persistently: 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.
  • Per job: the 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.json

Validation 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.

The default machine

A Haas-VF2-class 3-axis mill, and five carbide tools:

ShapeToolRadius
flat1/8" 2-flute flat end mill0.0015875 m
flat1/4" 4-flute flat end mill0.003175 m
flat1/2" 4-flute flat end mill0.00635 m
cone3/8" 2-flute 90° chamfer mill0.0047625 m
cone1/4" 2-flute 142° spot drill0.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.

Importing a tool library

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.

Settings the web UI does not expose

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.

FieldDefaultMeaning
detailunset → standardSimulation resolution: 1 draft, 2 standard, 3 high. Higher costs compute and buys fidelity on small features.
prune_aggression3 (standard)How hard the planner drops unproductive motion: 1 off, 2 conservative, 3 standard, 4 aggressive.
max_directions0 (uncapped)Cap on the number of discrete tool approach directions. Lower is faster and less complete.
plane_normalsemptyExplicit (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_finishsameFloor 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_cut0 (no cap)Absolute metres plus a fraction of flute length.
finish_feed_fraction0.5Feed multiplier on finish passes. RPM is unchanged.
feed_ramp0 (auto)Ramp feed, mm/min.
angle_helix0.05235988Helix plunge angle. Radians, despite what the proto comment says — this is 3°.
skip_fixture_collisionsfalseSkip collision checking against the vise and table.
default_skip_ballfalseSkip ball-end finishing.
enable_compensationfalseEmit G41 D# / G40 cutter compensation on finish contours.
edge_break5.08e-05Chamfer width on sharp corners, metres. 0 skips the pass entirely.
inwards_cut0 (all)0 all, 1 outside only, 2 top only, 3 disabled.
drill_settings.max_drill0.00635Largest hole that gets drilled rather than milled. A radius — the UI label says diameter.
drill_settings.peck_min_aspect0 (never peck)Depth-to-diameter ratio above which pecking starts. 2–3 is typical.
drill_settings.peck_depth_ratio0.3 when peckingPeck depth as a fraction of drill diameter.
drill_settings.default_spot_angle142.0Spot drill included angle, degrees.
drill_settings.skip_spot_drillfalseSkip spotting.
drill_settings.skip_countersinkfalseSkip countersinking.
drill_settings.skip_drillfalseMill every hole instead of drilling.
machine.tool_pad0.001Collision padding on tool radius and bottom, metres.
machine.spindle_radius0.1Spindle body radius for collision checks, metres.
machine.spindle_length1.0Spindle body length, metres.
machine.spindle_pad0.01Collision 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_deviation2.54e-05Cornering tolerance, metres.
machine.max_biarc_acceleration2.4525Acceleration limit in curved moves, m/s².
machine.use_fast_inplanetrueG0 rather than G1 for in-plane repositioning.
machine.use_variablestrueEmit 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.

Units, and five things that will bite you

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".
  • A 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).

Status codes

POST /v1/jobs:

CodeMeaning
202Created. Body has job_id; Location header has the poll URL.
400Malformed body, or settings failed validation. The message names the field.
401Bad or missing token.
402Out of credits.
413File over 20 MB — use the presign flow.
429Too many jobs already running for this account. Wait for one to finish.
503Queue unreachable. Retry in a few minutes.

GET /v1/jobs/{id}/result:

CodeMeaning
303Ready. Follow the redirect to the artifact.
202Not finished. Call again.
404No 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.
410Cancelled.
422The 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.

The anonymous tier

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:

  • No machine configuration. The anonymous upload route accepts per-part options only — 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.
  • 10 seconds of compute, against 90 for an account. A part that needs more comes back 422 JOB_FAILED, with a message that says so rather than a generic error.
  • One job at a time (ANON_ONE_AT_A_TIME), and a small per-session quota (ANON_QUOTA_EXHAUSTED).
  • Lowest queue priority. When there is no slack the upload is refused outright with 503 ANON_LOW_PRIORITY rather than queued behind everything.
  • Per-IP rate limiting, and a stricter file filter: plain uncompressed STEP, STL or IGES only. Archives are rejected without being opened.
  • Everything else — listing jobs, the quality report, rerun, profiles, tokens, credits — requires a login.

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.

Files

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.