Skip to main content
← CutOptim Engine API

API reference

Base URL https://api.cutoptim.com · contract version v1

Create a key on your dashboard, then send it as Authorization: Bearer <key>.

OpenAPI specification: /engine/openapi.json

Endpoints

POST/v1/optimize/2d2D panel optimization
POST/v1/optimize/1d1D / linear optimization (bars, profiles, tube)
POST/v1/optimize/woodtimber optimization — 1D with cross-section matching
POST/v1/optimize/nesttrue-shape nesting — irregular polygons on fixed sheets, with exclusion zones (laser / plasma / waterjet)
POST/v1/validate/2dvalidate a request without solving — free, no key, no quota (2d / 1d / wood / nest)
POST/v1/validate/1dvalidate a request without solving — free, no key, no quota (2d / 1d / wood / nest)
POST/v1/validate/woodvalidate a request without solving — free, no key, no quota (2d / 1d / wood / nest)
POST/v1/validate/nestvalidate a request without solving — free, no key, no quota (2d / 1d / wood / nest)
GET/v1/jobs/{id}poll an async max-engine job — returns its status and, once finished, the plan (no quota; your own jobs only)
POST/v1/import/nestread part outlines out of an SVG or DXF file — needs a key, spends no quota
GET/v1/usagethe ACCOUNT's current-month usage and quota (all keys share one)
GET/v1/healthliveness — no key, no rate limit, no database

POST /v1/validate/{2d,1d,wood,nest} — response

validbooleanAlways true on a 200 — a malformed body is a 400 with the exact bad field path instead. The body is the SAME one the matching optimize endpoint takes.
modestring"2d" | "1d" | "wood" | "nest" — the endpoint you called.
contractVersionstringShape version. Currently "1".
engineEnabledbooleanNEST ONLY — whether the nesting engine is built into this deployment. Absent for the rectangular modes.
partsobject{ rows, total } — part rows sent, and the total quantity after qty expansion. Check it against the part cap before you spend a call.
stockobject{ rows, total } — the same for stock.
warningsstring[]Approximate, SOLVE-FREE feasibility notes — e.g. a part that fits no stock (2D and nest: bounding box; wood: no matching section long enough). Empty ⇒ every part fits something. Ignores trim, kerf and material: a shape check, not a solve.

Units

The API is unit-agnostic. Pick one unit — millimetres, inches, anything — use it for every number you send, and every number you get back is in that same unit. Nothing is converted server-side, and no field name asserts a unit.

That covers part and stock dimensions, kerf, tolerance, trim and minOffcut on the way in, and every coordinate, position, remaining length, offcut and cut length on the way out. Mixing units inside one request produces a plan that validates and is physically wrong, and the server cannot detect it.

Coordinate system

The origin is the top-left corner of the sheet: x grows to the right along the sheet width, y grows downward along the sheet height. A part’s x and y are its top-left corner, and its w and h are the dimensions as placed — already swapped when rotated is true — so the rectangle x, y, w, h is the footprint on the board with no further arithmetic. Offcut rectangles use the same system.

Trim moves placements: trim.left shifts every part right and trim.top shifts every part down, because parts are nested inside the usable area and then offset back onto the full sheet. trim.right and trim.bottom shrink the usable area without moving the origin. A sheet’s w and h are always the full stock dimensions, trim included — which is also why trim counts as waste in yieldPct.

2D — request

POST /v1/optimize/2d
Authorization: Bearer co_live_…
Content-Type: application/json

{
  "parts": [{ "name": "Door", "w": 600, "h": 400, "qty": 4, "rotatable": true }],
  "stock": [{ "w": 2440, "h": 1220, "qty": 10, "price": 42 }],
  "options": {
    "kerf": 3,
    "trim": { "left": 10, "top": 10 },
    "minimizeCost": true,
    "effort": "balanced"
  },
  "engine": "heuristic",
  "include": ["cutPlan", "offcuts"]
}

Part fields

w, hrequired — part dimensions
qtydefault 1 — expanded server-side; counts toward the 2,000 cap
nameoptional label, echoed on every placement
rotatabledefault true — may the part be turned 90°
grainGroupmembers of a group are kept on one sheet (grain matching)
prioritymust-cut: wins board space when stock is capped (with respectStock)
edgeBandingper-edge banding: name a type reference on any of top / right / bottom / left (a free string, your own code) — the response totals the metres by reference. Metadata only, it never moves a part. 2D only
materialmaterial tag (a free string, your own code): parts and stock of the same material are packed only together. Unlike edgeBanding it changes the layout. Absent = one unspecified pool. Works on every mode

Stock fields

w and h are required. qty defaults to 1 and is a hard cap only with respectStock. price is per sheet and drives totalPrice and cost mode. priority (boolean) uses this stock up first; material (a free string) restricts it to parts of the same material.

Options

kerfblade width (default 0)
toleranceaccept cuts that overshoot by up to this much
trimper-side edge trim: left, right, top, bottom
firstCut'auto' | 'horizontal' | 'vertical' (default 'auto')
minimizeCostrank candidates by lowest total stock price; with several priced stock sizes it mixes them (2D) or picks the cheapest length (1D) to minimise the bill, even if that uses more material
respectStocktreat each stock row's qty as a hard cap
minOffcutonly report offcuts whose short side is at least this
maxCutStagespanel-saw stage limit — a phase count, not raw tree depth
minimizeRotationsprefer layouts that turn fewer parts
effort'fast' | 'balanced' (default 'balanced'). Search depth: 'balanced' runs the full multi-strategy best-of; 'fast' skips the one expensive per-board area-combination search — markedly quicker on large jobs for a few points of yield, still guillotine-valid and never denser than 'balanced'. Small jobs are usually identical. Heuristic engine only

include does two things. NARROWING — "cutPlan" and "offcuts" are on by default; a present array keeps only the narrowing tokens it lists (an empty array drops both). ADDITIVE EXPORT — "svg", "csv" and "dxf" each add that export to the response as a STRING: svg a self-contained 2D layout drawing (2D only — a 1d/wood request returns a warning instead), dxf an R12/AC1009 drawing on layers STOCK/PARTS/LABELS, csv a cut list. Export tokens do not affect narrowing, so include:["svg"] adds svg and — naming no narrowing token — drops cutPlan/offcuts; use ["cutPlan","offcuts","svg"] to keep everything and add svg.

parts[].meta · stock[].meta — passthrough (2d · 1d · wood · nest)

parts[].metaobjectOpaque JSON of your own — an ERP article number, an order-line id, a customer ref. Echoed VERBATIM on every placed piece of that part. The optimizer never reads it, so it can never change a layout.
stock[].metaobjectThe same on a stock row: echoed on every sheet / rod / nested sheet cut from it, so the plan reconciles with your system without a lookup table.

The effort option trades solve time against yield. Here is that trade, measured on one demanding job — every figure comes from the real packer.

Effort switch: material yield vs solve timeEffort switch: material yield vs solve time. fast: 350 boards · 76.2% · ≈1.9 s. balanced: 330 boards · 80.8% · ≈4.8 s. max: reserved — denser needs a slower search. On most (smaller) jobs the two are identical; the gap only opens on large jobs like this one. balanced is the default and is never denser than fast can reach.Effort switch: material yield vs solve timeOne demanding job — about 1,550 parts on a 2.07 × 5.6 m board. Every figure measured on the real packer.74%76%78%80%82%84%02 s4 s6 ssolve time · faster →material yield · denser ↑⇄ the effort switch+4.6 pts yield · −20 boards−5.7% material · ≈2.5× slowerfast350 boards · 76.2% · ≈1.9 s★ balanced · default330 boards · 80.8% · ≈4.8 smaxreserveddenser needs aslower search
On most (smaller) jobs the two are identical; the gap only opens on large jobs like this one. balanced is the default and is never denser than fast can reach.

And it is fast either way: even the largest production jobs — 2,000 parts and more — solve in single-digit seconds on the default engine, comfortably inside the API time budget.

2D — response

{
  "engine": "heuristic",
  "engineVersion": "1.0.0+10e0c941",
  "contractVersion": "1",
  "deterministic": true,
  "sheets": [
    {
      "w": 2440,
      "h": 1220,
      "price": 42,
      "parts": [
        { "name": "Door", "x": 10, "y": 10, "w": 600, "h": 400, "rotated": false },
        { "name": "Door", "x": 613, "y": 10, "w": 600, "h": 400, "rotated": false },
        { "name": "Door", "x": 1216, "y": 10, "w": 600, "h": 400, "rotated": false },
        { "name": "Door", "x": 1819, "y": 10, "w": 600, "h": 400, "rotated": false }
      ],
      "offcuts": [
        { "x": 2422, "y": 10, "w": 18, "h": 400 },
        { "x": 10, "y": 413, "w": 2430, "h": 807 }
      ]
    }
  ],
  "metrics": {
    "sheetCount": 1,
    "yieldPct": 32.25,
    "placed": 4,
    "total": 4,
    "cutLines": 5,
    "sawPasses": 5,
    "cutLength": 4030,
    "totalPrice": 42
  },
  "unplaced": [],
  "warnings": [],
  "guillotineValid": true,
  "timing": { "solveMs": 3.13 },
  "cutPlan": [
    { "sheet": 0, "step": 1, "axis": "h", "pos": 410, "length": 2430, "stage": 1 },
    { "sheet": 0, "step": 2, "axis": "v", "pos": 610, "length": 400, "stage": 2 },
    { "sheet": 0, "step": 3, "axis": "v", "pos": 1213, "length": 400, "stage": 2 },
    { "sheet": 0, "step": 4, "axis": "v", "pos": 1816, "length": 400, "stage": 2 },
    { "sheet": 0, "step": 5, "axis": "v", "pos": 2419, "length": 400, "stage": 2 }
  ]
}
  • cutLines vs sawPasses — cutLines merges collinear cuts (one fence setting); sawPasses counts every pass. Two honest measures of the same plan, not a claim to match any competitor’s count.
  • guillotineValid / cutPlan — When a layout cannot be cut edge-to-edge, guillotineValid is false and cutPlan is null. That is real information — it cannot be made on a panel saw — not an error.
  • unplaced + warnings — An unsatisfiable job returns 200 with the pieces listed in unplaced and a note in warnings. A plan you can act on beats a status code.
  • edgeBanding — When any part carries edgeBanding, the response adds an edgeBanding block: the linear metres each type reference consumes, per part and as an order total. It is exact geometry with no waste margin — the workshop adds its own — and it assumes millimetre input (÷1000 for metres). The key is absent entirely for an unbanded job.
  • materials / unmatchedMaterials — When any part or stock carries material, the response adds materials (a per-material rollup — material, sheetCount/rodCount, yieldPct, placed, total, totalPrice) and unmatchedMaterials (demand whose material has no matching stock). On wood the material rides per cross-section section instead. Both keys are absent for a material-free job, which stays byte-identical.

Response fields

Field names and types are the contract, so the tables below stay in English in every language — a translated field name would document an API that does not exist.

POST /v1/optimize/2d — top level

enginestringWhich engine ran: "heuristic", "balanced" or "max".
engineVersionstringAlgorithm identity — package version + a content hash of the algorithm source. Moves automatically on any packer change, and independently per engine.
contractVersionstringShape version, matching the /v1/ in the path. Currently "1".
deterministicbooleanAlways true. Present so a client can assert the guarantee it relies on.
sheetsarrayOne entry per sheet used, in cutting order.
metricsobjectAggregate numbers for the whole job.
cutPlanarray | nullThe sawing plan. null when guillotineValid is false; absent when excluded via include.
unplacedarrayParts that did not fit, aggregated by name + size. Empty when everything fitted.
warningsstring[]Free-text notes about the plan. Do not parse — branch on unplaced, guillotineValid and metrics.
guillotineValidbooleanTrue when the layout is producible with edge-to-edge cuts, i.e. on a panel saw.
edgeBandingobjectLinear metres of edge banding, grouped by type reference. ABSENT unless a part requested banding via parts[].edgeBanding. 2D only.
materialsarrayPer-material rollup. ABSENT unless a part or stock row carried material — a material-free job stays byte-identical. (OPEN-256)
unmatchedMaterialsarrayDemand whose material has no matching stock at all. ABSENT when it does not happen. A missing-material report, not a did-not-fit one.
svgstringInline SVG of the 2D layout (self-contained, no external refs). Present ONLY when include contains "svg". 2D only. (OPEN-223)
csvstringInline CSV cut list. Present ONLY when include contains "csv".
dxfstringInline DXF (R12/AC1009) on layers STOCK/PARTS/LABELS. Present ONLY when include contains "dxf".
timing.solveMsnumberMilliseconds inside the packer, 2 decimals. Excludes parsing, auth and queueing.

sheets[]

w, hnumberFULL stock dimensions, trim included — not the usable area.
pricenumber | nullPrice of the stock row this sheet came from, or null if none was given.
partsarrayPlacements on this sheet.
offcutsarrayUsable leftover rectangles, filtered by options.minOffcut. Absent (not empty) when excluded via include.
metaobjectPresent only when the stock row carried meta — echoed verbatim from stock[].meta. (OPEN-224)

sheets[].parts[]

namestringThe requested name, or the generated default "Part <row>".
x, ynumberTop-left corner of the part, from the top-left corner of the sheet.
w, hnumberDimensions AS PLACED — already swapped when rotated is true. No client-side swap needed.
rotatedbooleanTrue if the part was turned 90° from the requested w×h. Informational only.
metaobjectPresent only when the part carried meta — echoed verbatim from parts[].meta on every placed piece. (OPEN-224)

sheets[].offcuts[]

x, y, w, hnumberA leftover rectangle, in the same coordinate system as the placements.

metrics (2D)

sheetCountintegerSheets used. Equals sheets.length.
yieldPctnumberPlaced part area ÷ total FULL sheet area × 100, 2 decimals. Trim and kerf count as waste.
placedintegerPieces placed, after qty expansion.
totalintegerPieces requested, after qty expansion.
cutLinesintegerCollinear cuts merged: same axis, same coordinate, same stage counted once — one fence setting.
sawPassesintegerEvery cut, one per strip crossed — how many separate passes the saw makes.
cutLengthnumberTotal distance sawn, 3 decimals. Same under either counting convention. In your unit.
totalPricenumberSum of the used sheets’ prices, 2 decimals. 0 when no stock row carried a price.

unplaced[] (2D)

namestringThe part name.
w, hnumberAs requested.
qtyintegerHow many of this part could not be placed.

edgeBanding (2D — present only when a part is banded)

totalMetersnumberOrder-wide total across every banded edge, 3 decimals. ⚠️ Assumes mm input: top/bottom edges run the part width, left/right the height, ×qty, ÷1000. In another unit it is your raw edge length ÷ 1000.
byType[]{ reference, meters }Order total split by the caller-supplied type reference, sorted by reference.
byPart[]{ name, meters, byType }One entry per requested part row that has any banded edge; its byType splits that part’s metres by reference.

cutPlan[]

sheetinteger0-based index into sheets (2D) or rods (1D). Named sheet in both.
stepinteger1-based order within THIS sheet — it restarts at 1 on every sheet.
axis"h" | "v""h": blade travels along x, separating top from bottom. "v": along y, separating left from right.
posnumberThe blade’s LOW-COORDINATE edge — the y value for "h", the x value for "v" — not its centre line. The kerf occupies pos to pos + kerf.
lengthnumberDistance the blade travels on this cut: the extent of the region crossed. Always 0 in 1D.
stageinteger1-based machine pass. Increments only when the axis flips relative to the parent cut.

2D · edgeBanding

When any part carries edgeBanding, the response adds an edgeBanding block: the linear metres each type reference consumes, per part and as an order total. It is exact geometry with no waste margin — the workshop adds its own — and it assumes millimetre input (÷1000 for metres). The key is absent entirely for an unbanded job.

POST /v1/optimize/2d

{
  "parts": [
    {
      "name": "Door",
      "w": 600,
      "h": 400,
      "qty": 2,
      "edgeBanding": {
        "top": "ABS oak 22",
        "bottom": "ABS oak 22",
        "left": "ABS white 22",
        "right": "ABS white 22"
      }
    },
    { "name": "Shelf", "w": 800, "h": 300, "edgeBanding": { "top": "ABS oak 22" } }
  ],
  "stock": [{ "w": 2440, "h": 1220, "qty": 10, "price": 42 }],
  "options": { "kerf": 3 }
}
{
  "edgeBanding": {
    "totalMeters": 4.8,
    "byType": [
      { "reference": "ABS oak 22", "meters": 3.2 },
      { "reference": "ABS white 22", "meters": 1.6 }
    ],
    "byPart": [
      {
        "name": "Door",
        "meters": 4,
        "byType": [
          { "reference": "ABS oak 22", "meters": 2.4 },
          { "reference": "ABS white 22", "meters": 1.6 }
        ]
      },
      {
        "name": "Shelf",
        "meters": 0.8,
        "byType": [{ "reference": "ABS oak 22", "meters": 0.8 }]
      }
    ]
  }
}

material

When any part or stock carries material, the response adds materials (a per-material rollup — material, sheetCount/rodCount, yieldPct, placed, total, totalPrice) and unmatchedMaterials (demand whose material has no matching stock). On wood the material rides per cross-section section instead. Both keys are absent for a material-free job, which stays byte-identical.

POST /v1/optimize/2d

{
  "parts": [
    { "name": "Door", "w": 600, "h": 400, "qty": 4, "material": "MDF 18" },
    { "name": "Shelf", "w": 800, "h": 300, "qty": 6, "material": "Oak 18" },
    { "name": "Back panel", "w": 1000, "h": 500, "qty": 2, "material": "Ply 6" }
  ],
  "stock": [
    { "w": 2440, "h": 1220, "qty": 10, "price": 42, "material": "MDF 18" },
    { "w": 2440, "h": 1220, "qty": 10, "price": 68, "material": "Oak 18", "priority": true }
  ],
  "options": { "kerf": 3 },
  "engine": "heuristic"
}
{
  "materials": [
    {
      "material": "MDF 18",
      "sheetCount": 1,
      "yieldPct": 32.25,
      "placed": 4,
      "total": 4,
      "totalPrice": 42
    },
    {
      "material": "Oak 18",
      "sheetCount": 1,
      "yieldPct": 48.37,
      "placed": 6,
      "total": 6,
      "totalPrice": 68
    }
  ],
  "unmatchedMaterials": [
    {
      "material": "Ply 6",
      "parts": [{ "name": "Back panel", "w": 1000, "h": 500, "qty": 2 }]
    }
  ]
}

materials[] — per-material rollup (2d · 1d · nest)

materialstringThe tag exactly as you sent it. Free text, matched exactly.
sheetCount | rodCountintegerStock consumed for this material — sheetCount on 2D and nest, rodCount on 1D.
yieldPct | densitynumberThis material’s own fill — yieldPct on the rectangular modes, density on nest (a polygon fill, not comparable to yieldPct).
placed, totalintegerPieces placed and requested for this material, after qty expansion.
totalPricenumberSum of the prices of the stock used for this material.

unmatchedMaterials[] — demand with no matching stock

materialstringThe tag that has no stock of its own anywhere in the request.
partsarrayThe demand rows in that material, in the mode’s unplaced shape: name, w, h, qty on 2D; name, length, qty on 1D; name, qty on nest.

cutPlan — what one step means physically

A step is one blade movement, and the list is in the order you can actually saw: a parent cut before the cuts inside the piece it produced, because you cannot crosscut a strip before you have ripped it off. axis "h" means the blade travels along x and separates top from bottom; axis "v" means it travels along y and separates left from right. pos is the blade’s LOW-COORDINATE edge — the y value for "h", the x value for "v" — not its centre line: the kerf occupies pos to pos + kerf, so the blade eats in the direction the coordinate grows, which is downward for "h" and to the right for "v". The material on the low side of the line — above it for "h", to its left for "v" — is the piece that cut frees. length is how far the blade travels on that one cut: the extent of the region it crosses, not the width of the whole board.

stage is a machine pass. It starts at 1 and increments only when the axis flips relative to the parent cut, so ripping a board into six strips is one stage and crosscutting them is the next. That is the panel-saw sense of “three-stage cutting”, not the depth of the cut tree, and it is what maxCutStages constrains. sheet is a 0-based index into sheets, and step restarts at 1 on every sheet instead of running across the whole job.

cutPlan is null — not missing, not empty — whenever guillotineValid is false: a layout that cannot be cut edge to edge has no cut sequence to return. It is absent from the payload altogether if you left it out of include.

1D — linear

POST /v1/optimize/1d

{
  "parts": [{ "name": "Rail", "length": 1200, "qty": 6 }],
  "stock": [{ "length": 3000, "qty": 5, "price": 12.5 }],
  "options": { "kerf": 3, "trim": { "start": 10, "end": 0 } }
}

Parts take length (plus qty, name, priority); stock takes length, qty and price. Both parts and stock also accept an optional material tag (stock also priority) — material restricts a part to stock of the same material, and the response then adds materials and unmatchedMaterials as in 2D. Options are kerf, tolerance, trim.start / trim.end, minimizeCost, respectStock and minOffcut. The response returns rods instead of sheets, each with its parts, remaining length and offcuts.

1D — response

{
  "engine": "heuristic",
  "engineVersion": "1.0.0+10e0c941",
  "contractVersion": "1",
  "deterministic": true,
  "rods": [
    {
      "length": 3000,
      "price": 12.5,
      "remaining": 584,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [584]
    },
    {
      "length": 3000,
      "price": 12.5,
      "remaining": 584,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [584]
    },
    {
      "length": 3000,
      "price": 12.5,
      "remaining": 584,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [584]
    }
  ],
  "metrics": {
    "rodCount": 3,
    "yieldPct": 80,
    "placed": 6,
    "total": 6,
    "cuts": 6,
    "totalPrice": 37.5,
    "toleranceAcceptedCount": 0
  },
  "unplaced": [],
  "warnings": [],
  "timing": { "solveMs": 2.19 },
  "cutPlan": [
    { "sheet": 0, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
    { "sheet": 0, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 },
    { "sheet": 1, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
    { "sheet": 1, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 },
    { "sheet": 2, "step": 1, "axis": "v", "pos": 1210, "length": 0, "stage": 1 },
    { "sheet": 2, "step": 2, "axis": "v", "pos": 2413, "length": 0, "stage": 1 }
  ]
}

rods replaces sheets and there is no guillotineValid, because a linear cut is always producible. Each part’s pos is the offset of its near end from the bar end that trim.start trims, so the first part starts exactly at trim.start and each following pos adds one kerf. remaining is the USABLE off-cut: the kerf of the cut that frees it from the last piece is already deducted, so it is the reclaimable length, not the raw gap. It is reported on every rod even when it is below minOffcut — minOffcut only filters the offcuts array, which holds at most one entry. In the cut plan, sheet is the rod index, axis is always "v", stage is always 1 and length is always 0: a bar crosscut has no travel distance to report, which is also why the 1D metrics carry cuts but no cutLength.

Wood — cross-section

POST /v1/optimize/wood

{
  "parts": [
    { "name": "Rafter", "sw": 50, "sh": 100, "length": 2400, "qty": 2 },
    { "name": "Noggin", "sw": 50, "sh": 100, "length": 600, "qty": 4 },
    { "name": "Beam", "sw": 50, "sh": 150, "length": 3000, "qty": 2 }
  ],
  "stock": [
    { "name": "C24 50x100", "sw": 50, "sh": 100, "length": 4000, "qty": 5, "price": 12.5 },
    { "name": "C24 50x150", "sw": 50, "sh": 150, "length": 4000, "qty": 3, "price": 18 }
  ],
  "options": { "kerf": 3 }
}

Timber has an identity a plain bar does not: a 50×150 part cannot come out of 50×100 stock, however much length is left. Parts and stock therefore carry sw and sh, the two cross-section sides, in either order — 50×100 and 100×50 are the same bar turned over and are matched as one section. The job is split by cross-section, each section is matched to its own stock and solved on its own, and one call returns the whole thing. Parts and stock also accept an optional material tag (stock also priority): with it, an oak 50×100 and a pine 50×100 become two separate sections, and each section carries its material. Options are the same as 1D.

Wood — response

{
  "engine": "heuristic",
  "engineVersion": "1.0.0+7c1f3a62",
  "contractVersion": "1",
  "deterministic": true,
  "sections": [
    {
      "section": "50x100",
      "sw": 50,
      "sh": 100,
      "stockName": "C24 50x100",
      "rods": [
        {
          "length": 4000,
          "price": 12.5,
          "remaining": 391,
          "parts": [
            { "name": "Rafter", "pos": 0, "length": 2400 },
            { "name": "Noggin", "pos": 2403, "length": 600 },
            { "name": "Noggin", "pos": 3006, "length": 600 }
          ],
          "offcuts": [391]
        },
        {
          "length": 4000,
          "price": 12.5,
          "remaining": 391,
          "parts": [
            { "name": "Rafter", "pos": 0, "length": 2400 },
            { "name": "Noggin", "pos": 2403, "length": 600 },
            { "name": "Noggin", "pos": 3006, "length": 600 }
          ],
          "offcuts": [391]
        }
      ],
      "metrics": {
        "rodCount": 2,
        "yieldPct": 90,
        "placed": 6,
        "total": 6,
        "cuts": 6,
        "totalPrice": 25
      },
      "unplaced": []
    },
    {
      "section": "50x150",
      "sw": 50,
      "sh": 150,
      "stockName": "C24 50x150",
      "rods": [
        {
          "length": 4000,
          "price": 18,
          "remaining": 997,
          "parts": [{ "name": "Beam", "pos": 0, "length": 3000 }],
          "offcuts": [997]
        },
        {
          "length": 4000,
          "price": 18,
          "remaining": 997,
          "parts": [{ "name": "Beam", "pos": 0, "length": 3000 }],
          "offcuts": [997]
        }
      ],
      "metrics": {
        "rodCount": 2,
        "yieldPct": 75,
        "placed": 2,
        "total": 2,
        "cuts": 2,
        "totalPrice": 36
      },
      "unplaced": []
    }
  ],
  "unmatched": [],
  "metrics": {
    "sectionCount": 2,
    "rodCount": 4,
    "yieldPct": 82.5,
    "placed": 8,
    "total": 8,
    "cuts": 8,
    "totalPrice": 61,
    "toleranceAcceptedCount": 0
  },
  "unplaced": [],
  "warnings": [],
  "timing": { "solveMs": 2.96 },
  "cutPlan": [
    {
      "section": "50x100",
      "sheet": 0,
      "step": 1,
      "axis": "v",
      "pos": 2400,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x100",
      "sheet": 0,
      "step": 2,
      "axis": "v",
      "pos": 3003,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x100",
      "sheet": 0,
      "step": 3,
      "axis": "v",
      "pos": 3606,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x100",
      "sheet": 1,
      "step": 1,
      "axis": "v",
      "pos": 2400,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x100",
      "sheet": 1,
      "step": 2,
      "axis": "v",
      "pos": 3003,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x100",
      "sheet": 1,
      "step": 3,
      "axis": "v",
      "pos": 3606,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x150",
      "sheet": 0,
      "step": 1,
      "axis": "v",
      "pos": 3000,
      "length": 0,
      "stage": 1
    },
    {
      "section": "50x150",
      "sheet": 1,
      "step": 1,
      "axis": "v",
      "pos": 3000,
      "length": 0,
      "stage": 1
    }
  ]
}

sections replaces rods at the top level: each entry is one cross-section with its own rods (identical in shape to 1D) and its own metrics, so the per-material figures are there without recomputing them. unmatched has no 1D equivalent — it is demand whose cross-section you supplied no stock for at all, which is a different problem from unplaced (parts that had stock and did not fit) and has a different fix, so the two are never mixed. metrics.total counts every piece you asked for, unmatched ones included. In the cut plan each step also names its section, and sheet is the rod index WITHIN that section rather than a job-wide counter.

POST /v1/optimize/wood — top level (what differs from 1D)

sectionsarrayReplaces rods at the top level: one entry per cross-section, each matched to its own stock and solved on its own.
unmatchedarrayDemand whose cross-section has NO stock at all. Deliberately separate from unplaced (had stock, did not fit) — the fix differs, so the two are never mixed.
unplacedarrayDid-not-fit demand, aggregated across the sections that DID have stock. Same shape as 1D.
metricsobjectJob-wide totals across every section (below).
cutPlanarray | nullAs 1D, except every step also carries section, and sheet is the rod index WITHIN that section, not a job-wide counter.
csv, dxfstringInline export, present only when include names the token. svg is 2D-only — a wood request asking for it gets a warning instead.

sections[]

sectionstringNormalised cross-section key, e.g. "50x100" — short side first, so 50×100 and 100×50 are one section.
sw, shnumberThe two cross-section sides: sw the SHORT one, sh the long one, whatever order they arrived in.
stockNamestring | nullThe name of the stock row this section was matched to, or null when that row carried none.
materialstringPresent only when the section carries a material — an oak 50×100 and a pine 50×100 are two sections. (OPEN-256)
rodsarrayIdentical in shape to the 1D rods[] above, meta and offcuts included.
metricsobjectThis section’s own totals: rodCount, yieldPct, placed, total, cuts, totalPrice — so the per-section figures need no recomputation.
unplacedarrayThis section’s parts that had stock and still did not fit.

metrics (wood)

sectionCountintegerCross-sections solved. Equals sections.length.
rodCountintegerBars used across every section.
yieldPctnumberPlaced length ÷ total FULL bar length × 100 over the whole job, 2 decimals.
placedintegerPieces placed, after qty expansion.
totalintegerPieces REQUESTED, after qty expansion — unmatched ones included.
cutsintegerCrosscuts across every used bar.
totalPricenumberSum of the used bars’ prices, 2 decimals.
toleranceAcceptedCountintegerPieces that fitted only because options.tolerance allowed an overshoot.

unmatched[]

sectionstringThe cross-section key nothing in stock matched.
sw, shnumberThat cross-section’s two sides.
materialstringPresent when the cross-section DOES exist in stock but only in a different material. (OPEN-256)
partsarrayThe demand rows in that section, in the 1D unplaced shape: name, length, qty.
qtyintegerTotal pieces in this section that had no stock at all.

True-shape nesting

POST /v1/optimize/nest

{
  "parts": [
    {
      "name": "bracket",
      "polygon": [[0, 0], [300, 0], [300, 100], [100, 100], [100, 300], [0, 300]],
      "qty": 4,
      "allowedRotations": [0, 90, 180, 270]
    },
    {
      "name": "gusset",
      "polygon": [[0, 0], [280, 0], [0, 280]],
      "qty": 4,
      "allowedRotations": [0, 90, 180, 270]
    }
  ],
  "stock": [
    {
      "w": 2440,
      "h": 1220,
      "price": 12.5,
      "exclusions": [{ "polygon": [[0, 0], [300, 0], [0, 300]], "quality": 0 }]
    }
  ]
}

The three modes above pack rectangles. POST /v1/optimize/nest packs ARBITRARY POLYGONS: a part is an outline (polygon, with optional interior holes), not a width×height, so parts interlock into each other’s concave pockets and the notch-air a bounding box wastes is reclaimed — on a representative job, 6 sheets where the same parts by bounding box need 9. It is a different class of algorithm (a geometric collision engine, not the guillotine packer), for laser, plasma and waterjet cutting. Two things the rectangular API cannot express come with it: per-sheet exclusion zones (stock[].exclusions — a defect, a clamp footprint, a pre-printed area; a quality-0 zone is a no-go region for any part) and true-shape holes. material partition and the meta passthrough work as everywhere else. The example below is one real captured call — eight parts on a single sheet with a damaged corner excluded.

POST /v1/optimize/nest — request (top level)

partsarrayOne or more NestPart (see below). Required.
stockarrayOne or more NestStock sheet types (see below). Required.
optionsobjectSolve options (see below). Optional.
enginestring"lbf" (default, single-pass, instant) or "sparrow" (advertised for a future higher-density build; currently served by lbf with a warning).
includestring[]Additive inline export of the ACHIEVED nest: "svg" a self-contained styled drawing (one titled band per sheet, parts as filled polygons with holes, exclusion zones hatched), "dxf" an R12/AC1009 document on layers STOCK/PARTS/HOLES/ZONES/LABELS. Unit-agnostic, exactly like the request coordinates. The geometry in sheets[] is returned either way.

parts[] (NestPart)

polygonnumber[][]The part outline: an ordered ring of ≥3 [x, y] vertices. Given closed (first == last) or open; winding order is not required — it is oriented internally. Required UNLESS the row carries source.
sourceobjectOPEN-262 — read the outline out of an SVG or DXF FILE instead of listing coordinates (fields below). EXACTLY ONE of polygon / source: both, or neither, is a 400.
holesnumber[][][]Optional interior holes — a part with a cut-out. Each hole is a ring like polygon. NOT accepted alongside source: the file already carries its own holes, and two sources of truth for one geometry is not a thing we resolve silently.
qtyintegerCopies to place. Default 1.
allowedRotationsnumber[] | "continuous"Allowed rotations in DEGREES (e.g. [0,90,180,270]). Omit or "continuous" for free rotation.
minQualityintegerThe part may only be placed where sheet quality ≥ this. Default 1 → it avoids every quality-0 exclusion zone. Raise it to keep the part off inferior-but-not-forbidden zones too.
prioritybooleanMust-cut: wins sheet space when stock is capped (options.respectStock).
materialstringOPEN-256 — a part of material X nests only on material-X sheets; the job is partitioned by material.
namestringOptional label, echoed on every placement. Defaults to "Part <1-based row index>".
metaobjectOPEN-224 — opaque JSON (your ERP ids), echoed verbatim on every placed copy. Never affects the layout.

stock[] (NestStock)

w, hnumberRectangular sheet size. Give w & h OR polygon, not both.
polygonnumber[][]Arbitrary sheet outline (an off-cut remnant, a non-rectangular board); overrides w/h.
qtyintegerDefault 1. A HARD cap only when options.respectStock is true.
pricenumberPer-sheet price, for cost mode + totalPrice.
exclusionsobject[]OPEN-233 — per-sheet excluded / inferior zones. Each: { polygon: number[][], quality?: integer }. quality 0 (the default) = a hard no-go region for any part; a higher quality only excludes parts whose minQuality demands at least that. This is what the rectangular API cannot express.
materialstringOPEN-256 — this sheet serves only material-matching parts.
metaobjectOPEN-224 — echoed on every sheet cut from this stock row.

options (NestOptions)

minSeparationnumberMinimum clearance between parts and between a part and any hazard (sheet edge / exclusion zone). Use for kerf / beam / torch width. Default 0.
seedintegerDeterminism: a fixed seed → a reproducible layout. Omit and the server pins a fixed default so the response stays reproducible.
minimizeCostbooleanRank plans by total sheet price rather than sheet count.
respectStockbooleanTreat each stock qty as a hard cap.
simplifyTolerancenumberPolygon simplification tolerance (max area deviation as a fraction). Speeds up dense DXF outlines with hundreds of vertices. 0 disables.
timeBudgetMsintegerWall-clock budget for the metaheuristic (engine "sparrow"). Ignored by "lbf" (single-pass).

Nest — response

{
  "engine": "lbf",
  "engineVersion": "1.0.0+nest-d4046d5-07546022",
  "contractVersion": "1",
  "deterministic": true,
  "sheets": [
    {
      "w": 2440,
      "h": 1220,
      "price": 12.5,
      "density": 0.1217,
      "parts": [
        { "name": "bracket", "sheet": 0, "x": 300.003, "y": 400.226, "rotation": -180 },
        { "name": "bracket", "sheet": 0, "x": 300.133, "y": 700.617, "rotation": -180 },
        { "name": "bracket", "sheet": 0, "x": 300.061, "y": 1000.969, "rotation": -180 },
        { "name": "bracket", "sheet": 0, "x": 400.095, "y": 1102.332, "rotation": -180 },
        { "name": "gusset", "sheet": 0, "x": 530.102, "y": 50.205, "rotation": 90 },
        { "name": "gusset", "sheet": 0, "x": 300.058, "y": 380.171, "rotation": -90 },
        { "name": "gusset", "sheet": 0, "x": 580.15, "y": 380.193, "rotation": 90 },
        { "name": "gusset", "sheet": 0, "x": 300.204, "y": 660.982, "rotation": -90 }
      ],
      "exclusions": [{ "polygon": [[0, 0], [300, 0], [0, 300]], "quality": 0 }]
    }
  ],
  "metrics": { "sheetCount": 1, "density": 0.1217, "placed": 8, "total": 8, "totalPrice": 12.5 },
  "unplaced": [],
  "warnings": [],
  "timing": { "solveMs": 44.19 }
}

Each entry in sheets is one used sheet; a placed part carries the rigid transform (rotation in degrees, then x/y translation), NOT a re-emitted polygon — rotate your input outline by rotation about its origin and add (x, y) to reconstruct the placement exactly. rotation may be negative; the reconstruction is exact regardless of sign. ⚠️ density is the placed POLYGON area over used sheet area — the honest fill, with concave pockets counted as empty — and is NOT comparable to a rectangular packer’s yieldPct (which counts each bounding box as solid, so it reads higher for a worse result); the cross-comparable metric across the two is sheetCount on the same parts. The layout is deterministic: set options.seed to reproduce it. exclusions is echoed on each sheet for rendering. Ask for include:["svg","dxf"] and the response also carries a self-contained SVG drawing and an R12 DXF of the achieved nest, inline.

POST /v1/optimize/nest — top level

enginestringWhich nesting engine ran: "lbf" or "sparrow".
engineVersionstringThe nesting engine's algorithm identity (jagua-rs revision + build hash). Versions independently of the rectangular engines.
contractVersionstringNest contract version, currently "1". Versions independently of the /v1/ rectangular contract — it is a different path and engine family.
deterministicbooleanAlways true — guaranteed by the pinned seed.
sheetsarrayOne entry per used sheet.
metricsobjectJob totals (see below).
unplacedarrayDemand that could not be placed — a plan-plus-warning, not an error.
warningsstring[]e.g. an engine substitution ("sparrow" served by "lbf"), unplaced parts, or a material with no matching stock.
materialsarrayOPEN-256 per-material rollup — present only when parts/stock carry material.
unmatchedMaterialsarrayParts whose material has no matching stock — present only when it happens.
importedobjectOPEN-262 — what the request’s source files contributed (fields above). ABSENT for a coordinates-only job, which is what keeps such a request byte-identical to before the feature existed.
svg, dxfstringThe achieved nest as an inline drawing — present ONLY when include contains that token. svg is self-contained (no external refs); dxf is R12/AC1009.
timingobject{ solveMs: number } — the solve time; environment-dependent.

sheets[] (nest)

w, hnumberPresent for rectangular sheets.
polygonnumber[][]Present for arbitrary-outline sheets instead of w/h.
pricenumber | nullThe stock row's price, or null.
densitynumberThis sheet's fill = placed polygon area / sheet area.
partsarrayPlacements on this sheet (see below).
exclusionsobject[]The zones that applied to this sheet, echoed for rendering.
materialstringPresent when the sheet carried a material (OPEN-256).
metaobjectEchoed from the stock row's meta (OPEN-224).

sheets[].parts[] (nest — placed)

namestringThe requested name, or the generated default.
sheetinteger0-based index into sheets.
x, ynumberTranslation, applied AFTER rotation about the part's origin.
rotationnumber⚠️ Degrees, applied about the part's own origin FIRST. A placed part carries this rigid transform, NOT a re-emitted polygon: rotate your input outline by rotation, then add (x, y) to reconstruct the placement exactly. May be negative; the reconstruction is exact regardless of sign.
materialstringThe material this copy nested from (OPEN-256).
metaobjectThe part's opaque passthrough (OPEN-224).

metrics (nest)

sheetCountintegerSheets used. Equals sheets.length. ⚠️ The honest, cross-comparable metric between nesting and the rectangular modes.
densitynumber⚠️ Placed POLYGON area / used sheet area — the honest fill (concave pockets count as empty). NOT comparable to a rectangular packer's yieldPct, which counts each bounding box as solid and so reads higher for a worse result.
placedintegerPart copies placed, after qty expansion.
totalintegerPart copies requested, after qty expansion.
totalPricenumberSum of used sheet prices.

unplaced[] (nest)

namestringThe part name.
qtyintegerHow many copies could not be placed.

rods[]

lengthnumberFULL bar length as supplied in stock.
pricenumber | nullPrice of the stock row, or null.
remainingnumberUSABLE off-cut left on this bar, 3 decimals — the kerf of the cut that frees it from the last piece is already deducted (OPEN-257), so it is the reclaimable length, not the raw gap. Reported even when below minOffcut.
partsarrayPlacements, in cutting order along the bar.
offcutsnumber[]At most one entry: [remaining] (the kerf-corrected usable off-cut, OPEN-257) when it is > 0 and ≥ minOffcut, else []. Absent when excluded via include.
metaobjectPresent only when the stock row carried meta — echoed from stock[].meta (OPEN-224). Applies to 1D and wood rods.

rods[].parts[]

namestringThe requested name, or the generated default.
posnumberOffset of the part’s NEAR end from the bar end that trim.start trims.
lengthnumberThe part length, as requested.
metaobjectPresent only when the part carried meta — echoed from parts[].meta (OPEN-224).

metrics (1D)

rodCountintegerBars used. Equals rods.length.
yieldPctnumberPlaced length ÷ total FULL bar length × 100, 2 decimals. Trim and kerf count as waste.
placedintegerPieces placed, after qty expansion.
totalintegerPieces requested, after qty expansion.
cutsintegerTotal crosscuts across all used bars — one per placed piece.
totalPricenumberSum of used bar prices, 2 decimals.
toleranceAcceptedCountintegerPieces that fitted only because options.tolerance allowed an overshoot.

unplaced[] (1D)

namestringThe part name.
lengthnumberAs requested.
qtyintegerHow many could not be placed.

Parts from a file (SVG · DXF)

A part does not have to arrive as coordinates. Put an SVG or DXF document in parts[].source and the server reads the outline — and its holes — out of it with the same parser the CutOptim app uses when you drop a drawing on its Nesting mode. The file replaces ONLY the geometry: qty, material, allowedRotations, minQuality, priority and meta behave exactly as they do on a polygon part, so a part library that already exists as CAD files needs no path-and-arc flattener of your own. One source describes ONE part; a drawing holding several separate components is a 400 that points you at the import endpoint below. The response then carries an imported block saying how many rows came from a file, how many vertices they produced, and which units those files declared — reported, never applied, because this API converts nothing.

Nothing is stored. The bytes exist only as the request body, are parsed in memory, and are gone when the response is written: no disk, no database, no temporary file, no log line. There is nothing to delete afterwards and nothing retained — the same statelessness every other endpoint keeps.

POST /v1/optimize/nest

{
  "parts": [
    {
      "source": {
        "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 200\">\n  <path d=\"M0 0 H120 V80 H0 Z\"/>\n  <path d=\"M40 30 H80 V50 H40 Z\"/>\n</svg>",
        "filename": "washer-plate.svg"
      },
      "qty": 4,
      "meta": { "erpId": "ART-8891" }
    }
  ],
  "stock": [{ "w": 2440, "h": 1220 }]
}

Field names and types are the contract, so the tables below stay in English in every language — a translated field name would document an API that does not exist.

{
  "imported": { "parts": 1, "vertices": 8, "units": [] },
  "parts_after_import": [
    {
      "name": "washer-plate",
      "polygon": [[0, 0], [120, 0], [120, 80], [0, 80]],
      "holes": [[[40, 30], [80, 30], [80, 50], [40, 50]]],
      "qty": 4,
      "meta": { "erpId": "ART-8891" }
    }
  ]
}

parts[].source — geometry from an SVG / DXF file

contentstringThe file TEXT — not base64, not a URL, not a multipart upload. Both formats are text documents, so they travel inside the JSON body like any other field. At most 4,000,000 characters per file and 8,000,000 across one request; over either is a 413.
format"svg" | "dxf"Omit and the server sniffs the content (an <svg tag ⇒ svg, otherwise dxf). An explicit value ALWAYS wins — including over a filename whose extension disagrees, which is the case worth setting it for.
filenamestringUsed for format detection AND as the part name when the row has no name of its own (extension stripped). It never touches a filesystem — there is no file on our side to name.
flattenTolerancenumberCurve and arc flattening tolerance, in the FILE’s own coordinate unit. Default 0.2. Larger = fewer vertices; this is the lever when a dense outline trips the 2,000-vertex per-ring cap.

imported — what the files contributed (response, absent without a source)

partsintegerPart ROWS whose geometry came from a file.
verticesintegerTotal vertices those files produced after flattening, outlines and holes together — the number to watch against the per-ring cap.
unitsstring[]⚠️ Units the files DECLARED (a DXF $INSUNITS), not units we applied. Empty when none declared one. More than one entry also raises a warning: mixing a millimetre drawing with an inch one produces a plan that validates and is physically wrong, and the server must not "fix" that by converting — no field in this API asserts a unit.

POST /v1/import/nest — one file, every outline

When a single drawing holds several different parts, import it first: this endpoint returns every closed outline it contains, largest first, in exactly the shape a parts[] row wants. Paste in the ones you need, add your own qty and material, and post that to /v1/optimize/nest. It is also how you see what is in a file before spending a solve on it. It needs a key — flattening arbitrary geometry is real CPU work, and anonymous CPU is a bad trade — but it reserves nothing: your quota is untouched and no rate-limit headers come back, exactly like polling a job.

POST /v1/import/nest
Authorization: Bearer co_live_…

{
  "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 400 200\">\n  <path d=\"M0 0 H120 V80 H0 Z\"/>\n  <path d=\"M40 30 H80 V50 H40 Z\"/>\n</svg>",
  "filename": "washer-plate.svg"
}
{
  "format": "svg",
  "parts": [
    {
      "name": "washer-plate",
      "polygon": [[0, 0], [120, 0], [120, 80], [0, 80]],
      "holes": [[[40, 30], [80, 30], [80, 50], [40, 50]]]
    }
  ],
  "vertices": 8,
  "warnings": [],
  "engineEnabled": true,
  "contractVersion": "1"
}

POST /v1/import/nest — request

contentstringThe file text. Required. Same 4,000,000-character cap as parts[].source.
format"svg" | "dxf"Omit to sniff; an explicit value wins over the filename extension.
filenamestringUsed for detection and to NAME the results: a file with one outline keeps the bare name, several are numbered "<name> 1", "<name> 2", …
flattenTolerancenumberAs on parts[].source. Default 0.2.

POST /v1/import/nest — response

format"svg" | "dxf"The parser that actually ran — the useful bit when you let the server sniff.
parts[]{ name, polygon, holes? }One entry per closed outline, LARGEST FIRST. Each is already in the shape a parts[] row wants: paste it in and add your own qty / material / allowedRotations. Deterministic — the same bytes always give the same vertices in the same order.
verticesintegerTotal vertices after flattening. Compare it against the 2,000-per-ring cap before you build a large request.
sourceUnit"mm" | "in"A DXF $INSUNITS declaration, when the file carries one. REPORTED, NEVER APPLIED. Absent for SVG (the format has no unit) and for a DXF that declares none.
warningsstring[]What the parser could not honour — an SVG transform= attribute (CAD part exports are flat, so we do not apply them), or geometry that never closed into a contour. Empty means the file was read whole.
engineEnabledbooleanWhether THIS deployment can also SOLVE a nest. Importing is pure parsing and works everywhere; where the nesting engine is not built in, /v1/optimize/nest answers 503 and this flag says so up front. Same field /v1/validate/nest carries.
contractVersionstringNest contract version. Currently "1".

Engines

  • heuristic (default) — the guillotine multi-strategy packer. Highest yield, every layout saw-cuttable, always a full cutPlan.
  • balanced — a MaxRects free-nesting packer. Much faster on large jobs (measured ~25× at 2,000 parts) for a small yield cost, and its layouts are often not guillotine (guillotineValid: false, cutPlan: null). It does not model tolerance, minimizeCost, grainGroup, maxCutStages or minimizeRotations — set one and a warning tells you it was ignored.
  • max — the asynchronous tree-search tier (2D only): it reaches the proven optimum on far more jobs at the cost of seconds-to-a-minute per solve. Still deterministic and guillotine-valid. It does not return a plan directly — see Async jobs below. It models ONE stock format at full sheet size with unlimited supply and a fixed 3-stage guillotine pattern, so a second stock row, trim, respectStock, material or grainGroup is refused with 400 before a call is reserved; tolerance, minimizeCost, maxCutStages, minimizeRotations, firstCut and effort go through but are ignored with a warning, and a max result reports no offcuts. Send those jobs to heuristic.

Async jobs (engine = max)

A max solve takes seconds to a minute, so POST /v1/optimize/2d with engine:"max" does not return a plan — it returns 202 Accepted with a jobId, and the call is metered at submission. Poll GET /v1/jobs/{id} until status is "succeeded" (result holds the same 2D response a synchronous solve returns) or "failed" (error holds the message). Polling spends no quota; you see only your own jobs. Where the tier is not enabled on a deployment, engine:"max" fails closed with 503.

fieldtypemeaning
jobIdstringThe 202 body's id. Poll GET /v1/jobs/{id}.
statusstringqueuedrunningsucceeded | failed.
mode · enginestringAlways "2d" and "max".
pollAfterMsinteger202 only — suggested delay before the first poll.
resultobjectPresent once succeeded — the same shape as a synchronous 2D response.
errorstringPresent once failed — the reason.
quotaobject202 only — reserved, used and limit: the one call metered at submission, and where the ACCOUNT stands this month.
createdAtstringPoll only — when the job was submitted (ISO-8601).
finishedAtstring | nullPoll only — when the worker finished; null while queued or running.

Determinism & versioning

Every response carries engineVersion. The algorithm is deterministic, so improving it changes output for the same input — which is a breaking change if you cache. Pin behaviour by sending engine explicitly and watching engineVersion; the path version /v1/ only changes if the response shape changes.

Each engine versions independently, so a change to one never moves the other's version.

Errors

400invalid_requestSchema error. details.path points at the offending field.
401unauthorizedMissing or unknown API key.
402quota_exceededMonthly quota reached. Retry-After gives the seconds until the month rolls over.
403key_revokedThe key exists but may not be used: it has been revoked, or the account Engine API subscription is no longer active. The message says which.
404not_foundNo such route — also what you get for the right path with the wrong method.
413too_largeInput above a limit (see Limits).
429busyMomentarily at capacity. Retry-After in seconds — this never counts against your quota.
500internalUnexpected error, or the auth backend is unreachable (requests fail closed).
503service_unavailableAn engine you asked for cannot be served right now — the nesting engine, or the async max engine. Two causes for max, and the message says which: the tier is not built into this deployment, or it is built in but the worker that solves the jobs is not responding. Fail-closed before a call is reserved, so it never costs you anything.
504solve_timeoutThe solve exceeded its hard time bound. On the rectangular paths the proxy enforces it; on /v1/optimize/nest the engine enforces its own, shorter budget and answers solve_timeout in the normal envelope.

Error body

Every error the engine itself produces uses the same envelope. Branch on error, which is a stable code; never on message, whose wording can change between releases. details is present on invalid_request, where path names the offending field, and on too_large, where max and got give the cap and what you sent.

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{
  "error": "invalid_request",
  "message": "parts[0].h: must be greater than 0",
  "details": { "path": "parts[0].h" }
}
HTTP/1.1 402 Payment Required
Retry-After: 41400
Content-Type: application/json; charset=utf-8

{ "error": "quota_exceeded", "message": "monthly quota exhausted" }
  • 402 and 429 both carry a Retry-After header in seconds. On 402 it counts down to the quota reset at 00:00 UTC on the 1st of next month; on 429 it is a short back-off, and a 429 never consumes quota — the reserved call is handed back.
  • A routing failure answers with not_found, a code deliberately outside the list above because the router produces it rather than the API contract. You get 404 and not 405 when the path is right but the method is wrong: all four optimize endpoints are POST-only.
  • On the rectangular paths 504 comes from the reverse proxy, not from the engine, so its body belongs to the proxy and is not this JSON envelope; within the input limits below it should be unreachable. /v1/optimize/nest is the exception: that solve is a subprocess with its own budget, deliberately kept below the proxy bound, so a timeout there does use this envelope, with the code solve_timeout.

Rate-limit headers

A successful optimize call carries X-RateLimit-Limit (the ACCOUNT’s monthly cap — every key on the account shares one) and X-RateLimit-Remaining (calls left this month on the account, after this one). They are sent by the optimize endpoints only: the counter is reserved as part of authorizing a solve, so /v1/usage and /v1/health have nothing to report.

X-RateLimit-LimitintegerThe ACCOUNT’s monthly quota — the same number GET /v1/usage returns as limit, not a per-key cap (049).
X-RateLimit-RemainingintegerCalls left this month on the ACCOUNT, after this one.
Retry-AfterintegerSeconds to wait. Sent with 402 and 429 only.

GET /v1/usage

{
  "plan": "studio",
  "used": 137,
  "limit": 10000,
  "remaining": 9863,
  "periodEnd": "2026-08-01",
  "keyPrefix": "co_live_ab12",
  "contractVersion": "1"
}

Read-only: it does not consume a call and sends no rate-limit headers. ⚠️ used and limit describe the ACCOUNT, not the key you called with: every active key on the account draws on one shared allowance, so creating more keys does not create more quota. used counts the current UTC calendar month across all of them, remaining is limit minus used and never goes negative, periodEnd is the reset day as a plain YYYY-MM-DD date, and keyPrefix is the non-secret display prefix of the key you called with. The key itself is never returned by any endpoint — only its hash is stored, so a lost key is replaced, not recovered.

planstringTier slug frozen onto the key when it was created.
usedintegerCalls counted in the current UTC calendar month across EVERY key on the ACCOUNT — revoked keys included, so revoking a key cannot un-spend what it spent (053).
limitintegerThe ACCOUNT’s cap: the largest monthly quota frozen onto any of its ACTIVE keys (049). Not a per-key allowance — more keys do not add quota.
remainingintegerlimit − used, never negative.
periodEndstringReset day as YYYY-MM-DD — a date, not a timestamp.
keyPrefixstringNon-secret display prefix of the calling key.
contractVersionstringShape version. Currently "1".

GET /v1/health

{
  "status": "healthy",
  "service": "cutoptim-engine",
  "contractVersion": "1",
  "engineVersion": "1.0.0+10e0c941",
  "engines": ["heuristic", "balanced"],
  "modes": ["2d", "1d", "wood", "nest"],
  "nestEngines": ["lbf"],
  "maxEngines": ["max"],
  "uptimeSec": 16
}

No key, no quota, no database. It deliberately touches nothing stateful, so an outage in the key store cannot make the service look dead to an orchestrator. engines lists the ids this deployment accepts in engine, and engineVersion is the default engine’s version.

statusstringAlways "healthy" when the process answers.
servicestringAlways "cutoptim-engine".
contractVersionstringShape version. Currently "1".
engineVersionstringThe DEFAULT engine’s version, not a per-engine list.
enginesstring[]SYNCHRONOUS engine ids this deployment accepts in engine — ["heuristic","balanced"]. The async max tier is reported separately in maxEngines, never here.
modesstring[]Optimize paths this deployment serves: "2d", "1d", "wood", plus "nest" only where the nesting engine is built in. Endpoint discovery without reading this page.
nestEnginesstring[]Nesting engine ids this deployment can serve — ["lbf"] on the production API, [] where the Rust nesting stage is not built in.
maxEnginesstring[]The async tree-search tier — ["max"] where it is enabled, [] otherwise. Health never advertises a capability it cannot serve.
uptimeSecintegerWhole seconds since process start.

Limits

  • 2,000 parts per request (total quantity, after qty expansion)
  • 50 stock rows · request body up to 1 MB
  • 10 active keys per account — they share ONE monthly quota, so keys separate environments and integrations, they do not add allowance
  • 10 MB request body on the two nest paths that may carry a drawing (/v1/optimize/nest and /v1/import/nest); one source file is at most 4,000,000 characters, 8,000,000 across a request
  • concurrency is bounded server-side — a burst gets 429, never a slow queue. The keyless validate endpoints additionally have a per-address ceiling (429 with Retry-After); a key is never throttled that way. An account may hold 5 max jobs queued or running at once.

OpenAPI specification

A machine-readable OpenAPI 3.1 document describes all twelve endpoints, every request body, every response shape and every error. Point your client generator at it instead of transcribing this page. The document itself is English only: it is made of contract tokens, and OpenAPI has no localization mechanism.

curl -s https://cutoptim.com/engine/openapi.json > cutoptim-engine.json

Open the OpenAPI 3.1 document →

Downloadable resource
Engine API one-pager

A two-page summary of the Engine API — the four modes (2D, 1D, wood and true-shape nest), a request and response, determinism and pricing. Print-ready, with a QR back to the docs.

PDF2 pagesFree
Download the PDF