FrameAI REST API
Submit structural PDFs programmatically, poll job status, retrieve Eurocode check outputs, and download DXF/IFC/PDF exports — all via a simple REST interface.
Studio only — API access requires a Studio subscription ($400/yr). View pricing →
Overview
| Base URL | https://frameai-structural.polsia.app/api/v1 |
| Auth | Bearer token in Authorization header |
| Content-Type | multipart/form-data for PDF upload, application/json for JSON bodies |
| Rate limit | 60 requests / minute per token (sliding window) |
| Max file size | 20 MB per PDF |
| Response format | Always JSON (application/json) |
Quickstart — 5 minutes to your first result
This walkthrough takes you from zero to a complete Eurocode extraction in under 5 minutes.
- Mint a token — open Settings → API Keys, click New key, copy the
fai_…value. It's shown once. - Export it as an environment variable so none of the scripts below contain your secret.
- Upload a PDF — any structural steel drawing works.
- Poll until done — the pipeline typically completes in 60–120 s.
- Fetch the result — members, Eurocode checks, DXF/PDF download URL.
#!/usr/bin/env python3
"""FrameAI quickstart — upload → poll → print results."""
import os, time, sys
import requests
TOKEN = os.environ["FRAMEAI_TOKEN"] # fai_...
BASE = "https://frameai-structural.polsia.app/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# 1. Upload PDF
with open("drawing.pdf", "rb") as f:
r = requests.post(f"{BASE}/jobs", headers=HEADERS,
files={"pdf": ("drawing.pdf", f, "application/pdf")})
r.raise_for_status()
job_id = r.json()["job_id"]
print(f"Created job {job_id}")
# 2. Poll until done (5-minute timeout)
for attempt in range(60):
time.sleep(5)
r = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS)
r.raise_for_status()
job = r.json()["job"]
status = job["status"]
print(f" [{attempt+1:02d}] {status}")
if status == "done":
break
if status == "failed":
sys.exit(f"Job failed: {job.get('error')}")
else:
sys.exit("Timed out after 5 minutes")
# 3. Print results
data = job["extracted_data"]
print(f"\nProject : {data['drawing_info'].get('project_name', '—')}")
print(f"Members : {len(data.get('members', []))}")
print(f"Mass : {data.get('summary', {}).get('total_mass_kg', 0):,} kg")
print(f"PDF URL : {job.get('result_url', '—')}")
for m in data.get("members", []):
mark = m.get("mark", "?")
section = m.get("section", "?")
util = m.get("utilisation", 0)
passed = "✓" if m.get("passed") else "✗"
print(f" {passed} {mark:<6} {section:<12} η={util:.2f}")
// FrameAI quickstart — upload → poll → print results
import fs from "fs";
import FormData from "form-data";
import fetch from "node-fetch"; // npm i node-fetch form-data
const TOKEN = process.env.FRAMEAI_TOKEN;
const BASE = "https://frameai-structural.polsia.app/api/v1";
const HEADERS = { Authorization: `Bearer ${TOKEN}` };
// 1. Upload PDF
const form = new FormData();
form.append("pdf", fs.createReadStream("drawing.pdf"), "drawing.pdf");
const upload = await fetch(`${BASE}/jobs`, {
method: "POST", headers: { ...HEADERS, ...form.getHeaders() }, body: form,
});
const { job_id } = await upload.json();
console.log(`Created job ${job_id}`);
// 2. Poll until done
let job;
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 5000));
const r = await fetch(`${BASE}/jobs/${job_id}`, { headers: HEADERS });
job = (await r.json()).job;
console.log(` [${String(i+1).padStart(2,"0")}] ${job.status}`);
if (job.status === "done") break;
if (job.status === "failed") { console.error("Job failed:", job.error); process.exit(1); }
}
// 3. Print summary
const { drawing_info, members = [], summary = {} } = job.extracted_data;
console.log(`\nProject : ${drawing_info?.project_name ?? "—"}`);
console.log(`Members : ${members.length}`);
console.log(`Mass : ${summary.total_mass_kg?.toLocaleString() ?? 0} kg`);
console.log(`PDF URL : ${job.result_url ?? "—"}`);
members.forEach(m =>
console.log(` ${m.passed ? "✓" : "✗"} ${m.mark?.padEnd(6)} ${m.section?.padEnd(12)} η=${m.utilisation?.toFixed(2)}`)
);
#!/usr/bin/env bash
set -euo pipefail
TOKEN="${FRAMEAI_TOKEN}"
BASE="https://frameai-structural.polsia.app/api/v1"
# 1. Upload
RESP=$(curl -sf -X POST "$BASE/jobs" \
-H "Authorization: Bearer $TOKEN" \
-F "pdf=@drawing.pdf")
JOB_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
echo "Created job $JOB_ID"
# 2. Poll
for i in $(seq 1 60); do
sleep 5
STATUS_JSON=$(curl -sf "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['job']['status'])")
printf " [%02d] %s\n" "$i" "$STATUS"
if [ "$STATUS" = "done" ]; then echo "$STATUS_JSON" | python3 -m json.tool; break; fi
if [ "$STATUS" = "failed" ]; then echo "Job failed"; echo "$STATUS_JSON"; exit 1; fi
done
Authentication
Every request must include a bearer token in the Authorization header:
Authorization: Bearer fai_<your_token>
Minting a token
- Open Settings → API Keys.
- Click New key and give it a descriptive name (e.g. "CI pipeline").
- Copy the
fai_…value — it is shown once. The system only stores a hash. - Revoke from the same page when no longer needed. Revocation is immediate.
Token rotation
There is no automatic expiry. Rotate tokens whenever team members leave or tokens may be compromised: mint a new one, update your integration, then revoke the old token.
export FRAMEAI_TOKEN=fai_…) or via a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler, etc.).
Rate limits
Each token is limited to 60 requests per minute, measured in a sliding 60-second window. Every response carries rate-limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Max requests per window (always 60) |
X-RateLimit-Remaining | Requests left in the current window |
Retry-After | Seconds until window resets — only present on 429 responses |
429 response shape
{
"error": "Rate limit exceeded — 60 requests per minute",
"retry_after_seconds": 14
}
Back off for retry_after_seconds seconds, then retry. Exponential backoff is fine — there is no penalty for retrying after the window resets.
Errors
All error responses are JSON with a single error string. HTTP status codes follow REST conventions:
| Status | Meaning |
|---|---|
400 | Bad request — missing or invalid parameters (see error message) |
401 | Missing, invalid, or revoked bearer token |
403 | Valid token but Studio subscription not active — upgrade |
404 | Job or project not found (or not owned by your account) |
409 | Conflict — both jobs must be "done" to diff; or jobs not in same revision chain |
429 | Rate limit exceeded — back off for Retry-After seconds |
500 | Internal server error — retry once; contact support if it persists |
{ "error": "Invalid or revoked token" }
Eurocode Check API
Six synchronous POST endpoints — one per Eurocode standard. Pass a JSON body, get a check result immediately. No job polling required. Available to all tiers (Free: 10/month, Pro: 1 000/month, Studio: unlimited).
Base path: /api/v1 — same as the Studio job API.
Full interactive spec: scroll to bottom or open api-spec.json. Download Postman collection.
| Endpoint | Standard | What it checks |
|---|---|---|
POST /check/en-1993-1-8 | EN 1993-1-8 | Bolt shear F_v,Rd, bearing F_b,Rd, tension F_t,Rd |
POST /check/en-1993-1-9 | EN 1993-1-9 | Fatigue — Palmgren-Miner, λ-method, S-N curves |
POST /check/en-1993-1-1 | EN 1993-1-1 | Steel member buckling, LTB, N+M interaction |
POST /check/en-1992-1-1 | EN 1992-1-1 | RC columns — N-M interaction, §5.8 slenderness |
POST /check/en-1995-1-1 | EN 1995-1-1 | Timber bending, shear, LTB, column buckling |
POST /check/en-1998-1 | EN 1998-1 | Seismic spectrum, base shear F_b via lateral-force method |
GET /usage | — | Current period usage + tier quota |
Unified response shape
{
"check_id": null,
"endpoint": "en-1993-1-8",
"passed": true,
"utilisation": 78.3,
"summary": "Bolt M20 8.8: 78.3% utilisation — PASS",
"result": { /* raw calc output */ },
"eurocode_clauses_cited": ["EN 1993-1-8:2005 §3.5", "EN 1993-1-8:2005 §3.6"],
"api_version": "v1"
}
POST /check/en-1993-1-8 — Bolted connection
EN 1993-1-8:2005 §3.5/§3.6 — computes F_v,Rd, F_b,Rd, F_t,Rd and utilisation.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1993-1-8 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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}'
import requests, os
r = requests.post(
"https://frameai-structural.polsia.app/api/v1/check/en-1993-1-8",
headers={"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"},
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},
)
d = r.json()
print(f"Passed={d['passed']}, utilisation={d['utilisation']}%")
const r = await fetch('https://frameai-structural.polsia.app/api/v1/check/en-1993-1-8', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.FRAMEAI_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ 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 }),
});
const { passed, utilisation } = await r.json();
console.log(`Passed=${passed}, utilisation=${utilisation}%`);
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "fai_your_key");
var payload = new { 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.0 };
var resp = await client.PostAsync(
"https://frameai-structural.polsia.app/api/v1/check/en-1993-1-8",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
POST /check/en-1993-1-9 — Fatigue
EN 1993-1-9:2005 §6/§7/§8 — Palmgren-Miner damage sum and λ-method.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1993-1-9 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"details": [{
"detail_id": "T8.6-71",
"stress_history": [{"delta_sigma": 80, "n_cycles": 500000}],
"method": "safe_life",
"consequence": "high"
}]
}'
POST /check/en-1993-1-1 — Steel member
EN 1993-1-1:2005 §6.2/§6.3 — flexural buckling, LTB, combined N+M interaction.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1993-1-1 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"members":[{"mark":"B1","profile":"IPE 300","length_mm":5000,"N_Ed":0,"M_y_Ed":50,"section_class":1}]}'
POST /check/en-1992-1-1 — Concrete column
EN 1992-1-1:2004 §5.8/§6.1 — N-M interaction and slenderness.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1992-1-1 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"columns":[{"mark":"C1","b":300,"h":300,"fck":25,"fyk":500,"N_Ed":500,"M_Ed":60,"A_s":2513,"l0":3000}]}'
POST /check/en-1995-1-1 — Timber member
EN 1995-1-1:2004 §6 — bending, shear, LTB, column buckling.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1995-1-1 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"members":[{"mark":"T1","species":"C24","b":100,"h":200,"length_mm":4000,"M_y_Ed":5,"V_z_Ed":6,"N_Ed":0}]}'
POST /check/en-1998-1 — Seismic
EN 1998-1:2004 §3.2/§4.3.3.2 — design spectrum, behaviour factor, base shear F_b.
curl -X POST https://frameai-structural.polsia.app/api/v1/check/en-1998-1 \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"agR":0.15,"ground_type":"B","spectrum_type":1,"q":2.0,"importance_class":2,"T1":0.6,"W":5000}'
GET /usage — Current period quota
Returns calls used, remaining quota, and per-endpoint breakdown for the current billing month.
curl https://frameai-structural.polsia.app/api/v1/usage \
-H "Authorization: Bearer $FRAMEAI_TOKEN"
# Response:
{
"tier": "pro",
"limit": 1000,
"used": 47,
"remaining": 953,
"reset_at": "2026-07-01T00:00:00.000Z",
"by_endpoint": [
{ "endpoint": "en-1993-1-8", "calls": 30, "avg_ms": 12 },
{ "endpoint": "en-1993-1-1", "calls": 17, "avg_ms": 8 }
]
}
Interactive API spec
The full OpenAPI 3.1 spec is embedded below. Use it to explore all parameters and try requests directly. You can also download the spec or import it into Postman.
POST /jobs — Upload a PDF Studio
Submit a structural PDF for processing. The pipeline runs asynchronously. This endpoint returns a job_id immediately — poll GET /jobs/:id until status === "done".
Option A — multipart upload
curl -X POST https://frameai-structural.polsia.app/api/v1/jobs \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-F "pdf=@/path/to/drawing.pdf" \
-F "project_id=42"
import requests, os
HEADERS = {"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"}
with open("drawing.pdf", "rb") as f:
r = requests.post(
"https://frameai-structural.polsia.app/api/v1/jobs",
headers=HEADERS,
files={"pdf": ("drawing.pdf", f, "application/pdf")},
data={"project_id": 42}, # optional
)
print(r.json()) # {"job_id": 1234, "status": "pending", ...}
import fs from "fs";
import FormData from "form-data";
import fetch from "node-fetch";
const form = new FormData();
form.append("pdf", fs.createReadStream("drawing.pdf"), "drawing.pdf");
form.append("project_id", "42"); // optional
const r = await fetch("https://frameai-structural.polsia.app/api/v1/jobs", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.FRAMEAI_TOKEN}`, ...form.getHeaders() },
body: form,
});
console.log(await r.json()); // { job_id: 1234, status: "pending", ... }
Option B — URL reference
If the PDF is already hosted (e.g. your own R2 or S3 bucket), pass the URL in a JSON body.
curl -X POST https://frameai-structural.polsia.app/api/v1/jobs \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"pdf_url":"https://bucket.example.com/warehouse.pdf","project_id":42}'
import requests, os
r = requests.post(
"https://frameai-structural.polsia.app/api/v1/jobs",
headers={"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"},
json={"pdf_url": "https://bucket.example.com/warehouse.pdf", "project_id": 42},
)
print(r.json())
const r = await fetch("https://frameai-structural.polsia.app/api/v1/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FRAMEAI_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ pdf_url: "https://bucket.example.com/warehouse.pdf", project_id: 42 }),
});
console.log(await r.json());
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
pdf | File (multipart) | one of | PDF file, max 20 MB |
pdf_url | string (JSON) | one of | Publicly accessible PDF URL |
project_id | integer | optional | Assign to a Studio project; inherits National Annex |
Response 202 Accepted
{
"job_id": 1234,
"status": "pending",
"priority": 10,
"project_id": 42,
"poll_url": "/api/v1/jobs/1234"
}
GET /jobs — List jobs Studio
List recent jobs for your account. Supports filtering by status and project.
curl "https://frameai-structural.polsia.app/api/v1/jobs?limit=10&status=done" \
-H "Authorization: Bearer $FRAMEAI_TOKEN"
import requests, os
r = requests.get(
"https://frameai-structural.polsia.app/api/v1/jobs",
headers={"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"},
params={"limit": 10, "status": "done", "project_id": 42},
)
for job in r.json()["jobs"]:
print(job["id"], job["status"])
const r = await fetch(
"https://frameai-structural.polsia.app/api/v1/jobs?limit=10&status=done",
{ headers: { Authorization: `Bearer ${process.env.FRAMEAI_TOKEN}` } }
);
const { jobs } = await r.json();
jobs.forEach(j => console.log(j.id, j.status));
Query parameters
| Parameter | Default | Description |
|---|---|---|
limit | 20 | Max jobs to return (max 100) |
status | — | Filter: pending, processing, done, failed |
project_id | — | Filter to a specific project workspace |
Response 200 OK
{
"jobs": [
{ "id": 1234, "status": "done", "revision_number": 1, "project_id": 42,
"input_filename": "warehouse.pdf", "created_at": "2026-06-05T10:00:00.000Z" },
...
],
"count": 10
}
GET /jobs/:id — Get job status Studio
Retrieve full status and results for a single job. Use this to poll after upload — when status === "done" the response includes full extraction JSON.
curl https://frameai-structural.polsia.app/api/v1/jobs/1234 \
-H "Authorization: Bearer $FRAMEAI_TOKEN"
import requests, os
r = requests.get(
"https://frameai-structural.polsia.app/api/v1/jobs/1234",
headers={"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"},
)
print(r.json()["job"]["status"])
const r = await fetch("https://frameai-structural.polsia.app/api/v1/jobs/1234", {
headers: { Authorization: `Bearer ${process.env.FRAMEAI_TOKEN}` },
});
const { job } = await r.json();
console.log(job.status);
Job status values
pending — queued, not yet started.
processing — vision extraction + Eurocode checks running.
done — complete; results available.
failed — error during pipeline; check job.error field.
GET /jobs/:id — Full result shape
When status === "done", the job object includes these fields:
{
"job": {
"id": 1234,
"status": "done",
"revision_number": 1,
"project_id": 42,
"result_url": "https://r2.polsia.com/frameai/1234-fabrication.pdf",
"extracted_data": {
"drawing_info": { "project_name": "Warehouse Amersfoort 2026", "scale": "1:50" },
"members": [
{
"mark": "B1",
"section": "IPE 360",
"steel_grade": "S355",
"length_mm": 6000,
"section_class": 1,
"passed": true,
"utilisation": 0.82,
"buckling": {
"lambda_y": 0.61, "lambda_z": 1.12, "chi_y": 0.84, "chi_z": 0.48,
"N_b_Rd_kN": 912.4, "lambda_LT": 0.52, "chi_LT": 0.91, "M_b_Rd_kNm": 187.3
}
}
],
"summary": { "total_mass_kg": 4210, "member_count": 12 }
},
"connections_data": [
{ "id": "C01", "type": "end_plate", "M_j_Rd_kNm": 148.2, "S_j_ini_kNm_rad": 18540,
"classification": "semi-rigid", "passed": true, "utilisation": 0.81 }
],
"welds_data": [
{ "id": "W01", "type": "fillet", "throat_mm": 6, "F_w_Rd_kN": 94.2, "passed": true }
],
"base_plates_data": [
{ "id": "BP01", "section": "HEB 240", "plate_width_mm": 400, "N_c_Rd_kN": 1680, "passed": true }
],
"created_at": "2026-06-05T10:00:00.000Z",
"updated_at": "2026-06-05T10:01:22.000Z"
}
}
POST /jobs/:id/revise — Trigger revision Studio
Submit a replacement PDF as a new revision of an existing job. The child job inherits the parent's project and priority. Use GET /diff to compare results after both jobs are done.
Note: this endpoint uses session-based auth (email body parameter) rather than bearer token — it shares the session auth path with the web app. A future cycle will migrate it to bearer token auth.
curl -X POST https://frameai-structural.polsia.app/api/jobs/1234/revise \
-F "email=you@firm.com" \
-F "pdf=@drawing-rev2.pdf"
import requests
with open("drawing-rev2.pdf", "rb") as f:
r = requests.post(
"https://frameai-structural.polsia.app/api/jobs/1234/revise",
data={"email": "you@firm.com"},
files={"pdf": ("drawing-rev2.pdf", f, "application/pdf")},
)
print(r.json()) # { "job_id": 1235, "revision_number": 2, "status": "pending", ... }
import fs from "fs";
import FormData from "form-data";
import fetch from "node-fetch";
const form = new FormData();
form.append("email", "you@firm.com");
form.append("pdf", fs.createReadStream("drawing-rev2.pdf"), "drawing-rev2.pdf");
const r = await fetch("https://frameai-structural.polsia.app/api/jobs/1234/revise", {
method: "POST", headers: form.getHeaders(), body: form,
});
console.log(await r.json());
Response 202 Accepted
{
"job_id": 1235,
"parent_job_id": 1234,
"revision_number": 2,
"status": "pending",
"poll_url": "/api/upload/preview/1235"
}
GET /jobs/:id/diff/:previousId — Compare revisions Studio
Returns a structured diff between two jobs in the same revision chain. Both must be done and owned by your account.
curl https://frameai-structural.polsia.app/api/v1/jobs/1235/diff/1234 \
-H "Authorization: Bearer $FRAMEAI_TOKEN"
import requests, os
r = requests.get(
"https://frameai-structural.polsia.app/api/v1/jobs/1235/diff/1234",
headers={"Authorization": f"Bearer {os.environ['FRAMEAI_TOKEN']}"},
)
diff = r.json()["diff"]
print(f"Added: {len(diff['members']['added'])}")
print(f"Changed: {len(diff['members']['changed'])}")
print(f"Removed: {len(diff['members']['removed'])}")
const r = await fetch("https://frameai-structural.polsia.app/api/v1/jobs/1235/diff/1234", {
headers: { Authorization: `Bearer ${process.env.FRAMEAI_TOKEN}` },
});
const { diff } = await r.json();
console.log("Added:", diff.members.added.length);
console.log("Changed:", diff.members.changed.length);
Response shape
{
"diff": {
"members": {
"added": [ { "mark": "B7", "section": "HEA 200", ... } ],
"removed": [],
"changed": [
{
"mark": "B1",
"before": { "section": "IPE 360", "utilisation": 0.82 },
"after": { "section": "IPE 400", "utilisation": 0.74 }
}
]
},
"connections": { "added": [], "removed": [], "changed": [] },
"welds": { "added": [], "removed": [], "changed": [] }
}
}
GET /projects — List projects Studio
List all project workspaces for your account.
Note: project endpoints use the session email auth path. Bearer token auth for projects is planned.
curl "https://frameai-structural.polsia.app/api/projects?email=you@firm.com"
import requests
r = requests.get(
"https://frameai-structural.polsia.app/api/projects",
params={"email": "you@firm.com"},
)
for p in r.json()["projects"]:
print(p["id"], p["name"], p["national_annex"])
const r = await fetch(
"https://frameai-structural.polsia.app/api/projects?email=you@firm.com"
);
const { projects } = await r.json();
projects.forEach(p => console.log(p.id, p.name, p.national_annex));
Response 200 OK
{
"projects": [
{ "id": 42, "name": "Warehouse Amersfoort 2026", "national_annex": "NL",
"description": "Phase 1 steelwork", "created_at": "2026-05-01T09:00:00.000Z" }
]
}
POST /projects — Create project Studio
Create a new project workspace. Set national_annex to control which country's Eurocode partial factors (γ_M0 / γ_M1 / γ_M2) apply to all jobs uploaded into this project.
curl -X POST https://frameai-structural.polsia.app/api/projects \
-H "Content-Type: application/json" \
-d '{
"email": "you@firm.com",
"name": "Warehouse Amersfoort 2026",
"description": "Phase 1 steelwork",
"national_annex": "NL"
}'
import requests
r = requests.post(
"https://frameai-structural.polsia.app/api/projects",
json={
"email": "you@firm.com",
"name": "Warehouse Amersfoort 2026",
"description": "Phase 1 steelwork",
"national_annex": "NL", # NL | DE | UK | SE | FR
},
)
project = r.json()["project"]
print(f"Created project {project['id']}")
const r = await fetch("https://frameai-structural.polsia.app/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "you@firm.com",
name: "Warehouse Amersfoort 2026",
national_annex: "NL", // NL | DE | UK | SE | FR
}),
});
const { project } = await r.json();
console.log("Created project", project.id);
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | required | Your Studio account email |
name | string | required | Project display name |
description | string | optional | Free-text description |
national_annex | string | optional | NL, DE, UK, SE, or FR — controls Eurocode partial factors |
Response 201 Created
{ "project": { "id": 42, "name": "Warehouse Amersfoort 2026", "national_annex": "NL", ... } }
Webhooks
Stripe subscription events
FrameAI uses Stripe webhooks internally to track subscription state. If you need to build downstream integrations on subscription changes, consume Stripe events directly from your Stripe Dashboard — FrameAI does not re-emit Stripe events.
The following Stripe event types update the FrameAI user record:
| Stripe event | Effect |
|---|---|
checkout.session.completed | Activates plan (pro or studio) and records stripe_customer_id |
customer.subscription.updated | Updates subscription_plan and subscription_status |
customer.subscription.deleted | Sets subscription_status = "canceled"; API access revoked immediately |
Job lifecycle webhooks
Register one HTTPS endpoint per API token — FrameAI POSTs to it when a job completes or fails. No polling required.
Register in the UI: open Settings → API Keys, click Set webhook on any token, and paste your HTTPS endpoint. The signing secret is shown once — save it immediately.
Register via API:
curl -X PATCH https://frameai-structural.polsia.app/api/tokens/TOKEN_ID/webhook \
-H "Content-Type: application/json" \
-d '{"email":"you@firm.com","webhook_url":"https://your-server.com/frameai/webhook"}'
Response includes webhook_secret (shown once) — store it in your environment as FRAMEAI_WEBHOOK_SECRET.
Endpoint: your configured HTTPS URL, POST.
Signature verification (HMAC-SHA256):
# FrameAI sends these headers on every webhook delivery:
X-FrameAI-Timestamp: 1749120000 # Unix seconds
X-FrameAI-Signature: sha256=<hex> # HMAC of timestamp.body, signed with your webhook secret
# Verify in Python:
import hmac, hashlib, time
def verify_webhook(body: bytes, timestamp: str, sig: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # 5-minute tolerance
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Event payload — job.completed:
{
"event": "job.completed",
"job_id": 1234,
"status": "done",
"project_id": 42,
"result_url": "https://r2.polsia.com/frameai/1234-fabrication.pdf",
"member_count": 12,
"timestamp": "2026-06-05T10:01:22.000Z"
}
job_id + event.
Python SDK
The official Python client library ships typed Pydantic models, automatic retry on 429/5xx, multipart upload handling, and a webhook signature verifier. Python 3.9+. No heavy dependencies — just httpx and pydantic.
Install
pip install frameai
30-second example
import time
from frameai import FrameAI
client = FrameAI(api_key="fai_...") # or set FRAMEAI_API_KEY env var
# Upload a PDF and start the pipeline
project = client.projects.create(
name="Warehouse Portal Frame",
pdf_path="./portal_frame.pdf",
national_annex="NL",
)
# Poll until done (or use webhooks — see below)
while True:
job = client.jobs.get(project.latest_job_id)
if job.status in ("done", "failed"):
break
time.sleep(5)
# Download outputs — all return bytes
ifc = client.exports.ifc(project.id) # IFC 4.0 model
bom = client.exports.bom_xlsx(project.id) # Excel BOM
pdf = client.exports.shop_drawings(project.id) # Shop drawings PDF
nc1 = client.exports.dstv(project.id) # DSTV NC1 ZIP
with open("model.ifc", "wb") as f:
f.write(ifc)
Webhook signature verification
from frameai import webhooks
# In your Flask / FastAPI handler:
is_valid = webhooks.verify(
payload=request.get_data(), # raw bytes
signature=request.headers["X-FrameAI-Signature"],
secret=os.environ["FRAMEAI_WEBHOOK_SECRET"],
)
if not is_valid:
abort(400, "Bad signature")
Resources
- GitHub repo: github.com/frameai/frameai-python — source, changelog, issues
- Examples: github.com/frameai/frameai-examples — quickstart + webhook receiver with Dockerfile
- Node.js / curl: Raw HTTP examples are in the walkthrough sections below — no library needed.
Walkthrough — upload & poll
The canonical integration pattern in a single copy-pasteable Python script:
#!/usr/bin/env python3
"""Upload a PDF, poll until done, save results to results.json."""
import os, time, sys, json
import requests
TOKEN = os.environ["FRAMEAI_TOKEN"]
BASE = "https://frameai-structural.polsia.app/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
PDF = sys.argv[1] if len(sys.argv) > 1 else "drawing.pdf"
with open(PDF, "rb") as f:
r = requests.post(f"{BASE}/jobs", headers=HEADERS,
files={"pdf": (PDF, f, "application/pdf")})
r.raise_for_status()
job_id = r.json()["job_id"]
print(f"✓ Job {job_id} created — polling…")
for i in range(60):
time.sleep(5)
r = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS)
job = r.json()["job"]
print(f" [{i+1:02d}] {job['status']}", flush=True)
if job["status"] == "done":
with open("results.json", "w") as out:
json.dump(job, out, indent=2)
print(f"✓ Done — saved to results.json")
print(f" PDF: {job.get('result_url','—')}")
break
if job["status"] == "failed":
sys.exit(f"✗ Failed: {job.get('error')}")
#!/usr/bin/env node
// Usage: node upload-and-poll.mjs drawing.pdf
import fs from "fs/promises";
import { createReadStream } from "fs";
import FormData from "form-data";
import fetch from "node-fetch";
const TOKEN = process.env.FRAMEAI_TOKEN;
const BASE = "https://frameai-structural.polsia.app/api/v1";
const HEADERS = { Authorization: `Bearer ${TOKEN}` };
const PDF = process.argv[2] || "drawing.pdf";
const form = new FormData();
form.append("pdf", createReadStream(PDF), PDF);
const up = await fetch(`${BASE}/jobs`, {
method: "POST", headers: { ...HEADERS, ...form.getHeaders() }, body: form,
});
const { job_id } = await up.json();
console.log(`✓ Job ${job_id} created — polling…`);
let job;
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 5000));
const r = await fetch(`${BASE}/jobs/${job_id}`, { headers: HEADERS });
job = (await r.json()).job;
process.stdout.write(` [${String(i+1).padStart(2,"0")}] ${job.status}\n`);
if (job.status === "done") break;
if (job.status === "failed") { console.error("✗ Failed:", job.error); process.exit(1); }
}
await fs.writeFile("results.json", JSON.stringify(job, null, 2));
console.log(`✓ Done — saved to results.json`);
console.log(` PDF: ${job.result_url ?? "—"}`);
Walkthrough — assign to a project
Projects group jobs into named folders and control the National Annex for Eurocode partial factors.
- Create the project at Settings → Projects or via the API (see POST /projects).
- Copy the project
idfrom the Settings page or from the API response. - Pass
project_idwhen uploading.
curl -X POST https://frameai-structural.polsia.app/api/v1/jobs \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-F "pdf=@warehouse-steelwork.pdf" \
-F "project_id=42"
with open("warehouse-steelwork.pdf", "rb") as f:
r = requests.post(
f"{BASE}/jobs", headers=HEADERS,
files={"pdf": ("warehouse-steelwork.pdf", f, "application/pdf")},
data={"project_id": 42},
)
print(r.json())
NL (Netherlands), DE (Germany), UK (United Kingdom), SE (Sweden), FR (France). Default is EN (no NA correction).
Walkthrough — compare revisions
Submit a revised drawing, wait for it to complete, then diff against the original to see exactly which members changed section, which utilisation values shifted, and which connections were added or removed.
import requests, os, time
TOKEN = os.environ["FRAMEAI_TOKEN"]
BASE = "https://frameai-structural.polsia.app/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
ORIGINAL_JOB_ID = 1234 # already done
# Upload revision
with open("drawing-rev2.pdf", "rb") as f:
r = requests.post(f"{BASE}/jobs", headers=HEADERS,
files={"pdf": ("drawing-rev2.pdf", f, "application/pdf")})
rev_job_id = r.json()["job_id"]
print(f"Revision job: {rev_job_id}")
# Poll revision
for _ in range(60):
time.sleep(5)
job = requests.get(f"{BASE}/jobs/{rev_job_id}", headers=HEADERS).json()["job"]
if job["status"] == "done":
break
# Diff
diff = requests.get(
f"{BASE}/jobs/{rev_job_id}/diff/{ORIGINAL_JOB_ID}", headers=HEADERS
).json()["diff"]
print(f"Members added: {len(diff['members']['added'])}")
print(f"Members removed: {len(diff['members']['removed'])}")
print(f"Members changed: {len(diff['members']['changed'])}")
for c in diff["members"]["changed"]:
print(f" {c['mark']}: {c['before']['section']} → {c['after']['section']}"
f" η {c['before']['utilisation']:.2f} → {c['after']['utilisation']:.2f}")
# Upload revision
REV=$(curl -sf -X POST https://frameai-structural.polsia.app/api/v1/jobs \
-H "Authorization: Bearer $FRAMEAI_TOKEN" \
-F "pdf=@drawing-rev2.pdf")
REV_ID=$(echo "$REV" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
# ... poll REV_ID to "done" ...
# Diff
curl "https://frameai-structural.polsia.app/api/v1/jobs/$REV_ID/diff/1234" \
-H "Authorization: Bearer $FRAMEAI_TOKEN" | python3 -m json.tool
Questions? Email support@frameai-structural.polsia.app • View pricing • Open pipeline