API documentation
The 2D Cut API runs the same optimizer as the app. You send sheets and parts; a worker lays them out and you get back sheets, placements, cuts and offcuts — as JSON or as PDF, DXF, CSV and SVG files.
Optimizations are asynchronous: creating one returns 202 right away with an id. Small jobs usually finish within seconds. Poll the optimization or register a webhook to be told when it is done.
All requests go to https://2dcut.app/v1 over HTTPS with JSON bodies. Every length in a result is in millimetres, whatever unit you sent.
https://2dcut.app/v1 OpenAPI
https://2dcut.app/docs/openapi.yaml Authentication
The API is part of the Pro + API plan. Create keys on the Developer page; a key is shown once, so store it in your secrets manager. Send it as a bearer token:
Authorization: Bearer sk_live_…
Keep keys on your server — never in a browser or a mobile app. Use one key per system and revoke it on the Developer page if it leaks; revocation is immediate. Requests with a missing, unknown or revoked key get 401; keys of an account without the plan get 403 plan_limit.
curl https://2dcut.app/v1/usage \
-H "Authorization: Bearer $TWODCUT_KEY"Errors
Errors use HTTP status codes and a JSON body with a stable, machine-readable code. Validation errors list each problem under fields, keyed by the path of the value.
| Status | code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Missing, unknown or revoked key. |
| 403 | plan_limit | The plan does not include this. feature says what: api, max_pieces, max_materials, cut_type, shapes. |
| 404 | not_found | No such optimization for this account. |
| 409 | idempotency_conflict | The Idempotency-Key was used with a different body. |
| 409 | not_cancellable | The optimization has already finished. |
| 422 | validation_failed | The input is invalid; see fields. |
| 429 | rate_limited | Too many requests; wait Retry-After seconds. |
| 429 | quota_exceeded | The monthly optimizations are used up (limit, resetsAt). |
A rejected request never uses an optimization from your quota; failed and cancelled optimizations are given back.
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"fields": {
"materials.0.parts.1.length": "Must be a number.",
"materials.0.sheets.0.width": "Must be between 1 and 100000 mm."
}
}
} Rate limits & quotas
Each key may make 120 requests per minute. Beyond that you get 429 rate_limited with a Retry-After header. Polling once every one or two seconds per optimization is plenty.
Pro + API includes 2,000 optimizations a month, shared by the app and the API, with up to 20,000 pieces and 50 materials per optimization. Re-sending an input identical to one completed in the last 30 days returns a copy of that result immediately and costs nothing. See Usage for what is left.
HTTP/1.1 429 Too Many Requests
Retry-After: 17
{ "error": { "code": "rate_limited", "message": "Too many requests. Slow down.", "retryAfter": 17 } }Idempotency
Networks fail. To retry a create safely, send an Idempotency-Key header — for example your order number or a UUID (up to 100 visible ASCII characters). A repeat with the same key and the same body returns the original optimization with 200 and Idempotent-Replayed: true instead of starting a new one. The same key with a different body is rejected with 409 idempotency_conflict. Keys are scoped to the API key.
curl https://2dcut.app/v1/optimizations \
-H "Authorization: Bearer $TWODCUT_KEY" \
-H "Idempotency-Key: order-10442" \
-H "Content-Type: application/json" \
-d @job.jsonCreate an optimization
Queues an optimization and returns 202 Accepted with its id, a Location header and its position in the queue. API jobs run with the highest priority.
Body
materialsarray · required- One entry per material; each is optimized on its own sheets. Every material has
name,sheetsandparts— see Input fields. unit"mm" · "cm" · "in"- Unit of every length in the request. Default
"mm". cutType"guillotine" · "nested" · "multistage"- Guillotine (edge-to-edge cuts, panel saws), nested (CNC, no guillotine constraint) or multistage (guillotine with a limited number of stages). Default
"guillotine". quality"fast" · "normal" · "best"- Time the optimizer may spend.
bestfinds better layouts for large jobs but takes longer. Default"normal". kerflength- Blade thickness, removed along every cut. Default
0. advancedobjectminUsefulSize(offcuts smaller than this count as waste),maxCutLength,stages(2–9, multistage),firstDirection("horizontal"or"vertical").
Responses
- 202Queued. Also
200for an idempotent replay. - 422Validation failed;
fieldspoints at the value. - 403Over a plan limit (
plan_limit). - 429Rate limit or monthly quota reached.
curl https://2dcut.app/v1/optimizations \
-H "Authorization: Bearer $TWODCUT_KEY" \
-H "Content-Type: application/json" \
-d '{
"unit": "mm",
"cutType": "guillotine",
"quality": "normal",
"kerf": 4,
"materials": [{
"name": "MDF 18 mm",
"grain": "none",
"sheets": [{ "length": 2800, "width": 2070, "quantity": 10,
"trim": { "top": 10, "left": 10, "bottom": 10, "right": 10 } }],
"parts": [
{ "label": "Side", "length": 800, "width": 600, "quantity": 4 },
{ "label": "Arch", "length": 700, "width": 900, "quantity": 1,
"shape": { "code": "ARCH_SEG", "params": { "F": 250 } } }
]
}]
}'$ch = curl_init('https://2dcut.app/v1/optimizations');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TWODCUT_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'unit' => 'mm',
'kerf' => 4,
'materials' => [[
'name' => 'MDF 18 mm',
'sheets' => [['length' => 2800, 'width' => 2070, 'quantity' => 10]],
'parts' => [['label' => 'Side', 'length' => 800, 'width' => 600, 'quantity' => 4]],
]],
]),
]);
$optimization = json_decode(curl_exec($ch), true)['optimization'];const res = await fetch('https://2dcut.app/v1/optimizations', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TWODCUT_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
unit: 'mm',
kerf: 4,
materials: [{
name: 'MDF 18 mm',
sheets: [{ length: 2800, width: 2070, quantity: 10 }],
parts: [{ label: 'Side', length: 800, width: 600, quantity: 4 }],
}],
}),
});
const { optimization } = await res.json();import os, requests
res = requests.post(
"https://2dcut.app/v1/optimizations",
headers={"Authorization": f"Bearer {os.environ['TWODCUT_KEY']}"},
json={
"unit": "mm",
"kerf": 4,
"materials": [{
"name": "MDF 18 mm",
"sheets": [{"length": 2800, "width": 2070, "quantity": 10}],
"parts": [{"label": "Side", "length": 800, "width": 600, "quantity": 4}],
}],
},
)
optimization = res.json()["optimization"]using var http = new HttpClient { BaseAddress = new Uri("https://2dcut.app/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TWODCUT_KEY"));
var res = await http.PostAsJsonAsync("v1/optimizations", new {
unit = "mm",
kerf = 4,
materials = new[] { new {
name = "MDF 18 mm",
sheets = new[] { new { length = 2800, width = 2070, quantity = 10 } },
parts = new[] { new { label = "Side", length = 800, width = 600, quantity = 4 } },
} },
});
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
var id = body.GetProperty("optimization").GetProperty("id").GetString();Location: /v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS
{
"optimization": {
"id": "01M3RNX0GJ2HR4ZMT4WRD3GDCS",
"status": "queued",
"queuePosition": 1,
"createdAt": "2026-09-30T08:11:37Z",
"finishedAt": null,
"source": "api"
}
} Get an optimization
Returns the optimization with its status: queued (with queuePosition), processing, completed (with result), failed (with error) or cancelled.
Failure codes: invalid_input (the optimizer rejected the data), timeout, engine_error and internal. Failed optimizations do not count towards your quota; retrying after timeout with "quality": "fast" usually works.
curl https://2dcut.app/v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS \
-H "Authorization: Bearer $TWODCUT_KEY"do {
sleep(2);
$ch = curl_init("https://2dcut.app/v1/optimizations/$id");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TWODCUT_KEY')],
]);
$o = json_decode(curl_exec($ch), true)['optimization'];
} while (in_array($o['status'], ['queued', 'processing'], true));let o;
do {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`https://2dcut.app/v1/optimizations/${id}`, {
headers: { Authorization: `Bearer ${process.env.TWODCUT_KEY}` },
});
({ optimization: o } = await res.json());
} while (o.status === 'queued' || o.status === 'processing');import time
while True:
time.sleep(2)
o = requests.get(
f"https://2dcut.app/v1/optimizations/{id}",
headers={"Authorization": f"Bearer {os.environ['TWODCUT_KEY']}"},
).json()["optimization"]
if o["status"] not in ("queued", "processing"):
breakJsonElement o;
do {
await Task.Delay(2000);
o = (await http.GetFromJsonAsync<JsonElement>($"v1/optimizations/{id}"))
.GetProperty("optimization");
} while (o.GetProperty("status").GetString() is "queued" or "processing");List optimizations
The latest optimizations created through the API, newest first, without their full results. Completed ones carry a summary (sheets, yieldPct, unplaced). Query parameter limit: 1–100, default 20.
curl "https://2dcut.app/v1/optimizations?limit=5" \
-H "Authorization: Bearer $TWODCUT_KEY"Cancel an optimization
Cancels a queued or running optimization and gives the optimization back to your quota. Returns the cancelled optimization, or 409 not_cancellable if it has already finished.
curl -X DELETE https://2dcut.app/v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS \
-H "Authorization: Bearer $TWODCUT_KEY"The result object
A completed optimization has a result with an overall summary and, per material, its layouts and the parts that did not fit (unplaced).
- Lengths are millimetres, areas mm². Coordinates start at the top-left corner of the sheet: X along the sheet length, Y down.
- Identical sheets are merged:
countsays how many times to cut that layout. piecesare the placed parts with their box (x,y,w,h) androtated.refpoints back at your part row (p0is the first part of the material).cutsare in cutting order;levelis the guillotine stage.wastesare the offcuts,trim: truefor edge trims.summary.stoppedEarlyis true when the time limit ended the search; the layout is valid but a higherqualitymay find a better one.
"result": {
"unit": "mm",
"cutType": "guillotine",
"summary": { "sheets": 2, "yieldPct": 78.4, "wasteArea": 2461000,
"cutLength": 31640, "unplaced": 0, "stoppedEarly": false },
"materials": [{
"key": "m0",
"name": "MDF 18 mm",
"layouts": [{
"count": 2,
"sheet": { "ref": "s0", "length": 2800, "width": 2070, "isRemnant": false,
"trim": { "top": 10, "left": 10, "bottom": 10, "right": 10 } },
"yieldPct": 78.4,
"pieces": [{ "ref": "p0", "label": "Side", "x": 10, "y": 10,
"w": 800, "h": 600, "rotated": false }],
"cuts": [{ "x1": 814, "y1": 10, "x2": 814, "y2": 2060,
"thickness": 4, "level": 0 }],
"wastes": [{ "x": 2426, "y": 10, "w": 364, "h": 2050, "trim": false }]
}],
"unplaced": []
}]
}Placing shaped parts
A shaped piece is optimized as its bounding box and carries its contour in shape.outline: vertices [u, v, bulge] in the part's own box (origin bottom-left, Y up), where bulge is the DXF arc bulge to the next vertex.
To draw it on the sheet, map each vertex with the piece's x, y and h:
- not rotated:
X = x + u,Y = y + h − v - rotated:
X = x + v,Y = y + u
Both keep the bulge values unchanged. In sheet space a positive bulge is an SVG arc with sweep flag 0. The DXF export already contains the placed contours.
"pieces": [{
"ref": "p1", "label": "Arch",
"x": 820, "y": 10, "w": 700, "h": 900, "rotated": false,
"shape": {
"code": "ARCH_SEG",
"outline": [[0, 0, 0], [700, 0, 0], [700, 650, 0.714], [0, 650, 0]]
}
}]Export files
Completed optimizations can be downloaded ready for the workshop or a CNC:
/pdf | Cutting report: summary, one page per sheet layout, parts list. |
/dxf | DXF R12, one block per sheet; shaped parts as polylines with arcs. |
/csv | One row per placed piece with its sheet and position. |
/sheets/{m}/{l}.svg | One layout as SVG: material index m, layout index l (both from 0). |
curl -o layout.pdf https://2dcut.app/v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS/pdf \
-H "Authorization: Bearer $TWODCUT_KEY"
curl -o sheet-1.svg https://2dcut.app/v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS/sheets/0/0.svg \
-H "Authorization: Bearer $TWODCUT_KEY"Webhook events
Add HTTPS endpoints on the Developer page. When an optimization created with the API finishes, each active endpoint gets a POST with a JSON event:
optimization.completed— with asummary; fetchdata.urlfor the full result.optimization.failed— witherror.code.webhook.test— sent by the “Send test” button.
Headers: X-2DCut-Event, X-2DCut-Delivery (unique per delivery — use it to ignore duplicates) and X-2DCut-Signature. Answer with any 2xx within 10 seconds; do slow work after responding.
{
"id": "01M3RP2Q8E6VJ7B6X0W9Z3K4TA",
"type": "optimization.completed",
"createdAt": "2026-09-30T08:12:17Z",
"data": {
"id": "01M3RNX0GJ2HR4ZMT4WRD3GDCS",
"status": "completed",
"summary": { "sheets": 2, "yieldPct": 78.4, "unplaced": 0 },
"url": "https://2dcut.app/v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS"
}
}Verifying signatures
Every request is signed with your endpoint's secret (whsec_…, shown on the Developer page):
X-2DCut-Signature: t=1790755937,v1=5257a8…
Compute HMAC-SHA256 of t + "." + raw request body with the secret and compare it to v1 in constant time. Reject timestamps older than five minutes to stop replays. Use the raw body exactly as received — parsing and re-encoding the JSON changes it.
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_2DCUT_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, getenv('TWODCUT_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);import crypto from 'node:crypto';
// Express: app.post('/hooks/2dcut', express.raw({ type: 'application/json' }), handler)
function verify(rawBody, header, secret) {
const sig = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret)
.update(`${sig.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
return fresh && sig.v1?.length === 64 &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1));
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
sig = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{sig['t']}.".encode() + raw_body,
hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(sig["t"])) < 300
return fresh and hmac.compare_digest(expected, sig.get("v1", ""))static bool Verify(string rawBody, string header, string secret)
{
var sig = header.Split(',').Select(p => p.Split('=', 2))
.ToDictionary(p => p[0], p => p[1]);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{sig["t"]}.{rawBody}"));
var expected = Convert.ToHexString(hash).ToLowerInvariant();
var fresh = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(sig["t"])) < 300;
return fresh && CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(sig["v1"]));
}Retries
If your endpoint does not answer with 2xx (or times out), we try again after 1, 5, 30 minutes, then 2, 6, 12 and 24 hours — eight attempts in total over about two days. Deliveries can arrive more than once and out of order; use X-2DCut-Delivery and the optimization's status to stay consistent. The Developer page lists recent deliveries with their response codes.
Input fields
Material
namestring · required- Shown on reports, e.g.
"MDF 18 mm". grain"none" · "length" · "width"- Grain direction of the sheets. With grain, parts keep their direction and never rotate:
"length"means the part length runs along the sheet length,"width"across it. sheetsarray · required- Stock sizes:
length,width,quantity(default 1), optionallabel,trim(top,left,bottom,right) andisRemnant. Remnants are used before full sheets. partsarray · required- Pieces to cut:
length,width,quantity, optionallabel,canRotate(defaulttrue) andshape.
Shape
{ "code": "ARCH_SEG", "params": { "F": 250 } } — the part's length and width are the bounding box; parameters are lengths in the request unit. Codes and parameters come from Shapes. Use "RECT" or leave shape out for rectangles.
{
"unit": "mm",
"cutType": "guillotine",
"quality": "normal",
"kerf": 4,
"materials": [{
"name": "MDF 18 mm",
"grain": "none",
"sheets": [{ "length": 2800, "width": 2070, "quantity": 10,
"trim": { "top": 10, "left": 10, "bottom": 10, "right": 10 } }],
"parts": [
{ "label": "Side", "length": 800, "width": 600, "quantity": 4 },
{ "label": "Arch", "length": 700, "width": 900, "quantity": 1,
"shape": { "code": "ARCH_SEG", "params": { "F": 250 } } }
]
}]
}Units & lengths
Set unit once for the whole request: "mm", "cm" or "in". Lengths may be numbers or strings. Strings accept a decimal comma ("12,5") and, in inches, fractions: "12 3/8", "3/8", "48-1/2".
The optimizer works in tenths of a millimetre; results are returned in millimetres rounded to 0.1 mm.
{
"unit": "in",
"kerf": "1/8",
"materials": [{
"name": "Plywood 3/4",
"sheets": [{ "length": 96, "width": 48, "quantity": 4 }],
"parts": [{ "label": "Shelf", "length": "23 1/4", "width": "11 7/8", "quantity": 6 }]
}]
}Shapes
The catalog of shaped parts: arches, circles and ellipses, triangles, trapezoids, chamfered and radiused corners, L-shapes, octagons and more. Each entry has its name, group and params with a code, default and min/max expressions in terms of the box (W = length, H = width). The API validates parameters the same way.
"ARCH_SEG": {
"name": "Segment arch",
"group": "arch",
"params": [
{ "code": "F", "name": "Rise", "default": 200,
"min": "1", "max": "min(H-1,W/2)" }
]
}Usage
Optimizations used this month (app and API together), the monthly limit and when it resets. Handy as a cheap call to check a key.
{
"plan": "pro_api",
"usage": {
"used": 214,
"limit": 2000,
"period": "2026-09",
"resetsAt": "2026-10-01T00:00:00Z"
}
}