ARCNM

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:

  1. Uploaded the CAD and an optional drawing.
  2. Run the ARCNM engine against your production environment.
  3. Pulled the unit cost, costs, and audit blob.
  4. Generated a customer-ready PDF stamped with the calculation id.

Prerequisites

  • An ARCNM API key with scopes parts:write (to run calculations) and parts: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/quote with a JSON body (part_revision_id + costing_environment_id, optional lot_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-Key header runs in lock-only mode: a fast retry while the original is in flight returns 409 idempotency_in_flight rather than a cached body. The natural key for upload-and-quote is part_number — re-posting the same number attaches a new revision instead of duplicating the Part. See Idempotency.


See also