---
title: Recipe — quote a part end-to-end
description: From a STEP file on disk to a customer-ready quote PDF, with audit lineage.
---

# Recipe: quote a part end-to-end

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`).

```bash
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.

<CodeTabs>

```bash title="cURL"
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)
```

```python title="Python"
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']}")
```

```typescript title="TypeScript"
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)
```

</CodeTabs>

Response (`202 Accepted`):

```json
{ "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](../api/calculations.md).

---

## 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`.

<CodeTabs>

```bash title="cURL — poll"
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
```

```python title="Python — poll"
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']}")
```

```typescript title="TypeScript — poll"
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}`)
```

</CodeTabs>

A succeeded result carries the costs, timings, and the audit blob:

```json
{
  "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.

<CodeTabs>

```python title="Python — Jinja PDF"
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")
```

```typescript title="TypeScript — React-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")
```

</CodeTabs>

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:

```sql
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:

```python
# 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:

```python
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:

```python
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):

```bash
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](../pricing.md). |
| 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](../idempotency.md).

---

## See also

- [Quickstart](../quickstart.md) — the same flow, condensed.
- [Calculations](../api/calculations.md) — full endpoint reference.
- [Concepts → Audit & provenance](../concepts/audit-provenance.md) — what the audit blob carries.
- [Recipes → Bulk RFQ triage](./bulk-rfq-triage.md) — batches of CAD.
- [Recipes → ERP integration](./erp-integration.md) — wire it into SAP / Vault / etc.
