API documentation
The optimiser as an HTTP service: send cuts and stock, receive a verified cutting plan or a print-ready PDF. Stable, versioned, and backward compatible: fields are added, never removed.
Introduction
Base URL https://api.linearcutting.com. Everything is JSON, apart from the PDF endpoint. Requests are stateless: we do not store your cut lists.
You don't need an API key to try this. The line below runs, as written, right now. Paste it into a terminal.
curl -X POST https://api.linearcutting.com/v1/optimise \
-H "Content-Type: application/json" \
-d '{"parts": "2400x5, 1800x3", "stock": 6000}' That is a whole cut list: five parts at 2400, three at 1800, cut from 6000mm bars. You get back the layouts, how many bars it needs, how much is wasted, and whether the plan is provably optimal.
Unauthenticated calls run on a free public tier, so you can evaluate the thing before you pay for it: 30 requests an hour and 250 a calendar month, per IP address. Past that it returns a 429 that tells you when it resets. Failed calls never count against it. When you're building something real, get a key from $5 a month: it lifts both limits, raises the size caps, and gives you your own concurrency so a busy afternoon on the public tier can't slow you down.
Official clients
Zero dependencies, MIT licensed, and they need no key either. If you're in Python or JavaScript, don't hand-roll a fetch:
import linearcutting as lc
plan = lc.optimise(parts="2400x5, 1800x3", stock=6000)
plan.bars_used # 4
plan.optimal # True, proved, not guessed import { optimise } from "linearcutting";
const plan = await optimise({ parts: "2400x5, 1800x3", stock: 6000 });
plan.barsUsed; // 4
plan.optimal; // true Python on PyPI · JavaScript on npm · Python source · JavaScript source
Two guarantees you can build on. verified is always true on success: every plan is re-audited against your inputs before it's returned, so a plan that doesn't actually cut your list can't reach you. optimal is a proof, not a guess, true only when the search has established that no better plan exists, either by exhausting the possibilities or by a certificate. When it can't be proved in the time budget, we say false rather than hoping.
optimal and stockBarsLowerBound are different things, and they disagree on purpose. The lower bound is a relaxation, roughly "total length ÷ bar length", and it is often not achievable. Five 2400s and three 1800s is 17,400mm, which is 2.9 bars of 6000, so the bound says 3. You cannot actually cut those parts from 3 bars; 4 is the true minimum, and the solver proves it. So stockBarsUsed: 4 with stockBarsLowerBound: 3 and optimal: true is not a contradiction, it means the bound was loose, not that the plan was. Use optimal to decide whether to keep searching; use the bound only as a sanity check.
Shorthand
The long form is explicit and a bit of a mouthful. For the common case, say it the short way, a number is a length, and x means how many:
{"parts": "2400x5, 1800x3, 600", "stock": 6000} Every one of these means the same thing, so use whichever suits the code you're writing:
{"parts": "2400x5"}
{"parts": ["2400x5"]}
{"parts": [[2400, 5]]}
{"parts": [{"length": 2400, "quantity": 5}]} A third element is a label on a part, and a price on a stock: [2400, 5, "rail"], [6000, 20, 45.50]. And stock is the friendly name for the old stockLength / stocks split, a bare number means "this length, buy as many as you need".
Shorthand is expanded before anything else happens, so it is a way of typing rather than a different mode: a short request and the long request it expands to return byte-identical plans. Mix and match freely, every existing request still works exactly as it did.
A cut list in a URL
Small jobs fit in a query string, so GET works too. This is handy for a link you want to share, quote, or drop in a bug report:
curl "https://api.linearcutting.com/v1/optimise?parts=2400x5,1800x3&stock=6000" Same auth, same limits, same plan as the POST. Open it in a browser if you like.
Authentication
The public tier needs no key. Paid tiers send their key in a header; keys also carry your allowed browser origins, so calls from your own site work without a proxy.
X-API-Key: lk_live_9f2c... A bad or expired key returns 401. Keys are issued per workspace: see plans.
POST /v1/optimise
Solve a cut list. Returns the plan, statistics, and provable bounds.
Request body
| Field | Type | Default | Description |
|---|---|---|---|
parts required | array | - | The parts you need. Each: length (> 0), quantity (int ≥ 1), optional label, material, leftAngle, rightAngle. |
stockLength | number | - | A single unlimited stock length. Use this or stocks, not both. |
stocks | array | - | Purchasable lengths. Each: length, quantity (null = unlimited), price (null = optimise material instead of money), optional material, priority. |
offcuts | array | [] | Lengths you already own: length, quantity, optional material, priority. Free material, never counted as purchases. |
kerf | number ≥ 0 | 0 | Blade width consumed per cut. |
trim | boolean | false | Take one kerf off both ends of every source before cutting. |
method | string | balanced | balanced, least_waste, offcuts_first, fewest_setups (alias: fewest_setups). Prices on stocks switch balanced to lowest-cost. |
minRemnant | number ≥ 0 | 0 | Leftovers this long or longer come back as usableRemnants instead of waste. |
angleCuts | object | off | {enabled, width, axialSymmetry}. See angle cuts. |
unit | string | "mm" | Echoed back and printed on PDFs. Does not convert anything. |
unitFormat | string | decimal | fraction renders PDF dimensions as nearest-1/64 fractions. |
timeLimit | seconds | auto | Solve budget, capped by tier. The search is anytime: a longer budget only improves the answer. |
Response
{
"plan": {
"combinations": [
{
"usedComb": {
"combination": [2400, 2400, 1200],
"length": 6500,
"source": "stock",
"waste": { "total": 492, "remnant": 484, "saw": 8 }
},
"count": 2
}
],
"purchases": [{ "length": 6500, "count": 2 }],
"usableRemnants": [],
"unusedOffcuts": [],
"stockBarsUsed": 2,
"stockBarsLowerBound": 2,
"totalWaste": 984,
"optimal": true,
"verified": true,
"solveTimeMs": 12.5
},
"stats": {
"totalParts": 8, "totalPartsLength": 15600, "totalUsedLength": 16600,
"yieldPercent": 93.98, "cutCount": 8, "layoutCount": 1,
"totalCost": null, "trueWaste": 984
},
"costLowerBound": null,
"meta": { "method": "balanced", "kerf": 4, "tier": "public",
"poweredBy": "linearcutting.com" }
} | Field | Description |
|---|---|
plan.combinations[] | One entry per distinct layout. usedComb.combination lists the parts cut from that bar, length is the source, source is stock or offcut, and count is how many bars to cut that way. |
usedComb.waste | total = source − parts; split into saw (kerf + trim) and remnant (the leftover end). |
plan.purchases[] | The shopping list: length, count, plus price/cost when you supplied prices. |
plan.usableRemnants | Leftovers ≥ minRemnant, largest first: tomorrow's offcut stock. |
plan.unusedOffcuts | Offcuts the plan didn't need. |
plan.stockBarsUsed | Total parts to purchase. |
plan.optimal | Proof. True only when the plan met a provable floor (or the search completed exhaustively). Safe to show users as "best possible". |
plan.verified | Always true on success: demand matched, every layout physically fits, offcut supply respected. |
plan.stockBarsLowerBound, costLowerBound | Provable minimums. When optimal is false, the gap tells you the most this plan could be off by. |
stats | Job totals: parts, lengths, yieldPercent, cutCount, layoutCount, totalCost, trueWaste, and byMaterial when material groups are used. |
POST /v1/optimise/pdf
Same request body, plus an optional jobName. Returns application/pdf: page 1 is a project-manager summary (purchase order, costs, the optimality certificate); the pages after it are saw-operator sheets with running cut positions, piece labels and a tick box per bar. Keyed callers get their attribution or branding rendered.
curl -X POST https://api.linearcutting.com/v1/optimise/pdf \
-H "Content-Type: application/json" \
-H "X-API-Key: lk_live_9f2c..." \
-d '{ "parts": [{"length": 2400, "quantity": 4}], "stockLength": 6500,
"kerf": 4, "jobName": "Smith deck order" }' \
--output cutting-plan.pdf GET /v1/health
{ "status": "ok", "version": "1.3.0", "solver": "Linear Cutlist Optimizer v2" } Material groups
Tag parts, stocks and offcuts with material and each material is solved as its own job inside one request: cuts only ever land on stock of the same material. Layouts and purchases come back tagged, and stats.byMaterial breaks the numbers down per group.
{
"parts": [
{ "length": 2400, "quantity": 4, "material": "40x40 SHS" },
{ "length": 1300, "quantity": 6, "material": "Pine 90x45" }
],
"stocks": [
{ "length": 6500, "quantity": null, "material": "40x40 SHS" },
{ "length": 5400, "quantity": null, "material": "Pine 90x45", "priority": true }
],
"kerf": 3
} priority: true on a stock or offcut is a soft preference: among plans that are equally good, prioritised items get used first. It never trades away plan quality.
Angle / miter cuts
Give parts a leftAngle and rightAngle and enable angleCuts. Angles are the bottom angles of the piece as it lies; enter a top angle as a negative number (120 and -60 are the same cut). Valid range after that transform: 10 to 170 degrees.
| angleCuts field | Description |
|---|---|
enabled | Master switch. Angles on parts are only accepted when true. |
width | Material width, same unit as lengths. An angled cut reaches sideways by width / tan(angle). |
axialSymmetry | none, X, Y, Z or XYZ: which flips the material allows, which decides how mitres may nest. Square tube is typically XYZ; one-sided material is none. |
Where two neighbouring mitres are parallel, one saw cut makes both edges. Angle-mode layouts add an ordered parts array (with the orientation used) and cutPositions: each cut measured from the bar's zero end, as a [top, bottom] pair when it's angled, or a single number when it's square.
"cutPositions": [[1200, 1300], [2442.265, 2500], 3300] Errors
Error bodies are { "error": "...", "detail": "..." }. The detail is written to be shown to a human: it names the offending part or material.
| Status | error | When |
|---|---|---|
| 400 | validation | Bad lengths or quantities, unknown method, a part that fits no source, demand exceeding finite stock, angle rules broken, stocks and stockLength both sent. |
| 401 | badKey | Missing, unknown or disabled API key. |
| 413 | partsLimit | More parts than your tier allows. |
| 429 | rateLimited | Rate or monthly cap reached. Respect Retry-After. |
| 503 | busy | Solver at capacity. Retry shortly. |
Rate limits
Limits throttle; they never generate an overage bill. Requests that fail validation don't count toward your monthly cap. Full numbers are on the plans page.
| Guard | Free | Paid tiers |
|---|---|---|
| Rate | 10 requests / 5 min per IP | 20-240 / min by tier |
| Total parts | 300 | 500 - 5,000 |
| Distinct part lines | 60 | up to 400 |
| Solve budget | 20 s | 60 - 300 s |
Distinct part lines are capped as well as total parts because solve cost comes from length diversity: 300 identical cuts prove optimal in milliseconds, 300 different ones do not.
Recipes
Cheapest mix from several stock lengths (JavaScript)
const res = await fetch("https://api.linearcutting.com/v1/optimise", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "lk_live_9f2c...",
},
body: JSON.stringify({
parts: [
{ length: 3000, quantity: 8 },
{ length: 1200, quantity: 10 },
],
stocks: [
{ length: 6500, quantity: null, price: 45 }, // unlimited
{ length: 12000, quantity: 4, price: 70 }, // only four on hand
],
offcuts: [{ length: 2600, quantity: 1 }],
kerf: 4,
minRemnant: 400,
method: "balanced", // prices present -> optimises dollars
}),
});
const { plan, stats } = await res.json();
console.log(plan.purchases); // what to buy
console.log(stats.totalCost); // what it costs
console.log(plan.optimal); // true = provably the cheapest Mitered frame parts (Python)
import requests
r = requests.post(
"https://api.linearcutting.com/v1/optimise",
json={
"parts": [
{"length": 1300, "quantity": 2, "leftAngle": 90, "rightAngle": 45},
{"length": 1200, "quantity": 2, "leftAngle": 135, "rightAngle": 60},
],
"stockLength": 3310,
"kerf": 0,
"angleCuts": {"enabled": True, "width": 100, "axialSymmetry": "none"},
},
timeout=30,
)
r.raise_for_status()
for combo in r.json()["plan"]["combinations"]:
print(combo["count"], "x", combo["usedComb"]["cutPositions"]) Handling a slow, big job
Large lists can take seconds to minutes. The search is anytime, so set timeLimit to whatever your UI can wait for and show optimal to tell users whether the answer is proven or simply the best found so far.
{ "parts": [ /* 900 parts */ ], "stockLength": 6500,
"kerf": 4, "timeLimit": 60 }