AdGhost Vigil API
Read your bot-detection numbers from your own programs, and be told when something happens.
In two minutes
Create a key in the dashboard under Settings → API keys, then ask for your sites:
curl https://tq.theadghost.com/v1/tq/sites?days=7 \
-H "X-AdGhost-Key: agk_your_key_here"
You get one row per site with its visits and how many were clean, suspect and invalid.
Keys
- Send the key in the
X-AdGhost-Keyheader. Never put it in the address: addresses end up in logs. - A key is shown once, when you create it. If you lose it, remove it and create another.
- Keys only read. Adding sites and people is done in the dashboard.
- A key can be limited to one publisher. It then sees only that publisher's sites.
- Keep keys on your server. Do not put one in a web page or an app.
Reading the numbers
| Field | What it means |
|---|---|
visits | Clean + suspect + invalid. An estimate of all visits, also when a busy site reports only a sample. |
clean | Nothing suspicious. |
suspect | Suspicious, but not clearly a bot. Never blocked. |
invalid | A clear robot sign was found. |
excluded | Pages a browser loaded in advance and never showed. Not counted as visits. |
…Share | A fraction of visits: 0.0455 is 4.55%. |
change | Invalid share now against the same length of time before, in percentage points. null when there are too few visits to compare. |
status | ok, watch (3% invalid or more), critical (7% or more), or quiet (under 100 visits). |
tag.state | waiting (no report yet), live, or silent (no report for a day). |
reasons | What was detected, in plain words, with why text that includes innocent causes. |
engagement | How visits went, in 8 rough groups: how long the visitor stayed and whether they were active (scrolled, clicked, typed or moved). share is of the visits that reported it. Many short visits that did nothing is what bulk bot traffic looks like. |
All days are UTC. History goes back 13 months. Ask for a range with ?days=30 or ?from=2026-09-01&to=2026-09-30.
What Vigil never stores, so the API can never return it: an IP address, a page address, a device's time zone, or anything about who a visitor is. Country and "data centre or not" are kept as counts per site per hour.
Errors and limits
Every error has the same shape:
{ "error": "not_found", "message": "Not found." }
| Status | What happened |
|---|---|
| 400 | Something in the request is wrong. The message says what. |
| 401 | No key, or the key is wrong or was removed. |
| 403 | The key is not allowed to do that (keys only read). |
| 404 | Not found. Also the answer for a site that is not yours. |
| 429 | More than 120 requests in a minute. Wait a minute. |
| 503 | Our side is having trouble. Try again in a few minutes. |
The address always starts with /v1/. We may add fields to v1; anything that would break you goes into a new version.
Read your data
GET /v1/tq/me
Who this key belongs to. Your company, its contract limits and this month's use.
Example answer
{
"who": {
"key": "key:BI export",
"role": "analyst",
"publisherId": null
},
"companies": [
{
"id": 7,
"name": "Example Ads",
"role": "analyst"
}
],
"company": {
"id": 7,
"name": "Example Ads",
"status": "active",
"limits": {
"sites": 25,
"visitsPerMonth": 20000000
},
"usage": {
"sites": 9,
"visitsThisMonth": 4120000,
"overLimit": false
}
}
}GET /v1/tq/overview
Everything at once. Totals, the same period before, a point per day, and a row per publisher, per site and per reason. One call is enough to build a whole screen.
| Parameter | What it is |
|---|---|
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
publisher | Only this publisher (its id from /v1/tq/publishers). |
Example answer
{
"range": {
"from": "2026-09-25",
"to": "2026-10-01",
"days": 7,
"timezone": "UTC"
},
"totals": {
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
},
"previous": {
"visits": 1000,
"clean": 990,
"suspect": 60,
"invalid": 20,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.02,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
},
"change": 2.55,
"series": [
{
"day": "2026-09-25",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
],
"publishers": [
{
"id": 3,
"name": "Example Media",
"sites": 3,
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0,
"change": 2.1,
"trend": [
0.02,
0.0455
],
"status": "watch"
}
],
"sites": [
{
"siteKey": "news-example-com-x7k2mq",
"domain": "news.example.com",
"publisher": {
"id": 3,
"name": "Example Media"
},
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0,
"change": 2.1,
"trend": [
0.021,
0.024,
0.03,
0.045,
0.05,
0.048,
0.0455
],
"status": "watch",
"tag": {
"state": "live",
"lastReport": "2026-10-01T09:00:00.000Z"
},
"topReason": {
"reason": "robot_browser",
"name": "Robot browser"
}
}
],
"reasons": [
{
"reason": "robot_browser",
"name": "Robot browser",
"count": 30,
"share": 0.0273,
"why": "The browser says it is being driven by a program…",
"checks": [
{
"id": "f1.webdriver",
"label": "navigator.webdriver is true",
"count": 30
}
]
}
]
}GET /v1/tq/publishers
Publishers with their totals. For companies with many clients.
| Parameter | What it is |
|---|---|
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
Example answer
{
"range": {
"from": "2026-09-25",
"to": "2026-10-01",
"days": 7,
"timezone": "UTC"
},
"publishers": [
{
"id": 3,
"name": "Example Media",
"sites": 3,
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0,
"change": 2.1,
"trend": [
0.02,
0.0455
],
"status": "watch"
}
]
}GET /v1/tq/sites
Sites with clean, suspect and invalid. One row per site, busiest first.
| Parameter | What it is |
|---|---|
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
publisher | Only this publisher (its id from /v1/tq/publishers). |
Example answer
{
"range": {
"from": "2026-09-25",
"to": "2026-10-01",
"days": 7,
"timezone": "UTC"
},
"sites": [
{
"siteKey": "news-example-com-x7k2mq",
"domain": "news.example.com",
"publisher": {
"id": 3,
"name": "Example Media"
},
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0,
"change": 2.1,
"trend": [
0.021,
0.024,
0.03,
0.045,
0.05,
0.048,
0.0455
],
"status": "watch",
"tag": {
"state": "live",
"lastReport": "2026-10-01T09:00:00.000Z"
},
"topReason": {
"reason": "robot_browser",
"name": "Robot browser"
}
}
]
}GET /v1/tq/sites/{siteKey}
One site. Its history (by hour for 1 or 2 days, otherwise by day), where the bots are (page type, device, country, network), how visits went (engagement: how long visitors stayed and whether they did anything), and what was detected.
| Parameter | What it is |
|---|---|
siteKey required | The site key shown in Settings, for example news-example-com-x7k2mq. |
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
grain | Force a point per day even for 1 or 2 days. One of: day. |
Example answer
{
"site": {
"siteKey": "news-example-com-x7k2mq",
"domain": "news.example.com",
"publisher": {
"id": 3,
"name": "Example Media"
},
"reportingShare": 1,
"tag": {
"state": "live",
"lastReport": "2026-10-01T09:00:00.000Z"
}
},
"range": {
"from": "2026-09-25",
"to": "2026-10-01",
"days": 7,
"grain": "day",
"timezone": "UTC"
},
"totals": {
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
},
"change": 2.1,
"status": "watch",
"series": [
{
"at": "2026-09-25",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
],
"where": {
"page": [
{
"value": "article",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
],
"device": [
{
"value": "mobile",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
],
"country": [
{
"value": "GB",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
],
"network": [
{
"value": "datacentre",
"visits": 1100,
"clean": 990,
"suspect": 60,
"invalid": 50,
"excluded": 4,
"reports": 1104,
"cleanShare": 0.9,
"suspectShare": 0.0545,
"invalidShare": 0.0455,
"averageScore": 12.4,
"adsHeldBack": 31,
"clickBombs": 0
}
]
},
"engagement": [
{
"value": "d0e0",
"stayed": "Under 5 seconds",
"active": false,
"visits": 140,
"share": 0.1273,
"invalidShare": 0.2
}
],
"reasons": [
{
"reason": "robot_browser",
"name": "Robot browser",
"count": 30,
"share": 0.0273,
"why": "The browser says it is being driven by a program…",
"checks": [
{
"id": "f1.webdriver",
"label": "navigator.webdriver is true",
"count": 30
}
]
}
]
}GET /v1/tq/sites/{siteKey}/signals
What was detected on one site. Reasons in plain words, each with the checks behind it and their counts.
| Parameter | What it is |
|---|---|
siteKey required | The site key shown in Settings, for example news-example-com-x7k2mq. |
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
Example answer
{
"siteKey": "news-example-com-x7k2mq",
"range": {
"from": "2026-09-25",
"to": "2026-10-01"
},
"reasons": [
{
"reason": "robot_browser",
"name": "Robot browser",
"count": 30,
"share": 0.0273,
"why": "The browser says it is being driven by a program…",
"checks": [
{
"id": "f1.webdriver",
"label": "navigator.webdriver is true",
"count": 30
}
]
}
]
}GET /v1/tq/sites/{siteKey}/events
Alerts and events on one site. Click bombing, spikes, silence. Newest first.
| Parameter | What it is |
|---|---|
siteKey required | The site key shown in Settings, for example news-example-com-x7k2mq. |
open | Only the ones nobody has dealt with. One of: 1. |
Example answer
{
"events": [
{
"id": 41,
"kind": "spike",
"name": "Spike in invalid traffic",
"site": {
"siteKey": "news-example-com-x7k2mq",
"domain": "news.example.com"
},
"detail": {
"invalidPercent": 9.1,
"normalPercent": 2,
"visits": 38680
},
"at": "2026-10-01T04:07:00.000Z",
"open": true,
"handledBy": null
}
]
}GET /v1/tq/events
Alerts across all your sites. Newest first, up to 200.
| Parameter | What it is |
|---|---|
open | Only the ones nobody has dealt with. One of: 1. |
Example answer
{
"events": [
{
"id": 41,
"kind": "tag_silent",
"name": "Tag silent",
"site": {
"siteKey": "shop-example-com-p3n8ra",
"domain": "shop.example.com"
},
"detail": {
"hoursSilent": 52,
"lastReport": "2026-09-29T00:00:00.000Z"
},
"at": "2026-10-01T04:07:00.000Z",
"open": true,
"handledBy": null
}
]
}GET /v1/tq/report
A report, as JSON or a spreadsheet. The same numbers grouped your way. With format=csv the answer is a CSV file.
| Parameter | What it is |
|---|---|
group | What each row is. One of: publisher, site, day, reason. |
format | Leave out for JSON. One of: csv. |
days | How many days back from today, today included. |
from | First day, like 2026-09-01. Use with to instead of days. |
to | Last day, included. Defaults to today. |
publisher | Only this publisher (its id from /v1/tq/publishers). |
Example answer
{
"range": {
"from": "2026-09-25",
"to": "2026-10-01",
"days": 7,
"timezone": "UTC"
},
"group": "day",
"columns": [
"Day (UTC)",
"Visits",
"Clean",
"Suspect",
"Invalid",
"Clean %",
"Suspect %",
"Invalid %",
"Ads held back",
"Click-bombing events"
],
"rows": [
[
"2026-09-25",
1100,
990,
60,
50,
"90.00",
"5.45",
"4.55",
31,
0
]
]
}Webhooks
Add a webhook in the dashboard under Settings → Webhooks, and we call your server when an alert fires. Your address must start with https://.
| Type | When |
|---|---|
tq.spike | A site's invalid share jumped above its normal. |
tq.invalid_above | A site's invalid share went over the limit in one of your rules. |
tq.click_bomb | One visitor clicked ads many times; ads were hidden for that visitor. |
tq.tag_silent | A site stopped reporting: the tag is broken or was removed. |
tq.pattern | Far more of a site's visits than usual arrived and did nothing: what bulk bot traffic looks like, even when each visit passes every check. |
What we send:
POST your address
X-AdGhost-Id: evt_41_3
X-AdGhost-Signature: t=1759300000,v1=5f2c…
{ "id": "evt_41_3", "type": "tq.spike", "createdAt": "2026-10-01T04:07:00.000Z",
"site": { "siteKey": "news-example-com-x7k2mq", "domain": "news.example.com" },
"data": { "invalidPercent": 9.1, "normalPercent": 2, "visits": 38680 } }
Check every message before you trust it:
- The signature.
v1is HMAC-SHA256 oft + "." + body(the raw body, exactly as received), using the signing secret you were shown when you added the webhook, written as hex. - The time. Reject a message whose
tis more than 5 minutes old. - The id. Remember
X-AdGhost-Id. A message can arrive twice; handle it once.
// Node.js
const crypto = require('crypto');
function isFromVigil(rawBody, header, secret) {
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header) || [];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Answer with any 2xx status within 5 seconds. Otherwise we try again after 1 hour, then 2, 4, 8 and 24. After six tries we stop, and the dashboard shows every attempt.
On your pages
Each site sends reports with one line in its <head>. The dashboard shows the exact line for each site under Settings → Sites:
<script async src="https://cdn.theadghost.com/vigil/v1/vigil.js" data-site="your-site-key"></script>
Your own page code can ask the checker about the current visitor, at no cost and with no request to us:
window.AdGhostIVT.class // 'clean' | 'suspect' | 'invalid' | 'excluded'
window.AdGhostIVT.signals // names of the checks that fired, e.g. ['f1.webdriver']
window.AdGhostIVT.complete // true once the deeper checks have finished
document.addEventListener('adghost:ivt', (e) => {
// e.detail = { class, hard, soft, signals }
if (e.detail.class === 'invalid') { /* skip analytics, an affiliate pixel, a sign-up form… */ }
});
The deeper device checks follow the visitor's consent choice: they are skipped under Global Privacy Control, and where a consent platform is on the page they run only with consent.