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 methodHTTP POST to your registered URL
Content-Typeapplication/json
Signature headerX-FrameAI-Signature โ€” HMAC-SHA256 hex digest
Timestamp headerX-FrameAI-Timestamp โ€” Unix seconds (for replay protection)
Retry attempts5 (exponential back-off)
Timeout per attempt10 seconds
Expected responseAny 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.

Tip: Use webhook.site or 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:

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.

Attempt 1
Immediate
Attempt 2
1 min
Attempt 3
5 min
Attempt 4
30 min
Attempt 5
2 hr
Idempotent handlers required. Due to retries, your handler may receive the same event more than once. Always dedup on 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 */ }
}
project.created
Fired when a new project workspace is created via the API or dashboard.
{
  "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
  }
}
job.started
Fired when the pipeline picks up a job from the queue and begins processing.
{
  "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
  }
}
job.completed
Fired when the pipeline finishes successfully. Includes a utilization summary and export URLs.
{
  "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
  }
}
job.failed
Fired when the pipeline fails. 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
  }
}
revision.detected
Fired by the auto-rerun engine when a new drawing version is detected (watcher poll, inbound email, or manual upload). The new run has been queued.
{
  "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."
  }
}
export.ready
Fired when a specific fabrication export file becomes available (useful if you trigger exports asynchronously).
{
  "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.appFull API referencePython SDK quickstartOpenAPI 3.1 specDownload YAML