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/parts/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/parts/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/parts/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/parts/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/parts/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/parts/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/parts/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/parts/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/parts/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/parts/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, run the same revision
against two environments. Re-use the existing Part/Revision so you
only upload once — the first call returns part_revision_id, the
second uses the JSON /quote endpoint:
# First quote uploads + creates the Part/Revision.
ours = wait_for(calc["id"])
# Second quote re-uses the same revision against a different env.
r = requests.post(
"https://api.arcnm.io/api/v1/parts/calculations/quote",
headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},
json={
"part_revision_id": ours["part_revision_id"],
"costing_environment_id": SUB_ENV_ID,
"lot_size": 50,
},
)
r.raise_for_status()
sub = wait_for(r.json()["id"])
diff = ours["unit_cost"] - sub["unit_cost"]
print(f"in-house is {abs(diff):.2f} {ours['currency']}/unit {'cheaper' if diff < 0 else 'more expensive'}")
Force a drawing extraction tier (compliance run)
The extraction_tier field defaults to auto. Pin lite for fast
triage or full for the most thorough drawing pass:
requests.post(
"https://api.arcnm.io/api/v1/parts/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",
"extraction_tier": "full",
},
files={
"cad_file": ("bracket.step", open("./bracket.step", "rb"), "application/step"),
"drawing_file": ("bracket.pdf", open("./bracket.pdf", "rb"), "application/pdf"),
},
)
Currency conversion
The JSON /quote endpoint accepts a currency (ISO-4217) to convert
the response:
requests.post(
"https://api.arcnm.io/api/v1/parts/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/parts/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/parts/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/parts/calculations/{id}/run. The pipeline reuses the existing upload. |
material_not_resolved |
The material_ref didn't match the catalogue. Surface close matches to your sales engineer, or pass a material_grade_id directly. |
402 insufficient_funds |
The wallet hold for this run exceeds your balance. Top up, then retry. 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.