Python SDK Quickstart

Create a project, upload a structural PDF, poll the job, and download IFC in under 30 lines of Python. Studio API access requires a Studio subscription. Eurocode check endpoints work on any plan.

Step 1
Install
pip install requests — uses stdlib + requests only
Step 2
Set env var
FRAMEAI_API_KEY=fai_… — never hardcode your token
Step 3
Upload PDF
POST multipart form to /api/v1/jobs or /projects/:id/runs
Step 4
Poll & export
Poll every 5 s. On done, fetch IFC/BOM/DXF from export_urls

Install

No official frameai package yet — use requests directly. All examples here are self-contained.

pip install requests

Authentication

Mint a key at Settings → API Keys. Export it as an environment variable — never hardcode it.

import os, requests

TOKEN = os.environ["FRAMEAI_API_KEY"]   # fai_…
BASE  = "https://frameai-structural.polsia.app/api/v1"
HDR   = {"Authorization": f"Bearer {TOKEN}"}
Token security: tokens are shown once at creation. If you lose it, revoke and mint a new one from Settings.

30-line quickstart

The complete flow: create project → upload PDF → poll → download IFC.

#!/usr/bin/env python3
"""FrameAI Python quickstart — complete pipeline in 30 lines."""
import os, time, requests

TOKEN = os.environ["FRAMEAI_API_KEY"]
BASE  = "https://frameai-structural.polsia.app/api/v1"
HDR   = {"Authorization": f"Bearer {TOKEN}"}

# 1. Create a project workspace
proj = requests.post(f"{BASE}/projects", headers=HDR, json={
    "name": "Warehouse 2026", "national_annex": "NL"
}).json()["project"]
print(f"Project: {proj['id']} — {proj['name']}")

# 2. Upload a PDF and start a run
with open("warehouse.pdf", "rb") as f:
    run = requests.post(
        f"{BASE}/projects/{proj['id']}/runs",
        headers=HDR,
        files={"pdf": ("warehouse.pdf", f, "application/pdf")}
    ).json()
run_id = run["run_id"]
print(f"Run started: {run_id}  →  poll: {run['poll_url']}")

# 3. Poll until done (typically 60–120 s)
for attempt in range(120):
    time.sleep(5)
    status = requests.get(f"{BASE}/runs/{run_id}", headers=HDR).json()["run"]
    print(f"  [{attempt*5:3d}s] {status['status']}", end="\r")
    if status["status"] in ("done", "failed"):
        break

# 4. Download IFC
if status["status"] == "done":
    ifc_url = status["export_urls"]["model_ifc"]
    ifc = requests.get(f"https://frameai-structural.polsia.app{ifc_url}", headers=HDR)
    with open(f"run-{run_id}.ifc", "wb") as out:
        out.write(ifc.content)
    print(f"\n✓ Saved run-{run_id}.ifc  ({len(ifc.content)//1024} KB)")
    u = status["utilisation_summary"]
    print(f"  Members: {u['member_count']}  Passed: {u['passed_count']}  Max η: {u['max_utilisation']:.0f}%")
else:
    print(f"\n✗ Run failed: {status['error']}")

Upload a PDF

Two ways: multipart upload (file on disk) or URL reference (file already in cloud storage).

Multipart upload

with open("drawing.pdf", "rb") as f:
    r = requests.post(f"{BASE}/jobs", headers=HDR,
                      files={"pdf": ("drawing.pdf", f, "application/pdf")})
r.raise_for_status()
job_id = r.json()["job_id"]

URL reference

r = requests.post(f"{BASE}/jobs", headers=HDR, json={
    "pdf_url": "https://my-bucket.s3.amazonaws.com/drawing.pdf",
    "project_id": 42,          # optional — group into a project
})
job_id = r.json()["job_id"]

Poll until done

The pipeline takes 60–120 seconds for a typical drawing. Poll every 5 seconds — don't hammer at sub-second intervals or you'll hit the rate limit.

import time, requests

def wait_for_job(job_id, *, timeout=300, interval=5):
    """Poll /jobs/:id until done or failed. Raises on timeout."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{BASE}/jobs/{job_id}", headers=HDR)
        r.raise_for_status()
        job = r.json()["job"]
        if job["status"] in ("done", "failed"):
            return job
        time.sleep(interval)
    raise TimeoutError(f"Job {job_id} did not complete within {timeout}s")

job = wait_for_job(job_id)
if job["status"] == "failed":
    raise RuntimeError(f"Job failed: {job['error']}")

Download IFC

Completed jobs expose export_urls with relative paths to each format.

def download_export(job, fmt):
    """Download shop.pdf | bom.xlsx | bom.csv | model.ifc | package.zip"""
    urls = job.get("export_urls") or {}
    key  = fmt.replace(".", "_").replace("-", "_")   # model.ifc → model_ifc
    path = urls.get(key) or urls.get(fmt)
    if not path:
        raise KeyError(f"No export URL for {fmt}")
    if path.startswith("/"):
        path = "https://frameai-structural.polsia.app" + path
    r = requests.get(path, headers=HDR)
    r.raise_for_status()
    return r.content

# Download all formats
ifc  = download_export(job, "model_ifc")
xlsx = download_export(job, "bom_xlsx")
pdf  = download_export(job, "shop_pdf")

with open("model.ifc", "wb") as f: f.write(ifc)
with open("bom.xlsx",  "wb") as f: f.write(xlsx)
with open("shop.pdf",  "wb") as f: f.write(pdf)
print("Exports downloaded.")

Use projects

Group related runs into a named project workspace. Each project can have its own National Annex for country-specific partial factors.

# Create a project
proj = requests.post(f"{BASE}/projects", headers=HDR, json={
    "name":           "Amsterdam Office Tower",
    "description":    "Phase 1 structural steel",
    "national_annex": "NL",   # NL | DE | FR | IT | BE | recommended
}).json()["project"]

# List projects
projects = requests.get(f"{BASE}/projects", headers=HDR).json()["projects"]
for p in projects:
    print(f"{p['id']:4d}  {p['name']}")

# Get project with its jobs
detail = requests.get(f"{BASE}/projects/{proj['id']}", headers=HDR).json()["project"]
print(f"Jobs: {len(detail['jobs'])}")

Error handling

All error responses have the shape { "error": "message" }. HTTP status codes follow REST conventions.

StatusMeaningAction
400Bad request — missing field or invalid valueFix the request body
401Missing or invalid tokenCheck FRAMEAI_API_KEY is set and not expired
402Plan restriction — Studio requiredUpgrade at /pricing
403Resource belongs to another accountCheck you're using the right token
404Not foundVerify the ID exists for your account
409Conflict — job not in done statusPoll until done before downloading exports
422No extracted data on the jobRe-upload a clearer drawing
429Rate limitedBack off for Retry-After seconds (see below)
500Server errorRetry after a few seconds; report if persistent
def safe_get(url):
    r = requests.get(url, headers=HDR)
    if r.status_code == 402:
        raise PermissionError("Studio plan required — upgrade at /pricing")
    if r.status_code == 404:
        raise LookupError(f"Not found: {url}")
    r.raise_for_status()
    return r.json()

Rate limit retry

The API allows 60 requests/minute per token. A 429 response includes a Retry-After header.

import time, requests

def get_with_retry(url, *, max_retries=3):
    for attempt in range(max_retries + 1):
        r = requests.get(url, headers=HDR)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", 60))
            if attempt < max_retries:
                print(f"Rate limited — retrying in {wait}s")
                time.sleep(wait)
                continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError(f"Max retries exceeded for {url}")

Eurocode check API

Stateless per-endpoint checks. Free-tier: 10 calls/month (no auth needed). Pro: 1 000/month. Studio: unlimited.

import os, requests

# Eurocode checks work without auth on free tier (IP-keyed, 10/mo)
HDR_FREE = {}                           # unauthenticated
HDR_AUTH = {"Authorization": f"Bearer {os.environ['FRAMEAI_API_KEY']}"}

# EN 1993-1-8 bolted connection
r = requests.post(
    "https://frameai-structural.polsia.app/api/v1/check/en-1993-1-8",
    headers=HDR_AUTH,
    json={
        "bolt_grade":   "8.8",
        "bolt_size":    "M20",
        "shear_planes": 1,
        "plate_fu":     360,
        "plate_t":      10,
        "e1": 60, "e2": 40, "p1": 80,
        "is_end_bolt":  True,
        "V_Ed":         50000,   # N per bolt
        "F_t_Ed":       0,
    }
)
result = r.json()
print(f"Passed: {result['passed']}  η = {result['utilisation']:.1f}%")
print(f"Summary: {result['summary']}")
print(f"Clauses: {', '.join(result['eurocode_clauses_cited'])}")

# EN 1993-1-1 steel member
r = requests.post(
    "https://frameai-structural.polsia.app/api/v1/check/en-1993-1-1",
    headers=HDR_AUTH,
    json={
        "members": [{
            "mark":          "B1",
            "profile":       "IPE 300",
            "length_mm":     5000,
            "N_Ed":          0,
            "M_y_Ed":        50,    # kNm
            "section_class": 1,
        }],
        "national_annex": "NL",
    }
)
for m in r.json()["results"]:
    print(f"{m['check_id']}  passed={m['passed']}  η={m['utilisation']:.1f}%")

GitHub example repo

A full runnable example — including a Grasshopper script, Jupyter notebook, and GitHub Actions CI workflow — is available in the FrameAI Examples collection.

Browse tutorials →  ·  Full API reference →  ·  Webhook event catalog →  ·  OpenAPI 3.1 spec →

Questions? Email frameai@polsia.app or open a thread in the community forum.