# Webhooks

> Get a message at your own web address when an agent run, lookup, tracking check or trend update is done. Set up webhooks, check deliveries and resend failed ones.

Source: https://dev.virlo.ai/docs/webhooks
Markdown: https://dev.virlo.ai/docs/webhooks.md
Section: How the API works

## About the Virlo API (applies to every page)

- Base URL: `https://api.virlo.ai/v1`. Every request needs the header `Authorization: Bearer YOUR_API_KEY` (keys start with `virlo_tkn_`).
- Responses are JSON inside a `data` field, except the `/v1/webhooks` endpoints, which return the object or array directly. Field names are snake_case.
- Prices are in US dollars from a prepaid balance. 1 credit = $0.01. The `X-Cost` response header on each successful response is the exact charge. Errors are free.
- Slow jobs return an ID. Check its status every 15 seconds (or whatever `retry_after_seconds` says) until `finalized` is `true`.
- All docs pages: https://dev.virlo.ai/llms.txt. Every page in one file: https://dev.virlo.ai/llms-full.txt. MCP server for AI assistants: https://dev.virlo.ai/api/mcp/mcp.

---

A webhook sends a message to your server when something happens in Virlo, such as an agent run finishing. You don't have to keep checking back.

**At a glance**

- **What it does:** Tells your server the moment a job is done or something you watch changes.
- **You send:** An HTTPS address on your server and the events you want.
- **You get back:** A message at that address for each event, with the details inside.
- **Cost:** Free. The work that triggers a message is billed as usual.
- **How long:** Usually seconds after the event.

---

## How webhooks work

1. **Add a webhook** (the API calls it an endpoint): your address and the events you want. Use [`POST /v1/webhooks`](https://dev.virlo.ai/docs/webhooks#create-webhook) for any event, or the [Integrations page](https://dev.virlo.ai/dashboard/integrations) for agent runs, lookups and global trends.
2. **Virlo sends a message** (an HTTP `POST`) when an event happens.
3. **Your server replies** with a success code (200 to 299, written `2xx`) within 30 seconds.
4. **Failures are retried.** The [delivery log](https://dev.virlo.ai/docs/webhooks#list-deliveries) shows each delivery, how many attempts it took, and your server's last reply.

Webhooks belong to your team and get events for all its work.

---

## Supported events

| Event | Fires when |
| - | - |
| [`content_research_agent.run.completed`](https://dev.virlo.ai/docs/webhooks#agent-run-payload) | A Content Research Agent run finishes or fails. |
| [`content_research_agent.event.detected`](https://dev.virlo.ai/docs/webhooks#event-detected-payload) | A recurring agent spots a breaking story in its topic. |
| [`satellite.lookup.completed`](https://dev.virlo.ai/docs/webhooks#lookup-payload) | A creator, sound, hashtag or video outlier lookup finishes or fails. |
| [`trends.daily.completed`](https://dev.virlo.ai/docs/webhooks#trend-payload) | The global trend list updates (up to 3 times a day, despite the name). |
| [`trends.region.completed`](https://dev.virlo.ai/docs/webhooks#trend-payload) | A country trend list updates. One message per country. |
| [`tracking.cycle.completed`](https://dev.virlo.ai/docs/webhooks#tracking-payload) | A tracked creator or video gets its scheduled check. |
| [`tracking.outlier_video.detected`](https://dev.virlo.ai/docs/webhooks#tracking-payload) | A tracked creator posts a video with over 3 times their median views. |
| [`tracking.paused`](https://dev.virlo.ai/docs/webhooks#tracking-payload) | Virlo pauses tracking: 3 failed checks in a row, or your balance can't cover a check. |
| [`audience.snapshot.completed`](https://dev.virlo.ai/docs/webhooks#audience-payload) | An audience snapshot you asked for is ready. |

Trend events are public: no team or user details.

There's no separate failure event. Check `data.status`: agent runs and lookups say `success` or `failed`; trend updates include it only when they fail; breaking stories always say `confirmed`; tracking and audience events don't have it.

> **Note:** **Older names.** `orbit.run.completed` (agents that run once) and `comet.run.completed` (recurring agents) still work and still fire. Subscribe to the matching old name and the new one, and you get two messages per run.

---

## What we send

Every message has the same outer layer. `data` depends on the [event](https://dev.virlo.ai/docs/webhooks#data-shape).

**JSON:**

```json
{
  "id": "718bdf28-2a09-4813-88cd-71013dfeeab3",
  "event": "content_research_agent.run.completed",
  "created_at": "2026-09-24T17:32:28.733Z",
  "data": { ... }
}
```

`id` is a UUID matching the `Virlo-Event-Id` header and the log's delivery `id`. Retries reuse it, so use it to skip repeats. Two webhooks getting the same event get different `id`s.

Headers we send:

```
Content-Type:      application/json
Content-Encoding:  gzip                     # only when the body is over 1 KB
User-Agent:        Virlo-Webhooks/1.0
Virlo-Event-Id:    <same value as the body's id>
Virlo-Event-Type:  content_research_agent.run.completed
<your custom headers>
```

### Large messages

Bodies over 1 KB arrive gzip-compressed. Express unzips them; Flask doesn't (see below). A message over 10 MB compressed is never sent: it's marked `payload_too_large` and can't be retried.

---

## Responding to a delivery

Reply `2xx` within 30 seconds. Anything else is a failure and gets [retried](https://dev.virlo.ai/docs/webhooks#retry-ladder). Only the status code matters. Redirects are not followed, so register the final URL. Reply first, then do the slow work.

This receiver checks a secret header, replies fast and skips repeats:

**Node.js:**

```js
import express from 'express'

const app = express()
// Demo only: store seen IDs in your database so repeats are caught after a restart.
const seen = new Set()

// express.json() unzips gzip bodies for you.
// Raise the size limit: lookup results can be large.
app.post('/webhooks/virlo', express.json({ limit: '25mb' }), (req, res) => {
  // 1. Check your secret header (see Security).
  if (req.get('X-Webhook-Secret') !== process.env.VIRLO_WEBHOOK_SECRET) {
    return res.sendStatus(401)
  }

  // 2. Reply fast, then do the work.
  res.sendStatus(200)

  // 3. Skip duplicates. A retry reuses the same id.
  const event = req.body
  if (seen.has(event.id)) return
  seen.add(event.id)

  console.log(event.event, event.data) // your code here
})

app.listen(3000)
```

**Python:**

```python
import gzip
import json
import os

from flask import Flask, abort, request

app = Flask(__name__)
# Demo only: store seen IDs in your database so repeats are caught after a restart.
seen = set()

@app.post('/webhooks/virlo')
def virlo_webhook():
    # 1. Check your secret header (see Security).
    if request.headers.get('X-Webhook-Secret') != os.environ['VIRLO_WEBHOOK_SECRET']:
        abort(401)

    # 2. Flask leaves gzip bodies compressed, so unzip them here.
    body = request.get_data()
    if body[:2] == b'\x1f\x8b':
        body = gzip.decompress(body)
    event = json.loads(body)

    # 3. Skip duplicates. A retry reuses the same id.
    # For slow work, save the event and hand it to a background job, then return 200.
    if event['id'] not in seen:
        seen.add(event['id'])
        print(event['event'], event['data'])  # your code here

    return '', 200
```

---

## Retries

> **Note:** **Retries run late for now.** Each usually goes out about a day after the last, not on the schedule below. If you miss a message, check the result with the matching GET call.

The planned schedule, counting from the previous attempt:

| Attempt | When |
| - | - |
| 1 | Right away |
| 2 | 30 seconds later |
| 3 | 2 minutes later |
| 4 | 10 minutes later |
| 5 | 1 hour later |
| 6 | 6 hours later |
| 7 | 24 hours later |

`408`, `429`, `5xx`, timeouts and network errors get all 7 attempts, then the delivery is `dead`. Other codes, including redirects and `404`, get one more try, then it's `failed`. Resend either with [Retry delivery](https://dev.virlo.ai/docs/webhooks#retry-delivery).

### Automatic shutdown

Every failed attempt adds 1 to `consecutive_failures`, and any success resets it to 0. At 20, Virlo shuts the webhook down (`status: "disabled_by_system"`). Until you [re-enable it](https://dev.virlo.ai/docs/webhooks#reenable-webhook):

- New events are not sent or logged, and can't be resent.
- Deliveries waiting for a retry are marked `failed`.
- It still counts toward your limit of 5 webhooks.

---

## Security

- **Messages are not signed** (no signature header or secret), so anyone could fake one. Add a secret header, like `X-Webhook-Secret` with a long random value, and reject requests without it.
- **Header values are visible** in plain text to anyone with your team's API keys. Use a secret made just for this.
- **HTTPS and public addresses only.** `localhost`, `.local`, `.internal` and private IP addresses are rejected. To test locally, use a tunnel with a public HTTPS address.
- **Duplicates and order.** A delivery can arrive twice with the same `id`, and order isn't guaranteed. Sort by `created_at` if needed.

---

## What each event contains

### Agent run done

`content_research_agent.run.completed` fires once per run. For a run that worked, it waits for the AI report. Where the agent's settings sit depends on the outcome:

| Outcome | `status` | Agent settings are under | Also included |
| - | - | - | - |
| It worked | `success` | `orbit` (runs once) or `comet` (recurring) | `is_recurring`, `analysis` |
| It failed | `failed` | `content_research_agent`, with `is_recurring` inside | `intent_summary` |

Read the settings either way with `data.orbit ?? data.comet ?? data.content_research_agent`. `analysis` is `null` when the AI report failed or found no new videos. The videos are still saved ([`GET /v1/agents/:id/videos`](https://dev.virlo.ai/docs/agents#get-agent-videos)).

`metrics.credits_used` is an internal count of the data requests the run made, not your charge. A run costs $0.50, or $1.50 with Data Intelligence. Each charge is on the [Usage](https://dev.virlo.ai/dashboard/usage) page.

**Agent run finished:**

```json
{
  "id": "5359b43e-9efe-4326-83c8-293281d38e74",
  "event": "content_research_agent.run.completed",
  "created_at": "2026-09-20T00:06:19.544Z",
  "data": {
    "run_id": "8781db67-9f9a-41ac-8ba2-90c7a7dded3a",
    "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
    "user_id": "f70e0123-e5f6-7890-abcd-ef1234567890",
    "status": "success",
    "started_at": "2026-09-20T00:00:11.819+00:00",
    "completed_at": "2026-09-20T00:04:52.223+00:00",
    "is_recurring": true,
    "metrics": {
      "credits_used": 158,
      "execution_time_ms": 280513,
      "videos_linked": 70,
      "videos_inserted": 44,
      "videos_updated": 73,
      "meta_ads_linked": 0,
      "outliers_identified": 0,
      "language_filtered": 28,
      "exclude_keywords_filtered": 0,
      "platforms": { "youtube": 43, "tiktok": 135, "instagram": 5 }
    },
    "comet": {
      "id": "ca818bcf-05e3-4143-9434-a087df93a74d",
      "name": "AI video research",
      "keywords": ["ai video", "faceless ai"],
      "platforms": ["youtube", "tiktok", "instagram"],
      "cadence": "0 0 * * 0",
      "min_views": 5000,
      "time_range": "this_week",
      "intent": "Find which AI video formats get the most saves",
      "last_run_at": "2026-09-13T00:06:27.196+00:00",
      "next_run_at": "2026-09-27T00:00:00+00:00",
      "is_active": true
    },
    "analysis": {
      "id": "fab02395-c200-45e4-a84d-17efd0fd8228",
      "status": "completed",
      "analysis_data": {
        "key_highlight": "POV-style rants dominate AI video content with 3x higher save rates",
        "overview": { "avg_virality": 0.794, "total_videos": 36 },
        "themes": [
          {
            "stable_key": "pov-first-person-rant",
            "name": "POV First-Person Rants",
            "why_it_works": "Direct eye-contact delivery drives saves.",
            "tactics": ["open with strong emotional claim", "handheld camera"],
            "confidence": 0.91,
            "video_count": 12,
            "evidence_video_ids": ["36d99a1e-c7d1-4d78-bc01-fe6717ec0c8c"]
          }
        ],
        "viral_tactics": ["Hook in first 2 seconds"],
        "timing_analysis": { "peak_hours": [9, 12, 18] },
        "connecting_thread": "Every theme leans on a strong first-person opinion.",
        "top_10_breakdown": { "intro_header": "Opinions beat tutorials this week", "videos": [ ... ] },
        "excluded_videos": [ ... ]
      },
      "trends": [ ... ],
      "video_count": 36,
      "cost_usd": 0.0182842
    },
    "error": null
  }
}
```

**Details for developers**

A run that failed:

**JSON:**

```json
{
  "id": "9d10dfc2-4269-4655-b294-f4a0609fd19b",
  "event": "content_research_agent.run.completed",
  "created_at": "2026-09-20T00:10:02.117Z",
  "data": {
    "run_id": "b7e1c0d2-5f3a-4e8b-9c1d-2a3b4c5d6e7f",
    "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
    "user_id": "f70e0123-e5f6-7890-abcd-ef1234567890",
    "status": "failed",
    "started_at": "2026-09-20T00:00:11.819+00:00",
    "completed_at": "2026-09-20T00:10:01.904+00:00",
    "metrics": {
      "credits_used": 0,
      "execution_time_ms": 0,
      "videos_linked": 0,
      "videos_inserted": 0,
      "videos_updated": 0,
      "meta_ads_linked": 0,
      "outliers_identified": 0,
      "youtube_count": 0,
      "tiktok_count": 0,
      "instagram_count": 0
    },
    "intent_summary": null,
    "content_research_agent": {
      "id": "ca818bcf-05e3-4143-9434-a087df93a74d",
      "name": "AI video research",
      "is_recurring": true,
      "keywords": ["ai video", "faceless ai"],
      "platforms": ["youtube", "tiktok", "instagram"],
      "cadence": "0 0 * * 0",
      "min_views": 5000,
      "time_range": "this_week",
      "intent": "Find which AI video formats get the most saves",
      "last_run_at": "2026-09-13T00:06:27.196+00:00",
      "next_run_at": "2026-09-27T00:00:00+00:00",
      "active": true
    },
    "error": { "code": "TIMEOUT_ERROR", "message": "Request timeout" }
  }
}
```

- `status` (string, optional): A run where some keywords or platforms failed still arrives as `success`. [`GET /v1/agents/:id/runs`](https://dev.virlo.ai/docs/agents#list-agent-runs) shows it as `partial_failure`. Rarely, a run whose AI report couldn't start says `success` or `partial_failure` but is laid out like a failed run.
- `metrics` (object, optional): `videos_linked` is how many videos the run added to your agent. `platforms` counts videos before some filters, so it's often higher than `videos_linked`. Failed runs send zeros, with `youtube_count`, `tiktok_count` and `instagram_count` in place of `platforms`.
- `orbit, comet, content_research_agent` (object, optional): `cadence` is a cron expression, such as `0 0 * * 0` for weekly, even if you chose `weekly`. `min_views` and `time_range` are set by Virlo: you can't change them through `/v1/agents`.
- `intent_summary` (object | null, optional): Failed runs only: `{ matched, total_evaluated }`, or `null`.
- `analysis` (object | null, optional): `analysis.trends` is a copy of `analysis_data.themes`: patterns in your agent's videos, not the global trend list. A theme's `stable_key` stays the same across runs. `cost_usd` is Virlo's own AI cost, not yours.

### Breaking story found

`content_research_agent.event.detected` arrives when a recurring agent confirms a breaking story in its topic. [`GET /v1/agents/:id/events`](https://dev.virlo.ai/docs/agents/autopilot#get-agent-events) lists them too.

**content_research_agent.event.detected:**

```json
{
  "id": "2f6c9a1e-3b7d-4e8f-a0c2-5d9b1e7f3a64",
  "event": "content_research_agent.event.detected",
  "created_at": "2026-08-03T14:05:00.312Z",
  "data": {
    "agent_id": "bc6b7fb3-fa0e-4c21-9e06-27d36558bbe4",
    "agent_name": "Peak Climbing Insights",
    "is_recurring": true,
    "event_id": "e1111111-1111-4111-8111-111111111111",
    "title": "Broad Peak avalanche",
    "summary": "A serac collapse on Broad Peak triggered a rescue effort.",
    "salience": 9,
    "source": "news_scan",
    "status": "confirmed",
    "detected_at": "2026-08-03T14:04:41Z",
    "confirmed_at": "2026-08-03T14:05:02Z",
    "expires_at": "2026-08-17T14:04:41Z",
    "event_date": null,
    "keywords": ["broad peak avalanche", "nimsdai tribute"],
    "keywords_added": ["broad peak avalanche", "nimsdai tribute"],
    "evidence": [
      {
        "title": "Avalanche strikes Broad Peak, rescue underway",
        "url": "https://www.bbc.com/news/world-asia-...",
        "domain": "bbc.com"
      }
    ],
    "action_taken": "collecting_early",
    "next_run_at": "2026-08-03T14:30:00Z"
  }
}
```

- `salience`: how big the story is, 0 to 10.
- `source`: `news_scan` (news articles) or `corpus_burst` (a jump in the agent's own videos).
- `keywords_added`: temporary story keywords. Agents on [autopilot](https://dev.virlo.ai/docs/agents/autopilot#autonomy) add them; agents with autopilot off leave a [proposal](https://dev.virlo.ai/docs/agents/autopilot#list-agent-proposals) instead.
- `action_taken`: `collecting_early` (next run sooner), `breaking_ingest` (fresh videos now), `timely_keywords` (keywords only) or `none`. No extra cost.

### Lookup done

In `satellite.lookup.completed`, `data.type` is `creator_lookup`, `sound_lookup`, `hashtag_lookup` or `video_outlier`. A batch creator lookup sends one message per creator. On success, the full result is in `data.results`. On failure, `error` says why.

No message comes for a creator, sound or hashtag lookup repeated within 6 hours (the start call returns `cached: true`), or for a lookup that stalls. Read those with `GET /v1/satellite/runs/:run_id` (free).

**Creator lookup finished:**

```json
{
  "id": "3b0f2c4e-8d1a-4f6b-9e2c-7a5d1b3c9e0f",
  "event": "satellite.lookup.completed",
  "created_at": "2026-09-08T00:13:59.235Z",
  "data": {
    "run_id": "22222222-3333-4444-5555-666666666666",
    "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
    "user_id": "f70e0123-e5f6-7890-abcd-ef1234567890",
    "status": "success",
    "type": "creator_lookup",
    "platform": "tiktok",
    "started_at": "2026-09-08T00:12:41.018+00:00",
    "completed_at": "2026-09-08T00:13:59.101Z",
    "metrics": { "credits_used": 0, "execution_time_ms": 0 },
    "request": {
      "include_videos": true,
      "max_videos": 50,
      "trend_analysis": false,
      "data_intelligence": false
    },
    "results": {
      "username": "khaby.lame",
      "platform": "tiktok",
      "profile": { ... },
      "stats": { ... },
      "videos": [ ... ]
    },
    "error": null
  }
}
```

- `metrics` is always `0` for now. Your charge is the `X-Credits-Used` header on the call that started the lookup.
- With [Data Intelligence](https://dev.virlo.ai/docs/intelligence), that part can finish after the message. Read the run again until its `intelligence_status` is `ready`.

### Trend list updated

`trends.daily.completed` is the global list, `trends.region.completed` each country's. Each updates up to 3 times daily, around 7am, 1pm and 7pm in that country's time zone (New York for global). Same-day updates share a `trend_group_id`.

**trends.daily.completed:**

```json
{
  "id": "48571bb3-8861-4f11-a992-81282a3045f4",
  "event": "trends.daily.completed",
  "created_at": "2026-09-21T17:09:45.359Z",
  "data": {
    "trend_group_id": "eee23630-ac9c-4b1f-9ed1-9b9d678d2e6c",
    "date": "2026-09-20",
    "region": "global",
    "trends": [
      {
        "trend_id": "3d8e6476-14f3-4ce5-aefa-161e9a03a386",
        "trend_ranking_id": "843bd83d-5cfa-4bca-9b78-9ca3a086cbd1",
        "title": "Back-to-school desk makeovers",
        "description": "Students film before-and-after tours of cheap desk upgrades.",
        "ranking": 1,
        "scrape_status": "success",
        "exemplar_count": 358,
        "velocity": {
          "band": "sustained",
          "count_24h_total": 37,
          "median_views": 31513,
          "source": "trend_scrape_finalize",
          "per_platform": { "tiktok": 27, "youtube": 10, "instagram": 0 }
        },
        "top_exemplars": [
          {
            "video_id": "0b50764e-cc0a-4959-bf95-e5cb404efe41",
            "url": "https://www.tiktok.com/@creator/video/7687529554881039638",
            "platform": "tiktok",
            "views": 8169413,
            "thumbnail_url": "https://auth.virlo.ai/storage/v1/object/public/thumbnails/a2197c4e.jpg",
            "publish_date": "2026-09-20T08:20:37",
            "author": { "username": "creator", "avatar_url": "...", "verified": false }
          }
        ]
      }
    ]
  }
}
```

**Details for developers**

- `date` (string, optional): The previous day, whose videos the trends are based on.
- `trends[].velocity` (object, optional): An early speed reading from matching videos posted in the 24 hours before detection. `band` is `breaking`, `accelerating`, `sustained`, `fading` or `null`. It is not the [momentum](https://dev.virlo.ai/docs/trends#momentum) label.

A failed update sends `status: "failed"`. The next update runs as usual.

**trends.region.completed (failed):**

```json
{
  "id": "c41a9e27-5b3d-4f08-8e6a-2d7f1c9b0a53",
  "event": "trends.region.completed",
  "created_at": "2026-09-21T06:14:08.221Z",
  "data": {
    "run_id": null,
    "region": "gb",
    "status": "failed",
    "completed_at": "2026-09-21T06:14:08.198Z",
    "error": { "code": "trend_analysis_failed", "message": "Trend analysis failed" }
  }
}
```

### Tracking updates

These only come for creators and videos tracked through the [Tracking API](https://dev.virlo.ai/docs/tracking), not the web app. Video messages have `video_url` in place of `platform_handle`.

**`tracking.cycle.completed`**: `snapshot` has the latest numbers, `delta_` fields the change since the last check. Zero or unknown numbers are left out, so treat every field as optional. Creators may also have `subscribers`, `following`, `total_videos`, `posts_analyzed` and `delta_subscribers`. Videos have `title`, `views`, `likes`, `comments`, `shares` and `bookmarks`. Read the check's report with [Get creator report](https://dev.virlo.ai/docs/tracking#get-creator-report) or [Get video report](https://dev.virlo.ai/docs/tracking#get-video-report).

**tracking.cycle.completed:**

```json
{
  "id": "a3f9c2d1-7e4b-4c8a-9f1e-2b6d8c0a4e71",
  "event": "tracking.cycle.completed",
  "created_at": "2026-09-22T14:30:00.412Z",
  "data": {
    "tracking_type": "creator",
    "tracking_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "platform": "tiktok",
    "platform_handle": "fitnessguru",
    "snapshot": {
      "followers": 245000,
      "total_views": 18700000,
      "total_likes": 920000,
      "delta_followers": 1250,
      "delta_views": 340000,
      "delta_likes": 15800
    },
    "new_posts_count": 6,
    "report_available": true,
    "dashboard_url": "https://app.virlo.ai/tracking/creators/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

**`tracking.outlier_video.detected`**: `outlier_ratio` is views divided by the creator's median. `weighted_score` is ln(`outlier_ratio`) × ln(`median_views`), so the same jump scores higher for a bigger creator.

**tracking.outlier_video.detected:**

```json
{
  "id": "e7b1d4a2-9c3f-4e6b-8a0d-5f2c7e9b1a34",
  "event": "tracking.outlier_video.detected",
  "created_at": "2026-09-22T14:30:00.598Z",
  "data": {
    "tracking_type": "creator",
    "tracking_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "platform": "tiktok",
    "platform_handle": "fitnessguru",
    "outlier_videos": [
      {
        "url": "https://tiktok.com/@fitnessguru/video/7400123456",
        "title": "This 30-second trick changed my morning routine",
        "views": 4800000,
        "median_views": 320000,
        "outlier_ratio": 15.0,
        "weighted_score": 34.33,
        "publish_date": "2026-09-18T14:30:00Z"
      }
    ],
    "creator_median_views": 320000,
    "dashboard_url": "https://app.virlo.ai/tracking/creators/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

**`tracking.paused`**: `reason` is `insufficient_credits` or `max_failures_reached`. Add funds or fix the problem, then set `status` to `active` with [Update tracked creator](https://dev.virlo.ai/docs/tracking#update-tracked-creator) or [Update tracked video](https://dev.virlo.ai/docs/tracking#update-tracked-video).

**tracking.paused:**

```json
{
  "id": "0c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
  "event": "tracking.paused",
  "created_at": "2026-09-22T14:31:12.004Z",
  "data": {
    "tracking_type": "creator",
    "tracking_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "platform": "tiktok",
    "platform_handle": "fitnessguru",
    "reason": "insufficient_credits",
    "pause_reason": "insufficient_credits",
    "dashboard_url": "https://app.virlo.ai/tracking/creators/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

### Audience snapshot ready

A snapshot estimates a creator's audience (age, gender, country, city, language), mostly from comments. Request one with [Refresh audience snapshot](https://dev.virlo.ai/docs/tracking#refresh-audience-snapshot) or a [creator lookup](https://dev.virlo.ai/docs/satellite) with audience options. A failed one sends nothing and is refunded. No message? Check `GET /v1/audience/snapshot/:job_id` (free).

**audience.snapshot.completed:**

```json
{
  "id": "f1e2d3c4-b5a6-4978-8a6b-5c4d3e2f1a0b",
  "event": "audience.snapshot.completed",
  "created_at": "2026-09-01T12:04:33.120Z",
  "data": {
    "snapshot_id": "f5a3b2c1-d4e5-4f6a-8b7c-9d0e1f2a3b4c",
    "platform": "tiktok",
    "handle": "khaby.lame",
    "sample_size": 712,
    "cost_usd": 0.31,
    "confidence_per_signal": { "age": 0.72, "gender": 0.84, "country": 0.79, "city": 0.69, "language": 0.81 },
    "confidence_level": "high",
    "data_source": "comments",
    "signal_breakdown": { "comments": 712, "followers": 0 }
  }
}
```

- `confidence_level` (`low`, `medium` or `high`) says how far to trust it.
- `data_source: "profile_only"` means no audience data was found: it's always `low`, and the $0.50 charge is refunded.
- `cost_usd` is Virlo's own AI cost, not yours.

Full breakdowns: [demographics](https://dev.virlo.ai/docs/tracking#get-audience-demographics) and [geography](https://dev.virlo.ai/docs/tracking#get-audience-geography) for a tracked creator, or the lookup result (`GET /v1/satellite/runs/:run_id`) for a creator lookup. `GET /v1/audience/snapshot/:job_id` covers either for 24 hours.

---

## Managing webhooks

The calls below manage your webhooks. Unlike other Virlo calls, their responses are not wrapped in `{ "data": ... }`.

**Webhook object fields**

- `status` (string, optional): `active`, `inactive` (you deleted it or switched it off) or `disabled_by_system` (20 failed attempts in a row).
- `is_active` (boolean, optional): Stays `true` after an automatic shutdown, so check `status`.

### Errors

Errors use the standard [error body](https://dev.virlo.ai/docs/errors#error-shape).

| Status | `code` | When |
| - | - | - |
| `400` | `validation_error` | Bad URL or header, empty `enabled_events`, unknown field, malformed ID, or a key with no team |
| `400` | `unknown_event_type` | Unsupported event name (the message lists every accepted one) |
| `401` | `missing_api_key`, `invalid_api_key` | No API key, or a wrong one |
| `404` | `not_found` | No webhook or delivery with that ID on your team |
| `409` | `conflict` | Your team already has 5 webhooks switched on |

**400:**

```json
{
  "message": "Unknown event type(s): foo.bar. Supported: content_research_agent.run.completed, content_research_agent.event.detected, comet.run.completed, orbit.run.completed, satellite.lookup.completed, trends.daily.completed, trends.region.completed, tracking.cycle.completed, tracking.outlier_video.detected, tracking.paused, audience.snapshot.completed",
  "error": "Bad Request",
  "statusCode": 400,
  "code": "unknown_event_type"
}
```

---

## Create webhook

**Endpoint:** `POST https://api.virlo.ai/v1/webhooks`

Adds a webhook for your team. **Cost:** free.

Your team can have 5 webhooks switched on, counting any Virlo shut down for failing. Only [Delete](https://dev.virlo.ai/docs/webhooks#delete-webhook) or `is_active: false` frees a slot.

### Required

- `url` (string, required): Must start with `https://`, be at most 2048 characters, and point to a public server.
- `enabled_events` (string[], required): Names from [Supported events](https://dev.virlo.ai/docs/webhooks#supported-events), or the two older agent names. Can't be empty.

### Optional

- `headers` (object, optional): Up to 5 headers sent with every message. Use one as a shared secret. Names: letters, numbers and dashes, up to 64 characters. Values: text, up to 256 characters. Reserved in any capitalization: `Authorization`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Host`, `User-Agent`, and anything starting with `Virlo-`.
- `description` (string, optional): A label, up to 256 characters.
- `is_active` (boolean, optional): `false` creates it switched off. Defaults to `true`.

**cURL request:**

```bash
curl -X POST https://api.virlo.ai/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/virlo",
    "description": "Production webhook",
    "enabled_events": [
      "content_research_agent.run.completed"
    ],
    "headers": {
      "X-Webhook-Secret": "your-shared-secret"
    }
  }'
```

**JavaScript request:**

```js
const response = await fetch('https://api.virlo.ai/v1/webhooks', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://hooks.example.com/virlo',
    description: 'Production webhook',
    enabled_events: ['content_research_agent.run.completed'],
    headers: { 'X-Webhook-Secret': 'your-shared-secret' },
  }),
})
const data = await response.json()
```

**Python request:**

```python
import requests

response = requests.post(
    'https://api.virlo.ai/v1/webhooks',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
    json={
        'url': 'https://hooks.example.com/virlo',
        'description': 'Production webhook',
        'enabled_events': ['content_research_agent.run.completed'],
        'headers': {'X-Webhook-Secret': 'your-shared-secret'},
    },
)
data = response.json()
```

**Response 201:**

```json
{
  "id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
  "url": "https://hooks.example.com/virlo",
  "description": "Production webhook",
  "enabled_events": ["content_research_agent.run.completed"],
  "headers": { "X-Webhook-Secret": "your-shared-secret" },
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_by": "f70e0123-e5f6-7890-abcd-ef1234567890",
  "created_at": "2026-09-24T17:31:07.851257+00:00",
  "updated_at": "2026-09-24T17:31:07.851257+00:00"
}
```

**Response 400:**

```json
{
  "message": "header key \"Authorization\" is reserved and cannot be set by the customer",
  "error": "Bad Request",
  "statusCode": 400,
  "code": "validation_error"
}
```

**Response 409:**

```json
{
  "message": "Team already has the maximum of 5 active webhook endpoints. Disable or delete one before creating another.",
  "error": "Conflict",
  "statusCode": 409,
  "code": "conflict"
}
```

---

## List webhooks

**Endpoint:** `GET https://api.virlo.ai/v1/webhooks`

Returns all your team's webhooks, newest first, with no paging. **Cost:** free. Deleted webhooks stay in the list with `status: "inactive"`.

**cURL request:**

```bash
curl https://api.virlo.ai/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**JavaScript request:**

```js
const response = await fetch('https://api.virlo.ai/v1/webhooks', {
  headers: { Authorization: 'Bearer YOUR_API_KEY' },
})
const data = await response.json()
```

**Python request:**

```python
import requests
response = requests.get(
    'https://api.virlo.ai/v1/webhooks',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
)
data = response.json()
```

**Response 200:**

```json
[
  {
    "id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
    "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
    "url": "https://hooks.example.com/virlo",
    "description": "Production webhook",
    "enabled_events": ["content_research_agent.run.completed"],
    "headers": { "X-Webhook-Secret": "your-shared-secret" },
    "is_active": true,
    "status": "active",
    "consecutive_failures": 0,
    "last_success_at": "2026-09-24T18:02:11.204+00:00",
    "last_failure_at": null,
    "created_by": "f70e0123-e5f6-7890-abcd-ef1234567890",
    "created_at": "2026-09-24T17:31:07.851257+00:00",
    "updated_at": "2026-09-24T18:02:11.204+00:00"
  }
]
```

---

## Get webhook

**Endpoint:** `GET https://api.virlo.ai/v1/webhooks/:id`

Returns one webhook. **Cost:** free.

- `id` (uuid, required): The webhook's ID. An unknown ID returns `404`. A malformed one returns `400`.

**cURL request:**

```bash
curl https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 200:**

```json
{
  "id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
  "url": "https://hooks.example.com/virlo",
  "description": "Production webhook",
  "enabled_events": ["content_research_agent.run.completed"],
  "headers": { "X-Webhook-Secret": "your-shared-secret" },
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_by": "f70e0123-e5f6-7890-abcd-ef1234567890",
  "created_at": "2026-09-24T17:31:07.851257+00:00",
  "updated_at": "2026-09-24T17:31:07.851257+00:00"
}
```

**Response 404:**

```json
{
  "message": "Webhook not found",
  "error": "Not Found",
  "statusCode": 404,
  "code": "not_found"
}
```

---

## Update webhook

**Endpoint:** `PATCH https://api.virlo.ai/v1/webhooks/:id`

Changes only the fields you send and returns the full, updated webhook. **Cost:** free.

- `url` (string, optional): A new HTTPS address. Same checks as on create.
- `enabled_events` (string[], optional): Replaces the whole list. Can't be empty.
- `headers` (object, optional): Replaces all headers, so send every one you want to keep. `{}` removes them all. `null` returns `400`.
- `description` (string | null, optional): A new label. `null` clears it.
- `is_active` (boolean, optional): Switch it on or off. This doesn't undo an automatic shutdown: use [Re-enable webhook](https://dev.virlo.ai/docs/webhooks#reenable-webhook).

**cURL request:**

```bash
curl -X PATCH https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled_events": [
      "content_research_agent.run.completed",
      "satellite.lookup.completed"
    ]
  }'
```

**Response 200:**

```json
{
  "id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
  "url": "https://hooks.example.com/virlo",
  "description": "Production webhook",
  "enabled_events": [
    "content_research_agent.run.completed",
    "satellite.lookup.completed"
  ],
  "headers": { "X-Webhook-Secret": "your-shared-secret" },
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_by": "f70e0123-e5f6-7890-abcd-ef1234567890",
  "created_at": "2026-09-24T17:31:07.851257+00:00",
  "updated_at": "2026-09-24T17:45:00.112+00:00"
}
```

---

## Delete webhook

**Endpoint:** `DELETE https://api.virlo.ai/v1/webhooks/:id`

Switches a webhook off and frees one of your 5 slots. **Cost:** free. Nothing is erased: it stays in your list with its delivery log, and no call removes it. Deleting twice returns `204` both times.

**cURL request:**

```bash
curl -X DELETE https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

---

## Re-enable webhook

**Endpoint:** `POST https://api.virlo.ai/v1/webhooks/:id/reenable`

Turns a webhook back on after an [automatic shutdown](https://dev.virlo.ai/docs/webhooks#auto-disable). Fix your server first. **Cost:** free. It sets `status` to `active`, `consecutive_failures` to 0 and `is_active` to `true`, so it also revives a deleted webhook, even past the limit of 5. Failed deliveries aren't resent: use [Retry delivery](https://dev.virlo.ai/docs/webhooks#retry-delivery).

**cURL request:**

```bash
curl -X POST https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890/reenable \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 201:**

```json
{
  "id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
  "url": "https://hooks.example.com/virlo",
  "description": "Production webhook",
  "enabled_events": ["content_research_agent.run.completed"],
  "headers": { "X-Webhook-Secret": "your-shared-secret" },
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": "2026-09-20T09:12:44.018+00:00",
  "last_failure_at": "2026-09-22T06:24:17.698+00:00",
  "created_by": "f70e0123-e5f6-7890-abcd-ef1234567890",
  "created_at": "2026-09-01T17:31:07.851257+00:00",
  "updated_at": "2026-09-24T17:36:10.998+00:00"
}
```

---

## Send test event

**Endpoint:** `POST https://api.virlo.ai/v1/webhooks/:id/test`

Sends a fake message, usually within 2 seconds, so you can check your server replies `2xx`. The result shows in the [delivery log](https://dev.virlo.ai/docs/webhooks#list-deliveries). **Cost:** free.

- `event_type` (string, optional): Send `"content_research_agent.run.completed"`. If you leave it out or send an unknown name, the test is labeled `orbit.run.completed`, which your code may ignore.

The body is the same stub for every event name (`data.test: true`, `data.run_id: "test-run"`). It's sent even if the webhook isn't subscribed to that event. On a switched-off webhook it fails at once, with `last_error` `endpoint_inactive` (you switched it off) or `endpoint_disabled_by_system` (Virlo did).

**cURL request:**

```bash
curl -X POST https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890/test \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "content_research_agent.run.completed"}'
```

**Response 202:**

```json
{
  "delivery_id": "718bdf28-2a09-4813-88cd-71013dfeeab3",
  "event_type": "content_research_agent.run.completed"
}
```

**Test message:**

```json
{
  "id": "718bdf28-2a09-4813-88cd-71013dfeeab3",
  "event": "content_research_agent.run.completed",
  "created_at": "2026-09-24T17:32:28.733Z",
  "data": {
    "run_id": "test-run",
    "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
    "user_id": null,
    "status": "success",
    "started_at": "2026-09-24T17:32:28.733Z",
    "completed_at": "2026-09-24T17:32:28.733Z",
    "metrics": { "credits_used": 0, "execution_time_ms": 0 },
    "results": null,
    "error": null,
    "test": true
  }
}
```

---

## List deliveries

**Endpoint:** `GET https://api.virlo.ai/v1/webhooks/:id/deliveries`

Shows messages sent to one webhook, newest first, with your server's last reply. **Cost:** free.

Each delivery has a `status`:

- `pending`: not sent yet, or waiting for a retry.
- `succeeded`: your server replied `2xx`.
- `failed`: a reply not worth retrying, or the webhook was off.
- `dead`: all 7 attempts failed.
- `payload_too_large`: over 10 MB, never sent.
- `status, event_type` (string, optional): Filter by one value. An unknown value returns an empty list.
- `limit` (integer, optional): 1 to 100, default 50. Out-of-range values are adjusted, not rejected.
- `cursor` (string, optional): The last page's `next_cursor` (such as `2026-09-24T17:32:30.077677+00:00`), URL-encoded. The `+` must become `%2B`, or you get `400`. Stop at `null`. A full page always has a cursor, so the last page can be empty.

**Full field reference**

- `max_attempts` (integer, optional): Shows 3 for tests, but every delivery gets up to 7.
- `last_response_status, last_response_headers, last_response_body_snippet` (various, optional): Your server's reply to the last attempt only. The body is cut at 4,096 characters.
- `last_error` (string | null, optional): `http_` plus the code (such as `http_404`), a network error, `endpoint_inactive`, `endpoint_disabled_by_system`, `endpoint_deleted` or `payload_too_large`.
- `completed_at` (string | null, optional): After a retry, this and `last_error` keep their old values until the new attempt finishes.

**cURL request:**

```bash
curl "https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890/deliveries?status=failed&limit=25" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 200:**

```json
{
  "endpoint_id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
  "deliveries": [
    {
      "id": "5359b43e-9efe-4326-83c8-293281d38e74",
      "endpoint_id": "1a2b3c4d-e5f6-7890-abcd-ef1234567890",
      "team_id": "0d3f1234-e5f6-7890-abcd-ef1234567890",
      "event_type": "content_research_agent.run.completed",
      "source_run_id": "8781db67-9f9a-41ac-8ba2-90c7a7dded3a",
      "source_table": "content_research_agent_runs",
      "payload": {
        "id": "5359b43e-9efe-4326-83c8-293281d38e74",
        "event": "content_research_agent.run.completed",
        "created_at": "2026-09-20T00:06:19.544Z",
        "data": { ... }
      },
      "payload_size_bytes": 17791,
      "status": "failed",
      "attempt_count": 2,
      "max_attempts": 7,
      "next_attempt_at": null,
      "last_attempted_at": "2026-09-20T00:06:50.102+00:00",
      "last_response_status": 404,
      "last_response_headers": { "content-type": "text/plain" },
      "last_response_body_snippet": "Not Found",
      "last_error": "http_404",
      "completed_at": "2026-09-20T00:06:50.102+00:00",
      "created_at": "2026-09-20T00:06:19.606703+00:00"
    }
  ],
  "limit": 25,
  "next_cursor": null
}
```

---

## Retry delivery

**Endpoint:** `POST https://api.virlo.ai/v1/webhooks/deliveries/:deliveryId/retry`

Sends a `failed` or `dead` delivery again. **Cost:** free. It goes back to `pending` with the same `id`, so your duplicate check still works. If the last attempt was under a day ago, the new one can take up to a day. It's one more try, not a new schedule. Any other status, including `payload_too_large`, returns `400`.

**cURL request:**

```bash
curl -X POST https://api.virlo.ai/v1/webhooks/deliveries/5359b43e-9efe-4326-83c8-293281d38e74/retry \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 202:**

```json
{ "queued": true }
```

**Response 400:**

```json
{
  "message": "Only deliveries in 'failed' or 'dead' status can be retried (current: pending)",
  "error": "Bad Request",
  "statusCode": 400,
  "code": "validation_error"
}
```

---

More in How the API works:

- [How results load](https://dev.virlo.ai/docs/async-data.md)
- [Pagination](https://dev.virlo.ai/docs/pagination.md)
- [Rate limits](https://dev.virlo.ai/docs/rate-limits.md)
- [Errors](https://dev.virlo.ai/docs/errors.md)
- [Workflow recipes](https://dev.virlo.ai/docs/recipes.md)
- [API playground](https://dev.virlo.ai/docs/playground.md)
