> ## Documentation Index
> Fetch the complete documentation index at: https://developers.fastdrop.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get results pushed to you the moment they exist

Webhooks let FastDrop push results to your server when jobs complete or fail. If your service can receive an HTTP request, this is the recommended way to integrate — you get the result as soon as it exists and make no status requests at all.

<Warning>
  **Payload shape changed on 2026-08-15.** Endpoints registered before that date keep receiving the old flat payload indefinitely — nothing you have built will break. Endpoints registered since receive the versioned envelope documented below. Check `payload_version` on `GET /v1/webhooks` to see which shape an endpoint receives; register a new endpoint to move to the envelope.
</Warning>

## Events

| Event | Trigger | Subscribed by default |
| - | - | - |
| `job.completed` | A single video job finishes successfully | Yes |
| `job.failed` | A single video job fails | Yes |
| `batch.completed` | Every video in a batch finished, none failed | Yes |
| `batch.failed` | A batch finished with at least one failure | Yes |
| `batch.progress` | A batch crossed a 10% completion boundary | **No — opt in** |

`batch.progress` is off by default because a large batch emits several. Add it to `events` if you want to track a batch as it runs; it fires at most once per 10% and never for batches under 5 videos. It is never sent at 100% — `batch.completed` covers that.

## Register a webhook

```bash theme={null}
curl -X POST https://api.fastdrop.io/api/v1/webhooks \
  -H "Content-Type: application/json" \
  -H "X-API-Key: fd_live_your_key_here" \
  -d '{
    "url": "https://your-server.com/webhooks/fastdrop",
    "events": ["job.completed", "job.failed"],
    "description": "Production webhook"
  }'
```

```json Response theme={null}
{
  "id": "3f9a1c22-8e4b-4d7a-9c11-77d2f0b5a8e3",
  "url": "https://your-server.com/webhooks/fastdrop",
  "events": ["job.completed", "job.failed"],
  "description": "Production webhook",
  "secret": "a1b2c3d4e5f6...64_char_hex_string",
  "payload_version": "v1",
  "is_active": true,
  "created_at": "2026-08-15T12:00:00Z"
}
```

<Warning>
  Store the `secret` now. This is the only response that contains it — `GET /v1/webhooks` returns just an 8-character prefix. If you lose it, [rotate](#rotating-a-secret) rather than re-registering.
</Warning>

**Limits:** 5 active endpoints per API key. URLs must use HTTPS.

## Webhook payload

Every event arrives in the same envelope:

```json theme={null}
{
  "id": "evt_9f2c4a7b1e8d40a3b6c5d2e1f0a9b8c7",
  "event": "job.completed",
  "api_version": "2026-08-15",
  "timestamp": "2026-08-15T12:01:15Z",
  "data": {
    "job_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
    "status": "completed",
    "video_url": "https://example.com/my-video.mp4",
    "credits_charged": 4,
    "completed_at": "2026-08-15T12:01:15Z",
    "results": {
      "role": "A-Roll",
      "role_confidence": 0.92,
      "suggested_filename": "interview-001.mp4",
      "suggested_folder": "A-Roll/Interview"
    }
  }
}
```

The event-specific fields are always under `data`. The four envelope keys are:

| Key | Meaning |
| - | - |
| `id` | Unique event id, stable across retries and replays. Use it to dedupe. |
| `event` | Event type, matching what you subscribed to |
| `api_version` | The payload contract this was built against |
| `timestamp` | When the event *occurred* — not when we dispatched it |

`timestamp` reflects the job's completion time, so events stay correctly ordered even when a batch fan-in dispatches them later.

### Headers

| Header | Description |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `FastDrop-Webhooks/1.0` |
| `X-FastDrop-Signature` | `sha256={hmac_hex_digest}` |
| `X-FastDrop-Event` | Event type (e.g. `job.completed`) |
| `X-FastDrop-Event-Id` | Same value as the body's `id` |
| `X-FastDrop-Delivery` | Unique id for this delivery attempt |

`X-FastDrop-Event-Id` lets you dedupe before parsing the body.

## Idempotency

Delivery is **at-least-once**. A retry, a manual replay, or a network timeout where your server actually succeeded can all produce a repeat. The `id` is identical every time:

```python theme={null}
if seen(event["id"]):
    return 200          # already handled
process(event["data"])
remember(event["id"])
```

Deduplicate on `id`, not on `job_id` — a job legitimately produces more than one event.

## Verifying signatures

Every webhook is signed with your secret using HMAC-SHA256. Always verify before acting.

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook(payload_body: bytes, signature_header: str, secret: str) -> bool:
      """Verify a FastDrop webhook signature."""
      expected = hmac.new(
          secret.encode("utf-8"),
          payload_body,          # raw bytes, exactly as received
          hashlib.sha256,
      ).hexdigest()

      received = signature_header.replace("sha256=", "")
      return hmac.compare_digest(expected, received)
  ```

  ```javascript JavaScript theme={null}
  const crypto = require("crypto");

  function verifyWebhook(payloadBody, signatureHeader, secret) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(payloadBody)       // raw body, not the parsed object
      .digest("hex");

    const received = signatureHeader.replace("sha256=", "");
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(received)
    );
  }
  ```
</CodeGroup>

<Note>
  Verify against the **raw request body**. Re-serialising the parsed JSON will not reproduce our bytes, and the signature will not match. In Express use `express.raw()`; in FastAPI use `await request.body()`.
</Note>

## A complete receiver

<CodeGroup>
  ```python FastAPI theme={null}
  from fastapi import FastAPI, Request, Response, HTTPException

  app = FastAPI()
  SECRET = "your_webhook_secret"
  seen_events: set[str] = set()   # use Redis or a table in production

  @app.post("/webhooks/fastdrop")
  async def fastdrop_webhook(request: Request):
      body = await request.body()

      if not verify_webhook(body, request.headers.get("X-FastDrop-Signature", ""), SECRET):
          raise HTTPException(status_code=401, detail="bad signature")

      event = await request.json()

      # At-least-once delivery: the same event id can arrive more than once.
      if event["id"] in seen_events:
          return Response(status_code=200)
      seen_events.add(event["id"])

      if event["event"] == "job.completed":
          handle_result(event["data"]["job_id"], event["data"]["results"])
      elif event["event"] == "job.failed":
          handle_failure(event["data"]["job_id"], event["data"].get("error"))

      # Return 2xx promptly. Non-2xx or >15s is treated as a failure and retried.
      return Response(status_code=200)
  ```

  ```javascript Express theme={null}
  const express = require("express");
  const app = express();
  const SECRET = process.env.FASTDROP_WEBHOOK_SECRET;
  const seen = new Set();   // use Redis in production

  // express.raw so the body stays verifiable.
  app.post(
    "/webhooks/fastdrop",
    express.raw({ type: "application/json" }),
    (req, res) => {
      if (!verifyWebhook(req.body, req.headers["x-fastdrop-signature"], SECRET)) {
        return res.sendStatus(401);
      }

      const event = JSON.parse(req.body.toString());

      if (seen.has(event.id)) return res.sendStatus(200);
      seen.add(event.id);

      if (event.event === "job.completed") {
        handleResult(event.data.job_id, event.data.results);
      } else if (event.event === "job.failed") {
        handleFailure(event.data.job_id, event.data.error);
      }

      res.sendStatus(200);
    }
  );
  ```
</CodeGroup>

Acknowledge fast and do slow work afterwards. We wait 15 seconds; anything longer counts as a failed delivery and is retried.

## Retry policy

Non-2xx or a timeout triggers retries with exponential backoff:

| Attempt | Delay |
| - | - |
| 1st retry | 30 seconds |
| 2nd retry | 120 seconds |
| 3rd retry | 480 seconds |

After 3 failed attempts the delivery is marked `failed`. It is not lost — inspect and replay it below.

## Delivery history

Every delivery is recorded with the response your server gave.

```bash theme={null}
# Everything we could not deliver
curl "https://api.fastdrop.io/api/v1/webhooks/deliveries?status=failed" \
  -H "X-API-Key: fd_live_your_key_here"

# Did the webhook for one specific job fire?
curl "https://api.fastdrop.io/api/v1/webhooks/deliveries?job_id=JOB_ID" \
  -H "X-API-Key: fd_live_your_key_here"
```

```json Response theme={null}
{
  "deliveries": [
    {
      "id": "8c7d6e5f-4a3b-2c1d-0e9f-8a7b6c5d4e3f",
      "event": "job.completed",
      "event_id": "evt_9f2c4a7b1e8d40a3b6c5d2e1f0a9b8c7",
      "status": "failed",
      "webhook_id": "3f9a1c22-8e4b-4d7a-9c11-77d2f0b5a8e3",
      "target_url": "https://your-server.com/webhooks/fastdrop",
      "job_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
      "attempts": 3,
      "max_attempts": 3,
      "response_status": 502,
      "response_body": "Bad Gateway",
      "error": "HTTP 502",
      "created_at": "2026-08-15T12:01:15Z",
      "delivered_at": null
    }
  ],
  "next_cursor": null
}
```

Filters: `webhook_id`, `job_id`, `event`, `status`, `before`, `limit`. Paginate by passing `next_cursor` as `before`.

To see exactly what we sent — useful when your parser and our payload disagree — fetch one delivery:

```bash theme={null}
curl https://api.fastdrop.io/api/v1/webhooks/deliveries/DELIVERY_ID \
  -H "X-API-Key: fd_live_your_key_here"
```

That response adds a `payload` field containing the verbatim body.

## Replaying a delivery

Fixed your endpoint, or lost an event downstream? Replay it:

```bash theme={null}
curl -X POST https://api.fastdrop.io/api/v1/webhooks/deliveries/DELIVERY_ID/retry \
  -H "X-API-Key: fd_live_your_key_here"
```

The payload and its `id` are unchanged, so a handler that dedupes on `id` is safe. Replaying **costs no credits** — you never need to re-run a job to recover an event.

A delivery still queued for another automatic attempt returns `409 delivery_in_flight`; wait for that attempt to resolve.

## Rotating a secret

```bash theme={null}
curl -X POST https://api.fastdrop.io/api/v1/webhooks/WEBHOOK_ID/rotate-secret \
  -H "X-API-Key: fd_live_your_key_here"
```

Returns the endpoint with a new `secret`, shown once. **The old secret stops working immediately** — if your handler rejects bad signatures, deploy the new secret before rotating, or expect a few retried deliveries while you catch up.

## Testing

```bash theme={null}
curl -X POST https://api.fastdrop.io/api/v1/webhooks/test \
  -H "Content-Type: application/json" \
  -H "X-API-Key: fd_live_your_key_here" \
  -d '{"webhook_id": "WEBHOOK_ID"}'
```

Sends a `test` event in the **same shape your endpoint receives for real events**, including the signature. If your handler passes this, it will pass a real `job.completed`.

## Per-request webhooks

For a one-off callback without registering an endpoint, pass `webhook_url` on the submission:

```json theme={null}
{
  "video_url": "https://example.com/my-video.mp4",
  "webhook_url": "https://your-server.com/hook",
  "webhook_secret": "a-secret-you-choose"
}
```

This path is retried and appears in delivery history like any other. Two differences from a registered endpoint:

* The body is **flat** — `job_id`, `status`, `results`, `completed_at` at the top level, no envelope. This shape predates the envelope and is kept for compatibility.
* It is **unsigned unless you pass `webhook_secret`**. Do pass one; without it you cannot prove the request came from us.

Registered endpoints are better for anything long-lived: batch events, a rotatable secret, and the envelope.

## Managing endpoints

```bash theme={null}
# List (secrets shown only as an 8-char prefix)
curl https://api.fastdrop.io/api/v1/webhooks \
  -H "X-API-Key: fd_live_your_key_here"

# Delete
curl -X DELETE https://api.fastdrop.io/api/v1/webhooks/WEBHOOK_ID \
  -H "X-API-Key: fd_live_your_key_here"
```

<Card title="Getting your results" icon="arrows-split-up-and-left" href="/guides/delivery-modes">
  How webhooks compare to long-poll, and when to use which.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.