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

Reading the numbers

FieldWhat it means
visitsClean + suspect + invalid. An estimate of all visits, also when a busy site reports only a sample.
cleanNothing suspicious.
suspectSuspicious, but not clearly a bot. Never blocked.
invalidA clear robot sign was found.
excludedPages a browser loaded in advance and never showed. Not counted as visits.
…ShareA fraction of visits: 0.0455 is 4.55%.
changeInvalid share now against the same length of time before, in percentage points. null when there are too few visits to compare.
statusok, watch (3% invalid or more), critical (7% or more), or quiet (under 100 visits).
tag.statewaiting (no report yet), live, or silent (no report for a day).
reasonsWhat was detected, in plain words, with why text that includes innocent causes.
engagementHow 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." }
StatusWhat happened
400Something in the request is wrong. The message says what.
401No key, or the key is wrong or was removed.
403The key is not allowed to do that (keys only read).
404Not found. Also the answer for a site that is not yours.
429More than 120 requests in a minute. Wait a minute.
503Our 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.

ParameterWhat it is
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast day, included. Defaults to today.
publisherOnly 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.

ParameterWhat it is
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast 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.

ParameterWhat it is
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast day, included. Defaults to today.
publisherOnly 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.

ParameterWhat it is
siteKey requiredThe site key shown in Settings, for example news-example-com-x7k2mq.
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast day, included. Defaults to today.
grainForce 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.

ParameterWhat it is
siteKey requiredThe site key shown in Settings, for example news-example-com-x7k2mq.
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast 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.

ParameterWhat it is
siteKey requiredThe site key shown in Settings, for example news-example-com-x7k2mq.
openOnly 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.

ParameterWhat it is
openOnly 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.

ParameterWhat it is
groupWhat each row is. One of: publisher, site, day, reason.
formatLeave out for JSON. One of: csv.
daysHow many days back from today, today included.
fromFirst day, like 2026-09-01. Use with to instead of days.
toLast day, included. Defaults to today.
publisherOnly 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://.

TypeWhen
tq.spikeA site's invalid share jumped above its normal.
tq.invalid_aboveA site's invalid share went over the limit in one of your rules.
tq.click_bombOne visitor clicked ads many times; ads were hidden for that visitor.
tq.tag_silentA site stopped reporting: the tag is broken or was removed.
tq.patternFar 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:

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