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
| Event | When it fires | Payload schema |
|---|---|---|
preflight.completed | Job finished successfully (any verdict — PASS, CONDITIONAL_PASS, FAIL) | Includes verdict, finding counts, report_url |
preflight.failed | Job errored during analysis (corrupted PDF, timeout, etc.) | Includes friendly error message |
webhook.test | You called POST /v1/webhooks/test | Constant test payload. Send {"event": "submission.ready"} to get a sample submission.ready body instead, marked "test": "true". |
submission.ready | Every 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:
- Parse
tandv1from the header. - Reject if
now - t > 300 seconds(5-minute replay window). - Compute HMAC-SHA256 of
t + "." + raw_request_bodyusing your secret. - 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
- Up to 3 delivery attempts per event: at once, again 1 minute later, and again 5 minutes after that.
submission.readyis tried for a day: again after 1, 5 and 30 minutes, then every hour for 24 hours (28 attempts in all).- Success = 2xx response from your endpoint. Anything else (3xx redirects included) is treated as a failure.
- Timeout: 10 seconds per attempt.
- Every retry of an event carries the same body and the same
X-Preflight-Event-Id, so you can drop duplicates by that id. - After the last failed attempt, the event status flips to
abandoned. It stays in the delivery log and can be sent again. Our team hears about it; for events other thansubmission.ready, the key owner gets an email too (once per hour per key at most).
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
- HTTPS only. We refuse to deliver to
http://URLs at enqueue time. - No redirects followed. A 3xx response counts as a failed delivery — prevents redirect-based SSRF.
- User agent is
preflight-art-webhooks/1.0— allowlist this on your WAF if needed. - When you rotate your API key, the new key inherits the old key's webhook secret by default — your verifier keeps working without redeploy. Issue a brand-new key (not a rotation) if you want a fresh secret.