2D Cut
TR Sign in Try it free
REST API · Pro + API

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.

Base URL
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.

StatuscodeMeaning
401invalid_api_keyMissing, unknown or revoked key.
403plan_limitThe plan does not include this. feature says what: api, max_pieces, max_materials, cut_type, shapes.
404not_foundNo such optimization for this account.
409idempotency_conflictThe Idempotency-Key was used with a different body.
409not_cancellableThe optimization has already finished.
422validation_failedThe input is invalid; see fields.
429rate_limitedToo many requests; wait Retry-After seconds.
429quota_exceededThe monthly optimizations are used up (limit, resetsAt).

A rejected request never uses an optimization from your quota; failed and cancelled optimizations are given back.

422 Unprocessable Content
{
  "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.json
POST/v1/optimizations

Create 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, sheets and parts — 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. best finds better layouts for large jobs but takes longer. Default "normal".
kerflength
Blade thickness, removed along every cut. Default 0.
advancedobject
minUsefulSize (offcuts smaller than this count as waste), maxCutLength, stages (2–9, multistage), firstDirection ("horizontal" or "vertical").

Responses

  • 202Queued. Also 200 for an idempotent replay.
  • 422Validation failed; fields points 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();
202 Accepted
Location: /v1/optimizations/01M3RNX0GJ2HR4ZMT4WRD3GDCS

{
  "optimization": {
    "id": "01M3RNX0GJ2HR4ZMT4WRD3GDCS",
    "status": "queued",
    "queuePosition": 1,
    "createdAt": "2026-09-30T08:11:37Z",
    "finishedAt": null,
    "source": "api"
  }
}
GET/v1/optimizations/{id}

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"):
        break
JsonElement 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");
GET/v1/optimizations

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"
DELETE/v1/optimizations/{id}

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: count says how many times to cut that layout.
  • pieces are the placed parts with their box (x, y, w, h) and rotated. ref points back at your part row (p0 is the first part of the material).
  • cuts are in cutting order; level is the guillotine stage. wastes are the offcuts, trim: true for edge trims.
  • summary.stoppedEarly is true when the time limit ended the search; the layout is valid but a higher quality may 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]]
  }
}]
GET/v1/optimizations/{id}/pdf

Export files

Completed optimizations can be downloaded ready for the workshop or a CNC:

/pdfCutting report: summary, one page per sheet layout, parts list.
/dxfDXF R12, one block per sheet; shaped parts as polylines with arcs.
/csvOne row per placed piece with its sheet and position.
/sheets/{m}/{l}.svgOne 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 a summary; fetch data.url for the full result.
  • optimization.failed — with error.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), optional label, trim (top, left, bottom, right) and isRemnant. Remnants are used before full sheets.
partsarray · required
Pieces to cut: length, width, quantity, optional label, canRotate (default true) and shape.

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 }]
  }]
}
GET/v1/shapes

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)" }
  ]
}
GET/v1/usage

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