Kaminari Ad

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_tag or vast_tag it 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 url or ad_discovery it is where the visitor came from, and travels as the Referer header 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:

SurfaceWhat the creative reads
window.location.hrefthe full URL: https://publisher.example/news/article-1?utm=x
document.referrer (creative frame)the origin only: https://publisher.example/
Referer on cross-origin subrequeststhe 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 http or https
  • 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_ads without ad_discovery: true is rejected with 422, and so is a value outside 1 to 25. Nothing is clamped silently.
  • repeat_mode: "shared" together with ad_discovery: true is rejected with 422 — 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_formats without ad_discovery: true is rejected with 422, and so are an empty list, an unknown value and a format listed twice.
  • Scan and campaign responses return the selection as ad_formats, or null for 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 at repeat_count: 5 create fifteen scans. Each repeat is a separate, separately billed scan.
  • repeat_mode (isolated or shared, default isolated) — isolated gives every repeat a fresh browser and a new IP; shared runs the whole group in one browser behind one IP, carrying cookies and local storage from one repeat to the next. shared together with ad_discovery: true is 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 a proxy: … 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.

EndpointReturnsAvailable for
GET /api/v1/scans/{id}/screenshotimage/webp of the pageall scans
GET /api/v1/scans/{id}/creative-screenshotimage/webp of the creativead-tag and VAST scans
GET /api/v1/scans/{id}/creative-htmltext/plain creative markupad-tag scans
GET /api/v1/scans/{id}/creative-videothe stored MediaFile, usually video/mp4VAST scans
GET /api/v1/scans/{id}/vast-xmlapplication/xml, wrappers resolvedVAST scans
GET /api/v1/scans/{id}/landings/{ord}/screenshotimage/webp of one landing tabscans 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.

← All documentation