# SmartCut — full documentation > Cloud-based cutlist optimization API + hosted cut-to-size e-commerce. Source: https://smartcut.dev --- # SmartCut API Documentation URL: https://smartcut.dev/docs > Cloud-based cutting-optimization platform: REST API, embeddable calculator, hosted e-commerce, WordPress plugin and embedded diagrams. SmartCut is a cloud platform for **material cutting optimization**. Send it a list of parts and stock, and it returns efficient cutting layouts for sheet, linear and roll materials. Kerf, grain, trim, edge-banding, machining and saw constraints are all handled server-side, by the same optimisation engine that powers [Cutlist Evolution](https://cutlistevo.com). You can consume it however suits your product. - **REST API**. Submit a calculation, poll for the result, then export to PDF, DXF, SVG, CSV, labels and machine formats (PTX, Biesse, Mayer …). Processing is async, several algorithms are available, and the response carries full metadata. - **Embedded JavaScript calculator**. Drop a cut-to-size pricing and quoting widget straight into your own checkout. - **Embedded diagram**. Show an interactive cutting layout on any page with a single ` ``` Set the `src` attribute dynamically using the `jobId` returned by the `/v3/calculate` API call: ```javascript const jobId = 1733 // returned by /v3/calculate document.getElementById('smartcut-vis').src = `https://cutlistevo.com/embed/?result=${jobId}` ``` A live example is available at: `https://cutlistevo.com/embed/?result=1733` A result stays available for 24 hours. Past that the diagram shows "no result", unless you kept the JSON and served it yourself, which is what `src` is for. ## Parameters Pass parameters as query string values on the iframe `src` URL. | Parameter | Description | |-----------|-------------| | `result` | The calculation job ID to display. **Required** unless `src` is given | | `src` | URL of a result JSON you host yourself, described in [Your own JSON](#your-own-json). Used in place of `result` | | `dn` | Set to `true` to disable the stock navigation controls | | `hs` | Set to `true` to hide the stock information header | ## Your own JSON Save the result JSON when you receive it, put it anywhere you can serve a file from, whether that is S3, your own CDN or your app's storage, then point the embed at that URL with `src` instead of `result`: ```javascript const src = encodeURIComponent('https://cdn.example.com/jobs/1733.json') document.getElementById('smartcut-vis').src = `https://cutlistevo.com/embed/?src=${src}` ``` URL-encode the value, so a query string of its own does not merge into the embed's. Every other parameter behaves exactly as it does for `result`, including colours, navigation and `postMessage`. This is worth doing when: - **the diagram has to outlive our copy**, such as an order confirmation, an archived quote or a job sheet a customer opens weeks later - **the page must not depend on our API**. With `src` the iframe never calls us, so the layout still draws if our service is unreachable - **the result never came from a job you can re-request**, because you hold the JSON and not a job ID we can look up ### Accepted JSON Either of the two result documents you can already obtain: | Document | Where it comes from | |----------|---------------------| | V3 API result | the body of `GET /v3/result?id=…`, or the result delivered for `POST /v3/calculate` | | Embed result | the body of `GET /result/embedded?id=…` | V1/V2 API results are **not** accepted. They name sides `x1/x2/y1/y2` and nest parts under each stock, which is a different document rather than a renamed one. Re-fetch the job through V3, or store the embed result. Store the response body verbatim, with no re-shaping and no unwrapping. If your own records wrap it (`{ order, result }`), keep the result at a URL of its own: ```javascript // when the calculation completes, keep a copy const result = await fetch(`https://api.smartcut.dev/v3/result?id=${jobId}`, { headers: { Authorization: process.env.SMARTCUT_API_KEY } // raw key, no Bearer }).then((r) => r.json()) await putSomewherePublic(`jobs/${jobId}.json`, JSON.stringify(result)) ``` ### Requirements - Serve the file over **HTTPS** from an absolute URL. An `http` URL is blocked as mixed content by the browser. - Send `Access-Control-Allow-Origin: https://cutlistevo.com` (or `*`) with the file. The iframe fetches it directly, so without CORS the browser blocks it. - Keep the file **publicly readable**: the request is sent without credentials, so cookies and auth headers of yours play no part. - Stay under **25 MB**. ### Diagnosing a failure The reason is posted to the surrounding page as an `error` message (see [iframe Messages](#iframe-messages)) and logged to the iframe's console: | Message | Meaning | |---------|---------| | `src must be an absolute URL` | the value was relative, or not a URL | | `src must be an http or https URL` | the value used another scheme (`data:`, `file:`, …) | | `could not fetch the JSON at src` | the host did not answer, or CORS blocked the read | | `src responded 404` | the host answered, with that status | | `src did not return valid JSON` | the body did not parse, often an error page | | `src JSON must be a V3 API result …` | the JSON parsed but is not a result document | ## Colours Customise the colour scheme using HEX values (without the `#` prefix). | Parameter | Description | |-----------|-------------| | `pca` | Part colour A | | `pcb` | Part colour B | | `pch` | Part colour on hover | | `pcs` | Part colour when selected | | `sc` | Stock (board) background colour | | `tc` | Text colour | **Example:** ``` https://cutlistevo.com/embed/?result=1733&pca=BAD0F5&pcb=346AC9&pch=E09318&pcs=D35A5A&sc=EBEB58 ``` ## iframe Messages Communicate with the embed using the browser's `postMessage` API. ### Messages from the iframe Listen for these events on `window`: | Type | Description | |------|-------------| | `resize` | The embed has resized. `w` and `h` carry the new dimensions in pixels. | | `loaded` | The visualisation has loaded and a result was found. `payload` carries the stock IDs. | | `noResult` | No result was found for the given job ID, or the JSON at `src` held no placed parts. | | `error` | An error occurred. `message` carries the error text. | | `partClick` | A part was clicked. `message` carries part data. | ### Messages to the iframe Send these events to the iframe using `contentWindow.postMessage`: | Type | Fields | Description | |------|--------|-------------| | `navigate` | `stockID` | Navigate to a specific stock item by its ID (e.g. `'1.0'`) | ## Notes - The embed must be hosted on a page served over **HTTPS**. It will not work on HTTP. - The iframe height starts at `0`. The `resize` message sets it. - A result is kept for **24 hours**. Beyond that, render it from [your own JSON](#your-own-json). - `src` replaces `result`: give one or the other, and `src` wins if both are set. ## Full Example ### HTML ```html
Loading...
``` The same iframe, reading JSON you host. Only the query parameter changes: ```html ``` ### JavaScript ```javascript window.addEventListener('message', (e) => { if (!e.data) return if (e.data?.origin !== 'smartcut') return switch (e.data.type) { case 'resize': document.getElementById('smartcut-vis').style.height = e.data.h + 'px' break case 'loaded': document.getElementById('smartcut-vis-message').style.display = 'none' document.getElementById('smartcut-vis').style.visibility = 'visible' break case 'noResult': document.getElementById('smartcut-vis-message').innerText = 'No result found' break case 'error': document.getElementById('smartcut-vis-message').innerText = e.data.message break case 'partClick': console.log(e.data.message) break } }, false) // Navigate to a specific stock item document.getElementById('smartcut-vis').contentWindow.postMessage( { type: 'navigate', stockID: '1.0' } ) ``` ### CSS ```css #smartcut-vis, #smartcut-vis-message { width: 100%; max-width: 1000px; } #smartcut-vis { background-color: rgba(255, 255, 255, 0.3); box-sizing: border-box; visibility: hidden; } ``` ## Related - [API guide](/docs/guide/) covers producing the layout this diagram renders. - [Hosted store](/docs/ecommerce/) is the ready-made alternative to embedding. - [WordPress plugin](/docs/wordpress-plugin/) does the same thing without writing code. --- # WordPress Plugin URL: https://smartcut.dev/docs/wordpress-plugin > SmartCut WordPress WooCommerce plugin documentation The SmartCut WordPress plugin integrates cutting optimization directly into your WooCommerce store, enabling cut-to-size e-commerce functionality. ## Essentials Before getting started, ensure these foundational requirements are met: 1. **Add your domain** to your SmartCut account (24-hour activation period, or contact support for expedited activation) 2. **Install WooCommerce plugin** on your WordPress site 3. **Use the Storefront theme** (the official WooCommerce theme) 4. **Minimize plugin installations** - keep additional plugins to a minimum 5. **Avoid page builder tools** until compatibility is thoroughly verified 6. **Skip caching plugins** that may alter JavaScript loading order 7. **Enable automatic plugin updates** to stay current with the latest features :::caution[Important] Most issues encountered are related to the points above. Following these guidelines will ensure smooth operation. ::: ## Installation 1. Download the latest `smartcut.zip` from [Github](https://github.com/jgmedialtd/smartcut-wp-plugin/releases/latest/) 2. In WordPress admin, navigate to **Plugins** > **Add New** > **Upload Plugin** 3. Upload the `smartcut.zip` file 4. Activate the plugin ### Local Server Testing For local development, use `localhost` as the hostname rather than custom domains or IP addresses to enable proper domain validation. ## Product Category Setup Create a dedicated product category to identify cut-to-size items: 1. Create a new product category in WooCommerce 2. Navigate to **SmartCut** > **General settings** 3. Configure the category for cut-to-size products 4. Apply this category to all relevant products :::tip Complete this step before other configuration tasks to streamline your setup. ::: ## Product Templates The SmartCut admin menu offers template generation featuring example products: 1. Navigate to **SmartCut** > **Templates** 2. Select a template that matches your business structure 3. Customize the template to your needs 4. Ensure the category slug aligns with your saved settings ## Global and Product Settings Settings operate in a hierarchy: **Product settings override Global settings**. Implement global settings strategically to reduce administrative overhead when updates are needed across your store. ## Pricing Strategies SmartCut supports multiple pricing approaches: - **Full sheet pricing** - Price based on complete sheets - **Part area-based pricing** - Calculate price by part area - **Cut length pricing** - Price based on linear cutting - **Full sheet plus cut length** - Combined pricing model - **Full sheet plus number of parts** - Price by sheet and part count - **Additional surcharge options** - Custom pricing adjustments ## Product Structure ### Attribute Configuration WooCommerce product attributes manage variable options. Follow these guidelines: #### Simple Products with Fixed Stock - Use **length** and **width** attributes (millimeters or inches) - Optional **thickness** attribute (required for beam saws) #### Variable Products with Multiple Thicknesses - Use **thickness** attribute separated by pipes: `8 | 12 | 16` - Omit units #### Variable Products with Multiple Sizes - Use **size** format: `2440×1220` - Multiple options: `2440×1220 | 1000×1000` - Do not combine with length/width attributes #### Variable Products with Full-Size Purchase Option - Add "Cut-to-size" as a text option - Example: `2440×1220 | 1000×1000 | Cut-to-size` :::danger[Critical] Do not add units to dimensions or sizes. The plugin will handle unit conversion based on your store settings. ::: ## Edge Banding and Finishes These "extras" are managed via standard WooCommerce products linked to cut-to-size offerings. They can be hidden from customers by setting them as **Private**. ### Naming Constraints :::caution Extras names must only contain numbers, letters, underscores, hyphens & brackets. No other special characters are allowed. ::: ### Pricing Specifications - **Banding**: Priced by meter or foot - **Finishes**: Priced by square meter or square foot ### Simple Extra Products Use simple products when presenting a single list of choices. Each option requires its own product with controlled pricing. Product slugs identify extras in orders, PDFs, and CSV files. ### Variable Extra Products Introduced in version 3.1, variable extras allow breaking up choices (e.g., separate color and thickness selection): 1. Add attributes to the extra product 2. Generate variations 3. Set prices accordingly :::caution[Restriction] When using variable extra products, add only one product slug at a time to avoid errors. ::: ## CSV Import for Parts When **Settings** > **Cut list configuration** > **Enable CSV import** is enabled, users can import parts via CSV. 1. Download the example template from the support documentation 2. Ensure the template is available on your site :::danger[Critical Requirement] Units used in the import must match your store-wide units set in **Settings** > **Cut list configuration**. ::: CSV columns include: - Length - Width - Quantity - Rotation - Name All columns are optional and can be removed as needed. ## Translation Translation occurs in two stages: ### 1. Cut List Widget Manual translation via provided Google Doc ### 2. Store Text Use WooCommerce-compatible translation plugins: - **Loco Translate** (officially supported) - Other compatible WooCommerce translation plugins ## Troubleshooting ### Cut List Interface Not Displaying Verify the following checklist: - ✅ SmartCut plugin installed and activated - ✅ Domain added to SmartCut account with proper www designation - ✅ Cut-to-size category created and applied to products - ✅ Product pricing configured (simple products or variations) - ✅ Product stock status set appropriately - ✅ Storefront theme in use - ✅ Minimal plugin interference - ✅ Caching properly configured - ✅ Browser cache cleared ### Server Connection Issues **For local servers:** Use `localhost` as the hostname. **For live URLs:** - Confirm domain registration in your SmartCut account - Allow 24 hours for activation - Contact support for expedited processing if needed Common error message: *"We are having trouble connecting to the server"* This typically indicates: - Localhost configuration issues, or - Incomplete domain setup ### Version Downgrade If you need to revert to a previous version: 1. Access your SmartCut account dashboard 2. Navigate to the plugin downloads section 3. Select and download the desired version of `smartcut.zip` 4. Upload via WordPress **Plugins** menu ## Support The platform is designed for self-service setup using provided templates as references. For assistance: - **Email**: hello@cutrevolution.com - **API Updates**: [API Changelog](/docs/updates) - **Custom Development**: Available at standard hourly rates --- **Current Plugin Version:** 4.3.9 **Platform:** Built for WooCommerce ## See it running [store.smartcut.dev](https://store.smartcut.dev) is a stock WordPress and WooCommerce install running this plugin. Browse it like a customer before installing anything. --- # Inventory API URL: https://smartcut.dev/docs/ecommerce-inventory > Manage your store's materials and stock over HTTPS: create and update product lines, adjust quantities atomically, and sync from an ERP. If you run a [hosted SmartCut store](/docs/ecommerce), you can manage its inventory from your own systems: an ERP, a warehouse system, a stock-control spreadsheet, a nightly script. Everything the admin Inventory page does to materials and stock is available over HTTPS, through the **same validated path**, so nothing the API does can put your store into a state the admin couldn't. This page explains the model and the things worth knowing before you write against it. Once you're writing calls, the **[endpoint reference](/api-docs/ecommerce)** lists every parameter, schema and response, with a console to try them in. ## The model Inventory has two levels: - A **material** is a product line, such as "18mm Oak MFC". It carries a `code` that is unique within your store, plus the defaults shared by everything beneath it: price, colour, grain, category, edge banding. - A **stock** item is one cuttable sheet or length of that material, with its own dimensions and quantity. One material has many stock items, typically one per thickness, or per sheet size. ```text Material OAK-18 "18mm Oak MFC" cost 42.50 ├── Stock 2440 × 1220 × 18 q 40 └── Stock 3050 × 1220 × 18 q 12 ``` Stock is always addressed **under its material**, so the relationship comes from the URL and is never something you have to send: ```bash POST /ecommerce/api/materials/{materialId}/stock ``` ## Credentials You authenticate with your **existing SmartCut API key**, the same one the optimisation API uses. 1. Sign in at [smartcut.dev/account](https://smartcut.dev/account). You need an active **e-commerce subscription**. 2. Copy your **API key** from the account page. 3. Send it as an `Authorization` header. The value is the **raw key**, with no `Bearer ` prefix. ```bash curl https://api.smartcut.dev/ecommerce/api/materials \ -H 'Authorization: YOUR_API_KEY' ``` :::caution Your API key can change and delete live catalogue data. Keep it in an environment variable, never commit it, and don't ship it to a browser. ::: If your account belongs to more than one store, add `x-smartcut-org: ` to say which. Without it, ambiguous requests return `409` and list your options rather than guessing. ## Creating a product line A material needs a `code` and a `name`. Everything else is optional and inherited by its stock. ```bash curl -X POST https://api.smartcut.dev/ecommerce/api/materials \ -H 'Authorization: YOUR_API_KEY' -H 'Content-Type: application/json' \ -d '{ "code": "OAK-18", "name": "18mm Oak MFC", "t": [18], "cost": 42.50 }' ``` Then add stock beneath it. Set `ecommerce: true` for anything you sell. Those rows are validated more strictly and require a positive `cost`. ```bash curl -X POST https://api.smartcut.dev/ecommerce/api/materials/{id}/stock \ -H 'Authorization: YOUR_API_KEY' -H 'Content-Type: application/json' \ -d '{ "l": 2440, "w": 1220, "t": 18, "q": 40, "cost": 42.50, "ecommerce": true }' ``` Send an array to create a batch: `{ "materials": [ … ] }` or `{ "stock": [ … ] }`. Batches are **all or nothing**: if any row fails, nothing is written and the response tells you which row index was at fault. ## Inheritance Stock inherits `cost`, `grain`, `category`, `finish`, edge banding and the other shared fields from its material. **Set only what differs.** Reads always return the values actually in effect, so you never have to resolve the hierarchy yourself. Two rules worth knowing: - Setting an inherited field on a stock item pins it. That sheet keeps your value, and later edits to the material no longer move it. This is automatic, and there is no flag to manage. - Setting it back to `null` un-pins it, and the item resumes following its material. ```bash # this 3050mm sheet costs more than the material default curl -X PATCH .../stock/{id} -d '{ "cost": 49.99 }' # changed our minds — follow the material again curl -X PATCH .../stock/{id} -d '{ "cost": null }' ``` ## Changing quantities Use **adjust**, not `PATCH`, for stock movements: ```bash curl -X POST https://api.smartcut.dev/ecommerce/api/stock/{id}/adjust \ -H 'Authorization: YOUR_API_KEY' -H 'Content-Type: application/json' \ -d '{ "delta": -5, "reason": "goods out" }' ``` `delta` is relative: negative removes, positive adds. It's a single atomic operation, so two systems adjusting the same item at the same moment can't oversell it. A `PATCH` of `q` reads and writes separately and can lose a concurrent update. Two behaviours to rely on: - The row is never deleted at zero. It stays at `0`, so your next sync still finds it. - Going below zero is refused with `409`, and the response reports how many are actually available rather than silently clamping. If you see this, your stock model and SmartCut's have diverged. Items marked `unlimitedQuantity` return `200` with `adjusted: false`, because their quantity isn't tracked. ## Syncing from an ERP Use `PUT /ecommerce/api/materials/by-code/{code}`. It creates the material if it's absent and updates it if it's present, keyed on **your own product code**. The same request can be replayed safely, so a nightly job needs no state and never has to check existence first. ```bash curl -X PUT https://api.smartcut.dev/ecommerce/api/materials/by-code/OAK-18 \ -H 'Authorization: YOUR_API_KEY' -H 'Content-Type: application/json' \ -d '{ "name": "18mm Oak MFC", "t": [18], "cost": 44.95 }' ``` It responds `201` when it created and `200` when it updated. The `created` flag tells you which. URL-encode codes containing spaces or slashes. To find stock without tracking SmartCut ids, filter the flat list by material code: `GET /ecommerce/api/stock?code=OAK-18`. ## When something is wrong Errors carry a machine-readable `code` and name the field. **Unrecognised fields are rejected, not ignored**. A misspelled key fails loudly instead of returning success without changing anything. ```jsonc { "error": "Validation failed", "code": "VALIDATION_FAILED", "errors": [ { "field": "quantiy", "code": "UNKNOWN_FIELD", "message": "Unknown field \"quantiy\" for stock. Accepted fields: cost, discount, …" } ] } ``` | `code` | Meaning | |---|---| | `UNKNOWN_FIELD` | Not a field on this resource. The message lists the accepted set. | | `READONLY_FIELD` | Real, but managed by SmartCut: `id`, `area`, and a stock item's `material` name. | | `FORBIDDEN_FIELD` | Internal. Most often `db_materialId`. Create stock under its material instead. | | `REQUIRED_FIELD` / `INVALID_VALUE` | Missing or out of range. The message names the value. | | `DUPLICATE_CODE` | Another material already uses this code. In a batch, reported against the row index. | | `INSUFFICIENT_QUANTITY` | An adjust would go below zero. Carries `available`. | Branch on `code`. The message text is for people and may be reworded. A few things live on the material rather than on stock, and the error will say so: product `code`, the thickness **list**, and the form (`sheet` / `linear` / `roll`). A bonded board is one material thickness written `"18,36"`, and creating it produces a stock item for each half. ### Status codes | Status | When | |---|---| | `400` | Validation failed. See the `errors` array above. | | `401` | Missing or unrecognised API key. | | `403` | Your account has no e-commerce access, or is blocked. | | `404` | No such material or stock **in your store**. | | `409` | An adjust would take quantity below zero. | | `429` | Rate limited. Wait for `Retry-After` seconds. | `404` also covers ids that exist in a **different** store. You'll never get a `403` for those, so the response can't be used to work out whether an id exists somewhere else. If you're sure an id is right and still get `404`, check you're using the key for the store that owns it. ### Warnings A `PATCH` can succeed and still return a `warnings` array. Those are pre-existing problems on fields you **didn't** touch, such as a row that predates a validation rule. Your change was applied. The warnings tell you the record has other issues without blocking you from fixing them one at a time. ## Rate limits Inventory calls get their own budget, well above general read traffic, so a catalogue sync of a few hundred rows is fine. Exceeding it returns `429` with a `Retry-After` header. Wait that many seconds and continue. ## See also - [Endpoint reference](/api-docs/ecommerce) documents the same endpoints as this page, one at a time: every parameter, schema and response, with a console to try them in. It also covers the order-workflow endpoints, which this guide doesn't. Read this page to understand the model, then go there for the exact shape of a call. - [Store config MCP](/docs/ecommerce-mcp) does the same operations from an LLM client, if you'd rather ask than integrate. - [Hosted store](/docs/ecommerce) covers the storefront, analytics and webhooks. --- # 3D model analysis URL: https://smartcut.dev/docs/model-analysis > Upload a STEP, STL, OBJ, PLY or glTF model to the SmartCut API and get back a parts list ready to submit to /v3/calculate, with checks on shaped parts. Upload a 3D model. Get back a parts list. `POST /v3/model/analysis` reads a CAD file, works out which solids in it are panels, measures them, and returns them in exactly the shape [`/v3/calculate`](/api-docs/v3) wants. The `parts` array it gives you **is** that endpoint's `parts` array. No field mapping, no conversion. ```text POST https://api.smartcut.dev/v3/model/analysis ``` ## The request is the file The body is the model file itself, as raw bytes. Not a multipart form, not base64 inside JSON. There is exactly one file and nothing else to send, so everything else is a query parameter. ```bash curl -X POST \ "https://api.smartcut.dev/v3/model/analysis?filename=kitchen.step&units=mm" \ -H "Authorization: YOUR_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @kitchen.step ``` | Parameter | Default | What it does | |---|---|---| | `filename` | none | The file's name. Used to work out the format when the bytes carry no magic. | | `format` | none | Force the format instead of inferring it. Magic bytes still win where the file has them. | | `units` | `mm` | The unit every number in the response is in. One of `mm`, `cm`, `m`, `in`, `ft`. | | `sourceUnits` | none | The unit the model was **drawn** in. Overrides whatever the file declares. | | `dedupe` | `true` | Collapse identical parts into one row with a quantity, which is what a cut list wants. | | `mirrorDedupe` | `false` | Treat a part and its mirror image as the same part. | | `bladeWidth` | none | Your saw's kerf, in `units`. Enables the units sanity check below. | ## Formats The format is decided by the file's **magic bytes**, with the extension as a tiebreak. People rename files, and a `.stl` that is really a zip should fail with something better than a parser crash. OBJ and ASCII STL carry no magic at all, so for those you must send `filename` or `format`. Without one the request is refused rather than guessed at. | Format | Extensions | Supported | Notes | |---|---|---|---| | STEP | `.step`, `.stp` | Yes | **The best input.** Exact B-rep solids, tessellated server-side, and the file declares its own unit, so `sourceUnits` is not needed and cannot be got wrong. | | STL | `.stl` | Yes | Binary and ASCII. Carries no part names and no materials, so parts are separated by geometry alone. | | OBJ | `.obj` | Yes | Keeps object names and `usemtl` material names. | | PLY | `.ply` | Yes | Geometry only. | | glTF / GLB | `.gltf`, `.glb` | Yes | Embedded-buffer GLB works. External `.bin` or texture files are not fetched, so geometry only. | | Collada | `.dae` | **No, `415`** | Needs a browser DOM to parse. Convert to STEP or STL. | | 3MF | `.3mf` | **No, `415`** | Same reason. Convert to STEP or STL. | A `415` is not a dead end. It names what would have worked: ```json { "error": "The DAE format cannot be read here. Supported formats: obj, stl, ply, gltf, step.", "details": { "format": "dae", "supported": [ "obj", "stl", "ply", "gltf", "step" ], "help": "Re-export the model as STEP (best: exact solids and real units) or STL. Collada and 3MF can only be read in the browser." }, "version": "3" } ``` ## Units Only STEP declares a unit the server can act on. Every mesh format (STL, OBJ, PLY, glTF) carries **nothing**, so if you do not say what the model was drawn in, the numbers are taken to be already in `units`. Getting this wrong is silent. Every part stays consistent with every other, so a cabinet exported from a tool set to metres and read as millimetres imports as a set of perfectly self-consistent, thousand-times-too-small parts. It is worth being explicit: ```text ?filename=cabinet.obj&sourceUnits=m&units=mm ``` The response always tells you what it did: ```json "model": { "format": "obj", "units": "mm", "sourceUnits": "m", "declaredUnits": null } ``` Send `bladeWidth` and the server can also catch the mistake for you. A kerf is a fraction of a percent of anything worth cutting, so a blade that is an appreciable slice of the whole model means the model is not small. The unit is wrong. When that happens, `model.suggestedUnits` names the `sourceUnits` that would make sense of it. Without a `bladeWidth` there is nothing to measure against and no suggestion is made. ## The response ```json { "version": "3", "model": { "format": "step", "units": "mm", "sourceUnits": "mm", "declaredUnits": "mm", "stats": { "parts": 3, "rectangular": 2, "irregular": 1, "unrecognised": 0, "fixtures": 0, "merged": 2, "triangles": 4820, "components": 5, "debris": 0, "modelDiagonal": 1523.4, "modelSize": { "x": 1200, "y": 600, "z": 720 } } }, "parts": [ { "name": "SIDE", "l": 720, "w": 560, "t": 18, "q": 2, "material": "MDF", "role": "rect" }, { "name": "SHELF", "l": 564, "w": 540, "t": 18, "q": 1, "material": "MDF", "role": "rect", "machining": { "holes": [ { "x": 32, "y": 40, "diameter": 8, "face": 0, "type": "regular" } ] } }, { "name": "CURVED TOP", "l": 600, "w": 300, "t": 18, "q": 1, "material": "OAK", "role": "nesting", "outline": [ { "x": 0, "y": 0 }, { "x": 600, "y": 0 } ] } ], "advice": [ { "code": "squared-off-waste", "severity": "info", "partIndex": 2, "wastePct": 0.21, "message": "Squaring \"CURVED TOP\" off to a rectangle wastes 21.0% of its area. Send its outline to a nesting model to keep it." } ], "warnings": [] } ``` ### Parts Every field on a part except `role` is a `/v3/calculate` part field. `role` is not, and `/v3/calculate` **ignores fields it does not recognise** rather than rejecting them, so you can post the array back exactly as it arrived. You do not have to strip anything: ```bash # 1. analyse curl -sS -X POST \ "https://api.smartcut.dev/v3/model/analysis?filename=kitchen.step" \ -H "Authorization: YOUR_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @kitchen.step > analysis.json # 2. cut it. The parts array goes straight across jq '{ saw: { cutType: "guillotine", cutPreference: "l", bladeWidth: 3.2, stockType: "sheet" }, stock: [ { l: 2440, w: 1220, t: 18, material: "MDF", q: 20 } ], parts: .parts }' analysis.json > calculate.json curl -X POST https://api.smartcut.dev/v3/calculate \ -H "Authorization: YOUR_API_KEY" \ -H "Content-Type: application/json" \ --data @calculate.json ``` `role` says what each part is **for**, and it is the one thing worth acting on before you cut: | `role` | Meaning | What to do | |---|---|---| | `rect` | A plain rectangle. | Nothing. Cut it. | | `nesting` | A shaped part. `outline` (and `holes`) carry its real geometry. | Submit it to a nesting model to keep the shape, or let a guillotine model square it off to `l` x `w` and accept the waste. `advice` tells you how much that costs. | | `fixture` | A fitting such as a handle, a hinge or a knob, never cut from sheet at all. | **Filter these out** before cutting. A `fixture-detected` entry in `advice` names each one. | ### Holes A hole in a part is either **drilling** or **shape**, and the analysis decides which: | Where it comes back | What it is | Fields | |---|---|---| | `machining.holes` | A round hole, meaning drilling. Same structure `/v3/calculate` takes for machining operations. | `x`, `y` (the centre, from the left and bottom edges), `diameter`, `face`, `type`. `depth` is absent, which means a through hole. | | `holes` | A ring that is not round, such as a slot or a cut-out. Real geometry the part has to be cut around, in `outline`'s own frame. | An array of polygon rings, same vertex convention as `outline`. | A hole appears in exactly one of them, never both, so a part is never drilled **and** cut for the same hole. Round holes are also why a drilled shelf still comes back as `role: "rect"`: to a saw it is a plain panel, and the drilling rides along in `machining` rather than turning it into a shaped part. Which form you want depends on the machine, not on the part, so `?holes=` switches between them: ```bash # default: round holes come back as drilling operations curl ... "https://api.smartcut.dev/v3/model/analysis?filename=kitchen.step" # a router with no drill head: round holes come back as polygon rings to cut curl ... "https://api.smartcut.dev/v3/model/analysis?filename=kitchen.step&holes=cut" ``` `holes=cut` is the form a **nesting** model reads. It packs polygons, and a part whose geometry it cannot see is packed as its bounding rectangle instead. A part returned this way carries its `outline` as well, so send it to a nesting job rather than a guillotine one. Holes that are not round are shape under either setting, because there is no drilled equivalent of a slot. Measurements are in the response's `units`, so `diameter` follows the same unit as `l` and `w`, not millimetres. ```bash # drop the fittings jq '[ .parts[] | select( .role != "fixture" ) ]' analysis.json ``` ### Advice `advice` is everything the analysis knows that a calculation does not, as data rather than prose. Each entry has a stable `code`, a `severity` of `info`, `warning` or `error`, a human-readable `message`, and, where it is about a specific part, a `partIndex` into `parts`. | Field | When | |---|---| | `partIndex` | The advice is about one part. Absent when it is about the file as a whole. | | `otherPartIndex` | The advice is about a **pair**, currently only `parts-overlap`. | | `penetrationFraction` | On `parts-overlap`: how deeply, as a fraction of the smaller part's smallest dimension. | | `wastePct` | On `squared-off-waste`: the fraction of the bounding rectangle that is waste, 0..1. | | `confidence` | On `fixture-detected` / `role-uncertain`: how sure the verdict was, 0..1. | Codes worth handling: | Code | Severity | Means | |---|---|---| | `fixture-detected` | `info` | This is a fitting, not sheet goods. Drop it. | | `role-uncertain` | `info` | The fitting question was close. Worth a human look. | | `squared-off-waste` | `info` | Squaring this shaped part off costs `wastePct` of its area. | | `dimensions-approximate` | `warning` | The dimensions came from a fallback, not from measured flat faces. Check them. | | `parts-overlap` | `warning` | Two parts share space. Often deliberate (a housing, a rebate, a biscuit), so this is never a blocker. | | `not-flat-panel` | `warning` | Turned, curved, tapered or multi-body. The bounding box is the best available answer. | | `open-mesh` / `non-manifold-edge` | `warning` | The mesh is not a closed solid. Dimensions may be unreliable. | | `model-too-heavy` | `warning` | Very dense. It worked, but a coarser export would be faster. | | `scanned-mesh-file` | `error` | This is a scan or a conversion, not a CAD model of parts. | ## Limits and cost | | | |---|---| | **Maximum upload** | 32MB. Larger bodies get `413` without being buffered. | | **Maximum complexity** | 2,000,000 triangles. Above that, `422`. Export with a coarser mesh setting, or decimate. | | **Time budget** | 30 seconds. A model that does not finish returns `422` with the same advice. | | **Rate limit** | The same per-minute budget as `/v3/calculate`. | | **Billing** | The same compute-time meter as a calculation, and it counts against your monthly call allowance. | A refused request is **not billed** and does not spend a call: an unsupported format, an over-size model, a timeout. ## Errors Every error is `{ "error": "…", "details": { … }, "version": "3" }`. | Status | Means | |---|---| | `400` | The body is not a file, the format could not be determined, or `units`/`sourceUnits` are not ones we convert. | | `401` | Missing or unknown API key. | | `403` | Over the monthly call limit, or the account is on hold. | | `413` | Body larger than 32MB. | | `415` | A format this server cannot read. `details.supported` lists the ones that work. | | `422` | Too many triangles, or the analysis did not finish in its budget. | | `429` | Rate limited. Wait and retry. | ## Related - [API reference](/api-docs/v3): the generated schema for this endpoint and every other. - [Guide](/docs/guide/): getting started with the optimisation API. - [Result webhooks](/docs/webhooks/): how to receive the calculation you submit next. --- # Result webhooks URL: https://smartcut.dev/docs/webhooks > Receive finished SmartCut results at your own URL instead of polling: registration, payload, signature verification and retry behaviour. Webhooks are the recommended way to receive calculation results. Instead of polling [`/v3/result/ready`](/api-docs/v3) until a job finishes, SmartCut sends the finished result to a URL you control, as soon as it is ready. :::note This page covers the **optimisation API** webhook, which delivers calculation results and is configured per API user. It is **not** the e-commerce store webhook. That one delivers order and inventory events, is configured in your store settings, and is documented in the [Hosted store guide](/docs/ecommerce#webhooks). ::: ## Register a webhook There are two ways to set one up, and they can be combined. **Per-request.** Include a `webhook` field in your [`/v3/calculate`](/api-docs/v3) request body: ```json { "saw": { "cutType": "guillotine", "bladeWidth": 3, "stockType": "sheet" }, "stock": [ { "l": 2400, "w": 1200 } ], "parts": [ { "l": 400, "w": 300, "q": 5 } ], "webhook": "https://example.com/hooks/smartcut" } ``` **Account default.** Set a default URL at [smartcut.dev/account](https://smartcut.dev/account). It is used for every job that does not carry a per-request URL. A per-request `webhook` **wins** for that job. If you set neither, nothing is delivered and you must poll for the result. ## What gets delivered When the job finishes, SmartCut sends an HTTP `POST` with a `Content-Type: application/json` body. For a successful calculation the body is **identical to the response from `GET /v3/result`**, the same `ResultResponse` schema, with `jobId` set. Use that id to correlate the delivery with the job you got back from `/v3/calculate`. Every request carries these headers: | Header | Description | |--------|-------------| | `X-SmartCut-Event` | The event name, either `job.completed` or `job.failed` | | `X-SmartCut-Delivery` | Unique delivery ID. **Stable across retries**, so use it as an idempotency key | | `X-SmartCut-Attempt` | Which attempt this is, starting at 1 | | `X-SmartCut-Timestamp` | Unix timestamp (seconds) of this delivery attempt | | `X-SmartCut-Signature` | HMAC signature. See [Verifying the signature](#verifying-the-signature) | Respond with any `2xx` status within 10 seconds to acknowledge. Anything else counts as a failure and is retried. ## Events Read `X-SmartCut-Event` to tell the two apart. The body shape differs. | Event | Body | |-------|------| | `job.completed` | The full `ResultResponse`, exactly as `GET /v3/result` returns it | | `job.failed` | `{ "statusCode": 422, "message": "" }` | A failed job sends a notification and nothing else. There is no partial result to collect. Branch on the header before using the body: ```js // `payload` is the parsed body, after verifying the signature against the raw // bytes — see below. function handle( event, payload ) { if ( event === 'job.failed' ) { console.error( `Job failed: ${ payload.message }` ) return } console.log( `Job ${ payload.jobId } complete` ) // payload is a ResultResponse } ``` ## Verifying the signature Every delivery is signed with a secret unique to your account. You can reveal or rotate it on your [account page](https://smartcut.dev/account). Verify the signature before trusting a payload. It is what proves the request came from SmartCut. The `X-SmartCut-Signature` header looks like: ```text X-SmartCut-Signature: t=1720000000,v1=5257a869e7b... ``` `v1` is an HMAC-SHA256, in hex, of the string `{timestamp}.{raw request body}`, using your signing secret as the key. Note the timestamp is **inside** the signed payload: that's what lets you reject replayed deliveries. To verify: take `t` from the header, concatenate it with a `.` and the **raw** request body, HMAC that with your secret, and compare against `v1` using a constant-time comparison. Take the body before any JSON parsing, since re-serialising changes the bytes and breaks the signature. Reject the request if the timestamp is more than a few minutes old. ```js import crypto from 'crypto' function verifySmartCutWebhook( header, rawBody, secret, toleranceSeconds = 300 ) { const parts = Object.fromEntries( header.split( ',' ).map( p => p.trim().split( '=' ) ) ) const timestamp = Number( parts.t ) const provided = parts.v1 // A malformed header can never be valid — bail before the replay check. if ( !Number.isFinite( timestamp ) || !provided ) return false // Reject replays: the signature is still valid, but the delivery is stale. if ( Math.abs( Math.floor( Date.now() / 1000 ) - timestamp ) > toleranceSeconds ) return false const expected = crypto .createHmac( 'sha256', secret ) .update( `${ timestamp }.${ rawBody }`, 'utf8' ) .digest( 'hex' ) const a = Buffer.from( expected ) const b = Buffer.from( provided ) return a.length === b.length && crypto.timingSafeEqual( a, b ) } ``` Your framework must give you the raw body. In Express, use `express.raw({ type: 'application/json' })` on the webhook route and parse it yourself after verifying. Reaching for `express.json()` instead discards the exact bytes the signature covers: ```js app.post( '/hooks/smartcut', express.raw( { type: 'application/json' } ), ( req, res ) => { const raw = req.body // a Buffer, not an object if ( !verifySmartCutWebhook( req.get( 'X-SmartCut-Signature' ), raw, process.env.SMARTCUT_WEBHOOK_SECRET ) ) { return res.sendStatus( 400 ) } // Acknowledge first, then do the work — you have 10 seconds. res.sendStatus( 200 ) handle( req.get( 'X-SmartCut-Event' ), JSON.parse( raw ) ) } ) ``` ## Retries and idempotency If your endpoint does not return a `2xx`, the delivery is retried with an increasing backoff over roughly a day before being given up on. These responses are treated differently: | Response | Retried? | |----------|----------| | `2xx` | Delivered, so no retry | | `5xx` | Yes | | `429` / `408` | Yes, and a `Retry-After` header is honoured | | Other `4xx` | No. Resending an unchanged request cannot help | | `3xx` | No. Redirects are **not** followed | | Timeout (10s) or network error | Yes | Retries reuse the same `X-SmartCut-Delivery` ID, so **treat that ID as an idempotency key** and ignore a delivery you have already processed. A retry is not a new result. It is the same one arriving again. ## Endpoints that keep failing The two registration methods behave differently when your endpoint breaks, and the difference matters: - **Account-default URLs are health-tracked.** After sustained failure with no successful delivery in between, the endpoint is **disabled** and we stop sending to it. You will be emailed before that happens. A disabled endpoint has to be re-enabled by hand on your account page, never automatically. Any successful delivery resets the clock. - **Per-request URLs are not tracked.** They are signed and retried exactly the same way, but a URL that fails is never disabled and a broken one produces no warning. Nothing accumulates across jobs, because each job carries its own URL. If you rely on webhooks for anything important, prefer the account default so that a silently broken endpoint is surfaced to you rather than failing quietly on every job. ## Falling back to polling Webhooks require your service to accept inbound HTTP. If it cannot, poll [`/v3/result/ready`](/api-docs/v3) every 2-3 seconds, stop on `200` (then fetch `/v3/result`) or on `404`/`410`, and back off on `429`.