# Video outlier check

> Check whether one TikTok, YouTube or Instagram video beat its creator's usual views. Start a check for $0.50, then come back for an outlier score, a percentile and a plain label such as viral.

Source: https://dev.virlo.ai/docs/satellite/video-outlier
Markdown: https://dev.virlo.ai/docs/satellite/video-outlier.md
Section: Deep-dive lookups

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

---

Find out whether a video is a real **outlier**, meaning it beat its creator's usual views, or only looks big because the creator is big.

**At a glance**

- **What it does:** Tells you if one TikTok, YouTube or Instagram video is a real breakout for its creator.
- **You send:** The video's link and its platform.
- **You get back:** A job ID right away, then the video's stats, the creator's profile, an outlier score and a verdict such as `viral`.
- **Cost:** $0.50 per check, charged at the start, even if the check fails. Status checks and saved results are free.
- **How long:** Usually 15 to 60 seconds, a few minutes at busy times.

It's one of Virlo's [deep-dive lookups](https://dev.virlo.ai/docs/satellite), under `/v1/satellite/`.

---

## Start a video outlier check

**Endpoint:** `POST https://api.virlo.ai/v1/satellite/video-outlier`

Returns a `job_id` in about a second. Virlo then compares the video with up to 100 of the creator's recent videos.

Cost per request: $0.50

Each request is billed, even for a link you already checked. Creator, sound and hashtag lookups repeat free within 6 hours; this one doesn't.

### Body parameters

Send only these two fields. Anything missing or extra returns a `400`.

- `url` (string, required): The full link, including `https://`. It must match `platform`: Virlo won't catch a mismatch, and still charges.
- `platform` (string, required): `tiktok`, `youtube` or `instagram`, lowercase.

Limits: 5 starts a minute, 100 an hour, 1,000 a day. Status checks don't count.

**Details for developers**

- Limits reset on the clock: each minute, each hour and at midnight UTC.
- Requests rejected with a `400` still count.
- `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` describe the window closest to running out. A `429` adds `Retry-After` (seconds). See [Rate limits](https://dev.virlo.ai/docs/rate-limits).

**cURL request:**

```bash
curl -X POST https://api.virlo.ai/v1/satellite/video-outlier \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.instagram.com/reels/DVMBt9pE4ob/",
    "platform": "instagram"
  }'
```

**JavaScript request:**

```js
const response = await fetch(
  'https://api.virlo.ai/v1/satellite/video-outlier',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      url: 'https://www.instagram.com/reels/DVMBt9pE4ob/',
      platform: 'instagram'
    })
  }
);
const body = await response.json();
const jobId = body.data.job_id; // save this to check the status
```

**Python request:**

```python
import requests

response = requests.post(
    'https://api.virlo.ai/v1/satellite/video-outlier',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
    json={
        'url': 'https://www.instagram.com/reels/DVMBt9pE4ob/',
        'platform': 'instagram'
    }
)
job_id = response.json()['data']['job_id']  # save this to check the status
```

**Response 201 Started:**

```json
{
  "data": {
    "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "processing"
  }
}
```

**Response 400 Bad input:**

```json
{
  "message": [
    "A valid platform is required: youtube, tiktok, or instagram."
  ],
  "error": "Bad Request",
  "statusCode": 400,
  "code": "validation_error"
}
```

**Response 402 Low balance:**

```json
{
  "statusCode": 402,
  "message": "Insufficient balance. $0.50 required, $0.30 remaining. Add funds at https://dev.virlo.ai/dashboard/billing",
  "error": "Payment Required",
  "required_credits": 50,
  "remaining_credits": 30,
  "required_amount": "$0.50",
  "remaining_balance": "$0.30",
  "code": "insufficient_credits"
}
```

**Response 429 Too many:**

```json
{
  "statusCode": 429,
  "code": "rate_limit_exceeded",
  "message": "Rate limit exceeded",
  "error": "Too Many Requests",
  "limit": 5,
  "remaining": 0,
  "reset_at": 1790270580,
  "reset_at_formatted": "September 24th, 2026, 5:23 PM UTC",
  "retry_after": 38,
  "resetAt": 1790270580,
  "resetAtFormatted": "September 24th, 2026, 5:23 PM UTC",
  "retryAfter": 38
}
```

---

## Check the status

**Endpoint:** `GET https://api.virlo.ai/v1/satellite/video-outlier/status/:job_id`

Free. Returns `processing` while the check runs, then `completed` with the result or `failed` with a reason in `error`. After 24 hours the job ID expires (`404`); the [saved run](https://dev.virlo.ai/docs/satellite/video-outlier#durable-runs) keeps the result.

### Path parameters

- `job_id` (string, required): The `job_id` from the start call.

### How to read the result

The result (`data.result`) has three parts: `video`, `creator` and `analysis`. In `analysis`, start with:

- `outlier_score`: views divided by the creator's median views (half their recent videos got more, half fewer). `6.56` means about 6.6 times their usual.
- `performance_label`: the verdict.

| `performance_label` | `outlier_score` |
| - | - |
| `mega_viral` | 10 or more |
| `viral` | 3 to under 10 |
| `above_average` | 1.5 to under 3 |
| `average` | Under 1.5, including flops |

`percentile` is a good second check: `89` means it beat 89% of the creator's recent videos. New videos score low at first; judge them after a few days.

**Full field reference**

**`data`**: `result` and `run_id` (equal to your `job_id`) only when `completed`; `error` only when `failed`.

**`result.video`**: `url`, `title`, `views`, `likes`, `comments` and `publish_date` (UTC, or `null`). Ignore `publishDate`, an old duplicate due for removal.

**`result.creator`**: `username`, `platform` and `profile` (`null` if it couldn't load). `profile` holds `followers`, `following`, `avatar_url`, `url`, `description`, `is_verified`, `total_videos`, `total_likes`, `total_views`, `total_posts` and `category`, plus:

- `bio_link` on TikTok and Instagram, or `attribution` on YouTube (the channel header's link text, such as `X`, not a URL).
- `country`, a two-letter code, on TikTok only.
- On YouTube, `is_verified` is always `null` and `following` always `0`.
- Numbers a platform doesn't share come back `null` or `0`. Instagram often shows `0` for `total_videos` and `total_posts`.

**`result.analysis`**:

- `videos_analyzed`: up to 100, fewer if the creator has fewer videos or the platform returns fewer. At `0` there's no baseline: ignore the scores and label.
- `median_views`, `avg_views`: the creator's median and mean views.
- `weighted_score`: ln(`outlier_score`) × ln(`median_views`), `0` if the video didn't beat the median. It rewards beating a bigger baseline, so use it to rank videos. Not the same as the Virality Score `weighted_score` elsewhere, which uses followers.
- `percentile`: 0 to 100, can have decimals.
- `std_deviation`: how much the creator's views swing around the mean.
- `z_score`: how unusual these views are, given that swing (`0` is the mean, negative is below). It stays low for creators with a few huge hits, even when `outlier_score` is high.

**cURL request:**

```bash
curl https://api.virlo.ai/v1/satellite/video-outlier/status/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**JavaScript request:**

```js
const jobId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://api.virlo.ai/v1/satellite/video-outlier/status/${jobId}`,
  {
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY'
    }
  }
);
const job = (await response.json()).data;
// job.status is 'processing', 'completed' or 'failed'
```

**Python request:**

```python
import requests

job_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'

response = requests.get(
    f'https://api.virlo.ai/v1/satellite/video-outlier/status/{job_id}',
    headers={'Authorization': 'Bearer YOUR_API_KEY'}
)
job = response.json()['data']
# job['status'] is 'processing', 'completed' or 'failed'
```

**Response Completed:**

```json
{
  "data": {
    "status": "completed",
    "result": {
      "video": {
        "url": "https://www.instagram.com/reels/DVMBt9pE4ob/",
        "title": "Japan is turning footsteps into electricity! Using piezoelectric tiles, every step you take generates a small amount of energy...",
        "views": 247862,
        "likes": 27368,
        "comments": 185,
        "publish_date": "2026-02-25T16:40:48.000Z",
        "publishDate": "2026-02-25T16:40:48.000Z"
      },
      "creator": {
        "username": "notivion.ig",
        "platform": "instagram",
        "profile": {
          "followers": 178543,
          "following": 12,
          "avatar_url": "https://instagram.fsac1-2.fna.fbcdn.net/v/t51.82787-19/770751058_n.jpg",
          "url": "https://www.instagram.com/notivion.ig/",
          "description": "Small artist and animator",
          "is_verified": false,
          "total_videos": 0,
          "total_likes": null,
          "total_views": null,
          "total_posts": 0,
          "category": null,
          "bio_link": null
        }
      },
      "analysis": {
        "videos_analyzed": 100,
        "median_views": 37779,
        "avg_views": 131447,
        "outlier_score": 6.56,
        "weighted_score": 19.83,
        "percentile": 89,
        "std_deviation": 319747,
        "z_score": 0.36,
        "performance_label": "viral"
      }
    },
    "run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

**Response Failed:**

```json
{
  "data": {
    "status": "failed",
    "error": "Could not resolve Instagram author from video URL."
  }
}
```

**Response 404 Not found:**

```json
{
  "message": "Video outlier job not found or expired",
  "error": "Not Found",
  "statusCode": 404,
  "code": "not_found"
}
```

---

## Wait for the result

Check the status every 10 to 15 seconds, as below. Still `processing` after 10 minutes (rare, such as after a server restart)? Start a new check, at another $0.50.

Or have Virlo notify your server when a check finishes or fails: use the `satellite.lookup.completed` [webhook](https://dev.virlo.ai/docs/webhooks) (`data.type` is `video_outlier`).

**JavaScript:**

```js
const API = 'https://api.virlo.ai/v1';
const headers = {
  'Authorization': 'Bearer YOUR_API_KEY',
  'Content-Type': 'application/json'
};

async function analyzeVideo(url, platform) {
  // 1. Start the check ($0.50)
  const start = await fetch(`${API}/satellite/video-outlier`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ url, platform })
  });
  if (!start.ok) throw new Error(`Start failed: ${start.status}`);
  const jobId = (await start.json()).data.job_id;

  // 2. Check every 10 seconds until it finishes. Give up after 10 minutes.
  const deadline = Date.now() + 10 * 60 * 1000;
  while (Date.now() < deadline) {
    await new Promise((resolve) => setTimeout(resolve, 10_000));

    const res = await fetch(`${API}/satellite/video-outlier/status/${jobId}`, { headers });
    if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
    const job = (await res.json()).data;

    if (job.status === 'completed') return job.result;
    if (job.status === 'failed') throw new Error(job.error ?? 'Check failed');
  }
  throw new Error(`Still processing after 10 minutes (job ${jobId})`);
}
```

**Python:**

```python
import time
import requests

API = 'https://api.virlo.ai/v1'
HEADERS = {'Authorization': 'Bearer YOUR_API_KEY'}

def analyze_video(url, platform):
    # 1. Start the check ($0.50)
    start = requests.post(
        f'{API}/satellite/video-outlier',
        headers=HEADERS,
        json={'url': url, 'platform': platform}
    )
    start.raise_for_status()
    job_id = start.json()['data']['job_id']

    # 2. Check every 10 seconds until it finishes. Give up after 10 minutes.
    deadline = time.time() + 10 * 60
    while time.time() < deadline:
        time.sleep(10)
        res = requests.get(
            f'{API}/satellite/video-outlier/status/{job_id}',
            headers=HEADERS
        )
        res.raise_for_status()
        job = res.json()['data']

        if job['status'] == 'completed':
            return job['result']
        if job['status'] == 'failed':
            raise RuntimeError(job.get('error') or 'Check failed')
    raise TimeoutError(f'Still processing after 10 minutes (job {job_id})')
```

---

## Re-read past runs

Every check, failed ones too, is saved as a **run** that is free to read and never expires. Its ID is your `job_id`.

- `GET /v1/satellite/runs?type=video_outlier`: your checks, newest first.
- `GET /v1/satellite/runs/:run_id`: one check, with the verdict at `data.result.analysis`. Its top-level `analysis` is always `null` here.

The creator's comparison videos aren't saved, so `/runs/:run_id/videos` returns an empty list. See [all run fields](https://dev.virlo.ai/docs/satellite#durable-runs).

---

## Errors

Error responses aren't wrapped in `data` and are never charged. Check the top-level `code`, not the message. See [Errors](https://dev.virlo.ai/docs/errors).

A `failed` check is different: it started, so it's charged. For example, the video is deleted or private, or its creator can't be found. Open the link in a logged-out browser first.

- `400 Bad Request`: Bad `url` or `platform`, or an extra field. `message` is usually a list, sometimes a string. If it isn't about your input (like `Failed to analyze video`), the problem is on Virlo's side: retry shortly.
- `401 Unauthorized`: No API key (`missing_api_key`), or a wrong or inactive one (`invalid_api_key`).
- `402 Payment Required`: Balance under $0.50 (50 credits, at $0.01 each). [Add funds](https://dev.virlo.ai/dashboard/billing).
- `404 Not Found`: The job ID is unknown, another account's, or over 24 hours old. Read the [saved run](https://dev.virlo.ai/docs/satellite/video-outlier#durable-runs).
- `429 Too Many Requests`: Wait `retry_after` seconds, then retry.

---

More in Deep-dive lookups:

- [Creator lookup](https://dev.virlo.ai/docs/satellite.md)
- [Creator lookup add-ons](https://dev.virlo.ai/docs/satellite/creators.md)
- [Sound lookup](https://dev.virlo.ai/docs/satellite/sounds.md)
- [Hashtag lookup](https://dev.virlo.ai/docs/satellite/hashtags.md)
