Axton Robotics Instant Quote Engine API v1

Instant Quote Engine

Headless CNC quoting service for the Axton Robotics platform. Send a STEP file; get back measured geometry and display meshes, priced options (material, tolerance, finish, quantity, lead time), engineer-review flags, and a branded PDF. All geometry analysis and pricing run inside this service — responses carry prices, never rates.

Base URL: https://quote.axton.tools/api/v1
This API has no end users

The caller is the platform backend (Laravel at api.axtonrobotics.com), authenticated by a service key. Buyer accounts, sessions, ownership checks and quote history all live on the platform — this service trusts its caller completely. Never expose the key or these endpoints directly to a browser; proxy through the platform, which must verify a buyer owns a quoteId before forwarding requests about it.

Requests and responses are JSON, with two exceptions: POST /quotes takes multipart/form-data, and POST /quotes/{id}/pdf returns application/pdf bytes.

Authentication

Every request (except /health) needs a service key issued by Axton. Send it as a Bearer token in the Authorization header. Keys are configured server-side (SERVICE_API_KEYS, comma-separated so a new key can be rotated in while the old one still works) and must stay on your backend — never in client-side code.

curl -H "Authorization: Bearer axq_YOUR_KEY" \
  "https://quote.axton.tools/api/v1/catalog"

A missing or invalid key returns 401 with {"error": "Missing or invalid service key."}.

Quoting flow

StepCallWhat happens
1GET /catalogFetch option keys/names once (cache it) to build the buyer-facing selectors.
2POST /quotesForward the buyer's STEP file(s). The engine parses, measures and stores each part, returning a quoteId plus per-body display meshes for the 3D preview.
3—A part with needsPick: true holds several solid bodies; the buyer picks one in the viewer.
4POST /quotes/{id}/pricePrice the current selections. Call it on every option change — geometry is already stored, nothing re-parses.
5—A result with a non-empty review array is outside the instant-quote envelope: route it to engineer review, don't sell the number.
6POST /quotes/{id}/pdfBuild the branded PDF (viewer thumbnails passed in); the engine stores a copy and returns the bytes.
Quotes are additive

Passing an existing quoteId to POST /quotes appends new files to that quote — that's how a multi-part order is built. The optional meta field (JSON) is stored verbatim and echoed back, so you can attach your own user/order reference at creation.

3D viewer component

The engine's repo ships a drop-in React component — integration/react/PartViewer.tsx — that renders the mesh payload from POST /quotes with the same studio look as the original portal: orbit/pan/zoom, per-material appearance from the catalog's visual settings, click-to-pick for multi-body files, and captureThumbnail() for the PDF step. See integration/README.md for the Laravel proxy sketch and Next.js wiring (peer dep: three).

GET

/api/v1/health

Liveness probe — the only unauthenticated endpoint. Returns {"ok": true, "store": "postgres"}.

GET

/api/v1/catalog

The authoritative list of option keys and display names — materials, tolerances, finishes, lead times — plus per-material 3D appearance settings. It changes only when the shop edits rates, so cache it and serve it to your frontend from your own endpoint. See Catalog values for the shape and current keys.

POST

/api/v1/quotes

Uploads one or more STEP files as multipart/form-data. Each file is validated, parsed in an isolated worker with a hard timeout, measured and stored. Returns a display mesh per solid body for the 3D preview.

Form fieldTypeDescription
filesfile[]Required. Up to 25 files, 30 MB each. .step / .stp only — content is also checked for a STEP header.
quoteIdstringOptional. Append to this existing quote instead of creating a new one. 404 if unknown.
metastringOptional, new quotes only. JSON stored verbatim on the record and echoed back — attach your own user/order reference here. 400 if not valid JSON.

Response 200

{
  "quoteId": "AXQ-260827-4F2A",
  "meta": { "userId": 184 },
  "parts": [
    {
      "partId":    "9f2c1a7b3d4e5f60",
      "fileName":  "bracket.step",
      "needsPick": false,            // true when the file holds >1 body
      "bodies": [
        {
          "bodyIndex": 0,
          "position":  [0, 0, 0, /* flat XYZ vertex array */],
          "normal":    [0, 1, 0, /* … or null if absent */],
          "index":     [0, 1, 2, /* triangle indices */],
          "geo": {
            "volumeMm3": 48210.4,
            "areaMm2":   15320.8,
            "bbox":      { "x": 120.0, "y": 80.0, "z": 25.0 },
            "triangles": 4820,
            "faces":     86,
            "bodies":    1
          }
        }
      ]
    }
  ],
  "failed": ["corrupt-model.stp"]     // files that could not be read
}
Partial success is normal — and meshes are ephemeral

A request mixing good and bad files returns 200 with the good ones in parts and the rest named in failed — surface that list to the buyer; you get 422 only when every file failed. The position/normal/index arrays can be several MB and appear only in this response — they are not stored and no endpoint returns them again. Keep them in browser memory for the session; pricing uses the geometry the engine retained, so altering them changes nothing.

All dimensions are millimetres, taken from the STEP file's own units. Errors: 400 no files / bad meta · 404 unknown quoteId · 422 nothing readable as STEP · 500 parse or storage failure.

GET

/api/v1/quotes/{quoteId}

The stored record: createdAt, meta, and each part's partId/fileName/needsPick with per-body geo. No mesh arrays — for re-rendering a part later, re-upload it. 404 if unknown.

POST

/api/v1/quotes/{quoteId}/price

Prices the given selections against the stored geometry. Cheap and idempotent — call it on every option change. Debounce in the UI: the per-key rate budget is shared by the whole site.

Body fieldTypeDescription
leadtimestringOptional. standard, expedite or rush; whole-order. Defaults to standard.
selectionsobject[]Required. One entry per part to price.

Selection object

FieldTypeDescription
partIdstringRequired. Unknown ids are silently skipped.
bodyIndexintRequired when the part has needsPick: true; defaults to 0 otherwise.
materialstringOptional catalog key. Default al-6061.
tolerancestringOptional catalog key. Default std.
finishstringOptional catalog key. Default as-machined.
qtyintOptional. Clamped to 1–100,000. Default 1.
Unknown option keys do not error

An unrecognised material, tolerance, finish or leadtime silently falls back to its default instead of returning 400. Validate against the catalog on your side, or a typo quotes the wrong material at full confidence.

Response 200

{
  "quoteId": "AXQ-260827-4F2A",
  "orderTotal": 1428.70,          // sum of clean (no-review) line totals
  "results": [
    {
      "partId":    "9f2c1a7b3d4e5f60",
      "bodyIndex": 0,
      "needsPick": false,
      "quote": {
        "unit":      142.87,        // per-part price, all multipliers applied
        "total":     1428.70,
        "qty":       10,
        "cycleMin":  18.4,          // estimated machining minutes per part
        "leadDays":  10,
        "leadName":  "Standard · 10 days",
        "matName":   "Aluminum 6061-T6",
        "tolName":   "Std ±0.125 mm",
        "finName":   "As machined",
        "qtyFactor":  0.94,         // volume discount applied
        "leadFactor": 1.0,          // lead-time multiplier applied
        "minOrderApplied": false,
        "comp": {                        // per-part breakdown, already marked up
          "machining": 98.20,       // cutting + setup amortised over qty
          "material":  44.67,
          "finishing": 0
        }
      },
      "review": []
    }
  ]
}

Two result shapes need special handling

ConditionShapeWhat to do
needsPick: truequote is null, review is []Multiple bodies and no bodyIndex sent. Have the buyer pick one, re-price.
review.length > 0quote is still populatedDo not present the price as final. Show the review reasons and route to engineer review.
The review array is the gate, not the price

A flagged part still carries a computed quote — the engine does not null it out (and excludes it from orderTotal). If the site renders quote.total without checking review.length === 0, it will quote parts the shop has not agreed to make at that price. Review reasons are human-readable strings, e.g. "Part exceeds our instant-quote envelope (600 × 400 × 300 mm)", "Possible thin walls — needs a deflection/fixturing review".

Errors: 404 unknown quote · 500 pricing failure.

POST

/api/v1/quotes/{quoteId}/pdf

Builds the branded PDF, stores a copy, and returns the bytes (Content-Type: application/pdf, attachment named after the quote). It re-prices internally from the same selections, so the PDF always matches what the engine would quote — prices cannot be injected.

Body fieldTypeDescription
leadtimestringOptional. Same values as price.
selectionsobject[]Required. Same shape as price.
thumbsobjectOptional. Map of partId → PNG data URL from the viewer's captureThumbnail(). Omit for a PDF without part images. Keep thumbnails ~640×480 — the JSON body caps at 30 MB.

Errors: 404 unknown quote · 500 generation failure. A failure to store the copy is logged but the bytes are still returned.

Stored files

The engine keeps every uploaded STEP file and the latest generated PDF per quote — this is how the shop retrieves CAD for manufacturing after an order is placed.

EndpointReturns
GET /api/v1/quotes/{quoteId}/filesPart list + stored file inventory (kind, name, size) as JSON.
GET /api/v1/quotes/{quoteId}/files/step/{partId}The original STEP file, application/octet-stream, original filename preserved.
GET /api/v1/quotes/{quoteId}/files/pdfThe stored quote PDF. 404 if none generated yet.

Catalog values

Shape of GET /catalog. Build selectors from the live response rather than hard-coding the tables below — the shop adds materials and finishes without notice.

{
  "catalog": {
    "company": "Axton Robotics",
    "website": "axtonrobotics.com",
    "quoteValidDays": 30,
    "materials": {
      "al-6061": {
        "name":  "Aluminum 6061-T6",
        "group": "aluminum",
        "visual": { "color": 13160914, "metalness": 0.95, "roughness": 0.4 }
      }
    },
    "tolerances": { "std": { "name": "Std ±0.125 mm" } },
    "finishes":   { "anodize-clr": { "name": "Anodize clear", "groups": ["aluminum"] } },
    "leadTimes":  { "standard": { "name": "Standard · 10 days", "days": 10 } }
  }
}

visual feeds the viewer's material appearance (color is a decimal RGB integer) — pass it to PartViewer as materialVisual.

materials — key · name · group
al-6061Aluminum 6061-T6aluminum
al-7075Aluminum 7075-T6aluminum
st-1018Mild Steel 1018steel
st-4140Alloy Steel 4140steel
ss-304Stainless 304steel
ss-316Stainless 316steel
brass-360Brass 360brass
ti-6al4vTitanium 6Al-4Vtitanium
delrinDelrin (Acetal)plastic
tolerances
stdStd ±0.125 mm
precPrec ±0.05 mm
tightTight ±0.025 mm
leadTimes
standardStandard · 10 days
expediteExpedited · 5 days
rushRush · 3 days
finishes — key · name · allowed material groups
as-machinedAs machinedany
bead-blastBead blastany
anodize-clrAnodize clearaluminum
anodize-blkAnodize blackaluminum
powder-coatPowder coatany

groups restricts a finish to material groups (null = any). Filter the finish dropdown by the selected material's group — the engine doesn't reject a mismatched pair, it just prices it.

Field reference

geo — measured geometry

FieldTypeMeaning
volumeMm3numberSolid volume of the body.
areaMm2numberTotal surface area.
bboxobject{x, y, z} axis-aligned bounding-box extents, mm.
trianglesintTriangle count in the display mesh.
facesintB-rep surface count — the complexity driver in pricing.
bodiesintBodies in this measurement (1 per body entry).

quote — pricing result

FieldTypeMeaning
unitnumberPrice per part, all multipliers applied.
totalnumberunit × qty, or the minimum order value if higher.
qtyintQuantity priced.
cycleMinnumberEstimated machining minutes per part.
leadDays / leadNameint / stringChosen lead time, for display.
matName / tolName / finNamestringResolved option names, for display.
qtyFactornumberVolume-discount multiplier applied (≤ 1).
leadFactornumberLead-time multiplier applied (≥ 1).
minOrderAppliedbooltrue when the order floor raised the total — tell the buyer, or the unit price looks wrong for the quantity.
comp.machining / .material / .finishingnumberPer-part breakdown, marked up; sums ≈ unit (exactly, except when minOrderApplied).
quoteId — quote number
AXQ-YYMMDD-XXXX
e.g. AXQ-260827-4F2A

Opaque string; doubles as the human-facing quote number on the PDF. Not unguessable — the platform must check a buyer owns it before forwarding any request about it.

partId — part handle
16 lowercase hex chars
e.g. 9f2c1a7b3d4e5f60

Opaque string, unique within a quote. Keys selections and thumbnails.

Errors & limits

Every error body is JSON: {"error": "Human-readable message."}

StatusMeaning
400Invalid input — no files, or meta not valid JSON.
401Missing or invalid service key.
404Unknown quote or file.
422None of the uploaded files could be read as STEP.
429Per-key rate budget exceeded.
500Server-side failure — parse, pricing or PDF generation. Retry with backoff; contact Axton if it persists.

Limits

LimitDefaultNotes
rate budget300 req/minPer service key (not per IP — the whole site calls from one backend). Standard RateLimit-* headers returned.
max file size30 MBPer uploaded file.
max files25Per upload request.
parse timeout20 sPer file; a timeout marks that file failed.
JSON body30 MBGenerous because the PDF call carries PNG thumbnails.
extensions.step, .stpContent also checked for a STEP header.

Full example

A complete quote in four server-to-server calls.

# 0 — option catalog (cache it)
curl -s -H "Authorization: Bearer axq_YOUR_KEY" \
  https://quote.axton.tools/api/v1/catalog

# 1 — upload a STEP file (form field: "files"; optional meta JSON)
curl -s -H "Authorization: Bearer axq_YOUR_KEY" \
  -F 'files=@bracket.step' \
  -F 'meta={"userId":184}' \
  https://quote.axton.tools/api/v1/quotes
# → {"quoteId":"AXQ-260827-4F2A","parts":[{"partId":"9f2c…", …}],"failed":[]}

# 2 — price it
curl -s -H "Authorization: Bearer axq_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "leadtime": "standard",
        "selections": [
          { "partId": "9f2c…", "material": "al-6061",
            "tolerance": "std", "finish": "as-machined", "qty": 10 }
        ]
      }' \
  https://quote.axton.tools/api/v1/quotes/AXQ-260827-4F2A/price
# → {"quoteId":"…","orderTotal":1428.70,"results":[{"quote":{…},"review":[]}]}

# 3 — build + download the PDF (thumbs come from PartViewer.captureThumbnail())
curl -s -H "Authorization: Bearer axq_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"leadtime":"standard",
       "selections":[{"partId":"9f2c…","material":"al-6061","qty":10}]}' \
  -o quote.pdf \
  https://quote.axton.tools/api/v1/quotes/AXQ-260827-4F2A/pdf

Laravel controller and Next.js/PartViewer wiring for exactly this flow live in the repo's integration/ folder.