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
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
| Step | Call | What happens |
|---|---|---|
| 1 | GET /catalog | Fetch option keys/names once (cache it) to build the buyer-facing selectors. |
| 2 | POST /quotes | Forward 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. |
| 4 | POST /quotes/{id}/price | Price 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. |
| 6 | POST /quotes/{id}/pdf | Build the branded PDF (viewer thumbnails passed in); the engine stores a copy and returns the bytes. |
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).
/api/v1/health
Liveness probe — the only unauthenticated endpoint. Returns
{"ok": true, "store": "postgres"}.
/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.
/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 field | Type | Description |
|---|---|---|
| files | file[] | Required. Up to 25 files, 30 MB each.
.step / .stp only — content
is also checked for a STEP header. |
| quoteId | string | Optional. Append to this existing quote
instead of creating a new one. 404 if unknown. |
| meta | string | Optional, 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
}
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.
/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.
/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 field | Type | Description |
|---|---|---|
| leadtime | string | Optional. standard, expedite or rush; whole-order. Defaults to standard. |
| selections | object[] | Required. One entry per part to price. |
Selection object
| Field | Type | Description |
|---|---|---|
| partId | string | Required. Unknown ids are silently skipped. |
| bodyIndex | int | Required when the part has needsPick: true; defaults to 0 otherwise. |
| material | string | Optional catalog key. Default al-6061. |
| tolerance | string | Optional catalog key. Default std. |
| finish | string | Optional catalog key. Default as-machined. |
| qty | int | Optional. Clamped to 1–100,000. Default 1. |
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
| Condition | Shape | What to do |
|---|---|---|
| needsPick: true | quote is null, review is [] | Multiple bodies and no bodyIndex sent. Have the buyer pick one, re-price. |
| review.length > 0 | quote is still populated | Do not present the price as final. Show the review reasons and route to engineer review. |
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.
/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 field | Type | Description |
|---|---|---|
| leadtime | string | Optional. Same values as price. |
| selections | object[] | Required. Same shape as price. |
| thumbs | object | Optional. 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.
| Endpoint | Returns |
|---|---|
| GET /api/v1/quotes/{quoteId}/files | Part 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/pdf | The 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.
| al-6061 | Aluminum 6061-T6 | aluminum |
| al-7075 | Aluminum 7075-T6 | aluminum |
| st-1018 | Mild Steel 1018 | steel |
| st-4140 | Alloy Steel 4140 | steel |
| ss-304 | Stainless 304 | steel |
| ss-316 | Stainless 316 | steel |
| brass-360 | Brass 360 | brass |
| ti-6al4v | Titanium 6Al-4V | titanium |
| delrin | Delrin (Acetal) | plastic |
| std | Std ±0.125 mm |
| prec | Prec ±0.05 mm |
| tight | Tight ±0.025 mm |
| standard | Standard · 10 days |
| expedite | Expedited · 5 days |
| rush | Rush · 3 days |
| as-machined | As machined | any |
| bead-blast | Bead blast | any |
| anodize-clr | Anodize clear | aluminum |
| anodize-blk | Anodize black | aluminum |
| powder-coat | Powder coat | any |
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
| Field | Type | Meaning |
|---|---|---|
| volumeMm3 | number | Solid volume of the body. |
| areaMm2 | number | Total surface area. |
| bbox | object | {x, y, z} axis-aligned bounding-box extents, mm. |
| triangles | int | Triangle count in the display mesh. |
| faces | int | B-rep surface count — the complexity driver in pricing. |
| bodies | int | Bodies in this measurement (1 per body entry). |
quote — pricing result
| Field | Type | Meaning |
|---|---|---|
| unit | number | Price per part, all multipliers applied. |
| total | number | unit × qty, or the minimum order value if higher. |
| qty | int | Quantity priced. |
| cycleMin | number | Estimated machining minutes per part. |
| leadDays / leadName | int / string | Chosen lead time, for display. |
| matName / tolName / finName | string | Resolved option names, for display. |
| qtyFactor | number | Volume-discount multiplier applied (≤ 1). |
| leadFactor | number | Lead-time multiplier applied (≥ 1). |
| minOrderApplied | bool | true when the order floor raised the total — tell the buyer, or the unit price looks wrong for the quantity. |
| comp.machining / .material / .finishing | number | Per-part breakdown, marked up; sums ≈ unit (exactly, except when minOrderApplied). |
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.
e.g. 9f2c1a7b3d4e5f60
Opaque string, unique within a quote. Keys selections and thumbnails.
Errors & limits
Every error body is JSON: {"error": "Human-readable message."}
| Status | Meaning |
|---|---|
| 400 | Invalid input — no files, or meta not valid JSON. |
| 401 | Missing or invalid service key. |
| 404 | Unknown quote or file. |
| 422 | None of the uploaded files could be read as STEP. |
| 429 | Per-key rate budget exceeded. |
| 500 | Server-side failure — parse, pricing or PDF generation. Retry with backoff; contact Axton if it persists. |
Limits
| Limit | Default | Notes |
|---|---|---|
| rate budget | 300 req/min | Per service key (not per IP — the whole site calls from one backend). Standard RateLimit-* headers returned. |
| max file size | 30 MB | Per uploaded file. |
| max files | 25 | Per upload request. |
| parse timeout | 20 s | Per file; a timeout marks that file failed. |
| JSON body | 30 MB | Generous because the PDF call carries PNG thumbnails. |
| extensions | .step, .stp | Content 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.