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, h | required — part dimensions |
| qty | default 1 — expanded server-side; counts toward the 2,000 cap |
| name | optional label, echoed on every placement |
| rotatable | default true — may the part be turned 90° |
| grainGroup | members of a group are kept on one sheet (grain matching) |
| priority | must-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
| kerf | blade width (default 0) |
| tolerance | accept cuts that overshoot by up to this much |
| trim | per-side edge trim: left, right, top, bottom |
| firstCut | 'auto' | 'horizontal' | 'vertical' (default 'auto') |
| minimizeCost | rank candidates by total stock price instead of sheet count |
| respectStock | treat each stock row's qty as a hard cap |
| minOffcut | only report offcuts whose short side is at least this |
| maxCutStages | panel-saw stage limit — a phase count, not raw tree depth |
| minimizeRotations | prefer 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 }
]
} - 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.
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
- 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.
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" } - 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: both optimize endpoints are POST-only.
- 504 comes from the reverse proxy, not from the engine, so its body is the proxy’s and not this JSON envelope. Within the input caps below it should be unreachable.
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
- 2,000 parts per request (total quantity, after qty expansion)
- 50 stock rows · request body up to 1 MB
- 10 active keys per account
- concurrency is bounded server-side — a burst gets 429, never a slow queue
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