Recipes
Recipe — quote a part end-to-end
From a STEP file on disk to a customer-ready quote PDF, with audit lineage.
Turn a STEP file into a defensible, customer-ready quote in one pass. This recipe walks the canonical ARCNM flow: a file lands on your server (RFQ inbox, customer portal upload, ERP attachment), and you return a unit price to your sales team within a minute.
By the end you'll have:
- Uploaded the CAD and an optional drawing.
- Run the ARCNM engine against your production environment.
- Pulled the unit cost, costs, and audit blob.
- Generated a customer-ready PDF stamped with the calculation id.
Prerequisites
- An ARCNM API key with scopes
parts:write(to run calculations) andparts:read(to fetch the result). - One costing environment (a UUID). The dashboard onboarding flow creates a default one; this recipe assumes you have its id.
- A STEP file (
./bracket.step) and optionally a drawing (./bracket.pdf).
export ARCNM_API_KEY="ak_live_…"
export ENV_ID="…" # your costing environment UUID
Step 1 — Quote in one call
POST /api/v1/calculations/upload-and-quote creates the Part,
Revision, and Dataset from the uploaded file and enqueues the
calculation in a single multipart/form-data request. Returns 202
Accepted.
costing_environment_id, part_number, and cad_file are required.
CALC=$(curl -s -X POST https://api.arcnm.io/api/v1/calculations/upload-and-quote \
-H "X-API-Key: $ARCNM_API_KEY" \
-F "costing_environment_id=$ENV_ID" \
-F "part_number=BRACKET-001" \
-F "cad_file=@./bracket.step;type=application/step" \
-F "drawing_file=@./bracket.pdf;type=application/pdf" \
-F "lot_size=50" \
-F "annual_volume=500" \
-F "material_ref=1.4301")
echo "$CALC"
CALC_ID=$(echo "$CALC" | jq -r .id)
import os, requests
resp = requests.post(
"https://api.arcnm.io/api/v1/calculations/upload-and-quote",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
data={
"costing_environment_id": os.environ["ENV_ID"],
"part_number": "BRACKET-001",
"lot_size": 50,
"annual_volume": 500,
"material_ref": "1.4301",
},
files={
"cad_file": ("bracket.step", open("./bracket.step", "rb"), "application/step"),
"drawing_file": ("bracket.pdf", open("./bracket.pdf", "rb"), "application/pdf"),
},
)
resp.raise_for_status()
calc = resp.json()
print(f"queued: {calc['id']}")
import { readFile } from "node:fs/promises"
const form = new FormData()
form.set("costing_environment_id", process.env.ENV_ID!)
form.set("part_number", "BRACKET-001")
form.set("lot_size", "50")
form.set("annual_volume", "500")
form.set("material_ref", "1.4301")
form.set("cad_file", new Blob([await readFile("./bracket.step")]), "bracket.step")
form.set("drawing_file", new Blob([await readFile("./bracket.pdf")]), "bracket.pdf")
const resp = await fetch(
"https://api.arcnm.io/api/v1/calculations/upload-and-quote",
{ method: "POST", headers: { "X-API-Key": process.env.ARCNM_API_KEY! }, body: form },
)
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`)
const calc = await resp.json()
console.log("queued:", calc.id)
Response (202 Accepted):
{ "id": "9d8f3b62-…", "status": "queued" }
Already have a Part + Revision? Skip the upload and call
POST /api/v1/calculations/quotewith a JSON body (part_revision_id+costing_environment_id, optionallot_size,annual_volume,material_ref/material_grade_id,currency). It also returns 202 and enqueues in one call. See Calculations.
Step 2 — Poll for the result
Poll GET /api/v1/calculations/{id} until status reaches a
terminal value: succeeded, failed, cancelled, or timed_out.
while true; do
RESP=$(curl -s "https://api.arcnm.io/api/v1/calculations/$CALC_ID" \
-H "X-API-Key: $ARCNM_API_KEY")
STATUS=$(echo "$RESP" | jq -r .status)
echo "status=$STATUS"
case "$STATUS" in
succeeded) echo "$RESP" > result.json; break ;;
failed|cancelled|timed_out) echo "$RESP" | jq .error; exit 1 ;;
*) sleep 2 ;;
esac
done
import time
def wait_for(calc_id: str, timeout_s: int = 120) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(
f"https://api.arcnm.io/api/v1/calculations/{calc_id}",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
)
r.raise_for_status()
calc = r.json()
if calc["status"] == "succeeded":
return calc
if calc["status"] in ("failed", "cancelled", "timed_out"):
raise RuntimeError(calc.get("error"))
time.sleep(2)
raise TimeoutError(calc_id)
result = wait_for(calc["id"])
print(f"unit cost @ n=50: {result['unit_cost']:.2f} {result['currency']}")
async function waitFor(calcId: string, timeoutMs = 120_000): Promise<any> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const r = await fetch(
`https://api.arcnm.io/api/v1/calculations/${calcId}`,
{ headers: { "X-API-Key": process.env.ARCNM_API_KEY! } },
)
if (!r.ok) throw new Error(`${r.status}`)
const calc = await r.json()
if (calc.status === "succeeded") return calc
if (["failed", "cancelled", "timed_out"].includes(calc.status)) throw new Error(JSON.stringify(calc.error))
await new Promise(res => setTimeout(res, 2_000))
}
throw new Error("timeout")
}
const result = await waitFor(calc.id)
console.log(`unit cost @ n=50: ${result.unit_cost.toFixed(2)} ${result.currency}`)
A succeeded result carries the costs, timings, and the audit blob:
{
"id": "9d8f3b62-…",
"status": "succeeded",
"part_id": "…",
"part_revision_id": "…",
"costing_environment_id": "…",
"engine": "arcanum",
"lot_size": 50,
"annual_volume": 500,
"unit_cost": 12.84,
"total_cost": 642.00,
"setup_cost": 52.50,
"unit_time_s": 91.3,
"total_time_s": 4617.0,
"currency": "EUR",
"analytics": { /* full audit blob */ },
"started_at": "2026-05-28T14:23:00Z",
"finished_at": "2026-05-28T14:23:32Z"
}
Cost fields are unit_cost / total_cost / setup_cost with a
sibling currency. The full breakdown lives in the free-form
analytics blob — keep that server-side.
Step 3 — Render the customer-facing quote
The shape is stable enough to pass directly to a PDF template. The audit blob stays server-side; only the price and a calculation id go on the customer document.
from weasyprint import HTML
from jinja2 import Template
tpl = Template(open("./quote.html.j2").read())
html = tpl.render(
part_number="BRACKET-001",
calculation_id=result["id"],
unit_cost=result["unit_cost"],
total_cost=result["total_cost"],
setup_cost=result["setup_cost"],
currency=result["currency"],
)
HTML(string=html).write_pdf("./quote.pdf")
import { renderToFile } from "@react-pdf/renderer"
import { QuoteDocument } from "./QuoteDocument"
await renderToFile(<QuoteDocument
partNumber="BRACKET-001"
calculationId={result.id}
unitCost={result.unit_cost}
totalCost={result.total_cost}
setupCost={result.setup_cost}
currency={result.currency}
/>, "./quote.pdf")
The calculation_id printed in the PDF is the receipt: any auditor
(internal or external) can fetch the original calc and re-read the
inputs and the audit blob.
Step 4 — Persist the calculation id
Drop the calculation id into your ERP / quote management tool so re-quotes are trivially traceable:
INSERT INTO erp_quotes (rfq_id, part_number, arcanum_calc_id, unit_cost, currency, created_at)
VALUES ($1, $2, $3, $4, $5, NOW());
A year later, GET /api/v1/calculations/{id} returns the exact
audit row — same inputs, same costs, same analytics provenance.
Common variations
Quote against multiple environments
To compare your fleet vs a subcontractor (or any set of up to 16
environments), pass costing_environment_ids instead of the single
id — one call, one upload, one calculation per environment, grouped
under a batch_id. Each environment's run counts as one calculation
against your plan quota (2 environments below = 2 calculations):
r = requests.post(
"https://api.arcnm.io/api/v1/calculations/quote",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
json={
"part_revision_id": calc["part_revision_id"],
"costing_environment_ids": [OUR_ENV_ID, SUB_ENV_ID],
"lot_size": 50,
},
)
r.raise_for_status()
batch_id = r.json()["batch_id"] # runs: r.json()["environment_runs"]
Don't diff unit costs client-side — the comparison endpoint computes the whole matrix server-side (best environment per part, deltas vs the cheapest and vs your baseline, staleness and mixed-currency flags), so every consumer sees the same numbers:
cmp = requests.get(
f"https://api.arcnm.io/api/v1/calculations/batches/{batch_id}/comparison",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
).json()
# Poll while cmp["complete"] is false — cells fill in as runs finish.
for part in cmp["parts"]:
print(part["part_number"], "→ best:", part["best_environment_id"])
The same costing_environment_ids field works on
POST /calculations/batch (N revisions × M environments in one
request) and on upload-and-quote (the file is uploaded once for the
whole grid). To compare runs that were not submitted together, POST
their ids to /calculations/comparison instead.
Price from 3D geometry alone
There is no extraction-mode flag. To price from the geometry alone, simply don't attach a drawing — the engine analyses what you send it:
requests.post(
"https://api.arcnm.io/api/v1/calculations/upload-and-quote",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
data={
"costing_environment_id": os.environ["ENV_ID"],
"part_number": "BRACKET-001",
},
files={
# No drawing_file → no drawing analysis. Nothing else to configure.
"cad_file": ("bracket.step", open("./bracket.step", "rb"), "application/step"),
},
)
Attach drawing_file and the drawing is read automatically (title block,
dimensions, tolerances, notes). The response reports which path ran as
engine_path (vlm / no_vlm) for diagnostics.
Currency conversion
The JSON /quote endpoint accepts a currency (ISO-4217) to convert
the response:
requests.post(
"https://api.arcnm.io/api/v1/calculations/quote",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
json={
"part_revision_id": part_revision_id,
"costing_environment_id": os.environ["ENV_ID"],
"currency": "USD",
},
)
Attach an extra input after upload
To add a 2D drawing or RFQ text to a calculation created without one,
POST /api/v1/calculations/{id}/inputs with a role form field
and a single file (one attachment per call):
curl -X POST https://api.arcnm.io/api/v1/calculations/$CALC_ID/inputs \
-H "X-API-Key: $ARCNM_API_KEY" \
-F "role=drawing_2d" \
-F "file=@./bracket.pdf;type=application/pdf"
What to do when it fails
| Scenario | Action |
|---|---|
status: failed, error.details.retryable: false |
The input is unrecoverable — ask for a fresh upload or fall back to manual quoting. |
status: failed, error.details.retryable: true |
Re-enqueue with POST /api/v1/calculations/{id}/run. The pipeline reuses the existing upload. |
422 material_unresolved |
The material_ref didn't match the catalogue (or the material_grade_id is not a known grade). details.candidates carries ranked near matches — surface them to your sales engineer, or resend with a material_grade_id. Every calculation entry point answers the same way, single-environment and multi-environment alike. |
402 quota_exceeded |
Included calculations exhausted for this period; a grid the remaining quota can't cover never runs in part — details.cells_requested / details.cells_available carry the exact numbers. On /quote and upload-and-quote the cells are saved parked blocked and details.batch_id / details.parked_cells name the batch: top up, then resume it (POST /calculations/bulk-retry, or /run per cell) instead of re-submitting the grid. /calculations/batch rejects the request whole with nothing persisted — shrink the grid or top up, then resubmit. See Pricing & billing. |
| 429 / 5xx | Exponential backoff with jitter (the API doesn't emit Retry-After). Pair small JSON retries with an Idempotency-Key. |
Idempotency on uploads. A CAD upload is a large multipart request, so the
Idempotency-Keyheader runs in lock-only mode: a fast retry while the original is in flight returns409 idempotency_in_flightrather than a cached body. The natural key forupload-and-quoteispart_number— re-posting the same number attaches a new revision instead of duplicating the Part. See Idempotency.
See also
- Quickstart — the same flow, condensed.
- Calculations — full endpoint reference.
- Concepts → Audit & provenance — what the audit blob carries.
- Recipes → Bulk RFQ triage — batches of CAD.
- Recipes → ERP integration — wire it into SAP / Vault / etc.