API
Start here
This is a cut-list optimiser for sheet materials. You send finished part sizes in millimetres and a board size; it works out how many boards are needed and where every part sits on every board, then turns that plan into a drawing, a DXF or a machine program.
No key, no account, no registration. Post JSON, get JSON. Nothing you send is stored — no project row, no cut list on disk, no cookie — so there is nothing to delete afterwards and nothing to come back for later.
The smallest useful request — four shelves out of one 8x4 board:
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/optimise' \
-H 'Content-Type: application/json' \
-d '{
"materials": [
{
"ref": "mdf18",
"panel": { "width": 1220, "length": 2440 },
"parts": [{ "name": "shelf", "length": 600, "width": 400, "qty": 4 }]
}
]
}'
The same thing from JavaScript
const res = await fetch('https://cut.uniboards.co.uk/v2/api/optimise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
"materials": [
{
"ref": "mdf18",
"panel": { "width": 1220, "length": 2440 },
"parts": [{ "name": "shelf", "length": 600, "width": 400, "qty": 4 }]
}
]
}),
});
const { ok, summary } = await res.json();
if (!ok) throw new Error('the service refused that cut list');
console.log(summary.boards_total); // 1
console.log(summary.materials[0].sheets[0].parts[0]);
// { name: 'shelf (1)', part_id: '0_0', number: 1, x: 0, y: 0, width: 600, height: 400, rotated: true }
And the answer, field by field
{
"ok": true,
"engine": { "version": "V40", "algorithm": "guillotine", "ms": 0.4 },
"summary": {
"boards_total": 1,
"parts_placed": 4,
"parts_requested": 4,
"unplaced": 0,
"average_occupancy_pct": 32.249,
"cuttable_on_saw": true,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}
}
engine— which build of the nesting engine produced this and how long it took. Informational.summary.boards_total— how many boards to buy. Across every material in the request.summary.parts_placed/parts_requested/unplaced— did everything fit.unplacedabove zero means some parts are NOT on any board; the plan is still returned.summary.average_occupancy_pct— how much of the bought board is covered by parts. 32.249 here means two thirds of that board is untouched, because four shelves do not fill an 8x4.summary.cuttable_on_saw— whether a panel saw can reach every part, or whether this plan needs a CNC router. Worth showing to anyone with a saw.summary.offcuts,offcut_area_mm2,largest_offcut_mm2,scrap_area_mm2— what the waste actually is. An offcut counts as usable when both of its sides clear the threshold (300 mm by default), so it can be racked and cut again; everything else is scrap. Present per plan, per material and per sheet. Areas are in square millimetres — divide by 1 000 000 for m². Absent when the engine did not report them.sheets[].waste_pct— the same number as occupancy, read the other way round. People fix waste, not occupancy: a sheet showing 88 % waste is the one to look at.materials[].boardsandmaterials[].sheets[]— the boards themselves. One entry per physical board, numbered from 1.sheets[].parts[]— the placement.xandyare the bottom-left corner of the part on that board in millimetres,widthandheightare how it lies there, androtated: truemeans it was turned 90° from the size you asked for. A part withcan_rotate: falseis never rotated.- Offcuts are not in this answer. They are whatever the parts do not cover: work them out
from the rectangles above, or let
/api/layout-pdfdraw them — it marks the three largest usable pieces on every board with their sizes.
Every door this service has
This list is built from the routers themselves when the page is served, so it cannot describe a door that no longer exists or miss one that was just added. Every one of them carries a description. Every door anyone can call without an account is worked through below, with a request and the answer it really produced.
Everything is JSON in and JSON out unless the row says otherwise, everything is anonymous unless it says otherwise, and every door is rate limited per IP unless an API key says who is calling. The three marked costs money call a paid model and need an account.
Every door above also answers under /api/v1/…
(/api/v1/optimise and so on) — the same handler, the same limits, the same answer.
That is the address to build on: a future incompatible change gets its own /api/v2/…
next to it, and /api/v1/… keeps answering exactly as it does today.
| Method | Path | What it is for |
POST |
/api/assistant |
The cut-list assistant. Sees the page’s table, can change it, cannot spend money. costs money |
GET |
/api/assistant/followup |
May this customer send their next message without waiting for the answer to the last one? Costs nothing and changes nothing: the page asks once, and a "no" simply means the message box stays locked while the assistant is working, as it always was. |
GET |
/api/assistant/history |
The signed-in customer’s own past conversations with the assistant: newest first, with the first question as the title. |
GET |
/api/assistant/history/:id |
One of their own conversations, turn by turn. A conversation opens only when every turn in it belongs to that one address. |
GET |
/api/assistant/jobs/:id |
Is a long assistant job — reading a drawing, building a cut list — finished yet? Costs nothing: it reads the answer already written, so a connection that drops does not lose the job. A job that is not yours and one that does not exist answer the same way, 404 with no detail, so job numbers cannot be guessed at one by one. |
POST |
/api/assistant/jobs/:id/stop |
Stop one of your own long assistant jobs when you no longer need it: reading the drawing on would be paying for an answer nobody reads. Rate limited hard — six a minute — because this door changes state rather than reading it. |
GET |
/api/assistant/turn/:id |
Is the answer to one turn of your own conversation ready? Costs nothing: it reads the answer already written, so a connection that drops mid-answer does not lose it and does not need a second paid call. Takes ?turn=N. A turn that is not yours, and one that does not exist, answer the same as one that is not finished yet. |
GET |
/api/auth/me |
Who is calling, and whether the AI doors need an account. |
POST |
/api/auth/request |
Email a sign-in link. No password anywhere in this system. |
POST |
/api/auth/signout |
End the session. |
GET |
/api/collections |
The catalogue’s ranges, for the board picker. |
GET |
/api/collections/:handle |
The boards in one range. |
POST |
/api/enquiry |
Send this cut list to a human, with attachments. Reaches a phone; rate limited hard. |
GET |
/api/extras/catalogue |
Search anything else in the catalogue — hinges, runners, glass. Separate from /materials because that one only returns what can be cut: a hinge has no panel size at all. Prices come back, because the price is why you pick the product. |
GET |
/api/health |
Is it up, is the engine queue busy, how much of today’s paid budget is left. |
POST |
/api/import-program |
Read a machine file (BPP, MPR, CIX) or a DXF drawing back into a part with machining — the workshop's own old programs, opened in the editor. Free, stores nothing. |
POST |
/api/layout-dxf |
The same plan as DXF R12 for a CNC router, millimetres, one layer per purpose. |
POST |
/api/layout-pdf |
The cutting plan as a PDF: three layouts per page, offcuts and machining marked. Send labels: false to print the parts bare — the legend still names them.
|
GET |
/api/limits |
The limits that apply to YOU — parts per calculation, daily AI actions, rate. Comes with the list of what can be configured, so a settings screen is built from the code. |
GET |
/api/machines |
The machine catalogue behind the export module’s picker — one machine, native format or DXF with its own layers, so the page never grows a button per machine. |
POST |
/api/machining-bpp |
One machined part as a BPP part program for a Biesse. Refuses anything that would run off the panel. |
POST |
/api/machining-mpr |
One machined part as an MPR part program for a Homag. Built from sixteen real customer files; refuses the same unsafe geometry as BPP. |
GET |
/api/materials |
Search the boards we cut. Public catalogue data only. |
GET |
/api/my-boards |
The customer’s board library: their own boards and the catalogue boards they use, most recent first. |
POST |
/api/my-boards |
Remember a board in that library, or forget one. Using a board again bumps it up rather than duplicating it. |
GET |
/api/my-orders |
The customer’s orders in the shop: number, date, total, payment, delivery. |
GET |
/api/my-projects |
A signed-in customer’s own projects, including everything from the old calculator. |
GET |
/api/my-projects/:id |
One of the customer’s own projects. |
GET |
/api/my-projects/legacy/:id |
A project by the number the OLD calculator used — what a years-old email links to. |
POST |
/api/optimise |
Nest a cut list. The only door that runs the shared engine; everything else works off the plan it returns. Takes an optional mode — see the modes below.
|
POST |
/api/order |
Raise a draft order for the list, with a payment link. |
GET |
/api/projects |
The projects saved by this browser, or by this account when signed in. |
POST |
/api/projects |
Save a project; the link it returns is the key to it. |
DELETE |
/api/projects/:token |
Delete a saved project. |
GET |
/api/projects/:token |
Open a saved project by its link. |
POST |
/api/projects/claim |
Attach projects this browser saved before signing in to the account now signed in. |
POST |
/api/quote |
Price a plan that was already nested — boards, edging, VAT. No geometry is recalculated. |
POST |
/api/read-cut-list |
Read a photo, PDF or spreadsheet into cut list rows. Costs money per call; behind sign-in. costs money |
GET |
/api/read-quota |
How many paid AI actions (file readings and assistant messages) you have left today. Free, changes nothing. |
POST |
/api/shaker-door |
A Shaker front described once - frame width, how many panels, where the rail sits - and answered as ready pockets. Saves drawing dozens of lines by hand. |
POST |
/api/shape-from-cut |
One straight cut across a part, turned into the part’s own outline. Answers with BOTH halves and their areas — which one is the part is your call, not ours. |
POST |
/api/sheet-bpp |
One sheet of the plan as a BPP part program: every part cut out by its contour, with its own drilling. One file per sheet — that is how a sheet reaches the machine. |
POST |
/api/sheet-mpr |
The same sheet program in MPR — the Homag woodWOP format. Same body as sheet-bpp; the writer differs, not the request. |
POST |
/api/sheet-origins-csv |
The start-points report for one sheet as CSV — where each part’s own zero ended up on the board, and whether it turned. Same numbers sheet-bpp and sheet-mpr write into the machine file, so the printed sheet and the file cannot disagree. Same body as sheet-bpp. Free, no sign-in. |
GET |
/api/sheets |
Standard UK sheet sizes, and mm↔feet conversion. |
POST |
/api/transcribe |
A voice recording to text, for the assistant’s input box. costs money |
GET |
/api/v1/health |
Is it up, is the engine queue busy, how much of today’s paid budget is left. |
POST |
/api/v1/import-program |
Read a machine file (BPP, MPR, CIX) or a DXF drawing back into a part with machining — the workshop's own old programs, opened in the editor. Free, stores nothing. |
POST |
/api/v1/layout-dxf |
The same plan as DXF R12 for a CNC router, millimetres, one layer per purpose. |
POST |
/api/v1/layout-pdf |
The cutting plan as a PDF: three layouts per page, offcuts and machining marked. Send labels: false to print the parts bare — the legend still names them.
|
GET |
/api/v1/machines |
The machine catalogue behind the export module’s picker — one machine, native format or DXF with its own layers, so the page never grows a button per machine. |
POST |
/api/v1/machining-bpp |
One machined part as a BPP part program for a Biesse. Refuses anything that would run off the panel. |
POST |
/api/v1/machining-mpr |
One machined part as an MPR part program for a Homag. Built from sixteen real customer files; refuses the same unsafe geometry as BPP. |
POST |
/api/v1/optimise |
Nest a cut list. The only door that runs the shared engine; everything else works off the plan it returns. Takes an optional mode — see the modes below.
|
POST |
/api/v1/quote |
Price a plan that was already nested — boards, edging, VAT. No geometry is recalculated. |
POST |
/api/v1/shaker-door |
A Shaker front described once - frame width, how many panels, where the rail sits - and answered as ready pockets. Saves drawing dozens of lines by hand. |
POST |
/api/v1/shape-from-cut |
One straight cut across a part, turned into the part’s own outline. Answers with BOTH halves and their areas — which one is the part is your call, not ours. |
POST |
/api/v1/sheet-bpp |
One sheet of the plan as a BPP part program: every part cut out by its contour, with its own drilling. One file per sheet — that is how a sheet reaches the machine. |
POST |
/api/v1/sheet-mpr |
The same sheet program in MPR — the Homag woodWOP format. Same body as sheet-bpp; the writer differs, not the request. |
POST |
/api/v1/sheet-origins-csv |
The start-points report for one sheet as CSV — where each part’s own zero ended up on the board, and whether it turned. Same numbers sheet-bpp and sheet-mpr write into the machine file, so the printed sheet and the file cannot disagree. Same body as sheet-bpp. Free, no sign-in. |
GET |
/api/v1/sheets |
Standard UK sheet sizes, and mm↔feet conversion. |
GET |
/auth |
The link from the sign-in email lands here: it swaps the token for a cookie and sends you back to the calculator. |
GET |
/staff |
A one-time handoff from the authenticated Uniboards admin. It starts an administrator session and selects the requested project without becoming that customer. |
The public doors, one by one
Every request below was posted to a running server and every answer is what came back, captured on 1 September 2026. Nothing here is written out by hand from memory.
POST /api/optimise
Nest a cut list. The only door that runs the shared engine; everything else works off the plan it returns. Takes an optional mode — see the modes below.
Required
materials[]— at least one, and no more than the material limit below.materials[].panel—{ width, length }of the board, in millimetres.materials[].parts[]— at least one part per material.parts[].length, parts[].width— the finished size in millimetres. Length runs along the grain.
Optional
parts[].qty— how many of this part. Default 1.parts[].name— echoed back on every copy asname (1),name (2)…parts[].can_rotate—falselocks this part to the grain.parts[].bevel— for a part with one straight angled edge — a corner door, a panel under a rake:{ corner, length, width }.cornerisBL/BR/TL/TRin the part's own axes (x along the length), and the two numbers say how much of the length and of the width the cut eats from that corner. Two identical bevelled parts are packed as ONE rectangle, so an angled list can need half the boards; the answer still places each part separately, in its real position.materials[].ref— your own id for the board. Echoed back, and the only thing the pricing door matches on.materials[].title— a readable name. Echoed back on the plan; never sent to the engine.materials[].can_rotate—falsefor a board with a grain or a pattern direction.mode— one of the cutting modes below. Defaultguillotine. An unknown value falls back to the default rather than failing.params.spacing— saw kerf between parts, mm. Default 4.5.params.edge_spacing— trim off the board edge, mm. Default 0.params.small_edge_guard_mm— keep small parts at least this far from the board edge, mm. Default 0 (off) — a small part right on the edge chips on the saw. The answer says whether it was applied (summary.small_edge_guard_applied).materials[].stock[]— offcuts you already have:{ length, width, qty }in millimetres. They are cut from first, and the answer says how many sheets came off the rack (summary.from_stock) and how many must be bought (summary.to_buy). A sheet cut from an offcut carriesfrom_stock: trueand its ownpanel.
Request
{
"materials": [
{
"ref": "mdf18",
"panel": { "width": 1220, "length": 2440 },
"parts": [{ "name": "shelf", "length": 600, "width": 400, "qty": 4 }]
}
]
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/optimise' \
-H 'Content-Type: application/json' \
-d '{
"materials": [
{
"ref": "mdf18",
"panel": { "width": 1220, "length": 2440 },
"parts": [{ "name": "shelf", "length": 600, "width": 400, "qty": 4 }]
}
]
}'
Answer
{
"ok": true,
"engine": { "version": "V40", "algorithm": "guillotine", "ms": 0.4 },
"summary": {
"boards_total": 1,
"parts_placed": 4,
"parts_requested": 4,
"unplaced": 0,
"average_occupancy_pct": 32.249,
"cuttable_on_saw": true,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}
}
engine.ms is how long the engine took on that call, so it differs every time.
Refusals
| Status | code | What it means |
422 | invalid_input | The body is not a cut list: materials must be a non-empty array., materials[0].panel needs width and length in mm, both above zero. |
422 | invalid_input | Over a limit: Too many parts: 5000. This free door allows 200. Send us the list and we will run it in full. |
422 | invalid_input | The engine refused the geometry: part does not fit the panel. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
503 | busy | The engine queue is full. Retry-After: 5 — retry, do not give up. |
504 | timeout | The engine did not answer in time. The plan is not half-made; nothing was kept. |
502 | — | The engine answered with something we cannot use. Not your request. |
Free and unmetered beyond the rate limit. Body up to 512kb; shares the optimise request budget.
POST /api/quote
Price a plan that was already nested — boards, edging, VAT. No geometry is recalculated.
Required
materials[]— at least one.materials[].ref— the catalogue reference. Get one fromGET /api/materials?q=; a ref that is not a catalogue product comes back with no price rather than an error.materials[].sheets— how many boards of it — theboardsthe plan returned.
Optional
materials[].panel— the board size. Without it the cutting labour cannot be worked out and a warning says so.materials[].title— a readable name, used in the warnings.
Request
{ "materials": [{ "ref": "41948204007593", "panel": { "width": 1220, "length": 2440 }, "sheets": 2 }] }
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/quote' \
-H 'Content-Type: application/json' \
-d '{ "materials": [{ "ref": "41948204007593", "panel": { "width": 1220, "length": 2440 }, "sheets": 2 }] }'
Answer
{
"ok": true,
"quote": {
"currency": "GBP",
"lines": [
{
"kind": "board",
"material_ref": "41948204007593",
"label": "FINSA Hidrofugo Moisture Resistant MDF 1220x2440 8'x4' - 18mm",
"product_title": "FINSA Hidrofugo Moisture Resistant MDF 1220x2440 8'x4'",
"variant_title": "18mm",
"image_url": "https://cdn.shopify.com/s/files/1/0541/7071/4281/files/FinsaHidrofugo1.jpg",
"sku": "Finsa Hidrofugo MR MDF 8x4 18mm",
"qty": 2,
"unit_price": 54.97,
"amount": 109.94,
"edged": false
},
{
"kind": "cutting",
"code": "cutting_service",
"covers": ["41948204007593"],
"tier": "8x4",
"service": "cut_only",
"label": "Cutting service — 8'x4' Cut only",
"qty": 2,
"unit_price": 12,
"amount": 24,
"note": "2 boards × £12"
}
],
"subtotal_ex_vat": 133.94,
"vat_rate": 0.2,
"vat_amount": 26.79,
"total_inc_vat": 160.73,
"excludes": ["Delivery not included — quoted separately."],
"warnings": [],
"complete": true,
"priced_at": "2026-09-01T12:13:40.171Z",
"rules_snapshot": "… trimmed here; the real answer carries the pricing rules that produced these lines …"
}
}
Prices are the shop's live catalogue prices, so these numbers are what the board cost on the day this page was written. complete: false plus a warnings entry means part of the total is missing — show the warning, do not hide it. The real answer also carries the whole rules_snapshot — the pricing rules that produced these lines — and the real image_url has a cache-busting suffix; both are shortened above for length.
Refusals
| Status | code | What it means |
422 | invalid_input | materials must be a non-empty array. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
503 | not_configured | This site has no catalogue behind it, so nothing can be priced. |
504 | timeout | The catalogue did not answer in time. |
502 | — | The catalogue refused. Not your request. |
Free and unmetered beyond the rate limit. Body up to 512kb; shares the quote request budget.
POST /api/layout-pdf
The cutting plan as a PDF: three layouts per page, offcuts and machining marked. Send labels: false to print the parts bare — the legend still names them.
Required
materials[]— the plan's ownsummary.materials, handed straight back.materials[].panel, materials[].sheets[].parts[]— each part withx, y, width, height.
Optional
labels—falseprints the parts bare — no name written inside the rectangle. The legend still names them.materials[].title— the heading above each board. Falls back toref, then to "Board".
Request
This is { "materials": … } taken verbatim from the answer above. Nothing is nested a second time.
{
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/layout-pdf' \
-o cut-plan.pdf \
-H 'Content-Type: application/json' \
-d '{
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}'
Answer
A file, not JSON:
Content-Type: application/pdf
Content-Disposition: attachment; filename="cut-plan.pdf"
A4 PDF, three layouts per page, beginning %PDF-1.3. The one-board plan above came back just under 2 kB (the exact size moves: a PDF carries its own creation date).
Refusals
| Status | code | What it means |
422 | invalid_input | materials must be a non-empty array., or a board with no usable size. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 1mb; shares the layout-pdf request budget.
POST /api/layout-dxf
The same plan as DXF R12 for a CNC router, millimetres, one layer per purpose.
Required
materials[]— exactly the body /api/layout-pdf takes.
Optional
layers— what to call the layers, when your machine expects its own names:{ board, parts, text, machining, blank }. Anything you leave out keeps our name. Spaces and punctuation become underscores — a layer name in R12 cannot carry them.
Request
{
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/layout-dxf' \
-o cut-plan.dxf \
-H 'Content-Type: application/json' \
-d '{
"materials": [
{
"ref": "mdf18",
"title": null,
"can_rotate": true,
"panel": { "width": 1220, "length": 2440 },
"boards": 1,
"occupancy_pct": 32.249,
"sheets": [
{
"board": 1,
"occupancy_pct": 32.249,
"waste_pct": 67.8,
"offcuts": 1,
"offcut_area_mm2": 1969578,
"largest_offcut_mm2": 1969578,
"scrap_area_mm2": 47320,
"free_area_mm2": 2016898,
"parts": [
{
"name": "shelf (1)",
"part_id": "0_0",
"number": 1,
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (2)",
"part_id": "0_0",
"number": 2,
"x": 0,
"y": 404.5,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (3)",
"part_id": "0_0",
"number": 3,
"x": 0,
"y": 809,
"width": 600,
"height": 400,
"rotated": true
},
{
"name": "shelf (4)",
"part_id": "0_0",
"number": 4,
"x": 0,
"y": 1213.5,
"width": 600,
"height": 400,
"rotated": true
}
]
}
]
}
]
}'
Answer
A file, not JSON:
Content-Type: application/dxf
Content-Disposition: attachment; filename="cut-plan.dxf"
DXF R12, plain text, millimetres ($INSUNITS 4). Layers: BOARD, PARTS, TEXT, MACHINING. The one-board plan above came back as 489 lines / 1,817 bytes. Several boards are laid out side by side in one drawing.
Refusals
| Status | code | What it means |
422 | invalid_input | Nothing to export — calculate a plan first., or A material has no usable panel size. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 1mb; shares the layout-pdf request budget.
POST /api/machining-bpp
One machined part as a BPP part program for a Biesse. Refuses anything that would run off the panel.
Required
part.length, part.width— the part, in millimetres.part.machining[]— at least one operation. A part with none is refused: an empty program is not a file anyone wants on a machine.
Optional
part.name— becomes the file name, cut down to letters, digits,-and_.part.thickness— the board thickness. Defaults to the setting below.part.stock—{ length, width }of the piece actually on the machine, when it is bigger than the part. The part is centred in it and the outline is cut.part.variables— the program's own variables —[{ name, value }], the way BiesseWorks keeps them. A value is a number (29.8) or a formula of names, numbers,+ - * /and brackets.LPX,LPYandLPZare the panel this file declares, soLPY + 40counts from the real board. Any numeric field of any operation may name a variable instead of a number; the values are worked out before the file is written. An unknown name, a circle or a division by zero is refused with words rather than written as a number nobody meant.settings— the workshop's own machine settings — see the block under this table. They travel in the request because this service stores nothing about anybody.
Request
Operations are kind: "hole", "holes-row", "groove", "path", "edge-holes" (MPR only — no BPP sample has a horizontal drilling line to copy, so BPP refuses it rather than inventing one), and anything else is treated as a hinge cup, as above.
{
"part": {
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 700] }]
}
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/machining-bpp' \
-o door.bpp \
-H 'Content-Type: application/json' \
-d '{
"part": {
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 700] }]
}
}'
Answer
A file, not JSON:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="door.bpp"
A BiesseWorks part program, VER=150 by default, CRLF. The part above came back as 53 lines / 1,158 bytes, starting:
[HEADER]
TYPE=BPP
VER=150
[DESCRIPTION]
TEST FILE - not verified on a machine. Dry-run first. |
[VARIABLES]
PAN=LPX|800||4|
PAN=LPY|400||4|
PAN=LPZ|18||4|
Refusals
| Status | code | What it means |
422 | invalid_input | The part has no size., That part has no machining on it — a BPP would be an empty program., or an operation that would run off the panel. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 512kb; shares the layout-pdf request budget.
POST /api/machining-mpr
One machined part as an MPR part program for a Homag. Built from sixteen real customer files; refuses the same unsafe geometry as BPP.
Required
part— exactly the body /api/machining-bpp takes.
Optional
settings— the same settings block. Fields the format has no confirmed place for are ignored, not guessed at.
Request
{
"part": {
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 700] }]
}
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/machining-mpr' \
-o door.mpr \
-H 'Content-Type: application/json' \
-d '{
"part": {
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 700] }]
}
}'
Answer
A file, not JSON:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="door.mpr"
A Homag woodWOP program. The same part came back as 142 lines / 1,593 bytes, starting:
[H
VERSION="4.0 Alpha"
WW="6.0.62"
OP="1"
Refusals
| Status | code | What it means |
422 | invalid_input | Same refusals as BPP, plus anything MPR itself cannot carry. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 512kb; shares the layout-pdf request budget.
POST /api/sheet-bpp
One sheet of the plan as a BPP part program: every part cut out by its contour, with its own drilling. One file per sheet — that is how a sheet reaches the machine.
Required
material.panel—{ length, width }of the board, andthicknessif it is not 18.sheet.parts[]— the parts on this one board, withx, y, width, height— the plan's own numbers.
Optional
material.title— the file name. Falls back toref, then to "board".sheet.board— the board number, which goes in the file name. Default 1.sheet.parts[].machining— per part, the same operations /api/machining-bpp takes. They move with the part.sheet.parts[].variables— per part, the same variables /api/machining-bpp takes. HereLPXandLPYare the BOARD, because the panel on the machine is the board.sheet.parts[].ordered— the size as ordered, written into the file for the operator.settings— the same machine settings block.
Request
One board per call. A plan with four boards is four calls and four files — that is how a sheet reaches the machine.
{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/sheet-bpp' \
-o MDF_18_sheet-1.bpp \
-H 'Content-Type: application/json' \
-d '{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}'
Answer
A file, not JSON:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="MDF_18_sheet-1.bpp"
The sheet above came back as 60 lines / 1,900 bytes. The panel in the file is the BOARD (LPX 2440, LPY 1220) and each part is cut out of it by its own contour.
Refusals
| Status | code | What it means |
422 | invalid_input | A board with no size, or an operation that would run off it. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 1mb; shares the layout-pdf request budget.
POST /api/sheet-mpr
The same sheet program in MPR — the Homag woodWOP format. Same body as sheet-bpp; the writer differs, not the request.
Required
material, sheet— exactly the body /api/sheet-bpp takes.
Optional
settings— the same machine settings block.
Request
{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/sheet-mpr' \
-o MDF_18_sheet-1.mpr \
-H 'Content-Type: application/json' \
-d '{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}'
Answer
A file, not JSON:
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="MDF_18_sheet-1.mpr"
The same sheet came back as 243 lines / 2,552 bytes.
Refusals
| Status | code | What it means |
422 | invalid_input | Same refusals as the BPP sheet. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 1mb; shares the layout-pdf request budget.
POST /api/sheet-origins-csv
The start-points report for one sheet as CSV — where each part’s own zero ended up on the board, and whether it turned. Same numbers sheet-bpp and sheet-mpr write into the machine file, so the printed sheet and the file cannot disagree. Same body as sheet-bpp. Free, no sign-in.
Required
material, sheet— exactly the body /api/sheet-bpp takes.
Optional
settings— the same machine settings block — the report reads the same zero corner as the file does.
Request
One board per call, same as sheet-bpp and sheet-mpr. Free and no sign-in: nothing here is nested again, it only writes out numbers /api/sheet-bpp already had to work out.
{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/sheet-origins-csv' \
-o start-points-MDF_18-sheet-1.csv \
-H 'Content-Type: application/json' \
-d '{
"material": { "title": "MDF 18", "panel": { "length": 2440, "width": 1220, "thickness": 18 } },
"sheet": {
"board": 1,
"parts": [
{
"x": 0,
"y": 0,
"width": 600,
"height": 400,
"ordered": "600 x 400",
"machining": [{ "kind": "hinge-screw", "edge": "L1", "inset": 22, "positions": [100, 500] }]
}
]
}
}'
Answer
A file, not JSON:
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="start-points-MDF_18-sheet-1.csv"
One header row plus one row per part: <code>#,part,size,x,y,turned_deg,ops</code>. <code>x,y</code> is where that part’s own zero corner sits on the BOARD, measured from the corner <code>settings.origin</code> names (default bottom-left) — the same numbers written into the sheet’s BPP/MPR file. <code>turned_deg</code> is 90 when the part lies across the board, 0 otherwise. The sheet above came back as 2 lines.
Refusals
| Status | code | What it means |
422 | invalid_input | A board with no size, or a machining variable that will not resolve. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 1mb; shares the layout-pdf request budget.
POST /api/import-program
Read a machine file (BPP, MPR, CIX) or a DXF drawing back into a part with machining — the workshop's own old programs, opened in the editor. Free, stores nothing.
Required
text— the whole machine file as text. Read it as latin1 — these files are not UTF-8.
Optional
name— the file name. Only used to break a tie when the first line does not say which format it is.
Request
The body is { "text": "…the whole file…", "name": "door.bpp" }. Read the file as latin1, not UTF-8. The answer below is our own BPP for the door above, posted straight back.
jq -Rs '{ name: "door.bpp", text: . }' door.bpp \
| curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/import-program' \
-H 'Content-Type: application/json' --data-binary @-
Answer
{
"ok": true,
"format": "BPP (VER=150)",
"part": {
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"crn": "2",
"crnBase": "2",
"machining": [
{
"kind": "hole",
"corner": "BL",
"x": 100,
"y": 22,
"diameter": 35,
"depth": 12.5,
"through": false,
"side": "B",
"tooltype": "NORMALE",
"repeat": 1,
"stepX": 0,
"stepY": 0
},
{
"kind": "hole",
"corner": "BL",
"x": 700,
"y": 22,
"diameter": 35,
"depth": 12.5,
"through": false,
"side": "B",
"tooltype": "NORMALE",
"repeat": 1,
"stepX": 0,
"stepY": 0
}
]
},
"parts": [
{
"name": "door",
"length": 800,
"width": 400,
"thickness": 18,
"crn": "2",
"crnBase": "2",
"machining": [
{
"kind": "hole",
"corner": "BL",
"x": 100,
"y": 22,
"diameter": 35,
"depth": 12.5,
"through": false,
"side": "B",
"tooltype": "NORMALE",
"repeat": 1,
"stepX": 0,
"stepY": 0
},
{
"kind": "hole",
"corner": "BL",
"x": 700,
"y": 22,
"diameter": 35,
"depth": 12.5,
"through": false,
"side": "B",
"tooltype": "NORMALE",
"repeat": 1,
"stepX": 0,
"stepY": 0
}
]
}
],
"notes": []
}
A file that turns out to be a whole SHEET comes back with parts[] as well — one entry per part cut out of it. notes[] is where the reader says what it could not carry over (a pocket it has no operation for, a stock bigger than the part), in plain English, meant to be shown to the person who opened the file.
Refusals
| Status | code | What it means |
422 | invalid_input | That file is empty., This MPR has no part size in it. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 2mb; shares the layout-pdf request budget.
POST /api/shape-from-cut
One straight cut across a part, turned into the part’s own outline. Answers with BOTH halves and their areas — which one is the part is your call, not ours.
Required
from— where the cut meets the outline, [x, y] in mm from the bottom-left corner of the part.to— where it leaves the outline, [x, y].
Optional
length— length of the rectangle the part is cut from, mm. Give length and width, or give contour.width— width of that rectangle, mm.contour— the part outline as points, when it is not a rectangle: [{x, y}, ...].
Request
{ "length": 485, "width": 200, "from": [255, 200], "to": [485, 0] }
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/shape-from-cut' \
-H 'Content-Type: application/json' \
-d '{ "length": 485, "width": 200, "from": [255, 200], "to": [485, 0] }'
Answer
{
"ok": true,
"pieces": [
{
"points": [{ "x": 255, "y": 200 }, { "x": 0, "y": 200 }, { "x": 0, "y": 0 }, { "x": 485, "y": 0 }],
"area": 74000
},
{ "points": [{ "x": 485, "y": 0 }, { "x": 485, "y": 200 }, { "x": 255, "y": 200 }], "area": 23000 }
],
"note": "Which piece is the part is not decided here — pick one and set it as the part contour. Then the outline is cut in one pass and the offcut never comes loose under the cutter."
}
Areas are in mm². They add up to the rectangle they came from — 74000 + 23000 = 485 × 200.
Refusals
| Status | code | What it means |
422 | invalid_input | the cut starts at …, which is not on the outline. A cut that shapes the part has to run edge to edge. — a cut begun inside the part does not separate anything. Also both ends sit on the same side, so the cut takes nothing off., a cut from a point to itself, and a part with no outline. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 256kb; shares the optimise request budget.
POST /api/shaker-door
A Shaker front described once - frame width, how many panels, where the rail sits - and answered as ready pockets. Saves drawing dozens of lines by hand.
Required
length— height of the door, mm - the way it hangs.width— width of the door, mm.
Optional
frame— width of the outer frame on all four sides, mm. Default 80 - "usually 80 or 90".panels— how many panels: 1 for a drawer front, 2 for a tall door with a rail, 3-4 for classics. Default 1.rail_at— height of the rail above the bottom, mm. Default 900 - worktop level, so rails line up across a run. Only for 2 panels.rail— thickness of the rail between panels, mm. Defaults to the frame width.depth— how deep the panels are milled, mm. Default 6.radius— corner radius of each panel, mm - the radius of your finishing cutter.overlap— how much the milling passes overlap, per cent. Default 80.
Request
{ "length": 2000, "width": 500, "frame": 80, "panels": 2, "depth": 6, "radius": 8 }
curl -s -X POST 'https://cut.uniboards.co.uk/v2/api/shaker-door' \
-H 'Content-Type: application/json' \
-d '{ "length": 2000, "width": 500, "frame": 80, "panels": 2, "depth": 6, "radius": 8 }'
Answer
{
"ok": true,
"pockets": [
{
"kind": "pocket",
"id": "shaker-1",
"x": 470,
"y": 250,
"length": 780,
"width": 340,
"radius": 8,
"depth": 6,
"overlap": 80
},
{
"kind": "pocket",
"id": "shaker-2",
"x": 1430,
"y": 250,
"length": 980,
"width": 340,
"radius": 8,
"depth": 6,
"overlap": 80
}
],
"note": "Put these on the part as machining. Each one goes to a Homag as a single Tasche macro and to a Biesse as concentric passes, because BiesseWorks has no pocket of its own."
}
The rail sits at 900 mm above the bottom by default, so the lower panel is shorter than the upper one.
Refusals
| Status | code | What it means |
422 | invalid_input | a 80 mm frame on both sides leaves nothing of a 150 mm door., or a rail that does not fit between the frames. |
429 | rate_limited | Too many requests from this address. Retry-After says how many seconds to wait. |
Free and unmetered beyond the rate limit. Body up to 256kb; shares the optimise request budget.
GET /api/sheets
Standard UK sheet sizes, and mm↔feet conversion.
Required
None.
Optional
(nothing)— the whole table of standard UK sheet sizes.?width=&length=— describe one board: its name, area and imperial size.?mm=— millimetres to feet and inches.?ft=— feet to millimetres.
Request
curl -s 'https://cut.uniboards.co.uk/v2/api/sheets?mm=2440'
Answer
{ "ok": true, "converted": { "mm": 2440, "inches": 96.06, "feet": 8.005, "text": "8 ft 0.06 in" } }
With no query it answers { "ok": true, "sheets": [ … ] } — six boards, each with width, length, name, note, area_m2 and its imperial equivalent. 8x4 is 1220 × 2440 (2.977 m²); Egger MFC is 2070 × 2800, which is the size most cut lists get wrong.
Refusals
| Status | code | What it means |
— | — | This door has no failure mode: a nonsense query gets the whole table. |
No engine, no upstream, no rate limit worth enforcing — the answer is a constant.
GET /api/health
Is it up, is the engine queue busy, how much of today’s paid budget is left.
Required
None.
Optional
None.
Request
curl -s 'https://cut.uniboards.co.uk/v2/api/health'
Answer
{
"ok": true,
"gate": { "running": 0, "waiting": 0, "concurrency": 2 },
"vision_used_today": 0,
"vision_budget_today": 200
}
gate is the engine queue right now: how many nests are running, how many are waiting, and how many may run at once. waiting climbing towards the queue limit is why a call comes back busy. The two vision_ numbers are today's paid budget for the whole service, not yours.
Refusals
| Status | code | What it means |
— | — | It answers 200 or it is down. |
Free, unlimited, and it says nothing about who called.
Machine settings
The four machine-program doors take an optional settings object.
It travels in the request rather than living on the server because this service keeps nothing about
anybody, and tool numbers are exactly what two workshops will never agree on. Everything has a
default, so a request with no settings still produces a file.
version— 150 (default) or 120, the two BiesseWorks dialects. BPP only.thickness— the board, mm. Default 18, overridden bypart.thickness.cupDiameter,cupDepth— the hinge cup. Default 35 mm × 12.5 mm, which is a Blum cup in an 18 mm door.boreTool,cupTool,routTool— tool codes for VER=120, which names its tools in the file.cutToolName,cutSpeed,cutDiameter— the cutter that takes the part out of the board. Defaults 8MMCOMPR, 6000 mm/min, 8 mm.cutCorrection—auto(default),left,rightorcentral: which side of the line the cutter runs.autoworks it out from the contour; a named side that disagrees with the contour is refused with the millimetres the part would lose. It is a check, not a field: no sample file confirms where (CRC) sits in a BPP milling row, so it is not written into one — in MPR it is, as RK.crn—"1"(default) to"4": the reference corner of a BPP milling row, the four-corner picture inside the operation. It is not the panel zero (origin) — it mirrors Y for that one operation, so changing it moves the toolpath.cutLeadIn,cutLeadOut,cutLeadAngle— how the cutter enters and leaves.autoby default.origin— where the machine's zero corner is:BL(default),BR,TL,TR. It moves every coordinate in the file and nothing else.offsetX,offsetY— shift every coordinate, mm.
Cutting modes
The mode on /api/optimise chooses how the parts are
packed. The engine's own algorithm names never leave the service — these are the whole list, and an
unknown value falls back to the default rather than failing the job.
guillotinedefault — straight cuts right across the board, in the order a panel saw makes them. What most workshops can actually cut.nesting— the engine's tightest packing. Fewer boards, but a part can end up surrounded by others — a CNC router job, not a saw one.nesting-small-inside— nesting, with the narrow parts (any side under 200 mm) tucked into the gaps between the big ones instead of taking a board edge each.
The shape of an answer
Success is { "ok": true, … } and failure is
{ "ok": false, "error": { "code", "message" } } — the message is written to be shown to a
person as it is. A door that returns a file returns that file on success and this same JSON shape on
failure, so always check the status before treating the body as a download.
| Status | code | What it means, and what to do |
422 | invalid_input | Your request. The message says which field and why — show it. Nothing was run. |
401 | signin_required | Only the three paid doors, marked in the table above. No public door on this page can return it. |
413 | too_large | The body is over that door's size limit. Split the request. |
429 | rate_limited | Too many requests from your address. Retry-After is in seconds; the message says the same in English. |
503 | busy | The engine queue is full. Retry-After: 5. This is temporary and worth retrying — it is not a failure of your request. |
503 | not_configured | That door needs an upstream this site does not have (pricing on a site with no shop behind it). |
504 | timeout | The engine or the catalogue did not answer in time. Nothing half-done was kept. |
502 | various | An upstream answered with something unusable. Not your request; retrying later is reasonable. |
500 | internal | Our fault. It is logged with no cut list in it. |
The limits, honestly
What is free, and what is not
Free, unmetered, no account: the calculation itself
(/api/optimise), the PDF, the DXF, every machine program (BPP and MPR, part and sheet),
reading a machine file back in, the sheet-size table and the health check. There is no daily quota on
any of them and no per-day counter is even kept — only the per-minute rate below.
Costs real money per call, and is therefore counted per day:
POST /api/read-cut-list, POST /api/assistant, POST /api/transcribe. These call a paid model, need an account
(free, by email link, no password), and are capped at 500 actions a
day once you are signed in (30 without an account), plus a ceiling of
10000 for the whole service per day.
GET /api/health shows how much of today's service-wide budget is gone.
Nothing on this page requires an account or a key to use it once. If you need more than the numbers below, ask — that is a cheaper conversation than a workaround, and it is also how an API key below gets issued.
API keys — for a system, not a person
Every door works with no key at all, exactly as documented above — the limits are then per IP address, as they always were. A key exists for the opposite case: a system that calls this API on behalf of many people (an agent, a dispatcher) and needs its own quota instead of sharing one IP's budget with everyone else behind the same address, or with a stranger on the same network.
Ask for one — none are issued automatically. Send it as
x-api-key: <your key> on any request, including the plain
/api/… address; it does not require /api/v1/… and /api/v1/…
does not require it. Only the hash of a key is ever kept on this server, the same way a sign-in link
is: a leaked copy of its configuration is a list of useless hashes, not a list of working keys.
Three different answers, never one blanket "access denied":
| Situation | What happens |
No x-api-key header at all | Anonymous, exactly as above — not a refusal. |
| Header present, key not recognised | 401 invalid_api_key — a typo or a revoked key must not look like a working one with less quota. |
| Header present, key recognised, over its own rate or size limit | The same 429 rate_limited / 422 invalid_input as everyone else, just against the key's own numbers below rather than the IP's. |
A key does not, by itself, open POST /api/assistant or the
other AI doors below — those sit behind a sign-in the core checks by session, and a calculator key is
not a session. Building that bridge honestly is separate work, not done here.
How much you can send at once
The engine itself accepts far more, but it shares its workers with a live shop, so the public number is deliberately below its ceiling. Signing in raises it; it costs nothing and takes an email link.
| You are | Parts per calculation | Materials per calculation | Paid AI actions a day |
anon | 200 | 6 | 30 |
signed_in | 1000 | 18 | 500 |
api_key | 1000 | 18 | 500 |
premium | 2000 | 36 | 2000 |
admin | 10000 | 72 | 10000 |
Over the limit is a 422 naming the number, not a truncated
answer. Sizes are per REQUEST — a 900-part list is three calls, and each is a separate plan.
How often you can call
12 requests a minute per address, with a burst of
20. Every answer carries X-RateLimit-Remaining; going over is a
429 with Retry-After in seconds. Budgets are per bucket, and several doors
share one on purpose — the page calls them in a row on the same plan, and separate buckets would mean
four times as many requests get through as the single number suggests.
| Door | Request budget | Body up to |
POST /api/optimise | optimise | 512kb |
POST /api/quote | quote | 512kb |
POST /api/layout-pdf | layout-pdf | 1mb |
POST /api/layout-dxf | layout-pdf | 1mb |
POST /api/machining-bpp | layout-pdf | 512kb |
POST /api/machining-mpr | layout-pdf | 512kb |
POST /api/sheet-bpp | layout-pdf | 1mb |
POST /api/sheet-mpr | layout-pdf | 1mb |
POST /api/sheet-origins-csv | layout-pdf | 1mb |
POST /api/import-program | layout-pdf | 2mb |
POST /api/shape-from-cut | optimise | 256kb |
POST /api/shaker-door | optimise | 256kb |
2 calculations run at once across every caller in
the world, with room for 20 more waiting up to 15 seconds.
Past that it is 503 busy with Retry-After: 5 rather than a queue nobody
comes back to. Everything after the calculation — PDF, DXF, machine programs — is done in this
process and never touches that queue.
MCP: the same tools, for an AI assistant
There is an MCP server at https://cut.uniboards.co.uk/v2/mcp — streamable
HTTP, stateless, nothing to install, and no key needed for the tools listed here. Point any MCP
client at that URL:
{ "mcpServers": { "cut-optimiser": { "type": "http", "url": "https://cut.uniboards.co.uk/v2/mcp" } } }
Or ask it what it has, over plain HTTP:
curl -s -X POST 'https://cut.uniboards.co.uk/v2/mcp' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
https://cut.uniboards.co.uk/v2/mcp/info answers the same list as plain JSON,
for a person or a crawler. The tools, asked of the server itself as this page is built:
optimise_cut_list— Optimise a cut list. Work out how many boards a list of parts needs and where each part sits on each board.read_cut_list— Read a cut list out of a photo, scan, PDF or CSV. Transcribe a cut list from an image or document with a vision model: handwriting, a phone photo of a scribbled sheet, a supplier PDF, a spreadsheet export — behind a read permission of its own, which /mcp/info reports.list_materials— Find a cuttable board in the Uniboards catalogue. Search the boards Uniboards actually cuts to size, by name, decor, thickness or size.export_cut_plan— Turn a plan into a file for a workshop. Take the plan optimise_cut_list just returned and produce the file a workshop actually uses: a PDF to print, or a DXF for a CNC router.price_cut_plan— Price a plan that has already been worked out. Take the plan optimise_cut_list returned, plus the catalogue variant each material is, and get the price: per board, subtotal, VAT and total in GBP.sheet_sizes— Sheet sizes and mm/feet conversion. Standard UK sheet material sizes, and conversion between millimetres and feet/inches.make_machine_program— Turn one machined part into a CNC part program. Build the file a CNC actually runs for ONE part: BPP for a Biesse, MPR for a Homag woodWOP.make_sheet_program— Turn a whole nested sheet into a CNC program. Build one program for a WHOLE sheet: cut every part out of the board and do the machining on each of them, in board coordinates.read_machine_program— Read a CNC program back into parts and machining. The other direction: give it a BPP, MPR, CIX or DXF and get the part back — size, thickness, its outline if it is not a rectangle, and every operation our editor can express.shaker_door— Lay out a Shaker front. A Shaker door described once — how wide the frame is, how many panels, where the rail sits — and answered as ready pockets, in the part's own coordinates.shape_from_cut— Turn one straight cut into the part's own outline. One straight cut across a rectangle (or across a shape you give), answered as BOTH halves with their areas.list_machines— What machines we can write a file for. The machine catalogue: which machines get their own native program (BPP for a Biesse, MPR for a Homag woodWOP) and which get a DXF with their own layer names.
The server is stateless on purpose: a fresh server per request, no session id, nothing remembered between calls. Every tool above answers from its arguments alone, and none of them can reach a project, an order or a customer — the blast radius of a publicly reachable MCP server is whatever its tools can touch, and for a caller without a credential that is a packing engine and a table of sheet sizes. An administrator of this workshop's calculator, sending the admin session their own admin issues, additionally gets tools over that workshop's own projects; they are not part of this public API and are not listed here.
Also worth knowing
- /llms.txt — the same facts written for a model that will not run JavaScript.
- Board area divided by part area always UNDER-estimates the board count, because offcuts are the wrong shape. Only real packing gives a number you can order against.
- A standard UK sheet is 2440 × 1220 mm. Egger MFC is 2800 × 2070 — a cut list drawn on an 8x4 assumption will be wrong for it.
- Length runs along the grain, width across it. A material with
can_rotate: falsekeeps every part the way you gave it. - This page is generated from the routers at boot, and the examples on it are the same objects the test suite posts. If something here is wrong, the code is wrong.