Webhooks

Webhooks let your app react the moment a scrape run finishes or the daily trend analysis completes. You configure one or more HTTPS endpoints per team from the Integrations page in your dashboard (or via the API endpoints below). Every subscribed event is POSTed to your URL with a consistent JSON envelope.


Now, whenever something of interest happens in Virlo — like a hashtag going viral or a trend being detected — a webhook is fired off to your URL. In the next section, we'll look at how to consume webhooks.

Webhooks fire automatically in eight situations:

  • Whenever a Comet (custom niche) run terminates — success, partial failure, or failure.
  • Whenever an Orbit (keyword search) run terminates.
  • Whenever a Satellite lookup completes.
  • Once per day when the trend analysis cron finishes.
  • When a tracked creator or video completes a scrape cycle (metrics + AI report).
  • When a tracked creator publishes an outlier video (views significantly above their median).
  • When tracking is paused due to repeated scrape failures.
  • When an audience snapshot (demographics + geography) finishes generating for a tracked creator.

You typically configure webhooks from the Integrations tab in the dashboard, but everything is also available via the /v1/webhooks API described below. Webhook endpoints are team-scoped: every member of the team sees and manages the same set, and events fire for every run that belongs to the team.


Supported events

EventWhen it firesScope
comet.run.completedA Comet (custom niche) scrape terminatesTeam
orbit.run.completedAn Orbit (keyword search) run terminatesTeam
satellite.lookup.completedA creator lookup, sound lookup, or video outlier analysis terminates. Payload includes a type discriminator (creator_lookup | sound_lookup | video_outlier | batch_creator) for routing.Team
trends.daily.completedThe daily trend analysis cron finishes (~01:00 UTC)Broadcast
tracking.cycle.completedA creator or video tracking cycle completes (new snapshot + AI report ready)Team
tracking.outlier_video.detectedA tracked creator publishes a video that significantly outperforms their median viewsTeam
tracking.pausedTracking is auto-paused due to repeated scrape failuresTeam
audience.snapshot.completedAn audience snapshot (demographics + geography) finishes generating for a tracked creatorTeam

The outcome — success, partial_failure, or failed — lives inside the payload under data.status. We do not split successes and failures into separate event types; subscribe once per feature and branch on data.status in your handler.


Envelope

Every delivery uses the same envelope:

{
  "id": "evt_01HXYZ...",
  "event": "orbit.run.completed",
  "created_at": "2026-04-22T14:30:00Z",
  "data": { ... }
}

Request headers we send

Content-Type:      application/json
Content-Encoding:  gzip                     # if body > 1KB
User-Agent:        Virlo-Webhooks/1.0
Virlo-Event-Id:    <delivery uuid>
Virlo-Event-Type:  orbit.run.completed
<any custom headers you configured>

Data shape

The data block is designed to contain everything you'd get by calling the entity's GET endpoints. For run events it includes:

  • run_id, team_id, user_id, status, started_at, completed_at at the top level
  • The entity config (e.g. orbit, comet) nested under its own key
  • A metrics object with counts, credits used, execution time, platform breakdown. When the run used both intent and data_intelligence_enabled: true, metrics also includes intent_intelligence_matched (number of videos matching the intent) and intent_intelligence_total (total videos evaluated against the intent)
  • An intent_summary object with matched, total_evaluated, not_evaluated, and matched_urls — populated when the run used both intent and data_intelligence_enabled: true; null otherwise
  • An analysis object with the full AI analysis and detected trends — populated when analysis succeeds; null when it fails or hasn't completed yet
  • An error object with code and message — populated on failed/partial; null on success

Example orbit.run.completed

{
  "id": "evt_01HXYZ...",
  "event": "orbit.run.completed",
  "created_at": "2026-04-22T14:30:00Z",
  "data": {
    "run_id": "a8c2b...",
    "team_id": "0d3f1...",
    "user_id": "f70e0...",
    "status": "success",
    "started_at": "2026-04-22T14:29:12Z",
    "completed_at": "2026-04-22T14:30:00Z",
    "metrics": {
      "credits_used": 12,
      "execution_time_ms": 48219,
      "videos_found": 342,
      "platforms": { "youtube": 120, "tiktok": 180, "instagram": 42 },
      "intent_intelligence_matched": 198,
      "intent_intelligence_total": 337
    },
    "orbit": {
      "id": "a8c2b...",
      "keywords": ["ai video", "faceless ai"],
      "platforms": ["youtube", "tiktok", "instagram"],
      "intent": "content-format",
      "trend_group_id": null
    },
    "intent_summary": {
      "matched": 198,
      "total_evaluated": 337,
      "not_evaluated": 5,
      "matched_urls": ["https://www.tiktok.com/@creator/video/123", "https://youtube.com/watch?v=abc"]
    },
    "analysis": {
      "id": "d4e5f678-...",
      "status": "completed",
      "analysis_data": {
        "key_highlight": "POV-style rants dominate AI video content with 3x higher save rates",
        "themes": [
          {
            "stable_key": "pov-first-person-rant",
            "name": "POV First-Person Rants",
            "why_it_works": "Direct eye-contact delivery creates parasocial intimacy that drives saves.",
            "video_count": 12,
            "evidence_video_ids": ["uuid-1", "uuid-2", "uuid-3"],
            "tactics": ["open with strong emotional claim", "handheld camera"],
            "confidence": 0.91
          }
        ],
        "viral_tactics": ["Hook in first 2 seconds", "Text overlay with bold claim"],
        "timing_analysis": { "peak_hours": [9, 12, 18], "pattern": "Lunch breaks and commute hours" }
      },
      "trends": [
        {
          "stable_key": "pov-first-person-rant",
          "name": "POV First-Person Rants",
          "video_count": 12,
          "total_views": 8400000,
          "confidence": 0.91,
          "status": "rising",
          "evidence_video_ids": ["uuid-1", "uuid-2", "uuid-3"]
        }
      ],
      "video_count": 85,
      "cost_usd": 0.0342
    },
    "error": null
  }
}

satellite.lookup.completed payload

This event covers every Satellite job: creator lookups, sound lookups, video outlier analyses, and batch creator lookups. The shape is discriminated by data.type:

data.typeSource
creator_lookupGET /v1/satellite/creator/:platform/:username
sound_lookupGET /v1/satellite/sounds/:platform/:music_id
video_outlierPOST /v1/satellite/video-outlier
batch_creatorPOST /v1/satellite/creators/batch (one event per creator)

All payloads include the durable run_id — you can re-read the full persisted result indefinitely via GET /v1/satellite/runs/:run_id. The 24-hour Redis status cache is just a fast path; the run row is the source of truth.

Example satellite.lookup.completed (sound_lookup)

{
  "id": "evt_01HXYZ...",
  "event": "satellite.lookup.completed",
  "created_at": "2026-05-26T17:32:11Z",
  "data": {
    "type": "sound_lookup",
    "run_id": "11111111-2222-3333-4444-555555555555",
    "team_id": "0d3f1...",
    "user_id": "f70e0...",
    "status": "success",
    "platform": "tiktok",
    "subject": "7570679470014532374",
    "started_at": "2026-05-26T17:30:48Z",
    "completed_at": "2026-05-26T17:32:11Z",
    "credits_used": 100,
    "request": {
      "music_id": "7570679470014532374",
      "platform": "tiktok",
      "trend_analysis": true,
      "max_videos": 300
    },
    "metrics": {
      "videos_analyzed": 287,
      "pages_fetched": 14,
      "truncated_by_cap": false,
      "trends_detected": 3
    },
    "result_url": "https://api.virlo.ai/v1/satellite/runs/11111111-2222-3333-4444-555555555555",
    "error": null
  }
}

The full result body (sound metadata, videos, stats, trends) is not inlined in the webhook payload to keep deliveries small — fetch it from result_url (free, durable). The metrics block on sound_lookup events carries enough top-line counts (videos_analyzed, pages_fetched, truncated_by_cap, trends_detected) to route, gate, or surface in dashboards without an extra round-trip.

Example satellite.lookup.completed (creator_lookup)

{
  "id": "evt_01HXYZ...",
  "event": "satellite.lookup.completed",
  "created_at": "2026-05-26T17:32:11Z",
  "data": {
    "type": "creator_lookup",
    "run_id": "22222222-3333-4444-5555-666666666666",
    "team_id": "0d3f1...",
    "user_id": "f70e0...",
    "status": "success",
    "platform": "tiktok",
    "subject": "khaby.lame",
    "started_at": "2026-05-26T17:30:48Z",
    "completed_at": "2026-05-26T17:31:42Z",
    "credits_used": 50,
    "result_url": "https://api.virlo.ai/v1/satellite/runs/22222222-3333-4444-5555-666666666666",
    "error": null
  }
}

When the creator lookup was started with trend_analysis=true, the same event fires after the trend pipeline finishes (still inside the single creator-lookup job — there is no separate trend webhook event). The payload only differs in credits_used (100 = base + trend surcharge); the persisted result behind result_url carries the full trends block. Fetch it via GET /v1/satellite/runs/:run_id — free.


trends.daily.completed is a broadcast — no team/user identity in the payload:

Example trends.daily.completed

{
  "id": "evt_01HXYZ...",
  "event": "trends.daily.completed",
  "created_at": "2026-04-22T01:04:00Z",
  "data": {
    "run_id": "b2d91...",
    "status": "success",
    "completed_at": "2026-04-22T01:04:00Z",
    "date": "2026-04-21",
    "metrics": {
      "trends_detected": 24,
      "videos_analyzed": 500,
      "platforms_covered": ["youtube", "tiktok", "instagram"]
    },
    "trend_group_id": "b2d91..."
  }
}

Example tracking.cycle.completed

{
  "id": "evt_01HXYZ...",
  "event": "tracking.cycle.completed",
  "created_at": "2026-04-22T14:30:00Z",
  "data": {
    "tracking_type": "creator",
    "tracking_id": "a1b2c3d4-...",
    "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-..."
  }
}

Example tracking.outlier_video.detected

{
  "id": "evt_01HXYZ...",
  "event": "tracking.outlier_video.detected",
  "created_at": "2026-04-22T14:30:00Z",
  "data": {
    "tracking_type": "creator",
    "tracking_id": "a1b2c3d4-...",
    "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.3,
        "publish_date": "2026-04-18T14:30:00Z"
      }
    ],
    "creator_median_views": 320000,
    "dashboard_url": "https://app.virlo.ai/tracking/creators/a1b2c3d4-..."
  }
}

Example audience.snapshot.completed

{
  "id": "evt_01HXYZ...",
  "event": "audience.snapshot.completed",
  "created_at": "2026-05-01T12:04:33Z",
  "data": {
    "snapshot_id": "f5a3b2c1-...",
    "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 }
  }
}

The payload is deliberately compact — confidence_level (low / medium / high) and data_source (comments | mixed | followers | comments_extended | profile_only) let you decide at-a-glance whether to trust the snapshot, and signal_breakdown shows how the sample was assembled when a fallback fired. When data_source is profile_only the snapshot was synthesized from the creator's own declared profile (last-resort fallback when no audience signal could be harvested) — confidence_level will always be low in that case, and the customer was not charged for the snapshot. Fetch the full distributions (with age_distribution, gender_distribution, country_distribution, city_distribution, language_distribution, evidence_summary) via GET /v1/tracking/creators/:id/audience-demographics and /audience-geography once you receive the event.


Responding to a delivery

Return a 2xx status code as quickly as you can. We read the status code only — your response body is stored for audit (truncated at 4 KB) but not parsed.

If your handler returns 2xx, the delivery is marked succeeded. Anything else triggers the retry ladder.


Retry ladder

Failures go through an explicit schedule. The delay is the wait before the next attempt:

AttemptWait
1— (initial send)
230 s
32 min
410 min
51 h
66 h
724 h
delivery is marked dead

We retry on 408, 429, 5xx, and network/timeout/DNS errors. Other 4xx codes are treated as customer misconfiguration — we try once more, then mark the delivery failed.

After 20 consecutive failures across deliveries, an endpoint is auto-disabled. Re-enable it from the Integrations page or via the Re-enable webhook endpoint after fixing the problem.


Authentication & security

  • HTTPS only. We reject HTTP URLs and any hostname that resolves to a private/loopback/link-local/ULA IP range at configuration time.
  • Custom headers. Add up to 5 headers; they're sent verbatim on every delivery. Good for Authorization: Bearer …, tenant tags, or internal routing tokens.
  • Idempotency. Every delivery carries a unique id. Dedupe on your side if you receive the same id twice (rare but possible).
  • Ordering. Not guaranteed. Order by created_at or by the run's started_at / completed_at if you need sequencing.

Payload size

Delivery bodies are gzip-compressed when larger than 1 KB. We cap compressed payloads at 10 MB. When a run's results exceed that cap we mark the delivery payload_too_large and skip the send — you can still fetch full results via the GET endpoints.


POST/v1/webhooks

Create webhook

Registers a new webhook endpoint for your team. The endpoint URL must use HTTPS and resolve to a public IP.

Request body

  • Name
    url
    Type
    string
    Required
    *
    Description

    HTTPS destination URL for deliveries. Max 2048 characters.

  • Name
    enabled_events
    Type
    string[]
    Required
    *
    Description

    Array of event types to subscribe to. Must be a subset of: comet.run.completed, orbit.run.completed, satellite.lookup.completed, trends.daily.completed, tracking.cycle.completed, tracking.outlier_video.detected, tracking.paused, audience.snapshot.completed. Cannot be empty.

  • Name
    description
    Type
    string
    Description

    Human-readable description for dashboard display. Max 256 characters.

  • Name
    headers
    Type
    object
    Description

    Map of custom HTTP headers included on every delivery. Up to 5 entries. Keys must be ASCII alphanumeric + dash (max 64 chars). Values max 256 chars. Reserved header names (Content-Type, Content-Encoding, User-Agent, any Virlo-*) are rejected.

  • Name
    is_active
    Type
    boolean
    Description

    Whether the endpoint is enabled. Defaults to true.

Request

POST
/v1/webhooks
curl -X POST https://api.virlo.ai/v1/webhooks \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/virlo",
    "description": "Production webhook",
    "enabled_events": [
      "comet.run.completed",
      "orbit.run.completed"
    ],
    "headers": {
      "Authorization": "Bearer my-internal-token"
    }
  }'

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": ["comet.run.completed", "orbit.run.completed"],
  "headers": {"Authorization": "Bearer my-internal-token"},
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_by": "f70e0123-...",
  "created_at": "2026-04-22T14:30:00Z",
  "updated_at": "2026-04-22T14:30:00Z"
}

GET/v1/webhooks

List webhooks

Returns every webhook endpoint belonging to your team, newest first.

Request

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

Response

[
  {
    "id": "1a2b3c4d-...",
    "team_id": "0d3f1...",
    "url": "https://hooks.example.com/virlo",
    "enabled_events": ["comet.run.completed"],
    "is_active": true,
    "status": "active",
    "consecutive_failures": 0,
    "last_success_at": "2026-04-22T14:30:00Z",
    "last_failure_at": null,
    "created_at": "2026-04-22T14:30:00Z"
  }
]

GET/v1/webhooks/:id

Get webhook

Retrieves a single webhook endpoint by id.

Path parameters

  • Name
    id
    Type
    uuid
    Required
    *
    Description

    Webhook endpoint identifier.

Request

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

Response

{
  "id": "1a2b3c4d-...",
  "team_id": "0d3f1...",
  "url": "https://hooks.example.com/virlo",
  "enabled_events": ["comet.run.completed"],
  "headers": {},
  "is_active": true,
  "status": "active",
  "consecutive_failures": 0,
  "last_success_at": null,
  "last_failure_at": null,
  "created_at": "2026-04-22T14:30:00Z",
  "updated_at": "2026-04-22T14:30:00Z"
}

PATCH/v1/webhooks/:id

Update webhook

Updates one or more fields on an existing endpoint. Send only the fields you want to change.

Request body

  • Name
    url
    Type
    string
    Description

    New HTTPS URL. Must pass SSRF validation.

  • Name
    description
    Type
    string
    Description

    New description.

  • Name
    enabled_events
    Type
    string[]
    Description

    Replaces the full list of subscribed event types.

  • Name
    headers
    Type
    object
    Description

    Replaces the full custom-headers map.

  • Name
    is_active
    Type
    boolean
    Description

    Toggle the endpoint on or off.

Request

PATCH
/v1/webhooks/:id
curl -X PATCH https://api.virlo.ai/v1/webhooks/1a2b3c4d-... \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled_events": [
      "comet.run.completed",
      "orbit.run.completed",
      "satellite.lookup.completed"
    ],
    "is_active": true
  }'

Response

{
  "id": "1a2b3c4d-...",
  "enabled_events": [
    "comet.run.completed",
    "orbit.run.completed",
    "satellite.lookup.completed"
  ],
  "is_active": true,
  "updated_at": "2026-04-22T14:45:00Z"
}

DELETE/v1/webhooks/:id

Delete webhook

Soft-deletes an endpoint by setting is_active to false. The row and its delivery history are preserved for audit. To re-enable, update is_active back to true.

Returns 204 No Content.

Request

DELETE
/v1/webhooks/:id
curl -X DELETE https://api.virlo.ai/v1/webhooks/1a2b3c4d-... \
  -H "Authorization: Bearer {token}"

POST/v1/webhooks/:id/reenable

Re-enable webhook

Clears the disabled_by_system state and resets the consecutive-failure counter. Use this after fixing whatever was causing sustained delivery failures (e.g. you deployed a fix to your handler).

Requires no request body.

Request

POST
/v1/webhooks/:id/reenable
curl -X POST https://api.virlo.ai/v1/webhooks/1a2b3c4d-.../reenable \
  -H "Authorization: Bearer {token}"

Response

{
  "id": "1a2b3c4d-...",
  "status": "active",
  "consecutive_failures": 0,
  "is_active": true,
  "updated_at": "2026-04-22T14:50:00Z"
}

POST/v1/webhooks/:id/test

Send test event

Queues a synthetic event through the normal delivery worker so you can verify the round-trip, inspect the body shape, and confirm your endpoint handles it. The test payload has data.test: true so it can't be confused with a real run.

Request body

  • Name
    event_type
    Type
    string
    Description

    Which event type to simulate. Defaults to orbit.run.completed. Must be one of the supported events.

Returns 202 Accepted with the delivery id. Check the delivery log to see whether your endpoint accepted it.

Request

POST
/v1/webhooks/:id/test
curl -X POST https://api.virlo.ai/v1/webhooks/1a2b3c4d-.../test \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "orbit.run.completed"}'

Response

{
  "delivery_id": "c7d2e3f4-...",
  "event_type": "orbit.run.completed"
}

GET/v1/webhooks/:id/deliveries

List deliveries

Returns recent delivery attempts for an endpoint, newest first. Use to audit successes, investigate failures, and drive a dashboard view of your webhook health.

Query parameters

  • Name
    status
    Type
    string
    Description

    Filter by status: pending, succeeded, failed, dead, or payload_too_large.

  • Name
    event_type
    Type
    string
    Description

    Filter by event type.

  • Name
    limit
    Type
    integer
    Description

    Page size. Min 1, max 100, default 50.

  • Name
    cursor
    Type
    string
    Description

    Pass the next_cursor from the previous response to paginate older deliveries.

Request

GET
/v1/webhooks/:id/deliveries
curl "https://api.virlo.ai/v1/webhooks/1a2b3c4d-.../deliveries?status=failed&limit=25" \
  -H "Authorization: Bearer {token}"

Response

{
  "endpoint_id": "1a2b3c4d-...",
  "limit": 25,
  "deliveries": [
    {
      "id": "c7d2e3f4-...",
      "endpoint_id": "1a2b3c4d-...",
      "event_type": "orbit.run.completed",
      "source_run_id": "a8c2b...",
      "source_table": "scraper_runs",
      "status": "failed",
      "attempt_count": 2,
      "max_attempts": 7,
      "last_attempted_at": "2026-04-22T14:35:00Z",
      "last_response_status": 500,
      "last_error": "http_500",
      "next_attempt_at": null,
      "created_at": "2026-04-22T14:30:00Z"
    }
  ],
  "next_cursor": "2026-04-21T09:12:00Z"
}

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

Retry delivery

Re-queues a failed or dead delivery for immediate redelivery. The row's next_attempt_at is reset to now and the status flips back to pending. The retry uses the same payload as the original attempt.

Returns 202 Accepted.

Only deliveries in failed or dead status are eligible — other statuses return 400 Bad Request.

Request

POST
/v1/webhooks/deliveries/:deliveryId/retry
curl -X POST https://api.virlo.ai/v1/webhooks/deliveries/c7d2e3f4-.../retry \
  -H "Authorization: Bearer {token}"

Response

{ "queued": true }

Was this page helpful?