Webhook Event Catalog
FrameAI fires HTTP POST events to your registered URL whenever something significant happens in the pipeline — job starts, completes, fails, a revision is detected, or a fabrication export becomes ready.
All events are signed with HMAC-SHA256. You should verify the signature before processing.
Overview
| Delivery method | HTTP POST to your registered URL |
| Content-Type | application/json |
| Signature header | X-FrameAI-Signature — HMAC-SHA256 hex digest |
| Timestamp header | X-FrameAI-Timestamp — Unix seconds (for replay protection) |
| Retry attempts | 5 (exponential back-off) |
| Timeout per attempt | 10 seconds |
| Expected response | Any 2xx status — otherwise retried |
Register a webhook URL
Set your webhook URL and secret when creating or updating an API token via Settings → API Keys. You can also set it programmatically on the token at creation time.
ngrok http 3000 during development to inspect live payloads.
# Example: register a webhook when creating a token
curl -X POST https://frameai-structural.polsia.app/api/tokens \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $FRAMEAI_API_KEY" \
-d '{
"name": "CI pipeline",
"webhook_url": "https://my-server.example.com/webhooks/frameai",
"webhook_secret": "wh_mysecret_changeme"
}'
Verify HMAC-SHA256 signatures
Each delivery includes two headers:
X-FrameAI-Signature—sha256=<hex>X-FrameAI-Timestamp— Unix seconds at delivery time
The signature is computed over <timestamp>.<raw body> using your webhook_secret.
Reject requests where the timestamp is more than 300 seconds old to prevent replay attacks.
// Express middleware — verify FrameAI webhook signature
const crypto = require("crypto");
function verifyFrameAISignature(req, res, next) {
const secret = process.env.FRAMEAI_WEBHOOK_SECRET;
const sig = req.headers["x-frameai-signature"] || "";
const timestamp = req.headers["x-frameai-timestamp"] || "0";
const rawBody = req.rawBody; // needs express raw-body middleware
// Replay protection: reject requests older than 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
return res.status(401).json({ error: "Request too old — possible replay" });
}
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: "Invalid signature" });
}
next();
}
// Mount with raw body capture
app.post("/webhooks/frameai",
express.raw({ type: "application/json" }),
(req, _res, next) => { req.rawBody = req.body.toString("utf8"); next(); },
verifyFrameAISignature,
(req, res) => {
const event = JSON.parse(req.rawBody);
console.log("Received:", event.event, event.data);
res.json({ ok: true });
}
);
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["FRAMEAI_WEBHOOK_SECRET"].encode()
@app.route("/webhooks/frameai", methods=["POST"])
def webhook():
sig = request.headers.get("X-FrameAI-Signature", "")
timestamp = request.headers.get("X-FrameAI-Timestamp", "0")
raw_body = request.get_data()
# Replay protection
if abs(time.time() - int(timestamp)) > 300:
abort(401, "Request too old")
expected = "sha256=" + hmac.new(
SECRET,
f"{timestamp}.".encode() + raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, "Invalid signature")
event = request.get_json()
print(f"Received: {event['event']}", event["data"])
return {"ok": True}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = []byte(os.Getenv("FRAMEAI_WEBHOOK_SECRET"))
func verifySignature(r *http.Request, body []byte) bool {
sig := r.Header.Get("X-FrameAI-Signature")
tsStr := r.Header.Get("X-FrameAI-Timestamp")
ts, _ := strconv.ParseInt(tsStr, 10, 64)
// Replay protection
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, webhookSecret)
mac.Write([]byte(fmt.Sprintf("%d.", ts)))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !verifySignature(r, body) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Process event ...
w.Write([]byte(`{"ok":true}`))
}
Retry policy
If your endpoint returns a non-2xx status or times out (10 s), FrameAI retries with exponential back-off. After 5 failed attempts the event is written to a dead-letter log visible in your Studio dashboard.
data.job_id / data.project_id + event before taking action.
Idempotency key handling
Each delivery includes a unique delivery_id field in the root payload. Store processed delivery IDs and skip duplicates.
const processed = new Set(); // use Redis / DB in production
app.post("/webhooks/frameai", /* ...verify... */, (req, res) => {
const event = JSON.parse(req.rawBody);
if (processed.has(event.delivery_id)) {
return res.json({ ok: true, skipped: true }); // already handled
}
processed.add(event.delivery_id);
// handle event.event ...
res.json({ ok: true });
});
Event catalog
All events share the same envelope:
{
"event": "job.completed", // event type
"delivery_id": "evt_abc123", // unique per delivery attempt
"timestamp": "2026-06-09T14:22:00Z", // ISO-8601
"api_version": "1.1.0",
"data": { /* event-specific payload */ }
}
{
"event": "project.created",
"delivery_id": "evt_prj_8d3f",
"timestamp": "2026-06-09T14:20:00Z",
"api_version": "1.1.0",
"data": {
"project_id": 42,
"project_name": "Warehouse Amersfoort 2026",
"national_annex": "NL",
"user_id": 7
}
}
{
"event": "job.started",
"delivery_id": "evt_jst_9e4a",
"timestamp": "2026-06-09T14:21:05Z",
"api_version": "1.1.0",
"data": {
"job_id": 1337,
"project_id": 42,
"filename": "warehouse.pdf",
"priority": 10
}
}
{
"event": "job.completed",
"delivery_id": "evt_jcp_2f1b",
"timestamp": "2026-06-09T14:23:12Z",
"api_version": "1.1.0",
"data": {
"job_id": 1337,
"project_id": 42,
"result_url": "https://pub-629428d185ca4960a0a73c850d32294b.r2.dev/…/output.pdf",
"utilisation_summary": {
"member_count": 24,
"passed_count": 22,
"failed_count": 2,
"max_utilisation": 94.3,
"total_mass_kg": 12480
},
"export_urls": {
"shop_pdf": "/api/v1/projects/42/export/shop.pdf?run_id=1337",
"bom_xlsx": "/api/v1/projects/42/export/bom.xlsx?run_id=1337",
"bom_csv": "/api/v1/projects/42/export/bom.csv?run_id=1337",
"model_ifc": "/api/v1/projects/42/export/model.ifc?run_id=1337",
"package": "/api/v1/projects/42/export/package.zip?run_id=1337"
},
"duration_seconds": 87
}
}
error contains the human-readable reason.{
"event": "job.failed",
"delivery_id": "evt_jfl_7c3d",
"timestamp": "2026-06-09T14:23:50Z",
"api_version": "1.1.0",
"data": {
"job_id": 1338,
"project_id": 42,
"error": "Could not extract any structural members from the uploaded drawing — try a higher-resolution scan.",
"duration_seconds": 12
}
}
{
"event": "revision.detected",
"delivery_id": "evt_rev_5a9e",
"timestamp": "2026-06-09T22:15:00Z",
"api_version": "1.1.0",
"data": {
"project_id": 42,
"parent_job_id": 1337,
"new_job_id": 1339,
"revision_index": 2,
"source": "email", // upload | email | s3 | http
"delta_summary": "IPE 300 → IPE 360 on marks B3, B4. 2 new members added."
}
}
{
"event": "export.ready",
"delivery_id": "evt_exp_1d8f",
"timestamp": "2026-06-09T14:24:05Z",
"api_version": "1.1.0",
"data": {
"job_id": 1337,
"project_id": 42,
"format": "model.ifc", // shop.pdf | bom.xlsx | bom.csv | model.ifc | package.zip
"download_url": "/api/v1/projects/42/export/model.ifc?run_id=1337",
"size_bytes": 284291
}
}
Questions? Email frameai@polsia.app • Full API reference • Python SDK quickstart • OpenAPI 3.1 spec