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 utilisation 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 spec