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

# Webhooks

> Get called back when an async job finishes instead of polling.

## Overview

Rather than polling, an [async job](/guides/async-jobs) can call you back when it reaches a terminal state (`completed` or `failed`). Webhooks are available for every async endpoint:

* `POST /transcribe/async`
* `POST /extract/video/async`
* `POST /tweet/statement/async`
* `POST /tiktok/profile`
* `POST /tiktok/search`

## Configure a webhook

There are two ways to set the endpoint, and they combine:

<CardGroup cols={2}>
  <Card title="Account default (Studio)" icon="gear" href="https://vidnavigator.com/studio/api">
    In **Studio → API**, set a default webhook URL and get your **signing secret**. Every async job uses it unless the request says otherwise.
  </Card>

  <Card title="Per request (API)" icon="code">
    Pass `webhook_url` in the body of the submit request. It **overrides** the account default for that job only.
  </Card>
</CardGroup>

| `webhook_url` in the request | Result                                                          |
| ---------------------------- | --------------------------------------------------------------- |
| omitted                      | The account default from Studio is used (if one is configured). |
| `"https://…"`                | This URL is used for this job, instead of the default.          |
| `""` (empty string)          | No webhook for this job, even if a default is configured.       |

```bash cURL theme={null}
curl -X POST "https://api.vidnavigator.com/v1/transcribe/async" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "video_url": "https://www.instagram.com/reel/C86ZvEaqRmo/",
    "webhook_url": "https://example.com/hooks/vidnavigator"
  }'
```

<Note>
  The URL must be a **publicly reachable `https` URL**. Private, loopback and link-local hosts are rejected. The submit response echoes the URL that will be used in `data.webhook_url` (or `null`).
</Note>

## The event

When the job finishes, VidNavigator sends a `POST` with a JSON body to your URL.

```json theme={null}
{
  "id": "evt_9f2c1b4e8a7d4c1e9b3a5f6d7c8e9a0b",
  "type": "transcribe.completed",
  "created_at": "2026-09-23T12:03:41Z",
  "api_version": "1.0.0",
  "data": {
    "task_id": "550e8400-e29b-41d4-a716-446655440000",
    "task_status": "completed",
    "job_type": "transcribe",
    "check_status_url": "/v1/transcribe/550e8400-e29b-41d4-a716-446655440000",
    "result": {
      "video_info": { "title": "A long podcast episode", "duration": 3540.0 },
      "transcript": [{ "text": "Welcome to the show.", "start": 0.5, "end": 2.1 }]
    },
    "result_truncated": false,
    "error": null
  }
}
```

| Field                   | Description                                                                                                          |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `id`                    | Unique event id.                                                                                                     |
| `type`                  | Event type (see below).                                                                                              |
| `created_at`            | When the event was created.                                                                                          |
| `api_version`           | API version that produced the event.                                                                                 |
| `data.task_id`          | The job's `task_id`.                                                                                                 |
| `data.task_status`      | `completed` or `failed`.                                                                                             |
| `data.job_type`         | The job type.                                                                                                        |
| `data.check_status_url` | Where to fetch the full result.                                                                                      |
| `data.result`           | The job's result — same shape as the synchronous endpoint's `data` block. Omitted when `result_truncated` is `true`. |
| `data.result_truncated` | `true` when the result exceeded **256 KB** and was left out. Fetch it from `check_status_url`.                       |
| `data.error`            | On failure: `error`, `message`, `http_status` — the same codes as the synchronous endpoint.                          |

### Event types

| Type                                                   | Sent by                       |
| ------------------------------------------------------ | ----------------------------- |
| `transcribe.completed` / `transcribe.failed`           | `POST /transcribe/async`      |
| `extract_video.completed` / `extract_video.failed`     | `POST /extract/video/async`   |
| `tweet_statement.completed` / `tweet_statement.failed` | `POST /tweet/statement/async` |
| `tiktok_profile.completed` / `tiktok_profile.failed`   | `POST /tiktok/profile`        |
| `tiktok_search.completed` / `tiktok_search.failed`     | `POST /tiktok/search`         |

<Note>
  **TikTok events are notifications, not payloads.** A scrape can hold thousands of videos, so `tiktok_profile.*` and `tiktok_search.*` events carry the task `stats` and point you to `check_status_url` to read the results (paginated or through `download_url`).
</Note>

### Headers

| Header                     | Description                                                |
| -------------------------- | ---------------------------------------------------------- |
| `X-VidNavigator-Event`     | Same as `type`.                                            |
| `X-VidNavigator-Delivery`  | Delivery id, **stable across retries**. Deduplicate on it. |
| `X-VidNavigator-Task-Id`   | The job's `task_id`.                                       |
| `X-VidNavigator-Signature` | `t=<unix_ts>,v1=<hmac_sha256 hex>`                         |

## Verify the signature

Every delivery is signed with HMAC-SHA256 using your **signing secret** from [Studio → API](https://vidnavigator.com/studio/api).

1. Parse `t` and `v1` from `X-VidNavigator-Signature`.
2. Compute `HMAC-SHA256(secret, "{t}.{raw_request_body}")` as hex. Use the **raw** body bytes, before any JSON parsing.
3. Compare with `v1` in constant time.
4. Reject deliveries whose `t` is more than \~5 minutes old (replay protection).

<CodeGroup>
  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import time

  from flask import Flask, abort, request

  app = Flask(__name__)
  SIGNING_SECRET = b"YOUR_SIGNING_SECRET"
  seen_deliveries = set()  # use a persistent store in production

  @app.post("/hooks/vidnavigator")
  def vidnavigator_webhook():
      header = request.headers.get("X-VidNavigator-Signature", "")
      parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1 or abs(time.time() - int(t)) > 300:
          abort(400)

      raw_body = request.get_data()
      expected = hmac.new(SIGNING_SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, v1):
          abort(401)

      delivery_id = request.headers["X-VidNavigator-Delivery"]
      if delivery_id in seen_deliveries:
          return "", 200  # already processed
      seen_deliveries.add(delivery_id)

      event = request.get_json()
      if event["data"]["task_status"] == "completed":
          ...  # use event["data"]["result"], or fetch check_status_url if result_truncated
      else:
          ...  # event["data"]["error"]
      return "", 200
  ```

  ```javascript Node.js (Express) theme={null}
  const crypto = require('crypto');
  const express = require('express');

  const app = express();
  const SIGNING_SECRET = 'YOUR_SIGNING_SECRET';
  const seenDeliveries = new Set(); // use a persistent store in production

  app.post('/hooks/vidnavigator', express.raw({ type: 'application/json' }), (req, res) => {
    const header = req.get('X-VidNavigator-Signature') || '';
    const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
    const { t, v1 } = parts;
    if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

    const expected = crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(`${t}.`)
      .update(req.body) // raw Buffer
      .digest('hex');
    if (expected.length !== v1.length ||
        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
      return res.sendStatus(401);
    }

    const deliveryId = req.get('X-VidNavigator-Delivery');
    if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);
    seenDeliveries.add(deliveryId);

    const event = JSON.parse(req.body);
    // event.data.result, or fetch event.data.check_status_url if result_truncated
    res.sendStatus(200);
  });
  ```
</CodeGroup>

## Delivery and retries

* **Acknowledge with any `2xx`**, quickly. Do heavy processing asynchronously on your side.
* Delivery is **at-least-once** and **best effort**: up to **5 attempts**, backing off over \~13 minutes.
* **Retried:** `5xx`, `429`, timeouts and network errors.
* **Not retried:** any other `4xx` — it is treated as a permanent rejection.
* Because the same event can arrive more than once, make your receiver **idempotent** on `X-VidNavigator-Delivery`.
* Treat **polling as the source of truth**: if a delivery never arrives, the result is still available on `check_status_url` for 1 hour after the job finishes.

### Delivery status on the job

The poll endpoint exposes the delivery state in `data.webhook` (it is `null` when no webhook applies). The URL itself is never echoed back, since it frequently embeds a token.

```json theme={null}
"webhook": {
  "status": "failed",
  "attempts": 5,
  "response_status": 503,
  "last_error": "HTTP 503",
  "delivered_at": null
}
```

| Field             | Description                                             |
| ----------------- | ------------------------------------------------------- |
| `status`          | `pending`, `delivered` or `failed`.                     |
| `attempts`        | Delivery attempts made (max 5).                         |
| `response_status` | HTTP status your endpoint returned on the last attempt. |
| `last_error`      | Last delivery error, if any.                            |
| `delivered_at`    | When the delivery succeeded.                            |

## Auto-disable

An **account-level default** endpoint is switched off automatically when it fails **20 deliveries in a row over more than 72 hours**. The owner is notified in Studio.

* Both conditions must hold: a busy afternoon of failures won't disable a healthy endpoint, and neither will two failures a week apart.
* A single successful delivery resets the count.
* While disabled, jobs still run and results are still available by polling — only the callback stops.
* Re-enable it in [Studio → API](https://vidnavigator.com/studio/api) once the receiver is fixed.
* A per-request `webhook_url` is never auto-disabled, because it is not stored.
