Webhooks

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 for any event, or the Integrations page 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 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

EventFires when
content_research_agent.run.completedA Content Research Agent run finishes or fails.
content_research_agent.event.detectedA recurring agent spots a breaking story in its topic.
satellite.lookup.completedA creator, sound, hashtag or video outlier lookup finishes or fails.
trends.daily.completedThe global trend list updates (up to 3 times a day, despite the name).
trends.region.completedA country trend list updates. One message per country.
tracking.cycle.completedA tracked creator or video gets its scheduled check.
tracking.outlier_video.detectedA tracked creator posts a video with over 3 times their median views.
tracking.pausedVirlo pauses tracking: 3 failed checks in a row, or your balance can't cover a check.
audience.snapshot.completedAn 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.


What we send

Every message has the same outer layer. data depends on the event.

{
  "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 ids.

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

Webhook receiver

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)

Retries

The planned schedule, counting from the previous attempt:

AttemptWhen
1Right away
230 seconds later
32 minutes later
410 minutes later
51 hour later
66 hours later
724 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.

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:

  • 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:

OutcomestatusAgent settings are underAlso included
It workedsuccessorbit (runs once) or comet (recurring)is_recurring, analysis
It failedfailedcontent_research_agent, with is_recurring insideintent_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).

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

Agent run finished

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

{
  "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" }
  }
}
  • Name
    status
    Type
    string
    Description

    A run where some keywords or platforms failed still arrives as success. GET /v1/agents/:id/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.

  • Name
    metrics
    Type
    object
    Description

    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.

  • Name
    orbit, comet, content_research_agent
    Type
    object
    Description

    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.

  • Name
    intent_summary
    Type
    object | null
    Description

    Failed runs only: { matched, total_evaluated }, or null.

  • Name
    analysis
    Type
    object | null
    Description

    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 lists them too.

content_research_agent.event.detected

{
  "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 add them; agents with autopilot off leave a proposal 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

{
  "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, 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

{
  "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
  • Name
    date
    Type
    string
    Description

    The previous day, whose videos the trends are based on.

  • Name
    trends[].velocity
    Type
    object
    Description

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

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

trends.region.completed (failed)

{
  "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, 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 or Get video report.

tracking.cycle.completed

{
  "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

{
  "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 or Update tracked video.

tracking.paused

{
  "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 or a creator lookup with audience options. A failed one sends nothing and is refunded. No message? Check GET /v1/audience/snapshot/:job_id (free).

audience.snapshot.completed

{
  "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 and 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
  • Name
    status
    Type
    string
    Description

    active, inactive (you deleted it or switched it off) or disabled_by_system (20 failed attempts in a row).

  • Name
    is_active
    Type
    boolean
    Description

    Stays true after an automatic shutdown, so check status.

Errors

Errors use the standard error body.

StatuscodeWhen
400validation_errorBad URL or header, empty enabled_events, unknown field, malformed ID, or a key with no team
400unknown_event_typeUnsupported event name (the message lists every accepted one)
401missing_api_key, invalid_api_keyNo API key, or a wrong one
404not_foundNo webhook or delivery with that ID on your team
409conflictYour team already has 5 webhooks switched on

400

{
  "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"
}

POST/v1/webhooks

Create webhook

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 or is_active: false frees a slot.

Required

  • Name
    url
    Type
    string
    Required
    *
    Description

    Must start with https://, be at most 2048 characters, and point to a public server.

  • Name
    enabled_events
    Type
    string[]
    Required
    *
    Description

    Names from Supported events, or the two older agent names. Can't be empty.

Optional

  • Name
    headers
    Type
    object
    Description

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

  • Name
    description
    Type
    string
    Description

    A label, up to 256 characters.

  • Name
    is_active
    Type
    boolean
    Description

    false creates it switched off. Defaults to true.

Request

POST
/v1/webhooks
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"
    }
  }'

Response

{
  "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"
}

GET/v1/webhooks

List webhooks

Returns all your team's webhooks, newest first, with no paging. Cost: free.

Deleted webhooks stay in the list with status: "inactive".

Request

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

Response

[
  {
    "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/v1/webhooks/:id

Get webhook

Returns one webhook. Cost: free.

  • Name
    id
    Type
    uuid
    Required
    *
    Description

    The webhook's ID. An unknown ID returns 404. A malformed one returns 400.

Request

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

Response

{
  "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"
}

PATCH/v1/webhooks/:id

Update webhook

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

  • Name
    url
    Type
    string
    Description

    A new HTTPS address. Same checks as on create.

  • Name
    enabled_events
    Type
    string[]
    Description

    Replaces the whole list. Can't be empty.

  • Name
    headers
    Type
    object
    Description

    Replaces all headers, so send every one you want to keep. {} removes them all. null returns 400.

  • Name
    description
    Type
    string | null
    Description

    A new label. null clears it.

  • Name
    is_active
    Type
    boolean
    Description

    Switch it on or off. This doesn't undo an automatic shutdown: use Re-enable webhook.

Request

PATCH
/v1/webhooks/:id
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

{
  "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/v1/webhooks/:id

Delete webhook

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.

Request

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

POST/v1/webhooks/:id/reenable

Re-enable webhook

Turns a webhook back on after an automatic shutdown. 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.

Request

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

Response

{
  "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"
}

POST/v1/webhooks/:id/test

Send test event

Sends a fake message, usually within 2 seconds, so you can check your server replies 2xx. The result shows in the delivery log. Cost: free.

  • Name
    event_type
    Type
    string
    Description

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

Request

POST
/v1/webhooks/:id/test
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

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

Test message

{
  "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
  }
}

GET/v1/webhooks/:id/deliveries

List 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.
  • Name
    status, event_type
    Type
    string
    Description

    Filter by one value. An unknown value returns an empty list.

  • Name
    limit
    Type
    integer
    Description

    1 to 100, default 50. Out-of-range values are adjusted, not rejected.

  • Name
    cursor
    Type
    string
    Description

    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
  • Name
    max_attempts
    Type
    integer
    Description

    Shows 3 for tests, but every delivery gets up to 7.

  • Name
    last_response_status, last_response_headers, last_response_body_snippet
    Type
    various
    Description

    Your server's reply to the last attempt only. The body is cut at 4,096 characters.

  • Name
    last_error
    Type
    string | null
    Description

    http_ plus the code (such as http_404), a network error, endpoint_inactive, endpoint_disabled_by_system, endpoint_deleted or payload_too_large.

  • Name
    completed_at
    Type
    string | null
    Description

    After a retry, this and last_error keep their old values until the new attempt finishes.

Request

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

Response

{
  "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
}

POST/v1/webhooks/deliveries/:deliveryId/retry

Retry delivery

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.

Request

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

Response

{ "queued": true }

Was this page helpful?