Scans API
Submit single or bulk scans, read the classification and evidence, and receive tags via webhooks.
The Scans API is the core of Kaminari Ad. You submit a URL, an ad tag, or a VAST video tag and a context (country + emulator); you get back a scan with a classification, evidence, and — via webhooks — tags.
Single scan
POST /api/v1/scans with { url | ad_tag | vast_tag, country_code, emulator_id }
queues one scan and returns its id and initial status (pending).
Provide exactly one of url, ad_tag, or vast_tag:
ad_tag— an HTML snippet or an http(s) URL of a page where the creative is already rendered. In the URL form the crawler navigates there instead of injecting HTML, then clicks the creative the same way.vast_tag— an http(s) URL of a VAST endpoint or raw VAST XML (a document containing a<VAST>element). The tag is fetched, unwrapped, and played inside a real browser (never server-side, so cloakers can't tell us apart from a real player); we capture a video frame and follow the clickthrough. See VAST video verification.
country_code is an ISO 3166-1 alpha-2 code in any case: us is
accepted, and the scan stores and returns it as US.
Optional fields: referrer, ignore_first_n_domains, proxy (see
Proxy targeting for the accepted
values and how to read them), labels, campaign_id, run_id,
plus the repeat and retry controls below. Read the result with
GET /api/v1/scans/{id} or receive it via a
webhook.
Send campaign_id when you want alerts:
policies are attached to campaigns, so a scan without one is checked
and tagged but never raises an alert. Without a run_id, the scan
joins that campaign's API run for the day.
Checking from a publisher page (referrer)
referrer is an optional http(s) page URL that decides where the check
appears to happen — the single most common reason a cloaker behaves
differently for us than for real traffic.
- With
ad_tagorvast_tagit is the page the tag is embedded in. The browser commits the harness document on that URL without ever fetching the publisher, so the creative loads with the publisher as its embedding page, exactly as in a real placement. - With
urlorad_discoveryit is where the visitor came from, and travels as theRefererheader of the page request.
Which surface exposes the path
Only one of the three surfaces a creative can read carries the path and
query — the other two are origin-only. So a cloaker that keys on a path
segment or a query parameter sees it through window.location.href and
nowhere else. For a referrer of
https://publisher.example/news/article-1?utm=x:
| Surface | What the creative reads |
|---|---|
window.location.href | the full URL: https://publisher.example/news/article-1?utm=x |
document.referrer (creative frame) | the origin only: https://publisher.example/ |
Referer on cross-origin subrequests | the origin only: https://publisher.example/ |
That is Chromium's default strict-origin-when-cross-origin referrer
policy, not an artefact of how we serve the page: a really-served
publisher page yields the same three values, and document.referrer is
origin-only whether the creative sits in a cross-origin iframe or in a
same-origin srcdoc one.
{
"ad_tag": "<script src=\"https://ad.example.com/tag.js\"></script>",
"referrer": "https://publisher.example/news/article-1",
"country_code": "US",
"emulator_id": "samsung_galaxy_a15_android16"
}
A campaign can carry a referrer of its own,
and every scan a campaign run creates copies it — whether the run was
started by hand, from a group, or by a schedule. A scan submitted straight
to POST /api/v1/scans or /scans/bulk is the exception: it uses only the
referrer in its own request body, even when it carries a campaign_id.
Send the field explicitly on every direct submission.
What is rejected
The browser commits its document on this exact URL, so a referrer it
cannot navigate to is refused with 422 rather than accepted into a check
that could only fail:
- any scheme other than
httporhttps - credentials in the URL (
https://user:[email protected]/) - a host that is not publicly routable —
localhost, a private or link-local address, or a single-label internal name - more than 2048 characters after normalisation. Normalisation percent-encodes, so a non-ASCII path counts for more than its visible length: 337 Cyrillic characters already reach the limit.
Skipping your own click domains (ignore_first_n_domains)
When a check runs through your own direct link, the chain starts on your click and tracking domains, and the domain checkers rate them like any other hop — tags and alerts attributed to your infrastructure rather than to the offer.
ignore_first_n_domains (0 to 5, default 0) drops the first N unique
domains of the redirect chain, counted from the entry point, from
detection and tagging. Domains fold to their registrable form, so
www.example.com and example.com consume one slot between them, and
any other request to one of those domains is excluded too. 0 leaves
detection exactly as it is.
{
"url": "https://click.example.com/go?cid=42",
"ignore_first_n_domains": 1,
"country_code": "US",
"emulator_id": "samsung_galaxy_a15_android16"
}
The scan's report is unaffected — the full redirect chain is still captured and returned. Only what the checkers are given narrows.
GET /api/v1/scans/{id} echoes the value the scan was created with, so a
finished scan can still tell you how much of its chain was excluded even
after the campaign behind it has been edited.
A campaign can carry the setting instead, and
every scan a campaign run creates copies it, discovered-ad children
included. As with referrer, a scan submitted straight to
POST /api/v1/scans or /scans/bulk uses only the value in its own
body, even when it carries a campaign_id.
Skip no more domains than you own: a skipped domain is checked by nothing, so too high an N hides real findings on the offer.
Scanning every ad on a page (ad_discovery)
ad_discovery: true on a url scan turns it into a publisher sweep: the
crawler detects the ad blocks on the page and each one becomes its own
child scan, with its own report and its own artifacts. The parent scan
returns first; the children arrive as they finish and are listed by
GET /api/v1/scans/{id}/children.
max_discovered_ads (1 to 25) sets how many ads to look for before
stopping. Omit it to use the platform default of 12. Each ad found is
scanned and billed as a separate check, so a page scanned at 25 can
cost 26 checks — one parent plus its children.
Two rules to know:
max_discovered_adswithoutad_discovery: trueis rejected with422, and so is a value outside 1 to 25. Nothing is clamped silently.repeat_mode: "shared"together withad_discovery: trueis rejected with422— a shared session carries cookies between repeats, which a per-ad click sequence cannot be replayed into.
The same field exists on campaign create and update, where it applies to
every run of an ad_discovery campaign. On update, send
"max_discovered_ads": null to drop the campaign's own number and go
back to the platform default; omitting the key leaves it unchanged. See
Campaigns.
Checking only some ad formats
ad_formats narrows which ads a discovery scan checks: any of banner,
video and pop (pop covers pop-unders and tab-unders). Omit it to
check every format.
{
"url": "https://publisher.example/article",
"country_code": "US",
"emulator_id": "samsung_galaxy_a15_android16",
"ad_discovery": true,
"ad_formats": ["pop"]
}
A format left out is not captured at all — with ["pop"] the scan never
screenshots or clicks a banner, so no banner child is created or billed.
Every discovered ad is still limited by max_discovered_ads. A tab-under
the page forces on its own, with no click, is always reported as a pop,
because it takes over the visitor's tab whether or not you asked for pops.
ad_formatswithoutad_discovery: trueis rejected with422, and so are an empty list, an unknown value and a format listed twice.- Scan and campaign responses return the selection as
ad_formats, ornullfor every format.
The same field exists on campaign create and update. On update, send
"ad_formats": null to go back to every format; omitting the key leaves
it unchanged.
Which network served the ad
Each child scan carries two fields describing the ad's origin, and they answer different questions:
network— the ad network's name (ExoClick,PropellerAds,TrafficStars), resolved from the ad server's domain. Empty when the domain is not one we can name.network_host— the ad server's domain as observed. Populated whenever an ad server was attributable at all, so an unnamed network still tells you where the ad came from.
Read network first and fall back to network_host. Both are empty for
two kinds of ad: one served from infrastructure that is not an ad network
(tag managers, analytics, measurement, DMPs) and one served from the
publisher's own creative CDN. Those ads are still found, scanned and
billed like any other — only the attribution is withheld, because naming
a tag manager as the network would be worse than saying nothing.
A pop-under carries a network but no network_host: pop networks
rotate the domains they serve from, so the one that happened to fire
names nothing you could act on.
Scans created before September 2026 predate this split — they carry a
hostname in network and an empty network_host. Aggregate by network
only over scans created after it.
Bulk submission
POST /api/v1/scans/bulk scans one url (or ad_tag / vast_tag)
across multiple country_codes in a single call:
{
"url": "https://example.com/banner.html",
"country_codes": ["US", "GB", "DE"],
"emulator_id": "samsung_galaxy_a15_android16"
}
Every optional field of the single-scan endpoint works here too and
applies to every country in the batch: referrer,
ignore_first_n_domains, proxy, labels, campaign_id, run_id,
ad_discovery with max_discovered_ads and ad_formats, and the
repeat and retry controls. The same field rules apply, so a
combination of fields POST /api/v1/scans rejects is rejected here
with 422 before any scan is created. Country codes are case-insensitive, as on
the single-scan endpoint.
With a campaign_id, every country's scan joins the same campaign run
and can raise alerts — there is no need to
split a multi-country check into one call per country to get them:
{
"url": "https://example.com/offer",
"country_codes": ["US", "GB", "DE"],
"emulator_id": "samsung_galaxy_a15_android16",
"campaign_id": "3f6c2a5e-8b1d-4c7a-9e2f-1a2b3c4d5e6f"
}
It returns one scan per country — or one repeat group per country, if
you set repeat_count below. To scan many different URLs, submit
them as a campaign or loop the single-scan
endpoint.
Repeats and retries
Both scan endpoints — and campaign create/update — accept three optional fields:
repeat_count(1 to 20, default 1) — scan the same combination that many times. In bulk, the count applies per country, so three countries atrepeat_count: 5create fifteen scans. Each repeat is a separate, separately billed scan.repeat_mode(isolatedorshared, defaultisolated) —isolatedgives every repeat a fresh browser and a new IP;sharedruns the whole group in one browser behind one IP, carrying cookies and local storage from one repeat to the next.sharedtogether withad_discovery: trueis rejected with 422.retry_max_attempts(0 to 5, default 0) — extra crawl attempts after a transient failure: dead or rate-limited proxy, navigation timeout, browser crash. Permanent failures (blocked URL, invalid ad tag or VAST document) are never retried. Proxy failures split on cause: no node free at that moment is transient and retried, while one no attempt can clear — targeting that matches no node, an account-level refusal — is permanent, so aproxy: …error that survives means the request needs changing rather than repeating. A retry re-uses the same scan id, so it is never billed twice.
Values above the ceilings are rejected with 422, not clamped. The
response is the group leader; the ids of its siblings come back once,
in repeat_scan_ids on the create response, and nowhere else.
Asynchronous by design
Scans always run asynchronously: submit, then poll
GET /api/v1/scans/{id} or wait for a webhook. There is no synchronous
"wait for the result" mode — keep a polling interval, or subscribe to
the scanning.scan.completed webhook for push delivery.
Do not model the lifecycle as forward-only: a scan granted another
attempt returns from running to pending, so a poller that treats
pending as "not started yet" will misread a retry.
Reading a finished scan
A finished scan (status = completed or partial) exposes its
classification (brand, AI category, IAB v3, custom taxonomies), the
redirect_chain, landings, labels, timing, and the artifact
endpoints listed under Artifacts below.
On an ad-tag scan, redirect_chain is the landing tab's. The creative
loads sub-resources inside its own frame, and those appear on
creative_redirect_chain instead — the same hop shape, rooted at the
document the banner rendered. Read it when you need to attribute a
finding to the creative rather than to the page it clicked through to.
Empty on every other scan type, and on an ad-tag scan whose creative
requested nothing.
Repeat and retry state travels with the scan on both the detail and the
list responses: repeat_index (0-based) and repeat_total place it in
its group, repeat_session_id is set when that group shared one
browser, and retry_attempt / retry_max_attempts say which attempt
produced the result. A defaulted scan reads repeat_index: 0,
repeat_total: 1, retry_attempt: 0.
For VAST scans, creative_kind is video and a video block carries
the parsed metadata (duration, media-file URL, VAST version, ad system,
VPAID flag, wrapper depth); the captured video frame is served from the
same /creative-screenshot endpoint as banner creatives.
Detected tags are not part of the scan body. Receive them through
the scanning.tags.detected webhook (which carries tag_slugs), or
filter the scan list by tag: GET /api/v1/scans?tag=<slug>. Several
comma-separated slugs match scans carrying any of them; add
tag_match=all to require every one.
Artifacts
Every artifact streams through the API. There are no public or
presigned storage URLs, so a GET is all you need and the bytes are
authorised the same way as the rest of the API.
| Endpoint | Returns | Available for |
|---|---|---|
GET /api/v1/scans/{id}/screenshot | image/webp of the page | all scans |
GET /api/v1/scans/{id}/creative-screenshot | image/webp of the creative | ad-tag and VAST scans |
GET /api/v1/scans/{id}/creative-html | text/plain creative markup | ad-tag scans |
GET /api/v1/scans/{id}/creative-video | the stored MediaFile, usually video/mp4 | VAST scans |
GET /api/v1/scans/{id}/vast-xml | application/xml, wrappers resolved | VAST scans |
GET /api/v1/scans/{id}/landings/{ord}/screenshot | image/webp of one landing tab | scans with landings |
Screenshots are WebP in every case — the crawler stores nothing else, so
there is no PNG fallback to negotiate. The video endpoint echoes the
media type of what the VAST document actually served, so read
Content-Type rather than assuming MP4.
The screenshot endpoints accept w (100–1200) to resize server-side.
For the page and landing surfaces a resized capture is top-cropped when
its height would exceed 2.5x its width, so omit w when you need the
whole page; creative-screenshot never crops, because a creative's
aspect ratio is part of what you are verifying.
creative-html is deliberately served as text/plain with
X-Content-Type-Options: nosniff — the markup is untrusted third-party
ad code, and serving it as text/html would execute it on our origin.
Requesting an artifact a scan does not have returns 404. Use the
"Available for" column above to decide what to fetch: creative_kind
distinguishes video from banner but not an ad-tag scan from a URL
scan, since both of those read banner. The scan's own ad_tag /
vast_tag / url field is what tells you which family it is.
Other operations
POST /api/v1/scans/recheck— re-run checks against existing scans.POST /api/v1/scans/{id}/cancel— cancel a pending scan.
See the full field list in the Swagger reference.