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.
Each team can have up to 5 active endpoints. Deliveries are gzip-compressed when larger than 1 KB and capped at 10 MB compressed. Deliveries are retried on transient failures and every attempt is recorded in the delivery log.
Looking for "when is my analysis / audience / report ready?" Every async response now includes a top-level finalized boolean and a pending_jobs[] array that names exactly which webhook to subscribe to for each piece of secondary data. See the Async Data Model page — it's the canonical reference for the polling-vs-webhook decision and the per-resource event mapping.
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
| Event | When it fires | Scope |
|---|---|---|
comet.run.completed | A Comet (custom niche) scrape terminates | Team |
orbit.run.completed | An Orbit (keyword search) run terminates | Team |
satellite.lookup.completed | A 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.completed | The daily trend analysis cron finishes (~01:00 UTC) | Broadcast |
tracking.cycle.completed | A creator or video tracking cycle completes (new snapshot + AI report ready) | Team |
tracking.outlier_video.detected | A tracked creator publishes a video that significantly outperforms their median views | Team |
tracking.paused | Tracking is auto-paused due to repeated scrape failures | Team |
audience.snapshot.completed | An audience snapshot (demographics + geography) finishes generating for a tracked creator | Team |
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_atat the top level- The entity config (e.g.
orbit,comet) nested under its own key - A
metricsobject with counts, credits used, execution time, platform breakdown. When the run used bothintentanddata_intelligence_enabled: true, metrics also includesintent_intelligence_matched(number of videos matching the intent) andintent_intelligence_total(total videos evaluated against the intent) - An
intent_summaryobject withmatched,total_evaluated,not_evaluated, andmatched_urls— populated when the run used bothintentanddata_intelligence_enabled: true;nullotherwise - An
analysisobject with the full AI analysis and detected trends — populated when analysis succeeds;nullwhen it fails or hasn't completed yet - An
errorobject withcodeandmessage— populated on failed/partial;nullon success
The analysis field is included in comet.run.completed and orbit.run.completed payloads. When the AI analysis completes successfully, it contains the full analysis_data (insights, themes, tactics, timing) and trends (structured trend objects with evidence video IDs, aggregates, and stable keys for cross-cycle tracking). If analysis fails, analysis is null — you can still access video data through the regular API endpoints.
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.type | Source |
|---|---|
creator_lookup | GET /v1/satellite/creator/:platform/:username |
sound_lookup | GET /v1/satellite/sounds/:platform/:music_id |
video_outlier | POST /v1/satellite/video-outlier |
batch_creator | POST /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:
| Attempt | Wait |
|---|---|
| 1 | — (initial send) |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 h |
| 6 | 6 h |
| 7 | 24 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_ator by the run'sstarted_at/completed_atif 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.
All webhook API endpoints use the /v1 base path and snake_case for all parameter and response field names. Endpoints are team-scoped via your API key.
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, anyVirlo-*) are rejected.
- Name
is_active- Type
- boolean
- Description
Whether the endpoint is enabled. Defaults to
true.
Request
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"
}
List webhooks
Returns every webhook endpoint belonging to your team, newest first.
Request
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 webhook
Retrieves a single webhook endpoint by id.
Path parameters
- Name
id- Type
- uuid
- Required
- *
- Description
Webhook endpoint identifier.
Request
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"
}
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
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 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
curl -X DELETE https://api.virlo.ai/v1/webhooks/1a2b3c4d-... \
-H "Authorization: Bearer {token}"
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
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"
}
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
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"
}
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, orpayload_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_cursorfrom the previous response to paginate older deliveries.
Request
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"
}
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
curl -X POST https://api.virlo.ai/v1/webhooks/deliveries/c7d2e3f4-.../retry \
-H "Authorization: Bearer {token}"
Response
{ "queued": true }
