TrueProxies

Use cases

Ad verification proxies

Last updated:

Ad verification proxies add a selected network location to a check.

Record the exit, creative, redirect chain, placement, and timestamp.

View pricing
Ad verification proxy hero image showing placement checks, geo matching, and continuous monitoring

The problem

What ad verification proxies can and cannot prove

An ad verification proxy routes a check through another network location so you can inspect the creative, publisher placement, redirect path, and landing page returned there. It can reveal a mismatch worth investigating, but a proxy result does not prove fraud by itself.

Datacenter and residential exits may receive different content when a publisher or ad platform classifies network origin. Compare both when origin-specific behavior is part of the investigation.

Residential IPv4 provides ISP-origin exits and location controls. Publishers can still evaluate other request and browser signals, so do not describe the verification request as indistinguishable from a person.

Capture the exit IP, timestamp, requested and final URLs, HTTP status, creative, placement, screenshot, and account state for each check. Report sample size, success rate, challenge rate, and test date when comparing markets. Repeat suspicious results before escalating them.

If you're comparing workflows before you commit, explore all proxy use cases to see which setup fits best.

How TrueProxies solves it

Add residential vantage points to verification

ISP-origin viewpoint

Check placements through residential exits while recognizing that other automation signals may still be visible.

Verified geography checks

Target by country, city, ZIP, or ASN. If the current routed network exposes a finer control, document it and record the exit IP before evaluating a location-targeted campaign.

Continuous monitoring

An unlimited speed-and-duration plan can support recurring checks. Alert timing still depends on the schedule and successful responses.

Recommended

Which proxy product should you test for ad verification?

For recurring checks

Residential IPv4 (Unlimited)

From $4.08/hour at 10 Mbps

Compare fixed speed-and-duration pricing for a recurring verification schedule.

  • Unlimited transfer within the selected speed tier
  • Residential IPv4 location controls
  • Sticky or rotating session support
View plans
For periodic audits

Residential IPv4 (GB-Based)

From $1.64/GB

Spot-check specific campaigns or run periodic audits without paying for always-on bandwidth.

  • Pay for data transferred
  • Residential IPv4 location controls
  • Cost-efficient for smaller ad portfolios
View plans

Integration

Setting up ad verification with TrueProxies

  1. 1Define the campaign, expected markets, creative, publisher, placement, schedule, and escalation rule before collecting evidence.
  2. 2Choose GB-Based billing for periodic audits or compare an unlimited speed-and-duration plan for a recurring schedule.
  3. 3Generate proxy credentials and select the target country. Use a finer control only when the current routed network and customer selector explicitly expose it.
  4. 4Verify and record the observed proxy exit before loading the ad or publisher page.
  5. 5Capture the timestamp, requested and final URLs, HTTP status, creative, placement, redirect chain, account state, and screenshot.
  6. 6Repeat suspicious results from a control location, then compare the evidence with the ad platform report before escalating.
import { constants } from "node:fs";
import { createHmac, randomUUID } from "node:crypto";
import { lookup } from "node:dns/promises";
import { access, lstat, realpath } from "node:fs/promises";
import { isIP } from "node:net";
import { isAbsolute, join, relative, resolve } from "node:path";
import { chromium, errors } from "playwright";

const METHOD_VERSION = "ad-verification-v1";
const LAST_REVIEWED = "2026-07-18";
const CANONICAL_PAGE = "https://trueproxies.com/use-cases/ad-verification/";
const SELECTOR_OBSERVATION_WINDOW_MS = 2_500;
const ISO_3166_1_ALPHA_2 = new Set([
  "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
  "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ",
  "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ",
  "DE", "DJ", "DK", "DM", "DO", "DZ",
  "EC", "EE", "EG", "EH", "ER", "ES", "ET",
  "FI", "FJ", "FK", "FM", "FO", "FR",
  "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY",
  "HK", "HM", "HN", "HR", "HT", "HU",
  "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT",
  "JE", "JM", "JO", "JP",
  "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ",
  "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY",
  "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ",
  "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
  "OM",
  "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY",
  "QA",
  "RE", "RO", "RS", "RU", "RW",
  "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ",
  "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ",
  "UA", "UG", "UM", "US", "UY", "UZ",
  "VA", "VC", "VE", "VG", "VI", "VN", "VU",
  "WF", "WS",
  "YE", "YT",
  "ZA", "ZM", "ZW",
]);

function safe_identifier(value, fallback) {
  return /^[a-zA-Z0-9_-]{1,64}$/.test(value ?? "") ? value : fallback;
}

function required_string(value, name) {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(name + " is required");
  }
  return value;
}

function finite_integer(value, name, minimum, maximum) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || !Number.isInteger(parsed)
    || parsed < minimum || parsed > maximum) {
    throw new Error(name + " must be an integer from " + minimum + " to " + maximum);
  }
  return parsed;
}

function strict_boolean(value, name, fallback) {
  if (value === undefined) return fallback;
  if (value !== "true" && value !== "false") {
    throw new Error(name + " must be true or false");
  }
  return value === "true";
}

function validated_origin(value) {
  const url = new URL(required_string(value, "APPROVED_AD_ORIGIN"));
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
    || url.pathname !== "/" || url.search || url.hash) {
    throw new Error("APPROVED_AD_ORIGIN must be a credential-free HTTP(S) origin");
  }
  return url.origin;
}

function has_path_capability(pathname) {
  return pathname.split("/").some((segment) =>
    /(?:^|[-_.])(?:auth|capability|download|invite|key|password|secret|session|share|signed|token)(?:$|[-_.])/i.test(segment)
    || /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(segment)
    || (segment.length >= 24 && /^[A-Za-z0-9_-]+$/.test(segment)
      && new Set(segment).size >= 10));
}

function validated_safe_paths(value) {
  let paths;
  try {
    paths = JSON.parse(required_string(value, "APPROVED_AD_PATHS_JSON"));
  } catch {
    throw new Error("APPROVED_AD_PATHS_JSON must be a JSON array");
  }
  if (!Array.isArray(paths) || paths.length === 0 || paths.length > 64
    || paths.some((path) => typeof path !== "string"
      || !/^/(?:[A-Za-z0-9._~-]+/)*[A-Za-z0-9._~-]*$/.test(path)
      || path.includes("[redacted-path]")
      || has_path_capability(path))
    || new Set(paths).size !== paths.length) {
    throw new Error("APPROVED_AD_PATHS_JSON must contain unique exact safe paths");
  }
  return new Set(paths);
}

function validated_configured_url(value, name, approved_origin, approved_paths) {
  const url = new URL(required_string(value, name));
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
    || url.origin !== approved_origin || url.search || url.hash
    || !approved_paths.has(url.pathname)) {
    throw new Error(name + " must use the approved origin and an exact declared safe path");
  }
  return url.toString();
}

function validated_fixed_url(value, name) {
  const url = new URL(required_string(value, name));
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
    || url.search || url.hash) {
    throw new Error(name + " must be a credential-free HTTP(S) URL without query or fragment");
  }
  return url.toString();
}

function sanitized_url(value, approved_origin, approved_paths) {
  try {
    const url = new URL(value);
    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
    if (url.origin !== approved_origin || !approved_paths.has(url.pathname)) {
      return url.origin + "/[redacted-path]";
    }
    return approved_origin + url.pathname;
  } catch {
    return null;
  }
}

function sanitized_country(value) {
  return typeof value === "string" && ISO_3166_1_ALPHA_2.has(value) ? value : null;
}

function validated_route_origin(value) {
  const origin = value ?? "unknown";
  if (!["residential_ipv4", "datacenter", "control", "unknown"].includes(origin)) {
    throw new Error("ROUTE_ORIGIN is unsupported");
  }
  return origin;
}

function validated_route_selector(value) {
  if (value === undefined || value === "unknown") return "unknown";
  const selector = sanitized_country(value);
  if (!selector) throw new Error("ROUTE_SELECTOR must be an uppercase country code or unknown");
  return selector;
}

function validated_selector(value, name) {
  const selector = value ?? "";
  const safe_css = /^(?:#[A-Za-z][A-Za-z0-9_-]*|.[A-Za-z][A-Za-z0-9_-]*|[data-[a-z0-9_-]+(?:=(?:"[^"]{1,128}"|'[^']{1,128}'))?])$/;
  if (selector && (selector.length > 200 || !safe_css.test(selector))) {
    throw new Error(name + " must be an approved bounded CSS selector");
  }
  return selector;
}

function validated_attribute(value, name, fallback = "") {
  const attribute = value ?? fallback;
  if (attribute && !/^(?:data-[a-z0-9_-]+|id|class|aria-[a-z0-9_-]+)$/.test(attribute)) {
    throw new Error(name + " is not an approved attribute");
  }
  return attribute;
}

function validated_reference_key(value) {
  const key = required_string(value, "EVIDENCE_REFERENCE_KEY");
  if (Buffer.byteLength(key, "utf8") < 32 || new Set(key).size < 12
    || /^(?:changeme|example|password|secret|test)/i.test(key)) {
    throw new Error("EVIDENCE_REFERENCE_KEY must be a strong secret with at least 32 bytes");
  }
  return key;
}

function is_globally_routable_ipv4(value) {
  if (isIP(value) !== 4) return false;
  const [a, b, c] = value.split(".").map(Number);
  return !(a === 0
    || a === 10
    || a === 127
    || (a === 100 && b >= 64 && b <= 127)
    || (a === 169 && b === 254)
    || (a === 172 && b >= 16 && b <= 31)
    || (a === 192 && b === 0 && c === 0)
    || (a === 192 && b === 0 && c === 2)
    || (a === 192 && b === 88 && c === 99)
    || (a === 192 && b === 168)
    || (a === 198 && (b === 18 || b === 19))
    || (a === 198 && b === 51 && c === 100)
    || (a === 203 && b === 0 && c === 113)
    || a >= 224);
}

function is_globally_routable_ip(value) {
  const version = isIP(value);
  if (version === 4) return is_globally_routable_ipv4(value);
  if (version !== 6) return false;
  const normalized = value.toLowerCase();
  return !(normalized === "::" || normalized === "::1"
    || normalized.startsWith("fc") || normalized.startsWith("fd")
    || /^fe[89ab]/.test(normalized)
    || normalized.startsWith("2001:db8:"));
}

const public_host_cache = new Map();
async function is_public_request_url(value) {
  let url;
  try {
    url = new URL(value);
  } catch {
    return false;
  }
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false;
  if (isIP(url.hostname)) return is_globally_routable_ip(url.hostname);
  if (url.hostname === "localhost" || url.hostname.endsWith(".localhost")
    || url.hostname.endsWith(".local") || url.hostname.endsWith(".internal")) return false;
  if (!public_host_cache.has(url.hostname)) {
    public_host_cache.set(url.hostname, lookup(url.hostname, { all: true })
      .then((answers) => answers.length > 0
        && answers.every((answer) => is_globally_routable_ip(answer.address)))
      .catch(() => false));
  }
  return public_host_cache.get(url.hostname);
}

function reference_from_exit_ip(value, key) {
  if (!is_globally_routable_ipv4(value)) return null;
  return "hmac-sha256:" + createHmac("sha256", key).update(value).digest("hex");
}

async function validated_private_directory(value) {
  const directory = required_string(value, "PRIVATE_SCREENSHOT_DIR");
  if (!isAbsolute(directory)) {
    throw new Error("PRIVATE_SCREENSHOT_DIR must be absolute");
  }
  if ((await lstat(directory)).isSymbolicLink()) {
    throw new Error("PRIVATE_SCREENSHOT_DIR must not be a symbolic link");
  }
  const resolved_directory = await realpath(directory);
  const workspace_relative = relative(resolve(process.cwd()), resolved_directory);
  if (workspace_relative === ""
    || (!workspace_relative.startsWith("../") && !isAbsolute(workspace_relative))) {
    throw new Error("PRIVATE_SCREENSHOT_DIR must be outside the current workspace");
  }
  const metadata = await lstat(resolved_directory);
  if (!metadata.isDirectory() || metadata.isSymbolicLink()
    || (metadata.mode & 0o077) !== 0 || (metadata.mode & 0o700) !== 0o700) {
    throw new Error("PRIVATE_SCREENSHOT_DIR must be a private writable directory with mode 0700");
  }
  await access(resolved_directory, constants.W_OK);
  return resolved_directory;
}

function evaluate_success(observation, rule) {
  const final_path = observation.final_url
    ? new URL(observation.final_url).pathname
    : "";
  const checks = [
    observation.failure_code === null,
    Number.isInteger(observation.http_status),
    observation.http_status >= rule.accepted_http_status_min,
    observation.http_status <= rule.accepted_http_status_max,
    rule.required_final_path.length > 0,
    final_path === rule.required_final_path,
    typeof observation.observed_exit_reference === "string"
      && observation.observed_exit_reference.startsWith("hmac-sha256:"),
    observation.route_country_requested === null
      || observation.observed_exit_country === observation.route_country_requested,
  ];
  if (rule.required_creative_id) {
    checks.push(observation.creative_id === rule.required_creative_id);
  }
  if (rule.require_no_challenge) {
    checks.push(observation.challenge_result === "not_observed");
  }
  return checks.every(Boolean);
}

const batch_id = safe_identifier(process.env.AD_BATCH_ID, randomUUID());
const approved_origin = validated_origin(process.env.APPROVED_AD_ORIGIN);
const approved_paths = validated_safe_paths(process.env.APPROVED_AD_PATHS_JSON);
const placement_url = validated_configured_url(
  process.env.AD_PLACEMENT_URL,
  "AD_PLACEMENT_URL",
  approved_origin,
  approved_paths,
);
const ip_echo_url = validated_fixed_url(process.env.APPROVED_IP_ECHO_URL, "APPROVED_IP_ECHO_URL");
const evidence_reference_key = validated_reference_key(process.env.EVIDENCE_REFERENCE_KEY);
const screenshot_directory = await validated_private_directory(process.env.PRIVATE_SCREENSHOT_DIR);
const route_origin = validated_route_origin(process.env.ROUTE_ORIGIN);
const route_selector = validated_route_selector(process.env.ROUTE_SELECTOR);
const settling_window_ms = finite_integer(
  process.env.SETTLING_WINDOW_MS ?? "1000",
  "SETTLING_WINDOW_MS",
  0,
  5_000,
);
const accepted_http_status_min = finite_integer(
  process.env.SUCCESS_STATUS_MIN ?? "200",
  "SUCCESS_STATUS_MIN",
  100,
  599,
);
const accepted_http_status_max = finite_integer(
  process.env.SUCCESS_STATUS_MAX ?? "399",
  "SUCCESS_STATUS_MAX",
  100,
  599,
);
if (accepted_http_status_min > accepted_http_status_max) {
  throw new Error("SUCCESS_STATUS_MIN must not exceed SUCCESS_STATUS_MAX");
}
const required_final_path = required_string(process.env.SUCCESS_FINAL_PATH, "SUCCESS_FINAL_PATH");
if (!approved_paths.has(required_final_path)) {
  throw new Error("SUCCESS_FINAL_PATH must be an exact declared safe path");
}
const success_rule = {
  accepted_http_status_min,
  accepted_http_status_max,
  required_final_path,
  required_creative_id: process.env.SUCCESS_CREATIVE_ID ?? "",
  require_no_challenge: strict_boolean(
    process.env.SUCCESS_REQUIRE_NO_CHALLENGE,
    "SUCCESS_REQUIRE_NO_CHALLENGE",
    false,
  ),
};
const challenge_indicator = {
  selector: validated_selector(process.env.CHALLENGE_SELECTOR, "CHALLENGE_SELECTOR"),
  attribute: validated_attribute(process.env.CHALLENGE_ATTRIBUTE, "CHALLENGE_ATTRIBUTE"),
  expected_value: process.env.CHALLENGE_EXPECTED_VALUE ?? "",
};
if (Boolean(challenge_indicator.attribute) !== Boolean(challenge_indicator.expected_value)) {
  throw new Error("CHALLENGE_ATTRIBUTE and CHALLENGE_EXPECTED_VALUE must be paired");
}
if (!challenge_indicator.selector
  && (challenge_indicator.attribute || challenge_indicator.expected_value)) {
  throw new Error("Challenge attribute/value require a declared selector");
}
const creative_selector = validated_selector(process.env.CREATIVE_SELECTOR, "CREATIVE_SELECTOR");
const creative_attribute = validated_attribute(
  process.env.CREATIVE_ATTRIBUTE,
  "CREATIVE_ATTRIBUTE",
  "data-creative-id",
);
const placement_selector = validated_selector(process.env.PLACEMENT_SELECTOR, "PLACEMENT_SELECTOR");
const placement_attribute = validated_attribute(
  process.env.PLACEMENT_ATTRIBUTE,
  "PLACEMENT_ATTRIBUTE",
  "data-placement-id",
);
const batch_plan = {
  batch_id,
  campaign_reference: safe_identifier(process.env.CAMPAIGN_REFERENCE, "not_recorded"),
  expected_market: route_selector,
  schedule: process.env.TEST_SCHEDULE ?? "one controlled observation",
  declared_sample_size: finite_integer(
    process.env.DECLARED_SAMPLE_SIZE ?? "1",
    "DECLARED_SAMPLE_SIZE",
    1,
    10_000,
  ),
  route_origin,
  route_selector,
  approved_url_origin: approved_origin,
  approved_url_paths_json: JSON.stringify([...approved_paths]),
  success_rule,
  challenge_indicator,
  settling_window_ms,
  selector_observation_window_ms: SELECTOR_OBSERVATION_WINDOW_MS,
  batch_plan_limitation_notes: [
    "A declared plan does not make one observation representative.",
  ],
};

async function bounded_attribute(page, selector, attribute) {
  if (!selector) return { state: "not_tested", value: null };
  try {
    const locator = page.locator(selector).first();
    await locator.waitFor({
      state: "attached",
      timeout: SELECTOR_OBSERVATION_WINDOW_MS,
    });
    return {
      state: "observed",
      value: await locator.getAttribute(attribute, {
        timeout: SELECTOR_OBSERVATION_WINDOW_MS,
      }),
    };
  } catch (error) {
    if (error instanceof errors.TimeoutError) return { state: "timed_out", value: null };
    throw error;
  }
}

async function observe_challenge(page, indicator) {
  if (!indicator.selector) return "not_tested";
  const result = await bounded_attribute(page, indicator.selector, indicator.attribute || "class");
  if (result.state === "timed_out") return "not_observed";
  if (!indicator.attribute) return "observed";
  return result.value === indicator.expected_value ? "observed" : "not_observed";
}

let browser = null;
try {
  browser = await chromium.launch({
    proxy: {
      server: required_string(process.env.TRUEPROXIES_PROXY_URL, "TRUEPROXIES_PROXY_URL"),
      username: required_string(process.env.TRUEPROXIES_USERNAME, "TRUEPROXIES_USERNAME"),
      password: required_string(process.env.TRUEPROXIES_PASSWORD, "TRUEPROXIES_PASSWORD"),
    },
  });
  const context = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    locale: "en-US",
  });
  await context.route("**/*", async (route) => {
    if (await is_public_request_url(route.request().url())) {
      await route.continue();
    } else {
      await route.abort("blockedbyclient");
    }
  });
  const page = await context.newPage();
  const observation_id = randomUUID();
  const observation_limitation_notes = [];
  let observed_exit_reference = null;
  let observed_exit_country = null;
  try {
    const exit_response = await page.goto(ip_echo_url, {
      waitUntil: "domcontentloaded",
      timeout: 15_000,
    });
    if (!exit_response) {
      observation_limitation_notes.push("Exit response was not received; reference and country are unavailable.");
    } else if (!exit_response.ok()) {
      observation_limitation_notes.push("Exit response was not successful; reference and country are unavailable.");
    } else {
      const exit_payload = await exit_response.json();
      observed_exit_reference = reference_from_exit_ip(exit_payload?.ip, evidence_reference_key);
      if (!observed_exit_reference) {
        observation_limitation_notes.push("Exit response lacked a globally routable IPv4 value; reference is unavailable.");
      }
      observed_exit_country = sanitized_country(exit_payload?.country_code);
      if (!observed_exit_country) {
        observation_limitation_notes.push("Exit response lacked a valid country code; country is unavailable.");
      }
    }
  } catch {
    observation_limitation_notes.push("Exit request failed; reference and country are unavailable.");
  }
  if (route_selector !== "unknown" && observed_exit_country
    && observed_exit_country !== route_selector) {
    observation_limitation_notes.push("Observed exit country did not match the declared route selector.");
  }

  const requested_url = sanitized_url(placement_url, approved_origin, approved_paths);
  const redirect_chain = [];
  let response = null;
  let failure_code = null;
  const navigation_attempted_at_utc = new Date().toISOString();
  try {
    response = await page.goto(placement_url, {
      waitUntil: "domcontentloaded",
      timeout: 30_000,
    });
    let request = response?.request() ?? null;
    while (request) {
      const sanitized = sanitized_url(request.url(), approved_origin, approved_paths);
      if (sanitized) redirect_chain.unshift(sanitized);
      request = request.redirectedFrom();
    }
  } catch {
    failure_code = "navigation_failed";
    observation_limitation_notes.push("Navigation failed; raw exception text was not recorded.");
  }
  if (!failure_code && !response) {
    failure_code = "navigation_no_response";
    observation_limitation_notes.push("Navigation returned no target response; target evidence was not tested.");
  }

  let creative_result = { state: "not_tested", value: null };
  let placement_result = { state: "not_tested", value: null };
  let challenge_result = "not_tested";
  let screenshot_reference = null;
  if (!failure_code && response) {
    await page.waitForTimeout(settling_window_ms);
    creative_result = await bounded_attribute(page, creative_selector, creative_attribute);
    if (creative_result.state === "timed_out") {
      observation_limitation_notes.push("Creative selector timed out within the declared observation window.");
    }
    placement_result = await bounded_attribute(page, placement_selector, placement_attribute);
    if (placement_result.state === "timed_out") {
      observation_limitation_notes.push("Placement selector timed out within the declared observation window.");
    }
    challenge_result = await observe_challenge(page, challenge_indicator);
    const screenshot_candidate = "ad-check-" + observation_id + ".png";
    try {
      await page.screenshot({
        path: join(screenshot_directory, screenshot_candidate),
        fullPage: true,
      });
      screenshot_reference = screenshot_candidate;
    } catch {
      observation_limitation_notes.push("Screenshot capture failed; no screenshot reference was recorded.");
    }
  } else {
    observation_limitation_notes.push(
      "Target selectors, challenge indicator, and screenshot were not tested after navigation failure.",
    );
  }

  const observation = {
    observation_batch_id: batch_id,
    observation_id,
    navigation_attempted_at_utc,
    route_country_requested: route_selector === "unknown" ? null : route_selector,
    route_selector_notes: route_selector === "unknown"
      ? "No supported route selector was declared."
      : "Validated country selector; exit IP recorded separately.",
    observed_exit_reference,
    observed_exit_country,
    network_origin: route_origin,
    browser_name: "chromium",
    browser_version: browser.version(),
    viewport_width_px: page.viewportSize()?.width ?? null,
    viewport_height_px: page.viewportSize()?.height ?? null,
    locale: "en-US",
    consent_state: process.env.AD_TEST_CONSENT_STATE ?? "not_recorded",
    account_state: ["signed_out", "authorized_test_account", "not_recorded"].includes(
      process.env.AD_TEST_ACCOUNT_STATE ?? "",
    ) ? process.env.AD_TEST_ACCOUNT_STATE : "not_recorded",
    requested_url,
    final_url: failure_code ? null : sanitized_url(page.url(), approved_origin, approved_paths),
    redirect_chain_json: JSON.stringify(redirect_chain),
    http_status: response?.status() ?? null,
    creative_id: creative_result.value,
    placement_id: placement_result.value,
    screenshot_reference,
    challenge_result,
    success: false,
    failure_code,
    observation_limitation_notes,
  };
  observation.success = evaluate_success(observation, success_rule);
  if (!observation.success && !observation.failure_code) {
    observation.failure_code = "declared_rule_not_met";
  }

  const observations = [observation];
  const challenge_tested = observations.filter(
    (item) => item.challenge_result !== "not_tested",
  );
  const challenge_observed = challenge_tested.filter(
    (item) => item.challenge_result === "observed",
  );
  const batch_summary = {
    summary_declared_sample_size: batch_plan.declared_sample_size,
    summary_observed_sample_size: observations.length,
    summary_successful_observations: observations.filter((item) => item.success).length,
    summary_success_rate: observations.length
      ? observations.filter((item) => item.success).length / observations.length
      : null,
    summary_challenge_tested_observations: challenge_tested.length,
    summary_challenge_observed_observations: challenge_observed.length,
    summary_challenge_test_coverage: observations.length
      ? challenge_tested.length / observations.length
      : null,
    summary_challenge_rate: challenge_tested.length
      ? challenge_observed.length / challenge_tested.length
      : null,
    summary_failed_observations: observations.filter((item) => !item.success).length,
    batch_summary_limitation_notes: [
      "Challenge rate excludes not_tested observations and must be read with test coverage.",
    ],
  };
  const evidence = {
    schema_version: 1,
    method_version: METHOD_VERSION,
    last_reviewed: LAST_REVIEWED,
    canonical_page: CANONICAL_PAGE,
    artifact_state: "local_observation",
    batch_plan,
    observations,
    batch_summary,
  };

  console.log(JSON.stringify(evidence, null, 2));
} finally {
  await browser?.close();
}

Implementation options

Custom scriptsPuppeteerPlaywright

Optional evidence resources

Ad-verification evidence kit

Declare the batch before collection, retain successful and failed observations, and calculate summaries from the same ordered snake_case fields. The blank downloads contain no observations, credentials, raw exit IPs, account identifiers, screenshots, or share capabilities.

Method ad-verification-v1

Reviewed

Blank templates ยท no observations

Controlled collection method

  1. 1Predeclare a non-sensitive batch ID and campaign reference, expected market, schedule, sample size, success rule, optional paired challenge indicator, settling window, selector observation window, and known limitations.
  2. 2Preflight server-side proxy connectivity and cold response time, then separately verify a globally routable IPv4 browser exit and an assigned ISO 3166-1 alpha-2 routed-country match. Store raw exit evidence outside the worksheet and use only a non-reversible access-controlled reference.
  3. 3Store approved URL origins and exact capability-free paths. Remove credentials, queries, and fragments; redact every other path while preserving its actual HTTP(S) origin.
  4. 4Give every observation a unique ID. Run target selectors and screenshot capture only after target navigation succeeds; keep screenshots in a restrictive private directory and emit only a nullable non-public reference.
  5. 5Evaluate all declared success-rule conditions. Record not_tested for target evidence skipped after navigation failure, calculate challenge rate only across challenge-tested observations, and publish challenge-test coverage beside it.

Interpretation limits

  • A proxy adds a network vantage point. It does not reproduce every consumer, account, device, browser fingerprint, cookie state, or auction condition.
  • A placement difference is evidence to repeat and investigate; it is not automatic proof of fraud, invalid traffic, concealment, or target acceptance.
  • Proxy Checker is a server-side connectivity diagnostic and reports cold response time, not bandwidth. IP Leak Test inspects browser-visible routing signals. Neither tool proves that an ad was delivered correctly.
  • The public artifacts are intentionally blank. Populate them only with authorized observations and keep sensitive source evidence in an access-controlled system.

FAQ

Questions about Ad verification proxies

What are ad verification proxies?

Ad verification proxies provide additional network locations for placement checks. Device, account, and browser conditions must be controlled separately.

Why test residential proxies for ad verification?

Residential and datacenter network origins can receive different content. A residential exit adds an ISP-origin vantage point, but it does not guarantee the exact experience of every consumer.

Can I verify ads in multiple countries simultaneously?

Residential IPv4 publishes a 150+ country footprint, with city, ZIP, and ASN targeting on every plan. Target by city, ZIP, or ASN where the campaign needs it, verify each exit IP, and rate-limit parallel checks appropriately.

Which TrueProxies plan should I test for ad verification?

Compare an unlimited speed-and-duration plan for recurring transfer with GB-Based billing for periodic checks. Measure one representative verification batch before choosing.

What can an ad verification proxy show?

A proxy can show the creative and placement returned to a selected network location. Treat suspicious differences as evidence to repeat and investigate, not automatic proof of fraud.

Free trial

Record what selected, verified exits return.

Start a free trial and test it on your own workload.