API docs · Build
Webhooks
Get a signed HTTPS callback when a job completes, fails or is cancelled. Payload, Standard Webhooks signature verification, retries, secret rotation, testing and the delivery log.
Receiving webhooks
Set callbackUrl when you create a job (POST /jobs, POST /jobs/from-url, or once for a whole POST /jobs/batch). It must be a public HTTPS URL; anything else is rejected with 400 INVALID_CALLBACK_URL.
curl -X POST https://api.framequery.com/v1/api/jobs/from-url \
-H "Authorization: Bearer $FRAMEQUERY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/interview.mp4", "callbackUrl": "https://your-app.example.com/webhooks/framequery" }'When the job reaches a terminal state we POST one event to that URL:
| Event | Sent when |
|---|---|
job.completed | state became completed |
job.failed | state became failed (data.error says why; completed stages are still readable) |
job.cancelled | The job was cancelled |
webhook.test | You called POST /webhooks/test |
Webhooks follow the Standard Webhooks specification, so any Standard Webhooks library can verify them.
Payload
POST /webhooks/framequery HTTP/1.1
Content-Type: application/json
User-Agent: FrameQuery-Webhooks/1.0
webhook-id: 6f1c2a9e-3b7d-5e0f-8a41-2c9d7b6e1f03
webhook-timestamp: 1760346243
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4={
"type": "job.completed",
"timestamp": "2026-10-13T09:04:03Z",
"data": {
"jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10",
"state": "completed",
"processingMode": "all",
"stages": {
"ingest": { "status": "completed" },
"vision": { "status": "completed" },
"transcript": { "status": "completed" }
},
"metadata": { "assetId": "A003_C008" },
"links": {
"self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10",
"transcript": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/transcript",
"scenes": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10/scenes"
}
},
"links": { "self": "https://api.framequery.com/v1/api/jobs/0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10" }
}data is the full Job object (trimmed above) without processedData. Fetch results from data.links.transcript and data.links.scenes with your API key. Your metadata is included, so you can route the event without a lookup.
| Header | Meaning |
|---|---|
webhook-id | Event id. Stable across retries of the same event: use it to deduplicate |
webhook-timestamp | Unix seconds when this attempt was signed (new on every attempt) |
webhook-signature | Space-separated list of v1,<base64 HMAC-SHA256> signatures. Two entries during a secret rotation |
Your signing secret
Each account has one signing secret, whsec_ followed by base64. Reveal it on the API keys page under Webhook signing secret (it is created the first time you open it) and store it as FRAMEQUERY_WEBHOOK_SECRET. It is a different secret from your API key.
Rotation. Click Rotate to issue a new secret. For the next 24 hours every delivery is signed with both the new and the previous secret (two v1, entries), so receivers holding either one keep verifying while you deploy the new value. After 24 hours only the new secret signs.
Verifying signatures
The signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")), where key is the base64-decoded part of the secret after whsec_. Verify against the raw request body, before any JSON parsing; re-serialised JSON will not match. Reject timestamps more than five minutes from your clock, and compare in constant time.
# pip install framequery (FastAPI shown; any framework works)
from fastapi import FastAPI, Request, Response
from framequery import WebhookVerificationError, webhooks
app = FastAPI()
@app.post("/webhooks/framequery")
async def framequery_webhook(request: Request):
try:
# Secret defaults to FRAMEQUERY_WEBHOOK_SECRET
event = webhooks.unwrap(await request.body(), request.headers)
except WebhookVerificationError:
return Response(status_code=400)
if event.type == "job.completed":
enqueue_fetch(event.data.job_id, dedupe_key=event.id) # your code
return Response(status_code=204)The Standard Webhooks reference libraries (pip install standardwebhooks, npm install standardwebhooks, github.com/standard-webhooks/standard-webhooks/libraries/go, and others for Rust, Ruby, PHP and more) verify the same signatures.
Responding, retries and failures
- Return any
2xxwithin 10 seconds to acknowledge. Do the real work (fetching results) asynchronously after you respond. 429,5xx, a timeout or a network error is retried: up to 8 attempts in total, with exponential backoff from 10 seconds up to 10 minutes between attempts.- Any other
4xx, a redirect (redirects are never followed) or an address that fails our safety checks is final and is not retried. 410 Goneacknowledges the event and stops further deliveries for that job and event type.- Retries reuse the same
webhook-id, and events can arrive more than once or out of order. Deduplicate onwebhook-idand re-read the job if order matters. - Deliveries go only to public HTTPS endpoints; private and internal addresses are refused.
Webhooks are a notification, not the only source of truth: if your endpoint was down for longer than the retry window, GET /jobs?state=completed and the delivery log let you catch up.
Testing an endpoint
Send a signed webhook.test event to any URL (HTTPS on port 443, public address; 5 requests per minute):
curl -X POST https://api.framequery.com/v1/api/webhooks/test \
-H "Authorization: Bearer $FRAMEQUERY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.example.com/webhooks/framequery" }'{ "data": { "delivered": true, "statusCode": 204, "latencyMs": 182 }, "requestId": "0c9b8a7d6e5f4321" }On failure delivered is false, statusCode is your endpoint's status (0 when there was no response) and error names the failure class, for example http_5xx: 503, timeout or redirect.
Delivery log
The last 100 deliveries for your account, newest first, optionally for one job:
curl "https://api.framequery.com/v1/api/webhooks/deliveries?jobId=$JOB_ID" \
-H "Authorization: Bearer $FRAMEQUERY_API_KEY"{
"data": [
{
"id": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10:job.completed",
"jobId": "0b5e6c1e-6f43-4a0e-9d4c-2f7f3c1b9a10",
"eventType": "job.completed",
"urlHost": "your-app.example.com",
"status": "delivered",
"attempts": 2,
"lastStatus": 204,
"disabled": false,
"deliveredAt": "2026-10-13T09:04:21Z",
"createdAt": "2026-10-13T09:04:03Z"
}
],
"requestId": "7a6b5c4d3e2f1a09"
}status | Meaning |
|---|---|
in_flight | Being delivered now |
pending_retry | Last attempt got 429, 5xx or timed out; it will be retried |
delivered | A 2xx was received |
failed | Another 4xx, a redirect or a blocked address; not retried |
disabled | Your endpoint answered 410; no further deliveries for this job and event |
lastError holds the failure class and the first 4 KiB of your endpoint's response body, which is usually enough to see why it rejected the event.