# 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.

Source: https://www.framequery.com/docs/webhooks (updated 2026-10-13)

## 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`](https://www.framequery.com/docs/errors#INVALID_CALLBACK_URL).

```bash
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](https://www.standardwebhooks.com) specification, so any Standard Webhooks library can verify them.

## Payload

```http
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=
```

```json
{
  "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](https://www.framequery.com/docs/job-lifecycle#the-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](https://www.framequery.com/profile/api-keys) 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.

```python
# 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)
```

```typescript
// npm install framequery   (Express shown)
import express from "express";
import FrameQuery from "framequery";

const fq = new FrameQuery(); // webhookSecret defaults to FRAMEQUERY_WEBHOOK_SECRET
const app = express();

app.post("/webhooks/framequery", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await fq.webhooks.unwrap(req.body, req.headers);
    if (event.type === "job.completed") await enqueueFetch(event.data.jobId, event.id); // your code
    res.sendStatus(204);
  } catch {
    res.sendStatus(400);
  }
});
```

```javascript
// No dependencies. Node 18+.
import crypto from "node:crypto";

export function verifyFrameQueryWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) throw new Error("stale timestamp");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();
  const valid = String(signatures)
    .split(" ")
    .some((entry) => {
      const [version, signature] = entry.split(",");
      if (version !== "v1" || !signature) return false;
      const given = Buffer.from(signature, "base64");
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
  if (!valid) throw new Error("invalid signature");
}

// Express: app.post("/webhooks/framequery", express.raw({ type: "application/json" }), (req, res) => {
//   verifyFrameQueryWebhook(req.body, req.headers, process.env.FRAMEQUERY_WEBHOOK_SECRET);
//   const event = JSON.parse(req.body); ... res.sendStatus(204); });
```

```go
// Standard library only.
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func Verify(secret string, h http.Header, body []byte) error {
	id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	if id == "" || ts == "" || sigs == "" {
		return errors.New("missing webhook headers")
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return errors.New("invalid timestamp")
	}
	if d := time.Since(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return errors.New("stale timestamp")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return errors.New("invalid secret")
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, entry := range strings.Fields(sigs) {
		version, sig, ok := strings.Cut(entry, ",")
		if !ok || version != "v1" {
			continue
		}
		if given, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(given, expected) {
			return nil
		}
	}
	return errors.New("invalid signature")
}

// In your handler: body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
// if err := webhooks.Verify(os.Getenv("FRAMEQUERY_WEBHOOK_SECRET"), r.Header, body); err != nil { w.WriteHeader(400); return }
```

```python
import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> None:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"]
    if abs(time.time() - int(ts)) > tolerance:
        raise ValueError("stale timestamp")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(
        hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    ).decode()
    if not any(
        hmac.compare_digest(expected, s.split(",", 1)[1])
        for s in signatures.split()
        if s.startswith("v1,")
    ):
        raise ValueError("invalid signature")
```

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 `2xx` within 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 Gone` acknowledges 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 on `webhook-id` and 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):

```bash
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" }'
```

```json
{ "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:

```bash
curl "https://api.framequery.com/v1/api/webhooks/deliveries?jobId=$JOB_ID" \
  -H "Authorization: Bearer $FRAMEQUERY_API_KEY"
```

```json
{
  "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.
