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.
- 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
- Add a webhook (the API calls it an endpoint): your address and the events you want. Use
POST /v1/webhooksfor any event, or the Integrations page for agent runs, lookups and global trends. - Virlo sends a message (an HTTP
POST) when an event happens. - Your server replies with a success code (200 to 299, written
2xx) within 30 seconds. - 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
| Event | Fires when |
|---|---|
content_research_agent.run.completed | A Content Research Agent run finishes or fails. |
content_research_agent.event.detected | A recurring agent spots a breaking story in its topic. |
satellite.lookup.completed | A creator, sound, hashtag or video outlier lookup finishes or fails. |
trends.daily.completed | The global trend list updates (up to 3 times a day, despite the name). |
trends.region.completed | A country trend list updates. One message per country. |
tracking.cycle.completed | A tracked creator or video gets its scheduled check. |
tracking.outlier_video.detected | A tracked creator posts a video with over 3 times their median views. |
tracking.paused | Virlo pauses tracking: 3 failed checks in a row, or your balance can't cover a check. |
audience.snapshot.completed | An audience snapshot you asked for is ready. |
Trend events are public: no team or user details.
There's no separate failure event. Check data.status: agent runs and lookups say success or failed; trend updates include it only when they fail; breaking stories always say confirmed; tracking and audience events don't have it.
Older names. orbit.run.completed (agents that run once) and comet.run.completed (recurring agents) still work and still fire. Subscribe to the matching old name and the new one, and you get two messages per run.
What we send
Every message has the same outer layer. data depends on the event.
{
"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
Retries run late for now. Each usually goes out about a day after the last, not on the schedule below. If you miss a message, check the result with the matching GET call.
The planned schedule, counting from the previous attempt:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | 30 seconds later |
| 3 | 2 minutes later |
| 4 | 10 minutes later |
| 5 | 1 hour later |
| 6 | 6 hours later |
| 7 | 24 hours later |
408, 429, 5xx, timeouts and network errors get all 7 attempts, then the delivery is dead. Other codes, including redirects and 404, get one more try, then it's failed. Resend either with Retry delivery.
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-Secretwith 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,.internaland 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 bycreated_atif needed.
What each event contains
Agent run done
content_research_agent.run.completed fires once per run. For a run that worked, it waits for the AI report. Where the agent's settings sit depends on the outcome:
| Outcome | status | Agent settings are under | Also included |
|---|---|---|---|
| It worked | success | orbit (runs once) or comet (recurring) | is_recurring, analysis |
| It failed | failed | content_research_agent, with is_recurring inside | intent_summary |
Read the settings either way with data.orbit ?? data.comet ?? data.content_research_agent. analysis is null when the AI report failed or found no new videos. The videos are still saved (GET /v1/agents/:id/videos).
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/runsshows it aspartial_failure. Rarely, a run whose AI report couldn't start sayssuccessorpartial_failurebut is laid out like a failed run.
- Name
metrics- Type
- object
- Description
videos_linkedis how many videos the run added to your agent.platformscounts videos before some filters, so it's often higher thanvideos_linked. Failed runs send zeros, withyoutube_count,tiktok_countandinstagram_countin place ofplatforms.
- Name
orbit, comet, content_research_agent- Type
- object
- Description
cadenceis a cron expression, such as0 0 * * 0for weekly, even if you choseweekly.min_viewsandtime_rangeare set by Virlo: you can't change them through/v1/agents.
- Name
intent_summary- Type
- object | null
- Description
Failed runs only:
{ matched, total_evaluated }, ornull.
- Name
analysis- Type
- object | null
- Description
analysis.trendsis a copy ofanalysis_data.themes: patterns in your agent's videos, not the global trend list. A theme'sstable_keystays the same across runs.cost_usdis 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) orcorpus_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) ornone. 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
}
}
metricsis always0for now. Your charge is theX-Credits-Usedheader on the call that started the lookup.- With Data Intelligence, that part can finish after the message. Read the run again until its
intelligence_statusisready.
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.
bandisbreaking,accelerating,sustained,fadingornull. 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,mediumorhigh) says how far to trust it.data_source: "profile_only"means no audience data was found: it's alwayslow, and the $0.50 charge is refunded.cost_usdis 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) ordisabled_by_system(20 failed attempts in a row).
- Name
is_active- Type
- boolean
- Description
Stays
trueafter an automatic shutdown, so checkstatus.
Errors
Errors use the standard error body.
| Status | code | When |
|---|---|---|
400 | validation_error | Bad URL or header, empty enabled_events, unknown field, malformed ID, or a key with no team |
400 | unknown_event_type | Unsupported event name (the message lists every accepted one) |
401 | missing_api_key, invalid_api_key | No API key, or a wrong one |
404 | not_found | No webhook or delivery with that ID on your team |
409 | conflict | Your team already has 5 webhooks switched on |
400
{
"message": "Unknown event type(s): foo.bar. Supported: content_research_agent.run.completed, content_research_agent.event.detected, comet.run.completed, orbit.run.completed, satellite.lookup.completed, trends.daily.completed, trends.region.completed, tracking.cycle.completed, tracking.outlier_video.detected, tracking.paused, audience.snapshot.completed",
"error": "Bad Request",
"statusCode": 400,
"code": "unknown_event_type"
}
Create webhook
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 withVirlo-.
- Name
description- Type
- string
- Description
A label, up to 256 characters.
- Name
is_active- Type
- boolean
- Description
falsecreates it switched off. Defaults totrue.
Request
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"
}
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
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 webhook
Returns one webhook. Cost: free.
- Name
id- Type
- uuid
- Required
- *
- Description
The webhook's ID. An unknown ID returns
404. A malformed one returns400.
Request
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"
}
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.nullreturns400.
- Name
description- Type
- string | null
- Description
A new label.
nullclears it.
- Name
is_active- Type
- boolean
- Description
Switch it on or off. This doesn't undo an automatic shutdown: use Re-enable webhook.
Request
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 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
curl -X DELETE https://api.virlo.ai/v1/webhooks/1a2b3c4d-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer YOUR_API_KEY"
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
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"
}
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 labeledorbit.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
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
}
}
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 replied2xx.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 as2026-09-24T17:32:30.077677+00:00), URL-encoded. The+must become%2B, or you get400. Stop atnull. 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 ashttp_404), a network error,endpoint_inactive,endpoint_disabled_by_system,endpoint_deletedorpayload_too_large.
- Name
completed_at- Type
- string | null
- Description
After a retry, this and
last_errorkeep their old values until the new attempt finishes.
Request
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
}
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
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 }
