preflight.art API

Webhooks Guide

Skip polling. When a job finishes (success or failure), we POST a signed event to your configured URL within seconds. Below: payload shapes, signature verification, retry behavior.

Event types

EventWhen it firesPayload schema
preflight.completedJob finished successfully (any verdict — PASS, CONDITIONAL_PASS, FAIL)Includes verdict, finding counts, report_url
preflight.failedJob errored during analysis (corrupted PDF, timeout, etc.)Includes friendly error message
webhook.testYou called POST /v1/webhooks/testConstant test payload. Send {"event": "submission.ready"} to get a sample submission.ready body instead, marked "test": "true".
submission.readyEvery file of an upload has finished. Sent only to a key subscribed to it by name (webhook events on /account/api-keys).Flat string fields for a CRM: quote fields, result (Ready, Ready with notes, Needs changes, Could not read, Received), counts, findings as up to 8 lines, the full file list. Retried after 1, 5 and 30 minutes, then hourly for 24 hours. Resend with POST /v1/submissions/{id}/resend.

Payload example (preflight.completed)

{
  "event": "preflight.completed",
  "job_id": "4c68c919-1ded-43b4-916e-f9e97ea4ce0e",
  "api_key_id": "apk_a2889a1af9f9a64e",
  "verdict": "CONDITIONAL_PASS",
  "counts": { "critical": 1, "warning": 6, "info": 2 },
  "report_url": "https://preflight.art/v1/preflight/4c68c919.../report",
  "completed_at": "2026-05-15T22:29:04.162Z"
}

Signature verification

Every webhook carries an X-Preflight-Signature header in the format:

X-Preflight-Signature: t=1715800000,v1=abc123...64hexchars...

Where t is a Unix timestamp and v1 is the HMAC-SHA256 of `${t}.${raw_body}` using your webhook signing secret (issued alongside the API key when a webhook URL is set).

Steps:

  1. Parse t and v1 from the header.
  2. Reject if now - t > 300 seconds (5-minute replay window).
  3. Compute HMAC-SHA256 of t + "." + raw_request_body using your secret.
  4. Constant-time compare with v1. Reject the request if mismatch.
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.PREFLIGHT_WEBHOOK_SECRET;
const app = express();

// Use express.raw() so req.body is the unmodified bytes — required for
// signature comparison; JSON.parse + re-stringify will break the HMAC.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-preflight-signature'] || '';
  const m = Object.fromEntries(sig.split(',').map(p => p.split('=')));
  const t = parseInt(m.t, 10);
  if (!t || Math.abs(Date.now()/1000 - t) > 300) return res.status(401).send('stale');

  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${t}.${req.body.toString('utf8')}`)
    .digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m.v1 || '')))
    return res.status(401).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // …handle event.event === 'preflight.completed' etc.
  res.status(200).send('ok');
});
import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['PREFLIGHT_WEBHOOK_SECRET'].encode()

@app.post('/webhook')
def webhook():
    sig = request.headers.get('X-Preflight-Signature', '')
    parts = dict(p.split('=') for p in sig.split(',') if '=' in p)
    try:
        t = int(parts.get('t', '0'))
    except ValueError:
        abort(401)
    if abs(time.time() - t) > 300:
        abort(401, 'stale')

    raw = request.get_data()   # raw bytes, NOT request.json
    expected = hmac.new(SECRET, f'{t}.{raw.decode()}'.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get('v1', '')):
        abort(401, 'bad signature')

    event = request.get_json()
    # …handle event['event'] == 'preflight.completed' etc.
    return ('ok', 200)
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

var secret = []byte(os.Getenv("PREFLIGHT_WEBHOOK_SECRET"))

func webhook(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)
    parts := map[string]string{}
    for _, p := range strings.Split(r.Header.Get("X-Preflight-Signature"), ",") {
        kv := strings.SplitN(p, "=", 2); if len(kv)==2 { parts[kv[0]]=kv[1] }
    }
    t, _ := strconv.ParseInt(parts["t"], 10, 64)
    if t == 0 || abs(time.Now().Unix()-t) > 300 { w.WriteHeader(401); return }

    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(t,10) + "." + string(raw)))
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(parts["v1"])) { w.WriteHeader(401); return }

    // …handle the event
    w.WriteHeader(200)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }

Retry behavior

Verifying your endpoint works

After configuring a webhook URL on your key, fire a test:

curl -X POST https://preflight.art/v1/webhooks/test \
  -H "x-api-key: $PREFLIGHT_KEY"

Then check delivery in /v1/webhooks/events or in the /account/api-keys UI under "Webhooks" on the relevant key.

Security notes