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/2d 2D panel optimization
POST /v1/optimize/1d 1D / linear optimization (bars, profiles, tube)
GET /v1/usage the calling key's current-month usage and quota
GET /v1/health liveness — no key, no rate limit, no database

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 } },
  "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)

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.

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 total stock price instead of sheet count
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

include trims the response: pass ["cutPlan","offcuts"] (the default is both), or omit either to leave it out of the payload.

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": 400, "h": 600, "rotated": true },
        { "name": "Door", "x": 413, "y": 10, "w": 400, "h": 600, "rotated": true },
        { "name": "Door", "x": 816, "y": 10, "w": 400, "h": 600, "rotated": true },
        { "name": "Door", "x": 1219, "y": 10, "w": 400, "h": 600, "rotated": true }
      ],
      "offcuts": [
        { "x": 1622, "y": 10, "w": 818, "h": 600 },
        { "x": 10, "y": 613, "w": 2430, "h": 607 }
      ]
    }
  ],
  "metrics": {
    "sheetCount": 1,
    "yieldPct": 32.25,
    "placed": 4,
    "total": 4,
    "cutLines": 5,
    "sawPasses": 5,
    "cutLength": 4830,
    "totalPrice": 42
  },
  "unplaced": [],
  "warnings": [],
  "guillotineValid": true,
  "timing": { "solveMs": 3.13 },
  "cutPlan": [
    { "sheet": 0, "step": 1, "axis": "h", "pos": 610, "length": 2430, "stage": 1 },
    { "sheet": 0, "step": 2, "axis": "v", "pos": 410, "length": 600, "stage": 2 },
    { "sheet": 0, "step": 3, "axis": "v", "pos": 813, "length": 600, "stage": 2 },
    { "sheet": 0, "step": 4, "axis": "v", "pos": 1216, "length": 600, "stage": 2 },
    { "sheet": 0, "step": 5, "axis": "v", "pos": 1619, "length": 600, "stage": 2 }
  ]
}

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

engine string Which engine ran: "heuristic" or "balanced".
engineVersion string Algorithm identity — package version + a content hash of the algorithm source. Moves automatically on any packer change, and independently per engine.
contractVersion string Shape version, matching the /v1/ in the path. Currently "1".
deterministic boolean Always true. Present so a client can assert the guarantee it relies on.
sheets array One entry per sheet used, in cutting order.
metrics object Aggregate numbers for the whole job.
cutPlan array | null The sawing plan. null when guillotineValid is false; absent when excluded via include.
unplaced array Parts that did not fit, aggregated by name + size. Empty when everything fitted.
warnings string[] Free-text notes about the plan. Do not parse — branch on unplaced, guillotineValid and metrics.
guillotineValid boolean True when the layout is producible with edge-to-edge cuts, i.e. on a panel saw.
timing.solveMs number Milliseconds inside the packer, 2 decimals. Excludes parsing, auth and queueing.

sheets[]

w, h number FULL stock dimensions, trim included — not the usable area.
price number | null Price of the stock row this sheet came from, or null if none was given.
parts array Placements on this sheet.
offcuts array Usable leftover rectangles, filtered by options.minOffcut. Absent (not empty) when excluded via include.

sheets[].parts[]

name string The requested name, or the generated default "Part <row>".
x, y number Top-left corner of the part, from the top-left corner of the sheet.
w, h number Dimensions AS PLACED — already swapped when rotated is true. No client-side swap needed.
rotated boolean True if the part was turned 90° from the requested w×h. Informational only.

sheets[].offcuts[]

x, y, w, h number A leftover rectangle, in the same coordinate system as the placements.

metrics (2D)

sheetCount integer Sheets used. Equals sheets.length.
yieldPct number Placed part area ÷ total FULL sheet area × 100, 2 decimals. Trim and kerf count as waste.
placed integer Pieces placed, after qty expansion.
total integer Pieces requested, after qty expansion.
cutLines integer Collinear cuts merged: same axis, same coordinate, same stage counted once — one fence setting.
sawPasses integer Every cut, one per strip crossed — how many separate passes the saw makes.
cutLength number Total distance sawn, 3 decimals. Same under either counting convention. In your unit.
totalPrice number Sum of the used sheets’ prices, 2 decimals. 0 when no stock row carried a price.

unplaced[] (2D)

name string The part name.
w, h number As requested.
qty integer How many of this part could not be placed.

cutPlan[]

sheet integer 0-based index into sheets (2D) or rods (1D). Named sheet in both.
step integer 1-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.
pos number 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.
length number Distance the blade travels on this cut: the extent of the region crossed. Always 0 in 1D.
stage integer 1-based machine pass. Increments only when the axis flips relative to the parent cut.

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. 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": 587,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [587]
    },
    {
      "length": 3000,
      "price": 12.5,
      "remaining": 587,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [587]
    },
    {
      "length": 3000,
      "price": 12.5,
      "remaining": 587,
      "parts": [
        { "name": "Rail", "pos": 10, "length": 1200 },
        { "name": "Rail", "pos": 1213, "length": 1200 }
      ],
      "offcuts": [587]
    }
  ],
  "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 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.

rods[]

length number FULL bar length as supplied in stock.
price number | null Price of the stock row, or null.
remaining number Unused length left on this bar, 3 decimals. Reported even when below minOffcut.
parts array Placements, in cutting order along the bar.
offcuts number[] At most one entry: [remaining] when it is > 0 and ≥ minOffcut, else []. Absent when excluded via include.

rods[].parts[]

name string The requested name, or the generated default.
pos number Offset of the part’s NEAR end from the bar end that trim.start trims.
length number The part length, as requested.

metrics (1D)

rodCount integer Bars used. Equals rods.length.
yieldPct number Placed length ÷ total FULL bar length × 100, 2 decimals. Trim and kerf count as waste.
placed integer Pieces placed, after qty expansion.
total integer Pieces requested, after qty expansion.
cuts integer Total crosscuts across all used bars — one per placed piece.
totalPrice number Sum of used bar prices, 2 decimals.
toleranceAcceptedCount integer Pieces that fitted only because options.tolerance allowed an overshoot.

unplaced[] (1D)

name string The part name.
length number As requested.
qty integer How many could not be placed.

Engines

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

400 invalid_request Schema error. details.path points at the offending field.
401 unauthorized Missing or unknown API key.
402 quota_exceeded Monthly quota reached. Retry-After gives the seconds until the month rolls over.
403 key_revoked The key exists but has been revoked.
404 not_found No such route — also what you get for the right path with the wrong method.
413 too_large Input above a limit (see Limits).
429 busy Momentarily at capacity. Retry-After in seconds — this never counts against your quota.
500 internal Unexpected error, or the auth backend is unreachable (requests fail closed).
504 solve_timeout The solve exceeded the hard wall-clock bound (enforced at the proxy).

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

Rate-limit headers

A successful optimize call carries X-RateLimit-Limit (the key’s monthly cap) and X-RateLimit-Remaining (calls left this month, 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-Limit integer The key’s monthly quota.
X-RateLimit-Remaining integer Calls left this month, after this one.
Retry-After integer Seconds 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. plan and limit are the tier and cap frozen onto the key when it was created, so re-pricing the product later never rewrites a live key. used counts the current UTC calendar month, 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.

plan string Tier slug frozen onto the key when it was created.
used integer Calls counted in the current UTC calendar month.
limit integer The monthly cap frozen onto the key.
remaining integer limit − used, never negative.
periodEnd string Reset day as YYYY-MM-DD — a date, not a timestamp.
keyPrefix string Non-secret display prefix of the calling key.
contractVersion string Shape version. Currently "1".

GET /v1/health

{
  "status": "healthy",
  "service": "cutoptim-engine",
  "contractVersion": "1",
  "engineVersion": "1.0.0+10e0c941",
  "engines": ["heuristic", "balanced"],
  "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.

status string Always "healthy" when the process answers.
service string Always "cutoptim-engine".
contractVersion string Shape version. Currently "1".
engineVersion string The DEFAULT engine’s version, not a per-engine list.
engines string[] Engine ids this deployment accepts in engine.
uptimeSec integer Whole seconds since process start.

Limits

OpenAPI specification

A machine-readable OpenAPI 3.1 document describes all four endpoints, both request bodies, 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 →